@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.
- package/CHANGELOG.md +52 -0
- package/README.md +31 -4
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +15 -63
- package/dist/cli/run-cli.js +55 -26
- package/dist/domain/stage-catalog.js +2 -2
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/agent-profile.schema.json +17 -0
- package/dist/schemas/code-review-decision.schema.json +5 -3
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +3 -2
- package/dist/schemas/merge-result.schema.json +15 -0
- package/dist/schemas/protocol-plan.schema.json +1 -1
- package/dist/schemas/stage-start-response.schema.json +4 -2
- package/dist/schemas/status-report.schema.json +76 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +4 -4
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/code-checks.js +125 -208
- package/dist/services/eval-snapshots.js +10 -1
- package/dist/services/harness-adapter.js +59 -0
- package/dist/services/hooks.js +87 -237
- package/dist/services/ids.js +18 -1
- package/dist/services/lifecycle-command.js +288 -0
- package/dist/services/merge-server.js +124 -0
- package/dist/services/prompts.js +16 -10
- package/dist/services/runs.js +7 -2
- package/dist/services/sessions.js +18 -27
- package/dist/services/stage-pause.js +13 -0
- package/dist/services/vnext-code-review.js +71 -20
- package/dist/services/vnext-code.js +131 -34
- package/dist/services/vnext-execution-profile.js +6 -3
- package/dist/services/vnext-merge.js +330 -0
- package/dist/services/vnext-plan-review.js +2 -2
- package/dist/services/vnext-plan.js +22 -38
- package/dist/services/vnext-specify.js +5 -2
- package/dist/services/vnext-workspace-policy.js +8 -2
- package/dist/services/work-registry.js +85 -34
- package/dist/storage/database.js +66 -0
- 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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
354
|
+
function plannedAreaDrift(packet, result) {
|
|
302
355
|
const changed = JSON.parse(result).changed_paths;
|
|
303
|
-
if (!Array.isArray(changed))
|
|
304
|
-
return;
|
|
305
|
-
|
|
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 =
|
|
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
|
|
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>", "", "<
|
|
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>", "", "<
|
|
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
|
|
439
|
-
if (
|
|
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 =
|
|
493
|
+
const overlap = overlappingAreas(areas, workPlannedWriteAreas(running));
|
|
443
494
|
if (overlap.length)
|
|
444
|
-
blockers.push({ kind: "
|
|
495
|
+
blockers.push({ kind: "planned_write_area", work_id: running.work_id, paths: overlap });
|
|
445
496
|
}
|
|
446
497
|
return blockers;
|
|
447
498
|
}
|
|
448
|
-
function
|
|
449
|
-
function
|
|
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@
|
|
456
|
-
throw new AppError("validation", "CODE batch requires code-work-packet@
|
|
457
|
-
if (!Array.isArray(item[key]) || (key
|
|
458
|
-
throw new AppError("validation", `CODE work item requires ${key
|
|
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@
|
|
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))
|
package/dist/storage/database.js
CHANGED
|
@@ -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.
|
|
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
|
},
|