@gajae-code/coding-agent 0.3.0 → 0.3.1

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 (175) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/types/async/job-manager.d.ts +7 -0
  3. package/dist/types/cli/args.d.ts +1 -1
  4. package/dist/types/commands/deep-interview.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +5 -0
  6. package/dist/types/config/settings-schema.d.ts +4 -4
  7. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  8. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  9. package/dist/types/deep-interview/render-middleware.d.ts +1 -0
  10. package/dist/types/eval/py/executor.d.ts +2 -0
  11. package/dist/types/eval/py/kernel.d.ts +2 -0
  12. package/dist/types/exec/bash-executor.d.ts +10 -0
  13. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  14. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  15. package/dist/types/gjc-runtime/state-migrations.d.ts +9 -0
  16. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  17. package/dist/types/gjc-runtime/state-writer.d.ts +10 -0
  18. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  19. package/dist/types/harness-control-plane/control-endpoint.d.ts +3 -2
  20. package/dist/types/hooks/skill-state.d.ts +21 -0
  21. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  22. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  23. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  24. package/dist/types/internal-urls/types.d.ts +4 -0
  25. package/dist/types/lsp/index.d.ts +10 -10
  26. package/dist/types/modes/bridge/auth.d.ts +12 -0
  27. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  28. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  29. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  30. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  32. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  33. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  34. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  35. package/dist/types/modes/components/status-line.d.ts +2 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  37. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  38. package/dist/types/modes/index.d.ts +1 -0
  39. package/dist/types/modes/interactive-mode.d.ts +1 -0
  40. package/dist/types/modes/jobs-observer.d.ts +57 -0
  41. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  42. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  43. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  44. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  46. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  47. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  48. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  49. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  50. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  51. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  52. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  53. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  54. package/dist/types/modes/types.d.ts +1 -0
  55. package/dist/types/sdk.d.ts +2 -0
  56. package/dist/types/session/agent-session.d.ts +11 -1
  57. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -2
  58. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  59. package/dist/types/task/id.d.ts +7 -0
  60. package/dist/types/task/index.d.ts +5 -0
  61. package/dist/types/task/receipt.d.ts +85 -0
  62. package/dist/types/task/spawn-gate.d.ts +38 -0
  63. package/dist/types/task/types.d.ts +143 -11
  64. package/dist/types/tools/cron.d.ts +6 -0
  65. package/dist/types/tools/index.d.ts +2 -0
  66. package/dist/types/tools/path-utils.d.ts +1 -0
  67. package/dist/types/tools/subagent.d.ts +15 -0
  68. package/package.json +7 -7
  69. package/scripts/build-binary.ts +7 -0
  70. package/src/async/job-manager.ts +36 -0
  71. package/src/cli/args.ts +9 -2
  72. package/src/commands/deep-interview.ts +1 -0
  73. package/src/commands/harness.ts +289 -19
  74. package/src/commands/launch.ts +2 -2
  75. package/src/commands/state.ts +2 -1
  76. package/src/commands/team.ts +22 -4
  77. package/src/config/keybindings.ts +6 -0
  78. package/src/config/settings-schema.ts +6 -3
  79. package/src/dap/client.ts +17 -3
  80. package/src/debug/crash-diagnostics.ts +223 -0
  81. package/src/debug/runtime-gauges.ts +20 -0
  82. package/src/deep-interview/render-middleware.ts +6 -0
  83. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  84. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  85. package/src/defaults/gjc/skills/ultragoal/SKILL.md +28 -2
  86. package/src/eval/py/executor.ts +21 -1
  87. package/src/eval/py/kernel.ts +15 -0
  88. package/src/exec/bash-executor.ts +41 -0
  89. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  90. package/src/gjc-runtime/deep-interview-runtime.ts +69 -32
  91. package/src/gjc-runtime/ralplan-runtime.ts +213 -36
  92. package/src/gjc-runtime/state-migrations.ts +54 -7
  93. package/src/gjc-runtime/state-runtime.ts +461 -64
  94. package/src/gjc-runtime/state-schema.ts +192 -0
  95. package/src/gjc-runtime/state-writer.ts +32 -1
  96. package/src/gjc-runtime/team-runtime.ts +177 -105
  97. package/src/gjc-runtime/ultragoal-runtime.ts +114 -26
  98. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  99. package/src/gjc-runtime/workflow-manifest.generated.json +108 -4
  100. package/src/gjc-runtime/workflow-manifest.ts +3 -1
  101. package/src/harness-control-plane/control-endpoint.ts +19 -8
  102. package/src/harness-control-plane/owner.ts +57 -10
  103. package/src/harness-control-plane/state-machine.ts +2 -1
  104. package/src/hooks/skill-state.ts +176 -26
  105. package/src/internal-urls/agent-protocol.ts +68 -21
  106. package/src/internal-urls/artifact-protocol.ts +12 -17
  107. package/src/internal-urls/docs-index.generated.ts +3 -2
  108. package/src/internal-urls/registry-helpers.ts +19 -16
  109. package/src/internal-urls/types.ts +4 -0
  110. package/src/lsp/client.ts +18 -2
  111. package/src/main.ts +21 -5
  112. package/src/modes/bridge/auth.ts +41 -0
  113. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  114. package/src/modes/bridge/bridge-mode.ts +520 -0
  115. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  116. package/src/modes/bridge/event-stream.ts +70 -0
  117. package/src/modes/components/custom-editor.ts +101 -0
  118. package/src/modes/components/hook-selector.ts +61 -18
  119. package/src/modes/components/jobs-overlay-model.ts +109 -0
  120. package/src/modes/components/jobs-overlay.ts +172 -0
  121. package/src/modes/components/status-line/presets.ts +7 -5
  122. package/src/modes/components/status-line/segments.ts +25 -0
  123. package/src/modes/components/status-line/types.ts +2 -0
  124. package/src/modes/components/status-line.ts +9 -1
  125. package/src/modes/controllers/extension-ui-controller.ts +39 -3
  126. package/src/modes/controllers/input-controller.ts +97 -9
  127. package/src/modes/controllers/selector-controller.ts +29 -0
  128. package/src/modes/index.ts +1 -0
  129. package/src/modes/interactive-mode.ts +27 -0
  130. package/src/modes/jobs-observer.ts +204 -0
  131. package/src/modes/rpc/host-tools.ts +1 -186
  132. package/src/modes/rpc/host-uris.ts +1 -235
  133. package/src/modes/rpc/rpc-client.ts +25 -10
  134. package/src/modes/rpc/rpc-mode.ts +12 -381
  135. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  136. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  137. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  138. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  139. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  140. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  141. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  142. package/src/modes/shared/agent-wire/responses.ts +17 -0
  143. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  144. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  145. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  146. package/src/modes/types.ts +1 -0
  147. package/src/prompts/tools/subagent.md +12 -7
  148. package/src/prompts/tools/task-summary.md +3 -9
  149. package/src/prompts/tools/task.md +5 -1
  150. package/src/sdk.ts +4 -0
  151. package/src/session/agent-session.ts +214 -38
  152. package/src/skill-state/deep-interview-mutation-guard.ts +23 -4
  153. package/src/skill-state/workflow-state-contract.ts +7 -4
  154. package/src/skill-state/workflow-state-version.ts +3 -0
  155. package/src/slash-commands/builtin-registry.ts +8 -0
  156. package/src/task/executor.ts +29 -5
  157. package/src/task/id.ts +33 -0
  158. package/src/task/index.ts +257 -67
  159. package/src/task/output-manager.ts +5 -4
  160. package/src/task/receipt.ts +297 -0
  161. package/src/task/render.ts +48 -131
  162. package/src/task/spawn-gate.ts +132 -0
  163. package/src/task/types.ts +48 -7
  164. package/src/tools/ask.ts +73 -33
  165. package/src/tools/ast-edit.ts +1 -0
  166. package/src/tools/ast-grep.ts +1 -0
  167. package/src/tools/bash.ts +1 -1
  168. package/src/tools/cron.ts +48 -0
  169. package/src/tools/find.ts +4 -1
  170. package/src/tools/index.ts +2 -0
  171. package/src/tools/path-utils.ts +3 -2
  172. package/src/tools/read.ts +1 -0
  173. package/src/tools/search.ts +1 -0
  174. package/src/tools/skill.ts +6 -1
  175. package/src/tools/subagent.ts +237 -84
@@ -2,12 +2,12 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { Settings } from "../config/settings";
6
5
  import { syncSkillActiveState } from "../skill-state/active-state";
7
6
  import { buildDeepInterviewHudSummary } from "../skill-state/workflow-hud";
7
+ import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
8
8
  import { runNativeRalplanCommand } from "./ralplan-runtime";
9
9
  import { runNativeStateCommand } from "./state-runtime";
10
- import { appendJsonl, writeArtifact, writeJsonAtomic } from "./state-writer";
10
+ import { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
11
11
 
12
12
  /**
13
13
  * Native implementation of `gjc deep-interview`.
@@ -96,16 +96,6 @@ function deepInterviewStatePath(cwd: string, sessionId: string | undefined): str
96
96
  return path.join(stateDirFor(cwd, sessionId), "deep-interview-state.json");
97
97
  }
98
98
 
99
- async function readJsonObject(filePath: string): Promise<Record<string, unknown>> {
100
- try {
101
- const parsed = JSON.parse(await fs.readFile(filePath, "utf-8"));
102
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
103
- } catch {
104
- // Missing/corrupt state should not prevent the sanctioned persistence CLI from writing a receipt.
105
- }
106
- return {};
107
- }
108
-
109
99
  async function resolveSpecContent(rawSpec: string, cwd: string): Promise<string> {
110
100
  const candidate = path.isAbsolute(rawSpec) ? rawSpec : path.resolve(cwd, rawSpec);
111
101
  try {
@@ -145,6 +135,7 @@ export interface ResolvedDeepInterviewSpecWriteArgs {
145
135
  json: boolean;
146
136
  deliberate: boolean;
147
137
  handoff?: "ralplan";
138
+ force: boolean;
148
139
  }
149
140
 
150
141
  export interface PersistedDeepInterviewSpec {
@@ -162,6 +153,8 @@ interface DeepInterviewSpecWriteSummary {
162
153
  slug: string;
163
154
  path: string;
164
155
  sha256: string;
156
+ spec_path: string;
157
+ sha: string;
165
158
  created_at: string;
166
159
  state_path: string;
167
160
  handoff?: {
@@ -197,11 +190,16 @@ async function readSettingsAmbiguityThreshold(
197
190
  return { threshold: candidate, source: settingsPath };
198
191
  }
199
192
 
200
- async function readModernSettingsAmbiguityThreshold(
201
- cwd: string,
202
- ): Promise<{ threshold: number; source: string } | undefined> {
203
- const settings = await Settings.init({ cwd });
204
- const modernConfigPath = path.join(settings.getAgentDir(), "config.yml");
193
+ function modernSettingsPath(): string {
194
+ const configDir = process.env.GJC_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
195
+ if (configDir) return path.join(configDir, "config.yml");
196
+ const configRoot = process.env.GJC_CONFIG_DIR?.trim() || process.env.PI_CONFIG_DIR?.trim();
197
+ if (configRoot) return path.join(configRoot, "agent", "config.yml");
198
+ return path.join(os.homedir(), ".gjc", "agent", "config.yml");
199
+ }
200
+
201
+ async function readModernSettingsAmbiguityThreshold(): Promise<{ threshold: number; source: string } | undefined> {
202
+ const modernConfigPath = modernSettingsPath();
205
203
  let parsed: unknown;
206
204
  try {
207
205
  parsed = (await import("bun")).YAML.parse(await fs.readFile(modernConfigPath, "utf-8"));
@@ -218,7 +216,7 @@ async function readModernSettingsAmbiguityThreshold(
218
216
  async function resolveConfiguredAmbiguityThreshold(
219
217
  cwd: string,
220
218
  ): Promise<{ threshold: number; source: string } | undefined> {
221
- const modernValue = await readModernSettingsAmbiguityThreshold(cwd);
219
+ const modernValue = await readModernSettingsAmbiguityThreshold();
222
220
  if (modernValue) return modernValue;
223
221
  const projectSettings = path.join(cwd, ".gjc", "settings.json");
224
222
  const projectValue = await readSettingsAmbiguityThreshold(projectSettings);
@@ -292,6 +290,7 @@ async function resolveSpecWriteArgs(args: readonly string[], cwd: string): Promi
292
290
  "--handoff",
293
291
  "--deliberate",
294
292
  "--json",
293
+ "--force",
295
294
  ]);
296
295
  let skipNext = false;
297
296
  for (const arg of args) {
@@ -315,6 +314,7 @@ async function resolveSpecWriteArgs(args: readonly string[], cwd: string): Promi
315
314
  sessionId,
316
315
  json: hasFlag(args, "--json"),
317
316
  deliberate: hasFlag(args, "--deliberate"),
317
+ force: hasFlag(args, "--force"),
318
318
  handoff: rawHandoff as "ralplan" | undefined,
319
319
  };
320
320
  }
@@ -388,6 +388,16 @@ export async function persistDeepInterviewSpec(
388
388
  cwd: string,
389
389
  resolved: ResolvedDeepInterviewSpecWriteArgs,
390
390
  ): Promise<PersistedDeepInterviewSpec> {
391
+ const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
392
+ const existingRead = await readExistingStateForMutation(statePath);
393
+ if (existingRead.kind === "corrupt" && !resolved.force) {
394
+ throw new DeepInterviewCommandError(
395
+ 2,
396
+ `existing deep-interview state is corrupt or tampered (${existingRead.error}); use --force to overwrite ${statePath}`,
397
+ );
398
+ }
399
+ const existing = existingRead.kind === "valid" ? existingRead.value : {};
400
+
391
401
  const specPath = path.join(cwd, ".gjc", "specs", `deep-interview-${resolved.slug}.md`);
392
402
  const content = resolved.spec.endsWith("\n") ? resolved.spec : `${resolved.spec}\n`;
393
403
  await writeArtifact(specPath, content, {
@@ -403,14 +413,12 @@ export async function persistDeepInterviewSpec(
403
413
  { cwd, audit: { category: "ledger", verb: "append", owner: "gjc-runtime", skill: "deep-interview" } },
404
414
  );
405
415
 
406
- const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
407
- const existing = await readJsonObject(statePath);
408
416
  const payload: Record<string, unknown> = {
409
417
  ...existing,
410
418
  active: true,
411
419
  current_phase: "handoff",
412
420
  skill: "deep-interview",
413
- version: typeof existing.version === "number" ? existing.version : 1,
421
+ version: WORKFLOW_STATE_VERSION,
414
422
  spec_slug: resolved.slug,
415
423
  spec_path: specPath,
416
424
  spec_sha256: sha256,
@@ -419,9 +427,23 @@ export async function persistDeepInterviewSpec(
419
427
  updated_at: createdAt,
420
428
  };
421
429
  if (resolved.sessionId) payload.session_id = resolved.sessionId;
422
- await writeJsonAtomic(statePath, payload, {
430
+ await writeWorkflowEnvelopeAtomic(statePath, payload, {
423
431
  cwd,
424
- audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview" },
432
+ receipt: {
433
+ cwd,
434
+ skill: "deep-interview",
435
+ owner: "gjc-runtime",
436
+ command: "gjc deep-interview persist-spec-state",
437
+ sessionId: resolved.sessionId,
438
+ nowIso: createdAt,
439
+ },
440
+ audit: {
441
+ category: "state",
442
+ verb: "write",
443
+ owner: "gjc-runtime",
444
+ skill: "deep-interview",
445
+ forced: resolved.force,
446
+ },
425
447
  });
426
448
  await syncDeepInterviewHud({
427
449
  cwd,
@@ -447,6 +469,7 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
447
469
  active: true,
448
470
  current_phase: "interviewing",
449
471
  skill: "deep-interview",
472
+ version: WORKFLOW_STATE_VERSION,
450
473
  resolution: resolved.resolution,
451
474
  threshold: resolved.threshold,
452
475
  threshold_source: resolved.thresholdSource,
@@ -464,8 +487,16 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
464
487
  (payload.state as Record<string, unknown>).language = resolved.language;
465
488
  }
466
489
  if (resolved.sessionId) payload.session_id = resolved.sessionId;
467
- await writeJsonAtomic(statePath, payload, {
490
+ await writeWorkflowEnvelopeAtomic(statePath, payload, {
468
491
  cwd,
492
+ receipt: {
493
+ cwd,
494
+ skill: "deep-interview",
495
+ owner: "gjc-runtime",
496
+ command: "gjc deep-interview seed",
497
+ sessionId: resolved.sessionId,
498
+ nowIso: now,
499
+ },
469
500
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview" },
470
501
  });
471
502
  return statePath;
@@ -512,6 +543,8 @@ async function handleSpecWrite(args: readonly string[], cwd: string): Promise<De
512
543
  slug: persisted.slug,
513
544
  path: persisted.path,
514
545
  sha256: persisted.sha256,
546
+ spec_path: persisted.path,
547
+ sha: persisted.sha256,
515
548
  created_at: persisted.createdAt,
516
549
  state_path: persisted.statePath,
517
550
  };
@@ -549,10 +582,14 @@ async function handleSpecWrite(args: readonly string[], cwd: string): Promise<De
549
582
  }
550
583
 
551
584
  const stdout = resolved.json
552
- ? `${JSON.stringify(summary, null, 2)}\n`
585
+ ? `${JSON.stringify(summary)}\n`
553
586
  : [
554
- `Persisted deep-interview ${persisted.stage} spec at ${persisted.path}.`,
555
- shouldHandoff ? "Handed off deep-interview to ralplan (deliberate)." : undefined,
587
+ `deep-interview spec_path=${persisted.path}`,
588
+ `sha=${persisted.sha256}`,
589
+ `state_path=${persisted.statePath}`,
590
+ shouldHandoff
591
+ ? `handoff=ralplan run_id=${summary.handoff?.run_id ?? ""} state_path=${summary.handoff?.state_path ?? ""}`
592
+ : undefined,
556
593
  "",
557
594
  ]
558
595
  .filter((line): line is string => Boolean(line))
@@ -591,14 +628,14 @@ export async function runNativeDeepInterviewCommand(
591
628
  idea: resolved.idea,
592
629
  language: resolved.language,
593
630
  state_path: statePath,
594
- handoff: "Run `/skill:deep-interview` inside the GJC agent to drive the Socratic interview loop.",
631
+ handoff: "/skill:deep-interview",
595
632
  };
596
633
  const stdout = resolved.json
597
- ? `${JSON.stringify(summary, null, 2)}\n`
634
+ ? `${JSON.stringify(summary)}\n`
598
635
  : [
599
- `Seeded deep-interview ${resolved.resolution} run at ${statePath}.`,
600
- `Threshold: ${(resolved.threshold * 100).toFixed(0)}% (source: ${resolved.thresholdSource}).`,
601
- "Run `/skill:deep-interview` inside the GJC agent to execute the interview.",
636
+ `deep-interview seed state_path=${statePath}`,
637
+ `resolution=${resolved.resolution} threshold=${resolved.threshold} threshold_source=${resolved.thresholdSource}`,
638
+ "handoff=/skill:deep-interview",
602
639
  "",
603
640
  ].join("\n");
604
641
  return { status: 0, stdout };
@@ -3,8 +3,11 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { syncSkillActiveState } from "../skill-state/active-state";
5
5
  import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
6
+ import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
7
+ import { renderCliWriteReceipt } from "./cli-write-receipt";
6
8
  import { isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
7
- import { appendJsonl, writeArtifact, writeJsonAtomic } from "./state-writer";
9
+ import { migrateWorkflowState } from "./state-migrations";
10
+ import { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
8
11
 
9
12
  /**
10
13
  * Native implementation of `gjc ralplan`.
@@ -39,6 +42,17 @@ const KNOWN_CRITIC_KINDS = new Set(["openai-code"]);
39
42
 
40
43
  const PATH_COMPONENT_RE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
41
44
 
45
+ const SUBAGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
46
+
47
+ const KNOWN_FALLBACK_REASONS = new Set([
48
+ "context_unavailable",
49
+ "not_found",
50
+ "no_runner",
51
+ "resume_failed",
52
+ "process_restart",
53
+ "missing_record",
54
+ ]);
55
+
42
56
  class RalplanCommandError extends Error {
43
57
  constructor(
44
58
  public readonly exitStatus: number,
@@ -57,6 +71,12 @@ const VALUE_FLAGS = new Set([
57
71
  "--session-id",
58
72
  "--architect",
59
73
  "--critic",
74
+ "--planner-id",
75
+ "--planner-resumable",
76
+ "--fallback-reason",
77
+ "--fallback-attempted-id",
78
+ "--fallback-stage-n",
79
+ "--fallback-receipt-path",
60
80
  ]);
61
81
 
62
82
  function flagValue(args: readonly string[], flag: string): string | undefined {
@@ -145,37 +165,188 @@ function ralplanStatePath(cwd: string, sessionId: string | undefined): string {
145
165
  }
146
166
 
147
167
  async function readActiveRunId(cwd: string, sessionId: string | undefined): Promise<string | undefined> {
148
- try {
149
- const raw = await fs.readFile(ralplanStatePath(cwd, sessionId), "utf-8");
150
- const parsed = JSON.parse(raw) as { run_id?: unknown };
151
- const candidate = typeof parsed.run_id === "string" ? parsed.run_id.trim() : "";
152
- if (!candidate) return undefined;
153
- assertSafePathComponent(candidate, "run-id");
154
- return candidate;
155
- } catch {
156
- return undefined;
168
+ const statePath = ralplanStatePath(cwd, sessionId);
169
+ const existingRead = await readExistingStateForMutation(statePath);
170
+ if (existingRead.kind === "absent") return undefined;
171
+ if (existingRead.kind === "corrupt") {
172
+ throw new RalplanCommandError(
173
+ 2,
174
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to overwrite ${statePath}`,
175
+ );
157
176
  }
177
+ const candidate = typeof existingRead.value.run_id === "string" ? existingRead.value.run_id.trim() : "";
178
+ if (!candidate) return undefined;
179
+ assertSafePathComponent(candidate, "run-id");
180
+ return candidate;
158
181
  }
159
182
 
160
183
  async function persistActiveRunId(cwd: string, sessionId: string | undefined, runId: string): Promise<void> {
161
184
  const statePath = ralplanStatePath(cwd, sessionId);
162
- let existing: Record<string, unknown> = {};
163
- try {
164
- const raw = await fs.readFile(statePath, "utf-8");
165
- const parsed = JSON.parse(raw);
166
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
167
- existing = parsed as Record<string, unknown>;
168
- }
169
- } catch {
170
- // fresh receipt; fall through to create
185
+ const existingRead = await readExistingStateForMutation(statePath);
186
+ if (existingRead.kind === "corrupt") {
187
+ throw new RalplanCommandError(
188
+ 2,
189
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to overwrite ${statePath}`,
190
+ );
171
191
  }
172
- if (existing.run_id === runId) return;
192
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
193
+
194
+ if (existing.run_id === runId && existing.version === WORKFLOW_STATE_VERSION) return;
173
195
  existing.run_id = runId;
174
196
  if (typeof existing.skill !== "string") existing.skill = "ralplan";
175
197
  if (typeof existing.active !== "boolean") existing.active = true;
198
+ if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
199
+ existing = migrateWorkflowState(existing, "ralplan").state;
176
200
  existing.updated_at = new Date().toISOString();
177
- await writeJsonAtomic(statePath, existing, {
201
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
178
202
  cwd,
203
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan persist-run-id", sessionId },
204
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan" },
205
+ });
206
+ }
207
+
208
+ /* --------------------------- planner run-state --------------------------- */
209
+
210
+ interface PlannerStateUpdate {
211
+ subagentId?: string;
212
+ resumable?: boolean;
213
+ fallbackReason?: string;
214
+ fallbackAttemptedId?: string;
215
+ fallbackStageN?: number;
216
+ fallbackReceiptPath?: string;
217
+ }
218
+
219
+ function parseBooleanFlag(raw: string, flag: string): boolean {
220
+ if (raw === "true") return true;
221
+ if (raw === "false") return false;
222
+ throw new RalplanCommandError(2, `invalid ${flag}: ${raw}. Expected "true" or "false".`);
223
+ }
224
+
225
+ function assertSubagentId(value: string, label: string): void {
226
+ if (!SUBAGENT_ID_RE.test(value)) {
227
+ throw new RalplanCommandError(2, `invalid ${label}: ${value}`);
228
+ }
229
+ }
230
+
231
+ function plannerFlagValue(args: readonly string[], flag: string): string | undefined {
232
+ const value = flagValue(args, flag);
233
+ if (value === undefined && hasFlag(args, flag)) {
234
+ throw new RalplanCommandError(2, `missing value for ${flag}.`);
235
+ }
236
+ return value;
237
+ }
238
+
239
+ /**
240
+ * Parse the optional persisted-Planner metadata flags that may ride alongside a
241
+ * `--write`. Returns `undefined` when none are present so existing writes are
242
+ * unaffected. Throws `RalplanCommandError` on any malformed value. This records
243
+ * a same-session audit/routing hint, not a durable subagent registry.
244
+ */
245
+ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | undefined {
246
+ const subagentId = plannerFlagValue(args, "--planner-id");
247
+ const resumableRaw = plannerFlagValue(args, "--planner-resumable");
248
+ const fallbackReason = plannerFlagValue(args, "--fallback-reason");
249
+ const fallbackAttemptedId = plannerFlagValue(args, "--fallback-attempted-id");
250
+ const fallbackStageNRaw = plannerFlagValue(args, "--fallback-stage-n");
251
+ const fallbackReceiptPath = plannerFlagValue(args, "--fallback-receipt-path");
252
+
253
+ const anyPresent = [
254
+ subagentId,
255
+ resumableRaw,
256
+ fallbackReason,
257
+ fallbackAttemptedId,
258
+ fallbackStageNRaw,
259
+ fallbackReceiptPath,
260
+ ].some(value => value !== undefined);
261
+ if (!anyPresent) return undefined;
262
+
263
+ const update: PlannerStateUpdate = {};
264
+
265
+ if (subagentId !== undefined) {
266
+ assertSubagentId(subagentId, "--planner-id");
267
+ update.subagentId = subagentId;
268
+ }
269
+ if (resumableRaw !== undefined) {
270
+ update.resumable = parseBooleanFlag(resumableRaw, "--planner-resumable");
271
+ }
272
+
273
+ const anyFallback = [fallbackReason, fallbackAttemptedId, fallbackStageNRaw, fallbackReceiptPath].some(
274
+ value => value !== undefined,
275
+ );
276
+ if (anyFallback) {
277
+ if (!fallbackReason) {
278
+ throw new RalplanCommandError(2, "--fallback-reason is required when recording planner fallback metadata.");
279
+ }
280
+ if (!KNOWN_FALLBACK_REASONS.has(fallbackReason)) {
281
+ throw new RalplanCommandError(
282
+ 2,
283
+ `invalid --fallback-reason: ${fallbackReason}. Expected one of: ${[...KNOWN_FALLBACK_REASONS].join(", ")}.`,
284
+ );
285
+ }
286
+ update.fallbackReason = fallbackReason;
287
+ if (fallbackAttemptedId === undefined) {
288
+ throw new RalplanCommandError(
289
+ 2,
290
+ "--fallback-attempted-id is required when recording planner fallback metadata.",
291
+ );
292
+ }
293
+ assertSubagentId(fallbackAttemptedId, "--fallback-attempted-id");
294
+ update.fallbackAttemptedId = fallbackAttemptedId;
295
+ if (fallbackStageNRaw === undefined) {
296
+ throw new RalplanCommandError(2, "--fallback-stage-n is required when recording planner fallback metadata.");
297
+ }
298
+ update.fallbackStageN = parseStageN(fallbackStageNRaw);
299
+ if (fallbackReceiptPath !== undefined) {
300
+ if (fallbackReceiptPath.trim() === "") {
301
+ throw new RalplanCommandError(2, "--fallback-receipt-path must not be empty.");
302
+ }
303
+ update.fallbackReceiptPath = fallbackReceiptPath;
304
+ }
305
+ }
306
+
307
+ return update;
308
+ }
309
+
310
+ /** Snake-case projection of a PlannerStateUpdate for state JSON + receipts. Omitted fields stay absent — an unknown `planner_resumable` is encoded by omission, never literal null. */
311
+ function plannerStatePayload(update: PlannerStateUpdate): Record<string, unknown> {
312
+ const payload: Record<string, unknown> = {};
313
+ if (update.subagentId !== undefined) payload.planner_subagent_id = update.subagentId;
314
+ if (update.resumable !== undefined) payload.planner_resumable = update.resumable;
315
+ if (update.fallbackReason !== undefined) payload.planner_fallback_reason = update.fallbackReason;
316
+ if (update.fallbackAttemptedId !== undefined) payload.planner_fallback_attempted_id = update.fallbackAttemptedId;
317
+ if (update.fallbackStageN !== undefined) payload.planner_fallback_stage_n = update.fallbackStageN;
318
+ if (update.fallbackReceiptPath !== undefined) payload.planner_fallback_receipt_path = update.fallbackReceiptPath;
319
+ return payload;
320
+ }
321
+
322
+ /**
323
+ * Merge persisted-Planner metadata into the ralplan run-state JSON. Same-session
324
+ * audit/routing hint only — it records what the caller has already proven and is
325
+ * NOT a durable cross-process subagent registry.
326
+ */
327
+ async function applyPlannerStateUpdate(
328
+ cwd: string,
329
+ sessionId: string | undefined,
330
+ update: PlannerStateUpdate,
331
+ ): Promise<void> {
332
+ const statePath = ralplanStatePath(cwd, sessionId);
333
+ const existingRead = await readExistingStateForMutation(statePath);
334
+ if (existingRead.kind === "corrupt") {
335
+ throw new RalplanCommandError(
336
+ 2,
337
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to overwrite ${statePath}`,
338
+ );
339
+ }
340
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
341
+ Object.assign(existing, plannerStatePayload(update));
342
+ if (typeof existing.skill !== "string") existing.skill = "ralplan";
343
+ if (typeof existing.active !== "boolean") existing.active = true;
344
+ if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
345
+ existing = migrateWorkflowState(existing, "ralplan").state;
346
+ existing.updated_at = new Date().toISOString();
347
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
348
+ cwd,
349
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan planner-state", sessionId },
179
350
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan" },
180
351
  });
181
352
  }
@@ -296,8 +467,12 @@ async function syncRalplanHud(options: {
296
467
  }
297
468
 
298
469
  async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
470
+ const plannerState = parsePlannerStateArgs(args);
299
471
  const resolved = await resolveArtifactArgs(args, cwd);
300
472
  const persisted = await persistArtifact(resolved, cwd);
473
+ if (plannerState) {
474
+ await applyPlannerStateUpdate(cwd, resolved.sessionId, plannerState);
475
+ }
301
476
  await syncRalplanHud({
302
477
  cwd,
303
478
  sessionId: resolved.sessionId,
@@ -315,6 +490,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
315
490
  created_at: persisted.createdAt,
316
491
  };
317
492
  if (persisted.pendingApprovalPath) payload.pending_approval_path = persisted.pendingApprovalPath;
493
+ if (plannerState) payload.planner_state = plannerStatePayload(plannerState);
318
494
  const stdout = resolved.json
319
495
  ? `${JSON.stringify(payload, null, 2)}\n`
320
496
  : `Persisted ralplan ${persisted.stage} stage ${persisted.stageN} at ${persisted.path}.\n`;
@@ -406,6 +582,7 @@ async function seedRalplanState(
406
582
  active: true,
407
583
  current_phase: "planner",
408
584
  skill: "ralplan",
585
+ version: WORKFLOW_STATE_VERSION,
409
586
  mode: resolved.deliberate ? "deliberate" : "short",
410
587
  interactive: resolved.interactive,
411
588
  task: resolved.task,
@@ -415,8 +592,15 @@ async function seedRalplanState(
415
592
  if (resolved.architectKind) payload.architect_kind = resolved.architectKind;
416
593
  if (resolved.criticKind) payload.critic_kind = resolved.criticKind;
417
594
  if (resolved.sessionId) payload.session_id = resolved.sessionId;
418
- await writeJsonAtomic(statePath, payload, {
595
+ await writeWorkflowEnvelopeAtomic(statePath, payload, {
419
596
  cwd,
597
+ receipt: {
598
+ cwd,
599
+ skill: "ralplan",
600
+ owner: "gjc-runtime",
601
+ command: "gjc ralplan seed",
602
+ sessionId: resolved.sessionId,
603
+ },
420
604
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan" },
421
605
  });
422
606
  return { statePath, runId };
@@ -441,26 +625,19 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
441
625
  const summary = {
442
626
  skill: "ralplan",
443
627
  mode,
444
- interactive: resolved.interactive,
445
- architect: resolved.architectKind ?? "default",
446
- critic: resolved.criticKind ?? "default",
447
- task: resolved.task,
448
628
  state_path: statePath,
449
629
  run_id: runId,
450
- handoff: "Run `/skill:ralplan` inside the GJC agent to drive the Planner / Architect / Critic consensus loop.",
630
+ handoff: "/skill:ralplan",
451
631
  };
452
632
  const stdout = resolved.json
453
- ? `${JSON.stringify(summary, null, 2)}\n`
633
+ ? renderCliWriteReceipt({ ok: true, ...summary })
454
634
  : [
455
- `Seeded ralplan ${summary.mode} run (${resolved.interactive ? "interactive" : "automated"}) at ${statePath}.`,
456
- `Active run_id: ${runId}`,
457
- resolved.architectKind ? `Architect: ${resolved.architectKind}` : undefined,
458
- resolved.criticKind ? `Critic: ${resolved.criticKind}` : undefined,
459
- "Run `/skill:ralplan` inside the GJC agent to execute the consensus loop.",
635
+ `ralplan seed run_id=${runId}`,
636
+ `state_path=${statePath}`,
637
+ `mode=${mode} interactive=${resolved.interactive} architect=${resolved.architectKind ?? "default"} critic=${resolved.criticKind ?? "default"}`,
638
+ "handoff=/skill:ralplan",
460
639
  "",
461
- ]
462
- .filter((line): line is string => Boolean(line))
463
- .join("\n");
640
+ ].join("\n");
464
641
  return { status: 0, stdout };
465
642
  }
466
643
 
@@ -1,7 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { CanonicalGjcWorkflowSkill } from "../skill-state/active-state";
3
3
  import { initialPhaseForSkill } from "../skill-state/initial-phase";
4
- import { canonicalWorkflowSkill, WORKFLOW_STATE_RECEIPT_VERSION } from "../skill-state/workflow-state-contract";
4
+ import {
5
+ canonicalWorkflowSkill,
6
+ WORKFLOW_STATE_RECEIPT_VERSION,
7
+ WORKFLOW_STATE_VERSION,
8
+ } from "../skill-state/workflow-state-contract";
5
9
  import { writeWorkflowEnvelopeAtomic } from "./state-writer";
6
10
  import { getSkillManifest } from "./workflow-manifest";
7
11
 
@@ -21,6 +25,17 @@ export interface MigrateAndPersistLegacyStateResult {
21
25
  migrated: boolean;
22
26
  path: string;
23
27
  }
28
+ export interface MigrateWorkflowStateResult {
29
+ state: Record<string, unknown>;
30
+ fromVersion: number;
31
+ toVersion: number;
32
+ changed: boolean;
33
+ }
34
+
35
+ export type WorkflowStateMigration = (
36
+ state: Record<string, unknown>,
37
+ skill: CanonicalGjcWorkflowSkill,
38
+ ) => Record<string, unknown>;
24
39
 
25
40
  const RECEIPT_STRING_FIELDS = [
26
41
  "command",
@@ -75,6 +90,41 @@ function receiptWithRequiredFields(raw: unknown, skill: CanonicalGjcWorkflowSkil
75
90
  function recordsEqual(left: Record<string, unknown>, right: Record<string, unknown>): boolean {
76
91
  return JSON.stringify(left) === JSON.stringify(right);
77
92
  }
93
+ function migrateV1ToV2(state: Record<string, unknown>, skill: CanonicalGjcWorkflowSkill): Record<string, unknown> {
94
+ const migrated = cloneRecord(state);
95
+ migrated.version = WORKFLOW_STATE_VERSION;
96
+ migrated.skill = skill;
97
+
98
+ const sourcePhase = typeof migrated.current_phase === "string" ? migrated.current_phase : migrated.phase;
99
+ const normalizedPhase = normalizePhase(skill, sourcePhase);
100
+ migrated.current_phase = normalizedPhase;
101
+ if ("phase" in migrated && typeof migrated.phase === "string") migrated.phase = normalizedPhase;
102
+
103
+ return migrated;
104
+ }
105
+
106
+ const MIGRATIONS: Record<number, WorkflowStateMigration> = {
107
+ 1: migrateV1ToV2,
108
+ };
109
+
110
+ export function migrateWorkflowState(raw: Record<string, unknown>, skill: string): MigrateWorkflowStateResult {
111
+ const canonicalSkill = canonicalSkillOrThrow(skill);
112
+ const fromVersion = typeof raw.version === "number" ? raw.version : 1;
113
+ if (fromVersion >= WORKFLOW_STATE_VERSION) {
114
+ return { state: raw, fromVersion, toVersion: fromVersion, changed: false };
115
+ }
116
+
117
+ let version = fromVersion;
118
+ let state = raw;
119
+ let changed = false;
120
+ while (version < WORKFLOW_STATE_VERSION && MIGRATIONS[version]) {
121
+ state = MIGRATIONS[version](state, canonicalSkill);
122
+ version += 1;
123
+ changed = true;
124
+ }
125
+
126
+ return { state, fromVersion, toVersion: version, changed };
127
+ }
78
128
 
79
129
  /**
80
130
  * Pure legacy state normalizer for background/internal readers.
@@ -90,14 +140,11 @@ export function normalizeLegacyState(raw: Record<string, unknown>, skill: string
90
140
  state.skill = canonicalSkill;
91
141
  if (typeof state.version !== "number") state.version = 1;
92
142
  if (typeof state.active !== "boolean") state.active = true;
93
-
94
- const sourcePhase = typeof state.current_phase === "string" ? state.current_phase : state.phase;
95
- const normalizedPhase = normalizePhase(canonicalSkill, sourcePhase);
96
- state.current_phase = normalizedPhase;
97
- if ("phase" in state && typeof state.phase === "string") state.phase = normalizedPhase;
143
+ if (typeof state.updated_at !== "string") state.updated_at = new Date().toISOString();
98
144
  state.receipt = receiptWithRequiredFields(state.receipt, canonicalSkill);
99
145
 
100
- return { state, changed: !recordsEqual(raw, state) };
146
+ const migrated = migrateWorkflowState(state, canonicalSkill).state;
147
+ return { state: migrated, changed: !recordsEqual(raw, migrated) };
101
148
  }
102
149
 
103
150
  export async function migrateAndPersistLegacyState(