@gajae-code/coding-agent 0.2.5 → 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 (234) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/async/job-manager.d.ts +91 -2
  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/commands/harness.d.ts +37 -0
  6. package/dist/types/config/keybindings.d.ts +5 -0
  7. package/dist/types/config/settings-schema.d.ts +10 -4
  8. package/dist/types/config/settings.d.ts +2 -0
  9. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  10. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  11. package/dist/types/deep-interview/render-middleware.d.ts +6 -0
  12. package/dist/types/eval/py/executor.d.ts +2 -0
  13. package/dist/types/eval/py/kernel.d.ts +2 -0
  14. package/dist/types/exec/bash-executor.d.ts +10 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  17. package/dist/types/extensibility/shared-events.d.ts +1 -0
  18. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  19. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  20. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  21. package/dist/types/gjc-runtime/state-migrations.d.ts +33 -0
  22. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  23. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  25. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  26. package/dist/types/gjc-runtime/state-writer.d.ts +147 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  28. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  29. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  30. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  31. package/dist/types/harness-control-plane/control-endpoint.d.ts +31 -0
  32. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  33. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  34. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  35. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  36. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  37. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  38. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  39. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  40. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  41. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  42. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  43. package/dist/types/harness-control-plane/types.d.ts +162 -0
  44. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  45. package/dist/types/hooks/skill-state.d.ts +23 -29
  46. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  47. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  48. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  49. package/dist/types/internal-urls/types.d.ts +4 -0
  50. package/dist/types/lsp/index.d.ts +10 -10
  51. package/dist/types/modes/bridge/auth.d.ts +12 -0
  52. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  53. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  54. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  55. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  56. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  57. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  58. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  59. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  60. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  61. package/dist/types/modes/components/status-line.d.ts +2 -0
  62. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  63. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  64. package/dist/types/modes/index.d.ts +1 -0
  65. package/dist/types/modes/interactive-mode.d.ts +2 -0
  66. package/dist/types/modes/jobs-observer.d.ts +57 -0
  67. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  68. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  69. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  70. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  71. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  72. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  73. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  74. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  75. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  76. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  77. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  78. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  79. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  80. package/dist/types/modes/types.d.ts +2 -0
  81. package/dist/types/sdk.d.ts +4 -0
  82. package/dist/types/session/agent-session.d.ts +19 -1
  83. package/dist/types/skill-state/active-state.d.ts +2 -0
  84. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  85. package/dist/types/skill-state/workflow-state-contract.d.ts +25 -2
  86. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  87. package/dist/types/task/executor.d.ts +3 -0
  88. package/dist/types/task/id.d.ts +7 -0
  89. package/dist/types/task/index.d.ts +5 -0
  90. package/dist/types/task/receipt.d.ts +85 -0
  91. package/dist/types/task/spawn-gate.d.ts +38 -0
  92. package/dist/types/task/types.d.ts +198 -14
  93. package/dist/types/tools/cron.d.ts +6 -0
  94. package/dist/types/tools/index.d.ts +2 -0
  95. package/dist/types/tools/path-utils.d.ts +1 -0
  96. package/dist/types/tools/subagent.d.ts +26 -1
  97. package/package.json +7 -7
  98. package/scripts/build-binary.ts +7 -0
  99. package/src/async/job-manager.ts +334 -6
  100. package/src/cli/args.ts +9 -2
  101. package/src/cli/auth-broker-cli.ts +1 -0
  102. package/src/cli/config-cli.ts +10 -2
  103. package/src/cli.ts +2 -0
  104. package/src/commands/deep-interview.ts +1 -0
  105. package/src/commands/harness.ts +862 -0
  106. package/src/commands/launch.ts +2 -2
  107. package/src/commands/state.ts +2 -1
  108. package/src/commands/team.ts +54 -39
  109. package/src/config/keybindings.ts +6 -0
  110. package/src/config/settings-schema.ts +13 -3
  111. package/src/config/settings.ts +5 -0
  112. package/src/dap/client.ts +17 -3
  113. package/src/debug/crash-diagnostics.ts +223 -0
  114. package/src/debug/runtime-gauges.ts +20 -0
  115. package/src/deep-interview/render-middleware.ts +372 -0
  116. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  117. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  118. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  119. package/src/defaults/gjc/skills/ultragoal/SKILL.md +106 -13
  120. package/src/eval/py/executor.ts +21 -1
  121. package/src/eval/py/kernel.ts +15 -0
  122. package/src/exec/bash-executor.ts +41 -0
  123. package/src/extensibility/custom-tools/types.ts +1 -0
  124. package/src/extensibility/extensions/types.ts +6 -0
  125. package/src/extensibility/shared-events.ts +1 -0
  126. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  127. package/src/gjc-runtime/deep-interview-runtime.ts +98 -42
  128. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  129. package/src/gjc-runtime/ralplan-runtime.ts +235 -43
  130. package/src/gjc-runtime/state-graph.ts +86 -0
  131. package/src/gjc-runtime/state-migrations.ts +179 -0
  132. package/src/gjc-runtime/state-renderer.ts +345 -0
  133. package/src/gjc-runtime/state-runtime.ts +1155 -46
  134. package/src/gjc-runtime/state-schema.ts +192 -0
  135. package/src/gjc-runtime/state-validation.ts +49 -0
  136. package/src/gjc-runtime/state-writer.ts +749 -0
  137. package/src/gjc-runtime/team-runtime.ts +1255 -189
  138. package/src/gjc-runtime/ultragoal-runtime.ts +460 -43
  139. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  140. package/src/gjc-runtime/workflow-manifest.generated.json +1601 -0
  141. package/src/gjc-runtime/workflow-manifest.ts +427 -0
  142. package/src/harness-control-plane/classifier.ts +128 -0
  143. package/src/harness-control-plane/control-endpoint.ts +148 -0
  144. package/src/harness-control-plane/finalize.ts +222 -0
  145. package/src/harness-control-plane/frame-mapper.ts +286 -0
  146. package/src/harness-control-plane/operate.ts +225 -0
  147. package/src/harness-control-plane/owner.ts +600 -0
  148. package/src/harness-control-plane/preserve.ts +102 -0
  149. package/src/harness-control-plane/receipts.ts +216 -0
  150. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  151. package/src/harness-control-plane/seams.ts +39 -0
  152. package/src/harness-control-plane/session-lease.ts +388 -0
  153. package/src/harness-control-plane/state-machine.ts +98 -0
  154. package/src/harness-control-plane/storage.ts +257 -0
  155. package/src/harness-control-plane/types.ts +214 -0
  156. package/src/hooks/skill-keywords.ts +4 -2
  157. package/src/hooks/skill-state.ts +197 -64
  158. package/src/internal-urls/agent-protocol.ts +68 -21
  159. package/src/internal-urls/artifact-protocol.ts +12 -17
  160. package/src/internal-urls/docs-index.generated.ts +3 -2
  161. package/src/internal-urls/registry-helpers.ts +19 -16
  162. package/src/internal-urls/types.ts +4 -0
  163. package/src/lsp/client.ts +18 -2
  164. package/src/main.ts +21 -5
  165. package/src/modes/bridge/auth.ts +41 -0
  166. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  167. package/src/modes/bridge/bridge-mode.ts +520 -0
  168. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  169. package/src/modes/bridge/event-stream.ts +70 -0
  170. package/src/modes/components/assistant-message.ts +5 -1
  171. package/src/modes/components/custom-editor.ts +101 -0
  172. package/src/modes/components/hook-selector.ts +133 -20
  173. package/src/modes/components/jobs-overlay-model.ts +109 -0
  174. package/src/modes/components/jobs-overlay.ts +172 -0
  175. package/src/modes/components/status-line/presets.ts +7 -5
  176. package/src/modes/components/status-line/segments.ts +25 -0
  177. package/src/modes/components/status-line/types.ts +2 -0
  178. package/src/modes/components/status-line.ts +9 -1
  179. package/src/modes/controllers/event-controller.ts +71 -6
  180. package/src/modes/controllers/extension-ui-controller.ts +43 -1
  181. package/src/modes/controllers/input-controller.ts +105 -9
  182. package/src/modes/controllers/selector-controller.ts +31 -1
  183. package/src/modes/index.ts +1 -0
  184. package/src/modes/interactive-mode.ts +28 -0
  185. package/src/modes/jobs-observer.ts +204 -0
  186. package/src/modes/rpc/host-tools.ts +1 -186
  187. package/src/modes/rpc/host-uris.ts +1 -235
  188. package/src/modes/rpc/rpc-client.ts +25 -10
  189. package/src/modes/rpc/rpc-mode.ts +12 -381
  190. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  191. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  192. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  193. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  194. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  195. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  196. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  197. package/src/modes/shared/agent-wire/responses.ts +17 -0
  198. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  199. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  200. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  201. package/src/modes/types.ts +2 -0
  202. package/src/prompts/agents/executor.md +13 -0
  203. package/src/prompts/tools/subagent.md +39 -4
  204. package/src/prompts/tools/task-summary.md +3 -9
  205. package/src/prompts/tools/task.md +5 -1
  206. package/src/sdk.ts +8 -0
  207. package/src/session/agent-session.ts +445 -71
  208. package/src/session/session-manager.ts +13 -1
  209. package/src/skill-state/active-state.ts +58 -65
  210. package/src/skill-state/deep-interview-mutation-guard.ts +114 -17
  211. package/src/skill-state/initial-phase.ts +2 -0
  212. package/src/skill-state/workflow-state-contract.ts +33 -4
  213. package/src/skill-state/workflow-state-version.ts +3 -0
  214. package/src/slash-commands/builtin-registry.ts +8 -0
  215. package/src/task/executor.ts +79 -13
  216. package/src/task/id.ts +33 -0
  217. package/src/task/index.ts +376 -74
  218. package/src/task/output-manager.ts +5 -4
  219. package/src/task/receipt.ts +297 -0
  220. package/src/task/render.ts +54 -134
  221. package/src/task/spawn-gate.ts +132 -0
  222. package/src/task/types.ts +104 -10
  223. package/src/tools/ask.ts +88 -27
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash.ts +1 -1
  227. package/src/tools/cron.ts +48 -0
  228. package/src/tools/find.ts +4 -1
  229. package/src/tools/index.ts +2 -0
  230. package/src/tools/path-utils.ts +3 -2
  231. package/src/tools/read.ts +1 -0
  232. package/src/tools/search.ts +1 -0
  233. package/src/tools/skill.ts +6 -1
  234. package/src/tools/subagent.ts +423 -79
@@ -1,4 +1,3 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import * as fs from "node:fs/promises";
3
2
  import * as path from "node:path";
4
3
  import type { WorkflowHudSummary } from "../skill-state/active-state";
@@ -18,11 +17,42 @@ import {
18
17
  buildUltragoalHudSummary,
19
18
  } from "../skill-state/workflow-hud";
20
19
  import {
20
+ type AuditEntry,
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";
28
+ import { renderStateGraph, type StateGraphFormat } from "./state-graph";
29
+ import { migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
30
+ import {
31
+ buildStateStatusSummary,
32
+ compactProjectStateJson,
33
+ projectStateFields,
34
+ renderContractMarkdown,
35
+ renderHistoryMarkdown,
36
+ renderStateMarkdown,
37
+ renderStateStatusLine,
38
+ STATE_FIELD_ALLOWLIST,
39
+ type StateProjectionField,
40
+ } from "./state-renderer";
41
+ import { validateWorkflowStateEnvelope } from "./state-validation";
42
+ import {
43
+ appendAuditEntry,
44
+ beginWorkflowTransactionJournal,
45
+ completeWorkflowTransactionJournal,
46
+ detectWorkflowEnvelopeIntegrityMismatch,
47
+ type GenericHardPruneTarget,
48
+ hardPrune,
49
+ readExistingStateForMutation,
50
+ type StateWriterAuditContext,
51
+ softDelete,
52
+ updateWorkflowTransactionJournal,
53
+ writeWorkflowEnvelopeAtomic,
54
+ } from "./state-writer";
55
+ import { getSkillManifest, isKnownWorkflowState, isValidTransition, typedArgsFor } from "./workflow-manifest";
26
56
 
27
57
  /**
28
58
  * Native implementation of the `gjc state read|write|clear` command surface.
@@ -62,11 +92,104 @@ function hasFlag(args: readonly string[], flag: string): boolean {
62
92
  return args.includes(flag);
63
93
  }
64
94
 
65
- const FLAGS_WITH_VALUES = new Set(["--input", "--mode", "--session-id", "--thread-id", "--turn-id", "--to"]);
66
- const ACTION_NAMES = new Set(["read", "write", "clear", "contract", "handoff"]);
95
+ const GRAPH_FORMATS = new Set(["ascii", "mermaid", "dot"]);
96
+ const FLAGS_WITH_VALUES = new Set([
97
+ "--input",
98
+ "--mode",
99
+ "--session-id",
100
+ "--thread-id",
101
+ "--turn-id",
102
+ "--to",
103
+ "--skill",
104
+ "--format",
105
+ "--older-than",
106
+ "--status",
107
+ "--fields",
108
+ "--since",
109
+ "--limit",
110
+ ]);
111
+ const ACTION_NAMES = new Set([
112
+ "read",
113
+ "write",
114
+ "clear",
115
+ "contract",
116
+ "handoff",
117
+ "graph",
118
+ "prune",
119
+ "gc",
120
+ "migrate",
121
+ "status",
122
+ "doctor",
123
+ ]);
124
+ const BOOLEAN_FLAGS = new Set([
125
+ "--json",
126
+ "--replace",
127
+ "--hard",
128
+ "--dry-run",
129
+ "--migrate",
130
+ "--compact",
131
+ "--history",
132
+ "--force",
133
+ ]);
134
+ const VERB_SPECIFIC_FLAGS = new Set([
135
+ "--skill",
136
+ "--format",
137
+ "--older-than",
138
+ "--status",
139
+ "--fields",
140
+ "--since",
141
+ "--limit",
142
+ "--history",
143
+ ]);
144
+
145
+ function flagName(arg: string): string | undefined {
146
+ if (!arg.startsWith("--")) return undefined;
147
+ const equalsIndex = arg.indexOf("=");
148
+ return equalsIndex >= 0 ? arg.slice(0, equalsIndex) : arg;
149
+ }
150
+
151
+ function manifestFlagNames(action: ParsedInvocation["action"], positionalSkill: string | undefined): Set<string> {
152
+ const names = new Set<string>();
153
+ const skills =
154
+ positionalSkill && KNOWN_MODES.includes(positionalSkill)
155
+ ? [positionalSkill as CanonicalGjcWorkflowSkill]
156
+ : CANONICAL_GJC_WORKFLOW_SKILLS;
157
+ for (const skill of skills) {
158
+ for (const arg of typedArgsFor(skill, action)) names.add(`--${arg.name}`);
159
+ }
160
+ return names;
161
+ }
162
+
163
+ function assertKnownFlags(args: readonly string[], parsed: ParsedInvocation): void {
164
+ const manifestFlags = manifestFlagNames(parsed.action, parsed.positionalSkill);
165
+ for (const arg of args) {
166
+ const flag = flagName(arg);
167
+ if (!flag) continue;
168
+ if (
169
+ FLAGS_WITH_VALUES.has(flag) ||
170
+ BOOLEAN_FLAGS.has(flag) ||
171
+ VERB_SPECIFIC_FLAGS.has(flag) ||
172
+ manifestFlags.has(flag)
173
+ ) {
174
+ continue;
175
+ }
176
+ throw new StateCommandError(2, `unknown gjc state flag: ${flag}`);
177
+ }
178
+ }
67
179
 
68
180
  interface ParsedInvocation {
69
- action: "read" | "write" | "clear" | "contract" | "handoff";
181
+ action:
182
+ | "read"
183
+ | "write"
184
+ | "clear"
185
+ | "contract"
186
+ | "handoff"
187
+ | "graph"
188
+ | "prune"
189
+ | "gc"
190
+ | "migrate"
191
+ | "status"
192
+ | "doctor";
70
193
  positionalSkill?: string;
71
194
  }
72
195
 
@@ -90,7 +213,7 @@ function parsePositionalArgs(args: readonly string[]): ParsedInvocation {
90
213
  const first = positional[0];
91
214
  const second = positional[1];
92
215
  if (first && ACTION_NAMES.has(first)) {
93
- return { action: first as ParsedInvocation["action"] };
216
+ return { action: first as ParsedInvocation["action"], positionalSkill: second };
94
217
  }
95
218
  if (first && second && ACTION_NAMES.has(second)) {
96
219
  return { action: second as ParsedInvocation["action"], positionalSkill: first };
@@ -236,15 +359,441 @@ async function readJsonFile(filePath: string): Promise<Record<string, unknown> |
236
359
  } catch (error) {
237
360
  const err = error as NodeJS.ErrnoException;
238
361
  if (err.code === "ENOENT") return null;
239
- 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;
364
+ }
365
+ }
366
+
367
+ async function readJsonValue(filePath: string): Promise<unknown | null> {
368
+ try {
369
+ return JSON.parse(await fs.readFile(filePath, "utf-8"));
370
+ } catch (error) {
371
+ const err = error as NodeJS.ErrnoException;
372
+ if (err.code === "ENOENT") return null;
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;
240
419
  }
420
+ return entries
421
+ .filter(entry => entry.endsWith(".json"))
422
+ .sort()
423
+ .map(entry => path.join(dir, entry));
241
424
  }
242
425
 
243
- async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
244
- await fs.mkdir(path.dirname(filePath), { recursive: true });
245
- const tmp = `${filePath}.tmp-${randomBytes(6).toString("hex")}`;
246
- await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
247
- await fs.rename(tmp, filePath);
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
+ }
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
+ };
652
+ }
653
+
654
+ async function warnAndAuditOutOfBandIfNeeded(
655
+ cwd: string,
656
+ filePath: string,
657
+ skill: CanonicalGjcWorkflowSkill,
658
+ options?: { mutationId?: string; forced?: boolean },
659
+ ): Promise<string | undefined> {
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
+ }
668
+ if (!mismatch) return undefined;
669
+ const message = `WARNING: workflow mode-state out-of-band edit detected for ${skill}: ${filePath} expected sha256 ${mismatch.expected} but found ${mismatch.actual}`;
670
+ await appendAuditEntry(cwd, {
671
+ ts: new Date().toISOString(),
672
+ skill,
673
+ category: "state",
674
+ verb: "out_of_band_detected",
675
+ owner: "gjc-state-cli",
676
+ mutation_id: options?.mutationId ?? `${skill}:out-of-band:${new Date().toISOString()}`,
677
+ forced: options?.forced ?? false,
678
+ paths: [filePath],
679
+ expected_sha256: mismatch.expected,
680
+ actual_sha256: mismatch.actual,
681
+ } as AuditEntry);
682
+ return message;
683
+ }
684
+
685
+ async function writeJsonAtomic(
686
+ cwd: string,
687
+ filePath: string,
688
+ value: unknown,
689
+ verb: "write" | "clear" | "handoff" = "write",
690
+ options?: {
691
+ skill?: CanonicalGjcWorkflowSkill;
692
+ mutationId?: string;
693
+ force?: boolean;
694
+ fromPhase?: string;
695
+ toPhase?: string;
696
+ },
697
+ ): Promise<{ warning?: string; stamped: Record<string, unknown> }> {
698
+ const warning = options?.skill
699
+ ? await warnAndAuditOutOfBandIfNeeded(cwd, filePath, options.skill, {
700
+ mutationId: options.mutationId,
701
+ forced: options.force ?? false,
702
+ })
703
+ : undefined;
704
+ if (warning && !options?.force) {
705
+ throw new StateCommandError(2, `${warning}; use --force to overwrite tampered mode-state`);
706
+ }
707
+ await writeWorkflowEnvelopeAtomic(filePath, value, {
708
+ cwd,
709
+ audit: {
710
+ category: "state",
711
+ verb,
712
+ owner: "gjc-state-cli",
713
+ skill: options?.skill,
714
+ mutationId: options?.mutationId,
715
+ fromPhase: options?.fromPhase,
716
+ toPhase: options?.toPhase,
717
+ forced: options?.force ?? false,
718
+ },
719
+ });
720
+ return { warning, stamped: (await readJsonFile(filePath)) ?? {} };
721
+ }
722
+
723
+ function parseFieldsFlag(args: readonly string[]): StateProjectionField[] | undefined {
724
+ const raw = flagValue(args, "--fields");
725
+ if (raw === undefined) return undefined;
726
+ const allowed = new Set<string>(STATE_FIELD_ALLOWLIST);
727
+ const fields = raw
728
+ .split(",")
729
+ .map(field => field.trim())
730
+ .filter(Boolean);
731
+ const unknown = fields.filter(field => !allowed.has(field));
732
+ if (unknown.length) {
733
+ throw new StateCommandError(
734
+ 2,
735
+ `unknown --fields value(s): ${unknown.join(", ")}. Allowed fields: ${STATE_FIELD_ALLOWLIST.join(", ")}`,
736
+ );
737
+ }
738
+ return fields as StateProjectionField[];
739
+ }
740
+
741
+ function parseLimitFlag(args: readonly string[], defaultLimit = 50): number {
742
+ const raw = flagValue(args, "--limit");
743
+ if (raw === undefined) return defaultLimit;
744
+ const parsed = Number(raw);
745
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 500) {
746
+ throw new StateCommandError(2, "gjc state --limit requires an integer from 1 to 500");
747
+ }
748
+ return parsed;
749
+ }
750
+
751
+ function parseSinceFlag(args: readonly string[]): string | undefined {
752
+ const raw = flagValue(args, "--since")?.trim();
753
+ if (!raw) return undefined;
754
+ const duration = raw.match(/^(\d+)(m|h|d)$/);
755
+ if (duration) {
756
+ const amount = Number(duration[1]);
757
+ const unit = duration[2];
758
+ const multiplier = unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
759
+ return new Date(Date.now() - amount * multiplier).toISOString();
760
+ }
761
+ if (Number.isNaN(Date.parse(raw)))
762
+ throw new StateCommandError(2, "gjc state --since requires an ISO timestamp or duration like 30m, 6h, 7d");
763
+ return new Date(raw).toISOString();
764
+ }
765
+
766
+ async function readAuditWindow(
767
+ cwd: string,
768
+ args: readonly string[],
769
+ ): Promise<{ entries: unknown[]; limit: number; since?: string; truncated: boolean }> {
770
+ const limit = parseLimitFlag(args);
771
+ const since = parseSinceFlag(args);
772
+ const auditPath = path.join(cwd, ".gjc", "state", "audit.jsonl");
773
+ let raw = "";
774
+ try {
775
+ raw = await fs.readFile(auditPath, "utf-8");
776
+ } catch (error) {
777
+ const err = error as NodeJS.ErrnoException;
778
+ if (err.code !== "ENOENT") throw error;
779
+ }
780
+ const selected: unknown[] = [];
781
+ let matched = 0;
782
+ const lines = raw.split(/\r?\n/).filter(line => line.trim().length > 0);
783
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
784
+ const line = lines[index];
785
+ let entry: unknown;
786
+ try {
787
+ entry = JSON.parse(line);
788
+ } catch {
789
+ continue;
790
+ }
791
+ if (since && isPlainObject(entry) && typeof entry.ts === "string" && Date.parse(entry.ts) < Date.parse(since))
792
+ break;
793
+ matched += 1;
794
+ if (selected.length < limit) selected.push(entry);
795
+ }
796
+ return { entries: selected.reverse(), limit, ...(since ? { since } : {}), truncated: matched > limit };
248
797
  }
249
798
 
250
799
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -408,6 +957,14 @@ async function syncWorkflowSkillState(options: {
408
957
  // HUD sync is best-effort and must not change command semantics.
409
958
  }
410
959
  }
960
+ export async function readWorkflowStateJson(
961
+ cwd: string,
962
+ skill: CanonicalGjcWorkflowSkill,
963
+ sessionId?: string,
964
+ ): Promise<Record<string, unknown>> {
965
+ return (await readJsonFile(modeStateFile(cwd, skill, sessionId))) ?? {};
966
+ }
967
+
411
968
  async function handleRead(
412
969
  args: readonly string[],
413
970
  cwd: string,
@@ -415,19 +972,70 @@ async function handleRead(
415
972
  ): Promise<StateCommandResult> {
416
973
  const selectors = await resolveSelectors(args, cwd, positionalSkill);
417
974
  const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
975
+ const fields = parseFieldsFlag(args);
418
976
  if (mode) {
419
977
  const filePath = modeStateFile(cwd, mode, selectors.sessionId);
420
- const existing = await readJsonFile(filePath);
978
+ const existing = await readWorkflowStateJson(cwd, mode, selectors.sessionId);
979
+ const envelope = { skill: mode, state: existing, storage_path: filePath };
980
+ const manifest = getSkillManifest(mode);
981
+ if (fields) {
982
+ const projected = projectStateFields(mode, envelope, manifest, fields);
983
+ return {
984
+ status: 0,
985
+ stdout: hasFlag(args, "--json")
986
+ ? `${JSON.stringify(projected, null, 2)}\n`
987
+ : renderStateMarkdown(mode, projected, manifest),
988
+ };
989
+ }
990
+ if (hasFlag(args, "--compact")) {
991
+ const compact = compactProjectStateJson(mode, envelope, manifest);
992
+ return {
993
+ status: 0,
994
+ stdout: hasFlag(args, "--json")
995
+ ? `${JSON.stringify(compact, null, 2)}\n`
996
+ : renderStateMarkdown(mode, envelope, manifest),
997
+ };
998
+ }
421
999
  return {
422
1000
  status: 0,
423
- stdout: `${JSON.stringify({ skill: mode, state: existing, storage_path: filePath }, null, 2)}\n`,
1001
+ stdout: hasFlag(args, "--json")
1002
+ ? `${JSON.stringify(envelope, null, 2)}\n`
1003
+ : renderStateMarkdown(mode, envelope, manifest),
424
1004
  };
425
1005
  }
426
1006
  const filePath = activeStateFile(cwd, selectors.sessionId);
427
- const existing = await readJsonFile(filePath);
1007
+ const existingRaw = await readJsonValue(filePath);
1008
+ const existing = isPlainObject(existingRaw) ? existingRaw : null;
428
1009
  return { status: 0, stdout: `${JSON.stringify(existing ?? {}, null, 2)}\n` };
429
1010
  }
430
1011
 
1012
+ async function handleStatus(
1013
+ args: readonly string[],
1014
+ cwd: string,
1015
+ positionalSkill: string | undefined,
1016
+ ): Promise<StateCommandResult> {
1017
+ const selectors = await resolveSelectors(args, cwd, positionalSkill);
1018
+ const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
1019
+ if (!mode) {
1020
+ throw new StateCommandError(
1021
+ 2,
1022
+ "gjc state status requires --mode <skill>, positional <skill>, input.skill, or an active workflow in .gjc/state/skill-active-state.json",
1023
+ );
1024
+ }
1025
+ const filePath = modeStateFile(cwd, mode, selectors.sessionId);
1026
+ const existing = await readWorkflowStateJson(cwd, mode, selectors.sessionId);
1027
+ const summary = buildStateStatusSummary(
1028
+ mode,
1029
+ { skill: mode, state: existing, storage_path: filePath },
1030
+ getSkillManifest(mode),
1031
+ filePath,
1032
+ );
1033
+ return {
1034
+ status: 0,
1035
+ stdout: hasFlag(args, "--json") ? `${JSON.stringify(summary, null, 2)}\n` : renderStateStatusLine(summary),
1036
+ };
1037
+ }
1038
+
431
1039
  async function handleWrite(
432
1040
  args: readonly string[],
433
1041
  cwd: string,
@@ -444,8 +1052,17 @@ async function handleWrite(
444
1052
  );
445
1053
 
446
1054
  const filePath = modeStateFile(cwd, mode, sessionId);
447
- const existing = await readJsonFile(filePath);
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 : {};
448
1064
  const nowIsoStr = nowIso();
1065
+ const mutationId = `${mode}:${nowIsoStr}`;
449
1066
  const receipt = buildWorkflowStateReceipt({
450
1067
  cwd,
451
1068
  skill: mode,
@@ -453,8 +1070,8 @@ async function handleWrite(
453
1070
  command: `gjc state ${mode} write`,
454
1071
  sessionId,
455
1072
  nowIso: nowIsoStr,
1073
+ mutationId,
456
1074
  });
457
- const existingPayload = existing ?? {};
458
1075
  const innerState = (payload.state as Record<string, unknown> | undefined) ?? {};
459
1076
  const incomingPhase =
460
1077
  typeof payload.current_phase === "string" && payload.current_phase.trim()
@@ -476,19 +1093,53 @@ async function handleWrite(
476
1093
  delete merged.state;
477
1094
  }
478
1095
  }
1096
+ const preDefaultValidation = validateWorkflowStateEnvelope(mode, merged);
1097
+ if (!preDefaultValidation.valid) {
1098
+ throw new StateCommandError(2, preDefaultValidation.error ?? `invalid ${mode} state envelope`);
1099
+ }
479
1100
  merged.skill = mode;
480
1101
  if (incomingPhase) {
481
1102
  merged.current_phase = incomingPhase;
482
- } else if (typeof merged.current_phase !== "string") {
483
- merged.current_phase =
484
- 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();
485
1109
  }
486
- if (typeof merged.version !== "number") merged.version = 1;
1110
+ merged.version = WORKFLOW_STATE_VERSION;
487
1111
  if (typeof merged.active !== "boolean") merged.active = true;
488
1112
  merged.updated_at = nowIsoStr;
489
1113
  merged.receipt = receipt;
490
1114
  if (sessionId && typeof merged.session_id !== "string") merged.session_id = sessionId;
491
- await writeJsonAtomic(filePath, merged);
1115
+
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
+ }
1123
+ if (fromPhase && toPhase && isKnownWorkflowState(mode, fromPhase) && isKnownWorkflowState(mode, toPhase)) {
1124
+ if (!isValidTransition(mode, fromPhase, toPhase) && !forced) {
1125
+ throw new StateCommandError(
1126
+ 2,
1127
+ `invalid ${mode} phase transition from ${fromPhase} to ${toPhase}; use --force to bypass`,
1128
+ );
1129
+ }
1130
+ }
1131
+
1132
+ const validation = validateWorkflowStateEnvelope(mode, merged);
1133
+ if (!validation.valid) throw new StateCommandError(2, validation.error ?? `invalid ${mode} state envelope`);
1134
+
1135
+ const { warning: outOfBandWarning, stamped } = await writeJsonAtomic(cwd, filePath, merged, "write", {
1136
+ skill: mode,
1137
+ mutationId,
1138
+ force: forced,
1139
+ fromPhase,
1140
+ toPhase,
1141
+ });
1142
+ const stampedReceipt = isPlainObject(stamped.receipt) ? stamped.receipt : {};
492
1143
 
493
1144
  const phase = typeof merged.current_phase === "string" ? merged.current_phase : undefined;
494
1145
  const active = merged.active !== false;
@@ -496,7 +1147,17 @@ async function handleWrite(
496
1147
 
497
1148
  return {
498
1149
  status: 0,
499
- 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
+ }),
1160
+ ...(outOfBandWarning ? { stderr: `${outOfBandWarning}\n` } : {}),
500
1161
  };
501
1162
  }
502
1163
 
@@ -515,14 +1176,44 @@ async function handleClear(
515
1176
  );
516
1177
 
517
1178
  const filePath = modeStateFile(cwd, mode, sessionId);
518
- 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();
519
1189
  const cleared: Record<string, unknown> = {
1190
+ skill: mode,
520
1191
  ...existing,
521
1192
  active: false,
522
1193
  current_phase: "complete",
523
- updated_at: nowIso(),
1194
+ updated_at: clearedAt,
1195
+ version: WORKFLOW_STATE_VERSION,
524
1196
  };
525
- await writeJsonAtomic(filePath, cleared);
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", {
1210
+ skill: mode,
1211
+ mutationId,
1212
+ force: forced,
1213
+ fromPhase: typeof existing.current_phase === "string" ? existing.current_phase : undefined,
1214
+ toPhase: "complete",
1215
+ });
1216
+ const stampedReceipt = isPlainObject(stamped.receipt) ? stamped.receipt : {};
526
1217
 
527
1218
  await syncWorkflowSkillState({
528
1219
  cwd,
@@ -534,8 +1225,20 @@ async function handleClear(
534
1225
  phase: "complete",
535
1226
  payload: cleared,
536
1227
  });
537
-
538
- return { status: 0, stdout: `${JSON.stringify(cleared, null, 2)}\n` };
1228
+ return {
1229
+ status: 0,
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
+ }),
1240
+ ...(outOfBandWarning ? { stderr: `${outOfBandWarning}\n` } : {}),
1241
+ };
539
1242
  }
540
1243
 
541
1244
  /**
@@ -583,16 +1286,32 @@ async function handleHandoff(
583
1286
 
584
1287
  const callerPath = modeStateFile(cwd, caller, sessionId);
585
1288
  const calleePath = modeStateFile(cwd, callee, sessionId);
586
- const existingCaller = await readJsonFile(callerPath);
587
- 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") {
588
1298
  throw new StateCommandError(
589
1299
  2,
590
1300
  `gjc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
591
1301
  );
592
1302
  }
593
- 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 : {};
594
1312
 
595
1313
  const handoffAt = nowIso();
1314
+ const mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
596
1315
  const callerReceipt = buildWorkflowStateReceipt({
597
1316
  cwd,
598
1317
  skill: caller,
@@ -600,6 +1319,7 @@ async function handleHandoff(
600
1319
  command: `gjc state ${caller} handoff --to ${callee}`,
601
1320
  sessionId,
602
1321
  nowIso: handoffAt,
1322
+ mutationId,
603
1323
  });
604
1324
  const calleeReceipt = buildWorkflowStateReceipt({
605
1325
  cwd,
@@ -608,13 +1328,16 @@ async function handleHandoff(
608
1328
  command: `gjc state ${caller} handoff --to ${callee}`,
609
1329
  sessionId,
610
1330
  nowIso: handoffAt,
1331
+ mutationId,
611
1332
  });
612
1333
 
613
1334
  const calleeInitial = initialPhaseForSkill(callee);
1335
+ const normalizedCaller = migrateWorkflowState(existingCaller, caller).state;
1336
+ const normalizedCallee = migrateWorkflowState(existingCallee, callee).state;
614
1337
  const mergedCalleeState: Record<string, unknown> = {
615
- ...existingCallee,
1338
+ ...normalizedCallee,
616
1339
  skill: callee,
617
- version: typeof existingCallee.version === "number" ? existingCallee.version : 1,
1340
+ version: WORKFLOW_STATE_VERSION,
618
1341
  active: true,
619
1342
  current_phase: calleeInitial,
620
1343
  handoff_from: caller,
@@ -626,8 +1349,9 @@ async function handleHandoff(
626
1349
  mergedCalleeState.session_id = sessionId;
627
1350
  }
628
1351
  const mergedCallerState: Record<string, unknown> = {
629
- ...existingCaller,
1352
+ ...normalizedCaller,
630
1353
  skill: caller,
1354
+ version: WORKFLOW_STATE_VERSION,
631
1355
  active: false,
632
1356
  current_phase: "handoff",
633
1357
  handoff_to: callee,
@@ -636,6 +1360,14 @@ async function handleHandoff(
636
1360
  receipt: callerReceipt,
637
1361
  };
638
1362
 
1363
+ await beginWorkflowTransactionJournal({
1364
+ cwd,
1365
+ mutationId,
1366
+ caller,
1367
+ callee,
1368
+ paths: [calleePath, callerPath, activeStateFile(cwd, sessionId)],
1369
+ });
1370
+
639
1371
  // Atomic write order (architecture blocker AR-3): mode-state files first,
640
1372
  // then a single atomic active-state mutation per file (session before root)
641
1373
  // via applyHandoffToActiveState. The single-write transaction prevents the
@@ -643,8 +1375,34 @@ async function handleHandoff(
643
1375
  // and write order keeps the session-scoped source of truth ahead of the
644
1376
  // root aggregate. strict:true on the active-state read tolerates ENOENT
645
1377
  // only; corrupt JSON / IO failures propagate as non-zero CLI status.
646
- await writeJsonAtomic(calleePath, mergedCalleeState);
647
- await writeJsonAtomic(callerPath, mergedCallerState);
1378
+ const force = hasFlag(args, "--force");
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 : {};
1402
+ for (const warning of warnings) process.stderr.write(`${warning}\n`);
1403
+ if (process.env.GJC_STATE_HANDOFF_FAIL_AFTER_CALLER === mutationId) {
1404
+ throw new StateCommandError(1, `injected handoff failure after caller write for ${mutationId}`);
1405
+ }
648
1406
  await applyHandoffToActiveState({
649
1407
  cwd,
650
1408
  nowIso: handoffAt,
@@ -678,20 +1436,40 @@ async function handleHandoff(
678
1436
  receipt: calleeReceipt,
679
1437
  },
680
1438
  });
1439
+ await updateWorkflowTransactionJournal(cwd, mutationId, {
1440
+ steps: ["callee-mode-state", "caller-mode-state", "active-state"],
1441
+ });
1442
+ await completeWorkflowTransactionJournal(cwd, mutationId);
681
1443
 
682
1444
  return {
683
1445
  status: 0,
684
- stdout: `${JSON.stringify(
685
- {
686
- from: caller,
687
- to: callee,
688
- handoff_at: handoffAt,
689
- caller_state: mergedCallerState,
690
- 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,
691
1454
  },
692
- null,
693
- 2,
694
- )}\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
+ }),
695
1473
  };
696
1474
  }
697
1475
 
@@ -705,14 +1483,333 @@ async function handleContract(
705
1483
  throw new StateCommandError(2, "gjc state contract requires --mode <skill>, positional <skill>, or input.skill");
706
1484
  }
707
1485
  const payload = { skill: mode, contract: describeWorkflowStateContract(mode) };
708
- return { status: 0, stdout: `${JSON.stringify(payload, null, 2)}\n` };
1486
+ return {
1487
+ status: 0,
1488
+ stdout: hasFlag(args, "--json")
1489
+ ? `${JSON.stringify(payload, null, 2)}\n`
1490
+ : renderContractMarkdown(mode, payload.contract),
1491
+ };
1492
+ }
1493
+
1494
+ function parseNonNegativeIntegerFlag(args: readonly string[], flag: string): number | undefined {
1495
+ const value = flagValue(args, flag);
1496
+ if (value === undefined) return undefined;
1497
+ const parsed = Number(value);
1498
+ if (!Number.isInteger(parsed) || parsed < 0) {
1499
+ throw new StateCommandError(2, `gjc state ${flag} requires a non-negative integer value`);
1500
+ }
1501
+ return parsed;
1502
+ }
1503
+
1504
+ function statusFromFile(value: unknown): string | undefined {
1505
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1506
+ const record = value as Record<string, unknown>;
1507
+ if (typeof record.status === "string") return record.status;
1508
+ if (record.receipt && typeof record.receipt === "object" && !Array.isArray(record.receipt)) {
1509
+ const receiptStatus = (record.receipt as Record<string, unknown>).status;
1510
+ if (typeof receiptStatus === "string") return receiptStatus;
1511
+ }
1512
+ return undefined;
1513
+ }
1514
+
1515
+ interface RetentionCandidate {
1516
+ path: string;
1517
+ relativePath: string;
1518
+ category: string;
1519
+ mtimeMs: number;
1520
+ policy: { keep?: number; maxAgeDays?: number };
1521
+ }
1522
+
1523
+ interface GcSummary {
1524
+ skill: CanonicalGjcWorkflowSkill | "all";
1525
+ dry_run: boolean;
1526
+ eligible: string[];
1527
+ pruned: string[];
1528
+ counts: Record<string, number>;
1529
+ }
1530
+
1531
+ function categoryForStateRelativePath(relativePath: string): string | undefined {
1532
+ const normalized = relativePath.split(path.sep).join("/");
1533
+ if (normalized === "audit.jsonl") return undefined;
1534
+ if (normalized === SKILL_ACTIVE_STATE_FILE || normalized.endsWith(`/${SKILL_ACTIVE_STATE_FILE}`)) return undefined;
1535
+ if (normalized.startsWith("active/") || normalized.includes("/active/")) return undefined;
1536
+ if (
1537
+ /^[^/]+-state\.json$/.test(normalized) ||
1538
+ (normalized.includes("/sessions/") && /\/[^/]+-state\.json$/.test(normalized))
1539
+ )
1540
+ return undefined;
1541
+ if (normalized.startsWith("artifacts/") || normalized.includes("/artifacts/")) return "artifact";
1542
+ if (
1543
+ normalized.startsWith("logs/") ||
1544
+ normalized.includes("/logs/") ||
1545
+ normalized.endsWith(".log") ||
1546
+ normalized.endsWith(".jsonl")
1547
+ )
1548
+ return "log";
1549
+ if (normalized.startsWith("reports/") || normalized.includes("/reports/")) return "report";
1550
+ if (normalized.startsWith("ledgers/") || normalized.includes("/ledgers/")) return "ledger";
1551
+ if (normalized.startsWith("agents/") || normalized.includes("/agents/")) return "agents";
1552
+ if (normalized.startsWith("force/") || normalized.includes("/force/")) return "force";
1553
+ if (
1554
+ normalized.startsWith("prune/") ||
1555
+ normalized.includes("/prune/") ||
1556
+ normalized.startsWith("delete/") ||
1557
+ normalized.includes("/delete/")
1558
+ )
1559
+ return "prune/delete";
1560
+ if (normalized.startsWith("transactions/") || normalized.includes("/transactions/")) return "prune/delete";
1561
+ return undefined;
1562
+ }
1563
+
1564
+ async function collectRetentionCandidates(
1565
+ cwd: string,
1566
+ skills: readonly CanonicalGjcWorkflowSkill[],
1567
+ ): Promise<RetentionCandidate[]> {
1568
+ const stateRoot = path.join(cwd, ".gjc", "state");
1569
+ const policies = new Map<string, { keep?: number; maxAgeDays?: number }>();
1570
+ for (const skill of skills) {
1571
+ for (const policy of getSkillManifest(skill).retention) {
1572
+ const existing = policies.get(policy.category);
1573
+ policies.set(policy.category, {
1574
+ keep: Math.max(existing?.keep ?? 0, policy.keep ?? 0) || undefined,
1575
+ maxAgeDays:
1576
+ existing?.maxAgeDays === undefined
1577
+ ? policy.maxAgeDays
1578
+ : policy.maxAgeDays === undefined
1579
+ ? existing.maxAgeDays
1580
+ : Math.max(existing.maxAgeDays, policy.maxAgeDays),
1581
+ });
1582
+ }
1583
+ }
1584
+ const candidates: RetentionCandidate[] = [];
1585
+ async function visit(dir: string): Promise<void> {
1586
+ let entries: string[];
1587
+ try {
1588
+ entries = await fs.readdir(dir);
1589
+ } catch (error) {
1590
+ const err = error as NodeJS.ErrnoException;
1591
+ if (err.code === "ENOENT") return;
1592
+ throw error;
1593
+ }
1594
+ for (const entry of entries) {
1595
+ const filePath = path.join(dir, entry);
1596
+ const stat = await fs.stat(filePath);
1597
+ if (stat.isDirectory()) {
1598
+ await visit(filePath);
1599
+ continue;
1600
+ }
1601
+ if (!stat.isFile()) continue;
1602
+ const relativePath = path.relative(stateRoot, filePath);
1603
+ const category = categoryForStateRelativePath(relativePath);
1604
+ if (!category) continue;
1605
+ const policy = policies.get(category);
1606
+ if (!policy) continue;
1607
+ candidates.push({ path: filePath, relativePath, category, mtimeMs: stat.mtimeMs, policy });
1608
+ }
1609
+ }
1610
+ await visit(stateRoot);
1611
+ return candidates;
1612
+ }
1613
+
1614
+ function selectRetentionEligible(candidates: readonly RetentionCandidate[]): RetentionCandidate[] {
1615
+ const now = Date.now();
1616
+ const byCategory = new Map<string, RetentionCandidate[]>();
1617
+ for (const candidate of candidates) {
1618
+ const list = byCategory.get(candidate.category) ?? [];
1619
+ list.push(candidate);
1620
+ byCategory.set(candidate.category, list);
1621
+ }
1622
+ const eligible = new Set<RetentionCandidate>();
1623
+ for (const list of byCategory.values()) {
1624
+ list.sort((a, b) => b.mtimeMs - a.mtimeMs || a.relativePath.localeCompare(b.relativePath));
1625
+ for (let index = 0; index < list.length; index += 1) {
1626
+ const candidate = list[index];
1627
+ const keep = candidate.policy.keep ?? 0;
1628
+ if (keep > 0 && index < keep) continue;
1629
+ if (candidate.policy.maxAgeDays !== undefined) {
1630
+ const maxAgeMs = candidate.policy.maxAgeDays * 24 * 60 * 60 * 1000;
1631
+ if (now - candidate.mtimeMs < maxAgeMs) continue;
1632
+ }
1633
+ if (candidate.policy.keep !== undefined || candidate.policy.maxAgeDays !== undefined) eligible.add(candidate);
1634
+ }
1635
+ }
1636
+ return [...eligible].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
1637
+ }
1638
+
1639
+ async function buildGcSummary(
1640
+ args: readonly string[],
1641
+ cwd: string,
1642
+ positionalSkill: string | undefined,
1643
+ dryRun: boolean,
1644
+ ): Promise<GcSummary> {
1645
+ const rawSkill =
1646
+ flagValue(args, "--skill")?.trim() || flagValue(args, "--mode")?.trim() || positionalSkill?.trim() || "all";
1647
+ if (rawSkill !== "all") assertKnownMode(rawSkill);
1648
+ const skills = rawSkill === "all" ? CANONICAL_GJC_WORKFLOW_SKILLS : [rawSkill as CanonicalGjcWorkflowSkill];
1649
+ const eligible = selectRetentionEligible(await collectRetentionCandidates(cwd, skills));
1650
+ const counts: Record<string, number> = {};
1651
+ for (const candidate of eligible) counts[candidate.category] = (counts[candidate.category] ?? 0) + 1;
1652
+ const targets: GenericHardPruneTarget[] = eligible.map(candidate => ({
1653
+ path: candidate.path,
1654
+ category: candidate.category,
1655
+ }));
1656
+ let pruned: string[] = [];
1657
+ if (!dryRun && targets.length > 0) {
1658
+ const eligiblePaths = new Set(eligible.map(candidate => path.resolve(candidate.path)));
1659
+ pruned = await hardPrune(targets, context => eligiblePaths.has(path.resolve(context.path)), {
1660
+ cwd,
1661
+ audit: {
1662
+ cwd,
1663
+ skill: rawSkill,
1664
+ category: "prune",
1665
+ verb: "gc",
1666
+ owner: "gjc-state-cli",
1667
+ },
1668
+ });
1669
+ }
1670
+ return {
1671
+ skill: rawSkill as CanonicalGjcWorkflowSkill | "all",
1672
+ dry_run: dryRun,
1673
+ eligible: eligible.map(candidate => candidate.relativePath),
1674
+ pruned: pruned.map(filePath => path.relative(path.join(cwd, ".gjc", "state"), filePath)),
1675
+ counts,
1676
+ };
1677
+ }
1678
+
1679
+ async function handleGraph(
1680
+ args: readonly string[],
1681
+ _cwd: string,
1682
+ positionalSkill: string | undefined,
1683
+ ): Promise<StateCommandResult> {
1684
+ if (hasFlag(args, "--history")) {
1685
+ const history = await readAuditWindow(_cwd, args);
1686
+ return {
1687
+ status: 0,
1688
+ stdout: hasFlag(args, "--json") ? `${JSON.stringify(history, null, 2)}\n` : renderHistoryMarkdown(history),
1689
+ };
1690
+ }
1691
+ const rawSkill = flagValue(args, "--skill")?.trim() || positionalSkill?.trim() || "all";
1692
+ if (rawSkill !== "all") assertKnownMode(rawSkill);
1693
+ const format = flagValue(args, "--format")?.trim() || "ascii";
1694
+ if (!GRAPH_FORMATS.has(format)) {
1695
+ throw new StateCommandError(2, `Invalid graph format: ${format}. Expected one of: ascii, mermaid, dot.`);
1696
+ }
1697
+ return {
1698
+ status: 0,
1699
+ stdout: renderStateGraph(rawSkill as CanonicalGjcWorkflowSkill | "all", format as StateGraphFormat),
1700
+ };
1701
+ }
1702
+
1703
+ async function handlePrune(
1704
+ args: readonly string[],
1705
+ cwd: string,
1706
+ positionalSkill: string | undefined,
1707
+ ): Promise<StateCommandResult> {
1708
+ const selectors = await resolveSelectors(args, cwd, positionalSkill);
1709
+ const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
1710
+ if (!mode) {
1711
+ throw new StateCommandError(
1712
+ 2,
1713
+ "gjc state prune requires --mode <skill>, positional <skill>, input.skill, or an active workflow in .gjc/state/skill-active-state.json",
1714
+ );
1715
+ }
1716
+ const filePath = modeStateFile(cwd, mode, selectors.sessionId);
1717
+ const olderThanDays = parseNonNegativeIntegerFlag(args, "--older-than");
1718
+ const status = flagValue(args, "--status")?.trim();
1719
+ const targets: GenericHardPruneTarget[] = [{ path: filePath, category: "prune" }];
1720
+ const audit: StateWriterAuditContext = {
1721
+ cwd,
1722
+ skill: mode,
1723
+ category: "prune",
1724
+ verb: hasFlag(args, "--hard") ? "hard-prune" : "soft-delete",
1725
+ owner: "gjc-state-cli",
1726
+ };
1727
+ const olderThanMs = olderThanDays === undefined ? undefined : olderThanDays * 24 * 60 * 60 * 1000;
1728
+ const matchesSelector = async (
1729
+ stat: { mtimeMs: number | bigint },
1730
+ readJson: () => Promise<unknown>,
1731
+ ): Promise<boolean> => {
1732
+ const mtimeMs = typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs;
1733
+ if (olderThanMs !== undefined && Date.now() - mtimeMs < olderThanMs) return false;
1734
+ if (status) return statusFromFile(await readJson()) === status;
1735
+ return true;
1736
+ };
1737
+ if (hasFlag(args, "--hard")) {
1738
+ const pruned = await hardPrune(
1739
+ targets,
1740
+ context => (context.stat ? matchesSelector(context.stat, context.readJson) : false),
1741
+ { cwd, audit },
1742
+ );
1743
+ return { status: 0, stdout: `${JSON.stringify({ skill: mode, hard: true, pruned }, null, 2)}\n` };
1744
+ }
1745
+ let deleted: string[] = [];
1746
+ try {
1747
+ const stat = await fs.stat(filePath);
1748
+ if (await matchesSelector(stat, async () => JSON.parse(await fs.readFile(filePath, "utf-8")))) {
1749
+ const archivedPath = await softDelete(
1750
+ filePath,
1751
+ { skill: mode, reason: "gjc state prune", status: status ?? null, older_than_days: olderThanDays ?? null },
1752
+ { cwd, audit },
1753
+ );
1754
+ deleted = [archivedPath];
1755
+ }
1756
+ } catch (error) {
1757
+ const err = error as NodeJS.ErrnoException;
1758
+ if (err.code !== "ENOENT") throw error;
1759
+ }
1760
+ return { status: 0, stdout: `${JSON.stringify({ skill: mode, hard: false, soft_deleted: deleted }, null, 2)}\n` };
1761
+ }
1762
+
1763
+ async function handleGc(
1764
+ args: readonly string[],
1765
+ cwd: string,
1766
+ positionalSkill: string | undefined,
1767
+ ): Promise<StateCommandResult> {
1768
+ const summary = await buildGcSummary(args, cwd, positionalSkill, hasFlag(args, "--dry-run"));
1769
+ return { status: 0, stdout: `${JSON.stringify(summary, null, 2)}\n` };
1770
+ }
1771
+
1772
+ async function handleMigrate(
1773
+ args: readonly string[],
1774
+ cwd: string,
1775
+ positionalSkill: string | undefined,
1776
+ ): Promise<StateCommandResult> {
1777
+ const selectors = await resolveSelectors(args, cwd, positionalSkill);
1778
+ const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
1779
+ if (!mode) {
1780
+ throw new StateCommandError(
1781
+ 2,
1782
+ "gjc state migrate requires --mode <skill>, positional <skill>, input.skill, or an active workflow in .gjc/state/skill-active-state.json",
1783
+ );
1784
+ }
1785
+ const filePath = modeStateFile(cwd, mode, selectors.sessionId);
1786
+ const forced = hasFlag(args, "--force");
1787
+ const mismatchWarning = await warnAndAuditOutOfBandIfNeeded(cwd, filePath, mode, {
1788
+ forced,
1789
+ });
1790
+ if (mismatchWarning && !forced) {
1791
+ throw new StateCommandError(2, `${mismatchWarning}; use --force to migrate tampered mode-state`);
1792
+ }
1793
+ const result = await migrateAndPersistLegacyState({
1794
+ cwd,
1795
+ skill: mode,
1796
+ statePath: filePath,
1797
+ sessionId: selectors.sessionId,
1798
+ });
1799
+ return {
1800
+ status: 0,
1801
+ stdout: `${JSON.stringify({ skill: mode, ...result, integrity_mismatch: Boolean(mismatchWarning) }, null, 2)}\n`,
1802
+ ...(mismatchWarning ? { stderr: `${mismatchWarning}\n` } : {}),
1803
+ };
709
1804
  }
710
1805
 
711
1806
  export async function runNativeStateCommand(args: string[], cwd = process.cwd()): Promise<StateCommandResult> {
712
1807
  try {
713
1808
  const parsed = parsePositionalArgs(args);
1809
+ assertKnownFlags(args, parsed);
714
1810
  switch (parsed.action) {
715
1811
  case "read":
1812
+ if (hasFlag(args, "--migrate")) return await handleMigrate(args, cwd, parsed.positionalSkill);
716
1813
  return await handleRead(args, cwd, parsed.positionalSkill);
717
1814
  case "write":
718
1815
  return await handleWrite(args, cwd, parsed.positionalSkill);
@@ -720,8 +1817,20 @@ export async function runNativeStateCommand(args: string[], cwd = process.cwd())
720
1817
  return await handleClear(args, cwd, parsed.positionalSkill);
721
1818
  case "contract":
722
1819
  return await handleContract(args, cwd, parsed.positionalSkill);
1820
+ case "status":
1821
+ return await handleStatus(args, cwd, parsed.positionalSkill);
1822
+ case "doctor":
1823
+ return await handleDoctor(args, cwd, parsed.positionalSkill);
723
1824
  case "handoff":
724
1825
  return await handleHandoff(args, cwd, parsed.positionalSkill);
1826
+ case "graph":
1827
+ return await handleGraph(args, cwd, parsed.positionalSkill);
1828
+ case "prune":
1829
+ return await handlePrune(args, cwd, parsed.positionalSkill);
1830
+ case "gc":
1831
+ return await handleGc(args, cwd, parsed.positionalSkill);
1832
+ case "migrate":
1833
+ return await handleMigrate(args, cwd, parsed.positionalSkill);
725
1834
  default:
726
1835
  return { status: 2, stderr: `Unknown gjc state command: ${parsed.action}\n` };
727
1836
  }