@deksden-com/dd-flow-cli 0.7.0 → 0.8.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 +666 -0
- package/README.md +7 -2
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +88 -10
- package/dist/cli/run-cli.js +523 -28
- package/dist/domain/stage-catalog.js +22 -0
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/code-review-decision.schema.json +26 -0
- package/dist/schemas/code-review-result.schema.json +14 -0
- package/dist/schemas/code-verification.schema.json +14 -0
- package/dist/schemas/code-work-batch.schema.json +24 -0
- package/dist/schemas/code-work-result.schema.json +16 -0
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +9 -5
- package/dist/schemas/flow-run.schema.json +16 -123
- package/dist/schemas/plan-aspect-map.schema.json +22 -0
- package/dist/schemas/plan-review-decision.schema.json +14 -0
- package/dist/schemas/plan-review-result.schema.json +42 -0
- package/dist/schemas/protocol-plan.schema.json +15 -182
- package/dist/schemas/stage-finish-input.schema.json +16 -2
- package/dist/schemas/stage-report.schema.json +8 -7
- 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 +37 -0
- package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
- package/dist/schemas/vnext-specify.schema.json +45 -0
- package/dist/services/branch-context.js +1 -1
- package/dist/services/cleanup.js +8 -8
- package/dist/services/cli-operation-classifier.js +10 -2
- package/dist/services/code-checks.js +244 -0
- package/dist/services/config.js +7 -1
- package/dist/services/dashboard.js +12 -12
- package/dist/services/engines.js +1 -1
- package/dist/services/eval-snapshots.js +404 -0
- package/dist/services/hooks.js +774 -18
- package/dist/services/ids.js +16 -6
- package/dist/services/lanes.js +1 -1
- package/dist/services/merge-queue.js +5 -5
- package/dist/services/merge-worker.js +2 -2
- package/dist/services/migrations.js +2 -2
- package/dist/services/plan-runtime.js +1 -1
- package/dist/services/projects.js +4 -4
- package/dist/services/prompts.js +17 -11
- package/dist/services/protocols.js +8 -8
- package/dist/services/run-projection.js +49 -13
- package/dist/services/runs.js +504 -51
- package/dist/services/schema-validation.js +21 -3
- package/dist/services/sessions.js +51 -12
- package/dist/services/stage-blocker.js +57 -0
- package/dist/services/stage-context.js +90 -0
- package/dist/services/stage-lifecycle.js +198 -75
- package/dist/services/stage-pause.js +175 -0
- package/dist/services/stage-report-renderer.js +65 -0
- package/dist/services/usage.js +526 -18
- package/dist/services/vnext-code-review.js +305 -0
- package/dist/services/vnext-code.js +686 -0
- package/dist/services/vnext-contracts.js +1 -0
- package/dist/services/vnext-execution-profile.js +27 -0
- package/dist/services/vnext-fanout.js +79 -0
- package/dist/services/vnext-plan-review.js +499 -0
- package/dist/services/vnext-plan.js +552 -0
- package/dist/services/vnext-protocolize.js +542 -0
- package/dist/services/vnext-specify.js +595 -0
- package/dist/services/vnext-workspace-policy.js +87 -0
- package/dist/services/work-registry.js +522 -0
- package/dist/services/worktrees.js +58 -37
- package/dist/storage/database.js +263 -34
- package/dist/storage/paths.js +47 -1
- package/package.json +12 -12
|
@@ -17,6 +17,7 @@ import { preflightMemoryPermissions } from "./memory-permissions.js";
|
|
|
17
17
|
import { registerProject } from "./projects.js";
|
|
18
18
|
import { registerProtocol } from "./protocols.js";
|
|
19
19
|
import { previewNextEntityId } from "./ids.js";
|
|
20
|
+
import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
|
|
20
21
|
const stagePromptSections = [
|
|
21
22
|
"stage_identity",
|
|
22
23
|
"authoritative_runtime_facts",
|
|
@@ -101,7 +102,17 @@ function runtimeFacts(view, projectRoot) {
|
|
|
101
102
|
};
|
|
102
103
|
}
|
|
103
104
|
export function startStage(context, input) {
|
|
105
|
+
if (input.requireSessionBinding && !input.hookEventId) {
|
|
106
|
+
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);
|
|
107
|
+
}
|
|
104
108
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
109
|
+
let requiredSessionId = null;
|
|
110
|
+
let requiredIdentity = null;
|
|
111
|
+
if (input.requireSessionBinding) {
|
|
112
|
+
registerProject(context, { root: projectRoot });
|
|
113
|
+
requiredIdentity = hookSessionIdentity(context, requireProjectByRoot(context, projectRoot).id, input.hookEventId);
|
|
114
|
+
requiredSessionId = requiredIdentity.sessionId;
|
|
115
|
+
}
|
|
105
116
|
const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
|
|
106
117
|
if (!runId)
|
|
107
118
|
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
@@ -125,11 +136,22 @@ export function startStage(context, input) {
|
|
|
125
136
|
if (attached.run.subject.type === "protocol") {
|
|
126
137
|
syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
|
|
127
138
|
}
|
|
128
|
-
|
|
139
|
+
const sessionId = requiredSessionId ?? (input.hookEventId ? sessionIdForHookEvent(context, attached.run.project_id, input.hookEventId) : null);
|
|
140
|
+
if (sessionId) {
|
|
141
|
+
const identity = requiredIdentity ?? (input.hookEventId ? hookSessionIdentity(context, attached.run.project_id, input.hookEventId) : null);
|
|
129
142
|
registerFlowSession(context, {
|
|
130
|
-
sessionId
|
|
143
|
+
sessionId,
|
|
131
144
|
payloadJson: JSON.stringify({
|
|
132
145
|
project_root: projectRoot,
|
|
146
|
+
harness: identity?.harness ?? "codex-desktop",
|
|
147
|
+
provider_session_id: identity?.providerSessionId ?? sessionId,
|
|
148
|
+
agent_id: identity?.agentId ?? null,
|
|
149
|
+
provider: identity?.provider ?? null,
|
|
150
|
+
model: identity?.model ?? null,
|
|
151
|
+
reasoning: identity?.reasoning ?? null,
|
|
152
|
+
mode: identity?.mode ?? null,
|
|
153
|
+
agent_type: identity?.agentType ?? null,
|
|
154
|
+
parent_session_id: identity?.parentSessionId ?? null,
|
|
133
155
|
flow_kind: flowKindForStage(input.stage),
|
|
134
156
|
run_id: attached.run.id,
|
|
135
157
|
protocol_id: attached.run.subject.id,
|
|
@@ -145,6 +167,10 @@ export function startStage(context, input) {
|
|
|
145
167
|
const promptPath = path.join(stageRoot, "stage-prompt.md");
|
|
146
168
|
const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
|
|
147
169
|
atomicWrite(promptPath, prompt);
|
|
170
|
+
const semanticInputPath = path.join(stageRoot, "stage-input.json");
|
|
171
|
+
if (!fs.existsSync(semanticInputPath)) {
|
|
172
|
+
atomicWrite(semanticInputPath, semanticInputTemplate(input.stage));
|
|
173
|
+
}
|
|
148
174
|
const promptDataPath = path.join(stageRoot, "stage-prompt.json");
|
|
149
175
|
const planPath = planJsonPath(projectRoot, attached.run.subject.id);
|
|
150
176
|
const aspectMapPath = path.join(stageRoot, "aspect-map.json");
|
|
@@ -165,6 +191,7 @@ export function startStage(context, input) {
|
|
|
165
191
|
stage: stageRoot,
|
|
166
192
|
protocol: attached.run.subject.id,
|
|
167
193
|
intake: path.join(runHome, "intake"),
|
|
194
|
+
input: semanticInputPath,
|
|
168
195
|
...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
|
|
169
196
|
},
|
|
170
197
|
write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
|
|
@@ -178,8 +205,8 @@ export function startStage(context, input) {
|
|
|
178
205
|
},
|
|
179
206
|
permissions: preflight,
|
|
180
207
|
session_binding: {
|
|
181
|
-
status:
|
|
182
|
-
session_id:
|
|
208
|
+
status: sessionId ? "bound" : "unavailable",
|
|
209
|
+
session_id: sessionId
|
|
183
210
|
}
|
|
184
211
|
},
|
|
185
212
|
required_context: sources.map((source) => ({
|
|
@@ -189,7 +216,7 @@ export function startStage(context, input) {
|
|
|
189
216
|
})),
|
|
190
217
|
worker_prompt_markdown: prompt
|
|
191
218
|
});
|
|
192
|
-
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
|
|
219
|
+
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, ddFlowHome: context.ddFlowHome });
|
|
193
220
|
atomicWrite(path.join(stageRoot, "stage-start.json"), {
|
|
194
221
|
schema_id: "dd-flow/stage-start@2",
|
|
195
222
|
run_id: attached.run.id,
|
|
@@ -208,6 +235,7 @@ export function startStage(context, input) {
|
|
|
208
235
|
stage_root: stageRoot,
|
|
209
236
|
archive_path: null,
|
|
210
237
|
prompt_path: promptPath,
|
|
238
|
+
semantic_input_path: semanticInputPath,
|
|
211
239
|
aliases: {
|
|
212
240
|
project: projectRoot,
|
|
213
241
|
workspace: attached.run.workspace_root,
|
|
@@ -243,50 +271,71 @@ export function finishStage(context, input) {
|
|
|
243
271
|
ensureWithin(runHome, stageRoot, "stage root");
|
|
244
272
|
fs.mkdirSync(stageRoot, { recursive: true });
|
|
245
273
|
const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage);
|
|
275
|
+
try {
|
|
276
|
+
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, ddFlowHome: context.ddFlowHome });
|
|
277
|
+
const semantic = readSemanticFile(semanticFile, runHome);
|
|
278
|
+
const status = stringValue(semantic.status) ?? "done";
|
|
279
|
+
if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
|
|
280
|
+
throw new AppError("validation", "Stage finish status must be done, waiting_for_user, blocked, or failed", 2, { status });
|
|
281
|
+
}
|
|
282
|
+
validateStageOutcome(input.stage, status, semantic);
|
|
283
|
+
const coverage = workerCoverage(context, view.run.project_id, view.run.id);
|
|
284
|
+
if (coverage.jobs.missing?.length) {
|
|
285
|
+
throw new AppError("worker_jobs_incomplete", "Stage finish requires all declared worker jobs", 1, { coverage });
|
|
286
|
+
}
|
|
287
|
+
const lint = runTargetedMemoryBankLint(projectRoot);
|
|
288
|
+
const planFinish = input.stage === "plan"
|
|
289
|
+
? preparePlanFinish(context, projectRoot, view, stageRoot)
|
|
290
|
+
: undefined;
|
|
291
|
+
const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
|
|
292
|
+
const dataPath = path.join(stageRoot, "stage-report.json");
|
|
293
|
+
const reportPath = path.join(stageRoot, "stage-report.md");
|
|
294
|
+
const htmlPath = path.join(stageRoot, "stage-report.html");
|
|
295
|
+
atomicWrite(dataPath, report);
|
|
296
|
+
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, ddFlowHome: context.ddFlowHome });
|
|
297
|
+
atomicWrite(reportPath, renderMarkdown(report));
|
|
298
|
+
atomicWrite(htmlPath, renderHtml(projectRoot, report));
|
|
299
|
+
if (view.run.subject.type === "protocol") {
|
|
300
|
+
syncProtocolLifecycle(context, view.run.project_id, view.run.subject.id, input.stage, status, stringValue(semantic.next_action));
|
|
301
|
+
}
|
|
302
|
+
const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
|
|
303
|
+
const completed = completeFlowRunStage(context, {
|
|
304
|
+
projectRoot,
|
|
305
|
+
runId,
|
|
306
|
+
stage: input.stage,
|
|
307
|
+
status,
|
|
308
|
+
stageReport: htmlPath,
|
|
309
|
+
data: dataPath,
|
|
310
|
+
dataSchemaId: String(report.schema_id),
|
|
311
|
+
report: reportPath
|
|
312
|
+
});
|
|
313
|
+
finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
|
|
314
|
+
appendAudit(context, {
|
|
315
|
+
projectId: view.run.project_id,
|
|
316
|
+
...(view.run.subject.type === "protocol" ? { protocolId: view.run.subject.id } : {}),
|
|
317
|
+
eventType: "stage.finish.accepted",
|
|
318
|
+
payload: { run_id: runId, stage: input.stage, status, receipt: receipt.path, semantic_sha256: receipt.semantic_sha256 }
|
|
319
|
+
});
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
schema_id: "dd-flow/stage-finish@1",
|
|
323
|
+
...(completed && typeof completed === "object" && !Array.isArray(completed) ? completed : {}),
|
|
324
|
+
artifacts: { json: dataPath, markdown: reportPath, html: htmlPath, finish_receipt: receipt.path, ...(summaryPath ? { protocol_summary: summaryPath } : {}) },
|
|
325
|
+
gates: { worker_coverage: coverage, targeted_lint: lint },
|
|
326
|
+
generated: true
|
|
327
|
+
};
|
|
255
328
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot });
|
|
266
|
-
atomicWrite(reportPath, renderMarkdown(report));
|
|
267
|
-
atomicWrite(htmlPath, renderHtml(projectRoot, report));
|
|
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));
|
|
329
|
+
catch (error) {
|
|
330
|
+
finishReceipt(receipt, { outcome: "rejected", error: finishError(error) });
|
|
331
|
+
appendAudit(context, {
|
|
332
|
+
projectId: view.run.project_id,
|
|
333
|
+
...(view.run.subject.type === "protocol" ? { protocolId: view.run.subject.id } : {}),
|
|
334
|
+
eventType: "stage.finish.rejected",
|
|
335
|
+
payload: { run_id: runId, stage: input.stage, receipt: receipt.path, semantic_sha256: receipt.semantic_sha256, error: finishError(error) }
|
|
336
|
+
});
|
|
337
|
+
throw error;
|
|
270
338
|
}
|
|
271
|
-
const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
|
|
272
|
-
const completed = completeFlowRunStage(context, {
|
|
273
|
-
projectRoot,
|
|
274
|
-
runId,
|
|
275
|
-
stage: input.stage,
|
|
276
|
-
status,
|
|
277
|
-
stageReport: htmlPath,
|
|
278
|
-
data: dataPath,
|
|
279
|
-
dataSchemaId: String(report.schema_id),
|
|
280
|
-
report: reportPath
|
|
281
|
-
});
|
|
282
|
-
return {
|
|
283
|
-
ok: true,
|
|
284
|
-
schema_id: "dd-flow/stage-finish@1",
|
|
285
|
-
...(completed && typeof completed === "object" && !Array.isArray(completed) ? completed : {}),
|
|
286
|
-
artifacts: { json: dataPath, markdown: reportPath, html: htmlPath, ...(summaryPath ? { protocol_summary: summaryPath } : {}) },
|
|
287
|
-
gates: { worker_coverage: coverage, targeted_lint: lint },
|
|
288
|
-
generated: true
|
|
289
|
-
};
|
|
290
339
|
}
|
|
291
340
|
function runView(value) {
|
|
292
341
|
if (!value || typeof value !== "object")
|
|
@@ -302,12 +351,9 @@ function syncProtocolLifecycle(context, projectId, protocolId, stage, status, ne
|
|
|
302
351
|
return;
|
|
303
352
|
const protocol = requireProtocol(context, protocolId, projectId);
|
|
304
353
|
const { state } = readProtocolRuntimeState(context, protocol);
|
|
305
|
-
const
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
const completedRank = protocolStageRank(completedTarget);
|
|
309
|
-
const effectiveStage = completedRank >= currentRank ? completedTarget : state.stage;
|
|
310
|
-
const effectiveStatus = status === "done" ? "running" : status;
|
|
354
|
+
const transition = lifecycleTransition(targetStage, status);
|
|
355
|
+
const effectiveStage = transition.stage;
|
|
356
|
+
const effectiveStatus = transition.status;
|
|
311
357
|
const effectiveNextAction = nextAction ?? nextActionForProtocolStage(effectiveStage);
|
|
312
358
|
if (effectiveStage === state.stage && effectiveStatus === state.status && effectiveNextAction === state.next_action)
|
|
313
359
|
return;
|
|
@@ -328,7 +374,7 @@ function syncProtocolLifecycle(context, projectId, protocolId, stage, status, ne
|
|
|
328
374
|
from: state.stage,
|
|
329
375
|
to: effectiveStage,
|
|
330
376
|
status: effectiveStatus,
|
|
331
|
-
|
|
377
|
+
outcome: status
|
|
332
378
|
}
|
|
333
379
|
});
|
|
334
380
|
}
|
|
@@ -341,11 +387,18 @@ function protocolStageForRunStage(stage) {
|
|
|
341
387
|
return "implementation";
|
|
342
388
|
return null;
|
|
343
389
|
}
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
390
|
+
function lifecycleTransition(stage, status) {
|
|
391
|
+
if (status === "running")
|
|
392
|
+
return { stage, status };
|
|
393
|
+
if (status === "waiting_for_user" || status === "blocked" || status === "failed")
|
|
394
|
+
return { stage, status };
|
|
395
|
+
if (status !== "done")
|
|
396
|
+
throw new AppError("invalid_transition", `Unknown stage outcome: ${status}`, 2);
|
|
397
|
+
return stage === "specify"
|
|
398
|
+
? { stage: "plan", status: "running" }
|
|
399
|
+
: stage === "plan"
|
|
400
|
+
? { stage: "implementation", status: "running" }
|
|
401
|
+
: { stage: "readiness", status: "running" };
|
|
349
402
|
}
|
|
350
403
|
function nextActionForProtocolStage(stage) {
|
|
351
404
|
if (stage === "registered" || stage === "specify")
|
|
@@ -377,6 +430,8 @@ export function defaultStageDir(stage, flowKind) {
|
|
|
377
430
|
return known[normalized] ?? `03-${normalized.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
|
378
431
|
}
|
|
379
432
|
function dataSchemaForStage(stage) {
|
|
433
|
+
if (stage === "specify")
|
|
434
|
+
return "dd-flow/stage-finish-input@1";
|
|
380
435
|
if (stage === "plan")
|
|
381
436
|
return "dd-flow/plan-stage-report@5";
|
|
382
437
|
if (stage === "code" || stage === "implementation")
|
|
@@ -396,7 +451,27 @@ function stageReportSchemaName(stage, planFinish) {
|
|
|
396
451
|
}
|
|
397
452
|
function stageFinishCommand(run, projectRoot, stage) {
|
|
398
453
|
const compatibilityMode = run.flow_kind === "mb-upgrade" ? " --compatibility-mode mb-upgrade" : "";
|
|
399
|
-
return `dd-flow stage finish ${run.id} --stage ${stage} --
|
|
454
|
+
return `dd-flow stage finish ${run.id} --stage ${stage} --project-root ${JSON.stringify(projectRoot)}${compatibilityMode} --json`;
|
|
455
|
+
}
|
|
456
|
+
function semanticInputTemplate(stage) {
|
|
457
|
+
const waiting = stage === "specify";
|
|
458
|
+
return {
|
|
459
|
+
schema_id: "dd-flow/stage-finish-input@1",
|
|
460
|
+
status: waiting ? "waiting_for_user" : "done",
|
|
461
|
+
result: "Replace this placeholder with the stage result.",
|
|
462
|
+
acceptance: ["Replace with the acceptance evidence for this stage."],
|
|
463
|
+
checks: ["Replace with a check actually performed."],
|
|
464
|
+
evidence: ["Replace with a durable evidence path or reference."],
|
|
465
|
+
...(waiting ? {
|
|
466
|
+
questions: [{
|
|
467
|
+
id: "Q-001",
|
|
468
|
+
question: "Replace with an unresolved product decision.",
|
|
469
|
+
impact: "Replace with the consequence of leaving it unresolved.",
|
|
470
|
+
recommendation: "Replace with the smallest recommended decision."
|
|
471
|
+
}]
|
|
472
|
+
} : {}),
|
|
473
|
+
next_action: waiting ? "waiting_for_user: obtain the clarification packet" : "Continue to the next approved stage."
|
|
474
|
+
};
|
|
400
475
|
}
|
|
401
476
|
function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
|
|
402
477
|
const project = requireProjectByRoot(context, projectRoot);
|
|
@@ -405,8 +480,6 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
|
|
|
405
480
|
return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
|
|
406
481
|
}).join("\n\n");
|
|
407
482
|
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");
|
|
410
483
|
return [
|
|
411
484
|
"<stage_identity>",
|
|
412
485
|
"# Stage work packet",
|
|
@@ -443,7 +516,7 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
|
|
|
443
516
|
"</applicable_instructions>",
|
|
444
517
|
"",
|
|
445
518
|
"<required_context>",
|
|
446
|
-
|
|
519
|
+
"The bounded canonical instructions above are already included in this packet. Do not reopen them, prime.md, indexes, CLI help, Git status, or runtime state unless new semantic evidence makes a specific source necessary.",
|
|
447
520
|
"</required_context>",
|
|
448
521
|
"",
|
|
449
522
|
"<work_contract>",
|
|
@@ -453,7 +526,9 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
|
|
|
453
526
|
"",
|
|
454
527
|
"<completion_contract>",
|
|
455
528
|
`Write semantic output to \`@stage/stage-input.json\`, then run: \`${stageFinishCommand(current.run, projectRoot, stage)}\`.`,
|
|
529
|
+
"`@stage/stage-input.json` already contains the only valid field layout. Replace every placeholder; do not add mechanical fields such as changed_files. Set `status` to `done`, `waiting_for_user`, `blocked`, or `failed`. For `waiting_for_user`, preserve each question as a structured item in `questions` with id, question, impact, and recommended answer when known. For SPECIFY, `done` means there are no unresolved structured questions.",
|
|
456
530
|
"The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
|
|
531
|
+
"If finish rejects a required semantic status or a successful receipt disagrees with it, stop immediately with `flow_contract_conflict`: do not rewrite the result to another permitted status, retry finish, repair RUN state, or start a downstream stage.",
|
|
457
532
|
"</completion_contract>",
|
|
458
533
|
].join("\n");
|
|
459
534
|
}
|
|
@@ -478,7 +553,9 @@ function stageInstructionSources(context, projectRoot, flowKind, stage) {
|
|
|
478
553
|
.map((file) => ({ label: `canonical:${file}`, absolute: path.join(flowRoot, file) }));
|
|
479
554
|
}
|
|
480
555
|
const filesByStage = {
|
|
481
|
-
specify: [
|
|
556
|
+
specify: [
|
|
557
|
+
".memory-bank/dd-flow/mb-sdlc/specify/packet.md"
|
|
558
|
+
],
|
|
482
559
|
plan: [".memory-bank/dd-flow/plan.md"],
|
|
483
560
|
code: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
|
|
484
561
|
implementation: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
|
|
@@ -507,6 +584,49 @@ function readSemanticFile(file, runHome) {
|
|
|
507
584
|
throw new AppError("validation", "Semantic input must be a JSON object", 2);
|
|
508
585
|
return value;
|
|
509
586
|
}
|
|
587
|
+
function validateStageOutcome(stage, status, semantic) {
|
|
588
|
+
if (stage !== "specify")
|
|
589
|
+
return;
|
|
590
|
+
const questions = Array.isArray(semantic.questions) ? semantic.questions : [];
|
|
591
|
+
if (status === "waiting_for_user" && questions.length === 0) {
|
|
592
|
+
throw new AppError("semantic_status_inconsistent", "SPECIFY waiting_for_user requires at least one structured question", 2);
|
|
593
|
+
}
|
|
594
|
+
if (status === "done" && questions.length > 0) {
|
|
595
|
+
throw new AppError("semantic_status_inconsistent", "SPECIFY done cannot contain unresolved structured questions", 2);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage) {
|
|
599
|
+
const receiptDir = path.join(stageRoot, "finish-receipts");
|
|
600
|
+
fs.mkdirSync(receiptDir, { recursive: true });
|
|
601
|
+
const number = fs.readdirSync(receiptDir).filter((file) => /^\d{3}\.json$/u.test(file)).length + 1;
|
|
602
|
+
const receiptPath = path.join(receiptDir, `${String(number).padStart(3, "0")}.json`);
|
|
603
|
+
let raw = null;
|
|
604
|
+
try {
|
|
605
|
+
raw = fs.readFileSync(semanticFile, "utf8");
|
|
606
|
+
}
|
|
607
|
+
catch { /* receipt records the missing input below */ }
|
|
608
|
+
const semanticSha = raw === null ? null : crypto.createHash("sha256").update(raw).digest("hex");
|
|
609
|
+
atomicWrite(receiptPath, {
|
|
610
|
+
schema_id: "dd-flow/stage-finish-receipt@1",
|
|
611
|
+
run_id: runId,
|
|
612
|
+
stage,
|
|
613
|
+
attempted_at: context.now(),
|
|
614
|
+
semantic_file: semanticFile,
|
|
615
|
+
semantic_sha256: semanticSha,
|
|
616
|
+
semantic_input_raw: raw,
|
|
617
|
+
outcome: "pending"
|
|
618
|
+
});
|
|
619
|
+
return { path: receiptPath, semantic_sha256: semanticSha };
|
|
620
|
+
}
|
|
621
|
+
function finishReceipt(receipt, outcome) {
|
|
622
|
+
const current = JSON.parse(fs.readFileSync(receipt.path, "utf8"));
|
|
623
|
+
atomicWrite(receipt.path, { ...current, ...outcome, finished_at: new Date().toISOString() });
|
|
624
|
+
}
|
|
625
|
+
function finishError(error) {
|
|
626
|
+
if (error instanceof AppError)
|
|
627
|
+
return { code: error.code, message: error.message, details: error.details };
|
|
628
|
+
return { code: "unexpected", message: String(error) };
|
|
629
|
+
}
|
|
510
630
|
function resolveStageFile(file, stageRoot, runHome) {
|
|
511
631
|
const candidate = file === "@stage" || file.startsWith("@stage/")
|
|
512
632
|
? path.join(stageRoot, file.slice("@stage".length).replace(/^[/\\]/u, ""))
|
|
@@ -516,7 +636,7 @@ function resolveStageFile(file, stageRoot, runHome) {
|
|
|
516
636
|
}
|
|
517
637
|
function buildStageReport(context, view, stage, status, semantic, stageRoot, lint, planFinish) {
|
|
518
638
|
const result = stringValue(semantic.result) ?? `Stage ${stage} finished with status ${status}.`;
|
|
519
|
-
const changedFiles =
|
|
639
|
+
const changedFiles = gitChangedFiles(view.run.workspace_root);
|
|
520
640
|
const checks = stringArray(semantic.checks, []);
|
|
521
641
|
const evidence = stringArray(semantic.evidence, []);
|
|
522
642
|
const acceptance = stringArray(semantic.acceptance, []);
|
|
@@ -608,11 +728,12 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
608
728
|
};
|
|
609
729
|
}
|
|
610
730
|
return {
|
|
611
|
-
schema_id: "dd-flow/stage-report@
|
|
731
|
+
schema_id: "dd-flow/stage-report@2",
|
|
612
732
|
run_id: view.run.id,
|
|
613
733
|
stage,
|
|
614
734
|
generated_at: finishedAt,
|
|
615
735
|
verdict: status,
|
|
736
|
+
summary: result,
|
|
616
737
|
semantic: {
|
|
617
738
|
result,
|
|
618
739
|
acceptance,
|
|
@@ -620,6 +741,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
620
741
|
checks,
|
|
621
742
|
evidence,
|
|
622
743
|
next_action: stringValue(semantic.next_action) ?? "Resolve the stage result before continuing.",
|
|
744
|
+
...(Array.isArray(semantic.questions) ? { questions: semantic.questions } : {}),
|
|
623
745
|
...(Array.isArray(semantic.reviewer_findings) ? { reviewer_findings: semantic.reviewer_findings } : {}),
|
|
624
746
|
...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {})
|
|
625
747
|
},
|
|
@@ -634,8 +756,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
634
756
|
artifacts: {
|
|
635
757
|
json: path.join(stageRoot, "stage-report.json"),
|
|
636
758
|
markdown: path.join(stageRoot, "stage-report.md"),
|
|
637
|
-
html: path.join(stageRoot, "stage-report.html")
|
|
638
|
-
summary: path.join(stageRoot, "stage-report.md")
|
|
759
|
+
html: path.join(stageRoot, "stage-report.html")
|
|
639
760
|
},
|
|
640
761
|
validation: {
|
|
641
762
|
permission_scope: "known_targets_only",
|
|
@@ -706,7 +827,7 @@ function gitChangedFiles(workspaceRoot) {
|
|
|
706
827
|
return result.stdout.split(/\r?\n/).filter(Boolean).map((line) => line.slice(3).trim()).filter(Boolean);
|
|
707
828
|
}
|
|
708
829
|
function workerCoverage(context, projectId, runId) {
|
|
709
|
-
const sessions = context.db.all("SELECT * FROM
|
|
830
|
+
const sessions = context.db.all("SELECT * FROM sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id", [projectId, runId]);
|
|
710
831
|
const report = reconcileSessionCoverage(sessions);
|
|
711
832
|
const jobs = context.db.all("SELECT job_id, status FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY job_id", [projectId, runId]);
|
|
712
833
|
const missingJobs = jobs.filter((job) => job.status !== "done").map((job) => job.job_id);
|
|
@@ -720,9 +841,8 @@ function workerCoverage(context, projectId, runId) {
|
|
|
720
841
|
jobs: { expected: jobs.map((job) => job.job_id), missing: missingJobs }
|
|
721
842
|
};
|
|
722
843
|
}
|
|
723
|
-
function runTargetedMemoryBankLint(projectRoot,
|
|
724
|
-
const
|
|
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"))))];
|
|
844
|
+
export function runTargetedMemoryBankLint(projectRoot, selectedFiles) {
|
|
845
|
+
const files = [...new Set((selectedFiles ?? gitChangedFiles(projectRoot)).filter((file) => file.startsWith(".memory-bank/") && file.toLowerCase().endsWith(".md") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
|
|
726
846
|
for (const file of files)
|
|
727
847
|
ensureWithin(projectRoot, path.resolve(projectRoot, file), "lint target");
|
|
728
848
|
if (files.length === 0) {
|
|
@@ -769,16 +889,19 @@ function renderMarkdown(report) {
|
|
|
769
889
|
}
|
|
770
890
|
function renderHtml(projectRoot, report) {
|
|
771
891
|
const plan = report.schema_id === "dd-flow/plan-stage-report@5";
|
|
892
|
+
const specify = report.schema_id === "dd-flow/stage-report@2" && report.stage === "specify";
|
|
772
893
|
const templatePath = path.join(projectRoot, plan
|
|
773
894
|
? ".memory-bank/dd-flow/mb-sdlc/plan/stage-report-template.html"
|
|
774
|
-
:
|
|
895
|
+
: specify
|
|
896
|
+
? ".memory-bank/dd-flow/mb-sdlc/specify/stage-report-template.html"
|
|
897
|
+
: ".memory-bank/dd-flow/mb-sdlc/code/stage-report-template.html");
|
|
775
898
|
if (!fs.existsSync(templatePath))
|
|
776
|
-
throw new AppError("stage_report_template_missing", "
|
|
899
|
+
throw new AppError("stage_report_template_missing", "Stage report template is missing", 1, { path: templatePath });
|
|
777
900
|
const template = fs.readFileSync(templatePath, "utf8");
|
|
778
901
|
const canonicalMarker = "__STAGE_REPORT_DATA__";
|
|
779
902
|
const legacyMarker = "__CODE_DASHBOARD_DATA__";
|
|
780
903
|
if (!template.includes(canonicalMarker) && (!template.includes("script id=\"code-data\"") || !template.includes(legacyMarker))) {
|
|
781
|
-
throw new AppError("stage_report_template_invalid", "
|
|
904
|
+
throw new AppError("stage_report_template_invalid", "Stage report template is missing required anchors", 1, { path: templatePath });
|
|
782
905
|
}
|
|
783
906
|
const embedded = JSON.stringify(report).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
784
907
|
return template.replace(canonicalMarker, embedded).replace(legacyMarker, embedded);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
+
import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
|
|
8
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
9
|
+
import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
|
|
10
|
+
import { refreshRunWorkProjection } from "./work-registry.js";
|
|
11
|
+
export function pauseStageForUser(context, input) {
|
|
12
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
13
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
14
|
+
const run = requireRun(context, project.id, input.runId);
|
|
15
|
+
const work = requireWork(context, project.id, run.id, input.workId);
|
|
16
|
+
if (work.status !== "running")
|
|
17
|
+
throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
|
|
18
|
+
const question = input.question;
|
|
19
|
+
if (!question.trim())
|
|
20
|
+
throw new AppError("validation", "stage pause requires a non-empty user question", 2);
|
|
21
|
+
const stage = requireStage(run, input.stage, "running");
|
|
22
|
+
const pauseId = nextPauseId(requireRunHome(run));
|
|
23
|
+
const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
|
|
24
|
+
const questionPath = path.join(pauseRoot, "question.md");
|
|
25
|
+
fs.mkdirSync(pauseRoot, { recursive: true });
|
|
26
|
+
fs.writeFileSync(questionPath, question);
|
|
27
|
+
const now = context.now();
|
|
28
|
+
context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
|
|
29
|
+
pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
|
|
30
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
31
|
+
const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
|
|
32
|
+
const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
outcome: "paused",
|
|
36
|
+
run_id: run.id,
|
|
37
|
+
work_id: work.work_id,
|
|
38
|
+
stage: stage.stage,
|
|
39
|
+
pause: { id: pauseId, reason: "waiting_for_user", question_path: questionPath, user_message: question },
|
|
40
|
+
next_action: "ask_user_then_resume_same_stage",
|
|
41
|
+
agent_instruction: "Send user_message to the user and stop this Turn. When the user answers, do not interpret or edit the answer first: make resume_command the first flow command, pass the complete raw answer on stdin, then follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.",
|
|
42
|
+
resume_command: resume,
|
|
43
|
+
resume_command_template: resumeTemplate
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function resumeStageAfterUser(context, input) {
|
|
47
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
48
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
49
|
+
const run = requireRun(context, project.id, input.runId);
|
|
50
|
+
const work = requireWork(context, project.id, run.id, input.workId);
|
|
51
|
+
if (work.status !== "paused")
|
|
52
|
+
throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
|
|
53
|
+
const stage = requireStage(run, input.stage, "paused");
|
|
54
|
+
if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
|
|
55
|
+
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 });
|
|
56
|
+
if (!input.answer.trim())
|
|
57
|
+
throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
|
|
58
|
+
const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
|
|
59
|
+
projectId: project.id,
|
|
60
|
+
matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
|
|
61
|
+
errorCode: "trusted_stage_resume_required",
|
|
62
|
+
operation: "stage resume"
|
|
63
|
+
}).eventKey;
|
|
64
|
+
const identity = claimStageResumeHookEvent(context, {
|
|
65
|
+
projectId: project.id,
|
|
66
|
+
runId: run.id,
|
|
67
|
+
stage: stage.stage,
|
|
68
|
+
workId: work.work_id,
|
|
69
|
+
projectRoot,
|
|
70
|
+
eventKey: hookEventId
|
|
71
|
+
});
|
|
72
|
+
const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
|
|
73
|
+
fs.writeFileSync(answerPath, input.answer);
|
|
74
|
+
const now = context.now();
|
|
75
|
+
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]);
|
|
76
|
+
if (!workSession)
|
|
77
|
+
throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
|
|
78
|
+
if (workSession.session_id !== identity.sessionId) {
|
|
79
|
+
context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
|
|
80
|
+
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)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
|
|
81
|
+
}
|
|
82
|
+
context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
|
|
83
|
+
resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
|
|
84
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
85
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
|
|
86
|
+
const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
|
|
87
|
+
const continuation = [
|
|
88
|
+
prompt.trimEnd(),
|
|
89
|
+
"",
|
|
90
|
+
"<hitl_resume>",
|
|
91
|
+
`- Pause: ${stage.pause.id}`,
|
|
92
|
+
`- Question source: ${stage.pause.question_path}`,
|
|
93
|
+
`- Answer source: ${answerPath}`,
|
|
94
|
+
"- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
|
|
95
|
+
"",
|
|
96
|
+
"<user_question>",
|
|
97
|
+
fs.readFileSync(stage.pause.question_path, "utf8"),
|
|
98
|
+
"</user_question>",
|
|
99
|
+
"",
|
|
100
|
+
"<user_answer>",
|
|
101
|
+
input.answer,
|
|
102
|
+
"</user_answer>",
|
|
103
|
+
"</hitl_resume>",
|
|
104
|
+
""
|
|
105
|
+
].join("\n");
|
|
106
|
+
return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: stage.pause.id, question_path: stage.pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
|
|
107
|
+
}
|
|
108
|
+
export function stagePauseCommand(context, input) {
|
|
109
|
+
return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The only shell form allowed for an agent-owned HITL pause. Supplying a
|
|
113
|
+
* complete heredoc makes stdin explicit without asking an agent to invent a
|
|
114
|
+
* pipe, a temporary file or a second command.
|
|
115
|
+
*/
|
|
116
|
+
export function stagePauseCommandTemplate(command) {
|
|
117
|
+
return `${command} <<'USER_QUESTION'
|
|
118
|
+
## Q-001 — <short decision title>
|
|
119
|
+
|
|
120
|
+
Why this decision is required:
|
|
121
|
+
- <one sentence>
|
|
122
|
+
|
|
123
|
+
Options:
|
|
124
|
+
- <option A>
|
|
125
|
+
- <option B>
|
|
126
|
+
|
|
127
|
+
Recommendation:
|
|
128
|
+
- <one option and reason>
|
|
129
|
+
|
|
130
|
+
Effect on scope or acceptance:
|
|
131
|
+
- <one sentence>
|
|
132
|
+
USER_QUESTION`;
|
|
133
|
+
}
|
|
134
|
+
function stageResumeCommand(context, input) {
|
|
135
|
+
return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
|
|
136
|
+
}
|
|
137
|
+
export function flowCommand(context) {
|
|
138
|
+
const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
|
|
139
|
+
return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
|
|
140
|
+
}
|
|
141
|
+
function requireRun(context, projectId, id) {
|
|
142
|
+
const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
|
|
143
|
+
if (rows.length !== 1)
|
|
144
|
+
throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: id });
|
|
145
|
+
return rows[0];
|
|
146
|
+
}
|
|
147
|
+
function requireWork(context, projectId, runId, workId) {
|
|
148
|
+
const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
|
|
149
|
+
if (!work)
|
|
150
|
+
throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
|
|
151
|
+
return work;
|
|
152
|
+
}
|
|
153
|
+
function requireStage(run, stage, status) {
|
|
154
|
+
const index = JSON.parse(run.index_json);
|
|
155
|
+
const found = index.stage_runs?.find((item) => item.stage === stage);
|
|
156
|
+
if (!found || found.status !== status)
|
|
157
|
+
throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
function requireRunHome(run) {
|
|
161
|
+
if (!run.run_home_path)
|
|
162
|
+
throw new AppError("runtime_missing", "RUN has no artifact home", 1, { run_id: run.id });
|
|
163
|
+
return run.run_home_path;
|
|
164
|
+
}
|
|
165
|
+
function nextPauseId(runHome) {
|
|
166
|
+
const root = path.join(runHome, "intake", "hitl");
|
|
167
|
+
const max = fs.existsSync(root) ? fs.readdirSync(root).reduce((value, name) => Math.max(value, Number(name.match(/^HITL-(\d+)/)?.[1] ?? 0)), 0) : 0;
|
|
168
|
+
return `HITL-${String(max + 1).padStart(3, "0")}`;
|
|
169
|
+
}
|
|
170
|
+
function safeStage(stage) {
|
|
171
|
+
const value = stage.trim().toLowerCase();
|
|
172
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value))
|
|
173
|
+
throw new AppError("validation", "stage name is invalid", 2, { stage });
|
|
174
|
+
return value;
|
|
175
|
+
}
|