@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.
Files changed (165) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +6 -0
  4. package/dist/types/cli/setup-cli.d.ts +8 -1
  5. package/dist/types/commands/setup.d.ts +7 -0
  6. package/dist/types/config/file-lock.d.ts +24 -2
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +62 -0
  10. package/dist/types/dap/client.d.ts +2 -1
  11. package/dist/types/edit/read-file.d.ts +6 -0
  12. package/dist/types/eval/js/context-manager.d.ts +3 -0
  13. package/dist/types/eval/js/executor.d.ts +1 -0
  14. package/dist/types/exec/bash-executor.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  19. package/dist/types/lsp/types.d.ts +2 -0
  20. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  23. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  24. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  28. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  29. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  30. package/dist/types/modes/theme/theme.d.ts +1 -0
  31. package/dist/types/modes/types.d.ts +1 -1
  32. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  33. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  34. package/dist/types/runtime-mcp/types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +17 -1
  36. package/dist/types/session/artifacts.d.ts +4 -1
  37. package/dist/types/session/history-storage.d.ts +2 -2
  38. package/dist/types/session/session-manager.d.ts +10 -1
  39. package/dist/types/session/streaming-output.d.ts +5 -0
  40. package/dist/types/setup/credential-import.d.ts +79 -0
  41. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  42. package/dist/types/task/executor.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +1 -1
  44. package/dist/types/tools/bash.d.ts +1 -0
  45. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  46. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  47. package/dist/types/tools/subagent-render.d.ts +7 -1
  48. package/dist/types/tools/subagent.d.ts +21 -0
  49. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  50. package/dist/types/web/search/index.d.ts +4 -4
  51. package/dist/types/web/search/provider.d.ts +16 -20
  52. package/dist/types/web/search/providers/base.d.ts +2 -1
  53. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  54. package/dist/types/web/search/types.d.ts +14 -2
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +7 -0
  57. package/src/async/job-manager.ts +153 -39
  58. package/src/cli/args.ts +2 -0
  59. package/src/cli/fast-help.ts +2 -0
  60. package/src/cli/setup-cli.ts +138 -3
  61. package/src/commands/setup.ts +5 -1
  62. package/src/commands/ultragoal.ts +3 -1
  63. package/src/config/file-lock-gc.ts +14 -2
  64. package/src/config/file-lock.ts +63 -13
  65. package/src/config/model-profile-activation.ts +15 -3
  66. package/src/config/model-profiles.ts +15 -15
  67. package/src/config/model-registry.ts +21 -1
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +62 -0
  70. package/src/dap/client.ts +105 -64
  71. package/src/dap/session.ts +44 -7
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  73. package/src/edit/read-file.ts +19 -1
  74. package/src/eval/js/context-manager.ts +228 -65
  75. package/src/eval/js/executor.ts +2 -0
  76. package/src/eval/js/index.ts +1 -0
  77. package/src/eval/js/worker-core.ts +10 -6
  78. package/src/eval/py/executor.ts +68 -19
  79. package/src/eval/py/kernel.ts +46 -22
  80. package/src/eval/py/runner.py +68 -14
  81. package/src/exec/bash-executor.ts +49 -13
  82. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  83. package/src/gjc-runtime/launch-tmux.ts +3 -4
  84. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  85. package/src/gjc-runtime/state-runtime.ts +2 -1
  86. package/src/gjc-runtime/state-writer.ts +254 -7
  87. package/src/gjc-runtime/tmux-gc.ts +88 -38
  88. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  89. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  90. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  91. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  92. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  93. package/src/harness-control-plane/owner.ts +3 -2
  94. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  95. package/src/hooks/skill-state.ts +121 -2
  96. package/src/internal-urls/artifact-protocol.ts +10 -1
  97. package/src/internal-urls/docs-index.generated.ts +14 -10
  98. package/src/lsp/client.ts +64 -26
  99. package/src/lsp/defaults.json +1 -0
  100. package/src/lsp/index.ts +2 -1
  101. package/src/lsp/lspmux.ts +33 -9
  102. package/src/lsp/types.ts +2 -0
  103. package/src/main.ts +14 -4
  104. package/src/modes/acp/acp-agent.ts +4 -2
  105. package/src/modes/bridge/bridge-mode.ts +23 -1
  106. package/src/modes/components/assistant-message.ts +10 -2
  107. package/src/modes/components/bash-execution.ts +5 -1
  108. package/src/modes/components/eval-execution.ts +5 -1
  109. package/src/modes/components/history-search.ts +5 -2
  110. package/src/modes/components/model-selector.ts +60 -2
  111. package/src/modes/components/oauth-selector.ts +5 -0
  112. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  113. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  114. package/src/modes/components/skill-message.ts +24 -16
  115. package/src/modes/components/tool-execution.ts +6 -0
  116. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  117. package/src/modes/controllers/input-controller.ts +5 -0
  118. package/src/modes/controllers/selector-controller.ts +86 -2
  119. package/src/modes/interactive-mode.ts +11 -1
  120. package/src/modes/rpc/rpc-mode.ts +132 -18
  121. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  122. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  123. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  124. package/src/modes/theme/defaults/claude-code.json +100 -0
  125. package/src/modes/theme/defaults/codex.json +100 -0
  126. package/src/modes/theme/defaults/index.ts +6 -0
  127. package/src/modes/theme/defaults/opencode.json +102 -0
  128. package/src/modes/theme/theme.ts +2 -2
  129. package/src/modes/types.ts +1 -1
  130. package/src/modes/utils/ui-helpers.ts +5 -2
  131. package/src/prompts/agents/executor.md +5 -2
  132. package/src/runtime/process-lifecycle.ts +400 -0
  133. package/src/runtime-mcp/manager.ts +164 -50
  134. package/src/runtime-mcp/transports/http.ts +12 -11
  135. package/src/runtime-mcp/transports/stdio.ts +64 -38
  136. package/src/runtime-mcp/types.ts +3 -0
  137. package/src/sdk.ts +39 -1
  138. package/src/session/agent-session.ts +190 -33
  139. package/src/session/artifacts.ts +17 -2
  140. package/src/session/blob-store.ts +36 -2
  141. package/src/session/history-storage.ts +32 -11
  142. package/src/session/session-manager.ts +99 -31
  143. package/src/session/streaming-output.ts +54 -3
  144. package/src/setup/credential-import.ts +429 -0
  145. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  146. package/src/slash-commands/builtin-registry.ts +30 -3
  147. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  148. package/src/task/executor.ts +7 -1
  149. package/src/task/render.ts +18 -7
  150. package/src/tools/archive-reader.ts +10 -1
  151. package/src/tools/ask.ts +4 -2
  152. package/src/tools/bash.ts +11 -4
  153. package/src/tools/browser/tab-supervisor.ts +22 -0
  154. package/src/tools/browser.ts +38 -4
  155. package/src/tools/cron.ts +1 -1
  156. package/src/tools/read.ts +11 -12
  157. package/src/tools/sqlite-reader.ts +19 -5
  158. package/src/tools/subagent-render.ts +119 -29
  159. package/src/tools/subagent.ts +147 -7
  160. package/src/tools/ultragoal-ask-guard.ts +39 -0
  161. package/src/web/search/index.ts +25 -25
  162. package/src/web/search/provider.ts +178 -87
  163. package/src/web/search/providers/base.ts +2 -1
  164. package/src/web/search/providers/openai-compatible.ts +151 -0
  165. 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 { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
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
- async function persistActiveRunId(cwd: string, sessionId: string | undefined, runId: string): Promise<void> {
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
- if (existing.run_id === runId && existing.version === WORKFLOW_STATE_VERSION) return;
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
- if (typeof existing.active !== "boolean") existing.active = true;
204
- if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
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
- async function persistArtifact(resolved: ResolvedArtifactArgs, cwd: string): Promise<PersistedArtifact> {
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 appendJsonl(path.join(runDir, "index.jsonl"), indexEntry, {
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 persisted = await persistArtifact(resolved, cwd);
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: Awaited<ReturnType<typeof detectWorkflowEnvelopeIntegrityMismatch>>;
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: Awaited<ReturnType<typeof fs.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
- const current = (await readJsonIfPresent(filePath)) as T | undefined;
426
- const next = await mutator(current);
427
- await atomicWrite(filePath, jsonText(withWorkflowReceipt(next, buildReceipt(options))));
428
- await maybeAudit(filePath, options);
429
- return filePath;
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: Awaited<ReturnType<typeof fs.stat>>;
889
+ let stat: Stats;
643
890
  try {
644
891
  stat = await fs.stat(filePath);
645
892
  } catch (error) {