@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100

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 (153) hide show
  1. package/CHANGELOG.md +561 -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 +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  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 +39 -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/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  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 +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  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 +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -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 +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  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/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -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 +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,35 +1,59 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
- import os from "node:os";
4
3
  import path from "node:path";
4
+ import { quote } from "shell-quote";
5
+ import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
5
6
  import { AppError } from "../shared/errors.js";
7
+ import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
6
8
  import { resolveProjectRoot } from "../storage/paths.js";
9
+ import { resolveWorkReference } from "../storage/work-references.js";
7
10
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
8
11
  import { requireProjectByRoot } from "./projects.js";
9
12
  import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
10
- import { refreshRunWorkProjection } from "./work-registry.js";
11
- export function pauseStageForUser(context, input) {
13
+ import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
14
+ import { assertRunMutationAllowed } from "./run-recovery.js";
15
+ import { runtimeCommand } from "./runtime-command.js";
16
+ export { flowCommand } from "./runtime-command.js";
17
+ export function prepareStagePause(context, input) {
18
+ if (typeof input.question !== "string" || !input.question.trim())
19
+ throw new AppError("validation", "stage pause requires a non-empty user question", 2);
20
+ safeStage(input.stage);
12
21
  const projectRoot = resolveProjectRoot(input.projectRoot);
13
22
  const project = requireProjectByRoot(context, projectRoot);
14
23
  const run = requireRun(context, project.id, input.runId);
24
+ assertRunMutationAllowed(context, project.id, run.id);
15
25
  const work = requireWork(context, project.id, run.id, input.workId);
16
26
  if (work.status !== "running")
17
27
  throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
18
28
  const question = input.question;
19
- if (!question.trim())
20
- throw new AppError("validation", "stage pause requires a non-empty user question", 2);
21
29
  const stage = requireStage(run, input.stage, "running");
30
+ requireRunHome(run);
31
+ return { projectRoot, project, run, work, question, stage };
32
+ }
33
+ export function pauseStageForUser(context, input) {
34
+ const { projectRoot, project, run, work, question, stage } = prepareStagePause(context, input);
22
35
  const pauseId = nextPauseId(requireRunHome(run));
23
36
  const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
24
37
  const questionPath = path.join(pauseRoot, "question.md");
25
38
  fs.mkdirSync(pauseRoot, { recursive: true });
26
39
  fs.writeFileSync(questionPath, question);
27
40
  const now = context.now();
28
- context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
29
- pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
41
+ context.db.beginWriteTransaction();
42
+ try {
43
+ const paused = context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
44
+ if (paused.changes !== 1)
45
+ throw new AppError("invalid_work_state", "Work changed before pause could be accepted", 1, { work_id: work.work_id });
46
+ pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
47
+ context.db.exec("COMMIT");
48
+ }
49
+ catch (error) {
50
+ context.db.exec("ROLLBACK");
51
+ throw error;
52
+ }
30
53
  refreshRunWorkProjection(context, project.id, run.id);
31
- const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
32
- const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
54
+ const answerInputPath = path.join(pauseRoot, "answer-input.md");
55
+ const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, answerInputPath });
56
+ const resumeTemplate = `Write the complete user answer exactly to ${quote([answerInputPath])}, then run:\n${resume}`;
33
57
  return {
34
58
  ok: true,
35
59
  outcome: "paused",
@@ -38,63 +62,93 @@ export function pauseStageForUser(context, input) {
38
62
  stage: stage.stage,
39
63
  pause: { id: pauseId, reason: "waiting_for_user", question_path: questionPath, user_message: question },
40
64
  next_action: "ask_user_then_resume_same_stage",
41
- agent_instruction: "Send user_message to the user and stop this Turn. When the user answers, do not interpret or edit the answer first: make resume_command the first flow command, pass the complete raw answer on stdin, then follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.",
65
+ agent_instruction: `Send user_message to the user and stop this Turn. When the user answers, write the complete raw answer without interpretation to ${answerInputPath}, then make resume_command the first flow command and follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.`,
42
66
  resume_command: resume,
43
67
  resume_command_template: resumeTemplate
44
68
  };
45
69
  }
46
- export function resumeStageAfterUser(context, input) {
70
+ export function prepareStageResume(context, input) {
71
+ if (typeof input.answer !== "string" || !input.answer.trim())
72
+ throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
73
+ safeStage(input.stage);
47
74
  const projectRoot = resolveProjectRoot(input.projectRoot);
48
75
  const project = requireProjectByRoot(context, projectRoot);
49
76
  const run = requireRun(context, project.id, input.runId);
77
+ assertRunMutationAllowed(context, project.id, run.id);
50
78
  const work = requireWork(context, project.id, run.id, input.workId);
51
79
  if (work.status !== "paused")
52
80
  throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
53
81
  const stage = requireStage(run, input.stage, "paused");
54
82
  if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
55
83
  throw new AppError("invalid_stage_state", "Stage has no unanswered pause for this Work", 1, { run_id: run.id, stage: stage.stage, work_id: work.work_id });
56
- if (!input.answer.trim())
57
- throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
84
+ const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
85
+ if (!workSession)
86
+ throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
87
+ let evidence = input.evidence;
88
+ if (evidence && (evidence.pauseId !== stage.pause.id || evidence.promptPath !== workSession.prompt_path || evidence.questionPath !== stage.pause.question_path))
89
+ throw new AppError("runtime_state_changed", "Paused Work changed after resume preparation", 1, { work_id: work.work_id });
90
+ if (!evidence) {
91
+ try {
92
+ evidence = { pauseId: stage.pause.id, promptPath: workSession.prompt_path, questionPath: stage.pause.question_path, prompt: fs.readFileSync(workSession.prompt_path, "utf8"), question: fs.readFileSync(stage.pause.question_path, "utf8") };
93
+ }
94
+ catch (error) {
95
+ throw new AppError("runtime_missing", "Paused Work continuation artifacts cannot be read", 1, { work_id: work.work_id, cause: String(error) });
96
+ }
97
+ }
98
+ return { projectRoot, project, run, work, stage, pause: stage.pause, workSession, evidence };
99
+ }
100
+ export function resumeStageAfterUser(context, input) {
101
+ const { projectRoot, project, run, work, stage, pause, workSession, evidence } = prepareStageResume(context, input);
58
102
  const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
59
103
  projectId: project.id,
60
- matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
104
+ matchKey: entityReferenceVariants(run.id).flatMap((runReference) => entityReferenceVariants(work.work_id).map((workReference) => stageResumeMatchKey(runReference, stage.stage, workReference, projectRoot))),
61
105
  errorCode: "trusted_stage_resume_required",
62
106
  operation: "stage resume"
63
107
  }).eventKey;
64
- const identity = claimStageResumeHookEvent(context, {
65
- projectId: project.id,
66
- runId: run.id,
67
- stage: stage.stage,
68
- workId: work.work_id,
69
- projectRoot,
70
- eventKey: hookEventId
71
- });
72
- const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
73
- fs.writeFileSync(answerPath, input.answer);
74
108
  const now = context.now();
75
- const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
76
- if (!workSession)
77
- throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
78
- if (workSession.session_id !== identity.sessionId) {
79
- context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
80
- context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
109
+ const answerPath = path.join(path.dirname(pause.question_path), `answer-${crypto.createHash("sha256").update(`${pause.id}\0${input.answer}`).digest("hex")}.md`);
110
+ context.db.beginWriteTransaction();
111
+ let identity;
112
+ try {
113
+ identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
114
+ try {
115
+ fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
116
+ }
117
+ catch (error) {
118
+ if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
119
+ throw error;
120
+ }
121
+ if (workSession.session_id !== identity.sessionId) {
122
+ bindSessionForResume(context, work.work_id, identity, now);
123
+ context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
124
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
125
+ }
126
+ const resumed = context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
127
+ if (resumed.changes !== 1)
128
+ throw new AppError("invalid_work_state", "Work changed before resume could be accepted", 1, { work_id: work.work_id });
129
+ resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
130
+ context.db.exec("COMMIT");
131
+ }
132
+ catch (error) {
133
+ context.db.exec("ROLLBACK");
134
+ throw error;
81
135
  }
82
- context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
83
- resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
84
136
  refreshRunWorkProjection(context, project.id, run.id);
85
137
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
86
- const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
138
+ const prompt = evidence.prompt;
139
+ const retryCommands = lifecycleRetryCommands(context, stage.stage);
87
140
  const continuation = [
88
141
  prompt.trimEnd(),
89
142
  "",
90
143
  "<hitl_resume>",
91
- `- Pause: ${stage.pause.id}`,
92
- `- Question source: ${stage.pause.question_path}`,
144
+ `- Pause: ${pause.id}`,
145
+ `- Question source: ${pause.question_path}`,
93
146
  `- Answer source: ${answerPath}`,
94
147
  "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
148
+ ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; correct the rejected input and use only these replacements:", ...retryCommands.map(command => ` ${command}`)] : []),
95
149
  "",
96
150
  "<user_question>",
97
- fs.readFileSync(stage.pause.question_path, "utf8"),
151
+ evidence.question,
98
152
  "</user_question>",
99
153
  "",
100
154
  "<user_answer>",
@@ -103,18 +157,18 @@ export function resumeStageAfterUser(context, input) {
103
157
  "</hitl_resume>",
104
158
  ""
105
159
  ].join("\n");
106
- return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: stage.pause.id, question_path: stage.pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
160
+ return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
107
161
  }
108
162
  export function stagePauseCommand(context, input) {
109
- return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
163
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
164
+ const run = requireRun(context, project.id, input.runId);
165
+ const questionInputPath = path.join(requireRunHome(run), "works", input.workId, "question-input.md");
166
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "pause", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--question-file", questionInputPath, "--project-root", input.projectRoot, "--json"]));
110
167
  }
111
- /**
112
- * The only shell form allowed for an agent-owned HITL pause. Supplying a
113
- * complete heredoc makes stdin explicit without asking an agent to invent a
114
- * pipe, a temporary file or a second command.
115
- */
168
+ /** Render separate file-write and lifecycle-call instructions for HITL. */
116
169
  export function stagePauseCommandTemplate(command) {
117
- return `${command} <<'USER_QUESTION'
170
+ const questionFile = command.match(/--question-file\s+(?:'([^']*)'|"([^"]*)"|(\S+))/)?.slice(1).find(Boolean) ?? "<question-file-from-command>";
171
+ return `Write this text to ${questionFile}:
118
172
  ## Q-001 — <short decision title>
119
173
 
120
174
  Why this decision is required:
@@ -129,51 +183,41 @@ Recommendation:
129
183
 
130
184
  Effect on scope or acceptance:
131
185
  - <one sentence>
132
- USER_QUESTION`;
186
+
187
+ Then run this standalone command:
188
+ ${command}`;
133
189
  }
134
190
  function stageResumeCommand(context, input) {
135
- return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
136
- }
137
- export function flowCommand(context) {
138
- const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
139
- // Desktop/ACP tool processes are not required to inherit the environment of
140
- // the adapter that created their Session. In an isolated runtime, a bare
141
- // `dd-flow` can therefore select the host-global router despite a correct
142
- // DD_FLOW_HOME. The runner supplies an absolute executable for that case;
143
- // make every generated lifecycle command self-contained.
144
- const executable = context.env.DD_FLOW_BIN;
145
- if (executable && path.isAbsolute(executable)) {
146
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
147
- }
148
- const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
149
- if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
150
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
151
- }
152
- return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
191
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "resume", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--answer-file", input.answerInputPath, "--project-root", input.projectRoot, "--json"]));
153
192
  }
154
193
  function requireRun(context, projectId, id) {
155
- const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
194
+ const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
156
195
  if (rows.length !== 1)
157
196
  throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: id });
158
197
  return rows[0];
159
198
  }
160
199
  function requireWork(context, projectId, runId, workId) {
161
- const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
162
- if (!work)
163
- throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
164
- return work;
200
+ return resolveWorkReference(context, workId, { projectId, runId });
165
201
  }
166
202
  function requireStage(run, stage, status) {
167
- const index = JSON.parse(run.index_json);
168
- const found = index.stage_runs?.find((item) => item.stage === stage);
203
+ let index;
204
+ try {
205
+ index = JSON.parse(run.index_json);
206
+ if (!index || !Array.isArray(index.stage_runs))
207
+ throw new Error("RUN index must contain stage_runs");
208
+ }
209
+ catch (error) {
210
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
211
+ }
212
+ const found = index.stage_runs.find((item) => item.stage === stage);
169
213
  if (!found || found.status !== status)
170
214
  throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
171
215
  return found;
172
216
  }
173
217
  function requireRunHome(run) {
174
- if (!run.run_home_path)
175
- throw new AppError("runtime_missing", "RUN has no artifact home", 1, { run_id: run.id });
176
- return run.run_home_path;
218
+ if (!run.run_root)
219
+ throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
220
+ return run.run_root;
177
221
  }
178
222
  function nextPauseId(runHome) {
179
223
  const root = path.join(runHome, "intake", "hitl");
@@ -0,0 +1,35 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ export function stageAttempt(context, projectId, runId, stage) {
3
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
4
+ if (!run)
5
+ throw new AppError("not_found", "Stage graph RUN is unavailable", 1);
6
+ const stages = JSON.parse(run.index_json).stage_runs ?? [];
7
+ return [...stages].reverse().find(item => item.stage === stage)?.attempt ?? "try-001";
8
+ }
9
+ export function bindStageWork(context, input) {
10
+ context.db.run("INSERT OR IGNORE INTO stage_work_memberships (project_id, run_id, work_id, stage, attempt, cycle) VALUES (?, ?, ?, ?, ?, ?)", [input.projectId, input.runId, input.workId, input.stage, input.attempt ?? stageAttempt(context, input.projectId, input.runId, input.stage), input.cycle ?? stageCycle(context, input.projectId, input.runId, input.stage)]);
11
+ }
12
+ export function stageCycle(context, projectId, runId, stage) {
13
+ if (stage !== "code" && stage !== "code-review")
14
+ return "";
15
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
16
+ const cycle = row ? JSON.parse(row.index_json).variables?.["merge.source_repair"]?.cycle : undefined;
17
+ return typeof cycle === "string" ? cycle : stage === "code-review" ? "initial" : "";
18
+ }
19
+ export function stageWorkIds(context, projectId, runId, stage) {
20
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stage_work_memberships'"))
21
+ throw new AppError("stage_graph_compatibility_required", "Stage Work membership is unavailable; use the RUN's pinned engine", 1);
22
+ return new Set(context.db.all("SELECT work_id FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND stage = ? AND attempt = ? AND cycle = ?", [projectId, runId, stage, stageAttempt(context, projectId, runId, stage), stageCycle(context, projectId, runId, stage)]).map(row => row.work_id));
23
+ }
24
+ /** Generic Work APIs also serve non-stage runs; null preserves that contract. */
25
+ export function currentStageWorkIds(context, projectId, runId) {
26
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
27
+ if (!row)
28
+ return null;
29
+ const index = JSON.parse(row.index_json);
30
+ const stage = [...(index.stage_runs ?? [])].reverse().find(item => item.status === "running")?.stage;
31
+ return stage ? stageWorkIds(context, projectId, runId, stage) : null;
32
+ }
33
+ export function stageGraphScope(context, projectId, runId, stage) {
34
+ return { project_id: projectId, run_id: runId, attempt: stageAttempt(context, projectId, runId, stage), cycle: stageCycle(context, projectId, runId, stage) };
35
+ }