@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.
- package/CHANGELOG.md +28 -0
- package/dist/types/async/job-manager.d.ts +91 -2
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/commands/harness.d.ts +37 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +10 -4
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/debug/crash-diagnostics.d.ts +45 -0
- package/dist/types/debug/runtime-gauges.d.ts +6 -0
- package/dist/types/deep-interview/render-middleware.d.ts +6 -0
- package/dist/types/eval/py/executor.d.ts +2 -0
- package/dist/types/eval/py/kernel.d.ts +2 -0
- package/dist/types/exec/bash-executor.d.ts +10 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +6 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
- package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
- package/dist/types/gjc-runtime/state-migrations.d.ts +33 -0
- package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
- package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +147 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
- package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
- package/dist/types/harness-control-plane/classifier.d.ts +13 -0
- package/dist/types/harness-control-plane/control-endpoint.d.ts +31 -0
- package/dist/types/harness-control-plane/finalize.d.ts +47 -0
- package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
- package/dist/types/harness-control-plane/operate.d.ts +35 -0
- package/dist/types/harness-control-plane/owner.d.ts +46 -0
- package/dist/types/harness-control-plane/preserve.d.ts +19 -0
- package/dist/types/harness-control-plane/receipts.d.ts +88 -0
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
- package/dist/types/harness-control-plane/seams.d.ts +21 -0
- package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
- package/dist/types/harness-control-plane/storage.d.ts +53 -0
- package/dist/types/harness-control-plane/types.d.ts +162 -0
- package/dist/types/hooks/skill-keywords.d.ts +2 -1
- package/dist/types/hooks/skill-state.d.ts +23 -29
- package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
- package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
- package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
- package/dist/types/internal-urls/types.d.ts +4 -0
- package/dist/types/lsp/index.d.ts +10 -10
- package/dist/types/modes/bridge/auth.d.ts +12 -0
- package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
- package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
- package/dist/types/modes/bridge/event-stream.d.ts +8 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -0
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
- package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
- package/dist/types/modes/index.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/jobs-observer.d.ts +57 -0
- package/dist/types/modes/rpc/host-tools.d.ts +1 -16
- package/dist/types/modes/rpc/host-uris.d.ts +1 -38
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
- package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
- package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
- package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
- package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
- package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
- package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
- package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +19 -1
- package/dist/types/skill-state/active-state.d.ts +2 -0
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
- package/dist/types/skill-state/workflow-state-contract.d.ts +25 -2
- package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/id.d.ts +7 -0
- package/dist/types/task/index.d.ts +5 -0
- package/dist/types/task/receipt.d.ts +85 -0
- package/dist/types/task/spawn-gate.d.ts +38 -0
- package/dist/types/task/types.d.ts +198 -14
- package/dist/types/tools/cron.d.ts +6 -0
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +26 -1
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +334 -6
- package/src/cli/args.ts +9 -2
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/config-cli.ts +10 -2
- package/src/cli.ts +2 -0
- package/src/commands/deep-interview.ts +1 -0
- package/src/commands/harness.ts +862 -0
- package/src/commands/launch.ts +2 -2
- package/src/commands/state.ts +2 -1
- package/src/commands/team.ts +54 -39
- package/src/config/keybindings.ts +6 -0
- package/src/config/settings-schema.ts +13 -3
- package/src/config/settings.ts +5 -0
- package/src/dap/client.ts +17 -3
- package/src/debug/crash-diagnostics.ts +223 -0
- package/src/debug/runtime-gauges.ts +20 -0
- package/src/deep-interview/render-middleware.ts +372 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
- package/src/defaults/gjc/skills/team/SKILL.md +47 -21
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +106 -13
- package/src/eval/py/executor.ts +21 -1
- package/src/eval/py/kernel.ts +15 -0
- package/src/exec/bash-executor.ts +41 -0
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/extensions/types.ts +6 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/cli-write-receipt.ts +31 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +98 -42
- package/src/gjc-runtime/goal-mode-request.ts +11 -3
- package/src/gjc-runtime/ralplan-runtime.ts +235 -43
- package/src/gjc-runtime/state-graph.ts +86 -0
- package/src/gjc-runtime/state-migrations.ts +179 -0
- package/src/gjc-runtime/state-renderer.ts +345 -0
- package/src/gjc-runtime/state-runtime.ts +1155 -46
- package/src/gjc-runtime/state-schema.ts +192 -0
- package/src/gjc-runtime/state-validation.ts +49 -0
- package/src/gjc-runtime/state-writer.ts +749 -0
- package/src/gjc-runtime/team-runtime.ts +1255 -189
- package/src/gjc-runtime/ultragoal-runtime.ts +460 -43
- package/src/gjc-runtime/workflow-command-ref.ts +239 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +1601 -0
- package/src/gjc-runtime/workflow-manifest.ts +427 -0
- package/src/harness-control-plane/classifier.ts +128 -0
- package/src/harness-control-plane/control-endpoint.ts +148 -0
- package/src/harness-control-plane/finalize.ts +222 -0
- package/src/harness-control-plane/frame-mapper.ts +286 -0
- package/src/harness-control-plane/operate.ts +225 -0
- package/src/harness-control-plane/owner.ts +600 -0
- package/src/harness-control-plane/preserve.ts +102 -0
- package/src/harness-control-plane/receipts.ts +216 -0
- package/src/harness-control-plane/rpc-adapter.ts +276 -0
- package/src/harness-control-plane/seams.ts +39 -0
- package/src/harness-control-plane/session-lease.ts +388 -0
- package/src/harness-control-plane/state-machine.ts +98 -0
- package/src/harness-control-plane/storage.ts +257 -0
- package/src/harness-control-plane/types.ts +214 -0
- package/src/hooks/skill-keywords.ts +4 -2
- package/src/hooks/skill-state.ts +197 -64
- package/src/internal-urls/agent-protocol.ts +68 -21
- package/src/internal-urls/artifact-protocol.ts +12 -17
- package/src/internal-urls/docs-index.generated.ts +3 -2
- package/src/internal-urls/registry-helpers.ts +19 -16
- package/src/internal-urls/types.ts +4 -0
- package/src/lsp/client.ts +18 -2
- package/src/main.ts +21 -5
- package/src/modes/bridge/auth.ts +41 -0
- package/src/modes/bridge/bridge-client-bridge.ts +47 -0
- package/src/modes/bridge/bridge-mode.ts +520 -0
- package/src/modes/bridge/bridge-ui-context.ts +200 -0
- package/src/modes/bridge/event-stream.ts +70 -0
- package/src/modes/components/assistant-message.ts +5 -1
- package/src/modes/components/custom-editor.ts +101 -0
- package/src/modes/components/hook-selector.ts +133 -20
- package/src/modes/components/jobs-overlay-model.ts +109 -0
- package/src/modes/components/jobs-overlay.ts +172 -0
- package/src/modes/components/status-line/presets.ts +7 -5
- package/src/modes/components/status-line/segments.ts +25 -0
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line.ts +9 -1
- package/src/modes/controllers/event-controller.ts +71 -6
- package/src/modes/controllers/extension-ui-controller.ts +43 -1
- package/src/modes/controllers/input-controller.ts +105 -9
- package/src/modes/controllers/selector-controller.ts +31 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +28 -0
- package/src/modes/jobs-observer.ts +204 -0
- package/src/modes/rpc/host-tools.ts +1 -186
- package/src/modes/rpc/host-uris.ts +1 -235
- package/src/modes/rpc/rpc-client.ts +25 -10
- package/src/modes/rpc/rpc-mode.ts +12 -381
- package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
- package/src/modes/shared/agent-wire/command-validation.ts +131 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
- package/src/modes/shared/agent-wire/handshake.ts +117 -0
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
- package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
- package/src/modes/shared/agent-wire/protocol.ts +96 -0
- package/src/modes/shared/agent-wire/responses.ts +17 -0
- package/src/modes/shared/agent-wire/scopes.ts +89 -0
- package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
- package/src/modes/shared/agent-wire/ui-result.ts +48 -0
- package/src/modes/types.ts +2 -0
- package/src/prompts/agents/executor.md +13 -0
- package/src/prompts/tools/subagent.md +39 -4
- package/src/prompts/tools/task-summary.md +3 -9
- package/src/prompts/tools/task.md +5 -1
- package/src/sdk.ts +8 -0
- package/src/session/agent-session.ts +445 -71
- package/src/session/session-manager.ts +13 -1
- package/src/skill-state/active-state.ts +58 -65
- package/src/skill-state/deep-interview-mutation-guard.ts +114 -17
- package/src/skill-state/initial-phase.ts +2 -0
- package/src/skill-state/workflow-state-contract.ts +33 -4
- package/src/skill-state/workflow-state-version.ts +3 -0
- package/src/slash-commands/builtin-registry.ts +8 -0
- package/src/task/executor.ts +79 -13
- package/src/task/id.ts +33 -0
- package/src/task/index.ts +376 -74
- package/src/task/output-manager.ts +5 -4
- package/src/task/receipt.ts +297 -0
- package/src/task/render.ts +54 -134
- package/src/task/spawn-gate.ts +132 -0
- package/src/task/types.ts +104 -10
- package/src/tools/ask.ts +88 -27
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/cron.ts +48 -0
- package/src/tools/find.ts +4 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +1 -0
- package/src/tools/search.ts +1 -0
- package/src/tools/skill.ts +6 -1
- 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
|
|
66
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
484
|
-
typeof existingPayload.current_phase === "string" ? existingPayload.current_phase : "
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
1194
|
+
updated_at: clearedAt,
|
|
1195
|
+
version: WORKFLOW_STATE_VERSION,
|
|
524
1196
|
};
|
|
525
|
-
|
|
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
|
-
|
|
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
|
|
587
|
-
|
|
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
|
|
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
|
-
...
|
|
1338
|
+
...normalizedCallee,
|
|
616
1339
|
skill: callee,
|
|
617
|
-
version:
|
|
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
|
-
...
|
|
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
|
-
|
|
647
|
-
await writeJsonAtomic(
|
|
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:
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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 {
|
|
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
|
}
|