import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as logger from "@gajae-code/utils/logger"; import { activeSnapshotPath, activeStateDir, assertNonEmptyGjcSessionId, modeStatePath, } from "../gjc-runtime/session-layout"; import { resolveGjcSessionForRead, SessionResolutionError } from "../gjc-runtime/session-resolution"; import { type ActiveSessionScope, type GuardedWriteResult, readActiveEntries, rebuildActiveSnapshot, removeActiveEntry, setActiveStateCacheInvalidator, writeActiveEntry, } from "../gjc-runtime/state-writer"; import { getSkillManifest } from "../gjc-runtime/workflow-manifest"; import { CANONICAL_GJC_WORKFLOW_SKILLS, type CanonicalGjcWorkflowSkill } from "./canonical-skills"; import type { WorkflowStateReceipt } from "./workflow-state-contract"; export const SKILL_ACTIVE_STATE_FILE = "skill-active-state.json"; export { CANONICAL_GJC_WORKFLOW_SKILLS, type CanonicalGjcWorkflowSkill }; export type WorkflowHudSeverity = "info" | "warning" | "blocked" | "error" | "success"; export interface WorkflowHudChip { label: string; value?: string; priority?: number; severity?: WorkflowHudSeverity; } export interface WorkflowHudSummary { version: 1; summary?: string; chips?: WorkflowHudChip[]; details?: WorkflowHudChip[]; severity?: WorkflowHudSeverity; updated_at?: string; } export type { WorkflowStateReceipt } from "./workflow-state-contract"; export interface ActiveSubskillToolReference { extensionId: string; expectedDigest: string; } export interface ActiveSubskillEntry { plugin: string; subskillName: string; parent: string; bindsTo: string; phase: string; activationArg: string; /** Registry identity required for persisted v2 activation. */ scope?: "user" | "project"; extensionId?: string; expectedDigest?: string; toolRefs?: ActiveSubskillToolReference[]; /** Legacy paths are accepted only in transient in-memory values; persisted readers reject them. */ filePath?: string; toolPaths?: string[]; } export interface SkillActiveEntry { skill: string; phase?: string; active?: boolean; activated_at?: string; updated_at?: string; session_id?: string; thread_id?: string; turn_id?: string; hud?: WorkflowHudSummary; stale?: boolean; receipt?: WorkflowStateReceipt; handoff_from?: string; handoff_to?: string; handoff_at?: string; active_subskills?: ActiveSubskillEntry[]; source_state_revision?: number; } export interface SkillActiveState { version?: number; active?: boolean; skill?: string; keyword?: string; phase?: string; activated_at?: string; updated_at?: string; source?: string; session_id?: string; thread_id?: string; turn_id?: string; initialized_mode?: CanonicalGjcWorkflowSkill; initialized_state_path?: string; active_skills?: SkillActiveEntry[]; active_subskills?: ActiveSubskillEntry[]; [key: string]: unknown; } export interface SkillActiveStatePaths { rootPath: string; sessionPath: string; } export interface SyncSkillActiveStateOptions { cwd: string; skill: string; active: boolean; phase?: string; sessionId?: string; threadId?: string; turnId?: string; nowIso?: string; source?: string; hud?: WorkflowHudSummary; receipt?: WorkflowStateReceipt; handoff_from?: string; handoff_to?: string; handoff_at?: string; active_subskills?: ActiveSubskillEntry[]; sourceRevision?: number; /** Internal seed path: retain the authoritative entry receipt if snapshot rebuilding fails. */ bestEffortSnapshot?: boolean; } const HUD_TEXT_LIMIT = 80; const HUD_CHIP_LIMIT = 6; const HUD_DETAIL_LIMIT = 12; const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; const HUD_SEVERITIES = new Set(["info", "warning", "blocked", "error", "success"]); function safeString(value: unknown): string { return typeof value === "string" ? value : ""; } function sanitizeHudString(value: unknown, limit = HUD_TEXT_LIMIT): string | undefined { const normalized = safeString(value) .replace(ANSI_PATTERN, "") .replace(/[\r\n\t]+/g, " ") .trim(); if (!normalized) return undefined; return normalized.length > limit ? normalized.slice(0, limit) : normalized; } function normalizeSeverity(value: unknown): WorkflowHudSeverity | undefined { return typeof value === "string" && HUD_SEVERITIES.has(value as WorkflowHudSeverity) ? (value as WorkflowHudSeverity) : undefined; } function normalizeHudChip(raw: unknown): WorkflowHudChip | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; const label = sanitizeHudString(record.label, 32); if (!label) return null; const value = sanitizeHudString(record.value, HUD_TEXT_LIMIT); const priority = typeof record.priority === "number" && Number.isFinite(record.priority) ? record.priority : undefined; const severity = normalizeSeverity(record.severity); return { label, ...(value ? { value } : {}), ...(priority !== undefined ? { priority } : {}), ...(severity ? { severity } : {}), }; } function normalizeHudChips(raw: unknown, limit: number): WorkflowHudChip[] | undefined { if (!Array.isArray(raw)) return undefined; const chips = raw .map(normalizeHudChip) .filter((chip): chip is WorkflowHudChip => chip !== null) .slice(0, limit); return chips.length > 0 ? chips : undefined; } export function normalizeWorkflowHudSummary(raw: unknown): WorkflowHudSummary | undefined { if (!raw || typeof raw !== "object") return undefined; const record = raw as Record; if (record.version !== 1) return undefined; const summary = sanitizeHudString(record.summary); const chips = normalizeHudChips(record.chips, HUD_CHIP_LIMIT); const details = normalizeHudChips(record.details, HUD_DETAIL_LIMIT); const severity = normalizeSeverity(record.severity); const updatedAt = sanitizeHudString(record.updated_at, 40); return { version: 1, ...(summary ? { summary } : {}), ...(chips ? { chips } : {}), ...(details ? { details } : {}), ...(severity ? { severity } : {}), ...(updatedAt ? { updated_at: updatedAt } : {}), }; } function normalizeWorkflowStateReceipt(raw: unknown): WorkflowStateReceipt | undefined { if (!raw || typeof raw !== "object") return undefined; const record = raw as Record; if (record.version !== 1) return undefined; const skill = safeString(record.skill).trim(); if (!isCanonicalGjcWorkflowSkill(skill)) return undefined; const owner = safeString(record.owner).trim(); if (owner !== "gjc-state-cli" && owner !== "gjc-runtime" && owner !== "gjc-hook") return undefined; const command = sanitizeHudString(record.command, 120); const statePath = sanitizeHudString(record.state_path, 240); const storagePath = sanitizeHudString(record.storage_path, 240); const mutatedAt = sanitizeHudString(record.mutated_at, 40); const freshUntil = sanitizeHudString(record.fresh_until, 40); const status = safeString(record.status).trim(); const mutationId = sanitizeHudString(record.mutation_id, 120); if (!command || !statePath || !storagePath || !mutatedAt || !freshUntil || !mutationId) return undefined; return { version: 1, skill, owner, command, state_path: statePath, storage_path: storagePath, mutated_at: mutatedAt, fresh_until: freshUntil, status: status === "stale" ? "stale" : "fresh", mutation_id: mutationId, }; } function normalizeActiveSubskillEntry(raw: unknown): ActiveSubskillEntry | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; const plugin = safeString(record.plugin).trim(); const subskillName = safeString(record.subskillName).trim(); const parent = safeString(record.parent).trim(); const bindsTo = safeString(record.bindsTo).trim(); const phase = safeString(record.phase).trim(); const activationArg = safeString(record.activationArg).trim(); const scope = record.scope === "user" || record.scope === "project" ? record.scope : undefined; const extensionId = safeString(record.extensionId).trim(); const expectedDigest = safeString(record.expectedDigest).trim().toLowerCase(); const toolRefs = Array.isArray(record.toolRefs) ? record.toolRefs .map(item => { if (!item || typeof item !== "object") return null; const ref = item as Record; const id = safeString(ref.extensionId).trim(); const digest = safeString(ref.expectedDigest).trim().toLowerCase(); return id && digest ? { extensionId: id, expectedDigest: digest } : null; }) .filter((item): item is ActiveSubskillToolReference => item !== null) : []; // Path-only records are intentionally invalid: runtime must resolve through // the migrated registry, never trust persisted executable paths. if ( !plugin || !subskillName || !parent || !bindsTo || !phase || !activationArg || !scope || !extensionId || !expectedDigest ) return null; return { plugin, subskillName, parent, bindsTo, phase, activationArg, scope, extensionId, expectedDigest, toolRefs }; } function normalizeActiveSubskillEntries(raw: unknown): ActiveSubskillEntry[] | undefined { if (!Array.isArray(raw)) return undefined; const entries = raw .map(normalizeActiveSubskillEntry) .filter((entry): entry is ActiveSubskillEntry => entry !== null); return entries.length > 0 ? entries : undefined; } function activeSubskillEntryKey(entry: ActiveSubskillEntry): string { return [ entry.scope ?? "", entry.plugin, entry.extensionId ?? "", entry.parent, entry.phase, entry.activationArg, ].join("\0"); } function unionActiveSubskillEntries(...entrySets: Array): ActiveSubskillEntry[] { const merged: ActiveSubskillEntry[] = []; const seen = new Set(); for (const entries of entrySets) { for (const entry of entries ?? []) { const key = activeSubskillEntryKey(entry); if (seen.has(key)) continue; seen.add(key); merged.push(entry); } } return merged; } function resolveBoundarySessionId(cwd: string, sessionId?: string): Promise { const normalizedSessionId = safeString(sessionId).trim(); if (normalizedSessionId) return Promise.resolve(normalizedSessionId); return resolveGjcSessionForRead(cwd, { envSessionId: process.env.GJC_SESSION_ID }).then( context => context.gjcSessionId, ); } function entryKey(entry: Pick): string { return `${entry.skill}::${safeString(entry.session_id).trim()}`; } function normalizeEntry(raw: unknown): SkillActiveEntry | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; const skill = safeString(record.skill).trim(); if (!skill) return null; const hud = normalizeWorkflowHudSummary(record.hud); const receipt = normalizeWorkflowStateReceipt(record.receipt); const activeSubskills = normalizeActiveSubskillEntries(record.active_subskills); return { ...record, skill, phase: safeString(record.phase).trim() || undefined, active: record.active !== false, activated_at: safeString(record.activated_at).trim() || undefined, updated_at: safeString(record.updated_at).trim() || undefined, session_id: safeString(record.session_id).trim() || undefined, thread_id: safeString(record.thread_id).trim() || undefined, turn_id: safeString(record.turn_id).trim() || undefined, handoff_from: safeString(record.handoff_from).trim() || undefined, handoff_to: safeString(record.handoff_to).trim() || undefined, handoff_at: safeString(record.handoff_at).trim() || undefined, ...(hud ? { hud } : {}), ...(receipt ? { receipt } : {}), ...(activeSubskills ? { active_subskills: activeSubskills } : {}), stale: undefined, }; } export function isCanonicalGjcWorkflowSkill(skill: string): skill is CanonicalGjcWorkflowSkill { return (CANONICAL_GJC_WORKFLOW_SKILLS as readonly string[]).includes(skill); } /** * Nonterminal phases that intentionally await workflow-specific integration * rather than a generic synthetic compaction continuation. */ const CONTINUATION_INERT_WORKFLOW_PHASES: Readonly>>> = { autoresearch: new Set(["verdict"]), }; /** * Continuation inertness for compaction auto-continue, derived from each * workflow's manifest contract: manifest-terminal and explicitly inert phases * are inert; unknown skills or phases unknown to the skill's manifest are * conservatively inert so a synthetic continuation never wakes an unrecognized * workflow. Generic Ultragoal `blocked` remains continuation-active because its * blocker may be autonomously resolvable; verified human pauses are represented * by the inline goal's `paused` status instead. */ export function isWorkflowContinuationInert(skill: string, phase: string): boolean { const normalizedPhase = phase.trim().toLowerCase(); if (!isCanonicalGjcWorkflowSkill(skill)) return true; const manifest = getSkillManifest(skill); if (manifest.terminalStates.some(terminal => terminal.toLowerCase() === normalizedPhase)) return true; if (CONTINUATION_INERT_WORKFLOW_PHASES[skill]?.has(normalizedPhase)) return true; return !manifest.states.some(state => state.id.toLowerCase() === normalizedPhase); } export function listActiveSkills(raw: unknown): SkillActiveEntry[] { if (!raw || typeof raw !== "object") return []; const state = raw as SkillActiveState; const deduped = new Map(); if (Array.isArray(state.active_skills)) { for (const candidate of state.active_skills) { const normalized = normalizeEntry(candidate); if (!normalized || normalized.active === false) continue; deduped.set(entryKey(normalized), normalized); } } const topLevelSkill = safeString(state.skill).trim(); if (deduped.size === 0 && state.active === true && topLevelSkill) { const entry: SkillActiveEntry = { skill: topLevelSkill, phase: safeString(state.phase).trim() || undefined, active: true, activated_at: safeString(state.activated_at).trim() || undefined, updated_at: safeString(state.updated_at).trim() || undefined, session_id: safeString(state.session_id).trim() || undefined, thread_id: safeString(state.thread_id).trim() || undefined, turn_id: safeString(state.turn_id).trim() || undefined, }; deduped.set(entryKey(entry), entry); } return [...deduped.values()]; } export function normalizeSkillActiveState(raw: unknown): SkillActiveState | null { if (!raw || typeof raw !== "object") return null; const state = raw as SkillActiveState; const activeSkills = listActiveSkills(state); const primary = activeSkills.find(entry => entry.skill === safeString(state.skill).trim()) ?? activeSkills[0]; const skill = safeString(state.skill).trim() || primary?.skill || ""; if (!skill && activeSkills.length === 0) return null; return { ...state, version: typeof state.version === "number" ? state.version : 1, active: typeof state.active === "boolean" ? state.active : activeSkills.length > 0, skill, keyword: safeString(state.keyword).trim(), phase: safeString(state.phase).trim() || primary?.phase || "", activated_at: safeString(state.activated_at).trim() || primary?.activated_at || "", updated_at: safeString(state.updated_at).trim() || primary?.updated_at || "", source: safeString(state.source).trim() || undefined, session_id: safeString(state.session_id).trim() || primary?.session_id || undefined, thread_id: safeString(state.thread_id).trim() || primary?.thread_id || undefined, turn_id: safeString(state.turn_id).trim() || primary?.turn_id || undefined, active_subskills: activeSkills.flatMap(entry => entry.active_subskills ?? []), active_skills: activeSkills.length > 0 ? activeSkills : [], }; } export function getSkillActiveStatePaths(cwd: string, sessionId?: string): SkillActiveStatePaths { const normalizedSessionId = safeString(sessionId).trim(); assertNonEmptyGjcSessionId(normalizedSessionId, "getSkillActiveStatePaths"); const sessionPath = activeSnapshotPath(cwd, normalizedSessionId); return { rootPath: sessionPath, sessionPath }; } /** * Raw read for handoff mutations. Returns the *unnormalized* parsed object so * inactive entries remain visible to `rawActiveEntries` — `normalizeSkillActiveState` * delegates to `listActiveSkills`, which filters out `active:false` rows for HUD * purposes. Handoff history (e.g. previously demoted callers carrying * `handoff_to`/`handoff_at` lineage) must survive across successive handoffs, * so the on-disk `active_skills` array is preserved verbatim and the next * write recomputes the per-skill row from there. * * Strict semantics: tolerates ENOENT only. Corrupt JSON / non-ENOENT I/O * errors propagate so callers can surface a non-zero CLI status. */ async function readRawActiveStateForHandoff(filePath: string, strict: boolean): Promise { let raw: string; try { raw = await Bun.file(filePath).text(); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") return null; if (!strict) return null; throw err; } try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return null; return parsed as SkillActiveState; } catch (err) { if (!strict) { logger.warn( `gjc skill-state: invalid skill-active-state at ${filePath}: invalid JSON: ${(err as Error).message}`, ); return null; } throw err; } } function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] { if (!state) return []; const out: SkillActiveEntry[] = []; if (Array.isArray(state.active_skills)) { for (const candidate of state.active_skills) { const normalized = normalizeEntry(candidate); if (normalized) out.push(normalized); } } // Legacy top-level fallback: pre-`active_skills` state files persisted a single // active workflow as top-level `{ active: true, skill, phase, … }` with no // `active_skills` array. `normalizeSkillActiveState` still synthesizes that row, // so the raw read used by the HUD, mutation guard, and caller inference must do // the same or it would treat a legacy active workflow as absent. if (out.length === 0 && state.active === true) { const skill = safeString(state.skill).trim(); if (skill) { out.push({ skill, phase: safeString(state.phase).trim() || undefined, active: true, activated_at: safeString(state.activated_at).trim() || undefined, updated_at: safeString(state.updated_at).trim() || undefined, session_id: safeString(state.session_id).trim() || undefined, thread_id: safeString(state.thread_id).trim() || undefined, turn_id: safeString(state.turn_id).trim() || undefined, }); } } return out; } async function readModeStatePhase( cwd: string, sessionId: string, skill: CanonicalGjcWorkflowSkill, ): Promise { const filePath = modeStatePath(cwd, sessionId, skill); try { const parsed = JSON.parse(await Bun.file(filePath).text()); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const record = parsed as Record; const phase = safeString(record.current_phase).trim(); if (!phase) return undefined; if (record.active === false && !getSkillManifest("ralplan").canonicalOverrides.includes(phase)) return undefined; return phase; } catch { return undefined; } } function withCanonicalRalplanPhase(entry: SkillActiveEntry, canonicalPhase: string | undefined): SkillActiveEntry { if ( entry.skill !== "ralplan" || !canonicalPhase || !getSkillManifest("ralplan").canonicalOverrides.includes(canonicalPhase) || entry.phase === canonicalPhase ) { return entry; } const hud = entry.hud ? { ...entry.hud, chips: entry.hud.chips?.map(chip => (chip.label === "stage" ? { ...chip, value: canonicalPhase } : chip)), } : undefined; return { ...entry, phase: canonicalPhase, ...(hud ? { hud } : {}), }; } function entryRecency(entry: SkillActiveEntry): number { const stamp = entry.handoff_at || entry.updated_at || entry.activated_at; const ms = stamp ? Date.parse(stamp) : Number.NaN; // NaN signals "no trustworthy timestamp" so comparisons can refuse to let an // unknown-recency row win a tie; callers must treat NaN explicitly. return ms; } /** * Session ownership rank for a row visible to a `sessionId` read. When a concrete * session is in scope, a row owned by that exact session outranks a session-less * fallback row, which outranks a foreign-session row. Session-less rows are global * fallbacks and must never override a session's own state. With no scope session, * every row ranks equally. */ function sessionScopeRank(entry: SkillActiveEntry, sessionId?: string): number { const scope = safeString(sessionId).trim(); if (!scope) return 0; const entrySession = safeString(entry.session_id).trim(); if (entrySession === scope) return 2; if (entrySession.length === 0) return 1; return 0; } /** * Pick the surviving row for a single skill within a session-scoped visible set. * Precedence, highest first: * 1. exact-session ownership over a session-less fallback row, * 2. a strictly-newer valid timestamp, * 3. a valid timestamp over a missing/unparseable one, * 4. active over inactive — so an untrustworthy inactive row can never hide an * active row — then merge order for a total tie. * A genuine handoff demotion still supersedes a stale active row of the same skill * because, within one session scope, it carries the newest valid timestamp. */ function moreVisibleEntry( incumbent: SkillActiveEntry, challenger: SkillActiveEntry, sessionId?: string, ): SkillActiveEntry { const scopeDelta = sessionScopeRank(incumbent, sessionId) - sessionScopeRank(challenger, sessionId); if (scopeDelta !== 0) return scopeDelta > 0 ? incumbent : challenger; const ri = entryRecency(incumbent); const rc = entryRecency(challenger); const vi = Number.isFinite(ri); const vc = Number.isFinite(rc); if (vi && vc && ri !== rc) return ri > rc ? incumbent : challenger; if (vi !== vc) return vi ? incumbent : challenger; const incumbentActive = incumbent.active !== false; const challengerActive = challenger.active !== false; if (incumbentActive !== challengerActive) return incumbentActive ? incumbent : challenger; return incumbent; } /** * Collapse the merged, session-scoped entries down to a single row per skill. * A handed-off skill can leave more than one row visible to a session — e.g. a * row seeded without a session id (rendered globally by * `filterRootEntriesForSession`) plus a later, session-scoped handoff demotion * of the same skill. Without this collapse the HUD renders the same workflow * twice and keeps showing a skill that has already handed control to its * successor. `moreVisibleEntry` picks the winner so a handoff demotion supersedes * an older stale `active:true` row (and is then dropped by the active filter * below) while a session's own active row is never hidden by a session-less or * untrustworthy-timestamp row. */ function dedupeVisibleBySkill(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] { const winners = new Map(); for (const entry of entries) { const current = winners.get(entry.skill); winners.set(entry.skill, current ? moreVisibleEntry(current, entry, sessionId) : entry); } return [...winners.values()]; } /** * The planning pipeline advances one stage at a time: `deep-interview → * ralplan → ultragoal`. Activating a downstream stage supersedes upstream * stages so stale rows cannot keep owning the HUD, gate, or primary active * snapshot. `autoresearch` is intentionally excluded — it runs alongside ultragoal — * and every non-pipeline skill is left untouched. */ const PLANNING_PIPELINE_SKILLS = new Set(["deep-interview", "ralplan", "ultragoal"]); const PLANNING_PIPELINE_RANK = new Map([ ["deep-interview", 0], ["ralplan", 1], ["ultragoal", 2], ]); function planningPipelineRank(skill: string): number | undefined { return PLANNING_PIPELINE_RANK.get(skill); } function comparePipelineEntry(a: SkillActiveEntry, b: SkillActiveEntry): number { const aRank = planningPipelineRank(a.skill); const bRank = planningPipelineRank(b.skill); if (aRank !== undefined || bRank !== undefined) return (bRank ?? -1) - (aRank ?? -1); const aRecency = entryRecency(a); const bRecency = entryRecency(b); if (Number.isFinite(aRecency) || Number.isFinite(bRecency)) return (bRecency || 0) - (aRecency || 0); return 0; } export function upstreamPlanningPipelineSkills(skill: string): string[] { const rank = planningPipelineRank(skill); if (rank === undefined) return []; return [...PLANNING_PIPELINE_RANK.entries()] .filter(([, candidateRank]) => candidateRank < rank) .map(([candidate]) => candidate); } export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]): SkillActiveEntry[] { const pipeline = entries.filter(entry => PLANNING_PIPELINE_SKILLS.has(entry.skill)); if (pipeline.length <= 1) return [...entries]; const current = pipeline.toSorted(comparePipelineEntry)[0]; return entries.filter(entry => !PLANNING_PIPELINE_SKILLS.has(entry.skill) || entry === current); } async function mergeVisibleEntries( cwd: string, sessionState: SkillActiveState | null, sessionId: string, ): Promise { // Use the raw (active + inactive) rows so a handoff demotion stays visible // long enough to supersede a stale same-skill row before the active filter. // Per-skill files in active/.json are authoritative and are merged // after the derived snapshot cache, so a stale skill-active-state.json row // cannot override the latest entry file. const entries = [...rawActiveEntries(sessionState), ...(await readActiveEntries(cwd, { sessionId }))]; const merged = new Map(entries.map(entry => [entryKey(entry), entry])); const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan"); const visibleEntries = dedupeVisibleBySkill([...merged.values()], sessionId) .filter(entry => entry.active !== false) .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase)); return collapsePlanningPipeline(visibleEntries).toSorted(comparePipelineEntry); } export type VisibleSkillActiveStateCacheTier = "security" | "hud"; export interface ReadVisibleSkillActiveStateOptions { tier?: VisibleSkillActiveStateCacheTier; /** Bypass all signature/TTL caches for authorization decisions. */ bypassCache?: boolean; } interface ActiveStateStatSignature { path: string; mtimeMs: number; size: number; } interface ActiveStateSignature { sessionPath: ActiveStateStatSignature | null; activeDir: ActiveStateStatSignature | null; activeEntries: ActiveStateStatSignature[]; ralplanModeState: ActiveStateStatSignature | null; } interface VisibleSkillActiveStateCacheEntry { signature: ActiveStateSignature; value: SkillActiveState | null; cachedAtMs: number; } const HUD_VISIBLE_STATE_TTL_MS = 500; const SECURITY_VISIBLE_STATE_TTL_MS = 30_000; const visibleSkillActiveStateCache = new Map(); async function statSignature(filePath: string): Promise { try { const stat = await fs.stat(filePath); return { path: filePath, mtimeMs: stat.mtimeMs, size: stat.size }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } } function signaturesEqual(a: ActiveStateSignature, b: ActiveStateSignature): boolean { const statEqual = (left: ActiveStateStatSignature | null, right: ActiveStateStatSignature | null): boolean => left === right || (Boolean(left) && Boolean(right) && left?.path === right?.path && left?.mtimeMs === right?.mtimeMs && left?.size === right?.size); if (!statEqual(a.sessionPath, b.sessionPath)) return false; if (!statEqual(a.activeDir, b.activeDir)) return false; if (!statEqual(a.ralplanModeState, b.ralplanModeState)) return false; if (a.activeEntries.length !== b.activeEntries.length) return false; return a.activeEntries.every((entry, index) => statEqual(entry, b.activeEntries[index] ?? null)); } export async function computeActiveStateSignature(cwd: string, sessionId: string): Promise { const resolvedCwd = path.resolve(cwd); const { sessionPath } = getSkillActiveStatePaths(resolvedCwd, sessionId); const activeDirPath = activeStateDir(resolvedCwd, sessionId); const activeDir = await statSignature(activeDirPath); let activeEntries: ActiveStateStatSignature[] = []; try { const names = await fs.readdir(activeDirPath); activeEntries = ( await Promise.all( names .filter(name => name.endsWith(".json")) .sort() .map(name => statSignature(path.join(activeDirPath, name))), ) ).filter((entry): entry is ActiveStateStatSignature => entry !== null); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } return { sessionPath: await statSignature(sessionPath), activeDir, activeEntries, ralplanModeState: await statSignature(modeStatePath(resolvedCwd, sessionId, "ralplan")), }; } function visibleActiveStateCacheKey(cwd: string, sessionId: string): string { return `${path.resolve(cwd)}\0${sessionId}`; } export function invalidateVisibleSkillActiveStateCache(cwd?: string, sessionId?: string): void { if (!cwd && !sessionId) { visibleSkillActiveStateCache.clear(); return; } const resolvedCwd = cwd ? path.resolve(cwd) : undefined; const suffix = sessionId ? `\0${sessionId}` : undefined; for (const key of [...visibleSkillActiveStateCache.keys()]) { if (resolvedCwd && !key.startsWith(`${resolvedCwd}\0`)) continue; if (suffix && !key.endsWith(suffix)) continue; visibleSkillActiveStateCache.delete(key); } } async function readVisibleSkillActiveStateUncached( cwd: string, resolvedSessionId: string, ): Promise { const { sessionPath } = getSkillActiveStatePaths(cwd, resolvedSessionId); const sessionState = await readRawActiveStateForHandoff(sessionPath, false); const activeSkills = await mergeVisibleEntries(cwd, sessionState, resolvedSessionId); if (activeSkills.length === 0) return null; const primary = activeSkills[0]; return { ...(sessionState ?? {}), version: 1, active: true, skill: sessionState?.skill ?? primary?.skill ?? "", phase: sessionState?.phase ?? primary?.phase ?? "", session_id: resolvedSessionId, active_skills: activeSkills, active_subskills: activeSkills.flatMap(entry => entry.active_subskills ?? []), }; } setActiveStateCacheInvalidator(invalidateVisibleSkillActiveStateCache); export async function readVisibleSkillActiveState( cwd: string, sessionId?: string, opts?: ReadVisibleSkillActiveStateOptions, ): Promise { let resolvedSessionId: string; try { resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId); } catch (error) { if (error instanceof SessionResolutionError && error.code === "no_session") return null; throw error; } const resolvedCwd = path.resolve(cwd); const cacheKey = visibleActiveStateCacheKey(resolvedCwd, resolvedSessionId); if (opts?.bypassCache) { visibleSkillActiveStateCache.delete(cacheKey); return await readVisibleSkillActiveStateUncached(resolvedCwd, resolvedSessionId); } const tier = opts?.tier ?? "security"; const now = Date.now(); const cached = visibleSkillActiveStateCache.get(cacheKey); if (tier === "hud" && cached && now - cached.cachedAtMs < HUD_VISIBLE_STATE_TTL_MS) return cached.value; const signature = await computeActiveStateSignature(resolvedCwd, resolvedSessionId); if ( cached && signaturesEqual(cached.signature, signature) && (tier === "hud" || now - cached.cachedAtMs < SECURITY_VISIBLE_STATE_TTL_MS) ) { return cached.value; } const value = await readVisibleSkillActiveStateUncached(resolvedCwd, resolvedSessionId); visibleSkillActiveStateCache.set(cacheKey, { signature, value, cachedAtMs: now }); return value; } function activeStateWriterAudit(verb: string, sessionScope?: ActiveSessionScope | string) { const sessionId = typeof sessionScope === "string" ? sessionScope : sessionScope?.sessionId; return { category: "state" as const, verb, owner: "gjc-runtime" as const, ...(sessionId ? { sessionId } : {}) }; } async function persistActiveEntry( cwd: string, sessionScope: ActiveSessionScope | undefined, entry: SkillActiveEntry, ): Promise { if (entry.active === false) { await removeActiveEntry(cwd, sessionScope, entry.skill, { cwd, audit: activeStateWriterAudit("remove-active-entry", sessionScope), sourceRevision: entry.source_state_revision, }); return undefined; } return await writeActiveEntry(cwd, sessionScope, entry.skill, entry, { cwd, audit: activeStateWriterAudit("write-active-entry", sessionScope), }); } async function writeHandoffEntry( cwd: string, sessionScope: ActiveSessionScope | undefined, entry: SkillActiveEntry, ): Promise { await writeActiveEntry(cwd, sessionScope, entry.skill, entry, { cwd, audit: activeStateWriterAudit("write-active-entry", sessionScope), }); } async function rebuildActiveState(cwd: string, sessionScope?: ActiveSessionScope): Promise { await rebuildActiveSnapshot(cwd, sessionScope, { cwd, audit: activeStateWriterAudit("rebuild-active-snapshot", sessionScope), }); } async function removeSupersededPlanningPipelineEntries( cwd: string, sessionScope: ActiveSessionScope | undefined, entry: SkillActiveEntry, ): Promise { if (entry.active === false) return; for (const skill of upstreamPlanningPipelineSkills(entry.skill)) { await removeActiveEntry(cwd, sessionScope, skill, { cwd, audit: activeStateWriterAudit("remove-superseded-pipeline-entry", sessionScope), }); } } async function activeSubskillsForExistingEntry( cwd: string, sessionId: string | undefined, skill: string, ): Promise { const resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId); const { sessionPath } = getSkillActiveStatePaths(cwd, resolvedSessionId); const sessionState = await readRawActiveStateForHandoff(sessionPath, false); const existing = (await mergeVisibleEntries(cwd, sessionState, resolvedSessionId)).find( entry => entry.skill === skill, ); return existing?.active_subskills; } export async function syncSkillActiveState( options: SyncSkillActiveStateOptions, ): Promise { if (!options.sessionId) return undefined; const preservedActiveSubskills = options.active_subskills === undefined ? await activeSubskillsForExistingEntry(options.cwd, options.sessionId, options.skill) : undefined; const nowIso = options.nowIso ?? new Date().toISOString(); const hud = normalizeWorkflowHudSummary(options.hud); const entry: SkillActiveEntry = { skill: options.skill, phase: options.phase, active: options.active, activated_at: nowIso, updated_at: nowIso, session_id: options.sessionId, thread_id: options.threadId, turn_id: options.turnId, ...(options.handoff_from ? { handoff_from: options.handoff_from } : {}), ...(options.handoff_to ? { handoff_to: options.handoff_to } : {}), ...(options.handoff_at ? { handoff_at: options.handoff_at } : {}), ...(hud ? { hud } : {}), ...(options.receipt ? { receipt: options.receipt } : {}), ...(options.active_subskills !== undefined ? { active_subskills: options.active_subskills } : preservedActiveSubskills ? { active_subskills: preservedActiveSubskills } : {}), ...(typeof options.sourceRevision === "number" ? { source_state_revision: options.sourceRevision } : {}), }; const sessionScope = { sessionId: options.sessionId }; await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry); const entryWrite = await persistActiveEntry(options.cwd, sessionScope, entry); try { await rebuildActiveState(options.cwd, sessionScope); } catch (error) { if (!options.bestEffortSnapshot) throw error; } return entryWrite; } export interface ApplyHandoffOptions { cwd: string; caller: SyncSkillActiveStateOptions; callee: SyncSkillActiveStateOptions; /** Shared timestamp; falls back to new Date().toISOString(). */ nowIso?: string; /** When true, read errors other than ENOENT propagate. */ strict?: boolean; } /** * Atomically apply a workflow-skill handoff to the session-scoped active state. */ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): Promise { const nowIso = options.nowIso ?? new Date().toISOString(); const callerEntry = buildSyncEntry(options.caller, nowIso); const calleeEntry = buildSyncEntry(options.callee, nowIso); const sessionId = options.callee.sessionId ?? options.caller.sessionId; assertNonEmptyGjcSessionId(sessionId, "applyHandoffToActiveState"); const { sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId); const readState = (filePath: string) => readRawActiveStateForHandoff(filePath, options.strict === true); await readState(sessionPath); const handoffSession = safeString(sessionId).trim(); const reassignedSkills = new Set([callerEntry.skill, calleeEntry.skill]); const supersedesVisible = (entry: SkillActiveEntry): boolean => { if (!reassignedSkills.has(entry.skill)) return false; return safeString(entry.session_id).trim() === handoffSession; }; const applyEntries = (entries: SkillActiveEntry[]): SkillActiveEntry[] => { const callerKey = entryKey(callerEntry); const priorCaller = entries.find(e => entryKey(e) === callerKey) ?? entries.find(e => e.skill === callerEntry.skill && supersedesVisible(e) && Boolean(e.handoff_from)); const kept = entries.filter(e => !supersedesVisible(e)); const mergedCaller: SkillActiveEntry = priorCaller ? { ...callerEntry, ...(priorCaller.handoff_from && !callerEntry.handoff_from ? { handoff_from: priorCaller.handoff_from } : {}), ...(priorCaller.active_subskills ? { active_subskills: priorCaller.active_subskills } : {}), } : callerEntry; const activeSubskills = unionActiveSubskillEntries(priorCaller?.active_subskills, calleeEntry.active_subskills); const mergedCallee: SkillActiveEntry = activeSubskills.length > 0 ? { ...calleeEntry, active_subskills: activeSubskills } : calleeEntry; return [...kept, mergedCaller, mergedCallee]; }; const writeEntries = async (sessionScope: ActiveSessionScope, prior: SkillActiveState | null): Promise => { const nextEntries = applyEntries(rawActiveEntries(prior)); for (const entry of nextEntries) { await writeHandoffEntry(options.cwd, sessionScope, entry); } await rebuildActiveState(options.cwd, sessionScope); }; const prior = await readState(sessionPath); await writeEntries({ sessionId }, prior); } function buildSyncEntry(options: SyncSkillActiveStateOptions, nowIso: string): SkillActiveEntry { const hud = normalizeWorkflowHudSummary(options.hud); return { skill: options.skill, phase: options.phase, active: options.active, activated_at: nowIso, updated_at: nowIso, session_id: options.sessionId, thread_id: options.threadId, turn_id: options.turnId, ...(options.handoff_from ? { handoff_from: options.handoff_from } : {}), ...(options.handoff_to ? { handoff_to: options.handoff_to } : {}), ...(options.handoff_at ? { handoff_at: options.handoff_at } : {}), ...(hud ? { hud } : {}), ...(options.receipt ? { receipt: options.receipt } : {}), ...(options.active_subskills ? { active_subskills: options.active_subskills } : {}), }; }