@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +23 -8
  4. package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
  6. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
  7. package/dist/harness-runtime/lib/delegation-instructions.d.mts +3 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -1
  9. package/dist/protocol/local-files.js +18 -9
  10. package/dist/runtime/context.js +2 -2
  11. package/dist/services/cleanup.js +81 -32
  12. package/dist/services/code-checks.js +18 -19
  13. package/dist/services/continuation-outcome.js +1 -1
  14. package/dist/services/controller-fanout.js +12 -4
  15. package/dist/services/eval-snapshots.js +69 -41
  16. package/dist/services/execution-policy.js +12 -1
  17. package/dist/services/external-work-launch.js +2 -1
  18. package/dist/services/hooks.js +153 -43
  19. package/dist/services/lanes.js +23 -18
  20. package/dist/services/lifecycle-command.js +11 -2
  21. package/dist/services/lifecycle-invocations.js +291 -54
  22. package/dist/services/managed-processes.js +35 -23
  23. package/dist/services/merge-queue.js +71 -67
  24. package/dist/services/merge-server.js +5 -12
  25. package/dist/services/migrations.js +28 -12
  26. package/dist/services/plan-runtime.js +8 -4
  27. package/dist/services/projects.js +3 -11
  28. package/dist/services/protocols.js +98 -68
  29. package/dist/services/recovery-snapshot-database.js +43 -8
  30. package/dist/services/run-control-worker.js +15 -32
  31. package/dist/services/run-control.js +3 -17
  32. package/dist/services/run-controller-capture.js +58 -27
  33. package/dist/services/run-controller-process.js +21 -30
  34. package/dist/services/run-controller-recovery.js +36 -37
  35. package/dist/services/run-controller.js +83 -88
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +22 -64
  38. package/dist/services/run-recovery-runtime.js +25 -14
  39. package/dist/services/run-recovery.js +56 -54
  40. package/dist/services/runs.js +218 -160
  41. package/dist/services/runtime-budget.js +19 -45
  42. package/dist/services/runtime-scope-capture.js +8 -6
  43. package/dist/services/runtime-scope-control.js +49 -39
  44. package/dist/services/runtime-scope-resume.js +134 -122
  45. package/dist/services/runtime-scope-worker.js +48 -49
  46. package/dist/services/scope-writer-cleanup.js +26 -0
  47. package/dist/services/stage-context.js +6 -1
  48. package/dist/services/stage-lifecycle.js +1 -1
  49. package/dist/services/stage-pause.js +13 -29
  50. package/dist/services/stage-report-renderer.js +116 -6
  51. package/dist/services/usage.js +2 -9
  52. package/dist/services/vnext-code-review.js +10 -7
  53. package/dist/services/vnext-code.js +9 -17
  54. package/dist/services/vnext-merge.js +64 -21
  55. package/dist/services/vnext-plan-review.js +43 -30
  56. package/dist/services/vnext-plan.js +28 -24
  57. package/dist/services/vnext-protocolize.js +183 -20
  58. package/dist/services/vnext-specify.js +27 -19
  59. package/dist/services/work-registry.js +229 -150
  60. package/dist/storage/database.js +286 -56
  61. package/dist/storage/foreign-key-transaction.js +39 -0
  62. package/dist/storage/immutable-input.js +45 -0
  63. package/dist/storage/import-guard.js +102 -0
  64. package/dist/storage/native-cleanup.js +52 -0
  65. package/dist/storage/projection-lock.js +73 -0
  66. package/dist/storage/writer-contract.js +3 -3
  67. package/dist/storage/writer-migration.js +24 -41
  68. package/package.json +1 -1
  69. package/tools/repair-paused-run-status.mjs +48 -23
@@ -1,14 +1,15 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { projectFeatureWorktreePath, resolveProjectRoot } from "../storage/paths.js";
7
8
  import { previewNextEntityId } from "./ids.js";
8
9
  import { registerProtocol } from "./protocols.js";
9
10
  import { requireProjectByRoot } from "./projects.js";
10
- import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, prepareFlowRunStageAttachment, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
11
- import { resolveStageTransition } from "./execution-policy.js";
11
+ import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, prepareFlowRunStageAttachment, prepareFlowRunStageCompletion, withStageSettlement, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
12
+ import { resolveStageTransition, stageExecutionCwd } from "./execution-policy.js";
12
13
  import { validateSchema } from "./schema-validation.js";
13
14
  import { assertStageStartHookEvent, hookSessionIdentity } from "./hooks.js";
14
15
  import { bindStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
@@ -17,11 +18,13 @@ import { bootstrapManagedWorktree, createManagedWorktree, prepareManagedWorktree
17
18
  import { loadVnextWorkspacePolicy, requireVnextWorkspaceRoute, vnextRunBranch } from "./vnext-workspace-policy.js";
18
19
  import { readVnextSpecifyResult } from "./vnext-specify.js";
19
20
  import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
20
- import { writeStageReport } from "./stage-report-renderer.js";
21
+ import { acceptStageReport, prepareStageReport, publishStageReport } from "./stage-report-renderer.js";
21
22
  import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
22
23
  import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
24
+ import { stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
25
+ import { withProjectionLock } from "../storage/projection-lock.js";
23
26
  const stage = "protocolize";
24
- function stageSessionMode(context, run) {
27
+ function stageSessionMode(context, run, projectRoot, targetWorkspaceRoot) {
25
28
  const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id]);
26
29
  const value = row ? JSON.parse(row.index_json) : null;
27
30
  const profile = value?.execution_profile;
@@ -31,9 +34,10 @@ function stageSessionMode(context, run) {
31
34
  if (profile?.settings.execution) {
32
35
  if (!profile.agent_profiles)
33
36
  throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
34
- return resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, fromStage: "protocolize", stage: "plan", stageSessionMode: mode, mergeMode: profile.settings.merge_mode }).session_mode;
37
+ return resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot, workspaceRoot: targetWorkspaceRoot, fromStage: "protocolize", stage: "plan", stageSessionMode: mode, mergeMode: profile.settings.merge_mode }).session_mode;
35
38
  }
36
- return mode;
39
+ return stageExecutionCwd({ stage: "protocolize", projectRoot, workspaceRoot: targetWorkspaceRoot })
40
+ === stageExecutionCwd({ stage: "plan", projectRoot, workspaceRoot: targetWorkspaceRoot }) ? mode : "new_session";
37
41
  }
38
42
  export function isVnextProtocolizeRun(context, input) {
39
43
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -76,7 +80,7 @@ export function prepareVnextProtocolize(context, input, prepared = prepareVnextP
76
80
  writeJson(resultPath, resultTemplate(obligations));
77
81
  const pauseCommand = stagePauseCommand(context, { runId: run.id, stage, workId: work.work_id, projectRoot });
78
82
  const prompt = renderPrompt({ projectRoot, runId: run.id, workId: work.work_id, specifyPath, obligations, resultPath, handoff: input.handoff, catalog: projectCatalog, policy, template, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), finishCommand: managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --result-file ${JSON.stringify(resultPath)} --json`) });
79
- const workspaceContract = ["<workspace_contract>", `- stage artifact directory: ${stageRoot}`, `- project workspace: ${policy.worktree_path ?? projectRoot}`, `- project root (read-only identity): ${projectRoot}`, `Before editing the result, change into the project workspace: cd -- ${JSON.stringify(policy.worktree_path ?? projectRoot)}. Write only the named result file in the stage artifact directory; do not write project files, another RUN, another native home, branches, worktrees or Git commits.`, "The CLI performs all protocol document materialization after the result is accepted.", "</workspace_contract>"].join("\n");
83
+ const workspaceContract = ["<workspace_contract>", `- stage artifact directory: ${stageRoot}`, `- provisioned project workspace for later stages: ${policy.worktree_path ?? projectRoot}`, `- this PROTOCOLIZE Session remains in its launch directory: ${projectRoot}`, `Write only the named result file in the stage artifact directory; do not write project files, another RUN, another native home, branches, worktrees or Git commits. Do not change the Session directory to the newly provisioned worktree.`, "The CLI performs all protocol document materialization after the result is accepted.", "</workspace_contract>"].join("\n");
80
84
  const promptWithWorkspaceContract = prompt.replace("</work_context>", `</work_context>\n\n${workspaceContract}`);
81
85
  fs.writeFileSync(promptPath, promptWithWorkspaceContract);
82
86
  // Packet publication does not itself bind a Session or open the Stage.
@@ -184,17 +188,18 @@ export function finishVnextProtocolize(context, input) {
184
188
  const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
185
189
  const obligations = effectiveObligations(result, acceptedObligations);
186
190
  validateDelivery(projectRoot, result, obligations);
187
- const protocolIds = result.delivery.members.map((member) => allocateProtocolId(context, projectRoot, member.slug));
188
- const psetId = result.delivery.kind === "protocol_set" ? allocatePsetId(projectRoot, result.delivery.slug) : null;
189
191
  const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
190
192
  const receipt = readWorkspaceRouteReceipt(root);
191
193
  const workspaceRoot = run.workspace_root;
192
- const changedFiles = materialize(workspaceRoot, run.id, requireRunHome(run), result, obligations, protocolIds, psetId);
194
+ const intent = prepareProtocolizeIntent(context, run, resultFile, result, obligations);
195
+ publishProtocolizeIntent(context, project.id, intent);
196
+ const protocolIds = intent.protocol_ids;
197
+ const changedFiles = intent.changed_files;
193
198
  for (const protocolId of protocolIds)
194
199
  registerProtocol(context, { handshakeId: protocolId, projectRoot, workspacePath: workspaceRoot });
195
200
  if (receipt.created && receipt.bootstrap) {
196
201
  for (const protocolId of protocolIds)
197
- recordManagedWorktree(context, { protocolId, projectRoot, branch: requiredPolicyValue(resolvedPolicy.feature_branch, "feature branch"), base: requiredPolicyValue(resolvedPolicy.base_ref, "base ref"), created: receipt.created, bootstrap: receipt.bootstrap });
202
+ ensureManagedWorktreeRecorded(context, { protocolId, projectRoot, branch: requiredPolicyValue(resolvedPolicy.feature_branch, "feature branch"), base: requiredPolicyValue(resolvedPolicy.base_ref, "base ref"), created: receipt.created, bootstrap: receipt.bootstrap });
198
203
  }
199
204
  const now = context.now();
200
205
  const workSession = completeWorkSession(context, work.work_id, resultFile, now);
@@ -203,15 +208,150 @@ export function finishVnextProtocolize(context, input) {
203
208
  const reportJson = path.join(root, "stage-report.json");
204
209
  const reportMarkdown = path.join(root, "stage-report.md");
205
210
  const reportHtml = path.join(root, "stage-report.html");
206
- writeStageReport(root, report);
207
- validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
208
- completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "protocolize-result.json", dataSchemaId: "dd-flow/vnext-protocolize-result@3", report: "stage-report.md", stageReport: "stage-report.html" });
209
- appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
210
- advanceFlowRun(context, { settlementStage: "protocolize", projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
211
+ const currentIndex = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]).index_json);
212
+ const stageDone = currentIndex.stage_runs.some(item => item.stage === stage && item.status === "done");
213
+ if (stageDone) {
214
+ publishStageReport(context, project.id, run.id, stage, root);
215
+ if (!fs.existsSync(reportJson))
216
+ throw new AppError("protocolize_report_missing", "Completed PROTOCOLIZE has no accepted report to replay", 1, { run_id: run.id, report_path: reportJson });
217
+ }
218
+ else {
219
+ const preparedReport = prepareStageReport(report);
220
+ validateSchema({ schemaName: "stage-report", file: reportJson, data: preparedReport.normalized, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
221
+ const completion = { projectRoot, runId: run.id, stage, status: "done", data: "protocolize-result.json", dataSchemaId: "dd-flow/vnext-protocolize-result@3", report: "stage-report.md", stageReport: "stage-report.html", dataSha256: crypto.createHash("sha256").update(fs.readFileSync(resultFile)).digest("hex") };
222
+ const preparedCompletion = prepareFlowRunStageCompletion(context, completion);
223
+ withStageSettlement(context, () => {
224
+ acceptStageReport(context, { projectId: project.id, runId: run.id, stage, root, prepared: preparedReport });
225
+ completeFlowRunStage(context, completion, preparedCompletion);
226
+ });
227
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
228
+ }
229
+ if (currentIndex.verdict !== "protocolized")
230
+ advanceFlowRun(context, { settlementStage: "protocolize", projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
211
231
  refreshRunWorkProjection(context, project.id, run.id);
212
232
  const nextCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`);
213
233
  return { ok: true, outcome: "protocolized", run_id: run.id, protocol_ids: protocolIds, workspace_root: workspaceRoot, git_policy: resolvedPolicy, next_action: "await_plan", next_command: nextCommand, next: { kind: "start_stage", stage: "plan", command: nextCommand, cwd: workspaceRoot, session_mode: resolvedPolicy.stage_session_mode }, artifacts: { result: resultFile, report_json: reportJson, report_markdown: reportMarkdown, report_html: reportHtml, protocols: protocolIds.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "summary.md")) } };
214
234
  }
235
+ function prepareProtocolizeIntent(context, run, resultFile, result, obligations) {
236
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id]);
237
+ const index = row ? JSON.parse(row.index_json) : null;
238
+ const attempt = index?.stage_runs?.find(item => item.stage === stage)?.attempt;
239
+ if (!attempt)
240
+ throw new AppError("protocolize_owner_missing", "PROTOCOLIZE has no owning Stage attempt", 1, { run_id: run.id });
241
+ const resultSha = sha256(fs.readFileSync(resultFile));
242
+ const retained = readProtocolizeIntent(context, run.project_id, run.id, attempt);
243
+ if (retained) {
244
+ if (retained.workspace_root !== run.workspace_root || retained.result_sha256 !== resultSha)
245
+ throw new AppError("protocolize_intent_conflict", "Accepted PROTOCOLIZE publication belongs to different result or workspace bytes", 1, { run_id: run.id, attempt });
246
+ return retained;
247
+ }
248
+ const projectRoot = run.workspace_root;
249
+ const protocolIds = allocateProtocolIds(context, run.project_id, projectRoot, result.delivery.members.map(member => member.slug));
250
+ const psetId = result.delivery.kind === "protocol_set" ? allocatePsetId(context, run.project_id, projectRoot, result.delivery.slug) : null;
251
+ const draft = fs.mkdtempSync(path.join(os.tmpdir(), "dd-flow-protocolize-"));
252
+ try {
253
+ // A draft must never follow a workspace symlink back into the live tree.
254
+ fs.cpSync(path.join(projectRoot, ".memory-bank"), path.join(draft, ".memory-bank"), { recursive: true, filter: source => !fs.lstatSync(source).isSymbolicLink() });
255
+ const feature = result.feature.epic_path && path.isAbsolute(result.feature.epic_path)
256
+ ? { ...result.feature, epic_path: path.join(draft, path.relative(projectRoot, result.feature.epic_path)) }
257
+ : result.feature;
258
+ const draftResult = { ...result, feature };
259
+ const changedFiles = materialize(draft, run.id, requireRunHome(run), draftResult, obligations, protocolIds, psetId);
260
+ const files = changedFiles.map(relative => {
261
+ const source = path.join(projectRoot, relative), target = path.join(draft, relative);
262
+ const bytes = fs.readFileSync(target, "utf8");
263
+ return { relative, before_sha256: fs.existsSync(source) ? sha256(fs.readFileSync(source)) : null, after_sha256: sha256(bytes), bytes };
264
+ });
265
+ const candidate = { run_id: run.id, attempt, workspace_root: projectRoot, result_sha256: resultSha, protocol_ids: protocolIds, pset_id: psetId, changed_files: changedFiles, files };
266
+ return context.db.writeTransaction(() => {
267
+ const raced = readProtocolizeIntent(context, run.project_id, run.id, attempt);
268
+ if (raced) {
269
+ if (raced.workspace_root !== projectRoot || raced.result_sha256 !== resultSha)
270
+ throw new AppError("protocolize_intent_conflict", "A competing PROTOCOLIZE publication was accepted", 1, { run_id: run.id, attempt });
271
+ return raced;
272
+ }
273
+ const reserved = new Set(context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.protocolize_intent'", [run.project_id])
274
+ .flatMap(row => { const prior = JSON.parse(row.payload_json); return [...prior.protocol_ids, ...(prior.pset_id ? [prior.pset_id] : [])]; }));
275
+ if ([...protocolIds, ...(psetId ? [psetId] : [])].some(id => reserved.has(id)))
276
+ throw new AppError("protocolize_id_reserved", "Another PROTOCOLIZE attempt reserved the selected document ID; retry to allocate a new one", 1, { run_id: run.id });
277
+ context.db.run("INSERT INTO audit_events (project_id, event_type, forced, payload_json, created_at) VALUES (?, 'flow_run.protocolize_intent', 0, ?, ?)", [run.project_id, JSON.stringify(candidate), context.now()]);
278
+ return candidate;
279
+ });
280
+ }
281
+ finally {
282
+ fs.rmSync(draft, { recursive: true, force: true });
283
+ }
284
+ }
285
+ function readProtocolizeIntent(context, projectId, runId, attempt) {
286
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.protocolize_intent' ORDER BY id DESC", [projectId]);
287
+ return rows.map(row => JSON.parse(row.payload_json)).find(item => item.run_id === runId && item.attempt === attempt) ?? null;
288
+ }
289
+ function publishProtocolizeIntent(context, projectId, intent) {
290
+ const memory = path.join(intent.workspace_root, ".memory-bank");
291
+ withProjectionLock(path.join(context.ddFlowHome, "locks", `protocolize-${projectId}.publish-lock`), () => {
292
+ for (const file of intent.files) {
293
+ if (!file.relative.startsWith(".memory-bank/") || sha256(file.bytes) !== file.after_sha256)
294
+ throw new AppError("protocolize_intent_invalid", "Accepted PROTOCOLIZE file is invalid", 1, { relative: file.relative });
295
+ const target = path.resolve(intent.workspace_root, file.relative);
296
+ inside(memory, target);
297
+ assertNoSymlinkPath(memory, target);
298
+ const current = fs.existsSync(target) ? sha256(fs.readFileSync(target)) : null;
299
+ if (current !== file.before_sha256 && current !== file.after_sha256)
300
+ throw new AppError("protocolize_publication_conflict", "PROTOCOLIZE target changed since publication was accepted", 1, { target });
301
+ }
302
+ for (const file of intent.files) {
303
+ const target = path.resolve(intent.workspace_root, file.relative);
304
+ if (fs.existsSync(target) && sha256(fs.readFileSync(target)) === file.after_sha256)
305
+ continue;
306
+ fs.mkdirSync(path.dirname(target), { recursive: true });
307
+ const temporary = `${target}.${crypto.randomUUID()}.tmp`;
308
+ try {
309
+ fs.writeFileSync(temporary, file.bytes, { flag: "wx" });
310
+ if (file.before_sha256 === null) {
311
+ try {
312
+ fs.linkSync(temporary, target);
313
+ }
314
+ catch (error) {
315
+ if (error.code !== "EEXIST" || sha256(fs.readFileSync(target)) !== file.after_sha256)
316
+ throw error;
317
+ }
318
+ }
319
+ else {
320
+ if (sha256(fs.readFileSync(target)) !== file.before_sha256)
321
+ throw new AppError("protocolize_publication_conflict", "PROTOCOLIZE target changed during publication", 1, { target });
322
+ fs.renameSync(temporary, target);
323
+ }
324
+ }
325
+ finally {
326
+ fs.rmSync(temporary, { force: true });
327
+ }
328
+ }
329
+ });
330
+ }
331
+ function assertNoSymlinkPath(memory, target) {
332
+ for (let cursor = memory;; cursor = path.join(cursor, path.relative(cursor, target).split(path.sep)[0])) {
333
+ try {
334
+ if (fs.lstatSync(cursor).isSymbolicLink())
335
+ throw new AppError("protocolize_publication_conflict", "PROTOCOLIZE publication cannot follow a workspace symlink", 1, { target, symlink: cursor });
336
+ }
337
+ catch (error) {
338
+ if (error.code !== "ENOENT")
339
+ throw error;
340
+ }
341
+ if (cursor === target)
342
+ break;
343
+ }
344
+ }
345
+ function ensureManagedWorktreeRecorded(context, input) {
346
+ const existing = context.db.get("SELECT wr.feature_branch, wr.base_ref, wr.worktree_path FROM worktree_records wr JOIN protocols p ON p.project_id = wr.project_id AND p.id = wr.protocol_id WHERE p.project_root = ? AND p.id = ? AND wr.status = 'active' ORDER BY wr.created_at DESC LIMIT 1", [input.projectRoot, input.protocolId]);
347
+ if (existing) {
348
+ if (existing.feature_branch !== input.branch || existing.base_ref !== input.base || existing.worktree_path !== input.created.worktreePath)
349
+ throw new AppError("worktree_already_exists", "Existing managed worktree record conflicts with the accepted PROTOCOLIZE route", 1, { protocol_id: input.protocolId });
350
+ return;
351
+ }
352
+ recordManagedWorktree(context, input);
353
+ }
354
+ function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); }
215
355
  function materialize(projectRoot, runId, runHome, result, obligations, ids, psetId) {
216
356
  const psetPath = psetId ? path.join(projectRoot, ".memory-bank", "protocol", "_set", `${psetId}.md`) : null;
217
357
  const created = [];
@@ -483,7 +623,7 @@ function resultTemplate(obligations = []) {
483
623
  }
484
624
  function renderPrompt(input) {
485
625
  const obligationList = input.obligations.map((obligation) => `- ${obligation.id}: ${obligation.statement}`).join("\n");
486
- return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(input.obligations), null, 2)}\n\`\`\``, "obligation_id values and their one-per-obligation entries are prefilled by the CLI. Preserve those IDs exactly; change only member_keys when semantic ownership requires it. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, "</output_contract>", ""].join("\n");
626
+ return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(input.obligations), null, 2)}\n\`\`\``, "obligation_id values and their one-per-obligation entries are prefilled by the CLI. Preserve those IDs exactly; change only member_keys when semantic ownership requires it. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, stageLifecycleInstruction, "</output_contract>", ""].join("\n");
487
627
  }
488
628
  function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
489
629
  const receiptPath = path.join(stageRoot, "workspace-route.json");
@@ -492,8 +632,8 @@ function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, st
492
632
  prepareOutputFile(receiptPath);
493
633
  const config = loadVnextWorkspacePolicy(projectRoot);
494
634
  const stableFacts = gitFacts(projectRoot);
495
- const sessionMode = stageSessionMode(context, run);
496
635
  if (config.workspace.route === "integration_branch_direct") {
636
+ const sessionMode = stageSessionMode(context, run, projectRoot, projectRoot);
497
637
  const policy = { route: "integration_branch_direct", integration_branch: config.workspace.integration_branch, base_ref: stableFacts.head, feature_branch: null, worktree_path: null, reason: "Project workspace configuration explicitly selects the integration checkout.", provision_stage: "protocolize_start", stage_session_mode: sessionMode, provisioned: true, bootstrap_status: "not_applicable" };
498
638
  return { kind: "direct", receiptPath, policy };
499
639
  }
@@ -503,6 +643,7 @@ function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, st
503
643
  const branch = vnextRunBranch(config, run);
504
644
  const holder = path.basename(projectRoot).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
505
645
  const worktreePath = projectFeatureWorktreePath(context.ddFlowHome, projectId, `RUN-${run.short_id}`, holder);
646
+ const sessionMode = stageSessionMode(context, run, projectRoot, worktreePath);
506
647
  let create;
507
648
  try {
508
649
  create = prepareManagedWorktree({ projectRoot, branch, base: stableFacts.head, path: worktreePath });
@@ -545,8 +686,27 @@ function readWorkspaceRouteReceipt(root) {
545
686
  }
546
687
  function requiredPolicyValue(value, label) { if (!value)
547
688
  throw new AppError("runtime_missing", `PROTOCOLIZE Git policy is missing ${label}`, 1); return value; }
548
- function allocateProtocolId(context, projectRoot, name) { return previewNextEntityId(context, { projectRoot, type: "protocol", slug: name }).entity.id; }
549
- function allocatePsetId(projectRoot, value) { const root = path.join(projectRoot, ".memory-bank", "protocol", "_set"); const used = fs.existsSync(root) ? fs.readdirSync(root).map((file) => /^PSET-(\d+)-/.exec(file)?.[1]).filter((item) => Boolean(item)).map(Number) : []; return `PSET-${String(Math.max(0, ...used) + 1).padStart(3, "0")}-${slug(value)}`; }
689
+ function allocateProtocolIds(context, projectId, projectRoot, names) {
690
+ if (!names.length)
691
+ return [];
692
+ const first = previewNextEntityId(context, { projectRoot, type: "protocol", slug: names[0] }).entity.id;
693
+ const used = protocolizeReservedNumbers(context, projectId, "protocol_ids", /^PRT-(\d+)-/);
694
+ const sequence = Math.max(Number(/^PRT-(\d+)-/.exec(first)?.[1] ?? 1) - 1, ...used) + 1;
695
+ return names.map((name, index) => `PRT-${String(sequence + index).padStart(3, "0")}-${slug(name)}`);
696
+ }
697
+ function allocatePsetId(context, projectId, projectRoot, value) {
698
+ const root = path.join(projectRoot, ".memory-bank", "protocol", "_set");
699
+ const used = fs.existsSync(root) ? fs.readdirSync(root).map((file) => /^PSET-(\d+)-/.exec(file)?.[1]).filter((item) => Boolean(item)).map(Number) : [];
700
+ return `PSET-${String(Math.max(0, ...used, ...protocolizeReservedNumbers(context, projectId, "pset_id", /^PSET-(\d+)-/)) + 1).padStart(3, "0")}-${slug(value)}`;
701
+ }
702
+ function protocolizeReservedNumbers(context, projectId, field, pattern) {
703
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.protocolize_intent'", [projectId]);
704
+ return rows.flatMap(row => {
705
+ const value = JSON.parse(row.payload_json);
706
+ const ids = field === "protocol_ids" ? value.protocol_ids : value.pset_id ? [value.pset_id] : [];
707
+ return ids.map(id => Number(pattern.exec(id)?.[1])).filter(Number.isFinite);
708
+ });
709
+ }
550
710
  function catalog(projectRoot) {
551
711
  const memory = path.join(projectRoot, ".memory-bank");
552
712
  const candidates = [
@@ -575,6 +735,9 @@ function requireRunHome(run) { if (!run.run_root)
575
735
  export function prepareProtocolizeResultPath(runHome, file) {
576
736
  const resolved = path.resolve(file);
577
737
  inside(path.join(runHome, "02-protocolize"), resolved);
738
+ const canonical = path.join(runHome, "02-protocolize", "protocolize-result.json");
739
+ if (resolved !== canonical)
740
+ throw new AppError("protocolize_result_path_invalid", "PROTOCOLIZE result must be the canonical Stage data file named by its completion receipt", 2, { result_file: resolved, expected: canonical });
578
741
  return resolved;
579
742
  }
580
743
  function inside(root, file) { const relative = path.relative(root, file); if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
@@ -9,16 +9,17 @@ import { ensureVnextWorkStorage } from "../storage/database.js";
9
9
  import { projectHome, resolveProjectRoot, writeJsonAtomic } from "../storage/paths.js";
10
10
  import { preflightMemoryPermissions } from "./memory-permissions.js";
11
11
  import { registerProject, requireProjectByRoot } from "./projects.js";
12
- import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, prepareFlowRunStart, startFlowRun } from "./runs.js";
12
+ import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, prepareFlowRunStageCompletion, prepareFlowRunStart, startFlowRun, withStageSettlement } from "./runs.js";
13
13
  import { resolveStageTransition } from "./execution-policy.js";
14
14
  import { validateSchema } from "./schema-validation.js";
15
15
  import { bindRunningWorkSession, refreshRunWorkProjection } from "./work-registry.js";
16
16
  import { nextWorkId } from "./ids.js";
17
17
  import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
18
- import { writeStageReport } from "./stage-report-renderer.js";
18
+ import { acceptStageReport, prepareStageReport } from "./stage-report-renderer.js";
19
19
  import { prepareError, prepareJsonSchema, prepareJsonText, prepareOutputDirectory, prepareOutputFile, prepareTextFile } from "../cli/input-preparation.js";
20
20
  import { hookSessionIdentity } from "./hooks.js";
21
21
  import { assertRecoveryHookCurrent, assertRunMutationAllowed } from "./run-recovery.js";
22
+ import { stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
22
23
  const flowId = "mb-sdlc-vnext-specify";
23
24
  const protocolizeFlowId = "mb-sdlc-vnext-protocolize";
24
25
  const flowVersion = 4;
@@ -61,7 +62,7 @@ export function prepareVnextSpecifyStart(context, input) {
61
62
  if (preflight.ok !== true)
62
63
  throw new AppError("permission_preflight_failed", "SPECIFY project context is not readable", 1, { preflight });
63
64
  const grounding = projectGrounding(projectRoot);
64
- const handoff = run ? executionSnapshot(context, run.project_id, run.id) : executionSnapshotFromProfile(context, start?.executionProfile);
65
+ const handoff = run ? executionSnapshot(context, run.project_id, run.id, projectRoot, run.workspace_root) : executionSnapshotFromProfile(context, start?.executionProfile, projectRoot, projectRoot);
65
66
  return { projectRoot, intake, discussion, template, startInput, start, preflight, grounding, handoff };
66
67
  }
67
68
  function assertSpecifyNotStarted(context, projectRoot, runId) {
@@ -187,21 +188,14 @@ export function submitVnextSpecify(context, input) {
187
188
  const handoff = continuesToProtocolize ? executionSnapshotFromPath(path.join(stageRoot, "work-context.json"), work.work_id) : null;
188
189
  const sameSessionContinuation = handoff?.stage_handoff.effective === "same_session";
189
190
  const terminal = !continuesToProtocolize && (outcome === "specified" || outcome === "failed" || outcome === "cancelled");
190
- if (terminal) {
191
- context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [outcome === "specified" ? "completed" : outcome, normalizedResult, now, now, work.work_id]);
192
- context.db.run("UPDATE work_sessions SET status = ?, result_path = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [outcome === "specified" ? "completed" : outcome, resultFile, now, now, work.work_id]);
193
- }
194
191
  const workSession = latestWorkSession(context, work.work_id);
195
- if (workSession && !sameSessionContinuation) {
196
- context.db.run("UPDATE work_sessions SET status = 'completed', result_path = ?, updated_at = ?, completed_at = ? WHERE id = ?", [resultFile, now, now, workSession.id]);
197
- }
198
192
  const reportPath = path.join(stageRoot, "stage-report.json");
199
193
  const markdownPath = path.join(stageRoot, "stage-report.md");
200
194
  const htmlPath = path.join(stageRoot, "stage-report.html");
201
195
  const report = buildStageReport({ run, work, workSession, outcome, result, resultMarkdown: renderedMarkdown, now, stageRoot, resultFile });
202
- writeStageReport(stageRoot, report);
203
- validateSchema({ schemaName: "stage-report", file: reportPath, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
204
- completeFlowRunStage(context, {
196
+ const preparedReport = prepareStageReport(report);
197
+ validateSchema({ schemaName: "stage-report", file: reportPath, data: preparedReport.normalized, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
198
+ const completion = {
205
199
  projectRoot,
206
200
  runId: run.id,
207
201
  stage: stageId,
@@ -209,7 +203,20 @@ export function submitVnextSpecify(context, input) {
209
203
  data: "specify.json",
210
204
  dataSchemaId: "dd-flow/specify@1",
211
205
  report: "stage-report.md",
212
- stageReport: "stage-report.html"
206
+ stageReport: "stage-report.html",
207
+ dataSha256: crypto.createHash("sha256").update(fs.readFileSync(resultFile)).digest("hex")
208
+ };
209
+ const preparedCompletion = prepareFlowRunStageCompletion(context, completion);
210
+ withStageSettlement(context, () => {
211
+ const current = requireWork(context, work.work_id);
212
+ if (current.status !== "running" || current.updated_at !== work.updated_at)
213
+ throw new AppError("work_generation_stale", "SPECIFY Work changed during finish preparation", 1, { work_id: work.work_id });
214
+ if (terminal && context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running' AND updated_at = ?", [outcome === "specified" ? "completed" : outcome, normalizedResult, now, now, work.work_id, work.updated_at]).changes !== 1)
215
+ throw new AppError("work_generation_stale", "SPECIFY Work changed during finish preparation", 1, { work_id: work.work_id });
216
+ if (workSession && !sameSessionContinuation && context.db.run("UPDATE work_sessions SET status = 'completed', result_path = ?, updated_at = ?, completed_at = ? WHERE id = ? AND status = 'running' AND updated_at = ?", [resultFile, now, now, workSession.id, workSession.updated_at]).changes !== 1)
217
+ throw new AppError("work_generation_stale", "SPECIFY Session changed during finish preparation", 1, { work_id: work.work_id, work_session_id: workSession.id });
218
+ acceptStageReport(context, { projectId: project.id, runId: run.id, stage: stageId, root: stageRoot, prepared: preparedReport });
219
+ completeFlowRunStage(context, completion, preparedCompletion);
213
220
  });
214
221
  const updatedWork = requireWork(context, work.work_id);
215
222
  const updatedWorkSession = workSession ? requireWorkSession(context, workSession.id) : null;
@@ -420,7 +427,8 @@ function renderPrompt(input) {
420
427
  "```",
421
428
  "The response tells you exactly what to ask and how to resume this same stage. Ask its user_message and stop the Turn.",
422
429
  `Finish exactly once only after all questions are resolved. Run this as one standalone Bash command:\n\`${input.finishCommand}\`.`,
423
- "Do not combine the lifecycle command with cat, skill reads, Git commands, pipes or shell operators. If finish reports a no-effect validation error, correct the same result file and use its returned retry_command in this Session. Internal invocation IDs are bound by the runtime and must not be copied or invented. After success, follow only its returned next directive; a focused eval controller may explicitly stop at this stage boundary.",
430
+ "Do not combine the lifecycle command with cat, skill reads, Git commands, pipes or shell operators. After success, follow only its returned next directive; a focused eval controller may explicitly stop at this stage boundary.",
431
+ stageLifecycleInstruction,
424
432
  "</output_contract>",
425
433
  ""
426
434
  ].join("\n");
@@ -584,19 +592,19 @@ export function readVnextFlowDefinition(projectRoot) {
584
592
  }
585
593
  return null;
586
594
  }
587
- function executionSnapshot(context, projectId, runId) {
595
+ function executionSnapshot(context, projectId, runId, projectRoot, workspaceRoot) {
588
596
  const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
589
597
  const snapshot = row ? JSON.parse(row.index_json) : null;
590
- return executionSnapshotFromProfile(context, snapshot?.execution_profile, runId);
598
+ return executionSnapshotFromProfile(context, snapshot?.execution_profile, projectRoot, workspaceRoot, runId);
591
599
  }
592
- function executionSnapshotFromProfile(context, profile, runId) {
600
+ function executionSnapshotFromProfile(context, profile, projectRoot, workspaceRoot, runId) {
593
601
  let handoff = profile?.settings.stage_session_mode;
594
602
  if (handoff !== "same_session" && handoff !== "new_session")
595
603
  throw new AppError("execution_profile_invalid", "RUN has no frozen stage session mode", 1, { run_id: runId });
596
604
  if (profile?.settings.execution) {
597
605
  if (!profile.agent_profiles)
598
606
  throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
599
- handoff = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, fromStage: "specify", stage: "protocolize", stageSessionMode: handoff, mergeMode: profile.settings.merge_mode }).session_mode;
607
+ handoff = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot, workspaceRoot, fromStage: "specify", stage: "protocolize", stageSessionMode: handoff, mergeMode: profile.settings.merge_mode }).session_mode;
600
608
  }
601
609
  return { stage_handoff: { effective: handoff, source: "run_override" } };
602
610
  }