@deksden-com/dd-flow-cli 0.5.0 → 0.7.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 +12 -0
- package/README.md +10 -3
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +24 -11
- package/dist/cli/run-cli.js +117 -28
- package/dist/domain/flow-contract.js +15 -4
- package/dist/domain/session-coverage.js +88 -0
- package/dist/runtime/context.js +8 -2
- package/dist/schemas/code-stage-report.schema.json +7 -2
- package/dist/schemas/engine-manifest.schema.json +22 -0
- package/dist/schemas/flow-contract.schema.json +15 -13
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
- package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
- package/dist/schemas/run-engine-binding.schema.json +37 -0
- package/dist/schemas/stage-prompt.schema.json +9 -5
- package/dist/schemas/stage-start-response.schema.json +6 -5
- package/dist/services/canon.js +15 -1
- package/dist/services/cleanup.js +77 -0
- package/dist/services/cli-operation-classifier.js +52 -8
- package/dist/services/compatibility-preflight.js +1 -1
- package/dist/services/dashboard.js +2 -2
- package/dist/services/engines.js +408 -30
- package/dist/services/hooks.js +28 -15
- package/dist/services/lanes.js +0 -4
- package/dist/services/merge-queue.js +48 -0
- package/dist/services/merge-worker.js +3 -4
- package/dist/services/migrations.js +307 -44
- package/dist/services/plan-runtime.js +4 -4
- package/dist/services/plans.js +5 -3
- package/dist/services/protocols.js +23 -2
- package/dist/services/run-engine-bindings.js +157 -0
- package/dist/services/run-projection.js +18 -4
- package/dist/services/runs.js +54 -7
- package/dist/services/schema-validation.js +96 -0
- package/dist/services/sessions.js +33 -73
- package/dist/services/stage-lifecycle.js +298 -45
- package/dist/services/status.js +8 -3
- package/dist/storage/database.js +32 -11
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ import { appendAudit } from "./audit.js";
|
|
|
8
8
|
import { cancelLaneWaitersForWorker } from "./lanes.js";
|
|
9
9
|
import { checkpointSessionUsage } from "./usage.js";
|
|
10
10
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
11
|
+
import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
|
|
11
12
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
13
|
export function recordFlowSessionObservation(context, input) {
|
|
13
14
|
const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
@@ -99,68 +100,7 @@ export function getFlowSessionStatus(context, input) {
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
export function reconcileSessionCoverage(sessions) {
|
|
102
|
-
|
|
103
|
-
const duplicateSessionIds = [...sessionCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
|
|
104
|
-
const invalidSessionIds = [];
|
|
105
|
-
const parsed = sessions.map((session) => {
|
|
106
|
-
try {
|
|
107
|
-
return { session, units: normalizeCoverageUnits(JSON.parse(session.coverage_units_json || "[]")) };
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
invalidSessionIds.push(session.session_id);
|
|
111
|
-
return { session, units: [] };
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
const orchestrators = parsed.filter(({ session }) => session.session_kind === "orchestrator");
|
|
115
|
-
const expectedCounts = countValues(orchestrators.flatMap(({ units }) => units.map((unit) => unit.unit_id)));
|
|
116
|
-
const expectedUnitIds = [...expectedCounts.keys()].sort();
|
|
117
|
-
const duplicateExpectedUnitIds = [...expectedCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
|
|
118
|
-
const observedOwners = new Map();
|
|
119
|
-
for (const { session, units } of parsed) {
|
|
120
|
-
if (session.session_kind === "orchestrator")
|
|
121
|
-
continue;
|
|
122
|
-
for (const unit of units) {
|
|
123
|
-
const owners = observedOwners.get(unit.unit_id) ?? [];
|
|
124
|
-
owners.push(session.session_id);
|
|
125
|
-
observedOwners.set(unit.unit_id, owners);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const observedUnitIds = [...observedOwners.keys()].sort();
|
|
129
|
-
const missingUnitIds = expectedUnitIds.filter((unitId) => !observedOwners.has(unitId));
|
|
130
|
-
const duplicateUnitIds = [...observedOwners.entries()].filter(([, owners]) => new Set(owners).size > 1).map(([id]) => id).sort();
|
|
131
|
-
const diagnostics = [];
|
|
132
|
-
if (orchestrators.length === 0)
|
|
133
|
-
diagnostics.push("orchestrator_session_missing");
|
|
134
|
-
if (orchestrators.length > 1)
|
|
135
|
-
diagnostics.push("multiple_orchestrator_sessions");
|
|
136
|
-
if (expectedUnitIds.length === 0)
|
|
137
|
-
diagnostics.push("expected_worker_units_missing");
|
|
138
|
-
if (missingUnitIds.length > 0)
|
|
139
|
-
diagnostics.push("expected_worker_units_unobserved");
|
|
140
|
-
if (duplicateUnitIds.length > 0 || duplicateExpectedUnitIds.length > 0)
|
|
141
|
-
diagnostics.push("duplicate_coverage_binding");
|
|
142
|
-
if (duplicateSessionIds.length > 0)
|
|
143
|
-
diagnostics.push("duplicate_session_binding");
|
|
144
|
-
if (invalidSessionIds.length > 0)
|
|
145
|
-
diagnostics.push("invalid_coverage_units");
|
|
146
|
-
const unavailable = orchestrators.length === 0 || expectedUnitIds.length === 0 || invalidSessionIds.length > 0;
|
|
147
|
-
const partial = orchestrators.length !== 1
|
|
148
|
-
|| missingUnitIds.length > 0
|
|
149
|
-
|| duplicateUnitIds.length > 0
|
|
150
|
-
|| duplicateExpectedUnitIds.length > 0
|
|
151
|
-
|| duplicateSessionIds.length > 0;
|
|
152
|
-
return {
|
|
153
|
-
status: unavailable ? "unavailable" : partial ? "partial" : "complete",
|
|
154
|
-
expected_unit_ids: expectedUnitIds,
|
|
155
|
-
observed_unit_ids: observedUnitIds,
|
|
156
|
-
missing_unit_ids: missingUnitIds,
|
|
157
|
-
duplicate_unit_ids: duplicateUnitIds,
|
|
158
|
-
duplicate_expected_unit_ids: duplicateExpectedUnitIds,
|
|
159
|
-
duplicate_session_ids: duplicateSessionIds,
|
|
160
|
-
orchestrator_session_ids: orchestrators.map(({ session }) => session.session_id).sort(),
|
|
161
|
-
invalid_session_ids: [...new Set(invalidSessionIds)].sort(),
|
|
162
|
-
diagnostics
|
|
163
|
-
};
|
|
103
|
+
return reconcileSessionCoverageRows(sessions);
|
|
164
104
|
}
|
|
165
105
|
export function stopFlowSession(context, input) {
|
|
166
106
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -245,9 +185,10 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
|
|
|
245
185
|
return nextCount;
|
|
246
186
|
}
|
|
247
187
|
export function flowSessionPayloadFromRegisterCommand(command) {
|
|
248
|
-
if (
|
|
188
|
+
if (/\bdd-flow\s+stage\s+start\b/.test(command))
|
|
189
|
+
return flowSessionPayloadFromStageStartCommand(command);
|
|
190
|
+
if (!/\bdd-flow\s+session\s+register\b/.test(command))
|
|
249
191
|
return undefined;
|
|
250
|
-
}
|
|
251
192
|
const payloadBase64 = optionFromCommand(command, "payload-base64");
|
|
252
193
|
const payloadJson = optionFromCommand(command, "payload-json");
|
|
253
194
|
const payloadFile = optionFromCommand(command, "payload-file");
|
|
@@ -256,6 +197,25 @@ export function flowSessionPayloadFromRegisterCommand(command) {
|
|
|
256
197
|
}
|
|
257
198
|
return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
|
|
258
199
|
}
|
|
200
|
+
function flowSessionPayloadFromStageStartCommand(command) {
|
|
201
|
+
const projectRoot = optionFromCommand(command, "project-root");
|
|
202
|
+
const stage = optionFromCommand(command, "stage");
|
|
203
|
+
const run = command.match(/\bdd-flow\s+stage\s+start\s+([^\s]+)/)?.[1];
|
|
204
|
+
if (!projectRoot || !stage || !run || run.startsWith("--"))
|
|
205
|
+
return undefined;
|
|
206
|
+
return {
|
|
207
|
+
project_root: projectRoot,
|
|
208
|
+
flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
|
|
209
|
+
run_id: run,
|
|
210
|
+
protocol_id: null,
|
|
211
|
+
worker_id: null,
|
|
212
|
+
workspace_path: null,
|
|
213
|
+
continuation_policy: "go_router",
|
|
214
|
+
session_kind: "orchestrator",
|
|
215
|
+
current_stage: stage,
|
|
216
|
+
coverage_units: []
|
|
217
|
+
};
|
|
218
|
+
}
|
|
259
219
|
function decodeFlowSessionPayload(input) {
|
|
260
220
|
if (input.payloadFile) {
|
|
261
221
|
return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
|
|
@@ -485,9 +445,8 @@ function sessionKind(payload) {
|
|
|
485
445
|
function normalizeCoverageUnits(value) {
|
|
486
446
|
if (value === undefined || value === null)
|
|
487
447
|
return [];
|
|
488
|
-
if (!Array.isArray(value))
|
|
448
|
+
if (!Array.isArray(value))
|
|
489
449
|
throw new AppError("validation", "coverage_units must be an array", 2);
|
|
490
|
-
}
|
|
491
450
|
return value.map((item, index) => {
|
|
492
451
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
493
452
|
throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
|
|
@@ -496,16 +455,17 @@ function normalizeCoverageUnits(value) {
|
|
|
496
455
|
if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
|
|
497
456
|
throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
|
|
498
457
|
}
|
|
499
|
-
const optional = (key) =>
|
|
458
|
+
const optional = (key) => {
|
|
459
|
+
const field = object[key];
|
|
460
|
+
if (field === undefined || field === null)
|
|
461
|
+
return null;
|
|
462
|
+
if (typeof field === "string")
|
|
463
|
+
return field;
|
|
464
|
+
throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2);
|
|
465
|
+
};
|
|
500
466
|
return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
|
|
501
467
|
});
|
|
502
468
|
}
|
|
503
|
-
function countValues(values) {
|
|
504
|
-
const counts = new Map();
|
|
505
|
-
for (const value of values)
|
|
506
|
-
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
507
|
-
return counts;
|
|
508
|
-
}
|
|
509
469
|
function requiredStringField(payload, key) {
|
|
510
470
|
const value = stringField(payload, key, true);
|
|
511
471
|
if (!value) {
|
|
@@ -5,20 +5,111 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
6
6
|
import { appendAudit } from "./audit.js";
|
|
7
7
|
import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from "./protocols.js";
|
|
8
|
-
import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus } from "./runs.js";
|
|
8
|
+
import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
9
9
|
import { validateSchema } from "./schema-validation.js";
|
|
10
|
-
import { reconcileSessionCoverage } from "./sessions.js";
|
|
10
|
+
import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
|
|
11
11
|
import { usageForRun } from "./usage.js";
|
|
12
12
|
import { boundCanonicalPlan } from "./plan-runtime.js";
|
|
13
13
|
import { AppError } from "../shared/errors.js";
|
|
14
14
|
import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
15
|
+
import { resolveCanonRoot } from "./canon.js";
|
|
16
|
+
import { preflightMemoryPermissions } from "./memory-permissions.js";
|
|
17
|
+
import { registerProject } from "./projects.js";
|
|
18
|
+
import { registerProtocol } from "./protocols.js";
|
|
19
|
+
import { previewNextEntityId } from "./ids.js";
|
|
20
|
+
const stagePromptSections = [
|
|
21
|
+
"stage_identity",
|
|
22
|
+
"authoritative_runtime_facts",
|
|
23
|
+
"preflight",
|
|
24
|
+
"task_intake",
|
|
25
|
+
"applicable_instructions",
|
|
26
|
+
"required_context",
|
|
27
|
+
"work_contract",
|
|
28
|
+
"completion_contract"
|
|
29
|
+
];
|
|
30
|
+
function bootstrapStageRun(context, projectRoot, input) {
|
|
31
|
+
if (input.stage !== "specify") {
|
|
32
|
+
throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2, { stage: input.stage });
|
|
33
|
+
}
|
|
34
|
+
const subject = input.bootstrap?.subject.trim();
|
|
35
|
+
if (!subject)
|
|
36
|
+
throw new AppError("usage", "--bootstrap requires --subject", 2);
|
|
37
|
+
registerProject(context, { root: projectRoot });
|
|
38
|
+
const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: subject });
|
|
39
|
+
const protocolId = preview.entity.id;
|
|
40
|
+
registerProtocol(context, { handshakeId: protocolId, projectRoot });
|
|
41
|
+
const started = startFlowRun(context, {
|
|
42
|
+
projectRoot,
|
|
43
|
+
flowKind: "mb_sdlc",
|
|
44
|
+
subjectType: "protocol",
|
|
45
|
+
subjectId: protocolId,
|
|
46
|
+
slug: subject,
|
|
47
|
+
nextAction: "run_specify"
|
|
48
|
+
});
|
|
49
|
+
const intakeFile = input.bootstrap?.intakeFile;
|
|
50
|
+
if (intakeFile) {
|
|
51
|
+
const source = path.resolve(intakeFile);
|
|
52
|
+
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
|
|
53
|
+
throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
|
|
54
|
+
}
|
|
55
|
+
const intakeDir = path.join(started.run.run_home_path ?? path.dirname(started.run.id), "intake");
|
|
56
|
+
fs.mkdirSync(intakeDir, { recursive: true });
|
|
57
|
+
fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
|
|
58
|
+
}
|
|
59
|
+
return started.run.id;
|
|
60
|
+
}
|
|
61
|
+
function stagePreflight(projectRoot, stageRoot) {
|
|
62
|
+
const memoryBank = preflightMemoryPermissions({
|
|
63
|
+
root: projectRoot,
|
|
64
|
+
memoryBank: ".memory-bank",
|
|
65
|
+
flow: "custom",
|
|
66
|
+
mode: "write",
|
|
67
|
+
targets: [{ path: ".memory-bank", mode: "read" }]
|
|
68
|
+
});
|
|
69
|
+
const probe = path.join(stageRoot, `.dd-flow-permission-${crypto.randomUUID()}`);
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(probe, "");
|
|
72
|
+
fs.renameSync(probe, `${probe}.renamed`);
|
|
73
|
+
fs.rmSync(`${probe}.renamed`);
|
|
74
|
+
return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
for (const candidate of [probe, `${probe}.renamed`]) {
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(candidate, { force: true });
|
|
80
|
+
}
|
|
81
|
+
catch { /* best effort */ }
|
|
82
|
+
}
|
|
83
|
+
return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function flowKindForStage(stage) {
|
|
87
|
+
return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
|
|
88
|
+
}
|
|
89
|
+
function runtimeFacts(view, projectRoot) {
|
|
90
|
+
return {
|
|
91
|
+
project_root: projectRoot,
|
|
92
|
+
workspace_root: view.run.workspace_root,
|
|
93
|
+
run_id: view.run.id,
|
|
94
|
+
protocol_id: view.run.subject.id,
|
|
95
|
+
runtime: {
|
|
96
|
+
source: "dd-flow",
|
|
97
|
+
state: "trusted",
|
|
98
|
+
flow_kind: view.run.flow_kind
|
|
99
|
+
},
|
|
100
|
+
git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
|
|
101
|
+
};
|
|
102
|
+
}
|
|
15
103
|
export function startStage(context, input) {
|
|
16
104
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
17
|
-
const
|
|
18
|
-
|
|
105
|
+
const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
|
|
106
|
+
if (!runId)
|
|
107
|
+
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
108
|
+
const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
109
|
+
const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
|
|
19
110
|
const attached = runView(attachFlowRunStage(context, {
|
|
20
111
|
projectRoot,
|
|
21
|
-
runId
|
|
112
|
+
runId,
|
|
22
113
|
stage: input.stage,
|
|
23
114
|
dir,
|
|
24
115
|
status: "running",
|
|
@@ -28,22 +119,45 @@ export function startStage(context, input) {
|
|
|
28
119
|
const stageRoot = path.join(runHome, dir);
|
|
29
120
|
ensureWithin(runHome, stageRoot, "stage root");
|
|
30
121
|
fs.mkdirSync(stageRoot, { recursive: true });
|
|
31
|
-
|
|
122
|
+
const preflight = stagePreflight(projectRoot, stageRoot);
|
|
123
|
+
if (preflight.ok !== true)
|
|
124
|
+
throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
|
|
125
|
+
if (attached.run.subject.type === "protocol") {
|
|
126
|
+
syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
|
|
127
|
+
}
|
|
128
|
+
if (input.sessionId) {
|
|
129
|
+
registerFlowSession(context, {
|
|
130
|
+
sessionId: input.sessionId,
|
|
131
|
+
payloadJson: JSON.stringify({
|
|
132
|
+
project_root: projectRoot,
|
|
133
|
+
flow_kind: flowKindForStage(input.stage),
|
|
134
|
+
run_id: attached.run.id,
|
|
135
|
+
protocol_id: attached.run.subject.id,
|
|
136
|
+
worker_id: null,
|
|
137
|
+
workspace_path: attached.run.workspace_root,
|
|
138
|
+
continuation_policy: "go_router",
|
|
139
|
+
session_kind: "orchestrator",
|
|
140
|
+
current_stage: input.stage,
|
|
141
|
+
cwd: attached.run.workspace_root
|
|
142
|
+
})
|
|
143
|
+
});
|
|
144
|
+
}
|
|
32
145
|
const promptPath = path.join(stageRoot, "stage-prompt.md");
|
|
33
|
-
const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir);
|
|
146
|
+
const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
|
|
34
147
|
atomicWrite(promptPath, prompt);
|
|
35
148
|
const promptDataPath = path.join(stageRoot, "stage-prompt.json");
|
|
36
149
|
const planPath = planJsonPath(projectRoot, attached.run.subject.id);
|
|
37
150
|
const aspectMapPath = path.join(stageRoot, "aspect-map.json");
|
|
38
151
|
const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
|
|
39
152
|
const attemptNumber = Number(attempt.replace("try-", "")) || 1;
|
|
153
|
+
const sources = stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage);
|
|
40
154
|
atomicWrite(promptDataPath, {
|
|
41
|
-
schema_id: "dd-flow/stage-prompt@
|
|
155
|
+
schema_id: "dd-flow/stage-prompt@2",
|
|
42
156
|
run_id: attached.run.id,
|
|
43
157
|
stage: input.stage,
|
|
44
158
|
generated_at: context.now(),
|
|
45
159
|
prompt_path: promptPath,
|
|
46
|
-
sections:
|
|
160
|
+
sections: stagePromptSections,
|
|
47
161
|
aliases: {
|
|
48
162
|
project: projectRoot,
|
|
49
163
|
workspace: attached.run.workspace_root,
|
|
@@ -54,11 +168,30 @@ export function startStage(context, input) {
|
|
|
54
168
|
...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
|
|
55
169
|
},
|
|
56
170
|
write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
|
|
57
|
-
source_fragments:
|
|
171
|
+
source_fragments: sources.map((source) => source.label),
|
|
172
|
+
authoritative_facts: runtimeFacts(attached, projectRoot),
|
|
173
|
+
preflight: {
|
|
174
|
+
compatibility: {
|
|
175
|
+
status: "checked",
|
|
176
|
+
operation: `stage.${input.stage}`,
|
|
177
|
+
project_root: projectRoot
|
|
178
|
+
},
|
|
179
|
+
permissions: preflight,
|
|
180
|
+
session_binding: {
|
|
181
|
+
status: input.sessionId ? "bound" : "not_bound",
|
|
182
|
+
session_id: input.sessionId ?? null
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
required_context: sources.map((source) => ({
|
|
186
|
+
path: source.label,
|
|
187
|
+
reason: "Stage-specific canonical instruction source",
|
|
188
|
+
stop_condition: "Stop when the source no longer contains unresolved requirements for this stage"
|
|
189
|
+
})),
|
|
190
|
+
worker_prompt_markdown: prompt
|
|
58
191
|
});
|
|
59
192
|
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
|
|
60
193
|
atomicWrite(path.join(stageRoot, "stage-start.json"), {
|
|
61
|
-
schema_id: "dd-flow/stage-start@
|
|
194
|
+
schema_id: "dd-flow/stage-start@2",
|
|
62
195
|
run_id: attached.run.id,
|
|
63
196
|
stage: input.stage,
|
|
64
197
|
dir,
|
|
@@ -68,7 +201,7 @@ export function startStage(context, input) {
|
|
|
68
201
|
});
|
|
69
202
|
return {
|
|
70
203
|
ok: true,
|
|
71
|
-
schema_id: "dd-flow/stage-start-response@
|
|
204
|
+
schema_id: "dd-flow/stage-start-response@2",
|
|
72
205
|
run_id: attached.run.id,
|
|
73
206
|
stage: input.stage,
|
|
74
207
|
attempt_number: attemptNumber,
|
|
@@ -86,32 +219,33 @@ export function startStage(context, input) {
|
|
|
86
219
|
},
|
|
87
220
|
...(input.stage === "plan" ? { plan_ref: planPath, aspect_map_ref: aspectMapPath } : {}),
|
|
88
221
|
resolved_context: { run: attached.run, stage: { name: input.stage, dir, status: "running" } },
|
|
89
|
-
next_command:
|
|
90
|
-
permission_probe:
|
|
222
|
+
next_command: stageFinishCommand(attached.run, projectRoot, input.stage),
|
|
223
|
+
permission_probe: preflight,
|
|
91
224
|
run: attached.run,
|
|
92
225
|
prompt: { path: promptPath, data_path: promptDataPath },
|
|
226
|
+
worker_prompt_markdown: prompt,
|
|
93
227
|
lifecycle: "stage start -> semantic work -> stage finish"
|
|
94
228
|
};
|
|
95
229
|
}
|
|
96
230
|
export function finishStage(context, input) {
|
|
97
231
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
98
|
-
|
|
232
|
+
if (!input.runId)
|
|
233
|
+
throw new AppError("usage", "stage finish requires a RUN id", 2);
|
|
234
|
+
const runId = input.runId;
|
|
235
|
+
const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
99
236
|
const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
|
|
100
237
|
if (!existing) {
|
|
101
238
|
throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
|
|
102
239
|
}
|
|
103
|
-
const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage);
|
|
240
|
+
const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage, view.run.flow_kind);
|
|
104
241
|
const runHome = runHomePath(view.run);
|
|
105
242
|
const stageRoot = path.join(runHome, dir);
|
|
106
243
|
ensureWithin(runHome, stageRoot, "stage root");
|
|
107
244
|
fs.mkdirSync(stageRoot, { recursive: true });
|
|
108
|
-
|
|
109
|
-
throw new AppError("stage_finish_input_required", "Stage finish requires --semantic-file with dd-flow/stage-finish-input@1", 2);
|
|
110
|
-
}
|
|
111
|
-
const semanticFile = resolveStageFile(input.semanticFile, stageRoot, runHome);
|
|
245
|
+
const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
|
|
112
246
|
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot });
|
|
113
247
|
const semantic = readSemanticFile(semanticFile, runHome);
|
|
114
|
-
const status = input.
|
|
248
|
+
const status = input.outcome ?? "done";
|
|
115
249
|
if (!["done", "blocked", "failed"].includes(status)) {
|
|
116
250
|
throw new AppError("validation", "Stage finish status must be done, blocked, or failed", 2, { status });
|
|
117
251
|
}
|
|
@@ -128,14 +262,16 @@ export function finishStage(context, input) {
|
|
|
128
262
|
const reportPath = path.join(stageRoot, "stage-report.md");
|
|
129
263
|
const htmlPath = path.join(stageRoot, "stage-report.html");
|
|
130
264
|
atomicWrite(dataPath, report);
|
|
131
|
-
validateSchema({ schemaName:
|
|
265
|
+
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot });
|
|
132
266
|
atomicWrite(reportPath, renderMarkdown(report));
|
|
133
267
|
atomicWrite(htmlPath, renderHtml(projectRoot, report));
|
|
134
|
-
|
|
268
|
+
if (view.run.subject.type === "protocol") {
|
|
269
|
+
syncProtocolLifecycle(context, view.run.project_id, view.run.subject.id, input.stage, status, stringValue(semantic.next_action));
|
|
270
|
+
}
|
|
135
271
|
const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
|
|
136
272
|
const completed = completeFlowRunStage(context, {
|
|
137
273
|
projectRoot,
|
|
138
|
-
runId
|
|
274
|
+
runId,
|
|
139
275
|
stage: input.stage,
|
|
140
276
|
status,
|
|
141
277
|
stageReport: htmlPath,
|
|
@@ -223,8 +359,20 @@ function nextActionForProtocolStage(stage) {
|
|
|
223
359
|
function runHomePath(run) {
|
|
224
360
|
return run.run_home_path ?? path.dirname(run.run_index_path);
|
|
225
361
|
}
|
|
226
|
-
function defaultStageDir(stage) {
|
|
362
|
+
export function defaultStageDir(stage, flowKind) {
|
|
227
363
|
const normalized = stage === "implementation" ? "code" : stage;
|
|
364
|
+
if (flowKind === "mb-upgrade") {
|
|
365
|
+
const upgrade = {
|
|
366
|
+
preflight: "01-preflight",
|
|
367
|
+
"diff-analysis": "02-diff-analysis",
|
|
368
|
+
upgrade: "03-upgrade",
|
|
369
|
+
lint: "04-lint",
|
|
370
|
+
review: "05-review",
|
|
371
|
+
merge: "06-merge"
|
|
372
|
+
};
|
|
373
|
+
if (upgrade[normalized])
|
|
374
|
+
return upgrade[normalized];
|
|
375
|
+
}
|
|
228
376
|
const known = { specify: "01-specify", plan: "02-plan", code: "03-code", readiness: "03-code", merge: "04-merge" };
|
|
229
377
|
return known[normalized] ?? `03-${normalized.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
|
230
378
|
}
|
|
@@ -237,54 +385,109 @@ function dataSchemaForStage(stage) {
|
|
|
237
385
|
return "dd-flow/merge-stage-report@2";
|
|
238
386
|
return "dd-flow/code-stage-report@2";
|
|
239
387
|
}
|
|
240
|
-
function
|
|
388
|
+
function stageReportSchemaName(stage, planFinish) {
|
|
389
|
+
if (planFinish)
|
|
390
|
+
return "plan-stage-report";
|
|
391
|
+
if (stage === "code" || stage === "implementation")
|
|
392
|
+
return "code-stage-report";
|
|
393
|
+
if (stage === "merge")
|
|
394
|
+
return "merge-stage-report";
|
|
395
|
+
return "stage-report";
|
|
396
|
+
}
|
|
397
|
+
function stageFinishCommand(run, projectRoot, stage) {
|
|
398
|
+
const compatibilityMode = run.flow_kind === "mb-upgrade" ? " --compatibility-mode mb-upgrade" : "";
|
|
399
|
+
return `dd-flow stage finish ${run.id} --stage ${stage} --outcome done --project-root ${JSON.stringify(projectRoot)}${compatibilityMode} --json`;
|
|
400
|
+
}
|
|
401
|
+
function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
|
|
241
402
|
const project = requireProjectByRoot(context, projectRoot);
|
|
242
403
|
const runHome = runHomePath(current.run);
|
|
243
|
-
const
|
|
244
|
-
?
|
|
245
|
-
: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"];
|
|
246
|
-
const instructions = flowFiles.map((file) => {
|
|
247
|
-
const absolute = path.join(projectRoot, file);
|
|
248
|
-
return fs.existsSync(absolute) ? `### ${file}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${file}\n\n(unavailable)`;
|
|
404
|
+
const instructions = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage).map(({ label, absolute }) => {
|
|
405
|
+
return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
|
|
249
406
|
}).join("\n\n");
|
|
250
407
|
const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
|
|
408
|
+
const requiredContext = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage)
|
|
409
|
+
.map(({ label }) => `- Read: \`${label}\``).join("\n");
|
|
251
410
|
return [
|
|
252
|
-
"
|
|
253
|
-
"",
|
|
254
|
-
"## Identity",
|
|
411
|
+
"<stage_identity>",
|
|
412
|
+
"# Stage work packet",
|
|
255
413
|
`- Project: ${project.id}`,
|
|
256
414
|
`- Run: ${current.run.id}`,
|
|
257
415
|
`- Protocol: ${current.run.subject.id}`,
|
|
258
416
|
`- Stage: ${stage}`,
|
|
259
417
|
`- Attempt: ${current.index.stage_runs?.find((item) => item.stage === stage)?.attempt ?? "try-001"}`,
|
|
418
|
+
"</stage_identity>",
|
|
260
419
|
"",
|
|
261
|
-
"
|
|
420
|
+
"<authoritative_runtime_facts>",
|
|
421
|
+
"These facts were collected by dd-flow. Trust them; do not re-run CLI, Git, compatibility, or permission discovery unless the semantic work changes the relevant state.",
|
|
262
422
|
`- Project root: ${projectRoot}`,
|
|
263
423
|
`- Workspace root: ${current.run.workspace_root}`,
|
|
264
424
|
`- Run home: ${runHome}`,
|
|
265
425
|
`- Stage root: ${path.join(runHome, dir)}`,
|
|
266
426
|
`- Git branch: ${current.index.execution?.git?.branch ?? "unknown"}`,
|
|
267
427
|
`- Git head: ${current.index.execution?.git?.head ?? "unknown"}`,
|
|
428
|
+
"</authoritative_runtime_facts>",
|
|
429
|
+
"",
|
|
430
|
+
"<preflight>",
|
|
431
|
+
`- Permission probe: ${String(preflight.ok === true ? "passed" : "failed")}.`,
|
|
432
|
+
"- CLI compatibility was checked before this state-changing command.",
|
|
433
|
+
"</preflight>",
|
|
268
434
|
"",
|
|
269
|
-
"
|
|
435
|
+
"<task_intake>",
|
|
270
436
|
"Raw intake and provider telemetry stay under the RUN home. Promote only durable decisions to the protocol summary.",
|
|
271
437
|
`- Protocol next action: ${protocol?.next_action ?? "not recorded"}`,
|
|
272
438
|
`- Previous stage: ${before.index.stage_runs?.map((item) => `${item.stage}:${item.status}`).join(", ") || "none"}`,
|
|
439
|
+
"</task_intake>",
|
|
273
440
|
"",
|
|
274
|
-
"
|
|
441
|
+
"<applicable_instructions>",
|
|
275
442
|
instructions,
|
|
443
|
+
"</applicable_instructions>",
|
|
276
444
|
"",
|
|
277
|
-
"
|
|
278
|
-
|
|
445
|
+
"<required_context>",
|
|
446
|
+
requiredContext || "- No additional stage-specific source is required.",
|
|
447
|
+
"</required_context>",
|
|
279
448
|
"",
|
|
280
|
-
"
|
|
281
|
-
"
|
|
449
|
+
"<work_contract>",
|
|
450
|
+
"Perform only the semantic work for this stage. Write current-stage artifacts to `@stage`; `@stage/try-NNN` is read-only archive history.",
|
|
451
|
+
"Do not repeat deterministic preflight, global CLI help, runtime/Git inspection, session registration, report rendering, summary generation, or state transition work.",
|
|
452
|
+
"</work_contract>",
|
|
282
453
|
"",
|
|
283
|
-
"
|
|
284
|
-
`
|
|
454
|
+
"<completion_contract>",
|
|
455
|
+
`Write semantic output to \`@stage/stage-input.json\`, then run: \`${stageFinishCommand(current.run, projectRoot, stage)}\`.`,
|
|
285
456
|
"The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
|
|
457
|
+
"</completion_contract>",
|
|
286
458
|
].join("\n");
|
|
287
459
|
}
|
|
460
|
+
function stageInstructionSources(context, projectRoot, flowKind, stage) {
|
|
461
|
+
if (flowKind === "mb-upgrade") {
|
|
462
|
+
const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
|
|
463
|
+
if (!canon.ok || !canon.canon) {
|
|
464
|
+
throw new AppError("canon_unavailable", "mb-upgrade stage prompt requires the canonical Memory Bank", 1, {
|
|
465
|
+
blockers: canon.blockers,
|
|
466
|
+
bootstrap: canon.bootstrap
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const flowRoot = canon.canon.flow_root;
|
|
470
|
+
const stageSupport = {
|
|
471
|
+
"diff-analysis": "mb-upgrade/diff-analysis.md",
|
|
472
|
+
lint: "mb-upgrade/lint-verification.md",
|
|
473
|
+
review: "mb-upgrade/review/index.md",
|
|
474
|
+
merge: "mb-upgrade/merge.md"
|
|
475
|
+
};
|
|
476
|
+
return ["mb-upgrade.md", "mb-upgrade/index.md", stageSupport[stage]]
|
|
477
|
+
.filter((file) => Boolean(file))
|
|
478
|
+
.map((file) => ({ label: `canonical:${file}`, absolute: path.join(flowRoot, file) }));
|
|
479
|
+
}
|
|
480
|
+
const filesByStage = {
|
|
481
|
+
specify: [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"],
|
|
482
|
+
plan: [".memory-bank/dd-flow/plan.md"],
|
|
483
|
+
code: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
|
|
484
|
+
implementation: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
|
|
485
|
+
readiness: [".memory-bank/dd-flow/mb-sdlc/code/readiness.md"],
|
|
486
|
+
merge: [".memory-bank/dd-flow/merge.md"]
|
|
487
|
+
};
|
|
488
|
+
const files = filesByStage[stage] ?? [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"];
|
|
489
|
+
return files.map((file) => ({ label: file, absolute: path.join(projectRoot, file) }));
|
|
490
|
+
}
|
|
288
491
|
function safeProtocolState(context, projectId, protocolId) {
|
|
289
492
|
try {
|
|
290
493
|
const protocol = requireProtocol(context, protocolId, projectId);
|
|
@@ -376,6 +579,34 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
376
579
|
}
|
|
377
580
|
};
|
|
378
581
|
}
|
|
582
|
+
if (stage === "code" || stage === "implementation") {
|
|
583
|
+
return {
|
|
584
|
+
schema_id: "dd-flow/code-stage-report@2",
|
|
585
|
+
run: { run_id: view.run.id, run_state: view.run.run_index_path },
|
|
586
|
+
stage: { name: stage, dir: path.basename(stageRoot), status },
|
|
587
|
+
project: { id: view.run.project_id, title: path.basename(view.run.project_root) },
|
|
588
|
+
subject: { id: view.run.subject.id, title: view.run.subject.id },
|
|
589
|
+
flow_flags: flowFlagsReportProjection(view.index.flow_flags),
|
|
590
|
+
overall: {
|
|
591
|
+
verdict: status === "done" ? "accepted" : status,
|
|
592
|
+
summary: result,
|
|
593
|
+
next_action: stringValue(semantic.next_action) ?? "Proceed to readiness."
|
|
594
|
+
},
|
|
595
|
+
breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }],
|
|
596
|
+
implemented_goals: [{ title: `Stage ${stage}`, summary: result }],
|
|
597
|
+
acceptance_scenarios: acceptance.map((summary, index) => ({
|
|
598
|
+
id: `SCN-${stage}-${index + 1}`,
|
|
599
|
+
title: `Acceptance ${index + 1}`,
|
|
600
|
+
verdict: "accepted",
|
|
601
|
+
steps: [{ title: "Stage finish", summary }],
|
|
602
|
+
evidence: evidence.map((item) => ({ path: item, label: item }))
|
|
603
|
+
})),
|
|
604
|
+
changed_files: changedFiles.map((file) => ({ path: file, label: file })),
|
|
605
|
+
checks: checks.map((name) => ({ name, status: "passed" })),
|
|
606
|
+
review: { verdict: status === "done" ? "accepted" : status, findings: [] },
|
|
607
|
+
defs: Array.isArray(semantic.def_outcomes) ? semantic.def_outcomes : []
|
|
608
|
+
};
|
|
609
|
+
}
|
|
379
610
|
return {
|
|
380
611
|
schema_id: "dd-flow/stage-report@1",
|
|
381
612
|
run_id: view.run.id,
|
|
@@ -446,6 +677,28 @@ function stringValue(value) {
|
|
|
446
677
|
function stringArray(value, fallback) {
|
|
447
678
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
|
|
448
679
|
}
|
|
680
|
+
function flowFlagsReportProjection(value) {
|
|
681
|
+
if (!value) {
|
|
682
|
+
return {
|
|
683
|
+
flow_kind: "unknown",
|
|
684
|
+
snapshot_revision: 1,
|
|
685
|
+
resolution_status: "legacy_incomplete",
|
|
686
|
+
values: {},
|
|
687
|
+
snapshot_checksum: "0".repeat(64)
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
...(value.contract ? { contract: value.contract } : {}),
|
|
692
|
+
flow_kind: value.flow_kind ?? "unknown",
|
|
693
|
+
...(value.preset ? { preset: value.preset } : {}),
|
|
694
|
+
snapshot_revision: value.snapshot_revision ?? 1,
|
|
695
|
+
resolution_status: value.resolution_status ?? "legacy_incomplete",
|
|
696
|
+
values: value.values ?? {},
|
|
697
|
+
...(value.snapshot_checksum ? { snapshot_checksum: value.snapshot_checksum } : { snapshot_checksum: "0".repeat(64) }),
|
|
698
|
+
...(Array.isArray(value.floors_applied) ? { floors_applied: value.floors_applied } : {}),
|
|
699
|
+
...(value.resolved_at ? { resolved_at: value.resolved_at } : {})
|
|
700
|
+
};
|
|
701
|
+
}
|
|
449
702
|
function gitChangedFiles(workspaceRoot) {
|
|
450
703
|
const result = spawnSync("git", ["-C", workspaceRoot, "status", "--short"], { encoding: "utf8" });
|
|
451
704
|
if (result.status !== 0)
|
|
@@ -469,7 +722,7 @@ function workerCoverage(context, projectId, runId) {
|
|
|
469
722
|
}
|
|
470
723
|
function runTargetedMemoryBankLint(projectRoot, semantic) {
|
|
471
724
|
const declared = stringArray(semantic.changed_files, []);
|
|
472
|
-
const files = [...new Set(declared.filter((file) => file.startsWith(".memory-bank/") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
|
|
725
|
+
const files = [...new Set(declared.filter((file) => file.startsWith(".memory-bank/") && file.toLowerCase().endsWith(".md") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
|
|
473
726
|
for (const file of files)
|
|
474
727
|
ensureWithin(projectRoot, path.resolve(projectRoot, file), "lint target");
|
|
475
728
|
if (files.length === 0) {
|