@deksden-com/dd-flow-cli 0.9.0-beta.10 → 0.9.0-beta.103

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 (151) hide show
  1. package/CHANGELOG.md +491 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1437 -384
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  18. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  19. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  20. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +502 -0
  22. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  23. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  25. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  26. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  27. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  28. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +387 -0
  29. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  30. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  31. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  32. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +640 -0
  33. package/dist/harness-runtime/lib/dd-zcode.mjs +978 -0
  34. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.mjs +135 -0
  36. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  37. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  38. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  39. package/dist/harness-runtime/lib/managed-daemon.mjs +296 -0
  40. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  41. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  42. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  43. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  44. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  45. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  46. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  47. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  48. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  49. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  50. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  51. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  52. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  53. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  54. package/dist/schemas/agent-profile.schema.json +1 -1
  55. package/dist/schemas/code-review-result.schema.json +1 -1
  56. package/dist/schemas/code-work-batch.schema.json +3 -3
  57. package/dist/schemas/code-work-result.schema.json +3 -3
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +57 -13
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +583 -99
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +165 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +121 -76
  73. package/dist/services/eval-snapshots.js +952 -83
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +165 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +2 -0
  78. package/dist/services/hooks.js +596 -300
  79. package/dist/services/lanes.js +61 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +283 -27
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +73 -28
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/projects.js +18 -5
  93. package/dist/services/prompts.js +25 -16
  94. package/dist/services/protocols.js +64 -20
  95. package/dist/services/recovery-observation-budget.js +61 -0
  96. package/dist/services/recovery-snapshot-database.js +107 -0
  97. package/dist/services/repair-intents.js +40 -0
  98. package/dist/services/review-copy.js +136 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +368 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +231 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +1 -1
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-recovery-runtime.js +69 -0
  112. package/dist/services/run-recovery.js +343 -0
  113. package/dist/services/runs.js +371 -74
  114. package/dist/services/runtime-budget.js +439 -0
  115. package/dist/services/runtime-command.js +43 -0
  116. package/dist/services/runtime-scope-capture.js +50 -0
  117. package/dist/services/runtime-scope-control.js +450 -0
  118. package/dist/services/runtime-scope-resume.js +565 -0
  119. package/dist/services/runtime-scope-stop.js +159 -0
  120. package/dist/services/runtime-scope-worker.js +243 -0
  121. package/dist/services/runtime-service.js +97 -0
  122. package/dist/services/schema-validation.js +10 -8
  123. package/dist/services/sessions.js +35 -69
  124. package/dist/services/stage-blocker.js +17 -7
  125. package/dist/services/stage-context.js +44 -18
  126. package/dist/services/stage-lifecycle.js +99 -102
  127. package/dist/services/stage-pause.js +84 -55
  128. package/dist/services/stage-work-graph.js +35 -0
  129. package/dist/services/usage.js +37 -93
  130. package/dist/services/vnext-code-review.js +190 -80
  131. package/dist/services/vnext-code.js +283 -248
  132. package/dist/services/vnext-execution-profile.js +5 -3
  133. package/dist/services/vnext-fanout.js +172 -17
  134. package/dist/services/vnext-merge.js +296 -105
  135. package/dist/services/vnext-plan-review.js +141 -94
  136. package/dist/services/vnext-plan.js +142 -69
  137. package/dist/services/vnext-protocolize.js +132 -57
  138. package/dist/services/vnext-specify.js +95 -65
  139. package/dist/services/work-registry.js +891 -162
  140. package/dist/services/workspace-bootstrap.js +76 -0
  141. package/dist/services/workspace-files.js +83 -0
  142. package/dist/services/worktrees.js +88 -36
  143. package/dist/shared/entity-references.js +8 -0
  144. package/dist/shared/errors.js +12 -0
  145. package/dist/storage/database.js +459 -32
  146. package/dist/storage/paths.js +17 -4
  147. package/dist/storage/work-references.js +23 -0
  148. package/dist/storage/writer-contract.js +77 -0
  149. package/dist/storage/writer-migration.js +102 -0
  150. package/package.json +24 -13
  151. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,3 +1,4 @@
1
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -6,31 +7,39 @@ import { projectFeatureWorktreePath, resolveProjectRoot } from "../storage/paths
6
7
  import { previewNextEntityId } from "./ids.js";
7
8
  import { registerProtocol } from "./protocols.js";
8
9
  import { requireProjectByRoot } from "./projects.js";
9
- import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
10
+ import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, prepareFlowRunStageAttachment, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
11
+ import { resolveStageTransition } from "./execution-policy.js";
10
12
  import { validateSchema } from "./schema-validation.js";
11
13
  import { assertStageStartHookEvent, hookSessionIdentity } from "./hooks.js";
12
14
  import { bindStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
13
15
  import { runTargetedMemoryBankLint } from "./stage-lifecycle.js";
14
- import { bootstrapManagedWorktree, createManagedWorktree, recordManagedWorktree } from "./worktrees.js";
16
+ import { bootstrapManagedWorktree, createManagedWorktree, prepareManagedWorktree, prepareManagedWorktreeBootstrapSources, recordManagedWorktree } from "./worktrees.js";
15
17
  import { loadVnextWorkspacePolicy, requireVnextWorkspaceRoute, vnextRunBranch } from "./vnext-workspace-policy.js";
16
18
  import { readVnextSpecifyResult } from "./vnext-specify.js";
17
19
  import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
18
20
  import { writeStageReport } from "./stage-report-renderer.js";
19
- import { applyExternalStageContext } from "./stage-context.js";
21
+ import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
22
+ import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
20
23
  const stage = "protocolize";
21
24
  function stageSessionMode(context, run) {
22
25
  const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id]);
23
26
  const value = row ? JSON.parse(row.index_json) : null;
24
- const mode = value?.execution_profile?.settings?.stage_session_mode;
27
+ const profile = value?.execution_profile;
28
+ const mode = profile?.settings.stage_session_mode;
25
29
  if (mode !== "same_session" && mode !== "new_session")
26
30
  throw new AppError("execution_profile_invalid", "RUN is missing its frozen stage session mode", 1, { run_id: run.id });
31
+ if (profile?.settings.execution) {
32
+ if (!profile.agent_profiles)
33
+ 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;
35
+ }
27
36
  return mode;
28
37
  }
29
38
  export function isVnextProtocolizeRun(context, input) {
30
39
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
31
40
  return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
32
41
  }
33
- export function prepareVnextProtocolize(context, input) {
42
+ export function prepareVnextProtocolizeArtifacts(context, input) {
34
43
  const projectRoot = resolveProjectRoot(input.projectRoot);
35
44
  const project = requireProjectByRoot(context, projectRoot);
36
45
  const run = requireRun(context, projectRoot, input.runId);
@@ -42,45 +51,52 @@ export function prepareVnextProtocolize(context, input) {
42
51
  const specifyPath = path.join(runHome, "01-specify", "specify.json");
43
52
  const specifyMarkdownPath = path.join(runHome, "01-specify", "specify.md");
44
53
  const obligations = acceptedSpecifyObligations(context, projectRoot, run.id, specifyPath);
45
- fs.mkdirSync(stageRoot, { recursive: true });
46
54
  const resultPath = path.join(stageRoot, "protocolize-result.json");
47
55
  const promptPath = path.join(stageRoot, "prompt.md");
48
- const policy = provisionWorkspaceForProtocolize(context, projectRoot, project.id, run, stageRoot);
56
+ const templatePath = path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "protocolize.md");
57
+ if (!fs.existsSync(templatePath))
58
+ throw new AppError("runtime_artifact_missing", "vNext PROTOCOLIZE prompt template is missing", 1, { path: templatePath });
59
+ const template = fs.readFileSync(templatePath, "utf8");
60
+ const projectCatalog = catalog(projectRoot);
61
+ if (!["same_session", "new_session"].includes(input.handoff?.stage_handoff?.effective))
62
+ throw new AppError("runtime_context_invalid", "PROTOCOLIZE requires a frozen handoff policy", 1);
63
+ for (const file of [promptPath, path.join(stageRoot, "work-context.json"), ...(!fs.existsSync(resultPath) ? [resultPath] : [])])
64
+ prepareOutputFile(file);
65
+ const workspace = prepareWorkspaceForProtocolize(context, projectRoot, project.id, run, stageRoot);
66
+ return { projectRoot, project, run, work, stageRoot, specifyPath, specifyMarkdownPath, obligations, resultPath, promptPath, template, projectCatalog, workspace };
67
+ }
68
+ /** Materialize the next-stage packet; this is execution, not read-only CLI preparation. */
69
+ export function prepareVnextProtocolize(context, input, prepared = prepareVnextProtocolizeArtifacts(context, input)) {
70
+ const { projectRoot, project, run, work, stageRoot, specifyPath, specifyMarkdownPath, obligations, resultPath, promptPath, template, projectCatalog } = prepared;
71
+ fs.mkdirSync(stageRoot, { recursive: true });
72
+ const policy = provisionWorkspaceForProtocolize(context, projectRoot, run, prepared.workspace);
49
73
  const routedRun = requireRun(context, projectRoot, run.id);
50
- writeJson(path.join(stageRoot, "work-context.json"), { schema_id: "dd-flow/vnext-protocolize-context@2", system: { run_id: run.id, work_id: work.work_id, stage, handoff: input.handoff }, input: { accepted_specify_json: specifyPath, accepted_specify_markdown: specifyMarkdownPath }, catalog: catalog(projectRoot), workspace: { project_root: projectRoot, workspace_root: routedRun.workspace_root, stage_root: stageRoot, git_policy: policy } });
74
+ writeJson(path.join(stageRoot, "work-context.json"), { schema_id: "dd-flow/vnext-protocolize-context@2", system: { run_id: run.id, work_id: work.work_id, stage, handoff: input.handoff }, input: { accepted_specify_json: specifyPath, accepted_specify_markdown: specifyMarkdownPath }, catalog: projectCatalog, workspace: { project_root: projectRoot, workspace_root: routedRun.workspace_root, stage_root: stageRoot, git_policy: policy } });
51
75
  if (!fs.existsSync(resultPath))
52
76
  writeJson(resultPath, resultTemplate(obligations));
53
- const templatePath = path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "protocolize.md");
54
- if (!fs.existsSync(templatePath))
55
- throw new AppError("not_found", "vNext PROTOCOLIZE prompt template is missing", 1, { path: templatePath });
56
77
  const pauseCommand = stagePauseCommand(context, { runId: run.id, stage, workId: work.work_id, projectRoot });
57
- const prompt = renderPrompt({ projectRoot, runId: run.id, workId: work.work_id, specifyPath, obligations, resultPath, handoff: input.handoff, catalog: catalog(projectRoot), policy, template: fs.readFileSync(templatePath, "utf8"), pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), flow: flowCommand(context) });
58
- fs.writeFileSync(promptPath, prompt);
59
- // Preparation is deliberately non-productive: the standalone stage-start
60
- // command atomically installs external context, binds the worker and opens
61
- // the lifecycle Stage. Do not make this prompt an implicit running stage.
78
+ 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");
80
+ const promptWithWorkspaceContract = prompt.replace("</work_context>", `</work_context>\n\n${workspaceContract}`);
81
+ fs.writeFileSync(promptPath, promptWithWorkspaceContract);
82
+ // Packet publication does not itself bind a Session or open the Stage.
62
83
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_prepared", work_id: work.work_id, stage, handoff_mode: input.handoff.stage_handoff.effective });
63
- const directive = input.handoff.stage_handoff.effective === "same_session" ? { kind: "continue_current_session", stage, prompt_path: promptPath, worker_prompt_markdown: prompt } : { kind: "await_new_session", stage, work_id: work.work_id, prompt_path: promptPath, resume_command: `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json` };
84
+ const directive = input.handoff.stage_handoff.effective === "same_session" ? { kind: "continue_current_session", stage, prompt_path: promptPath, worker_prompt_markdown: promptWithWorkspaceContract } : { kind: "await_new_session", stage, work_id: work.work_id, prompt_path: promptPath, resume_command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`) };
64
85
  return { directive, prompt_path: promptPath };
65
86
  }
66
- export function startVnextProtocolize(context, input) {
87
+ export function prepareVnextProtocolizeStart(context, input) {
67
88
  const projectRoot = resolveProjectRoot(input.projectRoot);
68
89
  const project = requireProjectByRoot(context, projectRoot);
69
90
  const run = requireRun(context, projectRoot, input.runId);
70
- assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
71
91
  const work = context.db.get("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [project.id, run.id]);
72
92
  if (!work || work.status !== "running")
73
93
  throw new AppError("invalid_work_state", "PROTOCOLIZE is not ready for an Agent WorkSession", 1, { run_id: run.id });
74
94
  const protocolizeRoot = path.join(requireRunHome(run), "02-protocolize");
75
95
  const contextPath = path.join(protocolizeRoot, "work-context.json");
76
- if (!fs.existsSync(contextPath)) {
77
- const handoff = specifyHandoff(path.join(requireRunHome(run), "01-specify", "work-context.json"), work.work_id);
78
- prepareVnextProtocolize(context, { projectRoot, runId: run.id, workId: work.work_id, handoff });
79
- }
80
- const handoff = JSON.parse(fs.readFileSync(contextPath, "utf8"));
81
- const frozenContext = JSON.parse(fs.readFileSync(contextPath, "utf8"));
82
- const previous = context.db.get("SELECT ws.session_id, s.stop_reason FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, work.work_id]);
83
- const activeWorkSession = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
96
+ const frozenContext = fs.existsSync(contextPath) ? readProtocolizeContext(contextPath) : null;
97
+ const handoff = frozenContext?.system?.handoff ?? specifyHandoff(path.join(requireRunHome(run), "01-specify", "work-context.json"), work.work_id);
98
+ const packetInput = { projectRoot, runId: run.id, workId: work.work_id, handoff };
99
+ const packet = frozenContext ? null : prepareVnextProtocolizeArtifacts(context, packetInput);
84
100
  const stageAttached = (() => {
85
101
  const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
86
102
  try {
@@ -88,12 +104,35 @@ export function startVnextProtocolize(context, input) {
88
104
  return index.stage_runs?.some((item) => item.stage === stage) ?? false;
89
105
  }
90
106
  catch {
91
- return false;
107
+ throw new AppError("runtime_artifact_invalid", "RUN stage index is malformed", 1);
92
108
  }
93
109
  })();
94
- const mode = handoff.system?.handoff?.stage_handoff.effective;
110
+ if (!stageAttached)
111
+ prepareFlowRunStageAttachment(context, { projectRoot, runId: run.id, stage, dir: "02-protocolize", status: "running", dataSchemaId: "dd-flow/vnext-protocolize-result@3" });
112
+ const mode = handoff.stage_handoff.effective;
95
113
  const promptPath = path.join(protocolizeRoot, "prompt.md");
96
- const externalContext = applyExternalStageContext({ stageRoot: protocolizeRoot, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
114
+ if (frozenContext)
115
+ fs.readFileSync(promptPath, "utf8");
116
+ if (input.externalContext) {
117
+ prepareExternalStageContext({ stageRoot: protocolizeRoot, loaded: input.externalContext });
118
+ prepareOutputFile(promptPath);
119
+ }
120
+ return { projectRoot, project, run, work, protocolizeRoot, contextPath, frozenContext, packetInput, packet, stageAttached, mode, promptPath };
121
+ }
122
+ function readProtocolizeContext(file) {
123
+ try {
124
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
125
+ if (["same_session", "new_session"].includes(value?.system?.handoff?.stage_handoff?.effective))
126
+ return value;
127
+ }
128
+ catch { /* retained context is not a caller argument */ }
129
+ throw new AppError("runtime_context_invalid", "PROTOCOLIZE context is missing a valid frozen handoff", 1, { file });
130
+ }
131
+ export function startVnextProtocolize(context, input, prepared = prepareVnextProtocolizeStart(context, input)) {
132
+ const { projectRoot, project, run, work, protocolizeRoot, contextPath, packetInput, packet, stageAttached, mode, promptPath } = prepared;
133
+ assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
134
+ const previous = context.db.get("SELECT ws.session_id, s.stop_reason FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, work.work_id]);
135
+ const activeWorkSession = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
97
136
  const sessionId = hookSessionIdentity(context, project.id, input.hookEventId).sessionId;
98
137
  // A stage-entry eval snapshot deliberately stops the canonical binding. Its
99
138
  // Subject starts from a fork of the captured starter Session, so the stale
@@ -103,6 +142,10 @@ export function startVnextProtocolize(context, input) {
103
142
  throw new AppError("handoff_session_mismatch", "Project policy requires PROTOCOLIZE to continue in the SPECIFY session", 1);
104
143
  if (mode === "new_session" && previous?.session_id === sessionId)
105
144
  throw new AppError("handoff_session_mismatch", "Project policy requires PROTOCOLIZE to start in a fresh session", 1);
145
+ if (packet)
146
+ prepareVnextProtocolize(context, packetInput, packet);
147
+ const frozenContext = prepared.frozenContext ?? readProtocolizeContext(contextPath);
148
+ const externalContext = applyExternalStageContext({ stageRoot: protocolizeRoot, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
106
149
  const binding = bindStageCoordinatorWork(context, { workId: work.work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(requireRunHome(run), "02-protocolize", "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
107
150
  // Same-session handoff deliberately reuses the root Work/Session that
108
151
  // SPECIFY opened. That is not evidence that PROTOCOLIZE is already attached.
@@ -133,10 +176,9 @@ export function finishVnextProtocolize(context, input) {
133
176
  if (!work)
134
177
  throw new AppError("not_found", "RUN has no PROTOCOLIZE Work", 1, { run_id: run.id });
135
178
  const root = path.join(requireRunHome(run), "02-protocolize");
136
- const resultFile = path.resolve(input.resultFile);
137
- inside(root, resultFile);
138
- validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
139
- const result = readResult(resultFile);
179
+ const resultFile = prepareProtocolizeResultPath(requireRunHome(run), input.resultFile);
180
+ const result = input.result ?? readResult(resultFile, { projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
181
+ validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, data: result, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
140
182
  if (result.outcome !== "protocolized")
141
183
  throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
142
184
  const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
@@ -165,9 +207,9 @@ export function finishVnextProtocolize(context, input) {
165
207
  validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
166
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" });
167
209
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
168
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
210
+ advanceFlowRun(context, { settlementStage: "protocolize", projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
169
211
  refreshRunWorkProjection(context, project.id, run.id);
170
- const nextCommand = `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`;
212
+ const nextCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`);
171
213
  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")) } };
172
214
  }
173
215
  function materialize(projectRoot, runId, runHome, result, obligations, ids, psetId) {
@@ -309,21 +351,27 @@ function appendIndexEntry(indexPath, childPath, label) {
309
351
  }
310
352
  function epicRootFor(projectRoot, value) { const resolved = path.resolve(projectRoot, value); const relative = path.relative(projectRoot, resolved); if (relative.startsWith("..") || path.isAbsolute(relative))
311
353
  throw new AppError("path_escape", "epic_path must stay inside the project", 2, { epic_path: value }); return path.basename(resolved) === "index.md" ? path.dirname(resolved) : resolved; }
312
- function readResult(file) {
313
- if (!fs.existsSync(file))
314
- throw new AppError("not_found", "PROTOCOLIZE result is missing", 1, { file });
315
- const value = JSON.parse(fs.readFileSync(file, "utf8"));
316
- return value;
354
+ function readResult(file, context) {
355
+ return prepareJsonSchema(file, "PROTOCOLIZE result", {
356
+ schemaName: "vnext-protocolize-result", projectRoot: context.projectRoot, ddFlowHome: context.ddFlowHome, runId: context.runId, runRoot: context.runRoot
357
+ }).value;
317
358
  }
318
359
  function acceptedSpecifyObligations(context, projectRoot, runId, specifyPath) {
319
360
  if (!fs.existsSync(specifyPath))
320
- throw new AppError("not_found", "PROTOCOLIZE requires accepted specify.json", 1, { path: specifyPath });
321
- validateSchema({ schemaName: "vnext-specify", file: specifyPath, projectRoot, ddFlowHome: context.ddFlowHome, runId, runRoot: requireRunHome(requireRun(context, projectRoot, runId)) });
322
- const specify = readVnextSpecifyResult(specifyPath);
323
- return [
324
- ...specify.requirements.map((obligation) => ({ ...obligation, kind: "requirement" })),
325
- ...specify.acceptance_criteria.map((obligation) => ({ ...obligation, kind: "acceptance" }))
326
- ];
361
+ throw new AppError("runtime_artifact_missing", "PROTOCOLIZE requires accepted specify.json", 1, { path: specifyPath });
362
+ try {
363
+ const specify = readVnextSpecifyResult(specifyPath);
364
+ validateSchema({ schemaName: "vnext-specify", file: specifyPath, data: specify, projectRoot, ddFlowHome: context.ddFlowHome, runId, runRoot: requireRunHome(requireRun(context, projectRoot, runId)) });
365
+ return [
366
+ ...specify.requirements.map((obligation) => ({ ...obligation, kind: "requirement" })),
367
+ ...specify.acceptance_criteria.map((obligation) => ({ ...obligation, kind: "acceptance" }))
368
+ ];
369
+ }
370
+ catch (error) {
371
+ if (!(error instanceof AppError) || !["validation", "schema_validation", "invalid_json"].includes(error.code))
372
+ throw error;
373
+ throw new AppError("runtime_artifact_invalid", "Accepted SPECIFY result is invalid", 1, { path: specifyPath, cause_code: error.code });
374
+ }
327
375
  }
328
376
  /** SPECIFY is historical input; only a HITL-backed protocol amendment may alter its effective wording. */
329
377
  function effectiveObligations(result, accepted) {
@@ -435,18 +483,19 @@ function resultTemplate(obligations = []) {
435
483
  }
436
484
  function renderPrompt(input) {
437
485
  const obligationList = input.obligations.map((obligation) => `- ${obligation.id}: ${obligation.statement}`).join("\n");
438
- 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(), null, 2)}\n\`\`\``, "Allocate every supplied R-* and AC-* exactly once in obligation_ownership. 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. Run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second 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.flow} stage finish ${input.runId} --stage protocolize --project-root ${JSON.stringify(input.projectRoot)} --result-file ${JSON.stringify(input.resultPath)} --json`, "</output_contract>", ""].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");
439
487
  }
440
- function provisionWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
488
+ function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
441
489
  const receiptPath = path.join(stageRoot, "workspace-route.json");
442
490
  if (fs.existsSync(receiptPath))
443
- return readWorkspaceRouteReceipt(stageRoot).policy;
491
+ return { kind: "retained", policy: readWorkspaceRouteReceipt(stageRoot).policy };
492
+ prepareOutputFile(receiptPath);
444
493
  const config = loadVnextWorkspacePolicy(projectRoot);
445
494
  const stableFacts = gitFacts(projectRoot);
495
+ const sessionMode = stageSessionMode(context, run);
446
496
  if (config.workspace.route === "integration_branch_direct") {
447
- 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: stageSessionMode(context, run), provisioned: true, bootstrap_status: "not_applicable" };
448
- writeJson(receiptPath, { schema_id: "dd-flow/vnext-workspace-route@1", policy, created: null, bootstrap: null });
449
- return policy;
497
+ 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
+ return { kind: "direct", receiptPath, policy };
450
499
  }
451
500
  if (stableFacts.status === "unavailable" || !stableFacts.head || stableFacts.branch !== config.workspace.integration_branch) {
452
501
  throw new AppError("workspace_route_invalid", "Configured feature worktree requires a clean, known integration checkout", 1, { expected_integration_branch: config.workspace.integration_branch, actual: stableFacts, project_root: projectRoot });
@@ -454,10 +503,31 @@ function provisionWorkspaceForProtocolize(context, projectRoot, projectId, run,
454
503
  const branch = vnextRunBranch(config, run);
455
504
  const holder = path.basename(projectRoot).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
456
505
  const worktreePath = projectFeatureWorktreePath(context.ddFlowHome, projectId, `RUN-${run.short_id}`, holder);
457
- const created = createManagedWorktree(context, { projectRoot, branch, base: stableFacts.head, path: worktreePath });
506
+ let create;
507
+ try {
508
+ create = prepareManagedWorktree({ projectRoot, branch, base: stableFacts.head, path: worktreePath });
509
+ }
510
+ catch (error) {
511
+ if (error instanceof AppError && error.exitCode === 2)
512
+ throw new AppError("workspace_route_invalid", "The frozen RUN workspace route cannot be provisioned", 1, { cause_code: error.code, run_id: run.id });
513
+ throw error;
514
+ }
515
+ prepareManagedWorktreeBootstrapSources(projectRoot);
516
+ return { kind: "feature", receiptPath, config, stableFacts, sessionMode, branch, create };
517
+ }
518
+ function provisionWorkspaceForProtocolize(context, projectRoot, run, prepared) {
519
+ if (prepared.kind === "retained")
520
+ return prepared.policy;
521
+ const { receiptPath } = prepared;
522
+ if (prepared.kind === "direct") {
523
+ writeJson(receiptPath, { schema_id: "dd-flow/vnext-workspace-route@1", policy: prepared.policy, created: null, bootstrap: null });
524
+ return prepared.policy;
525
+ }
526
+ const { config, stableFacts, sessionMode, branch, create } = prepared;
527
+ const created = createManagedWorktree(context, create, create);
458
528
  const bootstrap = bootstrapManagedWorktree(context, { projectRoot, worktreePath: created.worktreePath });
459
529
  rebindFlowRunWorkspace(context, { projectRoot, runId: run.id, workspaceRoot: created.worktreePath, reason: "PROTOCOLIZE start provisioned the configured feature-worktree route" });
460
- const policy = { route: "feature_worktree", integration_branch: config.workspace.integration_branch, base_ref: stableFacts.head, feature_branch: branch, worktree_path: created.worktreePath, reason: "Project workspace configuration requires a managed feature worktree before durable protocol documents exist.", provision_stage: "protocolize_start", stage_session_mode: stageSessionMode(context, run), provisioned: true, bootstrap_status: bootstrap.status };
530
+ const policy = { route: "feature_worktree", integration_branch: config.workspace.integration_branch, base_ref: stableFacts.head, feature_branch: branch, worktree_path: created.worktreePath, reason: "Project workspace configuration requires a managed feature worktree before durable protocol documents exist.", provision_stage: "protocolize_start", stage_session_mode: sessionMode, provisioned: true, bootstrap_status: bootstrap.status };
461
531
  writeJson(receiptPath, { schema_id: "dd-flow/vnext-workspace-route@1", policy, created, bootstrap });
462
532
  return policy;
463
533
  }
@@ -502,7 +572,12 @@ function requireRun(context, projectRoot, id) { const project = requireProjectBy
502
572
  throw new AppError("not_found", "RUN is not registered", 1, { run_id: id }); return run; }
503
573
  function requireRunHome(run) { if (!run.run_root)
504
574
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
505
- function inside(root, file) { const relative = path.relative(root, file); if (relative.startsWith("..") || path.isAbsolute(relative))
575
+ export function prepareProtocolizeResultPath(runHome, file) {
576
+ const resolved = path.resolve(file);
577
+ inside(path.join(runHome, "02-protocolize"), resolved);
578
+ return resolved;
579
+ }
580
+ function inside(root, file) { const relative = path.relative(root, file); if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
506
581
  throw new AppError("path_escape", "Result must be inside the protocolize workspace", 2); }
507
582
  function writeJson(file, value) { const tmp = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(tmp, file); }
508
583
  function slug(value) { const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); if (!normalized)
@@ -1,19 +1,24 @@
1
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { spawnSync } from "node:child_process";
5
6
  import { AppError } from "../shared/errors.js";
6
- import { applyExternalStageContext } from "./stage-context.js";
7
+ import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
7
8
  import { ensureVnextWorkStorage } from "../storage/database.js";
8
- import { resolveProjectRoot, writeJsonAtomic } from "../storage/paths.js";
9
+ import { projectHome, resolveProjectRoot, writeJsonAtomic } from "../storage/paths.js";
9
10
  import { preflightMemoryPermissions } from "./memory-permissions.js";
10
11
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
- import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
12
+ import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, prepareFlowRunStart, startFlowRun } from "./runs.js";
13
+ import { resolveStageTransition } from "./execution-policy.js";
12
14
  import { validateSchema } from "./schema-validation.js";
13
15
  import { bindRunningWorkSession, refreshRunWorkProjection } from "./work-registry.js";
14
16
  import { nextWorkId } from "./ids.js";
15
17
  import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
16
18
  import { writeStageReport } from "./stage-report-renderer.js";
19
+ import { prepareError, prepareJsonSchema, prepareJsonText, prepareOutputDirectory, prepareOutputFile, prepareTextFile } from "../cli/input-preparation.js";
20
+ import { hookSessionIdentity } from "./hooks.js";
21
+ import { assertRecoveryHookCurrent, assertRunMutationAllowed } from "./run-recovery.js";
17
22
  const flowId = "mb-sdlc-vnext-specify";
18
23
  const protocolizeFlowId = "mb-sdlc-vnext-protocolize";
19
24
  const flowVersion = 4;
@@ -27,28 +32,57 @@ export function isVnextSpecifyRun(context, input) {
27
32
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
28
33
  return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind IN ('vnext_specify', 'vnext_protocolize')", [project.id, input.runId]));
29
34
  }
30
- export function launchVnextSpecify(context, input) {
31
- ensureVnextWorkStorage(context.db);
35
+ export function prepareVnextSpecifyStart(context, input) {
32
36
  const projectRoot = resolveProjectRoot(input.projectRoot);
33
- registerProject(context, { root: projectRoot });
34
37
  const intake = readIntake(input);
35
38
  const discussion = intake.markdown.trim();
36
39
  if (!discussion)
37
40
  throw new AppError("validation", "SPECIFY intake must not be empty", 2);
38
41
  const flow = assertFlowDefinition(projectRoot);
39
42
  const template = readRequiredFile(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "specify.md"), "vNext SPECIFY prompt template");
40
- const started = input.runId ? null : startFlowRun(context, {
41
- projectRoot,
42
- flowKind: flow.id === protocolizeFlowId ? "vnext_protocolize" : "vnext_specify",
43
- subjectType: "discussion",
44
- subjectId: "SPECIFY",
45
- slug: input.slug ?? "specify",
46
- nextAction: "await_work_session"
47
- });
43
+ const startInput = { projectRoot, flowKind: flow.id === protocolizeFlowId ? "vnext_protocolize" : "vnext_specify", subjectType: "discussion", subjectId: "SPECIFY", slug: input.slug ?? "specify", nextAction: "await_work_session" };
44
+ const start = input.runId ? undefined : prepareFlowRunStart(context, startInput);
45
+ if (input.runId)
46
+ assertSpecifyNotStarted(context, projectRoot, input.runId);
47
+ const run = input.runId ? requireRun(context, projectRoot, input.runId) : undefined;
48
+ if (!run) {
49
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
50
+ prepareOutputDirectory(project ? path.join(projectHome(context.ddFlowHome, project.id), "runs") : path.join(context.ddFlowHome, "projects"));
51
+ }
52
+ if (run) {
53
+ assertRunMutationAllowed(context, run.project_id, run.id);
54
+ const home = requiredRunHome(run), stageRoot = path.join(home, "01-specify");
55
+ for (const relative of ["intake/discussion.md", "01-specify/prompt.md", "01-specify/work-context.json"])
56
+ prepareOutputFile(path.join(home, relative));
57
+ if (input.externalContext)
58
+ prepareExternalStageContext({ stageRoot, loaded: input.externalContext });
59
+ }
60
+ const preflight = preflightMemoryPermissions({ root: projectRoot, memoryBank: ".memory-bank", flow: "custom", mode: "read", targets: [{ path: ".memory-bank", mode: "read" }] });
61
+ if (preflight.ok !== true)
62
+ throw new AppError("permission_preflight_failed", "SPECIFY project context is not readable", 1, { preflight });
63
+ const grounding = projectGrounding(projectRoot);
64
+ const handoff = run ? executionSnapshot(context, run.project_id, run.id) : executionSnapshotFromProfile(context, start?.executionProfile);
65
+ return { projectRoot, intake, discussion, template, startInput, start, preflight, grounding, handoff };
66
+ }
67
+ function assertSpecifyNotStarted(context, projectRoot, runId) {
68
+ const existing = getFlowRunStatus(context, { projectRoot, runId });
69
+ if (existing.index?.stage_runs?.some(entry => entry.stage === stageId))
70
+ throw new AppError("stage_already_started", "SPECIFY is already started for this RUN", 1, { run_id: runId });
71
+ }
72
+ export function launchVnextSpecify(context, input, prepared = prepareVnextSpecifyStart(context, input)) {
73
+ const { projectRoot, intake, discussion, template, startInput, start, preflight, grounding, handoff } = prepared;
74
+ if (input.runId)
75
+ assertSpecifyNotStarted(context, projectRoot, input.runId);
76
+ const project = requireProjectByRoot(context, projectRoot);
77
+ const identity = hookSessionIdentity(context, project.id, input.hookEventId);
78
+ if (input.runId) {
79
+ assertRunMutationAllowed(context, project.id, input.runId);
80
+ assertRecoveryHookCurrent(context, project.id, input.runId, identity);
81
+ }
82
+ ensureVnextWorkStorage(context.db);
83
+ registerProject(context, { root: projectRoot });
84
+ const started = input.runId ? null : startFlowRun(context, startInput, start);
48
85
  const run = requireRun(context, projectRoot, input.runId ?? started.run.id);
49
- const existingStages = getFlowRunStatus(context, { projectRoot, runId: run.id });
50
- if (existingStages.index?.stage_runs?.some((entry) => entry.stage === stageId))
51
- throw new AppError("stage_already_started", "SPECIFY is already started for this RUN", 1, { run_id: run.id });
52
86
  const runHome = requiredRunHome(run);
53
87
  const stageRoot = path.join(runHome, "01-specify");
54
88
  const intakeDir = path.join(runHome, "intake");
@@ -61,11 +95,6 @@ export function launchVnextSpecify(context, input) {
61
95
  const contextPath = path.join(stageRoot, "work-context.json");
62
96
  const workId = nextWorkId(context, run.project_id, "root");
63
97
  const now = context.now();
64
- const preflight = writablePreflight(projectRoot, stageRoot);
65
- if (preflight.ok !== true)
66
- throw new AppError("permission_preflight_failed", "SPECIFY workspace is not writable", 1, { preflight });
67
- const grounding = projectGrounding(projectRoot);
68
- const handoff = executionSnapshot(context, run.id);
69
98
  const workContext = {
70
99
  schema_id: "dd-flow/work-context@1",
71
100
  system: { run_id: run.id, work_id: workId, stage: stageId },
@@ -82,7 +111,7 @@ export function launchVnextSpecify(context, input) {
82
111
  };
83
112
  writeJson(contextPath, workContext);
84
113
  const pauseCommand = stagePauseCommand(context, { runId: run.id, stage: stageId, workId, projectRoot });
85
- const prompt = renderPrompt({ workId, runId: run.id, projectRoot, stageRoot, resultPath, discussion, template, workContext, grounding, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), flow: flowCommand(context) });
114
+ const prompt = renderPrompt({ workId, runId: run.id, projectRoot, stageRoot, resultPath, discussion, template, workContext, grounding, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), finishCommand: managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${run.id} --project-root ${JSON.stringify(projectRoot)} --stage specify --result-file ${JSON.stringify(path.join(stageRoot, "specify-result.json"))} --outcome specified --json`) });
86
115
  fs.writeFileSync(promptPath, prompt);
87
116
  const externalContext = applyExternalStageContext({ stageRoot, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
88
117
  context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, depends_on_json, status, result, started_at, created_at, updated_at, completed_at)
@@ -126,7 +155,9 @@ export function submitVnextSpecify(context, input) {
126
155
  if (work.project_id !== project.id)
127
156
  throw new AppError("project_mismatch", "Work does not belong to --project-root", 1, { work_id: work.work_id });
128
157
  if (work.status !== "running") {
129
- throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status });
158
+ // This check precedes all result reads and mutations. A later legal state
159
+ // transition may make a new invocation appropriate, but never replays this one.
160
+ throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status, retryable_no_effect: true });
130
161
  }
131
162
  const run = requireRun(context, projectRoot, work.run_id);
132
163
  if (runStatus(context, project.id, run.id) !== "running") {
@@ -187,9 +218,9 @@ export function submitVnextSpecify(context, input) {
187
218
  appendFlowRunTimelineEvent(context, run.project_id, run.id, { type: "work_completed", work_id: work.work_id, outcome, stage: stageId });
188
219
  if (continuesToProtocolize) {
189
220
  const nextWork = requireWork(context, work.work_id);
190
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "specified", nextAction: "start_protocolize" });
221
+ advanceFlowRun(context, { settlementStage: "specify", projectRoot, runId: run.id, status: "running", verdict: "specified", nextAction: "start_protocolize" });
191
222
  refreshRunWorkProjection(context, run.project_id, run.id);
192
- const nextCommand = `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`;
223
+ const nextCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`);
193
224
  return {
194
225
  ok: true,
195
226
  schema_id: "dd-flow/vnext-work-submit@1",
@@ -241,13 +272,23 @@ export function finishVnextSpecifyStage(context, input) {
241
272
  const run = requireRun(context, projectRoot, input.runId);
242
273
  const stageRoot = path.join(requiredRunHome(run), "01-specify");
243
274
  const acceptedResult = path.join(stageRoot, "specify.json");
244
- const suppliedResult = input.resultJson ?? readRequiredFile(path.resolve(input.resultFile ?? acceptedResult), "SPECIFY result");
275
+ const source = input.resultFile ? path.resolve(input.resultFile) : acceptedResult;
276
+ const prepared = input.result
277
+ ? { file: source, value: input.result }
278
+ : input.resultJson !== undefined
279
+ ? prepareJsonText(input.resultJson, { file: "<stdin>", label: "SPECIFY result" })
280
+ : prepareJsonSchema(source, "SPECIFY result", { schemaName: "vnext-specify", projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requiredRunHome(run) });
281
+ try {
282
+ validateSchema({ schemaName: "vnext-specify", file: prepared.file, data: prepared.value, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requiredRunHome(run) });
283
+ validateObligations(prepared.value, prepared.file);
284
+ }
285
+ catch (error) {
286
+ return prepareError(error, { file: prepared.file, label: "SPECIFY result" });
287
+ }
245
288
  const candidate = path.join(stageRoot, `.specify-candidate-${crypto.randomUUID()}.json`);
246
- writeText(candidate, suppliedResult);
247
- // A previous worker contract allowed the final projection path as input.
248
- // Do not leave that unvalidated payload behind as an accepted-looking result.
249
- if (input.resultFile && path.resolve(input.resultFile) === acceptedResult)
250
- fs.rmSync(acceptedResult, { force: true });
289
+ // Candidate storage starts only after the exact input has passed schema and
290
+ // semantic checks; it is never the caller's source file.
291
+ writeText(candidate, `${JSON.stringify(prepared.value, null, 2)}\n`);
251
292
  try {
252
293
  return submitVnextSpecify(context, { projectRoot, workId: work.work_id, resultFile: candidate, outcome: input.outcome });
253
294
  }
@@ -260,11 +301,7 @@ function readIntake(input) {
260
301
  return { markdown: input.intakeMarkdown };
261
302
  if (!input.intakeFile)
262
303
  throw new AppError("usage", "SPECIFY intake is required", 2);
263
- const intakeFile = path.resolve(input.intakeFile);
264
- if (!fs.existsSync(intakeFile) || !fs.statSync(intakeFile).isFile()) {
265
- throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: input.intakeFile });
266
- }
267
- return { markdown: fs.readFileSync(intakeFile, "utf8") };
304
+ return { markdown: prepareTextFile(input.intakeFile, "SPECIFY intake").text };
268
305
  }
269
306
  function requireRun(context, projectRoot, runId) {
270
307
  const project = requireProjectByRoot(context, projectRoot);
@@ -299,28 +336,6 @@ function requireWorkSession(context, workSessionId) {
299
336
  function latestWorkSession(context, workId) {
300
337
  return context.db.get("SELECT * FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC, id DESC LIMIT 1", [workId]) ?? null;
301
338
  }
302
- function writablePreflight(projectRoot, stageRoot) {
303
- const memoryBank = preflightMemoryPermissions({
304
- root: projectRoot,
305
- memoryBank: ".memory-bank",
306
- flow: "custom",
307
- mode: "write",
308
- targets: [{ path: ".memory-bank", mode: "read" }]
309
- });
310
- const probe = path.join(stageRoot, `.dd-flow-probe-${crypto.randomUUID()}`);
311
- try {
312
- fs.writeFileSync(probe, "");
313
- fs.rmSync(probe);
314
- return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
315
- }
316
- catch (error) {
317
- try {
318
- fs.rmSync(probe, { force: true });
319
- }
320
- catch { /* best effort */ }
321
- return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
322
- }
323
- }
324
339
  function projectGrounding(projectRoot) {
325
340
  return [
326
341
  ".memory-bank/index.md",
@@ -368,6 +383,13 @@ function renderPrompt(input) {
368
383
  "```",
369
384
  "</trusted_runtime_context>",
370
385
  "",
386
+ "<workspace_contract>",
387
+ `- authoritative SPECIFY artifact directory: ${input.stageRoot}`,
388
+ `- result file to write: ${input.resultPath}`,
389
+ `- project root is read-only grounding for this Work: ${input.projectRoot}`,
390
+ "Write only the named result and stage-local artifacts. Do not switch cwd, create a worktree, or write another RUN/native home.",
391
+ "</workspace_contract>",
392
+ "",
371
393
  "<eval_input_boundary>",
372
394
  "Use only this prompt, the current project root, this stage workspace and project files reached through the supplied grounding. Do not read or search another RUN, transcript, prior result, reviewer material or other project under ~/.dd-flow or ~/.codex.",
373
395
  "If stage finish rejects the result, correct this same JSON result only from the returned validation error. Do not seek another schema or example outside this stage workspace.",
@@ -392,13 +414,13 @@ function renderPrompt(input) {
392
414
  "```",
393
415
  "The JSON must preserve the problem-space contract for a fresh PROTOCOLIZE worker: user intent, scope, acceptance and verification, settled defaults, relevant project facts, gap-method outcomes, task assessment, delivery shape and handoff. Use stable R-001... identifiers in requirements and AC-001... identifiers in acceptance_criteria. Do not describe implementation design.",
394
416
  "Do not finish while a material user question remains. Use stage pause instead of adding a question to specify.json.",
395
- "If a user answer is required, make this the lifecycle command instead of finish. Run this exact one-command heredoc, replacing only the placeholder body with the concise user-facing question packet. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:",
417
+ "If a user answer is required, use the file and standalone lifecycle command below instead of finish. Write only the concise user-facing question packet to the named text file; do not combine file creation and dd-flow in one shell command:",
396
418
  "```sh",
397
419
  input.pauseCommandTemplate,
398
420
  "```",
399
421
  "The response tells you exactly what to ask and how to resume this same stage. Ask its user_message and stop the Turn.",
400
- `Finish exactly once only after all questions are resolved. Run this as one standalone Bash command:\n\`${input.flow} stage finish ${input.runId} --project-root "${input.projectRoot}" --stage specify --result-file ${JSON.stringify(path.join(input.stageRoot, "specify-result.json"))} --outcome specified --json\`.`,
401
- "Do not combine the lifecycle command with cat, skill reads, Git commands, pipes or shell operators. If finish reports a validation error, correct the same result file and rerun that exact command in this Session. After success, follow only its returned next directive; a focused eval controller may explicitly stop at this stage boundary.",
422
+ `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.",
402
424
  "</output_contract>",
403
425
  ""
404
426
  ].join("\n");
@@ -562,12 +584,20 @@ export function readVnextFlowDefinition(projectRoot) {
562
584
  }
563
585
  return null;
564
586
  }
565
- function executionSnapshot(context, runId) {
566
- const row = context.db.get("SELECT index_json FROM runs WHERE id = ?", [runId]);
587
+ function executionSnapshot(context, projectId, runId) {
588
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
567
589
  const snapshot = row ? JSON.parse(row.index_json) : null;
568
- const handoff = snapshot?.execution_profile?.settings?.stage_session_mode;
590
+ return executionSnapshotFromProfile(context, snapshot?.execution_profile, runId);
591
+ }
592
+ function executionSnapshotFromProfile(context, profile, runId) {
593
+ let handoff = profile?.settings.stage_session_mode;
569
594
  if (handoff !== "same_session" && handoff !== "new_session")
570
595
  throw new AppError("execution_profile_invalid", "RUN has no frozen stage session mode", 1, { run_id: runId });
596
+ if (profile?.settings.execution) {
597
+ if (!profile.agent_profiles)
598
+ 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;
600
+ }
571
601
  return { stage_handoff: { effective: handoff, source: "run_override" } };
572
602
  }
573
603
  function executionSnapshotFromPath(contextPath, workId) {