@gajae-code/coding-agent 0.2.5 → 0.3.0
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 +10 -0
- package/dist/types/async/job-manager.d.ts +84 -2
- package/dist/types/commands/harness.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +6 -0
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/deep-interview/render-middleware.d.ts +5 -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/state-graph.d.ts +4 -0
- package/dist/types/gjc-runtime/state-migrations.d.ts +24 -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-validation.d.ts +6 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +137 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
- 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 +30 -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 +2 -29
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +8 -0
- 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 +24 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/types.d.ts +55 -3
- package/dist/types/tools/subagent.d.ts +11 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +298 -6
- 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/harness.ts +592 -0
- package/src/commands/team.ts +36 -39
- package/src/config/settings-schema.ts +7 -0
- package/src/config/settings.ts +5 -0
- package/src/deep-interview/render-middleware.ts +366 -0
- package/src/defaults/gjc/skills/team/SKILL.md +47 -21
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +78 -11
- 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/deep-interview-runtime.ts +40 -21
- package/src/gjc-runtime/goal-mode-request.ts +11 -3
- package/src/gjc-runtime/ralplan-runtime.ts +25 -10
- package/src/gjc-runtime/state-graph.ts +86 -0
- package/src/gjc-runtime/state-migrations.ts +132 -0
- package/src/gjc-runtime/state-renderer.ts +345 -0
- package/src/gjc-runtime/state-runtime.ts +733 -21
- package/src/gjc-runtime/state-validation.ts +49 -0
- package/src/gjc-runtime/state-writer.ts +718 -0
- package/src/gjc-runtime/team-runtime.ts +1083 -89
- package/src/gjc-runtime/ultragoal-runtime.ts +348 -19
- package/src/gjc-runtime/workflow-manifest.generated.json +1497 -0
- package/src/gjc-runtime/workflow-manifest.ts +425 -0
- package/src/harness-control-plane/classifier.ts +128 -0
- package/src/harness-control-plane/control-endpoint.ts +137 -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 +553 -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 +97 -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 +24 -41
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/assistant-message.ts +5 -1
- package/src/modes/components/hook-selector.ts +72 -2
- package/src/modes/controllers/event-controller.ts +71 -6
- package/src/modes/controllers/extension-ui-controller.ts +6 -0
- package/src/modes/controllers/input-controller.ts +9 -1
- package/src/modes/controllers/selector-controller.ts +2 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/types.ts +1 -0
- package/src/prompts/agents/executor.md +13 -0
- package/src/prompts/tools/subagent.md +33 -3
- package/src/sdk.ts +4 -0
- package/src/session/agent-session.ts +231 -33
- 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 +91 -13
- package/src/skill-state/initial-phase.ts +2 -0
- package/src/skill-state/workflow-state-contract.ts +26 -0
- package/src/task/executor.ts +50 -8
- package/src/task/index.ts +120 -8
- package/src/task/render.ts +6 -3
- package/src/task/types.ts +56 -3
- package/src/tools/ask.ts +28 -7
- package/src/tools/subagent.ts +255 -64
|
@@ -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,39 @@ 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
24
|
type WorkflowStateReceipt,
|
|
25
25
|
} from "../skill-state/workflow-state-contract";
|
|
26
|
+
import { renderStateGraph, type StateGraphFormat } from "./state-graph";
|
|
27
|
+
import { migrateAndPersistLegacyState } from "./state-migrations";
|
|
28
|
+
import {
|
|
29
|
+
buildStateStatusSummary,
|
|
30
|
+
compactProjectStateJson,
|
|
31
|
+
projectStateFields,
|
|
32
|
+
renderContractMarkdown,
|
|
33
|
+
renderHistoryMarkdown,
|
|
34
|
+
renderStateMarkdown,
|
|
35
|
+
renderStateStatusLine,
|
|
36
|
+
STATE_FIELD_ALLOWLIST,
|
|
37
|
+
type StateProjectionField,
|
|
38
|
+
} from "./state-renderer";
|
|
39
|
+
import { validateWorkflowStateEnvelope } from "./state-validation";
|
|
40
|
+
import {
|
|
41
|
+
appendAuditEntry,
|
|
42
|
+
beginWorkflowTransactionJournal,
|
|
43
|
+
completeWorkflowTransactionJournal,
|
|
44
|
+
detectWorkflowEnvelopeIntegrityMismatch,
|
|
45
|
+
type GenericHardPruneTarget,
|
|
46
|
+
hardPrune,
|
|
47
|
+
type StateWriterAuditContext,
|
|
48
|
+
softDelete,
|
|
49
|
+
updateWorkflowTransactionJournal,
|
|
50
|
+
writeWorkflowEnvelopeAtomic,
|
|
51
|
+
} from "./state-writer";
|
|
52
|
+
import { getSkillManifest, isKnownWorkflowState, isValidTransition, typedArgsFor } from "./workflow-manifest";
|
|
26
53
|
|
|
27
54
|
/**
|
|
28
55
|
* Native implementation of the `gjc state read|write|clear` command surface.
|
|
@@ -62,11 +89,92 @@ function hasFlag(args: readonly string[], flag: string): boolean {
|
|
|
62
89
|
return args.includes(flag);
|
|
63
90
|
}
|
|
64
91
|
|
|
65
|
-
const
|
|
66
|
-
const
|
|
92
|
+
const GRAPH_FORMATS = new Set(["ascii", "mermaid", "dot"]);
|
|
93
|
+
const FLAGS_WITH_VALUES = new Set([
|
|
94
|
+
"--input",
|
|
95
|
+
"--mode",
|
|
96
|
+
"--session-id",
|
|
97
|
+
"--thread-id",
|
|
98
|
+
"--turn-id",
|
|
99
|
+
"--to",
|
|
100
|
+
"--skill",
|
|
101
|
+
"--format",
|
|
102
|
+
"--older-than",
|
|
103
|
+
"--status",
|
|
104
|
+
"--fields",
|
|
105
|
+
"--since",
|
|
106
|
+
"--limit",
|
|
107
|
+
]);
|
|
108
|
+
const ACTION_NAMES = new Set([
|
|
109
|
+
"read",
|
|
110
|
+
"write",
|
|
111
|
+
"clear",
|
|
112
|
+
"contract",
|
|
113
|
+
"handoff",
|
|
114
|
+
"graph",
|
|
115
|
+
"prune",
|
|
116
|
+
"gc",
|
|
117
|
+
"migrate",
|
|
118
|
+
"status",
|
|
119
|
+
]);
|
|
120
|
+
const BOOLEAN_FLAGS = new Set([
|
|
121
|
+
"--json",
|
|
122
|
+
"--replace",
|
|
123
|
+
"--hard",
|
|
124
|
+
"--dry-run",
|
|
125
|
+
"--migrate",
|
|
126
|
+
"--compact",
|
|
127
|
+
"--history",
|
|
128
|
+
"--force",
|
|
129
|
+
]);
|
|
130
|
+
const VERB_SPECIFIC_FLAGS = new Set([
|
|
131
|
+
"--skill",
|
|
132
|
+
"--format",
|
|
133
|
+
"--older-than",
|
|
134
|
+
"--status",
|
|
135
|
+
"--fields",
|
|
136
|
+
"--since",
|
|
137
|
+
"--limit",
|
|
138
|
+
"--history",
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
function flagName(arg: string): string | undefined {
|
|
142
|
+
if (!arg.startsWith("--")) return undefined;
|
|
143
|
+
const equalsIndex = arg.indexOf("=");
|
|
144
|
+
return equalsIndex >= 0 ? arg.slice(0, equalsIndex) : arg;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function manifestFlagNames(action: ParsedInvocation["action"], positionalSkill: string | undefined): Set<string> {
|
|
148
|
+
const names = new Set<string>();
|
|
149
|
+
const skills =
|
|
150
|
+
positionalSkill && KNOWN_MODES.includes(positionalSkill)
|
|
151
|
+
? [positionalSkill as CanonicalGjcWorkflowSkill]
|
|
152
|
+
: CANONICAL_GJC_WORKFLOW_SKILLS;
|
|
153
|
+
for (const skill of skills) {
|
|
154
|
+
for (const arg of typedArgsFor(skill, action)) names.add(`--${arg.name}`);
|
|
155
|
+
}
|
|
156
|
+
return names;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function assertKnownFlags(args: readonly string[], parsed: ParsedInvocation): void {
|
|
160
|
+
const manifestFlags = manifestFlagNames(parsed.action, parsed.positionalSkill);
|
|
161
|
+
for (const arg of args) {
|
|
162
|
+
const flag = flagName(arg);
|
|
163
|
+
if (!flag) continue;
|
|
164
|
+
if (
|
|
165
|
+
FLAGS_WITH_VALUES.has(flag) ||
|
|
166
|
+
BOOLEAN_FLAGS.has(flag) ||
|
|
167
|
+
VERB_SPECIFIC_FLAGS.has(flag) ||
|
|
168
|
+
manifestFlags.has(flag)
|
|
169
|
+
) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
throw new StateCommandError(2, `unknown gjc state flag: ${flag}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
67
175
|
|
|
68
176
|
interface ParsedInvocation {
|
|
69
|
-
action: "read" | "write" | "clear" | "contract" | "handoff";
|
|
177
|
+
action: "read" | "write" | "clear" | "contract" | "handoff" | "graph" | "prune" | "gc" | "migrate" | "status";
|
|
70
178
|
positionalSkill?: string;
|
|
71
179
|
}
|
|
72
180
|
|
|
@@ -90,7 +198,7 @@ function parsePositionalArgs(args: readonly string[]): ParsedInvocation {
|
|
|
90
198
|
const first = positional[0];
|
|
91
199
|
const second = positional[1];
|
|
92
200
|
if (first && ACTION_NAMES.has(first)) {
|
|
93
|
-
return { action: first as ParsedInvocation["action"] };
|
|
201
|
+
return { action: first as ParsedInvocation["action"], positionalSkill: second };
|
|
94
202
|
}
|
|
95
203
|
if (first && second && ACTION_NAMES.has(second)) {
|
|
96
204
|
return { action: second as ParsedInvocation["action"], positionalSkill: first };
|
|
@@ -240,11 +348,152 @@ async function readJsonFile(filePath: string): Promise<Record<string, unknown> |
|
|
|
240
348
|
}
|
|
241
349
|
}
|
|
242
350
|
|
|
243
|
-
async function
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
351
|
+
async function readJsonValue(filePath: string): Promise<unknown | null> {
|
|
352
|
+
try {
|
|
353
|
+
return JSON.parse(await fs.readFile(filePath, "utf-8"));
|
|
354
|
+
} catch (error) {
|
|
355
|
+
const err = error as NodeJS.ErrnoException;
|
|
356
|
+
if (err.code === "ENOENT") return null;
|
|
357
|
+
throw new StateCommandError(1, `failed to read ${filePath}: ${err.message}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function warnAndAuditOutOfBandIfNeeded(
|
|
362
|
+
cwd: string,
|
|
363
|
+
filePath: string,
|
|
364
|
+
skill: CanonicalGjcWorkflowSkill,
|
|
365
|
+
options?: { mutationId?: string; forced?: boolean },
|
|
366
|
+
): Promise<string | undefined> {
|
|
367
|
+
const mismatch = await detectWorkflowEnvelopeIntegrityMismatch(filePath);
|
|
368
|
+
if (!mismatch) return undefined;
|
|
369
|
+
const message = `WARNING: workflow mode-state out-of-band edit detected for ${skill}: ${filePath} expected sha256 ${mismatch.expected} but found ${mismatch.actual}`;
|
|
370
|
+
await appendAuditEntry(cwd, {
|
|
371
|
+
ts: new Date().toISOString(),
|
|
372
|
+
skill,
|
|
373
|
+
category: "state",
|
|
374
|
+
verb: "out_of_band_detected",
|
|
375
|
+
owner: "gjc-state-cli",
|
|
376
|
+
mutation_id: options?.mutationId ?? `${skill}:out-of-band:${new Date().toISOString()}`,
|
|
377
|
+
forced: options?.forced ?? false,
|
|
378
|
+
paths: [filePath],
|
|
379
|
+
expected_sha256: mismatch.expected,
|
|
380
|
+
actual_sha256: mismatch.actual,
|
|
381
|
+
} as AuditEntry);
|
|
382
|
+
return message;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function writeJsonAtomic(
|
|
386
|
+
cwd: string,
|
|
387
|
+
filePath: string,
|
|
388
|
+
value: unknown,
|
|
389
|
+
verb: "write" | "clear" | "handoff" = "write",
|
|
390
|
+
options?: {
|
|
391
|
+
skill?: CanonicalGjcWorkflowSkill;
|
|
392
|
+
mutationId?: string;
|
|
393
|
+
force?: boolean;
|
|
394
|
+
fromPhase?: string;
|
|
395
|
+
toPhase?: string;
|
|
396
|
+
},
|
|
397
|
+
): Promise<string | undefined> {
|
|
398
|
+
const warning = options?.skill
|
|
399
|
+
? await warnAndAuditOutOfBandIfNeeded(cwd, filePath, options.skill, {
|
|
400
|
+
mutationId: options.mutationId,
|
|
401
|
+
forced: options.force ?? false,
|
|
402
|
+
})
|
|
403
|
+
: undefined;
|
|
404
|
+
if (warning && !options?.force) {
|
|
405
|
+
throw new StateCommandError(2, `${warning}; use --force to overwrite tampered mode-state`);
|
|
406
|
+
}
|
|
407
|
+
await writeWorkflowEnvelopeAtomic(filePath, value, {
|
|
408
|
+
cwd,
|
|
409
|
+
audit: {
|
|
410
|
+
category: "state",
|
|
411
|
+
verb,
|
|
412
|
+
owner: "gjc-state-cli",
|
|
413
|
+
skill: options?.skill,
|
|
414
|
+
mutationId: options?.mutationId,
|
|
415
|
+
fromPhase: options?.fromPhase,
|
|
416
|
+
toPhase: options?.toPhase,
|
|
417
|
+
forced: options?.force ?? false,
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
return warning;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function parseFieldsFlag(args: readonly string[]): StateProjectionField[] | undefined {
|
|
424
|
+
const raw = flagValue(args, "--fields");
|
|
425
|
+
if (raw === undefined) return undefined;
|
|
426
|
+
const allowed = new Set<string>(STATE_FIELD_ALLOWLIST);
|
|
427
|
+
const fields = raw
|
|
428
|
+
.split(",")
|
|
429
|
+
.map(field => field.trim())
|
|
430
|
+
.filter(Boolean);
|
|
431
|
+
const unknown = fields.filter(field => !allowed.has(field));
|
|
432
|
+
if (unknown.length) {
|
|
433
|
+
throw new StateCommandError(
|
|
434
|
+
2,
|
|
435
|
+
`unknown --fields value(s): ${unknown.join(", ")}. Allowed fields: ${STATE_FIELD_ALLOWLIST.join(", ")}`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return fields as StateProjectionField[];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function parseLimitFlag(args: readonly string[], defaultLimit = 50): number {
|
|
442
|
+
const raw = flagValue(args, "--limit");
|
|
443
|
+
if (raw === undefined) return defaultLimit;
|
|
444
|
+
const parsed = Number(raw);
|
|
445
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 500) {
|
|
446
|
+
throw new StateCommandError(2, "gjc state --limit requires an integer from 1 to 500");
|
|
447
|
+
}
|
|
448
|
+
return parsed;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function parseSinceFlag(args: readonly string[]): string | undefined {
|
|
452
|
+
const raw = flagValue(args, "--since")?.trim();
|
|
453
|
+
if (!raw) return undefined;
|
|
454
|
+
const duration = raw.match(/^(\d+)(m|h|d)$/);
|
|
455
|
+
if (duration) {
|
|
456
|
+
const amount = Number(duration[1]);
|
|
457
|
+
const unit = duration[2];
|
|
458
|
+
const multiplier = unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
|
|
459
|
+
return new Date(Date.now() - amount * multiplier).toISOString();
|
|
460
|
+
}
|
|
461
|
+
if (Number.isNaN(Date.parse(raw)))
|
|
462
|
+
throw new StateCommandError(2, "gjc state --since requires an ISO timestamp or duration like 30m, 6h, 7d");
|
|
463
|
+
return new Date(raw).toISOString();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function readAuditWindow(
|
|
467
|
+
cwd: string,
|
|
468
|
+
args: readonly string[],
|
|
469
|
+
): Promise<{ entries: unknown[]; limit: number; since?: string; truncated: boolean }> {
|
|
470
|
+
const limit = parseLimitFlag(args);
|
|
471
|
+
const since = parseSinceFlag(args);
|
|
472
|
+
const auditPath = path.join(cwd, ".gjc", "state", "audit.jsonl");
|
|
473
|
+
let raw = "";
|
|
474
|
+
try {
|
|
475
|
+
raw = await fs.readFile(auditPath, "utf-8");
|
|
476
|
+
} catch (error) {
|
|
477
|
+
const err = error as NodeJS.ErrnoException;
|
|
478
|
+
if (err.code !== "ENOENT") throw error;
|
|
479
|
+
}
|
|
480
|
+
const selected: unknown[] = [];
|
|
481
|
+
let matched = 0;
|
|
482
|
+
const lines = raw.split(/\r?\n/).filter(line => line.trim().length > 0);
|
|
483
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
484
|
+
const line = lines[index];
|
|
485
|
+
let entry: unknown;
|
|
486
|
+
try {
|
|
487
|
+
entry = JSON.parse(line);
|
|
488
|
+
} catch {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (since && isPlainObject(entry) && typeof entry.ts === "string" && Date.parse(entry.ts) < Date.parse(since))
|
|
492
|
+
break;
|
|
493
|
+
matched += 1;
|
|
494
|
+
if (selected.length < limit) selected.push(entry);
|
|
495
|
+
}
|
|
496
|
+
return { entries: selected.reverse(), limit, ...(since ? { since } : {}), truncated: matched > limit };
|
|
248
497
|
}
|
|
249
498
|
|
|
250
499
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -408,6 +657,14 @@ async function syncWorkflowSkillState(options: {
|
|
|
408
657
|
// HUD sync is best-effort and must not change command semantics.
|
|
409
658
|
}
|
|
410
659
|
}
|
|
660
|
+
export async function readWorkflowStateJson(
|
|
661
|
+
cwd: string,
|
|
662
|
+
skill: CanonicalGjcWorkflowSkill,
|
|
663
|
+
sessionId?: string,
|
|
664
|
+
): Promise<Record<string, unknown>> {
|
|
665
|
+
return (await readJsonFile(modeStateFile(cwd, skill, sessionId))) ?? {};
|
|
666
|
+
}
|
|
667
|
+
|
|
411
668
|
async function handleRead(
|
|
412
669
|
args: readonly string[],
|
|
413
670
|
cwd: string,
|
|
@@ -415,19 +672,70 @@ async function handleRead(
|
|
|
415
672
|
): Promise<StateCommandResult> {
|
|
416
673
|
const selectors = await resolveSelectors(args, cwd, positionalSkill);
|
|
417
674
|
const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
|
|
675
|
+
const fields = parseFieldsFlag(args);
|
|
418
676
|
if (mode) {
|
|
419
677
|
const filePath = modeStateFile(cwd, mode, selectors.sessionId);
|
|
420
|
-
const existing = await
|
|
678
|
+
const existing = await readWorkflowStateJson(cwd, mode, selectors.sessionId);
|
|
679
|
+
const envelope = { skill: mode, state: existing, storage_path: filePath };
|
|
680
|
+
const manifest = getSkillManifest(mode);
|
|
681
|
+
if (fields) {
|
|
682
|
+
const projected = projectStateFields(mode, envelope, manifest, fields);
|
|
683
|
+
return {
|
|
684
|
+
status: 0,
|
|
685
|
+
stdout: hasFlag(args, "--json")
|
|
686
|
+
? `${JSON.stringify(projected, null, 2)}\n`
|
|
687
|
+
: renderStateMarkdown(mode, projected, manifest),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
if (hasFlag(args, "--compact")) {
|
|
691
|
+
const compact = compactProjectStateJson(mode, envelope, manifest);
|
|
692
|
+
return {
|
|
693
|
+
status: 0,
|
|
694
|
+
stdout: hasFlag(args, "--json")
|
|
695
|
+
? `${JSON.stringify(compact, null, 2)}\n`
|
|
696
|
+
: renderStateMarkdown(mode, envelope, manifest),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
421
699
|
return {
|
|
422
700
|
status: 0,
|
|
423
|
-
stdout:
|
|
701
|
+
stdout: hasFlag(args, "--json")
|
|
702
|
+
? `${JSON.stringify(envelope, null, 2)}\n`
|
|
703
|
+
: renderStateMarkdown(mode, envelope, manifest),
|
|
424
704
|
};
|
|
425
705
|
}
|
|
426
706
|
const filePath = activeStateFile(cwd, selectors.sessionId);
|
|
427
|
-
const
|
|
707
|
+
const existingRaw = await readJsonValue(filePath);
|
|
708
|
+
const existing = isPlainObject(existingRaw) ? existingRaw : null;
|
|
428
709
|
return { status: 0, stdout: `${JSON.stringify(existing ?? {}, null, 2)}\n` };
|
|
429
710
|
}
|
|
430
711
|
|
|
712
|
+
async function handleStatus(
|
|
713
|
+
args: readonly string[],
|
|
714
|
+
cwd: string,
|
|
715
|
+
positionalSkill: string | undefined,
|
|
716
|
+
): Promise<StateCommandResult> {
|
|
717
|
+
const selectors = await resolveSelectors(args, cwd, positionalSkill);
|
|
718
|
+
const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
|
|
719
|
+
if (!mode) {
|
|
720
|
+
throw new StateCommandError(
|
|
721
|
+
2,
|
|
722
|
+
"gjc state status requires --mode <skill>, positional <skill>, input.skill, or an active workflow in .gjc/state/skill-active-state.json",
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
const filePath = modeStateFile(cwd, mode, selectors.sessionId);
|
|
726
|
+
const existing = await readWorkflowStateJson(cwd, mode, selectors.sessionId);
|
|
727
|
+
const summary = buildStateStatusSummary(
|
|
728
|
+
mode,
|
|
729
|
+
{ skill: mode, state: existing, storage_path: filePath },
|
|
730
|
+
getSkillManifest(mode),
|
|
731
|
+
filePath,
|
|
732
|
+
);
|
|
733
|
+
return {
|
|
734
|
+
status: 0,
|
|
735
|
+
stdout: hasFlag(args, "--json") ? `${JSON.stringify(summary, null, 2)}\n` : renderStateStatusLine(summary),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
431
739
|
async function handleWrite(
|
|
432
740
|
args: readonly string[],
|
|
433
741
|
cwd: string,
|
|
@@ -444,8 +752,10 @@ async function handleWrite(
|
|
|
444
752
|
);
|
|
445
753
|
|
|
446
754
|
const filePath = modeStateFile(cwd, mode, sessionId);
|
|
447
|
-
const
|
|
755
|
+
const existingRaw = await readJsonValue(filePath);
|
|
756
|
+
const existing = isPlainObject(existingRaw) ? existingRaw : null;
|
|
448
757
|
const nowIsoStr = nowIso();
|
|
758
|
+
const mutationId = `${mode}:${nowIsoStr}`;
|
|
449
759
|
const receipt = buildWorkflowStateReceipt({
|
|
450
760
|
cwd,
|
|
451
761
|
skill: mode,
|
|
@@ -453,7 +763,11 @@ async function handleWrite(
|
|
|
453
763
|
command: `gjc state ${mode} write`,
|
|
454
764
|
sessionId,
|
|
455
765
|
nowIso: nowIsoStr,
|
|
766
|
+
mutationId,
|
|
456
767
|
});
|
|
768
|
+
if (existingRaw !== null && !isPlainObject(existingRaw)) {
|
|
769
|
+
throw new StateCommandError(2, `existing state for ${mode} must be a JSON object before write`);
|
|
770
|
+
}
|
|
457
771
|
const existingPayload = existing ?? {};
|
|
458
772
|
const innerState = (payload.state as Record<string, unknown> | undefined) ?? {};
|
|
459
773
|
const incomingPhase =
|
|
@@ -476,6 +790,10 @@ async function handleWrite(
|
|
|
476
790
|
delete merged.state;
|
|
477
791
|
}
|
|
478
792
|
}
|
|
793
|
+
const preDefaultValidation = validateWorkflowStateEnvelope(mode, merged);
|
|
794
|
+
if (!preDefaultValidation.valid) {
|
|
795
|
+
throw new StateCommandError(2, preDefaultValidation.error ?? `invalid ${mode} state envelope`);
|
|
796
|
+
}
|
|
479
797
|
merged.skill = mode;
|
|
480
798
|
if (incomingPhase) {
|
|
481
799
|
merged.current_phase = incomingPhase;
|
|
@@ -488,7 +806,29 @@ async function handleWrite(
|
|
|
488
806
|
merged.updated_at = nowIsoStr;
|
|
489
807
|
merged.receipt = receipt;
|
|
490
808
|
if (sessionId && typeof merged.session_id !== "string") merged.session_id = sessionId;
|
|
491
|
-
|
|
809
|
+
|
|
810
|
+
const fromPhase = typeof existingPayload.current_phase === "string" ? existingPayload.current_phase : undefined;
|
|
811
|
+
const toPhase = typeof merged.current_phase === "string" ? merged.current_phase : undefined;
|
|
812
|
+
const forced = hasFlag(args, "--force");
|
|
813
|
+
if (fromPhase && toPhase && isKnownWorkflowState(mode, fromPhase) && isKnownWorkflowState(mode, toPhase)) {
|
|
814
|
+
if (!isValidTransition(mode, fromPhase, toPhase) && !forced) {
|
|
815
|
+
throw new StateCommandError(
|
|
816
|
+
2,
|
|
817
|
+
`invalid ${mode} phase transition from ${fromPhase} to ${toPhase}; use --force to bypass`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const validation = validateWorkflowStateEnvelope(mode, merged);
|
|
823
|
+
if (!validation.valid) throw new StateCommandError(2, validation.error ?? `invalid ${mode} state envelope`);
|
|
824
|
+
|
|
825
|
+
const outOfBandWarning = await writeJsonAtomic(cwd, filePath, merged, "write", {
|
|
826
|
+
skill: mode,
|
|
827
|
+
mutationId,
|
|
828
|
+
force: forced,
|
|
829
|
+
fromPhase,
|
|
830
|
+
toPhase,
|
|
831
|
+
});
|
|
492
832
|
|
|
493
833
|
const phase = typeof merged.current_phase === "string" ? merged.current_phase : undefined;
|
|
494
834
|
const active = merged.active !== false;
|
|
@@ -497,6 +837,7 @@ async function handleWrite(
|
|
|
497
837
|
return {
|
|
498
838
|
status: 0,
|
|
499
839
|
stdout: `${JSON.stringify({ skill: mode, state: merged, receipt }, null, 2)}\n`,
|
|
840
|
+
...(outOfBandWarning ? { stderr: `${outOfBandWarning}\n` } : {}),
|
|
500
841
|
};
|
|
501
842
|
}
|
|
502
843
|
|
|
@@ -522,7 +863,12 @@ async function handleClear(
|
|
|
522
863
|
current_phase: "complete",
|
|
523
864
|
updated_at: nowIso(),
|
|
524
865
|
};
|
|
525
|
-
await writeJsonAtomic(filePath, cleared
|
|
866
|
+
const outOfBandWarning = await writeJsonAtomic(cwd, filePath, cleared, "clear", {
|
|
867
|
+
skill: mode,
|
|
868
|
+
force: hasFlag(args, "--force"),
|
|
869
|
+
fromPhase: typeof existing.current_phase === "string" ? existing.current_phase : undefined,
|
|
870
|
+
toPhase: "complete",
|
|
871
|
+
});
|
|
526
872
|
|
|
527
873
|
await syncWorkflowSkillState({
|
|
528
874
|
cwd,
|
|
@@ -534,8 +880,11 @@ async function handleClear(
|
|
|
534
880
|
phase: "complete",
|
|
535
881
|
payload: cleared,
|
|
536
882
|
});
|
|
537
|
-
|
|
538
|
-
|
|
883
|
+
return {
|
|
884
|
+
status: 0,
|
|
885
|
+
stdout: `${JSON.stringify(cleared, null, 2)}\n`,
|
|
886
|
+
...(outOfBandWarning ? { stderr: `${outOfBandWarning}\n` } : {}),
|
|
887
|
+
};
|
|
539
888
|
}
|
|
540
889
|
|
|
541
890
|
/**
|
|
@@ -593,6 +942,7 @@ async function handleHandoff(
|
|
|
593
942
|
const existingCallee = (await readJsonFile(calleePath)) ?? {};
|
|
594
943
|
|
|
595
944
|
const handoffAt = nowIso();
|
|
945
|
+
const mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
|
|
596
946
|
const callerReceipt = buildWorkflowStateReceipt({
|
|
597
947
|
cwd,
|
|
598
948
|
skill: caller,
|
|
@@ -600,6 +950,7 @@ async function handleHandoff(
|
|
|
600
950
|
command: `gjc state ${caller} handoff --to ${callee}`,
|
|
601
951
|
sessionId,
|
|
602
952
|
nowIso: handoffAt,
|
|
953
|
+
mutationId,
|
|
603
954
|
});
|
|
604
955
|
const calleeReceipt = buildWorkflowStateReceipt({
|
|
605
956
|
cwd,
|
|
@@ -608,6 +959,7 @@ async function handleHandoff(
|
|
|
608
959
|
command: `gjc state ${caller} handoff --to ${callee}`,
|
|
609
960
|
sessionId,
|
|
610
961
|
nowIso: handoffAt,
|
|
962
|
+
mutationId,
|
|
611
963
|
});
|
|
612
964
|
|
|
613
965
|
const calleeInitial = initialPhaseForSkill(callee);
|
|
@@ -636,6 +988,14 @@ async function handleHandoff(
|
|
|
636
988
|
receipt: callerReceipt,
|
|
637
989
|
};
|
|
638
990
|
|
|
991
|
+
await beginWorkflowTransactionJournal({
|
|
992
|
+
cwd,
|
|
993
|
+
mutationId,
|
|
994
|
+
caller,
|
|
995
|
+
callee,
|
|
996
|
+
paths: [calleePath, callerPath, activeStateFile(cwd, sessionId)],
|
|
997
|
+
});
|
|
998
|
+
|
|
639
999
|
// Atomic write order (architecture blocker AR-3): mode-state files first,
|
|
640
1000
|
// then a single atomic active-state mutation per file (session before root)
|
|
641
1001
|
// via applyHandoffToActiveState. The single-write transaction prevents the
|
|
@@ -643,8 +1003,31 @@ async function handleHandoff(
|
|
|
643
1003
|
// and write order keeps the session-scoped source of truth ahead of the
|
|
644
1004
|
// root aggregate. strict:true on the active-state read tolerates ENOENT
|
|
645
1005
|
// only; corrupt JSON / IO failures propagate as non-zero CLI status.
|
|
646
|
-
|
|
647
|
-
|
|
1006
|
+
const force = hasFlag(args, "--force");
|
|
1007
|
+
const warnings = [
|
|
1008
|
+
await writeJsonAtomic(cwd, calleePath, mergedCalleeState, "handoff", {
|
|
1009
|
+
skill: callee,
|
|
1010
|
+
mutationId,
|
|
1011
|
+
force,
|
|
1012
|
+
fromPhase: typeof existingCallee.current_phase === "string" ? existingCallee.current_phase : undefined,
|
|
1013
|
+
toPhase: calleeInitial,
|
|
1014
|
+
}),
|
|
1015
|
+
await updateWorkflowTransactionJournal(cwd, mutationId, { steps: ["callee-mode-state"] }).then(() => undefined),
|
|
1016
|
+
await writeJsonAtomic(cwd, callerPath, mergedCallerState, "handoff", {
|
|
1017
|
+
skill: caller,
|
|
1018
|
+
mutationId,
|
|
1019
|
+
force,
|
|
1020
|
+
fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
|
|
1021
|
+
toPhase: "handoff",
|
|
1022
|
+
}),
|
|
1023
|
+
await updateWorkflowTransactionJournal(cwd, mutationId, {
|
|
1024
|
+
steps: ["callee-mode-state", "caller-mode-state"],
|
|
1025
|
+
}).then(() => undefined),
|
|
1026
|
+
].filter((warning): warning is string => typeof warning === "string");
|
|
1027
|
+
for (const warning of warnings) process.stderr.write(`${warning}\n`);
|
|
1028
|
+
if (process.env.GJC_STATE_HANDOFF_FAIL_AFTER_CALLER === mutationId) {
|
|
1029
|
+
throw new StateCommandError(1, `injected handoff failure after caller write for ${mutationId}`);
|
|
1030
|
+
}
|
|
648
1031
|
await applyHandoffToActiveState({
|
|
649
1032
|
cwd,
|
|
650
1033
|
nowIso: handoffAt,
|
|
@@ -678,6 +1061,10 @@ async function handleHandoff(
|
|
|
678
1061
|
receipt: calleeReceipt,
|
|
679
1062
|
},
|
|
680
1063
|
});
|
|
1064
|
+
await updateWorkflowTransactionJournal(cwd, mutationId, {
|
|
1065
|
+
steps: ["callee-mode-state", "caller-mode-state", "active-state"],
|
|
1066
|
+
});
|
|
1067
|
+
await completeWorkflowTransactionJournal(cwd, mutationId);
|
|
681
1068
|
|
|
682
1069
|
return {
|
|
683
1070
|
status: 0,
|
|
@@ -705,14 +1092,329 @@ async function handleContract(
|
|
|
705
1092
|
throw new StateCommandError(2, "gjc state contract requires --mode <skill>, positional <skill>, or input.skill");
|
|
706
1093
|
}
|
|
707
1094
|
const payload = { skill: mode, contract: describeWorkflowStateContract(mode) };
|
|
708
|
-
return {
|
|
1095
|
+
return {
|
|
1096
|
+
status: 0,
|
|
1097
|
+
stdout: hasFlag(args, "--json")
|
|
1098
|
+
? `${JSON.stringify(payload, null, 2)}\n`
|
|
1099
|
+
: renderContractMarkdown(mode, payload.contract),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function parseNonNegativeIntegerFlag(args: readonly string[], flag: string): number | undefined {
|
|
1104
|
+
const value = flagValue(args, flag);
|
|
1105
|
+
if (value === undefined) return undefined;
|
|
1106
|
+
const parsed = Number(value);
|
|
1107
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
1108
|
+
throw new StateCommandError(2, `gjc state ${flag} requires a non-negative integer value`);
|
|
1109
|
+
}
|
|
1110
|
+
return parsed;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function statusFromFile(value: unknown): string | undefined {
|
|
1114
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1115
|
+
const record = value as Record<string, unknown>;
|
|
1116
|
+
if (typeof record.status === "string") return record.status;
|
|
1117
|
+
if (record.receipt && typeof record.receipt === "object" && !Array.isArray(record.receipt)) {
|
|
1118
|
+
const receiptStatus = (record.receipt as Record<string, unknown>).status;
|
|
1119
|
+
if (typeof receiptStatus === "string") return receiptStatus;
|
|
1120
|
+
}
|
|
1121
|
+
return undefined;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
interface RetentionCandidate {
|
|
1125
|
+
path: string;
|
|
1126
|
+
relativePath: string;
|
|
1127
|
+
category: string;
|
|
1128
|
+
mtimeMs: number;
|
|
1129
|
+
policy: { keep?: number; maxAgeDays?: number };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
interface GcSummary {
|
|
1133
|
+
skill: CanonicalGjcWorkflowSkill | "all";
|
|
1134
|
+
dry_run: boolean;
|
|
1135
|
+
eligible: string[];
|
|
1136
|
+
pruned: string[];
|
|
1137
|
+
counts: Record<string, number>;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function categoryForStateRelativePath(relativePath: string): string | undefined {
|
|
1141
|
+
const normalized = relativePath.split(path.sep).join("/");
|
|
1142
|
+
if (normalized === "audit.jsonl") return undefined;
|
|
1143
|
+
if (normalized === SKILL_ACTIVE_STATE_FILE || normalized.endsWith(`/${SKILL_ACTIVE_STATE_FILE}`)) return undefined;
|
|
1144
|
+
if (normalized.startsWith("active/") || normalized.includes("/active/")) return undefined;
|
|
1145
|
+
if (
|
|
1146
|
+
/^[^/]+-state\.json$/.test(normalized) ||
|
|
1147
|
+
(normalized.includes("/sessions/") && /\/[^/]+-state\.json$/.test(normalized))
|
|
1148
|
+
)
|
|
1149
|
+
return undefined;
|
|
1150
|
+
if (normalized.startsWith("artifacts/") || normalized.includes("/artifacts/")) return "artifact";
|
|
1151
|
+
if (
|
|
1152
|
+
normalized.startsWith("logs/") ||
|
|
1153
|
+
normalized.includes("/logs/") ||
|
|
1154
|
+
normalized.endsWith(".log") ||
|
|
1155
|
+
normalized.endsWith(".jsonl")
|
|
1156
|
+
)
|
|
1157
|
+
return "log";
|
|
1158
|
+
if (normalized.startsWith("reports/") || normalized.includes("/reports/")) return "report";
|
|
1159
|
+
if (normalized.startsWith("ledgers/") || normalized.includes("/ledgers/")) return "ledger";
|
|
1160
|
+
if (normalized.startsWith("agents/") || normalized.includes("/agents/")) return "agents";
|
|
1161
|
+
if (normalized.startsWith("force/") || normalized.includes("/force/")) return "force";
|
|
1162
|
+
if (
|
|
1163
|
+
normalized.startsWith("prune/") ||
|
|
1164
|
+
normalized.includes("/prune/") ||
|
|
1165
|
+
normalized.startsWith("delete/") ||
|
|
1166
|
+
normalized.includes("/delete/")
|
|
1167
|
+
)
|
|
1168
|
+
return "prune/delete";
|
|
1169
|
+
if (normalized.startsWith("transactions/") || normalized.includes("/transactions/")) return "prune/delete";
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function collectRetentionCandidates(
|
|
1174
|
+
cwd: string,
|
|
1175
|
+
skills: readonly CanonicalGjcWorkflowSkill[],
|
|
1176
|
+
): Promise<RetentionCandidate[]> {
|
|
1177
|
+
const stateRoot = path.join(cwd, ".gjc", "state");
|
|
1178
|
+
const policies = new Map<string, { keep?: number; maxAgeDays?: number }>();
|
|
1179
|
+
for (const skill of skills) {
|
|
1180
|
+
for (const policy of getSkillManifest(skill).retention) {
|
|
1181
|
+
const existing = policies.get(policy.category);
|
|
1182
|
+
policies.set(policy.category, {
|
|
1183
|
+
keep: Math.max(existing?.keep ?? 0, policy.keep ?? 0) || undefined,
|
|
1184
|
+
maxAgeDays:
|
|
1185
|
+
existing?.maxAgeDays === undefined
|
|
1186
|
+
? policy.maxAgeDays
|
|
1187
|
+
: policy.maxAgeDays === undefined
|
|
1188
|
+
? existing.maxAgeDays
|
|
1189
|
+
: Math.max(existing.maxAgeDays, policy.maxAgeDays),
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
const candidates: RetentionCandidate[] = [];
|
|
1194
|
+
async function visit(dir: string): Promise<void> {
|
|
1195
|
+
let entries: string[];
|
|
1196
|
+
try {
|
|
1197
|
+
entries = await fs.readdir(dir);
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
const err = error as NodeJS.ErrnoException;
|
|
1200
|
+
if (err.code === "ENOENT") return;
|
|
1201
|
+
throw error;
|
|
1202
|
+
}
|
|
1203
|
+
for (const entry of entries) {
|
|
1204
|
+
const filePath = path.join(dir, entry);
|
|
1205
|
+
const stat = await fs.stat(filePath);
|
|
1206
|
+
if (stat.isDirectory()) {
|
|
1207
|
+
await visit(filePath);
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (!stat.isFile()) continue;
|
|
1211
|
+
const relativePath = path.relative(stateRoot, filePath);
|
|
1212
|
+
const category = categoryForStateRelativePath(relativePath);
|
|
1213
|
+
if (!category) continue;
|
|
1214
|
+
const policy = policies.get(category);
|
|
1215
|
+
if (!policy) continue;
|
|
1216
|
+
candidates.push({ path: filePath, relativePath, category, mtimeMs: stat.mtimeMs, policy });
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
await visit(stateRoot);
|
|
1220
|
+
return candidates;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function selectRetentionEligible(candidates: readonly RetentionCandidate[]): RetentionCandidate[] {
|
|
1224
|
+
const now = Date.now();
|
|
1225
|
+
const byCategory = new Map<string, RetentionCandidate[]>();
|
|
1226
|
+
for (const candidate of candidates) {
|
|
1227
|
+
const list = byCategory.get(candidate.category) ?? [];
|
|
1228
|
+
list.push(candidate);
|
|
1229
|
+
byCategory.set(candidate.category, list);
|
|
1230
|
+
}
|
|
1231
|
+
const eligible = new Set<RetentionCandidate>();
|
|
1232
|
+
for (const list of byCategory.values()) {
|
|
1233
|
+
list.sort((a, b) => b.mtimeMs - a.mtimeMs || a.relativePath.localeCompare(b.relativePath));
|
|
1234
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
1235
|
+
const candidate = list[index];
|
|
1236
|
+
const keep = candidate.policy.keep ?? 0;
|
|
1237
|
+
if (keep > 0 && index < keep) continue;
|
|
1238
|
+
if (candidate.policy.maxAgeDays !== undefined) {
|
|
1239
|
+
const maxAgeMs = candidate.policy.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
1240
|
+
if (now - candidate.mtimeMs < maxAgeMs) continue;
|
|
1241
|
+
}
|
|
1242
|
+
if (candidate.policy.keep !== undefined || candidate.policy.maxAgeDays !== undefined) eligible.add(candidate);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return [...eligible].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
async function buildGcSummary(
|
|
1249
|
+
args: readonly string[],
|
|
1250
|
+
cwd: string,
|
|
1251
|
+
positionalSkill: string | undefined,
|
|
1252
|
+
dryRun: boolean,
|
|
1253
|
+
): Promise<GcSummary> {
|
|
1254
|
+
const rawSkill =
|
|
1255
|
+
flagValue(args, "--skill")?.trim() || flagValue(args, "--mode")?.trim() || positionalSkill?.trim() || "all";
|
|
1256
|
+
if (rawSkill !== "all") assertKnownMode(rawSkill);
|
|
1257
|
+
const skills = rawSkill === "all" ? CANONICAL_GJC_WORKFLOW_SKILLS : [rawSkill as CanonicalGjcWorkflowSkill];
|
|
1258
|
+
const eligible = selectRetentionEligible(await collectRetentionCandidates(cwd, skills));
|
|
1259
|
+
const counts: Record<string, number> = {};
|
|
1260
|
+
for (const candidate of eligible) counts[candidate.category] = (counts[candidate.category] ?? 0) + 1;
|
|
1261
|
+
const targets: GenericHardPruneTarget[] = eligible.map(candidate => ({
|
|
1262
|
+
path: candidate.path,
|
|
1263
|
+
category: candidate.category,
|
|
1264
|
+
}));
|
|
1265
|
+
let pruned: string[] = [];
|
|
1266
|
+
if (!dryRun && targets.length > 0) {
|
|
1267
|
+
const eligiblePaths = new Set(eligible.map(candidate => path.resolve(candidate.path)));
|
|
1268
|
+
pruned = await hardPrune(targets, context => eligiblePaths.has(path.resolve(context.path)), {
|
|
1269
|
+
cwd,
|
|
1270
|
+
audit: {
|
|
1271
|
+
cwd,
|
|
1272
|
+
skill: rawSkill,
|
|
1273
|
+
category: "prune",
|
|
1274
|
+
verb: "gc",
|
|
1275
|
+
owner: "gjc-state-cli",
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return {
|
|
1280
|
+
skill: rawSkill as CanonicalGjcWorkflowSkill | "all",
|
|
1281
|
+
dry_run: dryRun,
|
|
1282
|
+
eligible: eligible.map(candidate => candidate.relativePath),
|
|
1283
|
+
pruned: pruned.map(filePath => path.relative(path.join(cwd, ".gjc", "state"), filePath)),
|
|
1284
|
+
counts,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function handleGraph(
|
|
1289
|
+
args: readonly string[],
|
|
1290
|
+
_cwd: string,
|
|
1291
|
+
positionalSkill: string | undefined,
|
|
1292
|
+
): Promise<StateCommandResult> {
|
|
1293
|
+
if (hasFlag(args, "--history")) {
|
|
1294
|
+
const history = await readAuditWindow(_cwd, args);
|
|
1295
|
+
return {
|
|
1296
|
+
status: 0,
|
|
1297
|
+
stdout: hasFlag(args, "--json") ? `${JSON.stringify(history, null, 2)}\n` : renderHistoryMarkdown(history),
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
const rawSkill = flagValue(args, "--skill")?.trim() || positionalSkill?.trim() || "all";
|
|
1301
|
+
if (rawSkill !== "all") assertKnownMode(rawSkill);
|
|
1302
|
+
const format = flagValue(args, "--format")?.trim() || "ascii";
|
|
1303
|
+
if (!GRAPH_FORMATS.has(format)) {
|
|
1304
|
+
throw new StateCommandError(2, `Invalid graph format: ${format}. Expected one of: ascii, mermaid, dot.`);
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
status: 0,
|
|
1308
|
+
stdout: renderStateGraph(rawSkill as CanonicalGjcWorkflowSkill | "all", format as StateGraphFormat),
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
async function handlePrune(
|
|
1313
|
+
args: readonly string[],
|
|
1314
|
+
cwd: string,
|
|
1315
|
+
positionalSkill: string | undefined,
|
|
1316
|
+
): Promise<StateCommandResult> {
|
|
1317
|
+
const selectors = await resolveSelectors(args, cwd, positionalSkill);
|
|
1318
|
+
const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
|
|
1319
|
+
if (!mode) {
|
|
1320
|
+
throw new StateCommandError(
|
|
1321
|
+
2,
|
|
1322
|
+
"gjc state prune requires --mode <skill>, positional <skill>, input.skill, or an active workflow in .gjc/state/skill-active-state.json",
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
const filePath = modeStateFile(cwd, mode, selectors.sessionId);
|
|
1326
|
+
const olderThanDays = parseNonNegativeIntegerFlag(args, "--older-than");
|
|
1327
|
+
const status = flagValue(args, "--status")?.trim();
|
|
1328
|
+
const targets: GenericHardPruneTarget[] = [{ path: filePath, category: "prune" }];
|
|
1329
|
+
const audit: StateWriterAuditContext = {
|
|
1330
|
+
cwd,
|
|
1331
|
+
skill: mode,
|
|
1332
|
+
category: "prune",
|
|
1333
|
+
verb: hasFlag(args, "--hard") ? "hard-prune" : "soft-delete",
|
|
1334
|
+
owner: "gjc-state-cli",
|
|
1335
|
+
};
|
|
1336
|
+
const olderThanMs = olderThanDays === undefined ? undefined : olderThanDays * 24 * 60 * 60 * 1000;
|
|
1337
|
+
const matchesSelector = async (
|
|
1338
|
+
stat: { mtimeMs: number | bigint },
|
|
1339
|
+
readJson: () => Promise<unknown>,
|
|
1340
|
+
): Promise<boolean> => {
|
|
1341
|
+
const mtimeMs = typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs;
|
|
1342
|
+
if (olderThanMs !== undefined && Date.now() - mtimeMs < olderThanMs) return false;
|
|
1343
|
+
if (status) return statusFromFile(await readJson()) === status;
|
|
1344
|
+
return true;
|
|
1345
|
+
};
|
|
1346
|
+
if (hasFlag(args, "--hard")) {
|
|
1347
|
+
const pruned = await hardPrune(
|
|
1348
|
+
targets,
|
|
1349
|
+
context => (context.stat ? matchesSelector(context.stat, context.readJson) : false),
|
|
1350
|
+
{ cwd, audit },
|
|
1351
|
+
);
|
|
1352
|
+
return { status: 0, stdout: `${JSON.stringify({ skill: mode, hard: true, pruned }, null, 2)}\n` };
|
|
1353
|
+
}
|
|
1354
|
+
let deleted: string[] = [];
|
|
1355
|
+
try {
|
|
1356
|
+
const stat = await fs.stat(filePath);
|
|
1357
|
+
if (await matchesSelector(stat, async () => JSON.parse(await fs.readFile(filePath, "utf-8")))) {
|
|
1358
|
+
const archivedPath = await softDelete(
|
|
1359
|
+
filePath,
|
|
1360
|
+
{ skill: mode, reason: "gjc state prune", status: status ?? null, older_than_days: olderThanDays ?? null },
|
|
1361
|
+
{ cwd, audit },
|
|
1362
|
+
);
|
|
1363
|
+
deleted = [archivedPath];
|
|
1364
|
+
}
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
const err = error as NodeJS.ErrnoException;
|
|
1367
|
+
if (err.code !== "ENOENT") throw error;
|
|
1368
|
+
}
|
|
1369
|
+
return { status: 0, stdout: `${JSON.stringify({ skill: mode, hard: false, soft_deleted: deleted }, null, 2)}\n` };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function handleGc(
|
|
1373
|
+
args: readonly string[],
|
|
1374
|
+
cwd: string,
|
|
1375
|
+
positionalSkill: string | undefined,
|
|
1376
|
+
): Promise<StateCommandResult> {
|
|
1377
|
+
const summary = await buildGcSummary(args, cwd, positionalSkill, hasFlag(args, "--dry-run"));
|
|
1378
|
+
return { status: 0, stdout: `${JSON.stringify(summary, null, 2)}\n` };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async function handleMigrate(
|
|
1382
|
+
args: readonly string[],
|
|
1383
|
+
cwd: string,
|
|
1384
|
+
positionalSkill: string | undefined,
|
|
1385
|
+
): Promise<StateCommandResult> {
|
|
1386
|
+
const selectors = await resolveSelectors(args, cwd, positionalSkill);
|
|
1387
|
+
const mode = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.sessionId));
|
|
1388
|
+
if (!mode) {
|
|
1389
|
+
throw new StateCommandError(
|
|
1390
|
+
2,
|
|
1391
|
+
"gjc state migrate requires --mode <skill>, positional <skill>, input.skill, or an active workflow in .gjc/state/skill-active-state.json",
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
const filePath = modeStateFile(cwd, mode, selectors.sessionId);
|
|
1395
|
+
const mismatchWarning = await warnAndAuditOutOfBandIfNeeded(cwd, filePath, mode, {
|
|
1396
|
+
forced: hasFlag(args, "--force"),
|
|
1397
|
+
});
|
|
1398
|
+
const result = await migrateAndPersistLegacyState({
|
|
1399
|
+
cwd,
|
|
1400
|
+
skill: mode,
|
|
1401
|
+
statePath: filePath,
|
|
1402
|
+
sessionId: selectors.sessionId,
|
|
1403
|
+
});
|
|
1404
|
+
return {
|
|
1405
|
+
status: 0,
|
|
1406
|
+
stdout: `${JSON.stringify({ skill: mode, ...result, integrity_mismatch: Boolean(mismatchWarning) }, null, 2)}\n`,
|
|
1407
|
+
...(mismatchWarning ? { stderr: `${mismatchWarning}\n` } : {}),
|
|
1408
|
+
};
|
|
709
1409
|
}
|
|
710
1410
|
|
|
711
1411
|
export async function runNativeStateCommand(args: string[], cwd = process.cwd()): Promise<StateCommandResult> {
|
|
712
1412
|
try {
|
|
713
1413
|
const parsed = parsePositionalArgs(args);
|
|
1414
|
+
assertKnownFlags(args, parsed);
|
|
714
1415
|
switch (parsed.action) {
|
|
715
1416
|
case "read":
|
|
1417
|
+
if (hasFlag(args, "--migrate")) return await handleMigrate(args, cwd, parsed.positionalSkill);
|
|
716
1418
|
return await handleRead(args, cwd, parsed.positionalSkill);
|
|
717
1419
|
case "write":
|
|
718
1420
|
return await handleWrite(args, cwd, parsed.positionalSkill);
|
|
@@ -720,8 +1422,18 @@ export async function runNativeStateCommand(args: string[], cwd = process.cwd())
|
|
|
720
1422
|
return await handleClear(args, cwd, parsed.positionalSkill);
|
|
721
1423
|
case "contract":
|
|
722
1424
|
return await handleContract(args, cwd, parsed.positionalSkill);
|
|
1425
|
+
case "status":
|
|
1426
|
+
return await handleStatus(args, cwd, parsed.positionalSkill);
|
|
723
1427
|
case "handoff":
|
|
724
1428
|
return await handleHandoff(args, cwd, parsed.positionalSkill);
|
|
1429
|
+
case "graph":
|
|
1430
|
+
return await handleGraph(args, cwd, parsed.positionalSkill);
|
|
1431
|
+
case "prune":
|
|
1432
|
+
return await handlePrune(args, cwd, parsed.positionalSkill);
|
|
1433
|
+
case "gc":
|
|
1434
|
+
return await handleGc(args, cwd, parsed.positionalSkill);
|
|
1435
|
+
case "migrate":
|
|
1436
|
+
return await handleMigrate(args, cwd, parsed.positionalSkill);
|
|
725
1437
|
default:
|
|
726
1438
|
return { status: 2, stderr: `Unknown gjc state command: ${parsed.action}\n` };
|
|
727
1439
|
}
|