@gajae-code/coding-agent 0.3.0 → 0.3.2

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 (213) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +7 -0
  4. package/dist/types/cli/args.d.ts +3 -1
  5. package/dist/types/commands/deep-interview.d.ts +3 -0
  6. package/dist/types/commands/launch.d.ts +6 -0
  7. package/dist/types/config/keybindings.d.ts +5 -0
  8. package/dist/types/config/model-profile-activation.d.ts +30 -0
  9. package/dist/types/config/model-profiles.d.ts +19 -0
  10. package/dist/types/config/model-registry.d.ts +8 -0
  11. package/dist/types/config/model-resolver.d.ts +1 -1
  12. package/dist/types/config/models-config-schema.d.ts +47 -0
  13. package/dist/types/config/settings-schema.d.ts +14 -4
  14. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  15. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  16. package/dist/types/deep-interview/render-middleware.d.ts +1 -0
  17. package/dist/types/eval/py/executor.d.ts +2 -0
  18. package/dist/types/eval/py/kernel.d.ts +2 -0
  19. package/dist/types/exec/bash-executor.d.ts +10 -0
  20. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  21. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  22. package/dist/types/gjc-runtime/state-migrations.d.ts +9 -0
  23. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  24. package/dist/types/gjc-runtime/state-writer.d.ts +10 -0
  25. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -1
  26. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  27. package/dist/types/harness-control-plane/control-endpoint.d.ts +3 -2
  28. package/dist/types/hooks/skill-state.d.ts +21 -0
  29. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  30. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  31. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  32. package/dist/types/internal-urls/types.d.ts +4 -0
  33. package/dist/types/lsp/index.d.ts +10 -10
  34. package/dist/types/main.d.ts +10 -1
  35. package/dist/types/modes/bridge/auth.d.ts +12 -0
  36. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  37. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  38. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  39. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  40. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  41. package/dist/types/modes/components/custom-provider-wizard.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  43. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  44. package/dist/types/modes/components/model-selector.d.ts +6 -1
  45. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  46. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  47. package/dist/types/modes/components/status-line.d.ts +2 -0
  48. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  49. package/dist/types/modes/controllers/selector-controller.d.ts +9 -0
  50. package/dist/types/modes/index.d.ts +1 -0
  51. package/dist/types/modes/interactive-mode.d.ts +1 -0
  52. package/dist/types/modes/jobs-observer.d.ts +57 -0
  53. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  54. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  55. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  56. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  57. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  58. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  59. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  60. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  61. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  62. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  63. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  64. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  65. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  66. package/dist/types/modes/types.d.ts +2 -0
  67. package/dist/types/sdk.d.ts +3 -1
  68. package/dist/types/session/agent-session.d.ts +11 -1
  69. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -2
  70. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  71. package/dist/types/task/executor.d.ts +1 -0
  72. package/dist/types/task/id.d.ts +7 -0
  73. package/dist/types/task/index.d.ts +5 -0
  74. package/dist/types/task/receipt.d.ts +85 -0
  75. package/dist/types/task/spawn-gate.d.ts +38 -0
  76. package/dist/types/task/types.d.ts +143 -11
  77. package/dist/types/tools/cron.d.ts +6 -0
  78. package/dist/types/tools/hindsight-recall.d.ts +0 -2
  79. package/dist/types/tools/hindsight-reflect.d.ts +0 -2
  80. package/dist/types/tools/hindsight-retain.d.ts +0 -2
  81. package/dist/types/tools/index.d.ts +6 -4
  82. package/dist/types/tools/path-utils.d.ts +1 -0
  83. package/dist/types/tools/subagent.d.ts +15 -0
  84. package/package.json +7 -7
  85. package/scripts/build-binary.ts +7 -0
  86. package/src/async/job-manager.ts +36 -0
  87. package/src/cli/args.ts +19 -2
  88. package/src/commands/deep-interview.ts +1 -0
  89. package/src/commands/harness.ts +289 -19
  90. package/src/commands/launch.ts +10 -2
  91. package/src/commands/state.ts +2 -1
  92. package/src/commands/team.ts +22 -4
  93. package/src/config/keybindings.ts +6 -0
  94. package/src/config/model-profile-activation.ts +157 -0
  95. package/src/config/model-profiles.ts +155 -0
  96. package/src/config/model-registry.ts +19 -0
  97. package/src/config/model-resolver.ts +3 -2
  98. package/src/config/models-config-schema.ts +36 -0
  99. package/src/config/settings-schema.ts +16 -3
  100. package/src/dap/client.ts +17 -3
  101. package/src/debug/crash-diagnostics.ts +223 -0
  102. package/src/debug/runtime-gauges.ts +20 -0
  103. package/src/deep-interview/render-middleware.ts +6 -0
  104. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  105. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  106. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -3
  107. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +61 -0
  108. package/src/defaults/gjc-defaults.ts +7 -0
  109. package/src/eval/py/executor.ts +21 -1
  110. package/src/eval/py/kernel.ts +15 -0
  111. package/src/exec/bash-executor.ts +41 -0
  112. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  113. package/src/gjc-runtime/deep-interview-runtime.ts +69 -32
  114. package/src/gjc-runtime/ralplan-runtime.ts +213 -36
  115. package/src/gjc-runtime/state-migrations.ts +54 -7
  116. package/src/gjc-runtime/state-runtime.ts +461 -64
  117. package/src/gjc-runtime/state-schema.ts +192 -0
  118. package/src/gjc-runtime/state-writer.ts +32 -1
  119. package/src/gjc-runtime/team-runtime.ts +177 -105
  120. package/src/gjc-runtime/ultragoal-runtime.ts +231 -38
  121. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  122. package/src/gjc-runtime/workflow-manifest.generated.json +108 -4
  123. package/src/gjc-runtime/workflow-manifest.ts +3 -1
  124. package/src/harness-control-plane/control-endpoint.ts +19 -8
  125. package/src/harness-control-plane/owner.ts +57 -10
  126. package/src/harness-control-plane/state-machine.ts +2 -1
  127. package/src/hooks/skill-state.ts +176 -26
  128. package/src/internal-urls/agent-protocol.ts +68 -21
  129. package/src/internal-urls/artifact-protocol.ts +12 -17
  130. package/src/internal-urls/docs-index.generated.ts +8 -10
  131. package/src/internal-urls/registry-helpers.ts +19 -16
  132. package/src/internal-urls/types.ts +4 -0
  133. package/src/lsp/client.ts +18 -2
  134. package/src/main.ts +88 -6
  135. package/src/modes/bridge/auth.ts +41 -0
  136. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  137. package/src/modes/bridge/bridge-mode.ts +520 -0
  138. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  139. package/src/modes/bridge/event-stream.ts +70 -0
  140. package/src/modes/components/custom-editor.ts +101 -0
  141. package/src/modes/components/custom-provider-wizard.ts +318 -0
  142. package/src/modes/components/hook-selector.ts +61 -18
  143. package/src/modes/components/jobs-overlay-model.ts +109 -0
  144. package/src/modes/components/jobs-overlay.ts +172 -0
  145. package/src/modes/components/model-selector.ts +108 -18
  146. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  147. package/src/modes/components/status-line/presets.ts +7 -5
  148. package/src/modes/components/status-line/segments.ts +25 -0
  149. package/src/modes/components/status-line/types.ts +2 -0
  150. package/src/modes/components/status-line.ts +9 -1
  151. package/src/modes/controllers/extension-ui-controller.ts +39 -3
  152. package/src/modes/controllers/input-controller.ts +97 -9
  153. package/src/modes/controllers/selector-controller.ts +86 -1
  154. package/src/modes/index.ts +1 -0
  155. package/src/modes/interactive-mode.ts +27 -0
  156. package/src/modes/jobs-observer.ts +204 -0
  157. package/src/modes/rpc/host-tools.ts +1 -186
  158. package/src/modes/rpc/host-uris.ts +1 -235
  159. package/src/modes/rpc/rpc-client.ts +25 -10
  160. package/src/modes/rpc/rpc-mode.ts +12 -381
  161. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  162. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  163. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  164. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  165. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  166. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  167. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  168. package/src/modes/shared/agent-wire/responses.ts +17 -0
  169. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  170. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  171. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  172. package/src/modes/types.ts +2 -0
  173. package/src/prompts/memories/consolidation.md +1 -1
  174. package/src/prompts/memories/read-path.md +6 -7
  175. package/src/prompts/memories/unavailable.md +2 -2
  176. package/src/prompts/tools/bash.md +1 -1
  177. package/src/prompts/tools/irc.md +1 -1
  178. package/src/prompts/tools/read.md +2 -2
  179. package/src/prompts/tools/recall.md +1 -0
  180. package/src/prompts/tools/reflect.md +1 -0
  181. package/src/prompts/tools/retain.md +1 -0
  182. package/src/prompts/tools/subagent.md +12 -7
  183. package/src/prompts/tools/task-summary.md +3 -9
  184. package/src/prompts/tools/task.md +5 -1
  185. package/src/sdk.ts +5 -1
  186. package/src/session/agent-session.ts +214 -38
  187. package/src/skill-state/deep-interview-mutation-guard.ts +23 -4
  188. package/src/skill-state/workflow-state-contract.ts +7 -4
  189. package/src/skill-state/workflow-state-version.ts +3 -0
  190. package/src/slash-commands/builtin-registry.ts +9 -1
  191. package/src/task/executor.ts +31 -5
  192. package/src/task/id.ts +33 -0
  193. package/src/task/index.ts +259 -67
  194. package/src/task/output-manager.ts +5 -4
  195. package/src/task/receipt.ts +297 -0
  196. package/src/task/render.ts +48 -131
  197. package/src/task/spawn-gate.ts +132 -0
  198. package/src/task/types.ts +48 -7
  199. package/src/tools/ask.ts +73 -33
  200. package/src/tools/ast-edit.ts +1 -0
  201. package/src/tools/ast-grep.ts +1 -0
  202. package/src/tools/bash.ts +1 -1
  203. package/src/tools/cron.ts +48 -0
  204. package/src/tools/find.ts +4 -1
  205. package/src/tools/hindsight-recall.ts +0 -2
  206. package/src/tools/hindsight-reflect.ts +0 -2
  207. package/src/tools/hindsight-retain.ts +0 -2
  208. package/src/tools/index.ts +6 -18
  209. package/src/tools/path-utils.ts +3 -2
  210. package/src/tools/read.ts +4 -3
  211. package/src/tools/search.ts +1 -0
  212. package/src/tools/skill.ts +6 -1
  213. package/src/tools/subagent.ts +237 -84
@@ -21,10 +21,12 @@ import {
21
21
  buildWorkflowStateReceipt,
22
22
  canonicalWorkflowSkill,
23
23
  describeWorkflowStateContract,
24
+ WORKFLOW_STATE_VERSION,
24
25
  type WorkflowStateReceipt,
25
26
  } from "../skill-state/workflow-state-contract";
27
+ import { renderCliWriteReceipt } from "./cli-write-receipt";
26
28
  import { renderStateGraph, type StateGraphFormat } from "./state-graph";
27
- import { migrateAndPersistLegacyState } from "./state-migrations";
29
+ import { migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
28
30
  import {
29
31
  buildStateStatusSummary,
30
32
  compactProjectStateJson,
@@ -44,6 +46,7 @@ import {
44
46
  detectWorkflowEnvelopeIntegrityMismatch,
45
47
  type GenericHardPruneTarget,
46
48
  hardPrune,
49
+ readExistingStateForMutation,
47
50
  type StateWriterAuditContext,
48
51
  softDelete,
49
52
  updateWorkflowTransactionJournal,
@@ -116,6 +119,7 @@ const ACTION_NAMES = new Set([
116
119
  "gc",
117
120
  "migrate",
118
121
  "status",
122
+ "doctor",
119
123
  ]);
120
124
  const BOOLEAN_FLAGS = new Set([
121
125
  "--json",
@@ -174,7 +178,18 @@ function assertKnownFlags(args: readonly string[], parsed: ParsedInvocation): vo
174
178
  }
175
179
 
176
180
  interface ParsedInvocation {
177
- action: "read" | "write" | "clear" | "contract" | "handoff" | "graph" | "prune" | "gc" | "migrate" | "status";
181
+ action:
182
+ | "read"
183
+ | "write"
184
+ | "clear"
185
+ | "contract"
186
+ | "handoff"
187
+ | "graph"
188
+ | "prune"
189
+ | "gc"
190
+ | "migrate"
191
+ | "status"
192
+ | "doctor";
178
193
  positionalSkill?: string;
179
194
  }
180
195
 
@@ -344,7 +359,8 @@ async function readJsonFile(filePath: string): Promise<Record<string, unknown> |
344
359
  } catch (error) {
345
360
  const err = error as NodeJS.ErrnoException;
346
361
  if (err.code === "ENOENT") return null;
347
- throw new StateCommandError(1, `failed to read ${filePath}: ${err.message}`);
362
+ process.stderr.write(`WARNING: failed to read ${filePath}; ignoring corrupt state: ${err.message}\n`);
363
+ return null;
348
364
  }
349
365
  }
350
366
 
@@ -354,8 +370,285 @@ async function readJsonValue(filePath: string): Promise<unknown | null> {
354
370
  } catch (error) {
355
371
  const err = error as NodeJS.ErrnoException;
356
372
  if (err.code === "ENOENT") return null;
357
- throw new StateCommandError(1, `failed to read ${filePath}: ${err.message}`);
373
+ process.stderr.write(`WARNING: failed to read ${filePath}; ignoring corrupt state: ${err.message}\n`);
374
+ return null;
375
+ }
376
+ }
377
+
378
+ type DoctorProblemType = "orphan_journal" | "checksum_mismatch" | "schema_violation" | "stale_active_state";
379
+
380
+ interface DoctorProblem {
381
+ type: DoctorProblemType;
382
+ skill?: CanonicalGjcWorkflowSkill;
383
+ path: string;
384
+ message: string;
385
+ fixCommand: string;
386
+ }
387
+
388
+ interface DoctorSummary {
389
+ ok: boolean;
390
+ root: string;
391
+ summary: {
392
+ skills_scanned: number;
393
+ files_scanned: number;
394
+ journals_scanned: number;
395
+ findings_total: number;
396
+ by_kind: Record<DoctorProblemType, number>;
397
+ };
398
+ problems: DoctorProblem[];
399
+ }
400
+
401
+ async function readRawJson(filePath: string): Promise<{ exists: boolean; value?: unknown; error?: string }> {
402
+ try {
403
+ return { exists: true, value: JSON.parse(await fs.readFile(filePath, "utf-8")) };
404
+ } catch (error) {
405
+ const err = error as NodeJS.ErrnoException;
406
+ if (err.code === "ENOENT") return { exists: false };
407
+ return { exists: true, error: err.message };
408
+ }
409
+ }
410
+
411
+ async function listJsonFiles(dir: string): Promise<string[]> {
412
+ let entries: string[];
413
+ try {
414
+ entries = await fs.readdir(dir);
415
+ } catch (error) {
416
+ const err = error as NodeJS.ErrnoException;
417
+ if (err.code === "ENOENT") return [];
418
+ throw error;
419
+ }
420
+ return entries
421
+ .filter(entry => entry.endsWith(".json"))
422
+ .sort()
423
+ .map(entry => path.join(dir, entry));
424
+ }
425
+
426
+ function doctorProblem(
427
+ type: DoctorProblemType,
428
+ pathValue: string,
429
+ message: string,
430
+ fixCommand: string,
431
+ skill?: CanonicalGjcWorkflowSkill,
432
+ ): DoctorProblem {
433
+ return skill
434
+ ? { type, skill, path: pathValue, message, fixCommand }
435
+ : { type, path: pathValue, message, fixCommand };
436
+ }
437
+
438
+ function activeEntryDir(cwd: string, sessionId: string | undefined): string {
439
+ return path.join(stateDirFor(cwd, sessionId), "active");
440
+ }
441
+
442
+ function skillFromActiveValue(value: unknown): string | undefined {
443
+ return isPlainObject(value) && typeof value.skill === "string" ? value.skill : undefined;
444
+ }
445
+
446
+ function activeFlag(value: unknown): boolean {
447
+ return isPlainObject(value) && value.active !== false;
448
+ }
449
+
450
+ async function collectDoctorSummary(
451
+ cwd: string,
452
+ skill: CanonicalGjcWorkflowSkill | undefined,
453
+ sessionId: string | undefined,
454
+ ): Promise<DoctorSummary> {
455
+ const root = path.join(cwd, ".gjc", "state");
456
+ const skills = skill ? [skill] : [...CANONICAL_GJC_WORKFLOW_SKILLS];
457
+ const problems: DoctorProblem[] = [];
458
+ let filesScanned = 0;
459
+ let journalsScanned = 0;
460
+
461
+ for (const currentSkill of skills) {
462
+ const filePath = modeStateFile(cwd, currentSkill, sessionId);
463
+ const raw = await readRawJson(filePath);
464
+ if (!raw.exists) continue;
465
+ filesScanned += 1;
466
+ if (raw.error) {
467
+ problems.push(
468
+ doctorProblem(
469
+ "schema_violation",
470
+ filePath,
471
+ `mode-state JSON is unreadable: ${raw.error}`,
472
+ `gjc state ${currentSkill} migrate`,
473
+ currentSkill,
474
+ ),
475
+ );
476
+ continue;
477
+ }
478
+ const validation = validateWorkflowStateEnvelope(currentSkill, raw.value);
479
+ if (!validation.valid) {
480
+ problems.push(
481
+ doctorProblem(
482
+ "schema_violation",
483
+ filePath,
484
+ validation.error ?? `invalid ${currentSkill} state envelope`,
485
+ `gjc state ${currentSkill} migrate`,
486
+ currentSkill,
487
+ ),
488
+ );
489
+ }
490
+ const mismatch = await detectWorkflowEnvelopeIntegrityMismatch(filePath);
491
+ if (mismatch) {
492
+ problems.push(
493
+ doctorProblem(
494
+ "checksum_mismatch",
495
+ filePath,
496
+ `expected sha256 ${mismatch.expected} but found ${mismatch.actual}`,
497
+ `gjc state ${currentSkill} migrate`,
498
+ currentSkill,
499
+ ),
500
+ );
501
+ }
502
+ }
503
+
504
+ const journalFiles = await listJsonFiles(path.join(root, "transactions"));
505
+ for (const journalPath of journalFiles) {
506
+ journalsScanned += 1;
507
+ const raw = await readRawJson(journalPath);
508
+ const value = raw.value;
509
+ const status = isPlainObject(value) && typeof value.status === "string" ? value.status : undefined;
510
+ const paths =
511
+ isPlainObject(value) && Array.isArray(value.paths) ? value.paths.filter(p => typeof p === "string") : [];
512
+ const hasLiveMutation = status === "pending" && paths.some(filePath => path.resolve(filePath).startsWith(root));
513
+ if (!hasLiveMutation) {
514
+ problems.push(
515
+ doctorProblem(
516
+ "orphan_journal",
517
+ journalPath,
518
+ "transaction journal has no matching live mutation",
519
+ "gjc state prune --hard",
520
+ ),
521
+ );
522
+ }
358
523
  }
524
+
525
+ const inspectActiveScope = async (scopeSessionId: string | undefined): Promise<void> => {
526
+ const snapshotPath = activeStateFile(cwd, scopeSessionId);
527
+ const snapshot = await readRawJson(snapshotPath);
528
+ if (snapshot.exists) filesScanned += 1;
529
+ const entryFiles = await listJsonFiles(activeEntryDir(cwd, scopeSessionId));
530
+ const entrySkills = new Set<string>();
531
+ for (const entryPath of entryFiles) {
532
+ filesScanned += 1;
533
+ const entry = await readRawJson(entryPath);
534
+ const entrySkill = skillFromActiveValue(entry.value) ?? path.basename(entryPath, ".json");
535
+ entrySkills.add(entrySkill);
536
+ const canonical = canonicalWorkflowSkill(entrySkill);
537
+ if (canonical && !skills.includes(canonical)) continue;
538
+ const statePath = canonical
539
+ ? modeStateFile(cwd, canonical, scopeSessionId)
540
+ : path.join(root, `${entrySkill}-state.json`);
541
+ const state = await readRawJson(statePath);
542
+ if (activeFlag(entry.value) && (!state.exists || !activeFlag(state.value))) {
543
+ problems.push(
544
+ doctorProblem(
545
+ "stale_active_state",
546
+ entryPath,
547
+ `active entry for ${entrySkill} does not match a live active mode-state`,
548
+ canonical ? `gjc state ${canonical} clear` : "gjc state prune --hard",
549
+ canonical ?? undefined,
550
+ ),
551
+ );
552
+ }
553
+ }
554
+ if (isPlainObject(snapshot.value)) {
555
+ const activeSkills = Array.isArray(snapshot.value.active_skills) ? snapshot.value.active_skills : [];
556
+ for (const entry of activeSkills) {
557
+ const entrySkill = skillFromActiveValue(entry);
558
+ if (!entrySkill) continue;
559
+ const canonical = canonicalWorkflowSkill(entrySkill);
560
+ if (canonical && !skills.includes(canonical)) continue;
561
+ if (activeFlag(entry) && !entrySkills.has(entrySkill)) {
562
+ problems.push(
563
+ doctorProblem(
564
+ "stale_active_state",
565
+ snapshotPath,
566
+ `active snapshot lists ${entrySkill} but no raw per-skill active entry exists`,
567
+ canonical ? `gjc state ${canonical} clear` : "gjc state prune --hard",
568
+ canonical ?? undefined,
569
+ ),
570
+ );
571
+ }
572
+ }
573
+ }
574
+ };
575
+
576
+ await inspectActiveScope(sessionId);
577
+ if (!sessionId) {
578
+ const sessionsDir = path.join(root, "sessions");
579
+ let sessions: string[] = [];
580
+ try {
581
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
582
+ sessions = entries
583
+ .filter(entry => entry.isDirectory())
584
+ .map(entry => entry.name)
585
+ .sort();
586
+ } catch (error) {
587
+ const err = error as NodeJS.ErrnoException;
588
+ if (err.code !== "ENOENT") throw error;
589
+ }
590
+ for (const rawSession of sessions) await inspectActiveScope(decodeURIComponent(rawSession));
591
+ }
592
+
593
+ problems.sort(
594
+ (a, b) =>
595
+ a.type.localeCompare(b.type) || (a.skill ?? "").localeCompare(b.skill ?? "") || a.path.localeCompare(b.path),
596
+ );
597
+ const byKind: Record<DoctorProblemType, number> = {
598
+ orphan_journal: 0,
599
+ checksum_mismatch: 0,
600
+ schema_violation: 0,
601
+ stale_active_state: 0,
602
+ };
603
+ for (const problem of problems) byKind[problem.type] += 1;
604
+ return {
605
+ ok: problems.length === 0,
606
+ root,
607
+ summary: {
608
+ skills_scanned: skills.length,
609
+ files_scanned: filesScanned,
610
+ journals_scanned: journalsScanned,
611
+ findings_total: problems.length,
612
+ by_kind: byKind,
613
+ },
614
+ problems,
615
+ };
616
+ }
617
+
618
+ function renderDoctorText(summary: DoctorSummary): string {
619
+ const lines = [
620
+ `ok: ${summary.ok}`,
621
+ `root: ${summary.root}`,
622
+ `skills_scanned: ${summary.summary.skills_scanned}`,
623
+ `files_scanned: ${summary.summary.files_scanned}`,
624
+ `journals_scanned: ${summary.summary.journals_scanned}`,
625
+ `findings_total: ${summary.summary.findings_total}`,
626
+ `counts: ${Object.entries(summary.summary.by_kind)
627
+ .map(([kind, count]) => `${kind}=${count}`)
628
+ .join(", ")}`,
629
+ ];
630
+ for (const problem of summary.problems) {
631
+ lines.push(
632
+ `finding: kind=${problem.type} skill=${problem.skill ?? "-"} path=${problem.path} message=${problem.message} fix=${problem.fixCommand}`,
633
+ );
634
+ }
635
+ return `${lines.join("\n")}\n`;
636
+ }
637
+
638
+ async function handleDoctor(
639
+ args: readonly string[],
640
+ cwd: string,
641
+ positionalSkill: string | undefined,
642
+ ): Promise<StateCommandResult> {
643
+ const rawSkill = flagValue(args, "--skill")?.trim() || flagValue(args, "--mode")?.trim() || positionalSkill?.trim();
644
+ if (rawSkill) assertKnownMode(rawSkill);
645
+ const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
646
+ if (sessionId) assertSafePathComponent(sessionId, "session-id");
647
+ const summary = await collectDoctorSummary(cwd, rawSkill as CanonicalGjcWorkflowSkill | undefined, sessionId);
648
+ return {
649
+ status: summary.ok ? 0 : 1,
650
+ stdout: hasFlag(args, "--json") ? `${JSON.stringify(summary, null, 2)}\n` : renderDoctorText(summary),
651
+ };
359
652
  }
360
653
 
361
654
  async function warnAndAuditOutOfBandIfNeeded(
@@ -364,7 +657,14 @@ async function warnAndAuditOutOfBandIfNeeded(
364
657
  skill: CanonicalGjcWorkflowSkill,
365
658
  options?: { mutationId?: string; forced?: boolean },
366
659
  ): Promise<string | undefined> {
367
- const mismatch = await detectWorkflowEnvelopeIntegrityMismatch(filePath);
660
+ let mismatch: Awaited<ReturnType<typeof detectWorkflowEnvelopeIntegrityMismatch>>;
661
+ try {
662
+ mismatch = await detectWorkflowEnvelopeIntegrityMismatch(filePath);
663
+ } catch {
664
+ // Unparseable/corrupt state has no recoverable checksum to compare; the strict
665
+ // mutation reader already gates unforced overwrites, so fail-open here.
666
+ return undefined;
667
+ }
368
668
  if (!mismatch) return undefined;
369
669
  const message = `WARNING: workflow mode-state out-of-band edit detected for ${skill}: ${filePath} expected sha256 ${mismatch.expected} but found ${mismatch.actual}`;
370
670
  await appendAuditEntry(cwd, {
@@ -394,7 +694,7 @@ async function writeJsonAtomic(
394
694
  fromPhase?: string;
395
695
  toPhase?: string;
396
696
  },
397
- ): Promise<string | undefined> {
697
+ ): Promise<{ warning?: string; stamped: Record<string, unknown> }> {
398
698
  const warning = options?.skill
399
699
  ? await warnAndAuditOutOfBandIfNeeded(cwd, filePath, options.skill, {
400
700
  mutationId: options.mutationId,
@@ -417,7 +717,7 @@ async function writeJsonAtomic(
417
717
  forced: options?.force ?? false,
418
718
  },
419
719
  });
420
- return warning;
720
+ return { warning, stamped: (await readJsonFile(filePath)) ?? {} };
421
721
  }
422
722
 
423
723
  function parseFieldsFlag(args: readonly string[]): StateProjectionField[] | undefined {
@@ -752,8 +1052,15 @@ async function handleWrite(
752
1052
  );
753
1053
 
754
1054
  const filePath = modeStateFile(cwd, mode, sessionId);
755
- const existingRaw = await readJsonValue(filePath);
756
- const existing = isPlainObject(existingRaw) ? existingRaw : null;
1055
+ const forced = hasFlag(args, "--force");
1056
+ const existingRead = await readExistingStateForMutation(filePath);
1057
+ if (existingRead.kind === "corrupt" && !forced) {
1058
+ throw new StateCommandError(
1059
+ 2,
1060
+ `existing state for ${mode} is corrupt or tampered (${existingRead.error}); use --force to overwrite`,
1061
+ );
1062
+ }
1063
+ const existingPayload = existingRead.kind === "valid" ? existingRead.value : {};
757
1064
  const nowIsoStr = nowIso();
758
1065
  const mutationId = `${mode}:${nowIsoStr}`;
759
1066
  const receipt = buildWorkflowStateReceipt({
@@ -765,10 +1072,6 @@ async function handleWrite(
765
1072
  nowIso: nowIsoStr,
766
1073
  mutationId,
767
1074
  });
768
- if (existingRaw !== null && !isPlainObject(existingRaw)) {
769
- throw new StateCommandError(2, `existing state for ${mode} must be a JSON object before write`);
770
- }
771
- const existingPayload = existing ?? {};
772
1075
  const innerState = (payload.state as Record<string, unknown> | undefined) ?? {};
773
1076
  const incomingPhase =
774
1077
  typeof payload.current_phase === "string" && payload.current_phase.trim()
@@ -797,19 +1100,26 @@ async function handleWrite(
797
1100
  merged.skill = mode;
798
1101
  if (incomingPhase) {
799
1102
  merged.current_phase = incomingPhase;
800
- } else if (typeof merged.current_phase !== "string") {
801
- merged.current_phase =
802
- typeof existingPayload.current_phase === "string" ? existingPayload.current_phase : "active";
1103
+ } else if (typeof merged.current_phase !== "string" || !merged.current_phase.trim()) {
1104
+ const retainedPhase =
1105
+ typeof existingPayload.current_phase === "string" ? existingPayload.current_phase.trim() : "";
1106
+ merged.current_phase = retainedPhase || initialPhaseForSkill(mode);
1107
+ } else {
1108
+ merged.current_phase = merged.current_phase.trim();
803
1109
  }
804
- if (typeof merged.version !== "number") merged.version = 1;
1110
+ merged.version = WORKFLOW_STATE_VERSION;
805
1111
  if (typeof merged.active !== "boolean") merged.active = true;
806
1112
  merged.updated_at = nowIsoStr;
807
1113
  merged.receipt = receipt;
808
1114
  if (sessionId && typeof merged.session_id !== "string") merged.session_id = sessionId;
809
1115
 
810
- const fromPhase = typeof existingPayload.current_phase === "string" ? existingPayload.current_phase : undefined;
811
- const toPhase = typeof merged.current_phase === "string" ? merged.current_phase : undefined;
812
- const forced = hasFlag(args, "--force");
1116
+ const fromPhase =
1117
+ typeof existingPayload.current_phase === "string" ? existingPayload.current_phase.trim() : undefined;
1118
+ const toPhase = merged.current_phase as string;
1119
+ const manifestStates = new Set(getSkillManifest(mode).states.map(state => state.id));
1120
+ if (!manifestStates.has(toPhase) && !forced) {
1121
+ throw new StateCommandError(2, `unknown ${mode} phase "${toPhase}"; use --force to bypass`);
1122
+ }
813
1123
  if (fromPhase && toPhase && isKnownWorkflowState(mode, fromPhase) && isKnownWorkflowState(mode, toPhase)) {
814
1124
  if (!isValidTransition(mode, fromPhase, toPhase) && !forced) {
815
1125
  throw new StateCommandError(
@@ -822,13 +1132,14 @@ async function handleWrite(
822
1132
  const validation = validateWorkflowStateEnvelope(mode, merged);
823
1133
  if (!validation.valid) throw new StateCommandError(2, validation.error ?? `invalid ${mode} state envelope`);
824
1134
 
825
- const outOfBandWarning = await writeJsonAtomic(cwd, filePath, merged, "write", {
1135
+ const { warning: outOfBandWarning, stamped } = await writeJsonAtomic(cwd, filePath, merged, "write", {
826
1136
  skill: mode,
827
1137
  mutationId,
828
1138
  force: forced,
829
1139
  fromPhase,
830
1140
  toPhase,
831
1141
  });
1142
+ const stampedReceipt = isPlainObject(stamped.receipt) ? stamped.receipt : {};
832
1143
 
833
1144
  const phase = typeof merged.current_phase === "string" ? merged.current_phase : undefined;
834
1145
  const active = merged.active !== false;
@@ -836,7 +1147,16 @@ async function handleWrite(
836
1147
 
837
1148
  return {
838
1149
  status: 0,
839
- stdout: `${JSON.stringify({ skill: mode, state: merged, receipt }, null, 2)}\n`,
1150
+ stdout: renderCliWriteReceipt({
1151
+ ok: true,
1152
+ skill: mode,
1153
+ state_path: filePath,
1154
+ current_phase: phase,
1155
+ active,
1156
+ mutation_id: typeof stampedReceipt.mutation_id === "string" ? stampedReceipt.mutation_id : mutationId,
1157
+ status: typeof stampedReceipt.status === "string" ? stampedReceipt.status : undefined,
1158
+ content_sha256: stampedReceipt.content_sha256,
1159
+ }),
840
1160
  ...(outOfBandWarning ? { stderr: `${outOfBandWarning}\n` } : {}),
841
1161
  };
842
1162
  }
@@ -856,19 +1176,44 @@ async function handleClear(
856
1176
  );
857
1177
 
858
1178
  const filePath = modeStateFile(cwd, mode, sessionId);
859
- const existing = (await readJsonFile(filePath)) ?? {};
1179
+ const forced = hasFlag(args, "--force");
1180
+ const existingRead = await readExistingStateForMutation(filePath);
1181
+ if (existingRead.kind === "corrupt" && !forced) {
1182
+ throw new StateCommandError(
1183
+ 2,
1184
+ `existing state for ${mode} is corrupt or tampered (${existingRead.error}); use --force to overwrite`,
1185
+ );
1186
+ }
1187
+ const existing = existingRead.kind === "valid" ? existingRead.value : {};
1188
+ const clearedAt = nowIso();
860
1189
  const cleared: Record<string, unknown> = {
1190
+ skill: mode,
861
1191
  ...existing,
862
1192
  active: false,
863
1193
  current_phase: "complete",
864
- updated_at: nowIso(),
1194
+ updated_at: clearedAt,
1195
+ version: WORKFLOW_STATE_VERSION,
865
1196
  };
866
- const outOfBandWarning = await writeJsonAtomic(cwd, filePath, cleared, "clear", {
1197
+ cleared.skill = mode;
1198
+ const mutationId = `${mode}:clear:${clearedAt}`;
1199
+ const receipt = buildWorkflowStateReceipt({
1200
+ cwd,
1201
+ skill: mode,
1202
+ owner: "gjc-state-cli",
1203
+ command: `gjc state ${mode} clear`,
1204
+ sessionId,
1205
+ nowIso: clearedAt,
1206
+ mutationId,
1207
+ });
1208
+ cleared.receipt = receipt;
1209
+ const { warning: outOfBandWarning, stamped } = await writeJsonAtomic(cwd, filePath, cleared, "clear", {
867
1210
  skill: mode,
868
- force: hasFlag(args, "--force"),
1211
+ mutationId,
1212
+ force: forced,
869
1213
  fromPhase: typeof existing.current_phase === "string" ? existing.current_phase : undefined,
870
1214
  toPhase: "complete",
871
1215
  });
1216
+ const stampedReceipt = isPlainObject(stamped.receipt) ? stamped.receipt : {};
872
1217
 
873
1218
  await syncWorkflowSkillState({
874
1219
  cwd,
@@ -882,7 +1227,16 @@ async function handleClear(
882
1227
  });
883
1228
  return {
884
1229
  status: 0,
885
- stdout: `${JSON.stringify(cleared, null, 2)}\n`,
1230
+ stdout: renderCliWriteReceipt({
1231
+ ok: true,
1232
+ skill: mode,
1233
+ state_path: filePath,
1234
+ active: false,
1235
+ current_phase: typeof cleared.current_phase === "string" ? cleared.current_phase : undefined,
1236
+ mutation_id: typeof stampedReceipt.mutation_id === "string" ? stampedReceipt.mutation_id : mutationId,
1237
+ status: typeof stampedReceipt.status === "string" ? stampedReceipt.status : undefined,
1238
+ content_sha256: stampedReceipt.content_sha256,
1239
+ }),
886
1240
  ...(outOfBandWarning ? { stderr: `${outOfBandWarning}\n` } : {}),
887
1241
  };
888
1242
  }
@@ -932,14 +1286,29 @@ async function handleHandoff(
932
1286
 
933
1287
  const callerPath = modeStateFile(cwd, caller, sessionId);
934
1288
  const calleePath = modeStateFile(cwd, callee, sessionId);
935
- const existingCaller = await readJsonFile(callerPath);
936
- if (!existingCaller) {
1289
+ const forced = hasFlag(args, "--force");
1290
+ const callerRead = await readExistingStateForMutation(callerPath);
1291
+ if (callerRead.kind === "corrupt" && !forced) {
1292
+ throw new StateCommandError(
1293
+ 2,
1294
+ `existing state for ${caller} is corrupt or tampered (${callerRead.error}); use --force to overwrite`,
1295
+ );
1296
+ }
1297
+ if (callerRead.kind === "absent") {
937
1298
  throw new StateCommandError(
938
1299
  2,
939
1300
  `gjc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
940
1301
  );
941
1302
  }
942
- const existingCallee = (await readJsonFile(calleePath)) ?? {};
1303
+ const calleeRead = await readExistingStateForMutation(calleePath);
1304
+ if (calleeRead.kind === "corrupt" && !forced) {
1305
+ throw new StateCommandError(
1306
+ 2,
1307
+ `existing state for ${callee} is corrupt or tampered (${calleeRead.error}); use --force to overwrite`,
1308
+ );
1309
+ }
1310
+ const existingCaller = callerRead.kind === "valid" ? callerRead.value : {};
1311
+ const existingCallee = calleeRead.kind === "valid" ? calleeRead.value : {};
943
1312
 
944
1313
  const handoffAt = nowIso();
945
1314
  const mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
@@ -963,10 +1332,12 @@ async function handleHandoff(
963
1332
  });
964
1333
 
965
1334
  const calleeInitial = initialPhaseForSkill(callee);
1335
+ const normalizedCaller = migrateWorkflowState(existingCaller, caller).state;
1336
+ const normalizedCallee = migrateWorkflowState(existingCallee, callee).state;
966
1337
  const mergedCalleeState: Record<string, unknown> = {
967
- ...existingCallee,
1338
+ ...normalizedCallee,
968
1339
  skill: callee,
969
- version: typeof existingCallee.version === "number" ? existingCallee.version : 1,
1340
+ version: WORKFLOW_STATE_VERSION,
970
1341
  active: true,
971
1342
  current_phase: calleeInitial,
972
1343
  handoff_from: caller,
@@ -978,8 +1349,9 @@ async function handleHandoff(
978
1349
  mergedCalleeState.session_id = sessionId;
979
1350
  }
980
1351
  const mergedCallerState: Record<string, unknown> = {
981
- ...existingCaller,
1352
+ ...normalizedCaller,
982
1353
  skill: caller,
1354
+ version: WORKFLOW_STATE_VERSION,
983
1355
  active: false,
984
1356
  current_phase: "handoff",
985
1357
  handoff_to: callee,
@@ -1004,26 +1376,29 @@ async function handleHandoff(
1004
1376
  // root aggregate. strict:true on the active-state read tolerates ENOENT
1005
1377
  // only; corrupt JSON / IO failures propagate as non-zero CLI status.
1006
1378
  const force = hasFlag(args, "--force");
1007
- const warnings = [
1008
- await writeJsonAtomic(cwd, calleePath, mergedCalleeState, "handoff", {
1009
- skill: callee,
1010
- mutationId,
1011
- force,
1012
- fromPhase: typeof existingCallee.current_phase === "string" ? existingCallee.current_phase : undefined,
1013
- toPhase: calleeInitial,
1014
- }),
1015
- await updateWorkflowTransactionJournal(cwd, mutationId, { steps: ["callee-mode-state"] }).then(() => undefined),
1016
- await writeJsonAtomic(cwd, callerPath, mergedCallerState, "handoff", {
1017
- skill: caller,
1018
- mutationId,
1019
- force,
1020
- fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
1021
- toPhase: "handoff",
1022
- }),
1023
- await updateWorkflowTransactionJournal(cwd, mutationId, {
1024
- steps: ["callee-mode-state", "caller-mode-state"],
1025
- }).then(() => undefined),
1026
- ].filter((warning): warning is string => typeof warning === "string");
1379
+ const calleeWrite = await writeJsonAtomic(cwd, calleePath, mergedCalleeState, "handoff", {
1380
+ skill: callee,
1381
+ mutationId,
1382
+ force,
1383
+ fromPhase: typeof existingCallee.current_phase === "string" ? existingCallee.current_phase : undefined,
1384
+ toPhase: calleeInitial,
1385
+ });
1386
+ await updateWorkflowTransactionJournal(cwd, mutationId, { steps: ["callee-mode-state"] });
1387
+ const callerWrite = await writeJsonAtomic(cwd, callerPath, mergedCallerState, "handoff", {
1388
+ skill: caller,
1389
+ mutationId,
1390
+ force,
1391
+ fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
1392
+ toPhase: "handoff",
1393
+ });
1394
+ await updateWorkflowTransactionJournal(cwd, mutationId, {
1395
+ steps: ["callee-mode-state", "caller-mode-state"],
1396
+ });
1397
+ const warnings = [calleeWrite.warning, callerWrite.warning].filter(
1398
+ (warning): warning is string => typeof warning === "string",
1399
+ );
1400
+ const stampedCallerReceipt = isPlainObject(callerWrite.stamped.receipt) ? callerWrite.stamped.receipt : {};
1401
+ const stampedCalleeReceipt = isPlainObject(calleeWrite.stamped.receipt) ? calleeWrite.stamped.receipt : {};
1027
1402
  for (const warning of warnings) process.stderr.write(`${warning}\n`);
1028
1403
  if (process.env.GJC_STATE_HANDOFF_FAIL_AFTER_CALLER === mutationId) {
1029
1404
  throw new StateCommandError(1, `injected handoff failure after caller write for ${mutationId}`);
@@ -1068,17 +1443,33 @@ async function handleHandoff(
1068
1443
 
1069
1444
  return {
1070
1445
  status: 0,
1071
- stdout: `${JSON.stringify(
1072
- {
1073
- from: caller,
1074
- to: callee,
1075
- handoff_at: handoffAt,
1076
- caller_state: mergedCallerState,
1077
- callee_state: mergedCalleeState,
1446
+ stdout: renderCliWriteReceipt({
1447
+ ok: true,
1448
+ from: caller,
1449
+ to: callee,
1450
+ handoff_at: handoffAt,
1451
+ phases: {
1452
+ from: mergedCallerState.current_phase,
1453
+ to: mergedCalleeState.current_phase,
1078
1454
  },
1079
- null,
1080
- 2,
1081
- )}\n`,
1455
+ receipts: {
1456
+ from: {
1457
+ mutation_id: stampedCallerReceipt.mutation_id,
1458
+ status: stampedCallerReceipt.status,
1459
+ content_sha256: stampedCallerReceipt.content_sha256,
1460
+ },
1461
+ to: {
1462
+ mutation_id: stampedCalleeReceipt.mutation_id,
1463
+ status: stampedCalleeReceipt.status,
1464
+ content_sha256: stampedCalleeReceipt.content_sha256,
1465
+ },
1466
+ },
1467
+ paths: {
1468
+ from: callerPath,
1469
+ to: calleePath,
1470
+ active_state: activeStateFile(cwd, sessionId),
1471
+ },
1472
+ }),
1082
1473
  };
1083
1474
  }
1084
1475
 
@@ -1392,9 +1783,13 @@ async function handleMigrate(
1392
1783
  );
1393
1784
  }
1394
1785
  const filePath = modeStateFile(cwd, mode, selectors.sessionId);
1786
+ const forced = hasFlag(args, "--force");
1395
1787
  const mismatchWarning = await warnAndAuditOutOfBandIfNeeded(cwd, filePath, mode, {
1396
- forced: hasFlag(args, "--force"),
1788
+ forced,
1397
1789
  });
1790
+ if (mismatchWarning && !forced) {
1791
+ throw new StateCommandError(2, `${mismatchWarning}; use --force to migrate tampered mode-state`);
1792
+ }
1398
1793
  const result = await migrateAndPersistLegacyState({
1399
1794
  cwd,
1400
1795
  skill: mode,
@@ -1424,6 +1819,8 @@ export async function runNativeStateCommand(args: string[], cwd = process.cwd())
1424
1819
  return await handleContract(args, cwd, parsed.positionalSkill);
1425
1820
  case "status":
1426
1821
  return await handleStatus(args, cwd, parsed.positionalSkill);
1822
+ case "doctor":
1823
+ return await handleDoctor(args, cwd, parsed.positionalSkill);
1427
1824
  case "handoff":
1428
1825
  return await handleHandoff(args, cwd, parsed.positionalSkill);
1429
1826
  case "graph":