@deksden-com/dd-flow-cli 0.9.0-beta.49 → 0.9.0-beta.51

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,19 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.51
4
+
5
+ ### Patch Changes
6
+
7
+ - Settle a check receipt when its confirmed process identity has exited, even if an older registration has no parent-owner PID.
8
+
9
+ ## 0.9.0-beta.50
10
+
11
+ ### Patch Changes
12
+
13
+ - 422413d: Keep PLAN, PLAN-REVIEW, CODE and CODE-REVIEW settlement atomic across Work, Session and RUN state, including successor Work registration. Recover accepted CODE stage completion without repeating checks. Enforce coordinator ownership on repeated CODE start and exact reviewer coverage while preserving protocol-local aspect scopes.
14
+ - 422413d: Reuse completed check evidence consistently across CODE, CODE-REVIEW and MERGE,
15
+ with validated inputs, artifacts, retry handling and concurrent execution guards.
16
+
3
17
  ## 0.9.0-beta.49
4
18
 
5
19
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.49",
4
- "cli_commit": "111629d923b54e718a4bb1466416f5089c59bed2",
5
- "built_at": "2026-09-11T08:14:51.272Z",
3
+ "cli_version": "0.9.0-beta.51",
4
+ "cli_commit": "66660a1dc362cce527bfc33bd2e8124aff6a33c1",
5
+ "built_at": "2026-09-11T23:08:17.146Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.0",
8
8
  "commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
@@ -799,8 +799,11 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
799
799
  const workId = requiredPosition(parsed, 0, "work-id");
800
800
  const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work finish");
801
801
  assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_finish", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
802
+ const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = optionalOption(parsed, "reason");
803
+ if (Boolean(retryReceiptId) !== Boolean(retryReason))
804
+ throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
802
805
  const result = resultFile ? fs.readFileSync(path.resolve(resultFile), "utf8") : await readStdin(io.stdin);
803
- return await finishWork(context, workId, result, commandProgress(io, output));
806
+ return await finishWork(context, workId, result, commandProgress(io, output), retryReceiptId ? { receiptId: retryReceiptId, reason: retryReason } : undefined);
804
807
  }
805
808
  if (family === "work" && command === "fail") {
806
809
  const workId = requiredPosition(parsed, 0, "work-id");
@@ -16,7 +16,7 @@
16
16
  "spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"$ref": "#/$defs/stringList"}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
17
17
  "obligation": {"type": "object", "additionalProperties": false, "required": ["id", "statement"], "properties": {"id": {"type": "string", "minLength": 1}, "statement": {"type": "string", "minLength": 1}}},
18
18
  "acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"$ref": "#/$defs/stringList"}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"type": "string", "minLength": 1}}},
19
- "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "minLength": 1}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
19
+ "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "minLength": 1}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
20
20
  "documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason", "baseline_sha256"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}, "baseline_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}}},
21
21
  "reviewFinding": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "priority", "problem", "impact", "required_outcome", "evidence_refs", "obligation_refs", "decision_reason", "check_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "priority": {"enum": ["p0", "p1", "p2", "p3"]}, "problem": {"type": "string", "minLength": 1}, "impact": {"type": "string", "minLength": 1}, "required_outcome": {"type": "string", "minLength": 1}, "evidence_refs": {"$ref": "#/$defs/strings"}, "obligation_refs": {"$ref": "#/$defs/strings"}, "decision_reason": {"type": "string", "minLength": 1}, "check_refs": {"$ref": "#/$defs/strings"}}},
22
22
  "repair": {"type": "object", "additionalProperties": false, "required": ["origin_work_ids", "verification_check_refs"], "anyOf": [{"required": ["check_receipt_id", "failure_receipt_path"]}, {"required": ["review_findings"]}, {"required": ["semantic_unresolved", "verification_path"]}], "properties": {"origin_work_ids": {"$ref": "#/$defs/strings"}, "check_receipt_id": {"type": "string", "minLength": 1}, "failure_receipt_path": {"type": "string", "minLength": 1}, "review_findings": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/reviewFinding"}}, "semantic_unresolved": {"$ref": "#/$defs/strings"}, "verification_path": {"type": "string", "minLength": 1}, "verification_check_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/check"}}}},
@@ -29,7 +29,7 @@
29
29
  "documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}}},
30
30
  "spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"type": "array", "items": {"type": "string", "minLength": 1}}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
31
31
  "execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "planned_write_areas", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources. This is not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas. A worker may inspect other project-local sources when necessary."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional file or directory hints used only to coordinate concurrent Works. They never grant or deny write permission."}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Semantic contradictions or external blockers that require the worker to stop."}}},
32
- "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
32
+ "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
33
33
  "verification": {"type": "object", "additionalProperties": false, "required": ["check_refs"], "properties": {"check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}}},
34
34
  "item": {"type": "object", "additionalProperties": false, "required": ["id", "title", "summary", "details", "depends_on", "requirement_refs", "semantic_spine", "execution_context", "verification"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]+$"}, "title": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "details": {"type": "string", "minLength": 1}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "requirement_refs": {"$ref": "#/$defs/strings"}, "semantic_spine": {"$ref": "#/$defs/spine"}, "execution_context": {"$ref": "#/$defs/execution"}, "verification": {"$ref": "#/$defs/verification"}}},
35
35
  "acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "plan_item_ids", "changed_surfaces", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "plan_item_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "changed_surfaces": {"$ref": "#/$defs/strings"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"type": "array", "items": {"type": "string", "minLength": 1}}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}}}
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { assertRunMutationAllowed } from "./run-recovery.js";
6
6
  import { AppError } from "../shared/errors.js";
7
- import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
7
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
8
8
  import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
9
9
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
10
10
  // Receipt reservation precedes child spawn so an interrupted CLI leaves an
@@ -12,6 +12,7 @@ const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "co
12
12
  // for a dead check and overwrite its outcome.
13
13
  const receiptStartingGraceMs = 30_000;
14
14
  export function codeExecutionEnvironment(workspaceRoot) { const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12); return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix }; }
15
+ export function workspaceOperationId(workspaceRoot) { return `workspace:${crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex")}`; }
15
16
  export function readCodeCheckProfile(workspaceRoot) {
16
17
  const file = path.join(workspaceRoot, profileRelativePath);
17
18
  if (!fs.existsSync(file))
@@ -20,8 +21,8 @@ export function readCodeCheckProfile(workspaceRoot) {
20
21
  const value = JSON.parse(bytes.toString("utf8"));
21
22
  if (value.schema_id !== "dd-flow/code-check-profile@6")
22
23
  throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@6" });
23
- if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias))
24
- throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate or ports_by_alias are invalid", 2, { file });
24
+ if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias) || !isInputAliasRecord(workspaceRoot, value.inputs_by_alias))
25
+ throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate, ports_by_alias or inputs_by_alias are invalid", 2, { file });
25
26
  for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
26
27
  for (const alias of aliases ?? [])
27
28
  if (!value.aliases[alias])
@@ -29,6 +30,9 @@ export function readCodeCheckProfile(workspaceRoot) {
29
30
  for (const alias of Object.keys(value.ports_by_alias ?? {}))
30
31
  if (!value.aliases[alias])
31
32
  throw new AppError("invalid_code_check_profile", "A port declaration references an unknown alias", 2, { file, alias });
33
+ for (const alias of Object.keys(value.inputs_by_alias ?? {}))
34
+ if (!value.aliases[alias])
35
+ throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
32
36
  return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
33
37
  }
34
38
  export function validateCodeCheckCommands(workspaceRoot, commands) {
@@ -52,7 +56,7 @@ export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
52
56
  }
53
57
  export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
54
58
  const { profile } = readCodeCheckProfile(workspaceRoot);
55
- return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available", ...(profile?.ports_by_alias?.[alias] ? { ports: profile.ports_by_alias[alias] } : {}) }; });
59
+ return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available", ...(profile?.ports_by_alias?.[alias] ? { ports: profile.ports_by_alias[alias] } : {}), ...(profile?.inputs_by_alias?.[alias] ? { inputs: profile.inputs_by_alias[alias] } : {}) }; });
56
60
  }
57
61
  export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
58
62
  export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
@@ -68,6 +72,7 @@ export function validateCheckDeclaration(workspaceRoot, check) {
68
72
  if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
69
73
  throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
70
74
  validateCodeCheckCommands(workspaceRoot, [check.command]);
75
+ validateCheckInputs(workspaceRoot, check.inputs ?? []);
71
76
  if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
72
77
  throw new AppError("check_definition_drift", "The accepted check alias definition differs from the current project profile", 2, { check_id: check.id, alias: check.command, expected: check.definition, actual: profile?.aliases[check.command] ?? null });
73
78
  if (check.availability !== "planned")
@@ -81,21 +86,52 @@ export function validateCheckDeclaration(workspaceRoot, check) {
81
86
  }
82
87
  export async function runCodeChecks(context, input) {
83
88
  reconcileUnfinishedChecks(context, input);
89
+ reconcileWorkspaceOwnership(context, input.workspaceRoot);
90
+ const gate = acquireWorkspaceGate(context, input);
91
+ try {
92
+ return await runCodeChecksOwned(context, input, gate.id);
93
+ }
94
+ finally {
95
+ finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_settled" });
96
+ }
97
+ }
98
+ async function runCodeChecksOwned(context, input, workspaceGateId) {
84
99
  const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
85
100
  for (const check of input.checks)
86
101
  validateCheckDeclaration(input.workspaceRoot, check);
87
102
  const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
88
- const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; return check.ports || !ports ? check : { ...check, ports }; });
103
+ if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
104
+ throw new AppError("check_profile_drift", "The frozen check profile differs from the workspace profile", 1, { expected_profile_hash: input.expectedProfileHash, actual_profile_hash: profileHash });
105
+ const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; const inputs = profile?.inputs_by_alias?.[check.command]; return { ...check, ...(check.ports || !ports ? {} : { ports }), ...(check.inputs || !inputs ? {} : { inputs }) }; });
89
106
  const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
90
- const groups = deduplicate(checks, resolved);
91
- const receipts = [];
107
+ assertConsistentDeclarations(checks, resolved);
108
+ const groups = deduplicate(checks, resolved), receipts = [];
109
+ const retryIds = [...new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])])];
110
+ const retries = new Map();
111
+ for (const receiptId of retryIds) {
112
+ const predecessor = validateFailedCheckRetry(context, { projectId: input.projectId, runId: input.runId, receiptId, declarations: checks, reason: input.retryReason });
113
+ const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
114
+ const group = groups.find(candidate => checkInputHash(input.workspaceRoot, candidate, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome])) === predecessor.input_hash);
115
+ if (!group || retries.has(group))
116
+ throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
117
+ retries.set(group, receiptId);
118
+ }
92
119
  for (let index = 0; index < groups.length; index += 1) {
93
120
  const group = groups[index];
94
121
  const before = workspaceState(input.workspaceRoot, [input.runHome]);
95
122
  const startedAt = context.now();
96
- const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts, ports: [...group.ports].sort() })).digest("hex");
123
+ const inputFingerprint = workspaceInputFingerprint(input.workspaceRoot, before.files, group.inputs, [input.runHome]);
124
+ const inputHash = checkInputHash(input.workspaceRoot, group, profileHash, inputFingerprint);
97
125
  const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
98
- const reserved = reserveReceipt(context, { ...input, generation, group, before, profileHash, inputHash, epoch, startedAt });
126
+ const reservationInput = { ...input, retryReceiptIds: retries.has(group) ? [retries.get(group)] : [], generation, group, before, profileHash, inputHash, epoch, startedAt };
127
+ delete reservationInput.retryReceiptId;
128
+ const decision = reserveOrReuseReceipt(context, reservationInput);
129
+ if (decision.receipt) {
130
+ receipts.push(bindReceipt(decision.receipt, group));
131
+ input.progress?.(`check ${index + 1}/${groups.length} ${decision.receipt.status === "passed" ? "reused" : "requires repair"}: ${group.command}`);
132
+ continue;
133
+ }
134
+ const reserved = decision.reserved;
99
135
  const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
100
136
  let resources = {};
101
137
  let result = { exitCode: null, error: "check did not start", aborted: true };
@@ -103,7 +139,7 @@ export async function runCodeChecks(context, input) {
103
139
  fs.mkdirSync(evidenceDir, { recursive: true });
104
140
  writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
105
141
  const budget = runtimeBudgetForRun(context, input.projectId, input.runId);
106
- const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command, role: "check", budget } });
142
+ const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, operationId: workspaceOperationId(input.workspaceRoot), projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command, role: "check", workspace_root: input.workspaceRoot, workspace_gate_id: workspaceGateId, budget } });
107
143
  const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
108
144
  resources = allocation.ports;
109
145
  try {
@@ -158,6 +194,16 @@ export async function runCodeChecks(context, input) {
158
194
  receipts.push(receipt);
159
195
  input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
160
196
  }
197
+ if (receipts.every(receipt => receipt.status === "passed")) {
198
+ const current = workspaceState(input.workspaceRoot, [input.runHome]), { hash: currentProfileHash } = readCodeCheckProfile(input.workspaceRoot);
199
+ for (const receipt of receipts) {
200
+ const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
201
+ const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
202
+ if (!group || !checkReceiptEvidenceValid(receipt) || receipt.input_hash !== currentHash)
203
+ throw new AppError("check_evidence_stale", "Check evidence changed before the gate could be accepted", 1, { check_receipt_id: receipt.id });
204
+ }
205
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
206
+ }
161
207
  return receipts;
162
208
  }
163
209
  /**
@@ -184,7 +230,11 @@ export function reconcileUnfinishedChecks(context, input) {
184
230
  // A stale lease after host sleep is not permission to duplicate a living
185
231
  // check. Its durable completion marker or explicit reconciliation decides
186
232
  // the outcome before another invocation may start.
187
- if (process && ["starting", "running", "stopping"].includes(process.state) && (processTreeIsAlive(process) || (process.state === "starting" && (managedOwnerIsAlive(process) !== false || Date.parse(process.lease_expires_at) > Date.parse(context.now()))))) {
233
+ // A confirmed PID plus its birth time is sufficient to prove that this
234
+ // exact check writer is gone. Do not retain a dead receipt merely because
235
+ // older registrations lack the optional parent-owner identity.
236
+ const processProvenDead = Boolean(process?.pid && process.pid_started_at && !processTreeIsAlive(process));
237
+ if (process && !processProvenDead && (processTreeIsAlive(process) || (["starting", "running", "stopping", "orphaned"].includes(process.state) && managedOwnerIsAlive(process) !== false))) {
188
238
  throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
189
239
  }
190
240
  if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
@@ -207,26 +257,60 @@ export function reconcileUnfinishedChecks(context, input) {
207
257
  }
208
258
  return reconciled;
209
259
  }
210
- function reserveReceipt(context, input) {
260
+ function reserveOrReuseReceipt(context, input) {
211
261
  context.db.exec("BEGIN IMMEDIATE");
212
262
  try {
213
263
  assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
214
- const rows = context.db.all("SELECT id FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId]);
215
- const ordinal = rows.reduce((max, row) => Math.max(max, Number(/\/RCP-(\d+)$/.exec(row.id)?.[1] ?? 0)), 0) + 1;
216
- const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
217
- const id = `${input.workId ?? input.runId}/${localId}`;
264
+ const prior = latestApplicableReceipt(context, input);
265
+ if (prior?.status === "running")
266
+ throw new AppError("check_in_progress", "A matching check invocation is still running", 1, { check_receipt_id: prior.id });
267
+ const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
268
+ if (retryIds.size > 1)
269
+ throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
270
+ const retryId = [...retryIds][0];
271
+ if (retryId) {
272
+ if (!input.retryReason?.trim())
273
+ throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
274
+ const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
275
+ const predecessor = receipts.find(receipt => receipt.id === retryId);
276
+ if (!predecessor || !["failed", "aborted"].includes(predecessor.status) || predecessor.input_hash !== input.inputHash || predecessor.profile_hash !== input.profileHash || predecessor.command !== input.group.command || predecessor.gate !== input.group.gate)
277
+ throw new AppError("retry_check_invalid", "Retry predecessor does not match this exact check contract", 2, { check_receipt_id: retryId });
278
+ const successor = receipts.find(receipt => receipt.retry_of_receipt_id === retryId && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash);
279
+ if (prior?.id !== (successor?.id ?? predecessor.id))
280
+ throw new AppError("retry_check_invalid", "This retry was superseded by a newer check outcome; inspect the latest receipt", 2, { check_receipt_id: retryId, latest_check_receipt_id: prior?.id ?? null, latest_status: prior?.status ?? null });
281
+ if (successor?.status === "running")
282
+ throw new AppError("check_in_progress", "The requested retry is already running", 1, { check_receipt_id: successor.id, retry_of_receipt_id: retryId });
283
+ if (successor) {
284
+ if (successor.status === "passed" && checkReceiptEvidenceValid(successor)) {
285
+ context.db.exec("COMMIT");
286
+ return { receipt: successor };
287
+ }
288
+ throw new AppError("retry_check_invalid", "The requested retry already has a terminal outcome; choose its failed receipt for another retry", 2, { check_receipt_id: successor.id, retry_of_receipt_id: retryId, status: successor.status });
289
+ }
290
+ }
291
+ if (prior && !retryIds.has(prior.id) && (prior.status !== "passed" || (input.group.reusable && checkReceiptEvidenceValid(prior)))) {
292
+ context.db.exec("COMMIT");
293
+ return { receipt: prior };
294
+ }
295
+ let ordinal = (context.db.get("SELECT COALESCE(MAX(reservation_sequence), 0) + 1 AS sequence FROM check_receipts WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId])?.sequence ?? 1);
296
+ let localId = `RCP-${String(ordinal).padStart(3, "0")}`, id = `${input.workId ?? input.runId}/${localId}`;
297
+ while (context.db.get("SELECT 1 FROM check_receipts WHERE id = ?", [id])) {
298
+ ordinal += 1;
299
+ localId = `RCP-${String(ordinal).padStart(3, "0")}`;
300
+ id = `${input.workId ?? input.runId}/${localId}`;
301
+ }
218
302
  const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
219
- const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
220
- context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef)), input.group.gate, input.group.command, input.inputHash, input.epoch, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
303
+ const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
304
+ context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, reservation_sequence, retry_of_receipt_id, retry_reason, 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef).sort()), input.group.gate, input.group.command, input.inputHash, input.epoch, ordinal, reserved.retryOf ?? null, reserved.retryReason ?? null, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
221
305
  context.db.exec("COMMIT");
222
- return reserved;
306
+ return { reserved };
223
307
  }
224
308
  catch (error) {
225
309
  context.db.exec("ROLLBACK");
226
310
  throw error;
227
311
  }
228
312
  }
229
- function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, status: input.status, exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
313
+ function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
230
314
  function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
231
315
  function readReceipt(file) { try {
232
316
  return JSON.parse(fs.readFileSync(file, "utf8"));
@@ -242,19 +326,13 @@ function readCompletion(file) { try {
242
326
  catch {
243
327
  return null;
244
328
  } }
245
- export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
246
- for (const ref of receipt.check_refs)
247
- latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current && receipt.id !== input.retryReceiptId); }
248
329
  /** Validate an explicit retry; the caller still executes a fresh real gate and retains a new receipt. */
249
330
  export function validateFailedCheckRetry(context, input) {
250
331
  if (!input.reason?.trim())
251
332
  throw new AppError("retry_check_invalid", "--retry-check requires --reason describing the restored environment", 2);
252
333
  const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
253
- if (!receipt || receipt.scope !== "aggregate" || !["failed", "aborted"].includes(receipt.status))
254
- 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 });
255
- const relative = path.relative(input.artifactRoot, receipt.receipt_path);
256
- if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`) || /^try-\d+([/\\]|$)/.test(relative))
257
- 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 });
334
+ if (!receipt || !["failed", "aborted"].includes(receipt.status))
335
+ throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted receipt in this RUN", 2, { check_receipt_id: input.receiptId });
258
336
  const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
259
337
  if (!receipt.check_refs.some((ref) => declared.has(ref)))
260
338
  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 });
@@ -263,7 +341,7 @@ export function validateFailedCheckRetry(context, input) {
263
341
  export function checkReceipts(context, input) {
264
342
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
265
343
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
266
- 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)) }));
344
+ return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, retry_of_receipt_id, retry_reason, 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 reservation_sequence, rowid`, 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)) }));
267
345
  }
268
346
  /** File bytes remain immutable; only the reader projects their current location. */
269
347
  function resolvedReceiptArtifacts(receiptPath, artifacts) {
@@ -278,15 +356,15 @@ function resolvedReceiptArtifacts(receiptPath, artifacts) {
278
356
  const relative = path.relative(oldRoot, file);
279
357
  return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
280
358
  };
281
- return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
359
+ return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
282
360
  }
283
361
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
284
362
  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)
285
363
  return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
286
- 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)
364
+ function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const inputs = normalizedInputs(check.inputs ?? []); const reusable = check.reuse === "deterministic"; const key = JSON.stringify([check.run_at, commands[index], artifacts, ports, inputs, reusable]); const current = groups.get(key); if (current)
287
365
  current.declarations.push(check);
288
366
  else
289
- groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
367
+ groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports, inputs, reusable }); }); return [...groups.values()].map(group => ({ ...group, declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) })); }
290
368
  function workspaceState(root, ignoredRoots = []) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const ignored = ignoredRoots.map((entry) => path.resolve(entry)); const listing = (git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root)).filter((relative) => { const file = path.resolve(root, relative); return !ignored.some((ignoredRoot) => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)); }); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
291
369
  const file = path.join(root, relative);
292
370
  const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
@@ -294,8 +372,129 @@ function workspaceState(root, ignoredRoots = []) { const git = spawnSync("git",
294
372
  files.set(relative, digest);
295
373
  hash.update(relative).update("\0").update(digest).update("\0");
296
374
  } return { fingerprint: hash.digest("hex"), files }; }
375
+ function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
376
+ const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
377
+ for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
378
+ selected.set(file, digest);
379
+ const hash = crypto.createHash("sha256");
380
+ for (const [file, digest] of [...selected].sort(([left], [right]) => left.localeCompare(right)))
381
+ hash.update(file).update("\0").update(digest).update("\0");
382
+ return hash.digest("hex");
383
+ }
297
384
  function changedPaths(before, after) { return [...new Set([...before.keys(), ...after.keys()])].filter((key) => before.get(key) !== after.get(key)).sort(); }
298
385
  function checkRef(check) { return check.canonical_ref ?? check.id; }
386
+ function assertConsistentDeclarations(checks, commands) {
387
+ const seen = new Map();
388
+ for (const [index, check] of checks.entries()) {
389
+ const contract = JSON.stringify([check.run_at, commands[index], [...(check.required_artifacts ?? [])].sort(), [...(check.ports ?? [])].sort(), normalizedInputs(check.inputs ?? []), check.reuse ?? null]);
390
+ const ref = checkRef(check), prior = seen.get(ref);
391
+ if (prior && prior !== contract)
392
+ throw new AppError("check_declaration_conflict", "The same check reference has conflicting execution definitions", 2, { check_ref: ref });
393
+ seen.set(ref, contract);
394
+ }
395
+ }
396
+ function environmentFingerprint(workspaceRoot) {
397
+ const environment = codeExecutionEnvironment(workspaceRoot);
398
+ return crypto.createHash("sha256").update(JSON.stringify(Object.entries(environment).filter(([, value]) => value !== undefined).sort(([left], [right]) => left.localeCompare(right)))).digest("hex");
399
+ }
400
+ function acquireWorkspaceGate(context, input) {
401
+ const operationId = workspaceOperationId(input.workspaceRoot);
402
+ const active = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
403
+ if (active)
404
+ throw new AppError("check_in_progress", "The workspace is owned by another bootstrap or check; observe it before starting a new check", 1, { process_id: active.id, operation_id: operationId });
405
+ return registerManagedProcess(context, { kind: "workspace-gate", ownerPid: process.pid, ownerId: `workspace-gate:${crypto.randomUUID()}`, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, metadata: { workspace_root: input.workspaceRoot, role: "check_gate" } });
406
+ }
407
+ export function reconcileWorkspaceOwnership(context, workspaceRoot) {
408
+ const operationId = workspaceOperationId(workspaceRoot);
409
+ const active = managedProcessStatus(context).filter(item => item.operation_id === operationId && ["starting", "running", "stopping", "orphaned"].includes(item.state));
410
+ if (active.some(item => processTreeIsAlive(item) || managedOwnerIsAlive(item) !== false))
411
+ return;
412
+ // Registry IDs are host-wide, whereas receipts belong to one runtime DB.
413
+ // An unknown/foreign home must not be settled against this client's rows.
414
+ if (active.some(item => managedProcessRuntimeHome(item, context) !== context.ddFlowHome))
415
+ return;
416
+ for (const child of active.filter(item => item.kind !== "workspace-gate")) {
417
+ if (child.kind !== "check" || !child.project_id || !child.run_id || !child.check_id)
418
+ return;
419
+ const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [child.project_id, child.run_id]);
420
+ if (!run?.run_root || !context.db.get("SELECT 1 FROM check_receipts WHERE id = ? AND project_id = ? AND run_id = ?", [child.check_id, child.project_id, child.run_id]))
421
+ return;
422
+ reconcileUnfinishedChecks(context, { projectId: child.project_id, runId: child.run_id, workspaceRoot, runHome: run.run_root, receiptId: child.check_id });
423
+ // A crash may occur after receipt settlement but before registry settlement.
424
+ const current = managedProcessStatus(context).find(item => item.id === child.id);
425
+ if (!current || current.lease_token !== child.lease_token)
426
+ return;
427
+ if (!["stopped", "failed"].includes(current.state) && !finishManagedProcess(context, { id: child.id, leaseToken: child.lease_token, state: "stopped", reason: "workspace_check_owner_reconciled" }))
428
+ return;
429
+ }
430
+ for (const gate of active.filter(item => item.kind === "workspace-gate"))
431
+ finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_owner_reconciled" });
432
+ }
433
+ function normalizedInputs(inputs) { for (const input of inputs)
434
+ validateCheckInputSyntax(input); return [...new Set(inputs.map((input) => { const normalized = path.posix.normalize(input.replaceAll("\\", "/")).replace(/^\.\/$/, ""); return normalized === "" || normalized === "." ? "." : normalized.replace(/\/$/, ""); }))].sort(); }
435
+ function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); for (const input of normalizedInputs(inputs)) {
436
+ if (input === ".git" || input.startsWith(".git/"))
437
+ throw new AppError("invalid_check_input", "Check inputs cannot include Git internals", 2, { input });
438
+ const candidate = path.resolve(workspaceRoot, input);
439
+ if (candidate !== workspaceRoot && !candidate.startsWith(`${workspaceRoot}${path.sep}`))
440
+ throw new AppError("invalid_check_input", "Check inputs must stay within the workspace", 2, { input });
441
+ if (fs.existsSync(candidate))
442
+ validateInputTree(root, candidate, input, new Set());
443
+ else
444
+ validateMissingInputParent(root, candidate, input);
445
+ } }
446
+ function validateCheckInputSyntax(input) { if (typeof input !== "string" || !input.trim() || path.isAbsolute(input) || input.split(/[\\/]/).includes(".."))
447
+ throw new AppError("invalid_check_input", "Check inputs must be non-empty relative workspace paths", 2, { input }); }
448
+ function validateMissingInputParent(root, candidate, input) { let parent = candidate; while (!fs.existsSync(parent))
449
+ parent = path.dirname(parent); const resolved = fs.realpathSync.native(parent); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
450
+ throw new AppError("invalid_check_input", "Check input parent symlink escapes the workspace", 2, { input }); }
451
+ function validateInputTree(root, candidate, input, seen) {
452
+ const resolved = fs.realpathSync.native(candidate);
453
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
454
+ throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input });
455
+ if (seen.has(resolved))
456
+ throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input });
457
+ seen.add(resolved);
458
+ const stat = fs.statSync(candidate);
459
+ if (stat.isDirectory())
460
+ for (const entry of fs.readdirSync(candidate))
461
+ validateInputTree(root, path.join(candidate, entry), input, seen);
462
+ seen.delete(resolved);
463
+ }
464
+ function explicitInputFiles(root, inputs, ignoredRoots) {
465
+ const rootReal = fs.realpathSync.native(root), ignored = ignoredRoots.map(entry => path.resolve(entry)), result = new Map();
466
+ const visit = (file, relative, seen) => {
467
+ if (ignored.some(entry => file === entry || file.startsWith(`${entry}${path.sep}`)))
468
+ return;
469
+ if (!fs.existsSync(file)) {
470
+ result.set(relative, "<missing>");
471
+ return;
472
+ }
473
+ const resolved = fs.realpathSync.native(file);
474
+ if (resolved !== rootReal && !resolved.startsWith(`${rootReal}${path.sep}`))
475
+ throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input: relative });
476
+ if (seen.has(resolved))
477
+ throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input: relative });
478
+ const stat = fs.statSync(file);
479
+ if (stat.isDirectory()) {
480
+ seen.add(resolved);
481
+ for (const entry of fs.readdirSync(file).sort())
482
+ visit(path.join(file, entry), path.posix.join(relative, entry), seen);
483
+ seen.delete(resolved);
484
+ }
485
+ else if (stat.isFile())
486
+ result.set(relative, crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
487
+ };
488
+ for (const input of inputs)
489
+ visit(path.resolve(root, input), input, new Set());
490
+ return [...result.entries()];
491
+ }
492
+ function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
493
+ function bindReceipt(receipt, group) { return { ...receipt, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
494
+ function latestApplicableReceipt(context, input) { const candidates = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash); return candidates.at(-1) ?? null; }
495
+ export function checkReceiptEvidenceValid(receipt) { if (receipt.status !== "passed" || receipt.exit_code !== 0 || receipt.before_fingerprint !== receipt.after_fingerprint || !fs.existsSync(receipt.completion_path) || readCompletion(receipt.completion_path)?.exit_code !== 0)
496
+ return false; const stored = readReceipt(receipt.receipt_path); if (!stored || stored.status !== "passed" || stored.input_hash !== receipt.input_hash || stored.profile_hash !== receipt.profile_hash)
497
+ return false; const artifacts = collectCheckArtifacts(path.join(path.dirname(receipt.receipt_path), "artifacts"), stored.required_artifacts ?? []); return artifacts.complete && JSON.stringify(artifacts.items) === JSON.stringify(stored.artifacts ?? []); }
299
498
  function parseArray(value, fallback) { try {
300
499
  const parsed = JSON.parse(value);
301
500
  return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback;
@@ -306,6 +505,12 @@ catch {
306
505
  function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
307
506
  function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
308
507
  function isPortAliasRecord(value) { return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, ports]) => alias.startsWith("@check/") && Array.isArray(ports) && ports.length > 0 && new Set(ports).size === ports.length && ports.every((port) => typeof port === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(port)))); }
508
+ function isInputAliasRecord(workspaceRoot, value) { try {
509
+ return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, inputs]) => alias.startsWith("@check/") && Array.isArray(inputs) && (validateCheckInputs(workspaceRoot, inputs), true)));
510
+ }
511
+ catch {
512
+ return false;
513
+ } }
309
514
  function portEnvironment(ports) { return Object.fromEntries(Object.entries(ports).map(([name, value]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(value)])); }
310
515
  function readReceiptResources(file) { try {
311
516
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -583,6 +583,22 @@ export function attachFlowRunStage(context, input) {
583
583
  const updatedRun = requireRunById(context, project.id, run.id);
584
584
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
585
585
  }
586
+ /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
587
+ export function withStageSettlement(context, operation) {
588
+ context.db.exec("SAVEPOINT stage_settlement");
589
+ try {
590
+ const result = operation();
591
+ if (result instanceof Promise)
592
+ throw new Error("Stage settlement must be synchronous");
593
+ context.db.exec("RELEASE stage_settlement");
594
+ return result;
595
+ }
596
+ catch (error) {
597
+ context.db.exec("ROLLBACK TO stage_settlement");
598
+ context.db.exec("RELEASE stage_settlement");
599
+ throw error;
600
+ }
601
+ }
586
602
  export function completeFlowRunStage(context, input) {
587
603
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
588
604
  const run = resolveRun(context, project.id, input.runId);
@@ -165,7 +165,7 @@ export function recalculateRunUsage(context, input) {
165
165
  const usageFinal = final && unsettledSessions.length === 0;
166
166
  const persistRunProjection = !input.sessionId && !input.stage;
167
167
  if (persistRunProjection) {
168
- context.db.exec("BEGIN IMMEDIATE");
168
+ context.db.exec("SAVEPOINT run_usage");
169
169
  try {
170
170
  context.db.run("DELETE FROM usage WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId]);
171
171
  for (const row of rows) {
@@ -185,10 +185,11 @@ export function recalculateRunUsage(context, input) {
185
185
  AND status = 'idle'
186
186
  AND NOT EXISTS (SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running')`, [observedAt, session.completed_at, input.projectId, input.runId, session.session_id, session.session_id]);
187
187
  }
188
- context.db.exec("COMMIT");
188
+ context.db.exec("RELEASE run_usage");
189
189
  }
190
190
  catch (error) {
191
- context.db.exec("ROLLBACK");
191
+ context.db.exec("ROLLBACK TO run_usage");
192
+ context.db.exec("RELEASE run_usage");
192
193
  throw error;
193
194
  }
194
195
  }