@deksden-com/dd-flow-cli 0.9.0-beta.16 → 0.9.0-beta.18

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.18
4
+
5
+ ### Patch Changes
6
+
7
+ - Add Factory Droid harness routing, native lifecycle receipts and physical Session usage ingestion. Preserve child identity and reject conflicting hook replays or inclusive Droid usage to prevent incorrect Work binding and double counting.
8
+
9
+ ## 0.9.0-beta.17
10
+
11
+ ### Patch Changes
12
+
13
+ - 0dd4da3: Verify current-attempt gate retries, deduplicate equivalent checks across gate references, retain archive locations across interrupted and repeated repairs, and align MERGE environment recovery guidance with source repair.
14
+
15
+ Snapshot SQLite consistently with VACUUM INTO and exclude local environment secrets, dependencies and caches from project/workspace snapshots.
16
+
17
+ Reconcile native child failures only against unique registered Work/Session attempts, retain non-restorable incomplete snapshots for failed evals, and expose unexpected merge-lane release failures for safe retry.
18
+
3
19
  ## 0.9.0-beta.16
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -17,6 +17,27 @@ Never use a broad `pkill`/`killall`. A failed owned stop retains resources.
17
17
 
18
18
  It does not replace Memory Bank prompts and does not make product, design, merge, or verification judgments. Prompts own intent, route selection, planning depth, evidence meaning, and semantic readiness. The CLI owns explicit local state: projects, protocols, Codex session bindings, transitions, worktree records, lanes, locks, merge queue jobs, hook records, and audit events.
19
19
 
20
+ ## Factory Droid harness
21
+
22
+ Configure `droid-cli` in `~/.dd-flow/harnesses.json` with absolute
23
+ `adapter_command` (`dd-droid`) and `runtime_command` (`droid`) paths. Agent
24
+ profiles use the `droid` alias; the adapter receives `--droid-bin`.
25
+
26
+ The controlled adapter sends `dd-flow/droid-tool-event@1` JSON to
27
+ `dd-flow droid event handle --project-root <root> --json`. Events include a
28
+ stable `event_id`, `daemon_id`, `phase` (`before` or `after`), native Session
29
+ identity and parent, absolute Session directory, `Execute` input, observed
30
+ profile and transcript path. Before a lifecycle invocation, the returned
31
+ `hookSpecificOutput.updatedInput` adds its one-use `--hook-event-id` receipt.
32
+ The event ID belongs to the native hook invocation and is reused only for
33
+ transport retries. Session success is established by the Work contract.
34
+
35
+ `dd-flow droid usage ingest --project-root <root> --json` accepts cumulative
36
+ per-Session counters with explicit `usage_scope: physical_session` and
37
+ `completeness: complete|partial`. Inclusive root counters are rejected;
38
+ partial windows remain unavailable in RUN totals until complete evidence
39
+ arrives. Keep inclusive provider totals in adapter evidence for reconciliation.
40
+
20
41
  ## Runtime Model
21
42
 
22
43
  Root local database:
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.16",
4
- "cli_commit": "8de503c90a952a8b56300a0c0367dd89be5c2436",
5
- "built_at": "2026-09-05T22:05:40.605Z",
3
+ "cli_version": "0.9.0-beta.18",
4
+ "cli_commit": "5624d92e8e96732db6a81f1cf86fb5342f38401f",
5
+ "built_at": "2026-09-06T03:05:30.379Z",
6
6
  "built_with_canon": {
7
- "version": "4.0.4",
8
- "commit": "8e572d151c7f9d22c2b60122c87d9febcbfbde69",
7
+ "version": "4.0.5",
8
+ "commit": "272579e9035b88772a0f017c9bec09f877c17552",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
11
  "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
package/dist/cli/help.js CHANGED
@@ -38,6 +38,7 @@ Core commands:
38
38
  codex home/hooks/hook Manage Codex homes, hooks, and hook events.
39
39
  zcode event/usage Ingest trusted zcode-acp lifecycle and usage evidence.
40
40
  grok event/usage Ingest trusted Grok Build lifecycle and usage evidence.
41
+ droid event/usage Ingest trusted Factory Droid lifecycle and physical usage evidence.
41
42
  opencode event/usage Ingest trusted OpenCode lifecycle and usage evidence.
42
43
  agy event/usage Ingest trusted Antigravity CLI lifecycle and usage evidence.
43
44
  worktree plan/create/status Record Worktrunk-backed feature worktrees.
@@ -154,7 +155,7 @@ Usage:
154
155
  dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
155
156
  dd-flow run list --project-root <root> --json
156
157
  dd-flow run prepare-vnext-specify --project-root <root> --slug <slug> --json
157
- dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate) --project-root <root> --output <directory> --json
158
+ dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete) --project-root <root> --output <directory> --json
158
159
  dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> --json
159
160
  dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
160
161
  dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
@@ -190,6 +191,7 @@ Usage:
190
191
  dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
191
192
  dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
192
193
  dd-flow stage fanout dispatch <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
194
+ dd-flow stage fanout reconcile <RUN-ID> --project-root <root> --stage <name> --observations-file <native-observations.json> --json
193
195
  dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--semantic-file <RUN-local-json>] --json
194
196
  dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage specify (--result-file <RUN-local-specify.json>|--result-stdin) --outcome <specified|failed|cancelled> --json
195
197
 
@@ -548,6 +550,17 @@ Usage:
548
550
 
549
551
  Both payloads are read from stdin. The event adapter retains lifecycle identity;
550
552
  the usage adapter retains cumulative provider counters for RUN accounting.`
553
+ ],
554
+ [
555
+ "droid event",
556
+ `dd-flow droid event - ingest a controlled Factory Droid Execute hook
557
+
558
+ Usage:
559
+ dd-flow droid event handle --project-root <root> --json
560
+ dd-flow droid usage ingest --project-root <root> --json
561
+
562
+ Reads trusted adapter JSON from stdin. Usage requires physical_session scope and explicit completeness.
563
+ `
551
564
  ],
552
565
  [
553
566
  "opencode event",
@@ -10,7 +10,7 @@ import { blockPlanItem, completePlanItem, getPlanStatus, skipPlanItem, startPlan
10
10
  import { cancelMergeQueueJob, claimNextMergeJob, claimMergeJob, claimMergeBundle, completeMergeBundle, completeMergeJob, failMergeBundle, failMergeJob, getMergeBundleStatus, getMergeQueueStatus, noteMergeJob, waitNextMergeJob } from "../services/merge-queue.js";
11
11
  import { getMergeWorkerStatus, oneShotMergeClaim, startMergeWorker, stopProjectMergeWorker } from "../services/merge-worker.js";
12
12
  import { cleanupApply, cleanupScan } from "../services/cleanup.js";
13
- import { getCodexHomeStatus, getCodexHooksStatus, handleCodexHook, handleAgyEvent, handleGrokEvent, handleOpenCodeEvent, handleZcodeEvent, isolatedDdFlowHomeFromHookStdin, claimBootstrapHookEvent, findRecentMatchingHookEvent, stageStartMatchKey, initCodexHome, installCodexHooks, planCodexHome, printCodexHomeEnv, printCodexHooks, removeCodexHome, removeCodexHooks } from "../services/hooks.js";
13
+ import { getCodexHomeStatus, getCodexHooksStatus, handleCodexHook, handleAgyEvent, handleDroidEvent, handleGrokEvent, handleOpenCodeEvent, handleZcodeEvent, isolatedDdFlowHomeFromHookStdin, claimBootstrapHookEvent, findRecentMatchingHookEvent, stageStartMatchKey, initCodexHome, installCodexHooks, planCodexHome, printCodexHomeEnv, printCodexHooks, removeCodexHome, removeCodexHooks } from "../services/hooks.js";
14
14
  import { bootstrapWorktree, closeWorktree, createWorktreeRecord, getWorktreeStatus, planWorktree } from "../services/worktrees.js";
15
15
  import { acquireLaneLock, cancelLaneWaiter, checkLaneWorkspace, getLaneWaiters, getLaneStatus, heartbeatLaneLock, releaseLaneLock, setLaneWorkspace, waitAcquireLaneLock, waitForLaneLock } from "../services/lanes.js";
16
16
  import { getProjectConfigStatus, setProjectConfigValue } from "../services/config.js";
@@ -26,7 +26,7 @@ import { attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunSe
26
26
  import { previewNextEntityId } from "../services/ids.js";
27
27
  import { validateSchema } from "../services/schema-validation.js";
28
28
  import { preflightMemoryPermissions } from "../services/memory-permissions.js";
29
- import { ingestAgyUsage, ingestGrokUsage, ingestOpenCodeUsage, ingestZcodeUsage } from "../services/usage.js";
29
+ import { ingestDroidUsage, ingestAgyUsage, ingestGrokUsage, ingestOpenCodeUsage, ingestZcodeUsage } from "../services/usage.js";
30
30
  import { applyMigration, assessMigrationImpact, assertUpgradeRunStorageReady, planMigration, verifyMigrationReport } from "../services/migrations.js";
31
31
  import { publishProjectSummary } from "../services/project-summary.js";
32
32
  import { requireProjectByRoot } from "../services/projects.js";
@@ -41,7 +41,7 @@ import { addVnextCodeRepair, finishVnextCode, startVnextCode } from "../services
41
41
  import { addVnextCodeReviewRepair, finishVnextCodeReview, isVnextCodeReviewRun, startVnextCodeReview } from "../services/vnext-code-review.js";
42
42
  import { applyVnextMerge, finishVnextMerge, getVnextMergeRequest, isVnextMergeRun, repairVnextMerge, routeVnextMergeRequest, startVnextMerge } from "../services/vnext-merge.js";
43
43
  import { mergeServerStatus, serveMergeRequests, stopMergeServer } from "../services/merge-server.js";
44
- import { getVnextFanoutStatus } from "../services/vnext-fanout.js";
44
+ import { getVnextFanoutStatus, reconcileVnextFanout } from "../services/vnext-fanout.js";
45
45
  import { pauseStageForUser, resumeStageAfterUser } from "../services/stage-pause.js";
46
46
  import { blockStageForRuntime, unblockStageAfterRuntimeRepair } from "../services/stage-blocker.js";
47
47
  import { classifyCliOperation, requiresExplicitUpgradeAuthorization } from "../services/cli-operation-classifier.js";
@@ -661,12 +661,14 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
661
661
  const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "stage fanout");
662
662
  if (action === "status")
663
663
  return getVnextFanoutStatus(context, { projectRoot, runId, stage });
664
+ if (action === "reconcile")
665
+ return reconcileVnextFanout(context, { projectRoot, runId, stage, observations: JSON.parse(fs.readFileSync(requiredOption(parsed, "observations-file"), "utf8")) });
664
666
  if (action === "dispatch") {
665
667
  if (stage === "plan-review")
666
668
  return dispatchVnextPlanReview(context, { projectRoot, runId });
667
669
  return getVnextFanoutStatus(context, { projectRoot, runId, stage });
668
670
  }
669
- throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch> <RUN> --stage <stage> --project-root <path>", 2);
671
+ throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch|reconcile> <RUN> --stage <stage> --project-root <path>; reconcile requires --observations-file <native-observations.json>", 2);
670
672
  }
671
673
  if (family === "run" && command === "capacity") {
672
674
  if (requiredPosition(parsed, 0, "capacity action") !== "record")
@@ -1068,6 +1070,16 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1068
1070
  throw new AppError("usage", "Unknown opencode usage action", 2);
1069
1071
  return ingestOpenCodeUsage(context, { projectRoot: requiredOption(parsed, "project-root"), stdin: await readStdin(io.stdin) });
1070
1072
  }
1073
+ if (family === "droid" && command === "event") {
1074
+ if (requiredPosition(parsed, 0, "droid event action") !== "handle")
1075
+ throw new AppError("usage", "Unknown droid event action", 2);
1076
+ return handleDroidEvent(context, { projectRoot: requiredOption(parsed, "project-root"), stdin: await readStdin(io.stdin) });
1077
+ }
1078
+ if (family === "droid" && command === "usage") {
1079
+ if (requiredPosition(parsed, 0, "droid usage action") !== "ingest")
1080
+ throw new AppError("usage", "Unknown droid usage action", 2);
1081
+ return ingestDroidUsage(context, { projectRoot: requiredOption(parsed, "project-root"), stdin: await readStdin(io.stdin) });
1082
+ }
1071
1083
  if (family === "agy" && command === "event")
1072
1084
  return dispatchAgyEvent(context, parsed, await readStdin(io.stdin));
1073
1085
  if (family === "agy" && command === "usage") {
@@ -1435,16 +1447,17 @@ function dispatchRun(context, command, parsed) {
1435
1447
  }
1436
1448
  if (action === "create") {
1437
1449
  const candidate = parsed.options.has("candidate");
1450
+ const incomplete = parsed.options.has("incomplete");
1438
1451
  const stageEntry = optionalOption(parsed, "stage-entry");
1439
- if (candidate && stageEntry !== undefined)
1440
- throw new AppError("usage", "Use either --candidate or --stage-entry, not both", 2);
1441
- if (!candidate && stageEntry === undefined)
1442
- throw new AppError("usage", "Snapshot create requires --candidate or --stage-entry", 2);
1452
+ if (Number(candidate) + Number(incomplete) + Number(stageEntry !== undefined) !== 1)
1453
+ throw new AppError("usage", "Use exactly one of --candidate, --incomplete or --stage-entry", 2);
1443
1454
  const input = {
1444
1455
  projectRoot: requiredOption(parsed, "project-root"),
1445
1456
  runId: requiredPosition(parsed, 1, "run-id"),
1446
1457
  output: requiredOption(parsed, "output")
1447
1458
  };
1459
+ if (incomplete)
1460
+ return createEvalRunSnapshot(context, { ...input, incomplete: true });
1448
1461
  return candidate
1449
1462
  ? createEvalRunSnapshot(context, { ...input, candidate: true })
1450
1463
  : createEvalRunSnapshot(context, { ...input, stageEntry: stageEntry });
@@ -2369,7 +2382,7 @@ function projectRootForMutation(context, args, result, resolvedScope) {
2369
2382
  if (family === "opencode" && ((command === "event" && requiredPosition(parsed, 0, "opencode event action") === "handle") || command === "usage")) {
2370
2383
  return requiredOption(parsed, "project-root");
2371
2384
  }
2372
- if (family === "agy" && ((command === "event" && requiredPosition(parsed, 0, "agy event action") === "handle") || command === "usage"))
2385
+ if (["agy", "droid"].includes(family ?? "") && ((command === "event" && requiredPosition(parsed, 0, `${family} event action`) === "handle") || command === "usage"))
2373
2386
  return requiredOption(parsed, "project-root");
2374
2387
  if (family === "codex" && command === "hooks") {
2375
2388
  const action = requiredPosition(parsed, 0, "codex hooks action");
@@ -7,7 +7,7 @@
7
7
  "properties": {
8
8
  "schema_id": {"const": "dd-flow/agent-profile@1"},
9
9
  "id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"},
10
- "harness": {"enum": ["codex", "zcode", "agy", "opencode", "grok"]},
10
+ "harness": {"enum": ["codex", "zcode", "agy", "opencode", "grok", "droid"]},
11
11
  "provider": {"type": "string", "minLength": 1},
12
12
  "model": {"type": "string", "minLength": 1},
13
13
  "reasoning": {"type": "string", "minLength": 1},
@@ -106,6 +106,8 @@ function isRouterNative(args) {
106
106
  || (first === "zcode" && args[1] === "usage" && args[2] === "ingest")
107
107
  || (first === "grok" && args[1] === "event" && args[2] === "handle")
108
108
  || (first === "grok" && args[1] === "usage" && args[2] === "ingest")
109
+ || (first === "droid" && args[1] === "event" && args[2] === "handle")
110
+ || (first === "droid" && args[1] === "usage" && args[2] === "ingest")
109
111
  || (first === "opencode" && args[1] === "event" && args[2] === "handle")
110
112
  || (first === "opencode" && args[1] === "usage" && args[2] === "ingest")
111
113
  || (first === "agy" && args[1] === "event" && args[2] === "handle")
@@ -225,9 +225,14 @@ export function unchangedFinalGateFailures(context, input) { const wanted = new
225
225
  latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current && receipt.id !== input.retryReceiptId); }
226
226
  /** Validate an explicit retry; the caller still executes a fresh real gate and retains a new receipt. */
227
227
  export function validateFailedCheckRetry(context, input) {
228
+ if (!input.reason?.trim())
229
+ throw new AppError("retry_check_invalid", "--retry-check requires --reason describing the restored environment", 2);
228
230
  const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
229
231
  if (!receipt || receipt.scope !== "aggregate" || !["failed", "aborted"].includes(receipt.status))
230
232
  throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted aggregate receipt in this RUN", 2, { check_receipt_id: input.receiptId });
233
+ const relative = path.relative(input.artifactRoot, receipt.receipt_path);
234
+ if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`) || /^try-\d+([/\\]|$)/.test(relative))
235
+ throw new AppError("retry_check_invalid", "--retry-check must belong to this current stage attempt, not an archived or different stage", 2, { check_receipt_id: input.receiptId });
231
236
  const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
232
237
  if (!receipt.check_refs.some((ref) => declared.has(ref)))
233
238
  throw new AppError("retry_check_invalid", "--retry-check does not belong to this stage gate", 2, { check_receipt_id: input.receiptId, check_refs: receipt.check_refs });
@@ -236,12 +241,27 @@ export function validateFailedCheckRetry(context, input) {
236
241
  export function checkReceipts(context, input) {
237
242
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
238
243
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
239
- return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json), resources: readReceiptResources(row.receipt_path) }));
244
+ return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), resources: readReceiptResources(row.receipt_path), ...resolvedReceiptArtifacts(row.receipt_path, JSON.parse(row.artifacts_json)) }));
245
+ }
246
+ /** File bytes remain immutable; only the reader projects their current location. */
247
+ function resolvedReceiptArtifacts(receiptPath, artifacts) {
248
+ let original = {};
249
+ try {
250
+ original = JSON.parse(fs.readFileSync(receiptPath, "utf8"));
251
+ }
252
+ catch { /* Historical missing receipts remain visibly incomplete. */ }
253
+ const currentRoot = path.dirname(receiptPath);
254
+ const oldRoot = original.receipt_path ? path.dirname(original.receipt_path) : currentRoot;
255
+ const relocate = (file) => {
256
+ const relative = path.relative(oldRoot, file);
257
+ return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
258
+ };
259
+ return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
240
260
  }
241
261
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
242
262
  export function workspaceChangedPaths(root) { const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (status.status !== 0)
243
263
  return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
244
- function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts, ports]); const current = groups.get(key); if (current)
264
+ function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([commands[index], artifacts, ports]); const current = groups.get(key); if (current)
245
265
  current.declarations.push(check);
246
266
  else
247
267
  groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
@@ -19,7 +19,7 @@ export function createEvalBootstrapSnapshot(context, input) {
19
19
  if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
20
20
  throw new AppError("snapshot_output_inside_runtime", "Bootstrap snapshot output must be outside DD_FLOW_HOME", 1, { output });
21
21
  fs.mkdirSync(output, { recursive: true });
22
- fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
22
+ fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
23
23
  const manifest = {
24
24
  schema_id: "dd-eval/bootstrap-snapshot@1",
25
25
  stage: "specify",
@@ -55,7 +55,8 @@ export function createEvalRunSnapshot(context, input) {
55
55
  const project = requireProjectByRoot(context, projectRoot);
56
56
  const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
57
57
  assertDedicatedHome(context, project.id, input.runId);
58
- if (input.candidate)
58
+ if (input.incomplete) { /* Forensic evidence is not an accepted or restorable boundary. */ }
59
+ else if (input.candidate)
59
60
  assertTerminalCandidate(status.index.stage_runs ?? []);
60
61
  else if (input.stageEntry)
61
62
  assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
@@ -65,7 +66,7 @@ export function createEvalRunSnapshot(context, input) {
65
66
  const activeChildren = input.candidate
66
67
  ? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
67
68
  : context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
68
- if (activeChildren > 0)
69
+ if (!input.incomplete && activeChildren > 0)
69
70
  throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
70
71
  const output = path.resolve(input.output);
71
72
  if (fs.existsSync(output))
@@ -75,7 +76,6 @@ export function createEvalRunSnapshot(context, input) {
75
76
  const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
76
77
  if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
77
78
  throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
78
- context.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
79
79
  fs.mkdirSync(output, { recursive: true });
80
80
  fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
81
81
  recursive: true,
@@ -83,12 +83,17 @@ export function createEvalRunSnapshot(context, input) {
83
83
  // Managed checkouts are restored from their Git bundle and workspace
84
84
  // payload. Copying their .git indirection into runtime makes the snapshot
85
85
  // dependent on the source machine's worktree metadata.
86
- filter: (source) => !["engines", "checkouts"].includes(path.basename(source))
86
+ filter: (source) => snapshotPayloadAllowed(source) && !["engines", "checkouts"].includes(path.basename(source)) && ![context.db.path, `${context.db.path}-wal`, `${context.db.path}-shm`].includes(source)
87
87
  });
88
+ // VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
89
+ // pages. Copying the live database and its WAL separately is not atomic.
90
+ const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
91
+ fs.mkdirSync(path.dirname(databaseCopy), { recursive: true });
92
+ context.db.exec(`VACUUM INTO '${databaseCopy.replace(/'/g, "''")}'`);
88
93
  fs.cpSync(projectRoot, path.join(output, "project"), {
89
94
  recursive: true,
90
95
  verbatimSymlinks: true,
91
- filter: (source) => path.basename(source) !== ".git"
96
+ filter: snapshotPayloadAllowed
92
97
  });
93
98
  const projectGit = snapshotProjectGit(projectRoot, output);
94
99
  const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
@@ -98,8 +103,9 @@ export function createEvalRunSnapshot(context, input) {
98
103
  project_id: project.id,
99
104
  project_root: projectRoot,
100
105
  dd_flow_home: context.ddFlowHome,
101
- purpose: input.candidate ? "candidate" : "stage_entry",
102
- stage_entry: input.candidate ? null : input.stageEntry,
106
+ purpose: input.incomplete ? "incomplete" : input.candidate ? "candidate" : "stage_entry",
107
+ stage_entry: input.incomplete || input.candidate ? null : input.stageEntry,
108
+ ...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : {}),
103
109
  created_at: context.now(),
104
110
  runtime_sha256: treeHash(path.join(output, "runtime")),
105
111
  project_sha256: treeHash(path.join(output, "project")),
@@ -121,6 +127,8 @@ function createdWorkSchemaForStage(stage) {
121
127
  export function restoreEvalRunSnapshot(context, input) {
122
128
  const snapshot = path.resolve(input.snapshot);
123
129
  const manifest = readManifest(snapshot);
130
+ if (manifest.purpose === "incomplete")
131
+ throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
124
132
  if (manifest.purpose === "candidate")
125
133
  throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
126
134
  if (!manifest.stage_entry)
@@ -285,7 +293,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
285
293
  throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
286
294
  const workspace = path.join(output, "workspace");
287
295
  const bundle = path.join(output, "workspace.bundle");
288
- fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
296
+ fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
289
297
  runGit(projectRoot, ["bundle", "create", bundle, branch]);
290
298
  return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
291
299
  }
@@ -343,6 +351,11 @@ function runGit(root, args) {
343
351
  throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
344
352
  }
345
353
  function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
354
+ function snapshotPayloadAllowed(source) {
355
+ const name = path.basename(source);
356
+ return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
357
+ && name !== ".env" && !name.startsWith(".env.");
358
+ }
346
359
  function treeHash(root) {
347
360
  const hash = crypto.createHash("sha256");
348
361
  const visit = (dir, relative) => {
@@ -370,7 +383,7 @@ function readManifest(snapshot) {
370
383
  if (!fs.existsSync(file))
371
384
  throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
372
385
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
373
- if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose === "candidate" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
386
+ if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
374
387
  throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
375
388
  if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
376
389
  throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
@@ -10,6 +10,7 @@ export function harnessKey(value) {
10
10
  zcode: "zcode-acp", "zcode-acp": "zcode-acp",
11
11
  grok: "grok-acp", "grok-acp": "grok-acp",
12
12
  agy: "antigravity-cli", "antigravity-cli": "antigravity-cli",
13
+ droid: "droid-cli", "droid-cli": "droid-cli",
13
14
  opencode: "opencode-server", "opencode-server": "opencode-server"
14
15
  };
15
16
  const resolved = keys[value];
@@ -60,6 +61,7 @@ export function harnessRuntimeArguments(ddFlowHome, harness) {
60
61
  "zcode-acp": "--zcode-acp-bin",
61
62
  "grok-acp": "--grok-bin",
62
63
  "antigravity-cli": "--agy-bin",
64
+ "droid-cli": "--droid-bin",
63
65
  "opencode-server": "--opencode-bin"
64
66
  };
65
67
  return [option[key], runtime];
@@ -577,13 +577,25 @@ export function handleGrokEvent(context, input) {
577
577
  /** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
578
578
  export function handleOpenCodeEvent(context, input) {
579
579
  const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
580
- const agy = event.source_harness === "antigravity-cli";
581
- const harness = agy ? "antigravity-cli" : "opencode-server";
582
580
  if (event.schema_id !== "dd-flow/opencode-tool-event@1")
583
581
  throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
582
+ return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
583
+ }
584
+ /** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
585
+ export function handleDroidEvent(context, input) {
586
+ const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
587
+ if (event.schema_id !== "dd-flow/droid-tool-event@1")
588
+ throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
589
+ return handleControlledToolEvent(context, input, event, "droid-cli");
590
+ }
591
+ function handleControlledToolEvent(context, input, event, harness) {
592
+ const agy = harness === "antigravity-cli";
593
+ const droid = harness === "droid-cli";
594
+ const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
595
+ const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
584
596
  const phase = stringValue(event.phase);
585
597
  if (phase !== "before" && phase !== "after")
586
- throw new AppError("unsupported_opencode_event_schema", "OpenCode event phase must be before or after", 1);
598
+ throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
587
599
  const session = objectRecord(event.session);
588
600
  const profile = objectRecord(event.profile);
589
601
  const rawInput = objectRecord(event.input);
@@ -591,16 +603,18 @@ export function handleOpenCodeEvent(context, input) {
591
603
  const nativeParentId = stringValue(session.parent_provider_session_id);
592
604
  const daemonId = stringValue(event.daemon_id);
593
605
  const toolCallId = stringValue(event.tool_call_id);
594
- const eventId = stringValue(event.event_id) ?? toolCallId;
606
+ const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
595
607
  const toolName = stringValue(event.tool);
596
608
  const directory = stringValue(session.directory);
597
- if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
598
- throw new AppError("opencode_session_identity_invalid", "OpenCode event is missing trusted physical identity", 1);
609
+ if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
610
+ throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
599
611
  }
600
612
  const expectedRoot = resolveProjectRoot(input.projectRoot);
601
613
  if (resolveProjectRoot(directory) !== expectedRoot) {
602
- throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
614
+ throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
603
615
  }
616
+ if (droid && nativeParentId === providerSessionId)
617
+ throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
604
618
  const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
605
619
  const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
606
620
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
@@ -611,43 +625,49 @@ export function handleOpenCodeEvent(context, input) {
611
625
  projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
612
626
  daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
613
627
  eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
614
- eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
628
+ eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
615
629
  });
616
630
  return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
617
631
  }
618
- if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
632
+ if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
619
633
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
620
634
  }
621
635
  const lifecycle = lifecycleFacts(command);
622
636
  if (!lifecycle)
623
637
  return { ok: true, observed: false, reason: "event_not_participating" };
624
638
  if (lifecycle.analysis.kind === "compound") {
625
- throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
639
+ throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
626
640
  }
627
641
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
628
642
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
629
- throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
643
+ throw new AppError("project_mismatch", `${label} lifecycle command project root does not match the controlled workspace`, 1, { expected: expectedRoot, actual: commandProjectRoot });
630
644
  }
631
645
  const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
632
646
  if (bootstrapStageStart)
633
647
  registerProject(context, { root: expectedRoot });
634
648
  const project = requireProjectByRoot(context, expectedRoot);
649
+ if (droid) {
650
+ const existing = context.db.get("SELECT harness, provider_session_id, parent_session_id, daemon_id, match_key, tool_name FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, baseKey]);
651
+ if (existing && (existing.harness !== harness || existing.provider_session_id !== providerSessionId || existing.parent_session_id !== parentSessionId || existing.daemon_id !== daemonId || existing.match_key !== matchKey || existing.tool_name !== toolName)) {
652
+ throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
653
+ }
654
+ }
635
655
  const agentId = stringValue(session.agent_id);
636
656
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
637
657
  ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
638
658
  parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
639
- reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
640
- metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
659
+ reasoning: stringValue(droid ? profile.reasoning : profile.variant) ?? null, mode: stringValue(droid ? profile.mode : profile.agent) ?? null, agent_type: stringValue(droid ? profile.agent_type : profile.agent) ?? null,
660
+ metadata: droid ? { droid_version: stringValue(event.runtime_version) ?? null } : { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
641
661
  }, sessionId) : undefined;
642
662
  const payload = {
643
663
  session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
644
- provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
645
- mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
664
+ provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
665
+ mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
646
666
  };
647
667
  const inserted = recordHookEvent(context, {
648
668
  projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
649
669
  daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
650
- status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
670
+ status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
651
671
  });
652
672
  recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
653
673
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
@@ -17,6 +17,7 @@ import { bindCurrentEngineToRun } from "./engines.js";
17
17
  import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
18
18
  import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
19
19
  import { publicSessionIdentity } from "./session-identity.js";
20
+ import { flowCommand } from "./stage-pause.js";
20
21
  const runSchemaId = "dd-flow/flow-run@3";
21
22
  const runtimeSchemaId = "dd-flow/flow-run@3";
22
23
  const runIdType = "RUN";
@@ -252,7 +253,7 @@ function runContinuation(context, run, index) {
252
253
  const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
253
254
  if (!next)
254
255
  return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
255
- const command = `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
256
+ const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
256
257
  return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command };
257
258
  }
258
259
  return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
@@ -789,6 +790,9 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
789
790
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
790
791
  const run = resolveRun(context, project.id, input.runId);
791
792
  const index = authoritativeIndex(run);
793
+ const priorRepair = index.variables?.["merge.source_repair"];
794
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
795
+ return { run_id: run.id, cycle: priorRepair.cycle };
792
796
  const code = index.stage_runs.find((item) => item.stage === "code");
793
797
  const review = index.stage_runs.find((item) => item.stage === "code-review");
794
798
  const merge = index.stage_runs.find((item) => item.stage === "merge");
@@ -1419,28 +1423,43 @@ function resolveWorkspaceRoot(workspaceRoot) {
1419
1423
  }
1420
1424
  /** Move one completed stage attempt and update only known durable path columns.
1421
1425
  * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1422
- function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1426
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1423
1427
  const currentStageDir = path.join(runRoot, stageDir);
1424
1428
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1425
1429
  return;
1426
1430
  }
1427
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1428
- if (entries.length === 0) {
1431
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1432
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1433
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1434
+ if (entries.length === 0 && !pending) {
1429
1435
  return;
1430
1436
  }
1431
1437
  let attempt = 1;
1432
1438
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1433
1439
  attempt += 1;
1434
1440
  }
1435
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1441
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1442
+ if (!pending) {
1443
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1444
+ const prior = stored ? JSON.parse(stored.index_json) : null;
1445
+ writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
1446
+ }
1436
1447
  ensureDir(archiveDir);
1437
1448
  for (const entry of entries) {
1438
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1449
+ const source = path.join(currentStageDir, entry);
1450
+ const target = path.join(archiveDir, entry);
1451
+ if (fs.existsSync(source)) {
1452
+ if (fs.existsSync(target))
1453
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
1454
+ fs.renameSync(source, target);
1455
+ }
1456
+ else if (!fs.existsSync(target))
1457
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1439
1458
  }
1440
1459
  const oldPrefix = `${currentStageDir}${path.sep}`;
1441
1460
  const newPrefix = `${archiveDir}${path.sep}`;
1442
1461
  const rewrite = (table, column, where, values) => {
1443
- context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, ...values]);
1462
+ context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
1444
1463
  };
1445
1464
  const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1446
1465
  rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
@@ -1448,6 +1467,9 @@ function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDi
1448
1467
  const receiptScope = "project_id = ? AND run_id = ?";
1449
1468
  for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1450
1469
  rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1470
+ // Retain the original stage facts with its immutable artifacts; current
1471
+ // stage_runs stays a projection of the current attempt, not a second ledger.
1472
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1451
1473
  }
1452
1474
  function writeJsonFile(file, value) {
1453
1475
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -19,6 +19,14 @@ export function ingestOpenCodeUsage(context, input) {
19
19
  export function ingestAgyUsage(context, input) {
20
20
  return ingestHarnessUsage(context, input, { harness: "antigravity-cli", label: "Antigravity", idPrefix: "AUSG", sourceKind: "antigravity_cli_session_usage_v1" });
21
21
  }
22
+ /** Droid counters must describe one physical Session; inclusive roots double-count children. */
23
+ export function ingestDroidUsage(context, input) {
24
+ const payload = parseJsonObject(input.stdin || "{}", "Droid usage snapshot");
25
+ if (payload.usage_scope !== "physical_session" || !["complete", "partial"].includes(String(payload.completeness))) {
26
+ throw new Error("Droid usage requires physical_session scope and complete or partial completeness");
27
+ }
28
+ return ingestHarnessUsage(context, input, { harness: "droid-cli", label: "Droid", idPrefix: "DUSG", sourceKind: "droid_session_usage_v1" });
29
+ }
22
30
  function ingestHarnessUsage(context, input, harness) {
23
31
  const payload = parseJsonObject(input.stdin || "{}", `${harness.label} usage snapshot`);
24
32
  const usage = object(payload.usage);
@@ -262,9 +270,9 @@ function toolCallsForLinks(context, links, observedAt) {
262
270
  result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length ? "partial" : "measured";
263
271
  return result;
264
272
  }
265
- function isExternalHarness(harness) { return harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
273
+ function isExternalHarness(harness) { return harness === "droid-cli" || harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
266
274
  function harnessSourceKind(harness) {
267
- return harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
275
+ return harness === "droid-cli" ? "droid_session_usage_v1" : harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
268
276
  }
269
277
  function readHarnessToolCallsWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
270
278
  if (!providerSessionId)
@@ -425,7 +433,7 @@ function hasInclusiveUsageSnapshot(context, projectId, harness, providerSessionI
425
433
  function readHarnessUsageWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
426
434
  if (!providerSessionId)
427
435
  return { status: "not_observable", diagnostic: "provider_session_id_missing" };
428
- const snapshots = context.db.all(`SELECT observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
436
+ const snapshots = context.db.all(`SELECT completeness, observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
429
437
  FROM harness_usage_snapshots
430
438
  WHERE project_id = ? AND harness = ? AND provider_session_id = ? AND observed_at <= ?
431
439
  ORDER BY observed_at, id`, [projectId, harness, providerSessionId, observedAt]);
@@ -434,6 +442,8 @@ function readHarnessUsageWindow(context, harness, projectId, providerSessionId,
434
442
  const before = snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(startedAt)).at(-1);
435
443
  if (!last || !before || last === before)
436
444
  return { status: "not_yet_emitted", diagnostic: `${harness}_usage_window_incomplete` };
445
+ if (harness === "droid-cli" && (last.completeness !== "complete" || before.completeness !== "complete"))
446
+ return { status: "not_observable", diagnostic: "droid-cli_usage_window_partial" };
437
447
  const delta = (value, baseline) => value === null || baseline === null ? null : Math.max(0, value - baseline);
438
448
  const input = delta(last.input_tokens, before.input_tokens);
439
449
  const cacheRead = delta(last.cache_read_input_tokens, before.cache_read_input_tokens);
@@ -173,7 +173,7 @@ export async function finishVnextCodeReview(context, input) {
173
173
  const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
174
174
  const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
175
175
  if (input.retryCheckId) {
176
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks });
176
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
177
177
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
178
178
  }
179
179
  const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { aggregateCheckDeclarations, checkReceipts, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
7
+ import { aggregateCheckDeclarations, checkReceipts, effectiveCheckDeclarations, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
10
10
  import { validateSchema } from "./schema-validation.js";
@@ -158,7 +158,7 @@ export async function finishVnextCode(context, input) {
158
158
  }
159
159
  const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
160
160
  if (input.retryCheckId) {
161
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks });
161
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
162
162
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
163
163
  }
164
164
  const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
@@ -289,6 +289,11 @@ export function addVnextCodeRepair(context, input) {
289
289
  const home = requireHome(run);
290
290
  if (!input.objective.trim())
291
291
  throw new AppError("validation", "Repair objective must not be empty", 2);
292
+ if (input.reviewCycle) {
293
+ const previous = codeWorks(context, project.id, run.id).find((work) => packet(work)?.review_cycle === input.reviewCycle);
294
+ if (previous)
295
+ return { ok: true, run_id: run.id, repair_work_id: previous.work_id, start_command: workStartCommand(context, previous), reused: true };
296
+ }
292
297
  if (!input.checkReceiptId && !input.reviewFindings?.length && !input.semanticUnresolved?.length) {
293
298
  throw new AppError("validation", "Repair needs failed check evidence, a CODE-REVIEW finding, or an unresolved CODE verification", 2);
294
299
  }
@@ -312,10 +317,10 @@ export function addVnextCodeRepair(context, input) {
312
317
  // repair that fixes one failed aggregate check can make a second, already
313
318
  // declared aggregate failure impossible to repair. The immutable source
314
319
  // of repair eligibility is the original CODE graph.
315
- const declaredChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
320
+ const declaredChecks = effectiveCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
316
321
  .map((work) => packet(work))
317
322
  .filter((value) => !value.repair)
318
- .flatMap((value) => value.checks));
323
+ .flatMap((value) => value.checks), ["work", "code", "readiness", "merge"]);
319
324
  const failedCheck = receipt ? declaredChecks.find((check) => check.id === receipt.declaration_id) : undefined;
320
325
  if (receipt && !failedCheck) {
321
326
  throw new AppError("repair_check_declaration_missing", "Repair receipt is not backed by an accepted CODE check declaration", 2, {
@@ -3,8 +3,10 @@ import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
4
  import { resolveProjectRoot } from "../storage/paths.js";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
- import { getFlowRunVariables } from "./runs.js";
7
- import { listWorks } from "./work-registry.js";
6
+ import { getFlowRunVariables, appendFlowRunTimelineEvent } from "./runs.js";
7
+ import { listWorks, failWork, cancelWork } from "./work-registry.js";
8
+ import { storageSessionId } from "./session-identity.js";
9
+ import { reconcileUnfinishedChecks } from "./code-checks.js";
8
10
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
9
11
  export const subagentCapacityKey = "runtime.subagent.available_slots";
10
12
  export function fanoutState(input) {
@@ -70,3 +72,66 @@ export function getVnextFanoutStatus(context, input) {
70
72
  }
71
73
  };
72
74
  }
75
+ /** Explicit controller mutation, separate from read-only fanout status.
76
+ * A native child may be mapped only by its registered identity. Reused native
77
+ * Sessions need a provider execution identity: without one we report ambiguity
78
+ * instead of applying a late event to the newest Work attempt. */
79
+ export async function reconcileVnextFanout(context, input) {
80
+ const status = getVnextFanoutStatus(context, input);
81
+ if (!status.orchestration)
82
+ return { ok: true, issues: [], reconciled: [] };
83
+ const value = input.observations;
84
+ if (!value || typeof value.harness_id !== "string" || !value.harness_id || typeof value.parent_session_id !== "string" || !Array.isArray(value.children)
85
+ || value.children.some(child => !child || typeof child.session_id !== "string" || !["running", "unknown", "completed", "failed", "cancelled"].includes(String(child.status))))
86
+ throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
87
+ if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
88
+ throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
89
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
90
+ const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
91
+ const issues = [], reconciled = [];
92
+ for (const child of value.children) {
93
+ if (child.status === "running" || child.status === "unknown")
94
+ continue;
95
+ const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
96
+ const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? AND s.parent_session_id = ? ORDER BY ws.created_at`, [project.id, input.runId, sessionId, parent]);
97
+ const active = links.filter(link => link.status === "running" && link.work_status === "running" && link.parent_work_id === status.orchestration.parent_work_id);
98
+ if (!active.length) {
99
+ if (!links.length)
100
+ issues.push({ code: "child_launch_association_missing", session_id: child.session_id, outcome: child.status });
101
+ continue;
102
+ }
103
+ if (links.length !== 1 || active.length !== 1) {
104
+ issues.push({ code: "child_execution_ambiguous", session_id: child.session_id, work_ids: active.map(link => link.work_id) });
105
+ continue;
106
+ }
107
+ const link = active[0];
108
+ const pendingCheck = () => context.db.get("SELECT 1 FROM check_receipts WHERE project_id = ? AND run_id = ? AND work_id = ? AND status = 'running' LIMIT 1", [project.id, input.runId, link.work_id]);
109
+ if (pendingCheck()) {
110
+ const run = context.db.get("SELECT workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
111
+ try {
112
+ reconcileUnfinishedChecks(context, { projectId: project.id, runId: input.runId, workspaceRoot: run.workspace_root, runHome: run.run_root, workId: link.work_id });
113
+ }
114
+ catch (error) {
115
+ if (!(error instanceof AppError) || error.code !== "check_in_progress")
116
+ throw error;
117
+ }
118
+ if (pendingCheck()) {
119
+ issues.push({ code: "child_check_in_progress", work_id: link.work_id });
120
+ continue;
121
+ }
122
+ }
123
+ if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [link.work_id])) {
124
+ issues.push({ code: "child_descendants_unsettled", work_id: link.work_id });
125
+ continue;
126
+ }
127
+ if (child.status === "completed") {
128
+ issues.push({ code: "execution_ended_without_work_result", work_id: link.work_id, session_id: child.session_id });
129
+ continue;
130
+ }
131
+ const reason = JSON.stringify({ source: "native_child_observation", harness_id: value.harness_id, session_id: child.session_id, work_session_id: link.id, outcome: child.status });
132
+ const result = await (child.status === "failed" ? failWork : cancelWork)(context, link.work_id, reason);
133
+ reconciled.push({ work_id: link.work_id, work_session_id: link.id, outcome: child.status, result });
134
+ }
135
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
136
+ return { ok: true, issues, reconciled };
137
+ }
@@ -227,7 +227,7 @@ export async function finishVnextMerge(context, input) {
227
227
  }
228
228
  const checks = effectiveMergeChecks(run, request);
229
229
  if (input.retryCheckId) {
230
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks });
230
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
231
231
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
232
232
  }
233
233
  const unchanged = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: request.target_workspace, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
@@ -314,6 +314,11 @@ export async function repairVnextMerge(context, input) {
314
314
  const projectRoot = resolveProjectRoot(input.projectRoot);
315
315
  const project = requireProjectByRoot(context, projectRoot);
316
316
  const request = requireRequest(context, input.requestId);
317
+ if (request.project_id === project.id && request.status === "superseded" && errorCode(request) === "merge_source_repair") {
318
+ const saved = JSON.parse(request.last_error_json);
319
+ releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
320
+ return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: { repair_work_id: saved.repair_work_id }, next: { kind: "start_stage", stage: "code", command: `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } };
321
+ }
317
322
  if (request.project_id !== project.id || request.status !== "action_required" || errorCode(request) !== "merge_gate_failed") {
318
323
  throw new AppError("invalid_merge_repair_state", "MERGE source repair requires an action-required failed integration gate", 2, { merge_request_id: input.requestId, status: request.status, code: errorCode(request) });
319
324
  }
@@ -326,16 +331,18 @@ export async function repairVnextMerge(context, input) {
326
331
  throw new AppError("runtime_missing", "MERGE failure has no durable failed receipt", 1, { merge_request_id: request.merge_request_id });
327
332
  // Settle the child while its canonical result path still exists; the next
328
333
  // step archives the entire MERGE attempt as immutable evidence.
329
- await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
334
+ const executor = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
335
+ if (executor?.status !== "failed")
336
+ await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
330
337
  const prepared = prepareVnextMergeSourceRepairAttempt(context, { projectRoot, runId: request.run_id, mergeRequestId: request.merge_request_id });
331
- const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
338
+ const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, reviewCycle: `MRR-${request.merge_request_id}`, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
332
339
  const now = context.now();
333
340
  context.db.run("UPDATE merge_requests SET status = 'superseded', checkpoint = 'source_repair_required', last_error_json = ?, completed_at = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_source_repair", replacement_pending: true, failed_receipt_ids: failed, repair_work_id: repair.repair_work_id }), now, now, request.merge_request_id]);
334
341
  releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
335
342
  appendFlowRunTimelineEvent(context, project.id, request.run_id, { type: "merge_source_repair_created", merge_request_id: request.merge_request_id, repair_work_id: repair.repair_work_id, cycle: prepared.cycle, failed_receipt_ids: failed });
336
343
  return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } };
337
344
  }
338
- function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`; const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, inspect only the returned receipt/logs, then run this exact source-repair command: ${repair}. It restores the integration target to baseline and creates a CODE → independent CODE-REVIEW → replacement MRG cycle. Do not repair the integration target and do not retry this MRG.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
345
+ function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`; const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
339
346
  function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
340
347
  function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
341
348
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
@@ -400,7 +407,12 @@ function releaseMergeLane(context, projectRoot, request, reason) {
400
407
  try {
401
408
  releaseLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, reason });
402
409
  }
403
- catch { /* An expired lease cannot invalidate an already completed MERGE. */ }
410
+ catch (error) {
411
+ // An absent/replaced lease is already no longer ours. Storage and other
412
+ // failures must remain visible so a repeated operation can release it.
413
+ if (!(error instanceof AppError) || !["lane_lock_not_active", "lane_lock_owned_by_other_worker"].includes(error.code))
414
+ throw error;
415
+ }
404
416
  }
405
417
  function queueAhead(context, request) { return context.db.get("SELECT COUNT(*) AS count FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?))", [request.project_id, request.created_at, request.created_at, request.merge_request_id])?.count ?? 0; }
406
418
  function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.16",
3
+ "version": "0.9.0-beta.18",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {