@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.121

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.120",
4
- "cli_commit": "a9385404a7a9fced5e4cfdcdb656e534c06d1401",
5
- "built_at": "2026-09-28T22:25:15.635Z",
3
+ "cli_version": "0.9.0-beta.121",
4
+ "cli_commit": "2e7bef9a7813bd003855d94b394e63d8aa5f6419",
5
+ "built_at": "2026-09-29T16:13:36.621Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
@@ -315,7 +315,7 @@ export class Runtime {
315
315
  // creation/end_turn receipt can then establish an idle root. Dispatch
316
316
  // clears that receipt before sending any subsequent productive request.
317
317
  const receipt = this.sessions.get(result?.provider_session_id)?.native_root_receipt;
318
- if (status === "unknown" && ["created", "end_turn", "cancelled"].includes(receipt?.kind)) return false;
318
+ if (status === "unknown" && ["created", "end_turn", "cancelled", "failed"].includes(receipt?.kind)) return false;
319
319
  return !["completed", "failed", "cancelled", "stopped", "idle"].includes(status);
320
320
  }
321
321
  async refreshTree(cancel = false) {
@@ -356,7 +356,17 @@ export class Runtime {
356
356
  const running = name === "session.archive" ? false : this.treeRunning(result);
357
357
  await this.persist({ active_tree: running, active_operation: null });
358
358
  return { ...result, descendants: this.topology(result.provider_session_id), settled: !running };
359
- } catch (error) { await this.persist({ active_tree: true, active_operation: null }); throw error; }
359
+ } catch (error) {
360
+ // A matched JSON-RPC error completes this native prompt request. It is
361
+ // root-turn proof, not proof that any descendants have stopped. A lost
362
+ // response, bridge error, or failure before dispatch earns no receipt.
363
+ const response = error?.details?.terminal_response;
364
+ if (name === "session.prompt" && response?.method === "session/prompt" && response.session_id === sessionId) {
365
+ const session = this.sessions.get(sessionId);
366
+ if (session) session.native_root_receipt = { kind: "failed", daemon_id: this.state.daemon_id, request_id: response.request_id };
367
+ }
368
+ await this.persist({ active_tree: true, active_operation: null }); throw error;
369
+ }
360
370
  finally { this.active = null; }
361
371
  }
362
372
  async dispatch(operation, params) {
@@ -270,7 +270,9 @@ export class AcpBridge {
270
270
  const unknown = ["native_timeout", "native_backend_dead", "native_backend_pipe_broken", "native_outcome_unknown"].includes(nativeCode);
271
271
  error.code = unknown ? nativeCode : /payment required|quota|usage balance exhausted|insufficient.?credit|billing/i.test(messageText) ? "provider_quota_exhausted" : /rate.?limit|too many requests/i.test(messageText) ? "provider_rate_limited" : /\b429\b/.test(messageText) ? "provider_limit_unknown" : "acp_request_failed";
272
272
  error.retryable = error.code === "provider_rate_limited";
273
- error.details = { acp_code: message.error.code ?? null, ...(data ? { native: data } : {}), ...(details ? { provider_details: details } : {}), ...(providerDetails ? { provider_update: provider } : {}) };
273
+ error.details = { acp_code: message.error.code ?? null,
274
+ terminal_response: { method: pending.method, session_id: pending.sessionId, request_id: message.id },
275
+ ...(data ? { native: data } : {}), ...(details ? { provider_details: details } : {}), ...(providerDetails ? { provider_update: provider } : {}) };
274
276
  pending.reject(error);
275
277
  }
276
278
  else { pending.resolve(message.result ?? {}); }
@@ -9,11 +9,51 @@ import { assertRunMutationAllowed } from "./run-recovery.js";
9
9
  import { AppError } from "../shared/errors.js";
10
10
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
11
11
  import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
12
+ import { getResourceDatabase } from "../storage/database.js";
12
13
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
13
14
  // Receipt reservation precedes child spawn so an interrupted CLI leaves an
14
15
  // auditable attempt. A second caller must not mistake that small handoff window
15
16
  // for a dead check and overwrite its outcome.
16
17
  const receiptStartingGraceMs = 30_000;
18
+ /** The check writer proves an empty tree before marking its registration terminal. */
19
+ export function terminalCheckSettled(context, record) {
20
+ if (record.kind !== "check" || !record.check_id || record.owner_id !== `check:${record.check_id}` || !record.project_id || !record.run_id
21
+ || !record.pid || !record.pid_started_at || !["stopped", "failed"].includes(record.state) || !record.finished_at)
22
+ return null;
23
+ let metadata;
24
+ try {
25
+ metadata = JSON.parse(record.metadata_json);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ if (metadata.role !== "check" || managedProcessRuntimeHome(record, context) !== context.ddFlowHome)
31
+ return null;
32
+ let resource;
33
+ try {
34
+ resource = getResourceDatabase(context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, "read_existing");
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ try {
40
+ const current = resource.get("SELECT * FROM managed_processes WHERE id = ?", [record.id]);
41
+ if (!current || current.lease_token !== record.lease_token || current.owner_id !== record.owner_id || current.pid !== record.pid
42
+ || current.pid_started_at !== record.pid_started_at || current.metadata_json !== record.metadata_json || current.state !== record.state
43
+ || current.project_id !== record.project_id || current.run_id !== record.run_id || current.work_id !== record.work_id
44
+ || current.check_id !== record.check_id || current.finished_at !== record.finished_at)
45
+ return null;
46
+ const receipt = context.db.get("SELECT status, finished_at, work_id FROM check_receipts WHERE id = ? AND project_id = ? AND run_id = ?", [record.check_id, record.project_id, record.run_id]);
47
+ return receipt && receipt.work_id === record.work_id && ["passed", "failed", "aborted"].includes(receipt.status) && receipt.finished_at
48
+ ? { status: receipt.status, finished_at: receipt.finished_at } : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ finally {
54
+ resource.close?.();
55
+ }
56
+ }
17
57
  export function codeExecutionEnvironment(workspaceRoot) {
18
58
  const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
19
59
  // The managed CLI's Node must win over a provider app's PATH: a Rosetta
@@ -61,7 +61,12 @@ export function prepareManagedProcessRegistration(context, input) {
61
61
  throw new AppError("validation", "Unique managed operation requires operationId", 2);
62
62
  let metadataJson;
63
63
  try {
64
- metadataJson = JSON.stringify({ ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) });
64
+ const metadata = { ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) };
65
+ delete metadata.process_birth;
66
+ delete metadata.owner_birth;
67
+ if (input.ownerPid)
68
+ metadata.owner_birth = stableProcessBirth(input.ownerPid);
69
+ metadataJson = JSON.stringify(metadata);
65
70
  }
66
71
  catch {
67
72
  throw new AppError("validation", "metadata must be JSON-serializable", 2);
@@ -86,7 +91,8 @@ export function registerManagedProcess(context, rawInput) {
86
91
  if (!input.uniqueActiveOperation)
87
92
  return insertManagedProcess(context, input, metadataJson);
88
93
  const db = registry(context);
89
- if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth }))
94
+ const observerProcessBirth = observer ? stableProcessBirth(input.pid) : null;
95
+ if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth, metadata_json: JSON.stringify({ process_birth: observerProcessBirth }) }))
90
96
  throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
91
97
  return db.writeTransaction(() => {
92
98
  const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
@@ -97,6 +103,7 @@ export function registerManagedProcess(context, rawInput) {
97
103
  const metadata = JSON.parse(metadataJson);
98
104
  if (input.processGroupId)
99
105
  metadata.process_group_id = input.processGroupId;
106
+ metadata.process_birth = observerProcessBirth;
100
107
  const now = context.now();
101
108
  db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
102
109
  result = requireProcess(db, result.id);
@@ -137,9 +144,11 @@ export function confirmManagedProcess(context, input) {
137
144
  const metadata = parseMetadata(current.metadata_json);
138
145
  if (input.processGroupId)
139
146
  metadata.process_group_id = input.processGroupId;
147
+ metadata.process_birth = stableProcessBirth(input.pid);
140
148
  if (input.ownerPid) {
141
149
  metadata.owner_pid = input.ownerPid;
142
150
  metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
151
+ metadata.owner_birth = stableProcessBirth(input.ownerPid);
143
152
  }
144
153
  const result = db.run(`UPDATE managed_processes
145
154
  SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
@@ -216,6 +225,11 @@ export function processIsAlive(record) {
216
225
  catch (error) {
217
226
  return error.code !== "ESRCH";
218
227
  }
228
+ const metadata = record.metadata_json ? parseMetadata(record.metadata_json) : {};
229
+ if (Object.hasOwn(metadata, "process_birth")) {
230
+ const birth = parseBirth(metadata.process_birth), observed = stableProcessBirth(record.pid);
231
+ return !birth || !observed || observed === birth;
232
+ }
219
233
  const started = processStartedAt(record.pid);
220
234
  return !record.pid_started_at || !started || record.pid_started_at === started;
221
235
  }
@@ -223,6 +237,17 @@ export function processTreeIsAlive(record) {
223
237
  if (processIsAlive(record))
224
238
  return true;
225
239
  const group = parseMetadata(record.metadata_json).process_group_id;
240
+ const birth = parseBirth(parseMetadata(record.metadata_json).process_birth);
241
+ // POSIX cannot reuse a leader PID while its old process group survives.
242
+ if (process.platform !== "win32" && group === record.pid && record.pid && birth) {
243
+ try {
244
+ process.kill(record.pid, 0);
245
+ }
246
+ catch { /* A missing leader can still have living children. */ }
247
+ const observed = stableProcessBirth(record.pid);
248
+ if (observed && observed !== birth)
249
+ return false;
250
+ }
226
251
  if (process.platform !== "win32" && typeof group === "number") {
227
252
  try {
228
253
  process.kill(-group, 0);
@@ -238,7 +263,7 @@ export function managedOwnerIsAlive(record) {
238
263
  const metadata = parseMetadata(record.metadata_json);
239
264
  if (typeof metadata.owner_pid !== "number")
240
265
  return null;
241
- return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
266
+ return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null, metadata_json: JSON.stringify({ process_birth: metadata.owner_birth }) });
242
267
  }
243
268
  /**
244
269
  * Expiry proves that observation stopped, not that the owner died. A live PID
@@ -413,16 +438,27 @@ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
413
438
  return new Date(Date.parse(now) + leaseMs).toISOString();
414
439
  }
415
440
  export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
441
+ function stableProcessBirth(pid) {
442
+ const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" } });
443
+ const value = result.status === 0 ? result.stdout.trim() : "";
444
+ return value ? `ps-lstart-utc-c@1:${value}` : null;
445
+ }
446
+ function parseBirth(value) { return typeof value === "string" && value.startsWith("ps-lstart-utc-c@1:") && value.length > "ps-lstart-utc-c@1:".length ? value : null; }
416
447
  function releasePortClaims(db, claims) { for (const claim of claims)
417
448
  db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
418
449
  function terminateOwnedProcess(record, signal) {
419
450
  // Never signal a group merely because its former leader PID was once ours.
420
451
  // PID reuse or a departed leader makes group ownership unprovable.
421
- if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
452
+ if (!record.pid || !record.pid_started_at)
422
453
  return;
423
- const group = parseMetadata(record.metadata_json).process_group_id;
454
+ const metadata = parseMetadata(record.metadata_json);
455
+ const birth = parseBirth(metadata.process_birth);
456
+ if (Object.hasOwn(metadata, "process_birth") ? !birth || stableProcessBirth(record.pid) !== birth : processStartedAt(record.pid) !== record.pid_started_at)
457
+ return;
458
+ const group = metadata.process_group_id;
459
+ // A non-leader PID does not establish ownership of its whole process group.
424
460
  try {
425
- process.kill(process.platform === "win32" || typeof group !== "number" ? record.pid : -group, signal);
461
+ process.kill(process.platform === "win32" || group !== record.pid ? record.pid : -group, signal);
426
462
  }
427
463
  catch { /* inspect again below */ }
428
464
  }
@@ -11,7 +11,7 @@ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
11
11
  import { runtimeHarness } from "./execution-policy.js";
12
12
  import { harnessKey } from "./harness-config.js";
13
13
  import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
14
- import { reconcileUnfinishedChecks } from "./code-checks.js";
14
+ import { reconcileUnfinishedChecks, terminalCheckSettled } from "./code-checks.js";
15
15
  import { controlStatusReceipt } from "./run-control-receipt.js";
16
16
  import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
17
17
  /** Durable projection only: status never probes a provider or acquires an owner. */
@@ -545,6 +545,12 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
545
545
  return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_lease_unsettled" };
546
546
  if (!record.pid || !record.pid_started_at)
547
547
  return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_identity_unknown" };
548
+ // Code checks mark their process terminal only after proving its tree is
549
+ // empty, then publish the exact check receipt. A later PGID reuse cannot
550
+ // turn that already-settled check back into an owned live process.
551
+ const check = record.kind === "check" && record.project_id === project.id && record.run_id === input.runId ? terminalCheckSettled(context, record) : null;
552
+ if (check)
553
+ return { process_id: record.id, kind: record.kind, status: "completed", receipt: { source: "terminal_check_tree_settled", ...managedProcessBinding(record, home), check_id: record.check_id, finished_at: check.finished_at } };
548
554
  if (!interrupt && processTreeIsAlive(record))
549
555
  return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_running" };
550
556
  if (boundary)
@@ -1,9 +1,11 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { createContext } from "../runtime/context.js";
4
5
  import { AppError } from "../shared/errors.js";
5
6
  import { runHarnessAdapter } from "./harness-adapter.js";
6
- import { managedLeaseReleased, managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
7
+ import { managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
8
+ import { terminalCheckSettled } from "./code-checks.js";
7
9
  import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
8
10
  import { retainedDaemonOperations } from "./run-control.js";
9
11
  import { historicalDaemonSettlement } from "./native-daemon-history.js";
@@ -80,10 +82,29 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
80
82
  const metadata = JSON.parse(record.metadata_json);
81
83
  // RUN control owns active controllers/checks. Adopt only its already-finished
82
84
  // physical registrations here; death alone is not a completed RUN operation.
85
+ const terminalCheck = record.kind === "check" && metadata.role === "check" && record.check_id && record.project_id && record.run_id
86
+ ? (() => {
87
+ const home = managedProcessRuntimeHome(record, context);
88
+ if (!home)
89
+ return null;
90
+ let reader;
91
+ try {
92
+ reader = createContext({ ...context.env, DD_FLOW_HOME: home }, "read_existing");
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ try {
98
+ return terminalCheckSettled(reader, record);
99
+ }
100
+ finally {
101
+ reader.db.close?.();
102
+ }
103
+ })() : null;
83
104
  if ((record.kind === "run-controller" && metadata.role === "controller" && record.operation_id
84
105
  || record.kind === "check" && metadata.role === "check" && record.check_id)
85
106
  && record.project_id && record.run_id && ["stopped", "failed"].includes(record.state)
86
- && record.pid && record.pid_started_at && !processTreeIsAlive(record)) {
107
+ && record.pid && record.pid_started_at && (terminalCheck || !processTreeIsAlive(record))) {
87
108
  const operationId = record.operation_id ?? record.check_id;
88
109
  nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
89
110
  continue;
@@ -582,7 +582,13 @@ export function verificationProjection(works, receipts, options = {}) {
582
582
  if (item.obligation_ref)
583
583
  rawEvidence.set(item.obligation_ref, unique([...(rawEvidence.get(item.obligation_ref) ?? []), ...(item.refs ?? [])]));
584
584
  }
585
- const acceptance = uniqueBy(packets.flatMap((value) => value.acceptance), (value) => value.criterion_id);
585
+ const byCriterionGate = new Map();
586
+ for (const entry of packets.flatMap((value) => value.acceptance)) {
587
+ const key = JSON.stringify([entry.criterion_id, entry.gate]);
588
+ const previous = byCriterionGate.get(key);
589
+ byCriterionGate.set(key, { criterion_id: entry.criterion_id, gate: entry.gate, check_refs: unique([...(previous?.check_refs ?? []), ...entry.check_refs]) });
590
+ }
591
+ const acceptance = [...byCriterionGate.values()];
586
592
  const historicalReceiptRefs = new Set((options.historicalReceipts ?? receipts).flatMap((receipt) => receiptReferences(receipt, options)));
587
593
  const acceptanceEvidence = new Map(acceptance.map((item) => {
588
594
  const reported = rawEvidence.get(item.criterion_id) ?? [];
@@ -201,6 +201,7 @@ export function prepareVnextPlanArtifacts(context, input) {
201
201
  const batch = path.join(input.home, "03-plan", "code-work-batch.json");
202
202
  const ownership = protocolOwnership(input.home, input.protocols);
203
203
  const obligations = acceptedObligations(input.home);
204
+ const mergeEnding = runEndsAtMerge(context, input.projectRoot, input.runId);
204
205
  const frozenDocumentBaselines = input.publishBatch === false && fs.existsSync(batch)
205
206
  ? new Map(readJson(batch).works?.flatMap((work) => typeof work.key === "string" ? (work.document_updates ?? []).flatMap((update) => typeof update.path === "string" && (typeof update.baseline_sha256 === "string" || update.baseline_sha256 === null)
206
207
  ? [[`${work.key}\u0000${update.path}`, update.baseline_sha256]]
@@ -214,7 +215,7 @@ export function prepareVnextPlanArtifacts(context, input) {
214
215
  try {
215
216
  const { value } = prepareJsonSchema(file, "PLAN", { schemaName: "vnext-protocol-plan", projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
216
217
  assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
217
- validatePlanSemantics(file, value, new Set(ownership.get(protocolId) ?? []), obligations);
218
+ validatePlanSemantics(file, value, new Set(ownership.get(protocolId) ?? []), obligations, mergeEnding);
218
219
  validateCodeCheckCommands(workspaceRoot, value.checks.filter((check) => check.availability === "available").map((check) => check.command));
219
220
  for (const check of value.checks.filter((item) => item.availability === "available"))
220
221
  validateCheckDeclaration(workspaceRoot, check);
@@ -481,7 +482,7 @@ function normalizedAspectMapRefs(file, bytes, projectRoot, runHome, runId) {
481
482
  aspect.planned_artifact_refs = aspect.planned_artifact_refs.map(normalize);
482
483
  return changed ? `${JSON.stringify(map, null, 2)}\n` : bytes;
483
484
  }
484
- function projectCodeWorkBatch(input) {
485
+ export function projectCodeWorkBatch(input) {
485
486
  const obligationMap = acceptedObligationMap(input.home);
486
487
  const orientation = projectOrientation(input.workspaceRoot);
487
488
  const works = input.plans.flatMap(({ protocolId, file, value }) => value.items.map((item) => {
@@ -516,10 +517,9 @@ function projectCodeWorkBatch(input) {
516
517
  ])],
517
518
  discovery_boundary: item.execution_context.discovery_boundary,
518
519
  planned_write_areas: [...new Set([...item.execution_context.planned_write_areas, ...documentPaths])],
519
- // A criterion belongs to every listed Work. Its proof must travel with
520
- // those Work packets as well as item-local verification; otherwise CODE
521
- // silently loses readiness/code checks declared only by acceptance.
522
- checks: value.checks.filter((check) => itemCheckRefs(item, value.acceptance).includes(check.id)),
520
+ // CODE still needs acceptance-only aggregate declarations in its packets;
521
+ // work checks belong only to the item that explicitly verifies them.
522
+ checks: value.checks.filter((check) => itemCheckRefs(item, value.acceptance, value.checks).includes(check.id)),
523
523
  provides_checks: value.checks.filter((check) => check.availability === "planned" && check.provided_by === item.id),
524
524
  stop_conditions: item.execution_context.stop_conditions,
525
525
  depends_on: item.depends_on.map((dependency) => `${protocolId}:${dependency}`),
@@ -548,10 +548,15 @@ function projectCodeWorkBatch(input) {
548
548
  const ordered = [...works].sort((a, b) => a.key.localeCompare(b.key));
549
549
  return { schema_id: "dd-flow/code-work-batch@5", sources: input.plans.map(({ protocolId, file, value }) => ({ plan_id: value.plan_id, protocol_id: protocolId, revision: value.revision, sha256: checksum(file) })), works: ordered };
550
550
  }
551
- function itemCheckRefs(item, acceptance) {
552
- return [...new Set([...item.verification.check_refs, ...acceptance.filter((entry) => entry.plan_item_ids.includes(item.id)).flatMap((entry) => entry.check_refs)])];
551
+ function itemCheckRefs(item, acceptance, catalog) {
552
+ const aggregate = new Set(catalog.filter((check) => check.run_at !== "work").map((check) => check.id));
553
+ return [...new Set([...item.verification.check_refs, ...acceptance.filter((entry) => entry.plan_item_ids.includes(item.id)).flatMap((entry) => entry.check_refs.filter((ref) => aggregate.has(ref)))])];
554
+ }
555
+ function renderCodeTask(item, catalog, acceptance = []) {
556
+ const local = catalog.filter((check) => check.run_at === "work" && item.verification.check_refs.includes(check.id));
557
+ const aggregate = catalog.filter((check) => check.run_at !== "work" && itemCheckRefs(item, acceptance, catalog).includes(check.id));
558
+ return [item.title, item.summary, item.details, `Preserve: ${item.semantic_spine.must_preserve.join("; ")}`, `Work checks: ${local.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Later aggregate checks (CODE/MERGE; do not run in this Work): ${aggregate.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Stop: ${item.execution_context.stop_conditions.join("; ")}`].join("\n\n");
553
559
  }
554
- function renderCodeTask(item, catalog, acceptance = []) { const checks = catalog.filter((check) => itemCheckRefs(item, acceptance).includes(check.id)); return [item.title, item.summary, item.details, `Preserve: ${item.semantic_spine.must_preserve.join("; ")}`, `Checks: ${checks.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Stop: ${item.execution_context.stop_conditions.join("; ")}`].join("\n\n"); }
555
560
  function validateProjectedPaths(batch, workspaceRoot, home, runId) {
556
561
  for (const work of batch.works) {
557
562
  for (const write of work.planned_write_areas)
@@ -595,7 +600,7 @@ function resolvePortablePath(value, workspaceRoot, home, runId) {
595
600
  }
596
601
  function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
597
602
  function runRef(runId, runHome, file) { return `run://${runId}/${path.relative(runHome, file).split(path.sep).join("/")}`; }
598
- function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
603
+ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs, mergeEnding) {
599
604
  const items = plan.items ?? [];
600
605
  const ids = new Set(items.map((item) => item.id).filter((id) => Boolean(id)));
601
606
  if (ids.size !== items.length)
@@ -608,6 +613,8 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
608
613
  for (const check of plan.checks ?? []) {
609
614
  if (checks.has(check.id))
610
615
  throw new AppError("validation", "PLAN check ids must be unique", 2, { file, check_id: check.id });
616
+ if (mergeEnding && check.run_at === "release")
617
+ throw new AppError("check_gate_unreachable", "A RUN ending at MERGE cannot execute RELEASE checks", 2, { file, check_id: check.id });
611
618
  if (check.availability === "planned" && (!check.provided_by || !ids.has(check.provided_by)))
612
619
  throw new AppError("planned_check_provider_missing", "A planned check must name a PLAN item that provides it", 2, { file, check_id: check.id, provided_by: check.provided_by ?? null });
613
620
  if (check.availability === "planned" && !check.command.startsWith("@check/"))
@@ -616,6 +623,8 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
616
623
  throw new AppError("planned_check_definition_missing", "A planned check must declare its exact alias definition", 2, { file, check_id: check.id });
617
624
  checks.set(check.id, check);
618
625
  }
626
+ const localChecks = new Set(items.flatMap((item) => item.verification.check_refs));
627
+ const acceptanceIds = new Set();
619
628
  const ancestors = (itemId, seen = new Set()) => {
620
629
  for (const dependency of items.find((item) => item.id === itemId)?.depends_on ?? [])
621
630
  if (!seen.has(dependency)) {
@@ -639,7 +648,7 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
639
648
  const check = checks.get(checkId);
640
649
  if (!check)
641
650
  throw new AppError("check_reference_unknown", "PLAN item references an unknown check", 2, { file, item: item.id, check_id: checkId });
642
- if (check.availability === "planned" && check.provided_by !== item.id && !ancestors(item.id).has(check.provided_by))
651
+ if (check.run_at === "work" && check.availability === "planned" && check.provided_by !== item.id && !ancestors(item.id).has(check.provided_by))
643
652
  throw new AppError("check_consumer_not_ordered_after_provider", "A Work may consume a planned check only after its provider", 2, { file, item: item.id, check_id: checkId, provider: check.provided_by });
644
653
  }
645
654
  }
@@ -655,6 +664,11 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
655
664
  for (const acceptance of plan.acceptance ?? []) {
656
665
  if (!acceptance.criterion_id || !acceptance.criterion_id.startsWith("AC-") || !ownedRefs.has(acceptance.criterion_id))
657
666
  throw new AppError("validation", "PLAN acceptance references an AC-* owned by this protocol", 2, { file, criterion_id: acceptance.criterion_id });
667
+ if (acceptanceIds.has(acceptance.criterion_id))
668
+ throw new AppError("duplicate_plan_criterion", "PLAN acceptance criterion ids must be unique within a protocol", 2, { file, criterion_id: acceptance.criterion_id });
669
+ acceptanceIds.add(acceptance.criterion_id);
670
+ if (mergeEnding && acceptance.gate === "release")
671
+ throw new AppError("check_gate_unreachable", "A RUN ending at MERGE cannot prove RELEASE acceptance", 2, { file, criterion_id: acceptance.criterion_id });
658
672
  for (const id of acceptance.plan_item_ids ?? [])
659
673
  if (!ids.has(id))
660
674
  throw new AppError("validation", "PLAN acceptance references an unknown item", 2, { file, criterion_id: acceptance.criterion_id, plan_item_id: id });
@@ -662,9 +676,10 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
662
676
  const check = checks.get(id);
663
677
  if (!check)
664
678
  throw new AppError("check_reference_unknown", "PLAN acceptance references an unknown check", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
665
- for (const itemId of acceptance.plan_item_ids ?? [])
666
- if (check.availability === "planned" && check.provided_by !== itemId && !ancestors(itemId).has(check.provided_by))
667
- throw new AppError("check_consumer_not_ordered_after_provider", "Acceptance may consume a planned check only after its provider", 2, { file, criterion_id: acceptance.criterion_id, item: itemId, check_id: id, provider: check.provided_by });
679
+ if ((acceptance.gate !== "release" && check.run_at === "release") || (!["merge", "release"].includes(acceptance.gate) && check.run_at === "merge"))
680
+ throw new AppError("check_gate_unreachable", "PLAN acceptance references a check after its gate", 2, { file, criterion_id: acceptance.criterion_id, check_id: id, gate: acceptance.gate, run_at: check.run_at });
681
+ if (check.run_at === "work" && !localChecks.has(id))
682
+ throw new AppError("work_check_unassigned", "A work check referenced by acceptance needs an explicit verifying PLAN item", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
668
683
  }
669
684
  }
670
685
  for (const obligation of ownedRefs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.120",
3
+ "version": "0.9.0-beta.121",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {