@deksden-com/dd-flow-cli 0.8.0-beta.135 → 0.9.0-beta.0

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 (40) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +31 -4
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +15 -63
  5. package/dist/cli/run-cli.js +55 -26
  6. package/dist/domain/stage-catalog.js +2 -2
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/agent-profile.schema.json +17 -0
  9. package/dist/schemas/code-review-decision.schema.json +5 -3
  10. package/dist/schemas/code-work-batch.schema.json +3 -3
  11. package/dist/schemas/compatibility.schema.json +32 -0
  12. package/dist/schemas/flow-contract.schema.json +3 -2
  13. package/dist/schemas/merge-result.schema.json +15 -0
  14. package/dist/schemas/protocol-plan.schema.json +1 -1
  15. package/dist/schemas/stage-start-response.schema.json +4 -2
  16. package/dist/schemas/status-report.schema.json +76 -0
  17. package/dist/schemas/vnext-protocol-plan.schema.json +4 -4
  18. package/dist/services/cli-operation-classifier.js +1 -1
  19. package/dist/services/code-checks.js +125 -208
  20. package/dist/services/eval-snapshots.js +10 -1
  21. package/dist/services/harness-adapter.js +59 -0
  22. package/dist/services/hooks.js +87 -237
  23. package/dist/services/ids.js +18 -1
  24. package/dist/services/lifecycle-command.js +288 -0
  25. package/dist/services/merge-server.js +124 -0
  26. package/dist/services/prompts.js +16 -10
  27. package/dist/services/runs.js +7 -2
  28. package/dist/services/sessions.js +18 -27
  29. package/dist/services/stage-pause.js +13 -0
  30. package/dist/services/vnext-code-review.js +71 -20
  31. package/dist/services/vnext-code.js +131 -34
  32. package/dist/services/vnext-execution-profile.js +6 -3
  33. package/dist/services/vnext-merge.js +330 -0
  34. package/dist/services/vnext-plan-review.js +2 -2
  35. package/dist/services/vnext-plan.js +22 -38
  36. package/dist/services/vnext-specify.js +5 -2
  37. package/dist/services/vnext-workspace-policy.js +8 -2
  38. package/dist/services/work-registry.js +85 -34
  39. package/dist/storage/database.js +66 -0
  40. package/package.json +2 -1
@@ -17,6 +17,7 @@ import { writeStageReport } from "./stage-report-renderer.js";
17
17
  const flowId = "mb-sdlc-vnext-specify";
18
18
  const protocolizeFlowId = "mb-sdlc-vnext-protocolize";
19
19
  const flowVersion = 4;
20
+ const protocolizeFlowVersion = 5;
20
21
  const stageId = "specify";
21
22
  const entryId = "default";
22
23
  export function isVnextSpecifyFlow(projectRoot) {
@@ -553,8 +554,10 @@ export function readVnextFlowDefinition(projectRoot) {
553
554
  ? definition.stages.specify?.entries?.default
554
555
  : undefined;
555
556
  const ordered = Array.isArray(actions?.actions) ? actions.actions : [];
556
- if ((definition.id === flowId || definition.id === protocolizeFlowId) && definition.version === flowVersion && ordered.length === 3) {
557
- return { id: definition.id };
557
+ const definitionId = typeof definition.id === "string" ? definition.id : null;
558
+ const supportedVersion = definitionId === flowId ? flowVersion : definitionId === protocolizeFlowId ? protocolizeFlowVersion : null;
559
+ if (definitionId && supportedVersion !== null && definition.version === supportedVersion && ordered.length === 3) {
560
+ return { id: definitionId };
558
561
  }
559
562
  }
560
563
  return null;
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { AppError } from "../shared/errors.js";
4
5
  import { gitFacts } from "./runs.js";
5
6
  const configRelativePath = path.join(".memory-bank", "dd-flow", "project-workspace.json");
@@ -74,13 +75,18 @@ export function requireVnextWorkspaceRoute(input) {
74
75
  throw new AppError("workspace_route_missing", `${input.stage} requires a provisioned feature worktree`, 1, { run_id: input.runId, workspace_root: workspaceRoot, policy });
75
76
  }
76
77
  const facts = gitFacts(workspaceRoot);
77
- if (facts.status === "unavailable" || facts.branch !== policy.feature_branch || facts.head !== policy.base_ref) {
78
- throw new AppError("workspace_route_invalid", `${input.stage} workspace no longer matches its frozen feature branch and base`, 1, { run_id: input.runId, workspace_root: workspaceRoot, expected: policy, actual: facts });
78
+ const ancestry = facts.head ? gitAncestry(workspaceRoot, policy.base_ref, facts.head) : { ok: false, status: null, error: "missing HEAD" };
79
+ if (facts.status === "unavailable" || facts.branch !== policy.feature_branch || !ancestry.ok) {
80
+ throw new AppError("workspace_route_invalid", `${input.stage} workspace no longer matches its frozen feature branch and base`, 1, { run_id: input.runId, workspace_root: workspaceRoot, expected: policy, actual: facts, ancestry });
79
81
  }
80
82
  }
81
83
  return policy;
82
84
  }
83
85
  function realPath(value) { return fs.existsSync(value) ? fs.realpathSync(value) : path.resolve(value); }
86
+ function gitAncestry(workspaceRoot, ancestor, descendant) {
87
+ const result = spawnSync("git", ["-C", workspaceRoot, "merge-base", "--is-ancestor", ancestor, descendant], { encoding: "utf8" });
88
+ return { ok: result.status === 0, status: result.status, error: result.error ? String(result.error) : null };
89
+ }
84
90
  function safeSlug(value) {
85
91
  const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
86
92
  return slug || "work";
@@ -6,11 +6,22 @@ import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHo
6
6
  import { validateSchema } from "./schema-validation.js";
7
7
  import { resolveProjectRoot, resolveRunReferences, writeJsonAtomic } from "../storage/paths.js";
8
8
  import { refreshRunSessionProjection } from "./run-projection.js";
9
- import { runCodeChecks } from "./code-checks.js";
10
- import { nextWorkId } from "./ids.js";
9
+ import { readCodeCheckProfile, runCodeChecks } from "./code-checks.js";
10
+ import { nextWorkId, nextWorkIds } from "./ids.js";
11
11
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
+ import { flowCommand } from "./stage-pause.js";
12
13
  const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
13
14
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
15
+ export function createChildWork(context, input) {
16
+ const parent = requireWork(context, input.parentWorkId);
17
+ if (parent.status !== "running")
18
+ throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
19
+ const id = nextWorkId(context, parent.project_id, input.slug);
20
+ const now = context.now();
21
+ context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
22
+ refreshRunWorkProjection(context, parent.project_id, parent.run_id);
23
+ return requireWork(context, id);
24
+ }
14
25
  /** Validate a proposed batch before PLAN accepts it, without registering Work. */
15
26
  export function validateWorkBatchFile(file) {
16
27
  const parsed = readJson(file);
@@ -54,7 +65,8 @@ export function addWorkBatch(context, input) {
54
65
  const now = context.now();
55
66
  context.db.exec("BEGIN IMMEDIATE");
56
67
  try {
57
- const ids = new Map(items.map((item) => [item.key, nextWorkId(context, parent.project_id, item.key)]));
68
+ const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
69
+ const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
58
70
  const resolve = (value) => ids.get(value) ?? value;
59
71
  const proposed = items.map((item) => ({ ...item, id: ids.get(item.key), parentId: item.parent ? resolve(item.parent) : parent.work_id, dependencies: (item.depends_on ?? []).map(resolve) }));
60
72
  assertNoCycles(proposed.map((item) => ({ id: item.id, dependencies: item.dependencies })));
@@ -128,7 +140,13 @@ export function mutateWorkDeps(context, input) {
128
140
  export function deleteWork(context, id) { const work = requireWork(context, id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
129
141
  throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
130
142
  export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
131
- export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
143
+ /**
144
+ * Return the engine-bound command, never a PATH-dependent `dd-flow` token.
145
+ * Fan-out workers run in independently spawned harnesses where the adapter's
146
+ * PATH is not inherited; using the shared lifecycle command keeps them on the
147
+ * same captured runtime as their parent stage.
148
+ */
149
+ export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
132
150
  export function startWork(context, id, input) {
133
151
  ensureWorkRegistry(context);
134
152
  const work = requireWork(context, id);
@@ -137,7 +155,7 @@ export function startWork(context, id, input) {
137
155
  if (!isReady(context, work)) {
138
156
  const blockers = readinessBlockers(context, work);
139
157
  const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
140
- throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping write scopes are free", 2, { work_id: id, blockers, ready });
158
+ throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping planned coordination areas are free", 2, { work_id: id, blockers, ready });
141
159
  }
142
160
  const run = requireRun(context, work.project_id, work.run_id);
143
161
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
@@ -165,8 +183,31 @@ export function bindRunningWorkSession(context, input) {
165
183
  const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
166
184
  const active = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
167
185
  if (active) {
168
- if (active.session_id !== identity.sessionId)
169
- throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
186
+ if (active.session_id !== identity.sessionId) {
187
+ // A frozen `new_session` policy may transfer the same coordinator Work
188
+ // only to its next stage. Repeating the same stage from another Session
189
+ // remains a conflict because its result path is unchanged.
190
+ if (!input.allowStageHandoff || path.resolve(active.result_path ?? "") === path.resolve(input.resultPath ?? ""))
191
+ throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
192
+ const now = context.now();
193
+ const id = `WS-${crypto.randomUUID()}`;
194
+ context.db.exec("BEGIN IMMEDIATE");
195
+ try {
196
+ context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
197
+ const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
198
+ if (!stillRunning)
199
+ context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, active.session_id]);
200
+ bindSession(context, work, run, identity, now);
201
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
202
+ context.db.exec("COMMIT");
203
+ }
204
+ catch (error) {
205
+ context.db.exec("ROLLBACK");
206
+ throw error;
207
+ }
208
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
209
+ return { work_session_id: id, session_id: identity.sessionId, handed_off: true };
210
+ }
170
211
  context.db.run("UPDATE work_sessions SET prompt_path = ?, result_path = COALESCE(?, result_path), updated_at = ? WHERE id = ?", [input.promptPath, input.resultPath ?? null, context.now(), active.id]);
171
212
  return { work_session_id: active.id, session_id: identity.sessionId, reused: true };
172
213
  }
@@ -190,7 +231,8 @@ export function bindStageCoordinatorWork(context, input) {
190
231
  const work = requireWork(context, input.workId);
191
232
  const run = requireRun(context, work.project_id, work.run_id);
192
233
  claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
193
- const binding = bindRunningWorkSession(context, input);
234
+ const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
235
+ const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: snapshot.execution_profile?.settings?.stage_session_mode === "new_session" });
194
236
  context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, binding.session_id]);
195
237
  refreshRunSessionProjection(context, work.project_id, work.run_id);
196
238
  return binding;
@@ -203,7 +245,7 @@ export function startStageCoordinatorWork(context, input) {
203
245
  const run = requireRun(context, work.project_id, work.run_id);
204
246
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
205
247
  throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
206
- const identity = claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root });
248
+ const identity = claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
207
249
  const binding = startBoundWork(context, work, run, identity, input.hookEventId);
208
250
  const sessionId = String(binding.session_binding.session_id ?? "");
209
251
  context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
@@ -223,6 +265,8 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
223
265
  context.db.exec("BEGIN IMMEDIATE");
224
266
  try {
225
267
  bindSession(context, work, run, identity, now);
268
+ if (work.parent_work_id && work.launch_policy === "reuse_allowed")
269
+ context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, work.work_id]);
226
270
  const claimed = context.db.run("UPDATE works SET status = 'running', started_at = ?, updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, work.work_id]);
227
271
  if (claimed.changes !== 1)
228
272
  throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
@@ -241,6 +285,10 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
241
285
  return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session_id: identity.sessionId } };
242
286
  }
243
287
  export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
288
+ /** Structured fan-in closes a parent after its Session was handed to a child. */
289
+ export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
290
+ throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running') LIMIT 1", [work.work_id]))
291
+ throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
244
292
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
245
293
  export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
246
294
  export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
@@ -261,14 +309,19 @@ async function settle(context, id, status, result, progress) {
261
309
  if (work.status === "running" && !link)
262
310
  throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: id });
263
311
  let receipts = [];
312
+ let coordinationDrift = [];
264
313
  if (status === "completed") {
265
314
  validateWorkResult(work, result, run.project_root, run.workspace_root, run.id, link?.result_path ?? null);
266
315
  const packet = codePacket(work);
267
316
  if (packet) {
268
- validateChangedPaths(packet, result);
317
+ // CODE packets are projected from a PLAN that already validated this
318
+ // profile. Check it even when the Work has only raw focused checks, so a
319
+ // worker cannot complete after downgrading the frozen project contract.
320
+ readCodeCheckProfile(run.workspace_root);
321
+ coordinationDrift = plannedAreaDrift(packet, result);
269
322
  const artifactDir = path.relative(requireRunHome(run), path.dirname(link.result_path));
270
323
  receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: packet.checks.filter((check) => check.run_at === "work"), ...(progress ? { progress } : {}) });
271
- const failed = receipts.filter((receipt) => receipt.status === "failed");
324
+ const failed = receipts.filter((receipt) => receipt.status !== "passed");
272
325
  if (failed.length)
273
326
  throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
274
327
  }
@@ -293,18 +346,16 @@ async function settle(context, id, status, result, progress) {
293
346
  }
294
347
  refreshRunWorkProjection(context, work.project_id, work.run_id);
295
348
  const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
296
- appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null });
349
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
297
350
  for (const ready of newlyReady)
298
351
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
299
- return { ok: true, work_id: id, status, checks: receipts, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
352
+ return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
300
353
  }
301
- function validateChangedPaths(packet, result) {
354
+ function plannedAreaDrift(packet, result) {
302
355
  const changed = JSON.parse(result).changed_paths;
303
- if (!Array.isArray(changed))
304
- return;
305
- const outside = changed.filter((item) => typeof item === "string" && !packet.write_scope.some((scope) => item === scope || item.startsWith(`${scope}/`)));
306
- if (outside.length)
307
- throw new AppError("write_scope_violation", "CODE Work result declares paths outside its accepted write scope", 2, { outside, write_scope: packet.write_scope });
356
+ if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
357
+ return [];
358
+ return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) => item === area || item.startsWith(`${area}/`)));
308
359
  }
309
360
  function bindSession(context, work, run, identity, now) {
310
361
  const priorWorkSession = context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.work_id])?.session_id ?? null;
@@ -370,12 +421,12 @@ function validateCodeWorkResult(work, value, projectRoot) {
370
421
  }
371
422
  }
372
423
  function renderWorkerPrompt(context, work, run, dependencies, resultPath) {
373
- const command = `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
424
+ const command = flowCommand(context);
374
425
  const packet = codePacket(work);
375
- const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete only this Work's semantic_spine.acceptance_contribution, task, write_scope, and declared checks. A criterion may name another ordered Work's UI, documentation, or evidence surface; do not fail merely because that surface is outside this Work's write_scope.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<write_scope>", ...packet.write_scope.map((item) => `- ${item}`), "</write_scope>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
426
+ const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
376
427
  if (packet)
377
- codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<contract_failure>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. If a required path is missing from write_scope, do not report success: fail this Work with that exact path and the retained receipt so the coordinator can correct the packet.", "</contract_failure>", "");
378
- return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<workspace_contract>", `All project reads and writes belong under ${run.workspace_root}. Project root is only the stable lifecycle identity for dd-flow commands. Do not create, switch, merge or delete branches/worktrees.`, "</workspace_contract>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work), `Write it to ${resultPath}.`, "</result_contract>", ""] : []), "<completion>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", "Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output.", "Use Fail only for a concrete external blocker after deterministic bootstrap, or for a contractual scope conflict where a required repair path lies outside this Work's write_scope. Quote the exact receipt plus the error or missing path. A project check failure inside this Work's scope must be fixed before Finish.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command: ${command} work finish ${work.work_id} --result-file ${JSON.stringify(resultPath)} --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or scope-contract case: ${command} work fail ${work.work_id} --reason "receipt path + exact error or missing write_scope path" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
428
+ codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
429
+ return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", `HARD RULE: all project reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not.", "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work), `Write it to ${resultPath}.`, "</result_contract>", ""] : []), "<completion>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", "Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output.", "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command: ${command} work finish ${work.work_id} --result-file ${JSON.stringify(resultPath)} --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
379
430
  }
380
431
  function resultSchemaGuidance(work) {
381
432
  const schema = work.result_schema;
@@ -435,27 +486,27 @@ catch {
435
486
  function isReady(context, work) { return work.status === "created" && readinessBlockers(context, work).length === 0; }
436
487
  function readinessBlockers(context, work) {
437
488
  const blockers = parseDependencies(work).flatMap((id) => { const dependency = context.db.get("SELECT status FROM works WHERE work_id = ?", [id]); return dependency?.status === "completed" ? [] : [{ kind: "dependency", work_id: id, status: dependency?.status ?? "missing" }]; });
438
- const scope = workWriteScope(work);
439
- if (scope.length === 0)
489
+ const areas = workPlannedWriteAreas(work);
490
+ if (areas.length === 0)
440
491
  return blockers;
441
492
  for (const running of context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' AND work_id != ?`, [work.project_id, work.run_id, work.work_id])) {
442
- const overlap = overlappingScopes(scope, workWriteScope(running));
493
+ const overlap = overlappingAreas(areas, workPlannedWriteAreas(running));
443
494
  if (overlap.length)
444
- blockers.push({ kind: "write_scope", work_id: running.work_id, paths: overlap });
495
+ blockers.push({ kind: "planned_write_area", work_id: running.work_id, paths: overlap });
445
496
  }
446
497
  return blockers;
447
498
  }
448
- function workWriteScope(work) { const payload = parsePayload(work); return Array.isArray(payload?.write_scope) ? payload.write_scope.filter((item) => typeof item === "string").map((item) => item.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "")) : []; }
449
- function overlappingScopes(left, right) { const overlap = new Set(); for (const a of left)
499
+ function workPlannedWriteAreas(work) { const payload = parsePayload(work); return Array.isArray(payload?.planned_write_areas) ? payload.planned_write_areas.filter((item) => typeof item === "string").map((item) => item.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "")) : []; }
500
+ function overlappingAreas(left, right) { const overlap = new Set(); for (const a of left)
450
501
  for (const b of right)
451
502
  if (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`))
452
503
  overlap.add(a.length <= b.length ? a : b); return [...overlap]; }
453
504
  function validateItem(value, requireExecutionContext = false) { if (!value || typeof value !== "object" || Array.isArray(value))
454
505
  throw new AppError("validation", "work item must be an object", 2); const item = value; if (typeof item.key !== "string" || !item.key || typeof item.task !== "string" || !item.task.trim())
455
- throw new AppError("validation", "work item requires key and task", 2); const code = item.schema_id === "dd-flow/code-work-packet@4"; if (requireExecutionContext && !code)
456
- throw new AppError("validation", "CODE batch requires code-work-packet@4 items", 2, { key: item.key }); for (const key of code ? ["required_read", "discovery_boundary", "write_scope", "checks", "provides_checks", "stop_conditions"] : [])
457
- if (!Array.isArray(item[key]) || (key !== "provides_checks" && item[key].length === 0))
458
- throw new AppError("validation", `CODE work item requires ${key === "provides_checks" ? "an array" : `non-empty ${key}`}`, 2, { key: item.key }); if (item.depends_on !== undefined && (!Array.isArray(item.depends_on) || !item.depends_on.every((entry) => typeof entry === "string")))
506
+ throw new AppError("validation", "work item requires key and task", 2); const code = item.schema_id === "dd-flow/code-work-packet@5"; if (requireExecutionContext && !code)
507
+ throw new AppError("validation", "CODE batch requires code-work-packet@5 items", 2, { key: item.key }); for (const key of code ? ["required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions"] : [])
508
+ if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas'].includes(key) && item[key].length === 0))
509
+ throw new AppError("validation", `CODE work item requires ${['provides_checks', 'planned_write_areas'].includes(key) ? "an array" : `non-empty ${key}`}`, 2, { key: item.key }); if (item.depends_on !== undefined && (!Array.isArray(item.depends_on) || !item.depends_on.every((entry) => typeof entry === "string")))
459
510
  throw new AppError("validation", "depends_on must be a string array", 2); if (item.parent !== undefined && typeof item.parent !== "string")
460
511
  throw new AppError("validation", "parent must be a string", 2); if (item.launch_policy !== undefined && item.launch_policy !== "reuse_allowed" && item.launch_policy !== "fresh_agent_required")
461
512
  throw new AppError("validation", "launch_policy must be reuse_allowed or fresh_agent_required", 2); if (item.result_schema !== undefined && (typeof item.result_schema !== "string" || !item.result_schema))
@@ -480,7 +531,7 @@ function parsePayload(work) { if (!work.payload_json)
480
531
  catch {
481
532
  return null;
482
533
  } }
483
- function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@4")
534
+ function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@5")
484
535
  return null; return value; }
485
536
  function assertNoCycles(nodes) { const local = new Map(nodes.map((node) => [node.id, node.dependencies.filter((dependency) => nodes.some((candidate) => candidate.id === dependency))])); const active = new Set(); const done = new Set(); const visit = (id) => { if (active.has(id))
486
537
  throw new AppError("validation", "Work dependencies contain a cycle", 2); if (done.has(id))
@@ -58,13 +58,21 @@ export function ensureVnextWorkStorage(db) {
58
58
  work_id TEXT,
59
59
  scope TEXT NOT NULL,
60
60
  declaration_id TEXT NOT NULL,
61
+ check_refs_json TEXT NOT NULL DEFAULT '[]',
62
+ gate TEXT NOT NULL DEFAULT 'work',
61
63
  command TEXT NOT NULL,
64
+ input_hash TEXT NOT NULL DEFAULT '',
65
+ verification_epoch TEXT NOT NULL DEFAULT '',
62
66
  status TEXT NOT NULL,
63
67
  exit_code INTEGER,
64
68
  stdout_path TEXT NOT NULL,
65
69
  stderr_path TEXT NOT NULL,
66
70
  receipt_path TEXT NOT NULL,
67
71
  workspace_fingerprint TEXT NOT NULL,
72
+ before_fingerprint TEXT NOT NULL DEFAULT '',
73
+ after_fingerprint TEXT NOT NULL DEFAULT '',
74
+ profile_hash TEXT,
75
+ mutation_paths_json TEXT NOT NULL DEFAULT '[]',
68
76
  artifacts_json TEXT NOT NULL,
69
77
  started_at TEXT NOT NULL,
70
78
  finished_at TEXT NOT NULL,
@@ -474,6 +482,56 @@ function migrate(db) {
474
482
  CREATE INDEX IF NOT EXISTS idx_check_receipts_run
475
483
  ON check_receipts(project_id, run_id, started_at);
476
484
 
485
+ CREATE TABLE IF NOT EXISTS merge_requests (
486
+ merge_request_id TEXT PRIMARY KEY,
487
+ project_id TEXT NOT NULL,
488
+ run_id TEXT NOT NULL,
489
+ executor_work_id TEXT NOT NULL,
490
+ protocol_ids_json TEXT NOT NULL,
491
+ source_workspace TEXT NOT NULL,
492
+ source_branch TEXT NOT NULL,
493
+ source_commit TEXT NOT NULL,
494
+ target_workspace TEXT NOT NULL,
495
+ target_branch TEXT NOT NULL,
496
+ enqueue_target_head TEXT,
497
+ execution_target_head TEXT,
498
+ integration_commit TEXT,
499
+ execution_route TEXT NOT NULL,
500
+ status TEXT NOT NULL,
501
+ dispatch_owner TEXT,
502
+ dispatch_lease_token TEXT,
503
+ dispatch_lease_expires_at TEXT,
504
+ lock_acquired_at TEXT,
505
+ checkpoint TEXT NOT NULL DEFAULT 'queued',
506
+ profile_hash TEXT,
507
+ adapter_receipt_json TEXT,
508
+ result_json TEXT,
509
+ last_error_json TEXT,
510
+ created_at TEXT NOT NULL,
511
+ updated_at TEXT NOT NULL,
512
+ completed_at TEXT,
513
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id),
514
+ FOREIGN KEY(executor_work_id) REFERENCES works(work_id)
515
+ );
516
+ CREATE INDEX IF NOT EXISTS idx_merge_requests_fifo
517
+ ON merge_requests(project_id, status, created_at, merge_request_id);
518
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_run
519
+ ON merge_requests(project_id, run_id);
520
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_active_project
521
+ ON merge_requests(project_id)
522
+ WHERE status IN ('active', 'waiting_user', 'action_required', 'recovery_required');
523
+
524
+ CREATE TABLE IF NOT EXISTS merge_servers (
525
+ server_id TEXT PRIMARY KEY,
526
+ profile_id TEXT NOT NULL,
527
+ status TEXT NOT NULL,
528
+ pid INTEGER,
529
+ started_at TEXT NOT NULL,
530
+ heartbeat_at TEXT NOT NULL,
531
+ stopped_at TEXT,
532
+ journal_path TEXT NOT NULL
533
+ );
534
+
477
535
  CREATE TABLE IF NOT EXISTS flow_run_flag_mutations (
478
536
  project_id TEXT NOT NULL,
479
537
  run_id TEXT NOT NULL,
@@ -685,6 +743,14 @@ function migrate(db) {
685
743
  ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
686
744
  ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
687
745
  ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
746
+ ensureColumn(db, "check_receipts", "check_refs_json", "ALTER TABLE check_receipts ADD COLUMN check_refs_json TEXT NOT NULL DEFAULT '[]'");
747
+ ensureColumn(db, "check_receipts", "gate", "ALTER TABLE check_receipts ADD COLUMN gate TEXT NOT NULL DEFAULT 'work'");
748
+ ensureColumn(db, "check_receipts", "input_hash", "ALTER TABLE check_receipts ADD COLUMN input_hash TEXT NOT NULL DEFAULT ''");
749
+ ensureColumn(db, "check_receipts", "verification_epoch", "ALTER TABLE check_receipts ADD COLUMN verification_epoch TEXT NOT NULL DEFAULT ''");
750
+ ensureColumn(db, "check_receipts", "before_fingerprint", "ALTER TABLE check_receipts ADD COLUMN before_fingerprint TEXT NOT NULL DEFAULT ''");
751
+ ensureColumn(db, "check_receipts", "after_fingerprint", "ALTER TABLE check_receipts ADD COLUMN after_fingerprint TEXT NOT NULL DEFAULT ''");
752
+ ensureColumn(db, "check_receipts", "profile_hash", "ALTER TABLE check_receipts ADD COLUMN profile_hash TEXT");
753
+ ensureColumn(db, "check_receipts", "mutation_paths_json", "ALTER TABLE check_receipts ADD COLUMN mutation_paths_json TEXT NOT NULL DEFAULT '[]'");
688
754
  ensureColumn(db, "hook_events", "event_key", "ALTER TABLE hook_events ADD COLUMN event_key TEXT");
689
755
  ensureColumn(db, "hook_events", "match_key", "ALTER TABLE hook_events ADD COLUMN match_key TEXT");
690
756
  ensureColumn(db, "hook_events", "claimed_at", "ALTER TABLE hook_events ADD COLUMN claimed_at TEXT");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.8.0-beta.135",
3
+ "version": "0.9.0-beta.0",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "ajv": "^8.20.0",
30
+ "shell-quote": "^1.10.0",
30
31
  "smol-toml": "^1.6.1",
31
32
  "yaml": "^2.9.0"
32
33
  },