@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79
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 +8 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +7 -11
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +85 -42
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +207 -94
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
|
@@ -5,13 +5,14 @@ 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, startFlowRun } from "./runs.js";
|
|
8
|
+
import { attachFlowRunStage, prepareFlowRunStart, prepareFlowRunStageAttachment, prepareStageDirectory, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
9
9
|
import { validateSchema } from "./schema-validation.js";
|
|
10
10
|
import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
|
|
11
11
|
import { recalculateRunUsage, usageForRun } from "./usage.js";
|
|
12
|
-
import { boundCanonicalPlan } from "./plan-runtime.js";
|
|
12
|
+
import { boundCanonicalPlan, prepareCanonicalPlan } from "./plan-runtime.js";
|
|
13
|
+
import { prepareJsonFile, prepareJsonSchema, prepareOutputDirectory, prepareOutputFile, prepareTextFile } from "../cli/input-preparation.js";
|
|
13
14
|
import { AppError } from "../shared/errors.js";
|
|
14
|
-
import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
15
|
+
import { planJsonPath, projectHome, resolveProjectRoot } from "../storage/paths.js";
|
|
15
16
|
import { resolveCanonRoot } from "./canon.js";
|
|
16
17
|
import { preflightMemoryPermissions } from "./memory-permissions.js";
|
|
17
18
|
import { registerProject } from "./projects.js";
|
|
@@ -39,63 +40,30 @@ const stagePromptSections = [
|
|
|
39
40
|
"work_contract",
|
|
40
41
|
"completion_contract"
|
|
41
42
|
];
|
|
42
|
-
function bootstrapStageRun(context, projectRoot,
|
|
43
|
-
if (input.stage !== "specify") {
|
|
44
|
-
throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2, { stage: input.stage });
|
|
45
|
-
}
|
|
46
|
-
const subject = input.bootstrap?.subject.trim();
|
|
47
|
-
if (!subject)
|
|
48
|
-
throw new AppError("usage", "--bootstrap requires --subject", 2);
|
|
43
|
+
function bootstrapStageRun(context, projectRoot, bootstrap, intakeMarkdown) {
|
|
49
44
|
registerProject(context, { root: projectRoot });
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const started = startFlowRun(context, {
|
|
54
|
-
projectRoot,
|
|
55
|
-
flowKind: "mb_sdlc",
|
|
56
|
-
subjectType: "protocol",
|
|
57
|
-
subjectId: protocolId,
|
|
58
|
-
slug: subject,
|
|
59
|
-
nextAction: "run_specify"
|
|
60
|
-
});
|
|
61
|
-
const intakeFile = input.bootstrap?.intakeFile;
|
|
62
|
-
if (intakeFile) {
|
|
63
|
-
const source = path.resolve(intakeFile);
|
|
64
|
-
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
|
|
65
|
-
throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
|
|
66
|
-
}
|
|
45
|
+
registerProtocol(context, { handshakeId: bootstrap.protocolId, projectRoot });
|
|
46
|
+
const started = startFlowRun(context, bootstrap.startInput, bootstrap.start);
|
|
47
|
+
if (intakeMarkdown !== undefined) {
|
|
67
48
|
if (!started.run.run_root)
|
|
68
49
|
throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
|
|
69
50
|
const intakeDir = path.join(started.run.run_root, "intake");
|
|
70
51
|
fs.mkdirSync(intakeDir, { recursive: true });
|
|
71
|
-
fs.
|
|
52
|
+
fs.writeFileSync(path.join(intakeDir, "user-request.md"), intakeMarkdown);
|
|
72
53
|
}
|
|
73
54
|
return started.run.id;
|
|
74
55
|
}
|
|
75
|
-
function stagePreflight(projectRoot
|
|
56
|
+
function stagePreflight(projectRoot) {
|
|
76
57
|
const memoryBank = preflightMemoryPermissions({
|
|
77
58
|
root: projectRoot,
|
|
78
59
|
memoryBank: ".memory-bank",
|
|
79
60
|
flow: "custom",
|
|
80
|
-
mode: "
|
|
61
|
+
mode: "read",
|
|
81
62
|
targets: [{ path: ".memory-bank", mode: "read" }]
|
|
82
63
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
fs.renameSync(probe, `${probe}.renamed`);
|
|
87
|
-
fs.rmSync(`${probe}.renamed`);
|
|
88
|
-
return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
for (const candidate of [probe, `${probe}.renamed`]) {
|
|
92
|
-
try {
|
|
93
|
-
fs.rmSync(candidate, { force: true });
|
|
94
|
-
}
|
|
95
|
-
catch { /* best effort */ }
|
|
96
|
-
}
|
|
97
|
-
return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
|
|
98
|
-
}
|
|
64
|
+
if (memoryBank.ok !== true)
|
|
65
|
+
throw new AppError("permission_preflight_failed", "Stage project context is not readable", 1, { preflight: memoryBank });
|
|
66
|
+
return memoryBank;
|
|
99
67
|
}
|
|
100
68
|
function flowKindForStage(stage) {
|
|
101
69
|
return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
|
|
@@ -114,24 +82,57 @@ function runtimeFacts(view, projectRoot) {
|
|
|
114
82
|
git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
|
|
115
83
|
};
|
|
116
84
|
}
|
|
117
|
-
export function
|
|
85
|
+
export function prepareLegacyStageStart(context, input, intakeMarkdown) {
|
|
86
|
+
if (input.bootstrap && input.stage !== "specify")
|
|
87
|
+
throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2);
|
|
88
|
+
if (input.bootstrap && !input.bootstrap.subject?.trim())
|
|
89
|
+
throw new AppError("usage", "--bootstrap requires --subject", 2);
|
|
90
|
+
if (!input.bootstrap && !input.runId)
|
|
91
|
+
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
92
|
+
if (input.dir !== undefined)
|
|
93
|
+
prepareStageDirectory(input.dir);
|
|
94
|
+
const intake = intakeMarkdown ?? (input.bootstrap?.intakeFile ? prepareTextFile(input.bootstrap.intakeFile, "SPECIFY intake").text : undefined);
|
|
95
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
96
|
+
const retained = !input.bootstrap && input.runId ? runView(getFlowRunStatus(context, { projectRoot, runId: input.runId })) : null;
|
|
97
|
+
const dir = input.dir ?? defaultStageDir(input.stage, retained?.run.flow_kind ?? "mb_sdlc");
|
|
98
|
+
if (retained) {
|
|
99
|
+
prepareFlowRunStageAttachment(context, { projectRoot, runId: retained.run.id, stage: input.stage, dir, status: "running", dataSchemaId: dataSchemaForStage(input.stage) });
|
|
100
|
+
const root = path.join(runHomePath(retained.run), dir);
|
|
101
|
+
for (const name of ["stage-prompt.md", "stage-prompt.json", "stage-start.json", "stage-input.json"])
|
|
102
|
+
prepareOutputFile(path.join(root, name));
|
|
103
|
+
if (retained.run.subject.type === "protocol")
|
|
104
|
+
readProtocolRuntimeState(context, requireProtocol(context, retained.run.subject.id, retained.run.project_id));
|
|
105
|
+
}
|
|
106
|
+
const preflight = stagePreflight(projectRoot);
|
|
107
|
+
const sources = stageInstructionSources(context, projectRoot, retained?.run.flow_kind ?? "mb_sdlc", input.stage).map(source => ({ ...source, text: fs.existsSync(source.absolute) ? fs.readFileSync(source.absolute, "utf8").trim() : "(unavailable)" }));
|
|
108
|
+
let bootstrap;
|
|
109
|
+
if (input.bootstrap) {
|
|
110
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
|
|
111
|
+
prepareOutputDirectory(project ? projectHome(context.ddFlowHome, project.id) : path.join(context.ddFlowHome, "projects"));
|
|
112
|
+
const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: input.bootstrap.subject });
|
|
113
|
+
const startInput = { projectRoot, flowKind: "mb_sdlc", subjectType: "protocol", subjectId: preview.entity.id, slug: input.bootstrap.subject, nextAction: "run_specify" };
|
|
114
|
+
bootstrap = { protocolId: preview.entity.id, startInput, start: prepareFlowRunStart(context, startInput) };
|
|
115
|
+
}
|
|
116
|
+
return { intakeMarkdown: intake, projectRoot, retained, dir, preflight, sources, bootstrap };
|
|
117
|
+
}
|
|
118
|
+
export function startStage(context, input, prepared = prepareLegacyStageStart(context, input)) {
|
|
118
119
|
if (input.requireSessionBinding && !input.hookEventId) {
|
|
119
120
|
throw new AppError("trusted_session_binding_required", "This stage requires a trusted harness lifecycle binding; run it from a controlled Session with the dd-flow adapter enabled", 1);
|
|
120
121
|
}
|
|
121
|
-
const projectRoot =
|
|
122
|
+
const { projectRoot, retained, dir, preflight, sources } = prepared;
|
|
123
|
+
if (retained)
|
|
124
|
+
prepareFlowRunStageAttachment(context, { projectRoot, runId: retained.run.id, stage: input.stage, dir: input.dir ?? defaultStageDir(input.stage, retained.run.flow_kind), status: "running", dataSchemaId: dataSchemaForStage(input.stage) });
|
|
122
125
|
let requiredSessionId = null;
|
|
123
126
|
let requiredIdentity = null;
|
|
124
127
|
if (input.requireSessionBinding) {
|
|
125
|
-
registerProject(context, { root: projectRoot });
|
|
126
128
|
requiredIdentity = hookSessionIdentity(context, requireProjectByRoot(context, projectRoot).id, input.hookEventId);
|
|
127
129
|
requiredSessionId = requiredIdentity.sessionId;
|
|
128
130
|
}
|
|
129
|
-
const runId =
|
|
131
|
+
const runId = prepared.bootstrap ? bootstrapStageRun(context, projectRoot, prepared.bootstrap, prepared.intakeMarkdown) : input.runId;
|
|
130
132
|
if (!runId)
|
|
131
133
|
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
132
|
-
const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
134
|
+
const before = retained ?? runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
133
135
|
assertRunMutationAllowed(context, before.run.project_id, before.run.id);
|
|
134
|
-
const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
|
|
135
136
|
const attached = runView(attachFlowRunStage(context, {
|
|
136
137
|
projectRoot,
|
|
137
138
|
runId,
|
|
@@ -144,9 +145,6 @@ export function startStage(context, input) {
|
|
|
144
145
|
const stageRoot = path.join(runHome, dir);
|
|
145
146
|
ensureWithin(runHome, stageRoot, "stage root");
|
|
146
147
|
fs.mkdirSync(stageRoot, { recursive: true });
|
|
147
|
-
const preflight = stagePreflight(projectRoot, stageRoot);
|
|
148
|
-
if (preflight.ok !== true)
|
|
149
|
-
throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
|
|
150
148
|
if (attached.run.subject.type === "protocol") {
|
|
151
149
|
syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
|
|
152
150
|
}
|
|
@@ -154,7 +152,7 @@ export function startStage(context, input) {
|
|
|
154
152
|
if (sessionId) {
|
|
155
153
|
const identity = requiredIdentity ?? (input.hookEventId ? hookSessionIdentity(context, attached.run.project_id, input.hookEventId) : null);
|
|
156
154
|
registerFlowSession(context, {
|
|
157
|
-
|
|
155
|
+
hookEventId: input.hookEventId,
|
|
158
156
|
payloadJson: JSON.stringify({
|
|
159
157
|
project_root: projectRoot,
|
|
160
158
|
harness: identity?.harness ?? "codex-desktop",
|
|
@@ -179,7 +177,7 @@ export function startStage(context, input) {
|
|
|
179
177
|
});
|
|
180
178
|
}
|
|
181
179
|
const promptPath = path.join(stageRoot, "stage-prompt.md");
|
|
182
|
-
const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
|
|
180
|
+
const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight, sources);
|
|
183
181
|
atomicWrite(promptPath, prompt);
|
|
184
182
|
const semanticInputPath = path.join(stageRoot, "stage-input.json");
|
|
185
183
|
if (!fs.existsSync(semanticInputPath)) {
|
|
@@ -190,7 +188,6 @@ export function startStage(context, input) {
|
|
|
190
188
|
const aspectMapPath = path.join(stageRoot, "aspect-map.json");
|
|
191
189
|
const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
|
|
192
190
|
const attemptNumber = Number(attempt.replace("try-", "")) || 1;
|
|
193
|
-
const sources = stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage);
|
|
194
191
|
atomicWrite(promptDataPath, {
|
|
195
192
|
schema_id: "dd-flow/stage-prompt@2",
|
|
196
193
|
run_id: attached.run.id,
|
|
@@ -269,7 +266,7 @@ export function startStage(context, input) {
|
|
|
269
266
|
lifecycle: "stage start -> semantic work -> stage finish"
|
|
270
267
|
};
|
|
271
268
|
}
|
|
272
|
-
export function
|
|
269
|
+
export function prepareStageFinish(context, input) {
|
|
273
270
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
274
271
|
if (!input.runId)
|
|
275
272
|
throw new AppError("usage", "stage finish requires a RUN id", 2);
|
|
@@ -284,32 +281,38 @@ export function finishStage(context, input) {
|
|
|
284
281
|
const runHome = runHomePath(view.run);
|
|
285
282
|
const stageRoot = path.join(runHome, dir);
|
|
286
283
|
ensureWithin(runHome, stageRoot, "stage root");
|
|
287
|
-
fs.mkdirSync(stageRoot, { recursive: true });
|
|
288
284
|
const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
|
|
289
|
-
|
|
285
|
+
ensureWithin(runHome, semanticFile, "semantic file");
|
|
286
|
+
const source = prepareJsonSchema(semanticFile, "Stage finish input", { schemaName: "stage-finish-input", projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
287
|
+
const semantic = source.value;
|
|
288
|
+
const status = stringValue(semantic.status) ?? "done";
|
|
289
|
+
if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
|
|
290
|
+
throw new AppError("validation", "Stage finish status must be done, waiting_for_user, blocked, or failed", 2, { status });
|
|
291
|
+
}
|
|
292
|
+
validateStageOutcome(input.stage, status, semantic);
|
|
293
|
+
const planFinish = input.stage === "plan" ? preparePlanFinish(context, projectRoot, view, stageRoot) : undefined;
|
|
294
|
+
return { projectRoot, runId, view, existing, runHome, stageRoot, semanticFile, semantic, status, source, planFinish };
|
|
295
|
+
}
|
|
296
|
+
export function finishStage(context, input, prepared = prepareStageFinish(context, input)) {
|
|
297
|
+
const { projectRoot, runId, view, existing, runHome, stageRoot, semanticFile, semantic, status, source, planFinish } = prepared;
|
|
298
|
+
assertRunMutationAllowed(context, view.run.project_id, view.run.id);
|
|
299
|
+
fs.mkdirSync(stageRoot, { recursive: true });
|
|
300
|
+
const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage, source.text);
|
|
290
301
|
try {
|
|
291
|
-
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
292
|
-
const semantic = readSemanticFile(semanticFile, runHome);
|
|
293
|
-
const status = stringValue(semantic.status) ?? "done";
|
|
294
|
-
if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
|
|
295
|
-
throw new AppError("validation", "Stage finish status must be done, waiting_for_user, blocked, or failed", 2, { status });
|
|
296
|
-
}
|
|
297
|
-
validateStageOutcome(input.stage, status, semantic);
|
|
298
302
|
const coverage = workerCoverage(context, view.run.project_id, view.run.id);
|
|
299
303
|
if (coverage.jobs.missing?.length) {
|
|
300
304
|
throw new AppError("worker_jobs_incomplete", "Stage finish requires all declared worker jobs", 1, { coverage });
|
|
301
305
|
}
|
|
302
306
|
const lint = runTargetedMemoryBankLint(projectRoot);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
: undefined;
|
|
307
|
+
if (planFinish)
|
|
308
|
+
boundCanonicalPlan(context, { projectId: view.run.project_id, protocolId: view.run.subject.id, planPath: planFinish.plan_path }, planFinish.canonical);
|
|
306
309
|
const stageUsage = stageUsageProjection(context, view, input.stage, existing.started_at);
|
|
307
310
|
const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
|
|
308
311
|
const dataPath = path.join(stageRoot, "stage-report.json");
|
|
309
312
|
const reportPath = path.join(stageRoot, "stage-report.md");
|
|
310
313
|
const htmlPath = path.join(stageRoot, "stage-report.html");
|
|
314
|
+
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, data: report, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
311
315
|
atomicWrite(dataPath, report);
|
|
312
|
-
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
313
316
|
atomicWrite(reportPath, renderMarkdown(report));
|
|
314
317
|
atomicWrite(htmlPath, renderHtml(projectRoot, report));
|
|
315
318
|
if (view.run.subject.type === "protocol") {
|
|
@@ -495,12 +498,10 @@ function semanticInputTemplate(stage) {
|
|
|
495
498
|
next_action: waiting ? "waiting_for_user: obtain the clarification packet" : "Continue to the next approved stage."
|
|
496
499
|
};
|
|
497
500
|
}
|
|
498
|
-
function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
|
|
501
|
+
function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight, sources) {
|
|
499
502
|
const project = requireProjectByRoot(context, projectRoot);
|
|
500
503
|
const runHome = runHomePath(current.run);
|
|
501
|
-
const instructions =
|
|
502
|
-
return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
|
|
503
|
-
}).join("\n\n");
|
|
504
|
+
const instructions = sources.map(({ label, text }) => `### ${label}\n\n${text}`).join("\n\n");
|
|
504
505
|
const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
|
|
505
506
|
return [
|
|
506
507
|
"<stage_identity>",
|
|
@@ -523,7 +524,7 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
|
|
|
523
524
|
"</authoritative_runtime_facts>",
|
|
524
525
|
"",
|
|
525
526
|
"<preflight>",
|
|
526
|
-
`-
|
|
527
|
+
`- Read-only permission check: ${String(preflight.ok === true ? "passed" : "failed")}.`,
|
|
527
528
|
"- CLI compatibility was checked before this state-changing command.",
|
|
528
529
|
"</preflight>",
|
|
529
530
|
"",
|
|
@@ -596,16 +597,6 @@ function safeProtocolState(context, projectId, protocolId) {
|
|
|
596
597
|
return undefined;
|
|
597
598
|
}
|
|
598
599
|
}
|
|
599
|
-
function readSemanticFile(file, runHome) {
|
|
600
|
-
const resolved = path.resolve(file);
|
|
601
|
-
ensureWithin(runHome, resolved, "semantic file");
|
|
602
|
-
if (!fs.existsSync(resolved))
|
|
603
|
-
throw new AppError("not_found", "Semantic input file is missing", 1, { path: file });
|
|
604
|
-
const value = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
605
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
606
|
-
throw new AppError("validation", "Semantic input must be a JSON object", 2);
|
|
607
|
-
return value;
|
|
608
|
-
}
|
|
609
600
|
function validateStageOutcome(stage, status, semantic) {
|
|
610
601
|
if (stage !== "specify")
|
|
611
602
|
return;
|
|
@@ -617,17 +608,12 @@ function validateStageOutcome(stage, status, semantic) {
|
|
|
617
608
|
throw new AppError("semantic_status_inconsistent", "SPECIFY done cannot contain unresolved structured questions", 2);
|
|
618
609
|
}
|
|
619
610
|
}
|
|
620
|
-
function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage) {
|
|
611
|
+
function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage, raw) {
|
|
621
612
|
const receiptDir = path.join(stageRoot, "finish-receipts");
|
|
622
613
|
fs.mkdirSync(receiptDir, { recursive: true });
|
|
623
614
|
const number = fs.readdirSync(receiptDir).filter((file) => /^\d{3}\.json$/u.test(file)).length + 1;
|
|
624
615
|
const receiptPath = path.join(receiptDir, `${String(number).padStart(3, "0")}.json`);
|
|
625
|
-
|
|
626
|
-
try {
|
|
627
|
-
raw = fs.readFileSync(semanticFile, "utf8");
|
|
628
|
-
}
|
|
629
|
-
catch { /* receipt records the missing input below */ }
|
|
630
|
-
const semanticSha = raw === null ? null : crypto.createHash("sha256").update(raw).digest("hex");
|
|
616
|
+
const semanticSha = crypto.createHash("sha256").update(raw).digest("hex");
|
|
631
617
|
atomicWrite(receiptPath, {
|
|
632
618
|
schema_id: "dd-flow/stage-finish-receipt@1",
|
|
633
619
|
run_id: runId,
|
|
@@ -790,17 +776,13 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
790
776
|
}
|
|
791
777
|
function preparePlanFinish(context, projectRoot, view, stageRoot) {
|
|
792
778
|
const planPath = planJsonPath(projectRoot, view.run.subject.id);
|
|
793
|
-
const canonical =
|
|
779
|
+
const canonical = prepareCanonicalPlan(context, {
|
|
794
780
|
projectId: view.run.project_id,
|
|
795
781
|
protocolId: view.run.subject.id,
|
|
796
782
|
planPath
|
|
797
783
|
});
|
|
798
784
|
const aspectMapPath = path.join(stageRoot, "aspect-map.json");
|
|
799
|
-
|
|
800
|
-
throw new AppError("not_found", "PLAN aspect-map.json is missing", 1, { path: aspectMapPath });
|
|
801
|
-
}
|
|
802
|
-
const raw = fs.readFileSync(aspectMapPath, "utf8");
|
|
803
|
-
const value = JSON.parse(raw);
|
|
785
|
+
const { text: raw, value } = prepareJsonFile(aspectMapPath, "PLAN aspect map");
|
|
804
786
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
805
787
|
throw new AppError("validation", "PLAN aspect-map.json must be a JSON object", 2, { path: aspectMapPath });
|
|
806
788
|
}
|
|
@@ -2,7 +2,6 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { quote } from "shell-quote";
|
|
6
5
|
import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
7
6
|
import { AppError } from "../shared/errors.js";
|
|
8
7
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
@@ -10,17 +9,25 @@ import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatc
|
|
|
10
9
|
import { requireProjectByRoot } from "./projects.js";
|
|
11
10
|
import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
|
|
12
11
|
import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
|
|
13
|
-
|
|
12
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
13
|
+
export function prepareStagePause(context, input) {
|
|
14
|
+
if (typeof input.question !== "string" || !input.question.trim())
|
|
15
|
+
throw new AppError("validation", "stage pause requires a non-empty user question", 2);
|
|
16
|
+
safeStage(input.stage);
|
|
14
17
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
15
18
|
const project = requireProjectByRoot(context, projectRoot);
|
|
16
19
|
const run = requireRun(context, project.id, input.runId);
|
|
20
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
17
21
|
const work = requireWork(context, project.id, run.id, input.workId);
|
|
18
22
|
if (work.status !== "running")
|
|
19
23
|
throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
|
|
20
24
|
const question = input.question;
|
|
21
|
-
if (!question.trim())
|
|
22
|
-
throw new AppError("validation", "stage pause requires a non-empty user question", 2);
|
|
23
25
|
const stage = requireStage(run, input.stage, "running");
|
|
26
|
+
requireRunHome(run);
|
|
27
|
+
return { projectRoot, project, run, work, question, stage };
|
|
28
|
+
}
|
|
29
|
+
export function pauseStageForUser(context, input) {
|
|
30
|
+
const { projectRoot, project, run, work, question, stage } = prepareStagePause(context, input);
|
|
24
31
|
const pauseId = nextPauseId(requireRunHome(run));
|
|
25
32
|
const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
|
|
26
33
|
const questionPath = path.join(pauseRoot, "question.md");
|
|
@@ -55,18 +62,38 @@ export function pauseStageForUser(context, input) {
|
|
|
55
62
|
resume_command_template: resumeTemplate
|
|
56
63
|
};
|
|
57
64
|
}
|
|
58
|
-
export function
|
|
65
|
+
export function prepareStageResume(context, input) {
|
|
66
|
+
if (typeof input.answer !== "string" || !input.answer.trim())
|
|
67
|
+
throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
|
|
68
|
+
safeStage(input.stage);
|
|
59
69
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
60
70
|
const project = requireProjectByRoot(context, projectRoot);
|
|
61
71
|
const run = requireRun(context, project.id, input.runId);
|
|
72
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
62
73
|
const work = requireWork(context, project.id, run.id, input.workId);
|
|
63
74
|
if (work.status !== "paused")
|
|
64
75
|
throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
|
|
65
76
|
const stage = requireStage(run, input.stage, "paused");
|
|
66
77
|
if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
|
|
67
78
|
throw new AppError("invalid_stage_state", "Stage has no unanswered pause for this Work", 1, { run_id: run.id, stage: stage.stage, work_id: work.work_id });
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
80
|
+
if (!workSession)
|
|
81
|
+
throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
|
|
82
|
+
let evidence = input.evidence;
|
|
83
|
+
if (evidence && (evidence.pauseId !== stage.pause.id || evidence.promptPath !== workSession.prompt_path || evidence.questionPath !== stage.pause.question_path))
|
|
84
|
+
throw new AppError("runtime_state_changed", "Paused Work changed after resume preparation", 1, { work_id: work.work_id });
|
|
85
|
+
if (!evidence) {
|
|
86
|
+
try {
|
|
87
|
+
evidence = { pauseId: stage.pause.id, promptPath: workSession.prompt_path, questionPath: stage.pause.question_path, prompt: fs.readFileSync(workSession.prompt_path, "utf8"), question: fs.readFileSync(stage.pause.question_path, "utf8") };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new AppError("runtime_missing", "Paused Work continuation artifacts cannot be read", 1, { work_id: work.work_id, cause: String(error) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { projectRoot, project, run, work, stage, pause: stage.pause, workSession, evidence };
|
|
94
|
+
}
|
|
95
|
+
export function resumeStageAfterUser(context, input) {
|
|
96
|
+
const { projectRoot, project, run, work, stage, pause, workSession, evidence } = prepareStageResume(context, input);
|
|
70
97
|
const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
|
|
71
98
|
projectId: project.id,
|
|
72
99
|
matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
|
|
@@ -74,10 +101,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
74
101
|
operation: "stage resume"
|
|
75
102
|
}).eventKey;
|
|
76
103
|
const now = context.now();
|
|
77
|
-
const
|
|
78
|
-
if (!workSession)
|
|
79
|
-
throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
|
|
80
|
-
const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
|
|
104
|
+
const answerPath = path.join(path.dirname(pause.question_path), `answer-${crypto.createHash("sha256").update(`${pause.id}\0${input.answer}`).digest("hex")}.md`);
|
|
81
105
|
context.db.beginWriteTransaction();
|
|
82
106
|
let identity;
|
|
83
107
|
try {
|
|
@@ -106,20 +130,20 @@ export function resumeStageAfterUser(context, input) {
|
|
|
106
130
|
}
|
|
107
131
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
108
132
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
|
|
109
|
-
const prompt =
|
|
133
|
+
const prompt = evidence.prompt;
|
|
110
134
|
const retryCommands = lifecycleRetryCommands(context, stage.stage);
|
|
111
135
|
const continuation = [
|
|
112
136
|
prompt.trimEnd(),
|
|
113
137
|
"",
|
|
114
138
|
"<hitl_resume>",
|
|
115
|
-
`- Pause: ${
|
|
116
|
-
`- Question source: ${
|
|
139
|
+
`- Pause: ${pause.id}`,
|
|
140
|
+
`- Question source: ${pause.question_path}`,
|
|
117
141
|
`- Answer source: ${answerPath}`,
|
|
118
142
|
"- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
|
|
119
|
-
...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands;
|
|
143
|
+
...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; correct the rejected input and use only these replacements:", ...retryCommands.map(command => ` ${command}`)] : []),
|
|
120
144
|
"",
|
|
121
145
|
"<user_question>",
|
|
122
|
-
|
|
146
|
+
evidence.question,
|
|
123
147
|
"</user_question>",
|
|
124
148
|
"",
|
|
125
149
|
"<user_answer>",
|
|
@@ -128,7 +152,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
128
152
|
"</hitl_resume>",
|
|
129
153
|
""
|
|
130
154
|
].join("\n");
|
|
131
|
-
return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id:
|
|
155
|
+
return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
|
|
132
156
|
}
|
|
133
157
|
export function stagePauseCommand(context, input) {
|
|
134
158
|
return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
|
|
@@ -160,8 +184,6 @@ function stageResumeCommand(context, input) {
|
|
|
160
184
|
return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
|
|
161
185
|
}
|
|
162
186
|
export function flowCommand(context) {
|
|
163
|
-
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
164
|
-
const prefix = scope ? `DD_FLOW_INVOCATION_SCOPE=${quote([scope])} ` : "";
|
|
165
187
|
const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
|
|
166
188
|
// Desktop/ACP tool processes are not required to inherit the environment of
|
|
167
189
|
// the adapter that created their Session. In an isolated runtime, a bare
|
|
@@ -170,13 +192,13 @@ export function flowCommand(context) {
|
|
|
170
192
|
// make every generated lifecycle command self-contained.
|
|
171
193
|
const executable = context.env.DD_FLOW_BIN;
|
|
172
194
|
if (executable && path.isAbsolute(executable)) {
|
|
173
|
-
return
|
|
195
|
+
return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} "$DD_FLOW_BIN"`;
|
|
174
196
|
}
|
|
175
197
|
const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
|
|
176
198
|
if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
|
|
177
|
-
return
|
|
199
|
+
return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
|
|
178
200
|
}
|
|
179
|
-
return context.ddFlowHome === defaultHome ?
|
|
201
|
+
return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
|
|
180
202
|
}
|
|
181
203
|
function requireRun(context, projectId, id) {
|
|
182
204
|
const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
|
|
@@ -191,8 +213,16 @@ function requireWork(context, projectId, runId, workId) {
|
|
|
191
213
|
return work;
|
|
192
214
|
}
|
|
193
215
|
function requireStage(run, stage, status) {
|
|
194
|
-
|
|
195
|
-
|
|
216
|
+
let index;
|
|
217
|
+
try {
|
|
218
|
+
index = JSON.parse(run.index_json);
|
|
219
|
+
if (!index || !Array.isArray(index.stage_runs))
|
|
220
|
+
throw new Error("RUN index must contain stage_runs");
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
|
|
224
|
+
}
|
|
225
|
+
const found = index.stage_runs.find((item) => item.stage === stage);
|
|
196
226
|
if (!found || found.status !== status)
|
|
197
227
|
throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
|
|
198
228
|
return found;
|