@gajae-code/coding-agent 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +73 -1
  3. package/dist/types/cli/migrate-cli.d.ts +20 -0
  4. package/dist/types/commands/migrate.d.ts +33 -0
  5. package/dist/types/config/keybindings.d.ts +4 -0
  6. package/dist/types/config/settings-schema.d.ts +27 -0
  7. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +2 -0
  8. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -2
  9. package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
  10. package/dist/types/gjc-runtime/session-layout.d.ts +59 -0
  11. package/dist/types/gjc-runtime/session-resolution.d.ts +47 -0
  12. package/dist/types/gjc-runtime/state-graph.d.ts +1 -1
  13. package/dist/types/gjc-runtime/state-runtime.d.ts +5 -4
  14. package/dist/types/gjc-runtime/state-schema.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +36 -7
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  17. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +7 -4
  18. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +1 -1
  19. package/dist/types/gjc-runtime/workflow-manifest.d.ts +1 -1
  20. package/dist/types/harness-control-plane/storage.d.ts +2 -1
  21. package/dist/types/hooks/skill-state.d.ts +12 -4
  22. package/dist/types/migrate/action-planner.d.ts +11 -0
  23. package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
  24. package/dist/types/migrate/adapters/codex.d.ts +5 -0
  25. package/dist/types/migrate/adapters/index.d.ts +45 -0
  26. package/dist/types/migrate/adapters/opencode.d.ts +2 -0
  27. package/dist/types/migrate/executor.d.ts +2 -0
  28. package/dist/types/migrate/mcp-mapper.d.ts +20 -0
  29. package/dist/types/migrate/report.d.ts +18 -0
  30. package/dist/types/migrate/skill-normalizer.d.ts +27 -0
  31. package/dist/types/migrate/types.d.ts +126 -0
  32. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  33. package/dist/types/modes/components/welcome.d.ts +3 -1
  34. package/dist/types/modes/interactive-mode.d.ts +3 -0
  35. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  36. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
  37. package/dist/types/research-plan/index.d.ts +1 -0
  38. package/dist/types/research-plan/ledger.d.ts +33 -0
  39. package/dist/types/rlm/artifacts.d.ts +1 -1
  40. package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
  41. package/dist/types/skill-state/active-state.d.ts +6 -11
  42. package/dist/types/skill-state/canonical-skills.d.ts +3 -0
  43. package/dist/types/skill-state/workflow-hud.d.ts +2 -0
  44. package/dist/types/task/spawn-gate.d.ts +1 -10
  45. package/package.json +7 -7
  46. package/src/cli/migrate-cli.ts +106 -0
  47. package/src/cli/setup-cli.ts +14 -1
  48. package/src/cli.ts +1 -0
  49. package/src/commands/deep-interview.ts +2 -2
  50. package/src/commands/launch.ts +1 -1
  51. package/src/commands/migrate.ts +46 -0
  52. package/src/commands/state.ts +2 -1
  53. package/src/commands/team.ts +7 -3
  54. package/src/config/model-registry.ts +9 -2
  55. package/src/config/model-resolver.ts +13 -2
  56. package/src/config/settings-schema.ts +17 -0
  57. package/src/coordinator-mcp/policy.ts +10 -2
  58. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +0 -1
  59. package/src/defaults/gjc/skills/deep-interview/SKILL.md +28 -24
  60. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  61. package/src/defaults/gjc/skills/team/SKILL.md +51 -47
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +17 -13
  63. package/src/exec/bash-executor.ts +3 -1
  64. package/src/extensibility/custom-commands/loader.ts +0 -7
  65. package/src/extensibility/gjc-plugins/injection.ts +23 -4
  66. package/src/extensibility/gjc-plugins/state.ts +16 -1
  67. package/src/gjc-runtime/deep-interview-recorder.ts +43 -18
  68. package/src/gjc-runtime/deep-interview-runtime.ts +49 -23
  69. package/src/gjc-runtime/goal-mode-request.ts +26 -11
  70. package/src/gjc-runtime/launch-tmux.ts +68 -15
  71. package/src/gjc-runtime/ralplan-runtime.ts +79 -50
  72. package/src/gjc-runtime/session-layout.ts +180 -0
  73. package/src/gjc-runtime/session-resolution.ts +217 -0
  74. package/src/gjc-runtime/state-graph.ts +1 -2
  75. package/src/gjc-runtime/state-migrations.ts +1 -0
  76. package/src/gjc-runtime/state-runtime.ts +230 -121
  77. package/src/gjc-runtime/state-schema.ts +2 -0
  78. package/src/gjc-runtime/state-writer.ts +289 -41
  79. package/src/gjc-runtime/team-runtime.ts +43 -19
  80. package/src/gjc-runtime/tmux-sessions.ts +43 -2
  81. package/src/gjc-runtime/ultragoal-guard.ts +45 -2
  82. package/src/gjc-runtime/ultragoal-runtime.ts +121 -41
  83. package/src/gjc-runtime/workflow-command-ref.ts +1 -2
  84. package/src/gjc-runtime/workflow-manifest.ts +1 -2
  85. package/src/harness-control-plane/storage.ts +14 -4
  86. package/src/hooks/native-skill-hook.ts +38 -12
  87. package/src/hooks/skill-state.ts +178 -83
  88. package/src/internal-urls/docs-index.generated.ts +9 -6
  89. package/src/migrate/action-planner.ts +318 -0
  90. package/src/migrate/adapters/claude-code.ts +39 -0
  91. package/src/migrate/adapters/codex.ts +70 -0
  92. package/src/migrate/adapters/index.ts +277 -0
  93. package/src/migrate/adapters/opencode.ts +52 -0
  94. package/src/migrate/executor.ts +81 -0
  95. package/src/migrate/mcp-mapper.ts +152 -0
  96. package/src/migrate/report.ts +104 -0
  97. package/src/migrate/skill-normalizer.ts +80 -0
  98. package/src/migrate/types.ts +163 -0
  99. package/src/modes/bridge/bridge-mode.ts +2 -2
  100. package/src/modes/components/custom-editor.ts +30 -20
  101. package/src/modes/components/welcome.ts +42 -9
  102. package/src/modes/controllers/input-controller.ts +21 -3
  103. package/src/modes/interactive-mode.ts +22 -1
  104. package/src/modes/prompt-action-autocomplete.ts +11 -1
  105. package/src/modes/rpc/rpc-mode.ts +2 -2
  106. package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
  107. package/src/prompts/agents/init.md +1 -1
  108. package/src/prompts/system/plan-mode-active.md +1 -1
  109. package/src/prompts/tools/ast-grep.md +1 -1
  110. package/src/prompts/tools/search.md +1 -1
  111. package/src/prompts/tools/task.md +1 -2
  112. package/src/research-plan/index.ts +1 -0
  113. package/src/research-plan/ledger.ts +177 -0
  114. package/src/rlm/artifacts.ts +12 -3
  115. package/src/rlm/index.ts +7 -0
  116. package/src/runtime-mcp/config-writer.ts +46 -0
  117. package/src/session/agent-session.ts +15 -21
  118. package/src/session/session-manager.ts +19 -2
  119. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  120. package/src/setup/hermes-setup.ts +1 -1
  121. package/src/skill-state/active-state.ts +72 -108
  122. package/src/skill-state/canonical-skills.ts +4 -0
  123. package/src/skill-state/deep-interview-mutation-guard.ts +28 -109
  124. package/src/skill-state/workflow-hud.ts +4 -2
  125. package/src/skill-state/workflow-state-contract.ts +3 -3
  126. package/src/slash-commands/builtin-registry.ts +8 -4
  127. package/src/system-prompt.ts +11 -9
  128. package/src/task/agents.ts +1 -22
  129. package/src/task/index.ts +1 -41
  130. package/src/task/spawn-gate.ts +1 -38
  131. package/src/task/types.ts +1 -1
  132. package/src/tools/ask.ts +34 -12
  133. package/src/tools/computer.ts +58 -4
  134. package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
  135. package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
  136. package/src/prompts/agents/explore.md +0 -58
  137. package/src/prompts/agents/plan.md +0 -49
  138. package/src/prompts/agents/reviewer.md +0 -141
  139. package/src/prompts/agents/task.md +0 -16
  140. package/src/prompts/review-request.md +0 -70
@@ -149,6 +149,52 @@ export async function updateMCPServer(filePath: string, name: string, config: MC
149
149
  await writeMCPConfigFile(filePath, updated);
150
150
  }
151
151
 
152
+ /**
153
+ * Result of an {@link upsertMCPServer} call.
154
+ * - `added`: server did not exist and was written.
155
+ * - `updated`: server existed and was overwritten because `force` was set.
156
+ * - `skipped`: server existed and `force` was not set, so nothing was written.
157
+ */
158
+ export type UpsertMCPServerResult =
159
+ | { status: "added" }
160
+ | { status: "updated" }
161
+ | { status: "skipped"; reason: "exists" };
162
+
163
+ /**
164
+ * Add an MCP server, or overwrite an existing one only when `force` is set.
165
+ *
166
+ * Collision-aware wrapper over {@link addMCPServer} / {@link updateMCPServer} used by
167
+ * `gjc migrate`. Never connects to the server. Reuses the underlying writers so the
168
+ * rest of the config file (including `disabledServers`) is preserved on update.
169
+ *
170
+ * @throws Error if the server name or config is invalid (validated before any write).
171
+ */
172
+ export async function upsertMCPServer(
173
+ filePath: string,
174
+ name: string,
175
+ config: MCPServerConfig,
176
+ options: { force?: boolean } = {},
177
+ ): Promise<UpsertMCPServerResult> {
178
+ // Validate name up front so an invalid name fails regardless of collision state.
179
+ const nameError = validateServerName(name);
180
+ if (nameError) {
181
+ throw new Error(nameError);
182
+ }
183
+
184
+ const existing = await getMCPServer(filePath, name);
185
+ if (existing) {
186
+ if (!options.force) {
187
+ return { status: "skipped", reason: "exists" };
188
+ }
189
+ // updateMCPServer preserves the rest of MCPConfigFile, incl. disabledServers.
190
+ await updateMCPServer(filePath, name, config);
191
+ return { status: "updated" };
192
+ }
193
+
194
+ await addMCPServer(filePath, name, config);
195
+ return { status: "added" };
196
+ }
197
+
152
198
  /**
153
199
  * Remove an MCP server from a config file.
154
200
  *
@@ -183,6 +183,11 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
183
183
  import type { Skill, SkillWarning } from "../extensibility/skills";
184
184
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
185
185
  import { buildGjcRuntimeSessionEnv, consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
186
+ import {
187
+ assertNonEmptyGjcSessionId,
188
+ modeStatePath as sessionModeStatePath,
189
+ sessionStateDir,
190
+ } from "../gjc-runtime/session-layout";
186
191
  import { persistCoordinatorRuntimeStateFromEvent } from "../gjc-runtime/session-state-sidecar";
187
192
  import { writeArtifact } from "../gjc-runtime/state-writer";
188
193
  import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
@@ -312,13 +317,6 @@ export type AgentSessionEvent =
312
317
  | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }
313
318
  | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
314
319
 
315
- /**
316
- * Safe path component pattern used to validate session-id segments before
317
- * joining them into `.gjc/state` paths. Mirrors the regex used by the
318
- * `gjc state` runtime selector resolver.
319
- */
320
- const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
321
-
322
320
  function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
323
321
  const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
324
322
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -1370,21 +1368,17 @@ export class AgentSession {
1370
1368
  getActiveSkillPhase(): string | undefined {
1371
1369
  const active = this.#activeSkillState;
1372
1370
  if (!active) return undefined;
1373
- // Path safety: refuse to read mode-state files when the skill or
1374
- // session-id are not safe path components. The `skill` tool
1375
- // interprets undefined as a non-terminal phase, so chaining is
1376
- // refused — there is no risk of bypassing the guard via a custom
1377
- // skill name with `..` or a session-id with separators.
1378
1371
  if (!isCanonicalGjcWorkflowSkill(active.skill)) return undefined;
1379
- if (active.sessionId !== undefined && !SAFE_PATH_COMPONENT.test(active.sessionId)) {
1380
- return undefined;
1381
- }
1372
+ const sessionId = active.sessionId ?? this.sessionManager.getSessionId();
1382
1373
  try {
1383
- const stateDir = path.join(this.sessionManager.getCwd(), ".gjc", "state");
1384
- const segments = active.sessionId
1385
- ? [stateDir, "sessions", encodeURIComponent(active.sessionId).replaceAll(".", "%2E")]
1386
- : [stateDir];
1387
- const filePath = path.join(...segments, `${active.skill}-state.json`);
1374
+ assertNonEmptyGjcSessionId(sessionId, "AgentSession.getActiveSkillPhase");
1375
+ // Keep the session-state-dir construction explicit here so the chain guard
1376
+ // refuses to fall back to a legacy root `.gjc/state` read.
1377
+ const stateDir = sessionStateDir(this.sessionManager.getCwd(), sessionId);
1378
+ const filePath = path.join(
1379
+ stateDir,
1380
+ path.basename(sessionModeStatePath(this.sessionManager.getCwd(), sessionId, active.skill)),
1381
+ );
1388
1382
  const raw = fs.readFileSync(filePath, "utf-8");
1389
1383
  const parsed = JSON.parse(raw) as { current_phase?: unknown };
1390
1384
  return typeof parsed.current_phase === "string" ? parsed.current_phase : undefined;
@@ -3763,7 +3757,7 @@ export class AgentSession {
3763
3757
  * prompts or tool execution can run.
3764
3758
  */
3765
3759
  #wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
3766
- if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
3760
+ if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
3767
3761
  return new Proxy(tool, {
3768
3762
  get: (target, prop) => {
3769
3763
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -1129,6 +1129,23 @@ function formatTimeAgo(date: Date): string {
1129
1129
  return date.toLocaleDateString();
1130
1130
  }
1131
1131
 
1132
+ async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
1133
+ try {
1134
+ await fs.promises.rename(source, destination);
1135
+ return;
1136
+ } catch (error) {
1137
+ if (!hasFsCode(error, "EXDEV")) throw error;
1138
+ }
1139
+ const stat = await fs.promises.stat(source);
1140
+ if (stat.isDirectory()) {
1141
+ await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
1142
+ await fs.promises.rm(source, { recursive: true, force: false });
1143
+ return;
1144
+ }
1145
+ await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
1146
+ await fs.promises.unlink(source);
1147
+ }
1148
+
1132
1149
  const MAX_PERSIST_CHARS = 500_000;
1133
1150
  const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
1134
1151
  /** Minimum base64 length to externalize to blob store (skip tiny inline images) */
@@ -2498,14 +2515,14 @@ export class SessionManager {
2498
2515
  try {
2499
2516
  // Guard: session file may not exist yet (no assistant messages persisted)
2500
2517
  if (hadSessionFile) {
2501
- await fs.promises.rename(oldSessionFile, newSessionFile);
2518
+ await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
2502
2519
  movedSessionFile = true;
2503
2520
  }
2504
2521
 
2505
2522
  try {
2506
2523
  const stat = await fs.promises.stat(oldArtifactDir);
2507
2524
  if (stat.isDirectory()) {
2508
- await fs.promises.rename(oldArtifactDir, newArtifactDir);
2525
+ await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
2509
2526
  movedArtifactDir = true;
2510
2527
  }
2511
2528
  } catch (err) {
@@ -29,6 +29,14 @@ The Hermes bridge does not choose a model/provider. Generated setup configures `
29
29
 
30
30
  Provider-specific commands are examples only, never product defaults.
31
31
 
32
+ ## Visible routed-session fallback
33
+
34
+ If a Hermes/OpenClaw/Clawhip-style operator needs a human-visible, channel-routed GJC pane instead of a pure Coordinator MCP session, use the visible session pattern in [`docs/gjc-session-clawhip-routing.md`](../../../../../../docs/gjc-session-clawhip-routing.md).
35
+
36
+ Use that pattern only when the router must watch tmux output, send stale-session alerts, or inject follow-up prompts into the same visible pane. The short version is: prepare a dedicated worktree, register a stable tmux session through the host router, start interactive `gjc`, wait for TUI readiness, inject the task prompt separately, and verify actual tool/work activity before reporting acceptance.
37
+
38
+ Do not put private channel ids, mention targets, socket names, tokens, or local routing policy into portable setup output. Keep those in the host/operator deployment.
39
+
32
40
  ## Safety
33
41
 
34
42
  - Mutating tools require bridge startup mutation classes and per-call consent.
@@ -404,7 +404,7 @@ async function installConfig(spec: CoordinatorSetupSpec, force: boolean): Promis
404
404
 
405
405
  async function runSmoke(spec: CoordinatorSetupSpec): Promise<HermesSetupResult["smoke"]> {
406
406
  const requiredTools = [...COORDINATOR_MCP_TOOL_NAMES];
407
- const server = createCoordinatorMcpServer({ env: {} });
407
+ const server = createCoordinatorMcpServer({ env: renderHermesServerBlock(spec).env as NodeJS.ProcessEnv });
408
408
  const listed = await server.handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
409
409
  const listedResult = isRecord(listed.result) ? listed.result : {};
410
410
  const tools = Array.isArray(listedResult.tools) ? listedResult.tools : [];
@@ -1,4 +1,6 @@
1
- import * as path from "node:path";
1
+ import * as logger from "@gajae-code/utils/logger";
2
+ import { activeSnapshotPath, assertNonEmptyGjcSessionId, modeStatePath } from "../gjc-runtime/session-layout";
3
+ import { resolveGjcSessionForRead, SessionResolutionError } from "../gjc-runtime/session-resolution";
2
4
  import {
3
5
  type ActiveSessionScope,
4
6
  readActiveEntries,
@@ -6,13 +8,12 @@ import {
6
8
  removeActiveEntry,
7
9
  writeActiveEntry,
8
10
  } from "../gjc-runtime/state-writer";
11
+ import { CANONICAL_GJC_WORKFLOW_SKILLS, type CanonicalGjcWorkflowSkill } from "./canonical-skills";
9
12
  import type { WorkflowStateReceipt } from "./workflow-state-contract";
10
13
 
11
14
  export const SKILL_ACTIVE_STATE_FILE = "skill-active-state.json";
12
15
 
13
- export const CANONICAL_GJC_WORKFLOW_SKILLS = ["deep-interview", "ralplan", "ultragoal", "team"] as const;
14
-
15
- export type CanonicalGjcWorkflowSkill = (typeof CANONICAL_GJC_WORKFLOW_SKILLS)[number];
16
+ export { CANONICAL_GJC_WORKFLOW_SKILLS, type CanonicalGjcWorkflowSkill };
16
17
  export type WorkflowHudSeverity = "info" | "warning" | "blocked" | "error" | "success";
17
18
 
18
19
  export interface WorkflowHudChip {
@@ -60,6 +61,7 @@ export interface SkillActiveEntry {
60
61
  handoff_to?: string;
61
62
  handoff_at?: string;
62
63
  active_subskills?: ActiveSubskillEntry[];
64
+ source_state_revision?: number;
63
65
  }
64
66
 
65
67
  export interface SkillActiveState {
@@ -83,7 +85,7 @@ export interface SkillActiveState {
83
85
 
84
86
  export interface SkillActiveStatePaths {
85
87
  rootPath: string;
86
- sessionPath?: string;
88
+ sessionPath: string;
87
89
  }
88
90
 
89
91
  export interface SyncSkillActiveStateOptions {
@@ -102,6 +104,7 @@ export interface SyncSkillActiveStateOptions {
102
104
  handoff_to?: string;
103
105
  handoff_at?: string;
104
106
  active_subskills?: ActiveSubskillEntry[];
107
+ sourceRevision?: number;
105
108
  }
106
109
 
107
110
  const HUD_TEXT_LIMIT = 80;
@@ -246,8 +249,12 @@ function unionActiveSubskillEntries(...entrySets: Array<ActiveSubskillEntry[] |
246
249
  return merged;
247
250
  }
248
251
 
249
- function encodePathSegment(value: string): string {
250
- return encodeURIComponent(value).replaceAll(".", "%2E");
252
+ function resolveBoundarySessionId(cwd: string, sessionId?: string): Promise<string> {
253
+ const normalizedSessionId = safeString(sessionId).trim();
254
+ if (normalizedSessionId) return Promise.resolve(normalizedSessionId);
255
+ return resolveGjcSessionForRead(cwd, { envSessionId: process.env.GJC_SESSION_ID }).then(
256
+ context => context.gjcSessionId,
257
+ );
251
258
  }
252
259
 
253
260
  function entryKey(entry: Pick<SkillActiveEntry, "skill" | "session_id">): string {
@@ -343,14 +350,10 @@ export function normalizeSkillActiveState(raw: unknown): SkillActiveState | null
343
350
  }
344
351
 
345
352
  export function getSkillActiveStatePaths(cwd: string, sessionId?: string): SkillActiveStatePaths {
346
- const stateDir = path.join(cwd, ".gjc", "state");
347
- const rootPath = path.join(stateDir, SKILL_ACTIVE_STATE_FILE);
348
353
  const normalizedSessionId = safeString(sessionId).trim();
349
- if (!normalizedSessionId) return { rootPath };
350
- return {
351
- rootPath,
352
- sessionPath: path.join(stateDir, "sessions", encodePathSegment(normalizedSessionId), SKILL_ACTIVE_STATE_FILE),
353
- };
354
+ assertNonEmptyGjcSessionId(normalizedSessionId, "getSkillActiveStatePaths");
355
+ const sessionPath = activeSnapshotPath(cwd, normalizedSessionId);
356
+ return { rootPath: sessionPath, sessionPath };
354
357
  }
355
358
 
356
359
  /**
@@ -380,7 +383,12 @@ async function readRawActiveStateForHandoff(filePath: string, strict: boolean):
380
383
  if (!parsed || typeof parsed !== "object") return null;
381
384
  return parsed as SkillActiveState;
382
385
  } catch (err) {
383
- if (!strict) return null;
386
+ if (!strict) {
387
+ logger.warn(
388
+ `gjc skill-state: invalid skill-active-state at ${filePath}: invalid JSON: ${(err as Error).message}`,
389
+ );
390
+ return null;
391
+ }
384
392
  throw err;
385
393
  }
386
394
  }
@@ -419,14 +427,10 @@ function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] {
419
427
 
420
428
  async function readModeStatePhase(
421
429
  cwd: string,
422
- sessionId: string | undefined,
430
+ sessionId: string,
423
431
  skill: CanonicalGjcWorkflowSkill,
424
432
  ): Promise<string | undefined> {
425
- const stateDir = path.join(cwd, ".gjc", "state");
426
- const normalizedSessionId = safeString(sessionId).trim();
427
- const filePath = normalizedSessionId
428
- ? path.join(stateDir, "sessions", encodePathSegment(normalizedSessionId), `${skill}-state.json`)
429
- : path.join(stateDir, `${skill}-state.json`);
433
+ const filePath = modeStatePath(cwd, sessionId, skill);
430
434
  try {
431
435
  const parsed = JSON.parse(await Bun.file(filePath).text());
432
436
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
@@ -473,15 +477,6 @@ function withCanonicalRalplanPhase(entry: SkillActiveEntry, canonicalPhase: stri
473
477
  };
474
478
  }
475
479
 
476
- function filterRootEntriesForSession(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] {
477
- const normalizedSessionId = safeString(sessionId).trim();
478
- if (!normalizedSessionId) return entries;
479
- return entries.filter(entry => {
480
- const entrySessionId = safeString(entry.session_id).trim();
481
- return entrySessionId.length === 0 || entrySessionId === normalizedSessionId;
482
- });
483
- }
484
-
485
480
  function entryRecency(entry: SkillActiveEntry): number {
486
481
  const stamp = entry.handoff_at || entry.updated_at || entry.activated_at;
487
482
  const ms = stamp ? Date.parse(stamp) : Number.NaN;
@@ -603,57 +598,50 @@ export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]):
603
598
  async function mergeVisibleEntries(
604
599
  cwd: string,
605
600
  sessionState: SkillActiveState | null,
606
- rootState: SkillActiveState | null,
607
- sessionId?: string,
601
+ sessionId: string,
608
602
  ): Promise<SkillActiveEntry[]> {
609
603
  // Use the raw (active + inactive) rows so a handoff demotion stays visible
610
604
  // long enough to supersede a stale same-skill row before the active filter.
611
605
  // Per-skill files in active/<skill>.json are authoritative and are merged
612
606
  // after the derived snapshot cache, so a stale skill-active-state.json row
613
607
  // cannot override the latest entry file.
614
- const rootEntries = filterRootEntriesForSession(
615
- [...rawActiveEntries(rootState), ...(await readActiveEntries(cwd))],
616
- sessionId,
617
- );
618
- const merged = new Map(rootEntries.map(entry => [entryKey(entry), entry]));
619
- const sessionEntries = sessionId
620
- ? [...rawActiveEntries(sessionState), ...(await readActiveEntries(cwd, { sessionId }))]
621
- : rawActiveEntries(sessionState);
622
- for (const entry of sessionEntries) {
623
- merged.set(entryKey(entry), entry);
624
- }
608
+ const entries = [...rawActiveEntries(sessionState), ...(await readActiveEntries(cwd, { sessionId }))];
609
+ const merged = new Map(entries.map(entry => [entryKey(entry), entry]));
625
610
  const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan");
626
- return collapsePlanningPipeline(
627
- dedupeVisibleBySkill([...merged.values()], sessionId)
628
- .filter(entry => entry.active !== false)
629
- .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase)),
630
- );
611
+ const visibleEntries = dedupeVisibleBySkill([...merged.values()], sessionId)
612
+ .filter(entry => entry.active !== false)
613
+ .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase));
614
+ return collapsePlanningPipeline(visibleEntries).toSorted(comparePipelineEntry);
631
615
  }
632
616
 
633
617
  export async function readVisibleSkillActiveState(cwd: string, sessionId?: string): Promise<SkillActiveState | null> {
634
- const { rootPath, sessionPath } = getSkillActiveStatePaths(cwd, sessionId);
635
- const [rootState, sessionState] = await Promise.all([
636
- readRawActiveStateForHandoff(rootPath, false),
637
- sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
638
- ]);
639
- const activeSkills = await mergeVisibleEntries(cwd, sessionState, rootState, sessionId);
618
+ let resolvedSessionId: string;
619
+ try {
620
+ resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId);
621
+ } catch (error) {
622
+ if (error instanceof SessionResolutionError && error.code === "no_session") return null;
623
+ throw error;
624
+ }
625
+ const { sessionPath } = getSkillActiveStatePaths(cwd, resolvedSessionId);
626
+ const sessionState = await readRawActiveStateForHandoff(sessionPath, false);
627
+ const activeSkills = await mergeVisibleEntries(cwd, sessionState, resolvedSessionId);
640
628
  if (activeSkills.length === 0) return null;
641
629
  const primary = activeSkills[0];
642
630
  return {
643
- ...(rootState ?? {}),
644
631
  ...(sessionState ?? {}),
645
632
  version: 1,
646
633
  active: true,
647
- skill: primary?.skill ?? "",
648
- phase: primary?.phase ?? "",
649
- session_id: safeString(sessionId).trim() || primary?.session_id,
634
+ skill: sessionState?.skill ?? primary?.skill ?? "",
635
+ phase: sessionState?.phase ?? primary?.phase ?? "",
636
+ session_id: resolvedSessionId,
650
637
  active_skills: activeSkills,
651
638
  active_subskills: activeSkills.flatMap(entry => entry.active_subskills ?? []),
652
639
  };
653
640
  }
654
641
 
655
- function activeStateWriterAudit(verb: string) {
656
- return { category: "state" as const, verb, owner: "gjc-runtime" as const };
642
+ function activeStateWriterAudit(verb: string, sessionScope?: ActiveSessionScope | string) {
643
+ const sessionId = typeof sessionScope === "string" ? sessionScope : sessionScope?.sessionId;
644
+ return { category: "state" as const, verb, owner: "gjc-runtime" as const, ...(sessionId ? { sessionId } : {}) };
657
645
  }
658
646
 
659
647
  async function persistActiveEntry(
@@ -664,12 +652,13 @@ async function persistActiveEntry(
664
652
  if (entry.active === false) {
665
653
  await removeActiveEntry(cwd, sessionScope, entry.skill, {
666
654
  cwd,
667
- audit: activeStateWriterAudit("remove-active-entry"),
655
+ audit: activeStateWriterAudit("remove-active-entry", sessionScope),
656
+ sourceRevision: entry.source_state_revision,
668
657
  });
669
658
  } else {
670
659
  await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
671
660
  cwd,
672
- audit: activeStateWriterAudit("write-active-entry"),
661
+ audit: activeStateWriterAudit("write-active-entry", sessionScope),
673
662
  });
674
663
  }
675
664
  }
@@ -681,12 +670,15 @@ async function writeHandoffEntry(
681
670
  ): Promise<void> {
682
671
  await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
683
672
  cwd,
684
- audit: activeStateWriterAudit("write-active-entry"),
673
+ audit: activeStateWriterAudit("write-active-entry", sessionScope),
685
674
  });
686
675
  }
687
676
 
688
677
  async function rebuildActiveState(cwd: string, sessionScope?: ActiveSessionScope): Promise<void> {
689
- await rebuildActiveSnapshot(cwd, sessionScope, { cwd, audit: activeStateWriterAudit("rebuild-active-snapshot") });
678
+ await rebuildActiveSnapshot(cwd, sessionScope, {
679
+ cwd,
680
+ audit: activeStateWriterAudit("rebuild-active-snapshot", sessionScope),
681
+ });
690
682
  }
691
683
 
692
684
  async function removeSupersededPlanningPipelineEntries(
@@ -698,7 +690,7 @@ async function removeSupersededPlanningPipelineEntries(
698
690
  for (const skill of upstreamPlanningPipelineSkills(entry.skill)) {
699
691
  await removeActiveEntry(cwd, sessionScope, skill, {
700
692
  cwd,
701
- audit: activeStateWriterAudit("remove-superseded-pipeline-entry"),
693
+ audit: activeStateWriterAudit("remove-superseded-pipeline-entry", sessionScope),
702
694
  });
703
695
  }
704
696
  }
@@ -708,18 +700,17 @@ async function activeSubskillsForExistingEntry(
708
700
  sessionId: string | undefined,
709
701
  skill: string,
710
702
  ): Promise<ActiveSubskillEntry[] | undefined> {
711
- const { rootPath, sessionPath } = getSkillActiveStatePaths(cwd, sessionId);
712
- const [rootState, sessionState] = await Promise.all([
713
- readRawActiveStateForHandoff(rootPath, false),
714
- sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
715
- ]);
716
- const existing = (await mergeVisibleEntries(cwd, sessionState, rootState, sessionId)).find(
703
+ const resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId);
704
+ const { sessionPath } = getSkillActiveStatePaths(cwd, resolvedSessionId);
705
+ const sessionState = await readRawActiveStateForHandoff(sessionPath, false);
706
+ const existing = (await mergeVisibleEntries(cwd, sessionState, resolvedSessionId)).find(
717
707
  entry => entry.skill === skill,
718
708
  );
719
709
  return existing?.active_subskills;
720
710
  }
721
711
 
722
712
  export async function syncSkillActiveState(options: SyncSkillActiveStateOptions): Promise<void> {
713
+ if (!options.sessionId) return;
723
714
  const preservedActiveSubskills =
724
715
  options.active_subskills === undefined
725
716
  ? await activeSubskillsForExistingEntry(options.cwd, options.sessionId, options.skill)
@@ -745,12 +736,8 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
745
736
  : preservedActiveSubskills
746
737
  ? { active_subskills: preservedActiveSubskills }
747
738
  : {}),
739
+ ...(typeof options.sourceRevision === "number" ? { source_state_revision: options.sourceRevision } : {}),
748
740
  };
749
- await removeSupersededPlanningPipelineEntries(options.cwd, undefined, entry);
750
- await persistActiveEntry(options.cwd, undefined, entry);
751
- await rebuildActiveState(options.cwd);
752
-
753
- if (!options.sessionId) return;
754
741
  const sessionScope = { sessionId: options.sessionId };
755
742
  await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry);
756
743
  await persistActiveEntry(options.cwd, sessionScope, entry);
@@ -768,36 +755,23 @@ export interface ApplyHandoffOptions {
768
755
  }
769
756
 
770
757
  /**
771
- * Atomically apply a workflow-skill handoff to both the session-scoped and
772
- * root `skill-active-state.json` files in a single write per file.
773
- *
774
- * Write order: **session first, root last**. The session file is the
775
- * source of truth for HUD; the root aggregate must never lead the session
776
- * during a handoff window. Each file is rewritten once with caller demoted
777
- * to `active:false` (preserving `handoff_to`/`handoff_at` lineage) and
778
- * callee promoted to `active:true` (with `handoff_from`/`handoff_at`).
758
+ * Atomically apply a workflow-skill handoff to the session-scoped active state.
779
759
  */
780
760
  export async function applyHandoffToActiveState(options: ApplyHandoffOptions): Promise<void> {
781
761
  const nowIso = options.nowIso ?? new Date().toISOString();
782
762
  const callerEntry = buildSyncEntry(options.caller, nowIso);
783
763
  const calleeEntry = buildSyncEntry(options.callee, nowIso);
784
764
  const sessionId = options.callee.sessionId ?? options.caller.sessionId;
785
- const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
765
+ assertNonEmptyGjcSessionId(sessionId, "applyHandoffToActiveState");
766
+ const { sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
786
767
  const readState = (filePath: string) => readRawActiveStateForHandoff(filePath, options.strict === true);
787
- await Promise.all([readState(rootPath), ...(sessionPath ? [readState(sessionPath)] : [])]);
788
-
789
- // A skill can hold more than one visible row in this session's scope — e.g.
790
- // it was seeded without a session id (rendered globally) and is now handed
791
- // off under a concrete session id. Supersede every same-session-scope row of
792
- // the caller and callee skills, not just the exact `skill::session_id` key,
793
- // so a stale `active:true` row cannot survive the demotion and keep showing
794
- // in the HUD. Rows owned by other sessions are left untouched.
768
+ await readState(sessionPath);
769
+
795
770
  const handoffSession = safeString(sessionId).trim();
796
771
  const reassignedSkills = new Set([callerEntry.skill, calleeEntry.skill]);
797
772
  const supersedesVisible = (entry: SkillActiveEntry): boolean => {
798
773
  if (!reassignedSkills.has(entry.skill)) return false;
799
- const entrySession = safeString(entry.session_id).trim();
800
- return entrySession.length === 0 || entrySession === handoffSession;
774
+ return safeString(entry.session_id).trim() === handoffSession;
801
775
  };
802
776
  const applyEntries = (entries: SkillActiveEntry[]): SkillActiveEntry[] => {
803
777
  const callerKey = entryKey(callerEntry);
@@ -805,9 +779,6 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
805
779
  entries.find(e => entryKey(e) === callerKey) ??
806
780
  entries.find(e => e.skill === callerEntry.skill && supersedesVisible(e) && Boolean(e.handoff_from));
807
781
  const kept = entries.filter(e => !supersedesVisible(e));
808
- // Merge prior lineage into the demoted caller so multi-step handoff
809
- // chains preserve `handoff_from` from the previous transition while
810
- // the new `handoff_to`/`handoff_at` describe this one.
811
782
  const mergedCaller: SkillActiveEntry = priorCaller
812
783
  ? {
813
784
  ...callerEntry,
@@ -822,10 +793,7 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
822
793
  activeSubskills.length > 0 ? { ...calleeEntry, active_subskills: activeSubskills } : calleeEntry;
823
794
  return [...kept, mergedCaller, mergedCallee];
824
795
  };
825
- const writeEntries = async (
826
- sessionScope: ActiveSessionScope | undefined,
827
- prior: SkillActiveState | null,
828
- ): Promise<void> => {
796
+ const writeEntries = async (sessionScope: ActiveSessionScope, prior: SkillActiveState | null): Promise<void> => {
829
797
  const nextEntries = applyEntries(rawActiveEntries(prior));
830
798
  for (const entry of nextEntries) {
831
799
  await writeHandoffEntry(options.cwd, sessionScope, entry);
@@ -833,12 +801,8 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
833
801
  await rebuildActiveState(options.cwd, sessionScope);
834
802
  };
835
803
 
836
- if (sessionPath) {
837
- const prior = await readState(sessionPath);
838
- await writeEntries({ sessionId }, prior);
839
- }
840
- const priorRoot = await readState(rootPath);
841
- await writeEntries(undefined, priorRoot);
804
+ const prior = await readState(sessionPath);
805
+ await writeEntries({ sessionId }, prior);
842
806
  }
843
807
 
844
808
  function buildSyncEntry(options: SyncSkillActiveStateOptions, nowIso: string): SkillActiveEntry {
@@ -0,0 +1,4 @@
1
+ /** Native-free canonical GJC workflow skill identifiers. */
2
+ export const CANONICAL_GJC_WORKFLOW_SKILLS = ["deep-interview", "ralplan", "ultragoal", "team"] as const;
3
+
4
+ export type CanonicalGjcWorkflowSkill = (typeof CANONICAL_GJC_WORKFLOW_SKILLS)[number];