@deksden-com/dd-flow-cli 0.6.0 → 0.8.0-beta.135

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 (78) hide show
  1. package/CHANGELOG.md +636 -0
  2. package/README.md +13 -1
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +108 -18
  5. package/dist/cli/run-cli.js +626 -49
  6. package/dist/domain/flow-contract.js +11 -0
  7. package/dist/domain/stage-catalog.js +22 -0
  8. package/dist/runtime/context.js +8 -2
  9. package/dist/schemas/code-review-decision.schema.json +26 -0
  10. package/dist/schemas/code-review-result.schema.json +14 -0
  11. package/dist/schemas/code-stage-report.schema.json +7 -2
  12. package/dist/schemas/code-verification.schema.json +14 -0
  13. package/dist/schemas/code-work-batch.schema.json +24 -0
  14. package/dist/schemas/code-work-result.schema.json +16 -0
  15. package/dist/schemas/engine-manifest.schema.json +22 -0
  16. package/dist/schemas/flow-contract.schema.json +6 -3
  17. package/dist/schemas/flow-run.schema.json +16 -122
  18. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  19. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  20. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  21. package/dist/schemas/plan-review-decision.schema.json +14 -0
  22. package/dist/schemas/plan-review-result.schema.json +42 -0
  23. package/dist/schemas/protocol-plan.schema.json +15 -182
  24. package/dist/schemas/run-engine-binding.schema.json +37 -0
  25. package/dist/schemas/stage-finish-input.schema.json +16 -2
  26. package/dist/schemas/stage-prompt.schema.json +4 -4
  27. package/dist/schemas/stage-report.schema.json +8 -7
  28. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  29. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  30. package/dist/schemas/vnext-specify.schema.json +45 -0
  31. package/dist/services/branch-context.js +1 -1
  32. package/dist/services/canon.js +15 -1
  33. package/dist/services/cleanup.js +8 -8
  34. package/dist/services/cli-operation-classifier.js +60 -8
  35. package/dist/services/code-checks.js +244 -0
  36. package/dist/services/compatibility-preflight.js +1 -1
  37. package/dist/services/config.js +7 -1
  38. package/dist/services/dashboard.js +14 -14
  39. package/dist/services/engines.js +408 -30
  40. package/dist/services/eval-snapshots.js +404 -0
  41. package/dist/services/hooks.js +775 -23
  42. package/dist/services/ids.js +16 -6
  43. package/dist/services/lanes.js +1 -5
  44. package/dist/services/merge-queue.js +53 -5
  45. package/dist/services/merge-worker.js +5 -6
  46. package/dist/services/migrations.js +307 -44
  47. package/dist/services/plan-runtime.js +5 -5
  48. package/dist/services/plans.js +5 -3
  49. package/dist/services/projects.js +4 -4
  50. package/dist/services/prompts.js +1 -1
  51. package/dist/services/protocols.js +31 -10
  52. package/dist/services/run-engine-bindings.js +157 -0
  53. package/dist/services/run-projection.js +49 -13
  54. package/dist/services/runs.js +525 -58
  55. package/dist/services/schema-validation.js +116 -2
  56. package/dist/services/sessions.js +51 -12
  57. package/dist/services/stage-blocker.js +57 -0
  58. package/dist/services/stage-context.js +90 -0
  59. package/dist/services/stage-lifecycle.js +288 -77
  60. package/dist/services/stage-pause.js +175 -0
  61. package/dist/services/stage-report-renderer.js +65 -0
  62. package/dist/services/status.js +8 -3
  63. package/dist/services/usage.js +526 -18
  64. package/dist/services/vnext-code-review.js +308 -0
  65. package/dist/services/vnext-code.js +616 -0
  66. package/dist/services/vnext-contracts.js +1 -0
  67. package/dist/services/vnext-execution-profile.js +27 -0
  68. package/dist/services/vnext-fanout.js +79 -0
  69. package/dist/services/vnext-plan-review.js +499 -0
  70. package/dist/services/vnext-plan.js +576 -0
  71. package/dist/services/vnext-protocolize.js +542 -0
  72. package/dist/services/vnext-specify.js +595 -0
  73. package/dist/services/vnext-workspace-policy.js +87 -0
  74. package/dist/services/work-registry.js +499 -0
  75. package/dist/services/worktrees.js +58 -37
  76. package/dist/storage/database.js +292 -42
  77. package/dist/storage/paths.js +47 -1
  78. package/package.json +12 -12
@@ -0,0 +1,175 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { resolveProjectRoot } from "../storage/paths.js";
7
+ import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
8
+ import { requireProjectByRoot } from "./projects.js";
9
+ import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
10
+ import { refreshRunWorkProjection } from "./work-registry.js";
11
+ export function pauseStageForUser(context, input) {
12
+ const projectRoot = resolveProjectRoot(input.projectRoot);
13
+ const project = requireProjectByRoot(context, projectRoot);
14
+ const run = requireRun(context, project.id, input.runId);
15
+ const work = requireWork(context, project.id, run.id, input.workId);
16
+ if (work.status !== "running")
17
+ throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
18
+ const question = input.question;
19
+ if (!question.trim())
20
+ throw new AppError("validation", "stage pause requires a non-empty user question", 2);
21
+ const stage = requireStage(run, input.stage, "running");
22
+ const pauseId = nextPauseId(requireRunHome(run));
23
+ const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
24
+ const questionPath = path.join(pauseRoot, "question.md");
25
+ fs.mkdirSync(pauseRoot, { recursive: true });
26
+ fs.writeFileSync(questionPath, question);
27
+ const now = context.now();
28
+ context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
29
+ pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
30
+ refreshRunWorkProjection(context, project.id, run.id);
31
+ const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
32
+ const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
33
+ return {
34
+ ok: true,
35
+ outcome: "paused",
36
+ run_id: run.id,
37
+ work_id: work.work_id,
38
+ stage: stage.stage,
39
+ pause: { id: pauseId, reason: "waiting_for_user", question_path: questionPath, user_message: question },
40
+ next_action: "ask_user_then_resume_same_stage",
41
+ agent_instruction: "Send user_message to the user and stop this Turn. When the user answers, do not interpret or edit the answer first: make resume_command the first flow command, pass the complete raw answer on stdin, then follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.",
42
+ resume_command: resume,
43
+ resume_command_template: resumeTemplate
44
+ };
45
+ }
46
+ export function resumeStageAfterUser(context, input) {
47
+ const projectRoot = resolveProjectRoot(input.projectRoot);
48
+ const project = requireProjectByRoot(context, projectRoot);
49
+ const run = requireRun(context, project.id, input.runId);
50
+ const work = requireWork(context, project.id, run.id, input.workId);
51
+ if (work.status !== "paused")
52
+ throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
53
+ const stage = requireStage(run, input.stage, "paused");
54
+ if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
55
+ throw new AppError("invalid_stage_state", "Stage has no unanswered pause for this Work", 1, { run_id: run.id, stage: stage.stage, work_id: work.work_id });
56
+ if (!input.answer.trim())
57
+ throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
58
+ const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
59
+ projectId: project.id,
60
+ matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
61
+ errorCode: "trusted_stage_resume_required",
62
+ operation: "stage resume"
63
+ }).eventKey;
64
+ const identity = claimStageResumeHookEvent(context, {
65
+ projectId: project.id,
66
+ runId: run.id,
67
+ stage: stage.stage,
68
+ workId: work.work_id,
69
+ projectRoot,
70
+ eventKey: hookEventId
71
+ });
72
+ const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
73
+ fs.writeFileSync(answerPath, input.answer);
74
+ const now = context.now();
75
+ const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
76
+ if (!workSession)
77
+ throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
78
+ if (workSession.session_id !== identity.sessionId) {
79
+ context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
80
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
81
+ }
82
+ context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
83
+ resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
84
+ refreshRunWorkProjection(context, project.id, run.id);
85
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
86
+ const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
87
+ const continuation = [
88
+ prompt.trimEnd(),
89
+ "",
90
+ "<hitl_resume>",
91
+ `- Pause: ${stage.pause.id}`,
92
+ `- Question source: ${stage.pause.question_path}`,
93
+ `- Answer source: ${answerPath}`,
94
+ "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
95
+ "",
96
+ "<user_question>",
97
+ fs.readFileSync(stage.pause.question_path, "utf8"),
98
+ "</user_question>",
99
+ "",
100
+ "<user_answer>",
101
+ input.answer,
102
+ "</user_answer>",
103
+ "</hitl_resume>",
104
+ ""
105
+ ].join("\n");
106
+ return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: stage.pause.id, question_path: stage.pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
107
+ }
108
+ export function stagePauseCommand(context, input) {
109
+ return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
110
+ }
111
+ /**
112
+ * The only shell form allowed for an agent-owned HITL pause. Supplying a
113
+ * complete heredoc makes stdin explicit without asking an agent to invent a
114
+ * pipe, a temporary file or a second command.
115
+ */
116
+ export function stagePauseCommandTemplate(command) {
117
+ return `${command} <<'USER_QUESTION'
118
+ ## Q-001 — <short decision title>
119
+
120
+ Why this decision is required:
121
+ - <one sentence>
122
+
123
+ Options:
124
+ - <option A>
125
+ - <option B>
126
+
127
+ Recommendation:
128
+ - <one option and reason>
129
+
130
+ Effect on scope or acceptance:
131
+ - <one sentence>
132
+ USER_QUESTION`;
133
+ }
134
+ function stageResumeCommand(context, input) {
135
+ return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
136
+ }
137
+ export function flowCommand(context) {
138
+ const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
139
+ return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
140
+ }
141
+ function requireRun(context, projectId, id) {
142
+ const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
143
+ if (rows.length !== 1)
144
+ throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: id });
145
+ return rows[0];
146
+ }
147
+ function requireWork(context, projectId, runId, workId) {
148
+ const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
149
+ if (!work)
150
+ throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
151
+ return work;
152
+ }
153
+ function requireStage(run, stage, status) {
154
+ const index = JSON.parse(run.index_json);
155
+ const found = index.stage_runs?.find((item) => item.stage === stage);
156
+ if (!found || found.status !== status)
157
+ throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
158
+ return found;
159
+ }
160
+ function requireRunHome(run) {
161
+ if (!run.run_home_path)
162
+ throw new AppError("runtime_missing", "RUN has no artifact home", 1, { run_id: run.id });
163
+ return run.run_home_path;
164
+ }
165
+ function nextPauseId(runHome) {
166
+ const root = path.join(runHome, "intake", "hitl");
167
+ const max = fs.existsSync(root) ? fs.readdirSync(root).reduce((value, name) => Math.max(value, Number(name.match(/^HITL-(\d+)/)?.[1] ?? 0)), 0) : 0;
168
+ return `HITL-${String(max + 1).padStart(3, "0")}`;
169
+ }
170
+ function safeStage(stage) {
171
+ const value = stage.trim().toLowerCase();
172
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value))
173
+ throw new AppError("validation", "stage name is invalid", 2, { stage });
174
+ return value;
175
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { writeJsonAtomic } from "../storage/paths.js";
4
+ /** Deterministic JSON -> Markdown/HTML projection shared by every stage. */
5
+ export function writeStageReport(root, report) {
6
+ const json = path.join(root, "stage-report.json");
7
+ const markdown = path.join(root, "stage-report.md");
8
+ const html = path.join(root, "stage-report.html");
9
+ const semantic = object(report.semantic);
10
+ const sourceArtifacts = object(report.artifacts);
11
+ const normalized = {
12
+ ...report,
13
+ schema_id: "dd-flow/stage-report@2",
14
+ summary: string(report.summary) || string(semantic.result) || "Stage completed.",
15
+ semantic: {
16
+ ...semantic,
17
+ result: string(semantic.result),
18
+ acceptance: array(semantic.acceptance),
19
+ changed_files: array(semantic.changed_files ?? semantic.run_changed_files),
20
+ checks: array(semantic.checks),
21
+ evidence: array(semantic.evidence),
22
+ next_action: string(semantic.next_action)
23
+ },
24
+ artifacts: {
25
+ json: string(sourceArtifacts.json) || "stage-report.json",
26
+ markdown: string(sourceArtifacts.markdown) || "stage-report.md",
27
+ html: string(sourceArtifacts.html) || "stage-report.html"
28
+ }
29
+ };
30
+ writeJsonAtomic(json, normalized);
31
+ const text = renderStageReportMarkdown(normalized);
32
+ fs.writeFileSync(markdown, text);
33
+ fs.writeFileSync(html, renderStageReportHtml(normalized, text));
34
+ return { json, markdown, html };
35
+ }
36
+ export function renderStageReportMarkdown(report) {
37
+ const semantic = object(report.semantic);
38
+ const summary = string(semantic.result) || string(report.summary) || "Stage completed.";
39
+ const acceptance = strings(semantic.acceptance);
40
+ const checks = strings(semantic.checks);
41
+ const evidence = strings(semantic.evidence);
42
+ const next = string(semantic.next_action);
43
+ const sections = [
44
+ `# ${string(report.stage).toUpperCase() || "Stage"} report`,
45
+ "",
46
+ summary,
47
+ "",
48
+ `**Verdict:** ${string(report.verdict) || "unknown"}`,
49
+ ...(acceptance.length ? ["", "## Acceptance", "", ...acceptance.map((item) => `- ${item}`)] : []),
50
+ ...(checks.length ? ["", "## Checks", "", ...checks.map((item) => `- ${item}`)] : []),
51
+ ...(evidence.length ? ["", "## Evidence", "", ...evidence.map((item) => `- ${item}`)] : []),
52
+ ...(next ? ["", `**Next action:** ${next}`] : []),
53
+ ""
54
+ ];
55
+ return sections.join("\n");
56
+ }
57
+ function renderStageReportHtml(report, markdown) {
58
+ const title = `${string(report.stage).toUpperCase() || "Stage"} report`;
59
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title><style>body{font:16px/1.55 system-ui,sans-serif;max-width:880px;margin:3rem auto;padding:0 1.25rem;color:#202124}pre{white-space:pre-wrap;font:inherit}</style><main><pre>${escape(markdown)}</pre></main></html>\n`;
60
+ }
61
+ function object(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
62
+ function string(value) { return typeof value === "string" ? value : ""; }
63
+ function strings(value) { return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; }
64
+ function array(value) { return Array.isArray(value) ? value : []; }
65
+ function escape(value) { return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]); }
@@ -9,7 +9,7 @@ import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-sta
9
9
  import { findProjectByRoot } from "./projects.js";
10
10
  import { classifyCliOperation } from "./cli-operation-classifier.js";
11
11
  import { compatibilityReport } from "./compatibility-preflight.js";
12
- import { selectEngine } from "./engines.js";
12
+ import { compatibilityContexts, selectEngine } from "./engines.js";
13
13
  export function getRuntimeStatus(context, input = {}) {
14
14
  const cwd = process.cwd();
15
15
  const projectRootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd });
@@ -26,9 +26,13 @@ export function getRuntimeStatus(context, input = {}) {
26
26
  const cli = getCliBuildInfo();
27
27
  const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
28
28
  const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
29
- const engineSelection = projectRoot ? selectEngine(context, { projectRoot }) : null;
29
+ const classification = classifyCliOperation(["status"], context.env);
30
+ const engineSelection = projectRoot
31
+ ? selectEngine(context, { projectRoot, env: context.env }, { allowCurrentInProcess: true }, classification)
32
+ : null;
30
33
  const flowContract = projectRoot ? projectFlowContractStatus(projectRoot) : null;
31
34
  const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
35
+ const contexts = projectRoot ? compatibilityContexts(context, { projectRoot, env: context.env, classification }) : null;
32
36
  return {
33
37
  ok: true,
34
38
  schema_id: "dd-flow/status-report@1",
@@ -54,7 +58,8 @@ export function getRuntimeStatus(context, input = {}) {
54
58
  engine: engineSelection
55
59
  ? {
56
60
  selection: engineSelection,
57
- compatibility: compatibilityReport(engineSelection, classifyCliOperation(["status"]))
61
+ compatibility: compatibilityReport(engineSelection, classification),
62
+ contexts
58
63
  }
59
64
  : null,
60
65
  canon: {