@deksden-com/dd-flow-cli 0.9.0-beta.100 → 0.9.0-beta.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/hook-ingress.js +5 -10
  3. package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
  4. package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
  5. package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
  6. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  7. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  8. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  9. package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
  10. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  11. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
  12. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
  13. package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
  14. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  15. package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
  16. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
  17. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
  19. package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
  20. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
  21. package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
  22. package/dist/harness-runtime/lib/managed-daemon.mjs +32 -3
  23. package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
  24. package/dist/harness-runtime/lib/process-json.mjs +3 -2
  25. package/dist/services/code-checks.js +53 -16
  26. package/dist/services/controller-fanout.js +37 -6
  27. package/dist/services/eval-snapshots.js +148 -38
  28. package/dist/services/external-work-launch.js +60 -5
  29. package/dist/services/prompts.js +2 -0
  30. package/dist/services/review-copy.js +136 -0
  31. package/dist/services/run-control-worker.js +16 -11
  32. package/dist/services/run-controller-adapter.js +9 -0
  33. package/dist/services/runs.js +3 -0
  34. package/dist/services/runtime-scope-stop.js +66 -6
  35. package/dist/services/vnext-code-review.js +6 -2
  36. package/dist/services/vnext-code.js +1 -0
  37. package/dist/services/vnext-fanout.js +9 -1
  38. package/dist/services/vnext-merge.js +8 -4
  39. package/dist/services/vnext-plan-review.js +4 -15
  40. package/dist/services/vnext-plan.js +4 -0
  41. package/dist/services/vnext-protocolize.js +4 -2
  42. package/dist/services/vnext-specify.js +7 -0
  43. package/dist/services/work-registry.js +208 -22
  44. package/dist/services/workspace-files.js +83 -0
  45. package/dist/services/worktrees.js +3 -1
  46. package/package.json +1 -1
@@ -70,7 +70,7 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
70
70
  ].join("\n");
71
71
  }
72
72
 
73
- export function renderDelegationInstructions({ harness, stage, capacity, works = [] }) {
73
+ export function renderDelegationInstructions({ harness, stage, capacity, works = [], totalWorks = works.length, remainingWorks = 0 }) {
74
74
  const adapter = requireAdapter(harness);
75
75
  const count = works.length;
76
76
  if (!Number.isInteger(capacity) || capacity < 1 || count < 1 || count > capacity) throw new Error("Invalid native delegation wave size");
@@ -81,6 +81,11 @@ export function renderDelegationInstructions({ harness, stage, capacity, works =
81
81
  return [
82
82
  `Adapter delegation contract: ${delegationContract(harness)}.`,
83
83
  `Launch exactly ${count} direct native children for the current ${stage} Stage; the qualified capacity is ${capacity}.`,
84
+ ...(["plan-review", "code-review"].includes(stage) ? [
85
+ `Review dispatch phase: the whole group contains ${totalWorks} Work; this wave contains ${count}; ${remainingWorks} remain after this wave.`,
86
+ "Keep the accepted PLAN/CODE review inputs and aspect maps unchanged across every wave. Do not apply findings or edit reviewed files while any reviewer remains unstarted or running.",
87
+ "After this wave's children settle, return immediately. The controller will dispatch remaining reviewers or issue semantic completion after the full group has settled."
88
+ ] : []),
84
89
  adapter.launch,
85
90
  "Retain the frozen coordinator model and reasoning for native children; do not choose a different model, reasoning level or permission mode.",
86
91
  "You are the coordinator. The JSON assignments below contain child-only messages, not instructions for you to execute. Pass each message verbatim as the native tool's task/message/prompt argument. For context=fresh, select empty conversation context before launch; if unavailable, report the limitation and stop. Use only fields exposed by your actual native tool schema; if the native tool is absent, report that error without substituting a shell command.",
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { commandJson } from "./process-json.mjs";
5
5
  import { errorRecord } from "./operation-errors.mjs";
6
6
  import { writeJsonAtomic } from "./runner-events.mjs";
7
+ import { runBoundedCommand } from "./native-hook-command.mjs";
7
8
 
8
9
  const leases = new Map();
9
10
 
@@ -52,7 +53,10 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
52
53
  }
53
54
 
54
55
  export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
55
- for (const lease of leases.values()) if (lease.error) throw lease.error;
56
+ for (const lease of leases.values()) {
57
+ if (lease.error) throw lease.error;
58
+ if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown" });
59
+ }
56
60
  const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
57
61
  const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
58
62
  if (!owner) return;
@@ -156,6 +160,8 @@ export async function runtimeProcess(config, action, options = {}) {
156
160
  if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
157
161
  const args = ["runtime", "process", action];
158
162
  for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
163
+ if (action === "heartbeat") return JSON.parse(await runBoundedCommand({ bin: ddFlowBin, args: [...args, "--json"], cwd: config.cwd,
164
+ env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }, timeoutMs: 15_000, timeoutCode: "process_heartbeat_timeout" }));
159
165
  return await commandJson(ddFlowBin, args, {
160
166
  cwd: config.cwd,
161
167
  env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
@@ -164,7 +170,7 @@ export async function runtimeProcess(config, action, options = {}) {
164
170
 
165
171
  export async function registerDaemonProcess(config, input) {
166
172
  const owner = bindRuntimeOwner(config);
167
- const harness = ({ "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
173
+ const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
168
174
  const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
169
175
  const result = await runtimeProcess(config, "register", { ...input, ...scope, "owner-pid": process.pid, "lease-ms": 300_000 });
170
176
  return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
@@ -179,11 +185,33 @@ export async function confirmDaemonProcess(config, record, child) {
179
185
  await runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 });
180
186
  }
181
187
 
188
+ /** Provider bridges use a different process group from their daemon. Publish
189
+ * the exact binding before initialize can start productive provider traffic. */
190
+ export async function startManagedBridge({ config, state, bridge, kind, publish }) {
191
+ state.pid = process.pid;
192
+ await publish(state);
193
+ const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
194
+ bridge.options.onSpawn = async child => {
195
+ await confirmDaemonProcess(config, provider, child);
196
+ await heartbeatDaemonProcess(config, provider);
197
+ state.provider_pid = child.pid;
198
+ state.provider_process = provider;
199
+ await publish(state);
200
+ };
201
+ try { return await bridge.start(); }
202
+ catch (error) { await cleanupFailedStart(config, provider, bridge.child, error); throw error; }
203
+ }
204
+
205
+ export async function closeManagedBridge(config, state, bridge) {
206
+ await bridge.close();
207
+ await finishDaemonProcess(config, state.provider_process, "stopped", "daemon_stopped");
208
+ }
209
+
182
210
  export async function heartbeatDaemonProcess(config, record) {
183
211
  if (!record?.id || !record.lease_token) return;
184
212
  let lease = leases.get(record.id);
185
213
  if (!lease) {
186
- lease = { pending: null, error: null, timer: null, lost: false };
214
+ lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000 };
187
215
  leases.set(record.id, lease);
188
216
  lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
189
217
  lease.timer.unref();
@@ -198,6 +226,7 @@ export async function heartbeatDaemonProcess(config, record) {
198
226
  throw Object.assign(new Error("managed process lease is no longer owned; new productive operations are blocked"), { code: "process_lease_lost", details: { process_id: record.id } });
199
227
  }
200
228
  lease.error = null;
229
+ lease.expiresAt = Date.now() + 300_000;
201
230
  } catch (cause) {
202
231
  lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id } });
203
232
  throw lease.error;
@@ -1,31 +1,105 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { reportedFailure } from "./process-json.mjs";
3
+
4
+ /** Native command hooks have a 30s outer limit; leave room for transport and cleanup. */
5
+ export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
6
+ const started = performance.now();
7
+ const duration = Math.max(0, Math.min(timeoutMs, Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : timeoutMs));
8
+ const remaining = (phase, maximum = duration) => {
9
+ const value = duration - (performance.now() - started);
10
+ if (value <= 0) throw Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" } });
11
+ return Math.max(1, Math.min(maximum, value));
12
+ };
13
+ return { deadlineAt: Date.now() + duration, remaining, async run(action, phase) {
14
+ const timeoutMs = remaining(phase);
15
+ let timer;
16
+ try { return await Promise.race([Promise.resolve().then(action), new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, effect: "unknown" } })), timeoutMs); })]); }
17
+ finally { clearTimeout(timer); }
18
+ } };
19
+ }
20
+
21
+ /** Bound native hook ingress before parsing or opening adapter state. */
22
+ export function readNativeHookInput(stream, budget, maxBytes = 1_048_576) {
23
+ return new Promise((resolve, reject) => {
24
+ const chunks = []; let bytes = 0, done = false;
25
+ const cleanup = () => { clearTimeout(timer); stream.off("data", onData); stream.off("end", onEnd); stream.off("error", onError); };
26
+ const finish = (error, value) => { if (done) return; done = true; cleanup(); if (error) { stream.destroy(); reject(error); } else resolve(value); };
27
+ const onData = chunk => {
28
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ bytes += buffer.length;
30
+ if (bytes > maxBytes) finish(Object.assign(new Error("Native hook input exceeds 1 MiB"), { code: "native_hook_input_too_large" }));
31
+ else chunks.push(buffer);
32
+ };
33
+ const onEnd = () => finish(null, Buffer.concat(chunks).toString("utf8"));
34
+ const onError = error => finish(error);
35
+ let timeoutMs;
36
+ try { timeoutMs = budget.remaining("input"); } catch (error) { reject(error); return; }
37
+ const timer = setTimeout(() => finish(Object.assign(new Error("Native hook deadline exceeded during input"), { code: "native_hook_timeout", details: { phase: "input", effect: "unknown" } })), timeoutMs);
38
+ stream.on("data", onData).once("end", onEnd).once("error", onError);
39
+ stream.resume();
40
+ });
41
+ }
42
+
43
+ export async function terminateCommandTree(child, { platform = process.platform, launch = spawn, timeoutMs = 400 } = {}) {
44
+ if (!child.pid) return;
45
+ if (platform !== "win32") { process.kill(-child.pid, "SIGKILL"); return; }
46
+ // Node's child.kill on Windows only terminates the leader. taskkill is the
47
+ // native subtree primitive; bound its own lifetime as well as the request.
48
+ await new Promise((resolve, reject) => {
49
+ const killer = launch("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
50
+ let settled = false, timer;
51
+ const finish = error => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(); };
52
+ timer = setTimeout(() => { killer.kill("SIGKILL"); killer.unref(); finish(Object.assign(new Error("Windows subtree cleanup timed out"), { code: "process_cleanup_timeout" })); }, timeoutMs);
53
+ killer.once("error", finish);
54
+ killer.once("close", code => finish(code === 0 ? null : Object.assign(new Error(`taskkill exited ${code}`), { code: "process_cleanup_failed" })));
55
+ });
56
+ }
2
57
 
3
58
  /** Preserve the native hook protocol across the CLI process boundary. */
4
- export async function invokeNativeHook({ bin, args, payload, home, timeoutMs = 15_000 }) {
59
+ export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout" }) {
60
+ const started = performance.now();
5
61
  const script = /\.[cm]?js$/.test(bin);
6
62
  const output = await new Promise((resolve, reject) => {
7
63
  const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
8
- env: { ...process.env, DD_FLOW_HOME: home }, stdio: ["pipe", "pipe", "pipe"]
64
+ cwd, env: { ...process.env, ...env }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
9
65
  });
10
- let stdout = "", stderr = "", failure;
11
- const stop = error => { failure ??= error; child.kill("SIGKILL"); };
12
- const timer = setTimeout(() => stop(Object.assign(new Error("Native hook admission timed out"), { code: "native_hook_timeout" })), timeoutMs);
66
+ let stdout = "", stderr = "", failure, cleanupTimer, settled = false, termination;
67
+ const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(cleanupTimer); error ? reject(error) : resolve(value); };
68
+ const stop = error => {
69
+ if (termination || settled) return;
70
+ failure ??= error;
71
+ failure.details = { ...failure.details, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" };
72
+ termination = terminateCommandTree(child).catch(cleanup => {
73
+ if (cleanup.code === "ESRCH") return;
74
+ failure.details.cleanup_error = { code: cleanup.code, message: cleanup.message };
75
+ failure.details.cleanup_unconfirmed = true;
76
+ child.kill("SIGKILL");
77
+ });
78
+ cleanupTimer ??= setTimeout(() => {
79
+ failure.details.cleanup_unconfirmed = true;
80
+ child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
81
+ finish(failure);
82
+ }, 500);
83
+ };
84
+ const timer = setTimeout(() => stop(Object.assign(new Error("Bounded CLI request timed out"), { code: timeoutCode })), timeoutMs);
13
85
  child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; if (stdout.length > 1_048_576) stop(new Error("Native hook response exceeds protocol limit")); });
14
86
  child.stderr.setEncoding("utf8").on("data", chunk => { stderr = (stderr + chunk).slice(-65_536); });
15
87
  child.stdin.on("error", error => { failure ??= error; });
16
- child.on("error", error => { clearTimeout(timer); reject(error); });
88
+ child.on("error", error => finish(failure ?? error));
17
89
  child.on("close", code => {
18
- clearTimeout(timer);
19
- if (failure) return reject(failure);
90
+ if (failure) { void (termination ?? Promise.resolve()).then(() => finish(failure)); return; }
20
91
  if (code !== 0) {
21
- let record;
22
- try { record = JSON.parse(stderr.trim()).error; } catch { /* Non-JSON stderr remains diagnostic evidence. */ }
23
- return reject(Object.assign(new Error(record?.message ?? (stderr.trim() || "Native hook admission failed")), { code: record?.code ?? "native_hook_failed", details: record?.details }));
92
+ return finish(reportedFailure(stdout, stderr, "Native hook admission failed", "native_hook_failed"));
24
93
  }
25
- resolve(stdout);
94
+ finish(null, stdout);
26
95
  });
27
- child.stdin.end(`${JSON.stringify(payload)}\n`);
96
+ child.stdin.end(input);
28
97
  });
98
+ return output;
99
+ }
100
+
101
+ export async function invokeNativeHook({ bin, args, payload, home, cwd, env, timeoutMs = 15_000 }) {
102
+ const output = await runBoundedCommand({ bin, args, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
29
103
  let result;
30
104
  try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
31
105
  if (result?.ok === false && typeof result.error?.code === "string") throw Object.assign(new Error(result.error.message ?? "Native hook failed"), { code: result.error.code, details: result.error.details });
@@ -5,17 +5,18 @@ function failure(message, code) {
5
5
  return Object.assign(new Error(message), { code });
6
6
  }
7
7
 
8
- function reportedFailure(stdout, stderr, fallback) {
8
+ export function reportedFailure(stdout, stderr, fallback, fallbackCode = "flow_reconciliation_failed") {
9
9
  for (const text of [stdout, stderr]) {
10
10
  for (const line of [text.trim(), ...text.trim().split(/\n/).reverse()]) {
11
11
  try {
12
12
  const value = JSON.parse(line);
13
13
  const error = value?.error;
14
14
  if (typeof error?.code === "string" && error.code) return reportedError(error, fallback);
15
+ if (typeof value?.code === "string" && value.code) return reportedError({ ...value, message: typeof error === "string" ? error : value.message }, fallback);
15
16
  } catch { /* Not a structured CLI error line. */ }
16
17
  }
17
18
  }
18
- return failure(stderr.trim() || stdout.trim() || fallback, "flow_reconciliation_failed");
19
+ return failure(stderr.trim() || stdout.trim() || fallback, fallbackCode);
19
20
  }
20
21
 
21
22
  function processTarget(executable, args) {
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
2
3
  import { spawn, spawnSync } from "node:child_process";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -43,6 +44,28 @@ export function readCodeCheckProfile(workspaceRoot) {
43
44
  throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
44
45
  return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
45
46
  }
47
+ /**
48
+ * Planned aliases are qualification-owned extensions: their provider Work is
49
+ * expected to materialize them after PLAN. Freeze the project policy and
50
+ * existing aliases, while still validating every planned alias definition at
51
+ * execution time. Unknown edits and changes to existing policy remain part
52
+ * of the hash and therefore fail closed.
53
+ */
54
+ export function codeCheckProfileHash(workspaceRoot, checks = []) {
55
+ const { profile } = readCodeCheckProfile(workspaceRoot);
56
+ if (!profile)
57
+ return null;
58
+ const planned = new Set(checks.filter(check => check.availability === "planned" && check.command.startsWith("@check/")).map(check => check.command));
59
+ if (!planned.size)
60
+ return crypto.createHash("sha256").update(JSON.stringify(profile)).digest("hex");
61
+ const canonical = {
62
+ ...profile,
63
+ aliases: Object.fromEntries(Object.entries(profile.aliases).filter(([alias]) => !planned.has(alias))),
64
+ ports_by_alias: Object.fromEntries(Object.entries(profile.ports_by_alias ?? {}).filter(([alias]) => !planned.has(alias))),
65
+ inputs_by_alias: Object.fromEntries(Object.entries(profile.inputs_by_alias ?? {}).filter(([alias]) => !planned.has(alias)))
66
+ };
67
+ return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
68
+ }
46
69
  export function validateCodeCheckCommands(workspaceRoot, commands) {
47
70
  const { profile, file } = readCodeCheckProfile(workspaceRoot);
48
71
  return commands.map((command) => {
@@ -139,7 +162,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
139
162
  const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
140
163
  for (const check of input.checks)
141
164
  validateCheckDeclaration(input.workspaceRoot, check);
142
- const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
165
+ const { profile } = readCodeCheckProfile(input.workspaceRoot);
166
+ const profileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
143
167
  if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
144
168
  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 });
145
169
  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 }) }; });
@@ -239,7 +263,7 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
239
263
  input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
240
264
  }
241
265
  if (receipts.every(receipt => receipt.status === "passed")) {
242
- const current = workspaceState(input.workspaceRoot, [input.runHome]), { hash: currentProfileHash } = readCodeCheckProfile(input.workspaceRoot);
266
+ const current = workspaceState(input.workspaceRoot, [input.runHome]), currentProfileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
243
267
  for (const receipt of receipts) {
244
268
  const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
245
269
  const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
@@ -414,7 +438,7 @@ export function workspaceChangedPaths(root) {
414
438
  // A workspace can be intentionally gitless. Keep the same conservative
415
439
  // file inventory used for fingerprints instead of making result validation
416
440
  // fail before the work contract can report its actual error.
417
- if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
441
+ if (isGitlessWorkspace(root, status))
418
442
  return listFiles(root);
419
443
  if (status.status !== 0 || status.error)
420
444
  throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
@@ -434,7 +458,8 @@ export function workspaceChangedPaths(root) {
434
458
  paths.push(normalizeWorkspacePath(root, prior));
435
459
  }
436
460
  }
437
- return [...new Set(paths)].sort();
461
+ const ownedHook = paths.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root) : null;
462
+ return [...new Set(paths)].filter(file => file !== ownedHook).sort();
438
463
  }
439
464
  /** Ignored machine state cannot be reported as a product change. Tracked paths
440
465
  * remain eligible regardless of their directory name. */
@@ -443,15 +468,15 @@ export function ignoredWorkspacePaths(root, paths) {
443
468
  if (!normalized.length)
444
469
  return [];
445
470
  const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
446
- // Projects may intentionally run without Git (the flow supports a local
447
- // workspace identity independently from version control). In that case
448
- // there is no ignore database to consult; a declared path is a product
449
- // path, not an infrastructure path. Other Git failures remain fail-closed.
450
- if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
451
- return [];
452
- if (![0, 1].includes(status.status ?? -1) || status.error)
471
+ // The canonical owned hook has one meaning across Git status, check cache
472
+ // and MERGE, without changing a sibling worktree's shared Git excludes.
473
+ const gitless = isGitlessWorkspace(root, status);
474
+ if (!gitless && (![0, 1].includes(status.status ?? -1) || status.error))
453
475
  throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
454
- return [...new Set(status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)))].sort();
476
+ const ownedHook = normalized.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, gitless ? [] : undefined) : null;
477
+ if (gitless)
478
+ return ownedHook ? [ownedHook] : [];
479
+ return [...new Set([...status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)), ...(ownedHook ? [ownedHook] : [])])].sort();
455
480
  }
456
481
  export function normalizeWorkspacePath(root, value) {
457
482
  if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
@@ -480,11 +505,14 @@ function deduplicate(checks, commands) {
480
505
  return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
481
506
  }
482
507
  function workspaceState(root, ignoredRoots = []) {
483
- const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
508
+ const git = workspaceGitFiles(root, ["-co", "--exclude-standard"]);
484
509
  const ignored = ignoredRoots.map(entry => path.resolve(entry));
485
- const listing = [...new Set(git.status === 0 ? git.stdout.split("\0").filter(Boolean) : listFiles(root))].sort();
510
+ const listing = [...new Set(git ?? listFiles(root))].sort();
511
+ const ownedHook = listing.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, git ? undefined : []) : null;
486
512
  const files = new Map(), hash = crypto.createHash("sha256");
487
513
  for (const relative of listing) {
514
+ if (relative === ownedHook)
515
+ continue;
488
516
  const file = path.resolve(root, relative);
489
517
  if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
490
518
  continue;
@@ -677,8 +705,17 @@ function collectCheckArtifacts(root, required) {
677
705
  const missing = required.filter((relative) => !retained.has(path.normalize(relative)));
678
706
  return { complete: missing.length === 0, missing, items };
679
707
  }
680
- function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
681
- return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
708
+ function listFiles(root) {
709
+ const allowed = projectServicePathFilter(root, [], []);
710
+ const ownedHook = ownedWorkspaceHookPath(root, []);
711
+ const visit = (current) => fs.readdirSync(current, { withFileTypes: true }).flatMap(entry => {
712
+ const absolute = path.join(current, entry.name);
713
+ if ([".git", "node_modules"].includes(entry.name) || !allowed(absolute))
714
+ return [];
715
+ return entry.isDirectory() ? visit(absolute) : [path.relative(root, absolute)];
716
+ });
717
+ return visit(root).filter(file => file.split(path.sep).join("/") !== ownedHook).sort();
718
+ }
682
719
  export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
683
720
  return new Promise((resolve) => {
684
721
  const started = Date.now();
@@ -4,6 +4,7 @@ import { getFlowRunVariables } from "./runs.js";
4
4
  import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
6
6
  import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
7
+ import { assertWorkLaunchReady } from "./work-registry.js";
7
8
  function semanticCompletionPrompt(stage, completed) {
8
9
  const task = stage === "plan-review"
9
10
  ? "Classify the reviewer findings, apply every accepted correction in the current PLAN-REVIEW Work, and write the authoritative review decision."
@@ -85,7 +86,11 @@ export async function nextControllerFanout(context, input) {
85
86
  for (const work of wave)
86
87
  if (!work.work_id || !work.start_command)
87
88
  throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
88
- return prompt("dispatch_work", renderDelegationInstructions({ harness: input.harness, stage: input.stage, capacity, works: wave }));
89
+ if (input.stage === "plan-review" || input.stage === "code-review")
90
+ for (const work of wave)
91
+ assertWorkLaunchReady(context, work.work_id);
92
+ const totalWorks = fanout.works.created + fanout.works.running + fanout.works.completed + fanout.works.failed + fanout.works.cancelled;
93
+ return prompt("dispatch_work", renderDelegationInstructions({ harness: input.harness, stage: input.stage, capacity, works: wave, totalWorks, remainingWorks: fanout.works.created - wave.length }));
89
94
  }
90
95
  if (fanout.works.running)
91
96
  return { kind: "wait", reason: "work_receipts_pending" };
@@ -109,11 +114,21 @@ export function controllerNativeChildren(receipt, rootSessionId) {
109
114
  const value = object(raw);
110
115
  const id = text(value, ["provider_session_id", "session_id", "sessionId", "childSessionId", "conversation_id", "conversationId", "id", "subagentId"]);
111
116
  const parent = text(value, ["parent_provider_session_id", "parent_session_id", "parentSessionId", "parentID", "parentId"]) ?? scopedParent;
112
- if (!id || parent !== rootSessionId)
117
+ if (!id)
113
118
  return;
119
+ const outcomeError = object(value.outcome_error);
120
+ if (typeof outcomeError.code === "string")
121
+ throw new AppError(outcomeError.code, String(outcomeError.message ?? "Native child outcome is unconfirmed"), 1, { ...object(outcomeError.details), session_id: id, source });
114
122
  const state = String(value.status ?? value.state ?? fallback).toLowerCase();
115
- const status = ["completed", "complete", "done", "idle", "success", "succeeded"].includes(state) ? "completed" : state === "settled_by_root" ? "settled_by_root" : ["failed", "error"].includes(state) ? "failed" : ["cancelled", "canceled"].includes(state) ? "cancelled" : ["running", "active", "pending"].includes(state) ? "running" : fallback;
116
- candidates.push({ session_id: id, parent_session_id: parent, status: status === "unknown" && settledTree.has(id) ? "settled_by_root" : status, source });
123
+ if (state === "lost")
124
+ throw new AppError("native_child_outcome_lost", "Native child ended without a recoverable outcome", 1, { session_id: id, parent_session_id: parent, source, native_status: state });
125
+ // Provider idle means no current activity, not a Work result. Only an
126
+ // explicit terminal result or a separately proven root settlement may be
127
+ // terminal here; unknown states stay unknown for reconciliation.
128
+ const status = ["completed", "complete", "done", "success", "succeeded"].includes(state) ? "completed" : state === "settled_by_root" ? "settled_by_root" : ["failed", "error"].includes(state) ? "failed" : ["cancelled", "canceled", "interrupted"].includes(state) ? "cancelled" : ["running", "active", "pending"].includes(state) ? "running" : fallback;
129
+ const rootChild = parent === rootSessionId;
130
+ const settled = rootChild && status === "unknown" && settledTree.has(id) ? "settled_by_root" : status;
131
+ candidates.push({ session_id: id, parent_session_id: parent ?? "", status: settled, source, ...(rootChild ? {} : { provenance: "parent_mismatch" }) });
117
132
  };
118
133
  for (const value of list(receipt.descendants))
119
134
  add(value, "adapter.descendants");
@@ -122,12 +137,28 @@ export function controllerNativeChildren(receipt, rootSessionId) {
122
137
  const subagents = object(object(receipt.evidence).subagents ?? receipt.subagents);
123
138
  for (const value of list(subagents.running))
124
139
  add(value, "zcode/session/subagents", rootSessionId, "running");
125
- for (const value of [...list(subagents.completed), ...list(object(subagents.ended).items)])
140
+ for (const value of list(subagents.completed))
126
141
  add(value, "zcode/session/subagents", rootSessionId, "completed");
142
+ for (const value of list(object(subagents.ended).items)) {
143
+ const status = String(object(value).status ?? "unknown");
144
+ if (!["success", "failed", "cancelled", "lost"].includes(status))
145
+ throw new AppError("native_child_outcome_unavailable", "ZCode ended child has no recognized native outcome", 1, { child: value });
146
+ add(value, "zcode/session/subagents", rootSessionId);
147
+ }
127
148
  const unique = new Map();
149
+ const rank = { unknown: 0, running: 1, settled_by_root: 2, completed: 3, failed: 3, cancelled: 3 };
128
150
  for (const child of candidates) {
129
151
  const previous = unique.get(child.session_id);
130
- if (!previous || previous.status === "unknown" || previous.status === "running" && child.status !== "running")
152
+ if (previous && previous.parent_session_id !== child.parent_session_id) {
153
+ // Keep the contradictory parent visible to the existing provenance gate,
154
+ // independently of input order or which source happened to say success.
155
+ const foreign = [previous, child].filter(item => item.parent_session_id !== rootSessionId).sort((a, b) => a.parent_session_id.localeCompare(b.parent_session_id))[0];
156
+ unique.set(child.session_id, { ...foreign, provenance: "parent_mismatch" });
157
+ continue;
158
+ }
159
+ if (previous && rank[previous.status] === 3 && rank[child.status] === 3 && previous.status !== child.status)
160
+ throw new AppError("native_child_outcome_conflict", "Native child has contradictory terminal outcomes", 1, { session_id: child.session_id, observations: [previous, child] });
161
+ if (!previous || rank[child.status] > rank[previous.status])
131
162
  unique.set(child.session_id, child);
132
163
  }
133
164
  return [...unique.values()].sort((left, right) => left.session_id.localeCompare(right.session_id));