@gajae-code/coding-agent 0.5.1 → 0.5.3
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 +31 -0
- package/README.md +1 -1
- package/dist/types/async/job-manager.d.ts +6 -0
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock.d.ts +24 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/dap/client.d.ts +2 -1
- package/dist/types/edit/read-file.d.ts +6 -0
- package/dist/types/eval/js/context-manager.d.ts +3 -0
- package/dist/types/eval/js/executor.d.ts +1 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/runtime/process-lifecycle.d.ts +108 -0
- package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +17 -1
- package/dist/types/session/artifacts.d.ts +4 -1
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/session/streaming-output.d.ts +5 -0
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
- package/dist/types/tools/sqlite-reader.d.ts +2 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +21 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +153 -39
- package/src/cli/args.ts +2 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/setup-cli.ts +138 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +14 -2
- package/src/config/file-lock.ts +63 -13
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +15 -15
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/dap/client.ts +105 -64
- package/src/dap/session.ts +44 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/edit/read-file.ts +19 -1
- package/src/eval/js/context-manager.ts +228 -65
- package/src/eval/js/executor.ts +2 -0
- package/src/eval/js/index.ts +1 -0
- package/src/eval/js/worker-core.ts +10 -6
- package/src/eval/py/executor.ts +68 -19
- package/src/eval/py/kernel.ts +46 -22
- package/src/eval/py/runner.py +68 -14
- package/src/exec/bash-executor.ts +49 -13
- package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ralplan-runtime.ts +174 -12
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/tmux-gc.ts +88 -38
- package/src/gjc-runtime/tmux-sessions.ts +44 -6
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/owner.ts +3 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/artifact-protocol.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +14 -10
- package/src/lsp/client.ts +64 -26
- package/src/lsp/defaults.json +1 -0
- package/src/lsp/index.ts +2 -1
- package/src/lsp/lspmux.ts +33 -9
- package/src/lsp/types.ts +2 -0
- package/src/main.ts +14 -4
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +23 -1
- package/src/modes/components/assistant-message.ts +10 -2
- package/src/modes/components/bash-execution.ts +5 -1
- package/src/modes/components/eval-execution.ts +5 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/model-selector.ts +60 -2
- package/src/modes/components/oauth-selector.ts +5 -0
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
- package/src/modes/components/skill-message.ts +24 -16
- package/src/modes/components/tool-execution.ts +6 -0
- package/src/modes/controllers/extension-ui-controller.ts +33 -6
- package/src/modes/controllers/input-controller.ts +5 -0
- package/src/modes/controllers/selector-controller.ts +86 -2
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +132 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/prompts/agents/executor.md +5 -2
- package/src/runtime/process-lifecycle.ts +400 -0
- package/src/runtime-mcp/manager.ts +164 -50
- package/src/runtime-mcp/transports/http.ts +12 -11
- package/src/runtime-mcp/transports/stdio.ts +64 -38
- package/src/runtime-mcp/types.ts +3 -0
- package/src/sdk.ts +39 -1
- package/src/session/agent-session.ts +190 -33
- package/src/session/artifacts.ts +17 -2
- package/src/session/blob-store.ts +36 -2
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +99 -31
- package/src/session/streaming-output.ts +54 -3
- package/src/setup/credential-import.ts +429 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/slash-commands/builtin-registry.ts +30 -3
- package/src/slash-commands/helpers/fast-status-report.ts +111 -0
- package/src/task/executor.ts +7 -1
- package/src/task/render.ts +18 -7
- package/src/tools/archive-reader.ts +10 -1
- package/src/tools/ask.ts +4 -2
- package/src/tools/bash.ts +11 -4
- package/src/tools/browser/tab-supervisor.ts +22 -0
- package/src/tools/browser.ts +38 -4
- package/src/tools/cron.ts +1 -1
- package/src/tools/read.ts +11 -12
- package/src/tools/sqlite-reader.ts +19 -5
- package/src/tools/subagent-render.ts +119 -29
- package/src/tools/subagent.ts +147 -7
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
} from "./ledger-event-renderer";
|
|
14
14
|
import { isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
|
|
15
15
|
import { migrateWorkflowState } from "./state-migrations";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
appendJsonlIdempotent,
|
|
18
|
+
readExistingStateForMutation,
|
|
19
|
+
writeArtifact,
|
|
20
|
+
writeWorkflowEnvelopeAtomic,
|
|
21
|
+
} from "./state-writer";
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* Native implementation of `gjc ralplan`.
|
|
@@ -186,7 +191,37 @@ async function readActiveRunId(cwd: string, sessionId: string | undefined): Prom
|
|
|
186
191
|
return candidate;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Run-state phases that an artifact write must never reopen. Once ralplan has
|
|
196
|
+
* reached a terminal/handed-off phase, a stray `--write` must not regress
|
|
197
|
+
* `current_phase` back to a stage — that would silently re-arm a chain guard or
|
|
198
|
+
* undo Stop semantics. Every other phase advances to track the stage just
|
|
199
|
+
* persisted so run-state stays coherent with the active ralplan stage.
|
|
200
|
+
*/
|
|
201
|
+
const PHASE_LOCK = new Set([
|
|
202
|
+
"final",
|
|
203
|
+
"handoff",
|
|
204
|
+
"complete",
|
|
205
|
+
"completed",
|
|
206
|
+
"failed",
|
|
207
|
+
"cancelled",
|
|
208
|
+
"canceled",
|
|
209
|
+
"inactive",
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
/** Phase that keeps run-state coherent with the stage just written, preserving locked phases. */
|
|
213
|
+
function advanceCurrentPhase(existingPhase: unknown, stage: RalplanStage): string {
|
|
214
|
+
const current = typeof existingPhase === "string" ? existingPhase.trim() : "";
|
|
215
|
+
if (current && PHASE_LOCK.has(current)) return current;
|
|
216
|
+
return stage;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function persistActiveRunId(
|
|
220
|
+
cwd: string,
|
|
221
|
+
sessionId: string | undefined,
|
|
222
|
+
runId: string,
|
|
223
|
+
stage: RalplanStage,
|
|
224
|
+
): Promise<void> {
|
|
190
225
|
const statePath = ralplanStatePath(cwd, sessionId);
|
|
191
226
|
const existingRead = await readExistingStateForMutation(statePath);
|
|
192
227
|
if (existingRead.kind === "corrupt") {
|
|
@@ -197,11 +232,25 @@ async function persistActiveRunId(cwd: string, sessionId: string | undefined, ru
|
|
|
197
232
|
}
|
|
198
233
|
let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
|
|
199
234
|
|
|
200
|
-
|
|
235
|
+
// A new run_id is a fresh run, not a stray write on the prior run: never inherit a
|
|
236
|
+
// previous run's terminal/locked phase (which would start the new run already
|
|
237
|
+
// "complete"/"handoff" and disarm the Stop hook). PHASE_LOCK only guards same-run writes.
|
|
238
|
+
const isNewRun = existing.run_id !== runId;
|
|
239
|
+
const nextPhase = isNewRun ? stage : advanceCurrentPhase(existing.current_phase, stage);
|
|
240
|
+
if (
|
|
241
|
+
existing.run_id === runId &&
|
|
242
|
+
existing.version === WORKFLOW_STATE_VERSION &&
|
|
243
|
+
existing.current_phase === nextPhase &&
|
|
244
|
+
(existing.active === true || PHASE_LOCK.has(nextPhase))
|
|
245
|
+
) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
201
248
|
existing.run_id = runId;
|
|
202
249
|
if (typeof existing.skill !== "string") existing.skill = "ralplan";
|
|
203
|
-
|
|
204
|
-
|
|
250
|
+
// A successful persist means ralplan is actively writing this run's artifacts, so always
|
|
251
|
+
// re-assert active. Fallback-only init left active:false after a clear (#644, sibling of #638).
|
|
252
|
+
existing.active = true;
|
|
253
|
+
existing.current_phase = nextPhase;
|
|
205
254
|
existing = migrateWorkflowState(existing, "ralplan").state;
|
|
206
255
|
existing.updated_at = new Date().toISOString();
|
|
207
256
|
await writeWorkflowEnvelopeAtomic(statePath, existing, {
|
|
@@ -381,8 +430,6 @@ async function resolveArtifactArgs(args: readonly string[], cwd: string): Promis
|
|
|
381
430
|
const explicitRunId = flagValue(args, "--run-id")?.trim();
|
|
382
431
|
const runId = explicitRunId || (await readActiveRunId(cwd, sessionId)) || sessionIdRaw || defaultRunId();
|
|
383
432
|
assertSafePathComponent(runId, "run-id");
|
|
384
|
-
// Persist the active run id so later writes in the same loop land in the same directory.
|
|
385
|
-
await persistActiveRunId(cwd, sessionId, runId);
|
|
386
433
|
|
|
387
434
|
const artifact = await resolveArtifactContent(rawArtifact, cwd);
|
|
388
435
|
return { stage: stage as RalplanStage, stageN, runId, artifact, sessionId, json: hasFlag(args, "--json") };
|
|
@@ -398,18 +445,34 @@ interface PersistedArtifact {
|
|
|
398
445
|
pendingApprovalPath?: string;
|
|
399
446
|
}
|
|
400
447
|
|
|
401
|
-
|
|
448
|
+
/**
|
|
449
|
+
* Content-addressed identity for an `index.jsonl` row: a repeated `--write` of the
|
|
450
|
+
* same `(stage, stage_n)` at identical content (same sha256) is the #638 duplicate
|
|
451
|
+
* the append must collapse. Rows missing these fields opt out of dedup.
|
|
452
|
+
*/
|
|
453
|
+
function ralplanIndexKey(entry: unknown): string | undefined {
|
|
454
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
|
|
455
|
+
const record = entry as Record<string, unknown>;
|
|
456
|
+
const { stage, stage_n, sha256 } = record;
|
|
457
|
+
if (typeof stage !== "string" || typeof stage_n !== "number" || typeof sha256 !== "string") return undefined;
|
|
458
|
+
return `${stage}\u0000${stage_n}\u0000${sha256}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function persistArtifact(
|
|
462
|
+
resolved: ResolvedArtifactArgs,
|
|
463
|
+
cwd: string,
|
|
464
|
+
content: string,
|
|
465
|
+
sha256: string,
|
|
466
|
+
): Promise<PersistedArtifact> {
|
|
402
467
|
const runDir = path.join(cwd, ".gjc", "plans", "ralplan", resolved.runId);
|
|
403
468
|
|
|
404
469
|
const fileName = `stage-${pad2(resolved.stageN)}-${resolved.stage}.md`;
|
|
405
470
|
const filePath = path.join(runDir, fileName);
|
|
406
|
-
const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
|
|
407
471
|
await writeArtifact(filePath, content, {
|
|
408
472
|
cwd,
|
|
409
473
|
audit: { category: "artifact", verb: "write", owner: "gjc-runtime", skill: "ralplan" },
|
|
410
474
|
});
|
|
411
475
|
|
|
412
|
-
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
413
476
|
const createdAt = new Date().toISOString();
|
|
414
477
|
const indexEntry = {
|
|
415
478
|
stage: resolved.stage,
|
|
@@ -418,9 +481,10 @@ async function persistArtifact(resolved: ResolvedArtifactArgs, cwd: string): Pro
|
|
|
418
481
|
created_at: createdAt,
|
|
419
482
|
sha256,
|
|
420
483
|
};
|
|
421
|
-
await
|
|
484
|
+
await appendJsonlIdempotent(path.join(runDir, "index.jsonl"), indexEntry, {
|
|
422
485
|
cwd,
|
|
423
486
|
audit: { category: "ledger", verb: "append", owner: "gjc-runtime", skill: "ralplan" },
|
|
487
|
+
key: ralplanIndexKey,
|
|
424
488
|
});
|
|
425
489
|
|
|
426
490
|
let pendingApprovalPath: string | undefined;
|
|
@@ -443,6 +507,56 @@ async function persistArtifact(resolved: ResolvedArtifactArgs, cwd: string): Pro
|
|
|
443
507
|
};
|
|
444
508
|
}
|
|
445
509
|
|
|
510
|
+
/** The persisted `(stage, stage_n)` artifact recorded in a run's `index.jsonl`. */
|
|
511
|
+
interface ExistingStageArtifact {
|
|
512
|
+
path: string;
|
|
513
|
+
sha256: string;
|
|
514
|
+
createdAt: string;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Find the most recent `index.jsonl` row for a `(stage, stage_n)` pair so a
|
|
519
|
+
* repeated `--write` can dedupe instead of silently clobbering the artifact and
|
|
520
|
+
* appending a duplicate ledger row. Best-effort: a missing or unreadable index
|
|
521
|
+
* yields `undefined`, treated as "no prior artifact". The ledger is the source of
|
|
522
|
+
* truth for dedup because it is exactly what a duplicate write would corrupt.
|
|
523
|
+
*/
|
|
524
|
+
async function findExistingStageArtifact(
|
|
525
|
+
cwd: string,
|
|
526
|
+
runId: string,
|
|
527
|
+
stage: RalplanStage,
|
|
528
|
+
stageN: number,
|
|
529
|
+
): Promise<ExistingStageArtifact | undefined> {
|
|
530
|
+
const indexPath = path.join(cwd, ".gjc", "plans", "ralplan", runId, "index.jsonl");
|
|
531
|
+
let text: string;
|
|
532
|
+
try {
|
|
533
|
+
text = await fs.readFile(indexPath, "utf8");
|
|
534
|
+
} catch {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
let match: ExistingStageArtifact | undefined;
|
|
538
|
+
for (const line of text.split(/\r?\n/)) {
|
|
539
|
+
const trimmed = line.trim();
|
|
540
|
+
if (!trimmed) continue;
|
|
541
|
+
let row: unknown;
|
|
542
|
+
try {
|
|
543
|
+
row = JSON.parse(trimmed);
|
|
544
|
+
} catch {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
548
|
+
const record = row as Record<string, unknown>;
|
|
549
|
+
if (record.stage !== stage || record.stage_n !== stageN) continue;
|
|
550
|
+
if (typeof record.path !== "string" || typeof record.sha256 !== "string") continue;
|
|
551
|
+
match = {
|
|
552
|
+
path: record.path,
|
|
553
|
+
sha256: record.sha256,
|
|
554
|
+
createdAt: typeof record.created_at === "string" ? record.created_at : "",
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return match;
|
|
558
|
+
}
|
|
559
|
+
|
|
446
560
|
/**
|
|
447
561
|
* Read and parse the run's `index.jsonl` rows. Best-effort: returns [] when the
|
|
448
562
|
* file is absent or unreadable so HUD sync never fails on a missing index.
|
|
@@ -518,7 +632,26 @@ async function buildRalplanHud(options: {
|
|
|
518
632
|
async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
|
|
519
633
|
const plannerState = parsePlannerStateArgs(args);
|
|
520
634
|
const resolved = await resolveArtifactArgs(args, cwd);
|
|
521
|
-
const
|
|
635
|
+
const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
|
|
636
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
637
|
+
|
|
638
|
+
// Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
|
|
639
|
+
// silently clobber the artifact or append a duplicate ledger row. Classify before any
|
|
640
|
+
// state mutation so a conflict never regresses run-state phase.
|
|
641
|
+
const existingArtifact = await findExistingStageArtifact(cwd, resolved.runId, resolved.stage, resolved.stageN);
|
|
642
|
+
if (existingArtifact) {
|
|
643
|
+
if (existingArtifact.sha256 !== sha256) {
|
|
644
|
+
throw new RalplanCommandError(
|
|
645
|
+
2,
|
|
646
|
+
`refusing to overwrite ralplan ${resolved.stage} stage ${resolved.stageN} at ${existingArtifact.path}: an artifact with different content already exists (existing sha256=${existingArtifact.sha256}, new sha256=${sha256}). Use a new --stage_n to record another pass.`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Keep run-state `current_phase` coherent with the stage being persisted.
|
|
653
|
+
await persistActiveRunId(cwd, resolved.sessionId, resolved.runId, resolved.stage);
|
|
654
|
+
const persisted = await persistArtifact(resolved, cwd, content, sha256);
|
|
522
655
|
if (plannerState) {
|
|
523
656
|
await applyPlannerStateUpdate(cwd, resolved.sessionId, plannerState);
|
|
524
657
|
}
|
|
@@ -547,6 +680,35 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
|
|
|
547
680
|
return { status: 0, stdout };
|
|
548
681
|
}
|
|
549
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Deterministic no-op receipt for an identical repeated `--write`: report the
|
|
685
|
+
* already-persisted artifact without rewriting the file, appending a ledger row, or
|
|
686
|
+
* churning run-state. `deduplicated: true` lets callers distinguish it from a fresh write.
|
|
687
|
+
*/
|
|
688
|
+
function buildDeduplicatedResult(
|
|
689
|
+
resolved: ResolvedArtifactArgs,
|
|
690
|
+
existing: ExistingStageArtifact,
|
|
691
|
+
sha256: string,
|
|
692
|
+
cwd: string,
|
|
693
|
+
): RalplanCommandResult {
|
|
694
|
+
const payload: Record<string, unknown> = {
|
|
695
|
+
run_id: resolved.runId,
|
|
696
|
+
path: existing.path,
|
|
697
|
+
stage: resolved.stage,
|
|
698
|
+
stage_n: resolved.stageN,
|
|
699
|
+
sha256,
|
|
700
|
+
created_at: existing.createdAt,
|
|
701
|
+
deduplicated: true,
|
|
702
|
+
};
|
|
703
|
+
if (resolved.stage === "final") {
|
|
704
|
+
payload.pending_approval_path = path.join(cwd, ".gjc", "plans", "ralplan", resolved.runId, "pending-approval.md");
|
|
705
|
+
}
|
|
706
|
+
const stdout = resolved.json
|
|
707
|
+
? `${JSON.stringify(payload, null, 2)}\n`
|
|
708
|
+
: `ralplan ${resolved.stage} stage ${resolved.stageN} already persisted at ${existing.path} (identical content; no changes written).\n`;
|
|
709
|
+
return { status: 0, stdout };
|
|
710
|
+
}
|
|
711
|
+
|
|
550
712
|
/* -------------------------------- handoff -------------------------------- */
|
|
551
713
|
|
|
552
714
|
interface ConsensusHandoffArgs {
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
type StateWriterAuditContext,
|
|
53
53
|
softDelete,
|
|
54
54
|
updateWorkflowTransactionJournal,
|
|
55
|
+
type WorkflowEnvelopeIntegrityMismatch,
|
|
55
56
|
writeWorkflowEnvelopeAtomic,
|
|
56
57
|
} from "./state-writer";
|
|
57
58
|
import { getSkillManifest, isKnownWorkflowState, isValidTransition, typedArgsFor } from "./workflow-manifest";
|
|
@@ -659,7 +660,7 @@ async function warnAndAuditOutOfBandIfNeeded(
|
|
|
659
660
|
skill: CanonicalGjcWorkflowSkill,
|
|
660
661
|
options?: { mutationId?: string; forced?: boolean },
|
|
661
662
|
): Promise<string | undefined> {
|
|
662
|
-
let mismatch:
|
|
663
|
+
let mismatch: WorkflowEnvelopeIntegrityMismatch | undefined;
|
|
663
664
|
try {
|
|
664
665
|
mismatch = await detectWorkflowEnvelopeIntegrityMismatch(filePath);
|
|
665
666
|
} catch {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
5
|
+
import { type FileLockOptions, withFileLock } from "../config/file-lock";
|
|
4
6
|
import type { ActiveSubskillEntry, SkillActiveEntry, SkillActiveState } from "../skill-state/active-state";
|
|
5
7
|
import {
|
|
6
8
|
type AuditEntry,
|
|
@@ -80,6 +82,12 @@ export interface StateWriterOptions {
|
|
|
80
82
|
cwd?: string;
|
|
81
83
|
receipt?: StateWriterReceiptContext;
|
|
82
84
|
audit?: StateWriterAuditContext;
|
|
85
|
+
/**
|
|
86
|
+
* Cross-process lock tuning for read-modify-write paths that route through
|
|
87
|
+
* `withWorkflowStateLock` / `updateJsonAtomic`. Omit for the hardened
|
|
88
|
+
* `withFileLock` defaults.
|
|
89
|
+
*/
|
|
90
|
+
lock?: FileLockOptions;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
export interface DeleteIfOwnedOptions extends StateWriterOptions {
|
|
@@ -113,7 +121,7 @@ export interface GenericHardPruneTarget {
|
|
|
113
121
|
export interface GenericHardPruneSelectorContext {
|
|
114
122
|
path: string;
|
|
115
123
|
category: WriterCategory | string;
|
|
116
|
-
stat:
|
|
124
|
+
stat: Stats;
|
|
117
125
|
readJson: () => Promise<unknown>;
|
|
118
126
|
}
|
|
119
127
|
|
|
@@ -388,6 +396,57 @@ export async function writeJsonAtomic(
|
|
|
388
396
|
return filePath;
|
|
389
397
|
}
|
|
390
398
|
|
|
399
|
+
async function readPersistedPhase(filePath: string): Promise<string | undefined> {
|
|
400
|
+
try {
|
|
401
|
+
const existing = await readJsonIfPresent(filePath);
|
|
402
|
+
if (!isPlainObject(existing)) return undefined;
|
|
403
|
+
// Only an *active* prior envelope is a transition source. A cleared / handed-off
|
|
404
|
+
// envelope (`active: false`, terminal phase such as `complete` / `handoff`) is outside
|
|
405
|
+
// active workflow progression, so reactivation from it (e.g. a fresh kickoff) must not
|
|
406
|
+
// be reported as an invalid transition.
|
|
407
|
+
if (existing.active !== true) return undefined;
|
|
408
|
+
const phase = existing.current_phase;
|
|
409
|
+
return typeof phase === "string" ? phase : undefined;
|
|
410
|
+
} catch {
|
|
411
|
+
// Best-effort diagnostic read: a corrupt/unreadable prior envelope simply yields no
|
|
412
|
+
// `from` phase, so the transition invariant degrades to a no-op rather than failing
|
|
413
|
+
// the sanctioned write it is observing.
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function recordInvalidWorkflowTransition(args: {
|
|
419
|
+
filePath: string;
|
|
420
|
+
skill: CanonicalGjcWorkflowSkill;
|
|
421
|
+
fromPhase: string;
|
|
422
|
+
toPhase: string;
|
|
423
|
+
options?: StateWriterOptions;
|
|
424
|
+
}): Promise<void> {
|
|
425
|
+
const { filePath, skill, fromPhase, toPhase, options } = args;
|
|
426
|
+
// Audit-only diagnostic: a successful sanctioned write must NOT emit to stderr — callers
|
|
427
|
+
// may treat any stderr output as failure or parse stdout/stderr as machine output. The
|
|
428
|
+
// `invalid_transition_detected` audit entry is the durable, non-intrusive evidence that an
|
|
429
|
+
// internal write skipped a manifest edge.
|
|
430
|
+
const cwd = path.resolve(options?.audit?.cwd ?? options?.cwd ?? process.cwd());
|
|
431
|
+
try {
|
|
432
|
+
await appendAuditEntry(cwd, {
|
|
433
|
+
ts: new Date().toISOString(),
|
|
434
|
+
skill,
|
|
435
|
+
category: "state",
|
|
436
|
+
verb: "invalid_transition_detected",
|
|
437
|
+
owner: options?.audit?.owner ?? "gjc-runtime",
|
|
438
|
+
mutation_id: options?.audit?.mutationId ?? `${skill}:invalid-transition:${new Date().toISOString()}`,
|
|
439
|
+
from_phase: fromPhase,
|
|
440
|
+
to_phase: toPhase,
|
|
441
|
+
forced: false,
|
|
442
|
+
paths: [filePath],
|
|
443
|
+
});
|
|
444
|
+
} catch {
|
|
445
|
+
// Audit logging is best-effort diagnostics; never fail a sanctioned write because the
|
|
446
|
+
// audit append failed (e.g. cwd is not a writable project root).
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
391
450
|
export async function writeWorkflowEnvelopeAtomic(
|
|
392
451
|
targetPath: string,
|
|
393
452
|
value: unknown,
|
|
@@ -404,6 +463,50 @@ export async function writeWorkflowEnvelopeAtomic(
|
|
|
404
463
|
.join("; ")}`,
|
|
405
464
|
);
|
|
406
465
|
}
|
|
466
|
+
// #658: internal runtime writers (ralplan/ultragoal/deep-interview/team) persist
|
|
467
|
+
// envelopes directly, bypassing the `gjc state` CLI transition gate (`isValidTransition`,
|
|
468
|
+
// historically the sole call site in state-runtime.ts). Re-assert that gate on every
|
|
469
|
+
// sanctioned envelope write so internal writes cannot persist invalid state-machine phase
|
|
470
|
+
// transitions silently. Forced writes (`gjc state ... --force`, reconcile repairs) carry
|
|
471
|
+
// `audit.forced` and bypass, mirroring the CLI's `use --force to bypass`.
|
|
472
|
+
//
|
|
473
|
+
// The gate governs ACTIVE workflow progression only. Deactivation/teardown writes
|
|
474
|
+
// (`active: false`, e.g. `gjc state clear`, which persists the universal `complete`
|
|
475
|
+
// sentinel that is not a per-skill manifest state) leave the transition graph and are
|
|
476
|
+
// intentionally exempt.
|
|
477
|
+
if (options?.audit?.forced !== true && parsed.data.active === true) {
|
|
478
|
+
const toPhase = parsed.data.current_phase.trim();
|
|
479
|
+
if (toPhase) {
|
|
480
|
+
// Lazy import: workflow-manifest dereferences CANONICAL_GJC_WORKFLOW_SKILLS at
|
|
481
|
+
// module load, and active-state -> state-writer -> workflow-manifest -> active-state
|
|
482
|
+
// is a load-time cycle. Importing at call time (after init) avoids the TDZ.
|
|
483
|
+
const { isKnownWorkflowState, isValidTransition } = await import("./workflow-manifest");
|
|
484
|
+
const skill = parsed.data.skill;
|
|
485
|
+
// Structural invariant (hard): a `current_phase` absent from the skill's manifest is
|
|
486
|
+
// never a legitimate internal write, matching the CLI/reconcile unknown-phase gate.
|
|
487
|
+
if (!isKnownWorkflowState(skill, toPhase)) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`Refusing to write unknown ${skill} phase "${toPhase}" to ${filePath}: not a known ${skill} manifest state (forced writes bypass via audit.forced)`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
// Transition invariant (#658, diagnostic-only safety net): resolve the prior phase
|
|
493
|
+
// (caller-supplied `audit.fromPhase`, else the active persisted envelope on disk) and
|
|
494
|
+
// flag edges the manifest does not define. Intentionally NON-blocking and audit-only
|
|
495
|
+
// — the CLI path already hard-fails invalid edges before reaching here, and legitimate
|
|
496
|
+
// internal repairs / ralplan short-mode stage skips move between valid states without a
|
|
497
|
+
// direct manifest edge. It records an `invalid_transition_detected` audit entry (no
|
|
498
|
+
// stderr) so such transitions are non-silent without breaking those flows.
|
|
499
|
+
const fromPhase = (options?.audit?.fromPhase ?? (await readPersistedPhase(filePath)))?.trim();
|
|
500
|
+
if (
|
|
501
|
+
fromPhase &&
|
|
502
|
+
fromPhase !== toPhase &&
|
|
503
|
+
isKnownWorkflowState(skill, fromPhase) &&
|
|
504
|
+
!isValidTransition(skill, fromPhase, toPhase)
|
|
505
|
+
) {
|
|
506
|
+
await recordInvalidWorkflowTransition({ filePath, skill, fromPhase, toPhase, options });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
407
510
|
await atomicWrite(filePath, jsonText(stamped));
|
|
408
511
|
await maybeAudit(filePath, options);
|
|
409
512
|
return filePath;
|
|
@@ -416,17 +519,55 @@ export async function writeTextAtomic(targetPath: string, text: string, options?
|
|
|
416
519
|
return filePath;
|
|
417
520
|
}
|
|
418
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Serialize a read-modify-write (or any multi-step mutation) against concurrent
|
|
524
|
+
* writers of the same `.gjc/**` target. Uses the cross-process directory lock
|
|
525
|
+
* from `withFileLock`, keyed on the resolved file path, so separate CLI/agent
|
|
526
|
+
* processes (e.g. team-mode workers) cannot interleave one writer's read with
|
|
527
|
+
* another writer's write and silently drop the first mutation (issue #646).
|
|
528
|
+
*
|
|
529
|
+
* The lock is advisory: it only protects callers that route through it, so every
|
|
530
|
+
* read-modify-write of a given file MUST acquire this lock for the same resolved
|
|
531
|
+
* path. `atomicWrite`'s temp-file + rename crash-atomicity is preserved; this
|
|
532
|
+
* layers concurrency-atomicity on top without weakening it.
|
|
533
|
+
*/
|
|
534
|
+
export async function withWorkflowStateLock<T>(
|
|
535
|
+
targetPath: string,
|
|
536
|
+
fn: () => Promise<T>,
|
|
537
|
+
options?: StateWriterOptions,
|
|
538
|
+
): Promise<T> {
|
|
539
|
+
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
540
|
+
return lockResolvedWorkflowTarget(filePath, fn, options?.lock);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function lockResolvedWorkflowTarget<T>(
|
|
544
|
+
filePath: string,
|
|
545
|
+
fn: () => Promise<T>,
|
|
546
|
+
lockOptions?: FileLockOptions,
|
|
547
|
+
): Promise<T> {
|
|
548
|
+
// `withFileLock` creates the lock dir next to the target with a non-recursive
|
|
549
|
+
// mkdir, so the parent directory must exist before the lock is acquired.
|
|
550
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
551
|
+
return withFileLock(filePath, fn, lockOptions);
|
|
552
|
+
}
|
|
553
|
+
|
|
419
554
|
export async function updateJsonAtomic<T = unknown>(
|
|
420
555
|
targetPath: string,
|
|
421
556
|
mutator: (current: T | undefined) => T | Promise<T>,
|
|
422
557
|
options?: StateWriterOptions,
|
|
423
558
|
): Promise<string> {
|
|
424
559
|
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
560
|
+
return lockResolvedWorkflowTarget(
|
|
561
|
+
filePath,
|
|
562
|
+
async () => {
|
|
563
|
+
const current = (await readJsonIfPresent(filePath)) as T | undefined;
|
|
564
|
+
const next = await mutator(current);
|
|
565
|
+
await atomicWrite(filePath, jsonText(withWorkflowReceipt(next, buildReceipt(options))));
|
|
566
|
+
await maybeAudit(filePath, options);
|
|
567
|
+
return filePath;
|
|
568
|
+
},
|
|
569
|
+
options?.lock,
|
|
570
|
+
);
|
|
430
571
|
}
|
|
431
572
|
|
|
432
573
|
export async function appendJsonl(targetPath: string, entry: unknown, options?: StateWriterOptions): Promise<string> {
|
|
@@ -437,6 +578,112 @@ export async function appendJsonl(targetPath: string, entry: unknown, options?:
|
|
|
437
578
|
return filePath;
|
|
438
579
|
}
|
|
439
580
|
|
|
581
|
+
export interface AppendJsonlIdempotentOptions extends StateWriterOptions {
|
|
582
|
+
/**
|
|
583
|
+
* Identity key for an entry. Two entries that produce the same non-`undefined`
|
|
584
|
+
* key are duplicates, so only the first is appended. Return `undefined` to opt a
|
|
585
|
+
* candidate out of dedup (it is always appended). Use `key` for the common case
|
|
586
|
+
* where identity reduces to a single string.
|
|
587
|
+
*/
|
|
588
|
+
key?: (entry: unknown) => string | undefined;
|
|
589
|
+
/**
|
|
590
|
+
* Equivalence predicate: return `true` when `existing` already represents
|
|
591
|
+
* `candidate`, suppressing the append. Use when identity cannot be reduced to a
|
|
592
|
+
* single string key. When both `key` and `equals` are supplied, `equals` wins.
|
|
593
|
+
*/
|
|
594
|
+
equals?: (candidate: unknown, existing: unknown) => boolean;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export interface AppendJsonlIdempotentResult {
|
|
598
|
+
path: string;
|
|
599
|
+
/** `true` when the entry was written; `false` when an equivalent entry already existed. */
|
|
600
|
+
appended: boolean;
|
|
601
|
+
/** The pre-existing entry that suppressed the append, when `appended` is `false`. */
|
|
602
|
+
duplicate?: unknown;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function readJsonlEntries(filePath: string): Promise<unknown[]> {
|
|
606
|
+
let raw: string;
|
|
607
|
+
try {
|
|
608
|
+
raw = await fs.readFile(filePath, "utf-8");
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (isErrno(error, "ENOENT")) return [];
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
const entries: unknown[] = [];
|
|
614
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
615
|
+
const trimmed = line.trim();
|
|
616
|
+
if (!trimmed) continue;
|
|
617
|
+
try {
|
|
618
|
+
entries.push(JSON.parse(trimmed));
|
|
619
|
+
} catch {
|
|
620
|
+
// Best-effort: dedup compares parseable rows only. A corrupt line cannot
|
|
621
|
+
// be matched, so it never suppresses a new append.
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return entries;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function findJsonlDuplicate(
|
|
628
|
+
existing: readonly unknown[],
|
|
629
|
+
candidate: unknown,
|
|
630
|
+
options: AppendJsonlIdempotentOptions,
|
|
631
|
+
): unknown | undefined {
|
|
632
|
+
if (options.equals) {
|
|
633
|
+
const equals = options.equals;
|
|
634
|
+
return existing.find(item => equals(candidate, item));
|
|
635
|
+
}
|
|
636
|
+
const key = options.key;
|
|
637
|
+
if (!key) return undefined;
|
|
638
|
+
const candidateKey = key(candidate);
|
|
639
|
+
if (candidateKey === undefined) return undefined;
|
|
640
|
+
return existing.find(item => key(item) === candidateKey);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Append `entry` to a JSONL file only when no equivalent entry already exists —
|
|
645
|
+
* the shared idempotent append primitive (issue #660).
|
|
646
|
+
*
|
|
647
|
+
* `appendJsonl` is a pure append with no dedup, so every recurring "duplicate
|
|
648
|
+
* ledger row" bug (#638, #643, #645) had to be patched with bespoke per-call-site
|
|
649
|
+
* guards. This primitive centralizes the read-check-append cycle: a caller
|
|
650
|
+
* declares identity once via `key` or `equals` instead of re-deriving the lookup
|
|
651
|
+
* at each site.
|
|
652
|
+
*
|
|
653
|
+
* The read-then-append is serialized through the same cross-process workflow lock
|
|
654
|
+
* as `updateJsonAtomic`, so two concurrent idempotent appends cannot both observe
|
|
655
|
+
* "no duplicate" and both write (the #646 TOCTOU that a plain `appendJsonl`
|
|
656
|
+
* preceded by a manual existence check is still exposed to).
|
|
657
|
+
*
|
|
658
|
+
* Scope note: this dedups the *append* only. Call sites whose idempotency must
|
|
659
|
+
* also skip a coupled mutation — e.g. the plan/state rewrite in #643/#645 — still
|
|
660
|
+
* need a whole-operation guard; this primitive is the ledger-level half of that.
|
|
661
|
+
*/
|
|
662
|
+
export async function appendJsonlIdempotent(
|
|
663
|
+
targetPath: string,
|
|
664
|
+
entry: unknown,
|
|
665
|
+
options: AppendJsonlIdempotentOptions,
|
|
666
|
+
): Promise<AppendJsonlIdempotentResult> {
|
|
667
|
+
if (!options.key && !options.equals) {
|
|
668
|
+
throw new Error("appendJsonlIdempotent requires a `key` or `equals` option to detect duplicates");
|
|
669
|
+
}
|
|
670
|
+
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
671
|
+
return lockResolvedWorkflowTarget(
|
|
672
|
+
filePath,
|
|
673
|
+
async () => {
|
|
674
|
+
const existing = await readJsonlEntries(filePath);
|
|
675
|
+
const duplicate = findJsonlDuplicate(existing, entry, options);
|
|
676
|
+
if (duplicate !== undefined) {
|
|
677
|
+
return { path: filePath, appended: false, duplicate };
|
|
678
|
+
}
|
|
679
|
+
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
680
|
+
await maybeAudit(filePath, options);
|
|
681
|
+
return { path: filePath, appended: true };
|
|
682
|
+
},
|
|
683
|
+
options.lock,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
440
687
|
export async function appendText(targetPath: string, text: string, options?: StateWriterOptions): Promise<string> {
|
|
441
688
|
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
442
689
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
@@ -639,7 +886,7 @@ export async function hardPrune(
|
|
|
639
886
|
const removed: string[] = [];
|
|
640
887
|
for (const target of targets) {
|
|
641
888
|
const filePath = resolveGjcTarget(target.path, cwd);
|
|
642
|
-
let stat:
|
|
889
|
+
let stat: Stats;
|
|
643
890
|
try {
|
|
644
891
|
stat = await fs.stat(filePath);
|
|
645
892
|
} catch (error) {
|