@deksden-com/dd-flow-cli 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/help.js +14 -3
  4. package/dist/cli/run-cli.js +32 -16
  5. package/dist/domain/flow-contract.js +81 -2
  6. package/dist/domain/validation.js +56 -28
  7. package/dist/protocol/local-files.js +1 -16
  8. package/dist/schemas/code-stage-report.schema.json +2 -2
  9. package/dist/schemas/flow-contract.schema.json +150 -94
  10. package/dist/schemas/flow-run.schema.json +129 -23
  11. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  12. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  13. package/dist/schemas/merge-stage-report.schema.json +2 -2
  14. package/dist/schemas/plan-stage-report.schema.json +38 -335
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  16. package/dist/schemas/protocol-plan.schema.json +197 -0
  17. package/dist/schemas/release-impact.schema.json +9 -5
  18. package/dist/schemas/session-usage.schema.json +16 -0
  19. package/dist/schemas/stage-finish-input.schema.json +20 -0
  20. package/dist/schemas/stage-prompt.schema.json +31 -0
  21. package/dist/schemas/stage-report.schema.json +20 -0
  22. package/dist/schemas/stage-start-response.schema.json +29 -0
  23. package/dist/schemas/timeline-event.schema.json +29 -0
  24. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  25. package/dist/services/branch-context.js +9 -4
  26. package/dist/services/dashboard.js +51 -26
  27. package/dist/services/engines.js +86 -19
  28. package/dist/services/hooks.js +80 -246
  29. package/dist/services/memory-permissions.js +77 -69
  30. package/dist/services/plan-runtime.js +124 -0
  31. package/dist/services/plans.js +22 -84
  32. package/dist/services/projects.js +2 -1
  33. package/dist/services/prompts.js +26 -21
  34. package/dist/services/protocols.js +29 -25
  35. package/dist/services/run-projection.js +77 -11
  36. package/dist/services/runs.js +95 -61
  37. package/dist/services/schema-validation.js +168 -7
  38. package/dist/services/sessions.js +132 -68
  39. package/dist/services/stage-lifecycle.js +572 -0
  40. package/dist/services/tooling.js +285 -0
  41. package/dist/services/usage.js +183 -30
  42. package/dist/services/version-status.js +1 -1
  43. package/dist/services/worktrees.js +88 -39
  44. package/dist/storage/database.js +72 -30
  45. package/dist/storage/paths.js +0 -9
  46. package/package.json +14 -13
  47. package/tools/worktrunk-manifest.json +34 -0
  48. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  49. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -6,47 +6,55 @@ import { AppError } from "../shared/errors.js";
6
6
  const schemaId = "dd-flow/memorybank-permissions-preflight@1";
7
7
  const flows = ["mb-init", "mb-upgrade", "mb-audit", "mb-fix", "mb-upgrade-review", "custom"];
8
8
  const modes = ["read", "write", "repair", "report_only"];
9
- const maxScannedFiles = 5000;
10
9
  export function preflightMemoryPermissions(options) {
11
10
  const flow = requireEnum(options.flow, flows, "--flow");
12
11
  const mode = requireEnum(options.mode, modes, "--mode");
13
12
  const projectRoot = path.resolve(options.root);
14
13
  const memoryBank = resolveInside(projectRoot, options.memoryBank);
15
14
  const tasks = options.tasks ? resolveInside(projectRoot, options.tasks) : path.join(projectRoot, ".tasks");
15
+ if (options.targets && options.targets.length === 0) {
16
+ throw new AppError("validation", "Exact permission preflight requires at least one target", 2);
17
+ }
16
18
  const checks = [];
17
19
  const platform = platformInfo(projectRoot);
18
20
  checks.push(checkExistingDirectory("project-root-read", projectRoot, "read_existing_file", fs.constants.R_OK));
19
- if (flow === "mb-init" && !fs.existsSync(memoryBank)) {
21
+ if (options.targets) {
22
+ for (const [index, target] of options.targets.entries()) {
23
+ checks.push(...checkTarget(`exact-target-${index + 1}`, resolveInside(projectRoot, target.path), target.mode));
24
+ }
25
+ }
26
+ else if (flow === "mb-init" && !fs.existsSync(memoryBank)) {
20
27
  checks.push(checkCreateDirectory("memory-bank-create", memoryBank));
21
28
  }
22
29
  else {
23
30
  checks.push(checkExistingDirectory("memory-bank-read", memoryBank, "read_existing_file", fs.constants.R_OK));
24
31
  if (mode !== "read") {
25
32
  checks.push(checkExistingDirectory("memory-bank-write", memoryBank, "create_file", fs.constants.W_OK | fs.constants.X_OK));
26
- checks.push(...checkWritableTree(memoryBank, "memory-bank-existing-file"));
27
33
  checks.push(probeDirectory("memory-bank-probe", memoryBank));
28
34
  }
29
35
  }
30
- if (mode === "write" || mode === "repair" || mode === "report_only") {
31
- if (fs.existsSync(tasks)) {
32
- checks.push(checkExistingDirectory("tasks-write", tasks, "create_file", fs.constants.W_OK | fs.constants.X_OK));
33
- checks.push(probeDirectory("tasks-probe", tasks));
36
+ if (!options.targets) {
37
+ if (mode === "write" || mode === "repair" || mode === "report_only") {
38
+ if (fs.existsSync(tasks)) {
39
+ checks.push(checkExistingDirectory("tasks-write", tasks, "create_file", fs.constants.W_OK | fs.constants.X_OK));
40
+ checks.push(probeDirectory("tasks-probe", tasks));
41
+ }
42
+ else {
43
+ checks.push(checkCreateDirectory("tasks-create", tasks));
44
+ }
34
45
  }
35
46
  else {
36
- checks.push(checkCreateDirectory("tasks-create", tasks));
47
+ checks.push({
48
+ id: "tasks-read-mode-skipped",
49
+ severity: "info",
50
+ path: displayPath(projectRoot, tasks),
51
+ operation: "create_directory",
52
+ status: "skipped",
53
+ reason: "ok",
54
+ side_effect: "none"
55
+ });
37
56
  }
38
57
  }
39
- else {
40
- checks.push({
41
- id: "tasks-read-mode-skipped",
42
- severity: "info",
43
- path: displayPath(projectRoot, tasks),
44
- operation: "create_directory",
45
- status: "skipped",
46
- reason: "ok",
47
- side_effect: "none"
48
- });
49
- }
50
58
  const errors = checks.filter((check) => check.severity === "error" && check.status === "failed").length;
51
59
  const warnings = checks.filter((check) => check.severity === "warning").length;
52
60
  const ok = errors === 0;
@@ -78,7 +86,32 @@ function requireEnum(value, allowed, label) {
78
86
  return value;
79
87
  }
80
88
  function resolveInside(projectRoot, input) {
81
- return path.isAbsolute(input) ? path.resolve(input) : path.resolve(projectRoot, input);
89
+ const resolvedRoot = path.resolve(projectRoot);
90
+ const resolved = path.isAbsolute(input) ? path.resolve(input) : path.resolve(resolvedRoot, input);
91
+ if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
92
+ throw new AppError("validation", "Permission target must remain inside project root", 2, { root: resolvedRoot, target: resolved });
93
+ }
94
+ const existingPath = nearestExistingPath(resolved);
95
+ if (existingPath) {
96
+ const realRoot = fs.realpathSync(resolvedRoot);
97
+ const realExisting = fs.realpathSync(existingPath);
98
+ const suffix = path.relative(existingPath, resolved);
99
+ const realResolved = path.resolve(realExisting, suffix);
100
+ if (realResolved !== realRoot && !realResolved.startsWith(`${realRoot}${path.sep}`)) {
101
+ throw new AppError("validation", "Permission target escapes project root through a symlink", 2, { root: realRoot, target: resolved });
102
+ }
103
+ }
104
+ return resolved;
105
+ }
106
+ function nearestExistingPath(target) {
107
+ let current = target;
108
+ while (!fs.existsSync(current)) {
109
+ const parent = path.dirname(current);
110
+ if (parent === current)
111
+ return undefined;
112
+ current = parent;
113
+ }
114
+ return current;
82
115
  }
83
116
  function platformInfo(projectRoot) {
84
117
  const user = safeUserInfo();
@@ -132,55 +165,26 @@ function checkCreateDirectory(id, target) {
132
165
  return failedCheck(id, target, "create_directory", "not_writable", errorMessage(error), safeStat(parent));
133
166
  }
134
167
  }
135
- function checkWritableTree(root, prefix) {
136
- if (!fs.existsSync(root))
137
- return [];
138
- const checks = [];
139
- const stack = [root];
140
- let scanned = 0;
141
- while (stack.length > 0 && scanned < maxScannedFiles) {
142
- const current = stack.pop();
143
- if (!current)
144
- continue;
145
- let entries;
146
- try {
147
- entries = fs.readdirSync(current, { withFileTypes: true });
148
- }
149
- catch (error) {
150
- checks.push(failedCheck(`${prefix}-read-${checks.length + 1}`, current, "read_existing_file", "not_readable", errorMessage(error), safeStat(current)));
151
- continue;
152
- }
153
- for (const entry of entries) {
154
- if (entry.name === ".git")
155
- continue;
156
- const entryPath = path.join(current, entry.name);
157
- scanned += 1;
158
- if (entry.isDirectory()) {
159
- stack.push(entryPath);
160
- checks.push(checkExistingDirectory(`${prefix}-dir-${checks.length + 1}`, entryPath, "create_file", fs.constants.W_OK | fs.constants.X_OK));
161
- }
162
- else if (entry.isFile()) {
163
- checks.push(checkExistingFile(`${prefix}-${checks.length + 1}`, entryPath, fs.constants.R_OK | fs.constants.W_OK));
164
- }
165
- if (scanned >= maxScannedFiles)
166
- break;
167
- }
168
+ function checkTarget(id, target, mode) {
169
+ if (!fs.existsSync(target)) {
170
+ if (mode !== "write")
171
+ return [failedCheck(id, target, "read_existing_file", "not_found", "Path does not exist")];
172
+ const parentCheck = checkCreateDirectory(id, target);
173
+ return parentCheck.status === "passed" ? [parentCheck, probeDirectory(`${id}-probe`, path.dirname(target))] : [parentCheck];
168
174
  }
169
- if (scanned >= maxScannedFiles) {
170
- checks.push({
171
- id: `${prefix}-scan-limit`,
172
- severity: "warning",
173
- path: root,
174
- operation: "stat_metadata",
175
- status: "warning",
176
- reason: "unknown",
177
- fs_error: { message: `Stopped after ${maxScannedFiles} filesystem entries` },
178
- side_effect: "none"
179
- });
175
+ const stat = safeStat(target);
176
+ if (!stat)
177
+ return [failedCheck(id, target, "stat_metadata", "unknown", "Cannot stat permission target")];
178
+ if (stat.isDirectory()) {
179
+ const check = checkExistingDirectory(id, target, mode === "write" ? "create_file" : "read_existing_file", mode === "write" ? fs.constants.W_OK | fs.constants.X_OK : fs.constants.R_OK);
180
+ return mode === "write" && check.status === "passed" ? [check, probeDirectory(`${id}-probe`, target)] : [check];
180
181
  }
181
- return checks;
182
+ const check = checkExistingFile(id, target, mode === "write" ? fs.constants.R_OK | fs.constants.W_OK : fs.constants.R_OK, mode === "write" ? "write_existing_file" : "read_existing_file");
183
+ return mode === "write" && check.status === "passed"
184
+ ? [check, probeDirectory(`${id}-probe`, path.dirname(target))]
185
+ : [check];
182
186
  }
183
- function checkExistingFile(id, target, access) {
187
+ function checkExistingFile(id, target, access, operation = "write_existing_file") {
184
188
  try {
185
189
  const stat = fs.statSync(target);
186
190
  const immutable = detectImmutableFlag(target);
@@ -189,10 +193,10 @@ function checkExistingFile(id, target, access) {
189
193
  }
190
194
  fs.accessSync(target, access);
191
195
  const ownerWarning = ownerMismatchWarning(id, target, stat);
192
- return ownerWarning ?? passedCheck(id, target, "write_existing_file", stat);
196
+ return operation === "write_existing_file" ? ownerWarning ?? passedCheck(id, target, operation, stat) : passedCheck(id, target, operation, stat);
193
197
  }
194
198
  catch (error) {
195
- return failedCheck(id, target, "write_existing_file", accessReason(error, access), errorMessage(error), safeStat(target));
199
+ return failedCheck(id, target, operation, accessReason(error, access), errorMessage(error), safeStat(target));
196
200
  }
197
201
  }
198
202
  function ownerMismatchWarning(id, target, stat) {
@@ -211,9 +215,11 @@ function ownerMismatchWarning(id, target, stat) {
211
215
  }
212
216
  function probeDirectory(id, target) {
213
217
  const probePath = path.join(target, `.dd-flow-permission-probe-${process.pid}-${Date.now()}`);
218
+ const renamedProbePath = `${probePath}.renamed`;
214
219
  try {
215
220
  fs.writeFileSync(probePath, "probe\n", { flag: "wx" });
216
- fs.unlinkSync(probePath);
221
+ fs.renameSync(probePath, renamedProbePath);
222
+ fs.unlinkSync(renamedProbePath);
217
223
  return {
218
224
  ...metadata(id, target, "create_file", safeStat(target)),
219
225
  severity: "info",
@@ -226,6 +232,8 @@ function probeDirectory(id, target) {
226
232
  try {
227
233
  if (fs.existsSync(probePath))
228
234
  fs.unlinkSync(probePath);
235
+ if (fs.existsSync(renamedProbePath))
236
+ fs.unlinkSync(renamedProbePath);
229
237
  }
230
238
  catch (cleanupError) {
231
239
  return {
@@ -0,0 +1,124 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { validatePlan } from "../domain/validation.js";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { parseJsonObject } from "../shared/json.js";
6
+ import { refreshRunSessionProjection } from "./run-projection.js";
7
+ export function readCanonicalPlan(planPath, protocolId) {
8
+ if (!fs.existsSync(planPath) || !fs.statSync(planPath).isFile()) {
9
+ throw new AppError("not_found", `Canonical protocol plan is missing: ${planPath}`, 1, { plan_path: planPath });
10
+ }
11
+ const raw = fs.readFileSync(planPath, "utf8");
12
+ const value = parseJsonObject(raw, planPath);
13
+ const plan = validatePlan(value, protocolId);
14
+ const revisionValue = value.revision;
15
+ const revision = typeof revisionValue === "number" && Number.isInteger(revisionValue) && revisionValue > 0 ? revisionValue : 1;
16
+ return { plan, raw, sha256: crypto.createHash("sha256").update(raw).digest("hex"), revision };
17
+ }
18
+ export function boundCanonicalPlan(context, input) {
19
+ const canonical = readCanonicalPlan(input.planPath, input.protocolId);
20
+ const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
21
+ if (binding && (binding.plan_revision !== canonical.revision || binding.plan_sha256 !== canonical.sha256)) {
22
+ throw new AppError("plan_stale", "Canonical plan changed after the runtime binding was created", 1, {
23
+ protocol_id: input.protocolId,
24
+ expected_revision: binding.plan_revision,
25
+ actual_revision: canonical.revision,
26
+ expected_sha256: binding.plan_sha256,
27
+ actual_sha256: canonical.sha256,
28
+ plan_path: input.planPath
29
+ });
30
+ }
31
+ const now = context.now();
32
+ if (!binding) {
33
+ context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)
34
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [input.projectId, input.protocolId, canonical.revision, canonical.sha256, input.planPath, now, now]);
35
+ }
36
+ for (const item of canonical.plan.items) {
37
+ context.db.run(`INSERT INTO plan_progress
38
+ (project_id, protocol_id, item_id, plan_revision, plan_sha256, status, summary, evidence_json, block_reason, user_required, updated_at)
39
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
40
+ ON CONFLICT(project_id, protocol_id, item_id) DO NOTHING`, [
41
+ input.projectId,
42
+ input.protocolId,
43
+ item.id,
44
+ canonical.revision,
45
+ canonical.sha256,
46
+ item.status,
47
+ item.summary,
48
+ JSON.stringify(item.evidence),
49
+ item.block_reason ?? null,
50
+ item.user_required ? 1 : 0,
51
+ now
52
+ ]);
53
+ }
54
+ refreshProtocolRuns(context, input.projectId, input.protocolId);
55
+ return canonical;
56
+ }
57
+ export function planWithProgress(context, input) {
58
+ const canonical = boundCanonicalPlan(context, input);
59
+ const rows = context.db.all(`SELECT item_id, plan_revision, plan_sha256, status, summary, evidence_json, block_reason, user_required
60
+ FROM plan_progress WHERE project_id = ? AND protocol_id = ?`, [input.projectId, input.protocolId]);
61
+ const progress = new Map(rows.map((row) => [row.item_id, row]));
62
+ return {
63
+ canonical,
64
+ plan: {
65
+ ...canonical.plan,
66
+ items: canonical.plan.items.map((item) => mergeProgress(item, progress.get(item.id), canonical))
67
+ }
68
+ };
69
+ }
70
+ export function planSummary(context, input) {
71
+ const { canonical, plan } = planWithProgress(context, input);
72
+ return {
73
+ plan_id: plan.plan_id,
74
+ revision: canonical.revision,
75
+ sha256: canonical.sha256,
76
+ total: plan.items.length,
77
+ done: plan.items.filter((item) => item.status === "done").length,
78
+ blocked: plan.items.filter((item) => item.status === "blocked").length
79
+ };
80
+ }
81
+ export function updatePlanProgress(context, input) {
82
+ const canonical = boundCanonicalPlan(context, input);
83
+ context.db.run(`UPDATE plan_progress
84
+ SET plan_revision = ?, plan_sha256 = ?, status = ?, summary = ?, evidence_json = ?, block_reason = ?, user_required = ?, updated_at = ?
85
+ WHERE project_id = ? AND protocol_id = ? AND item_id = ?`, [
86
+ canonical.revision,
87
+ canonical.sha256,
88
+ input.item.status,
89
+ input.item.summary,
90
+ JSON.stringify(input.item.evidence),
91
+ input.item.block_reason ?? null,
92
+ input.item.user_required ? 1 : 0,
93
+ context.now(),
94
+ input.projectId,
95
+ input.protocolId,
96
+ input.item.id
97
+ ]);
98
+ refreshProtocolRuns(context, input.projectId, input.protocolId);
99
+ }
100
+ function refreshProtocolRuns(context, projectId, protocolId) {
101
+ for (const run of context.db.all("SELECT id FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?", [projectId, protocolId]))
102
+ refreshRunSessionProjection(context, projectId, run.id);
103
+ }
104
+ function mergeProgress(item, row, canonical) {
105
+ if (!row || row.plan_revision !== canonical.revision || row.plan_sha256 !== canonical.sha256)
106
+ return item;
107
+ let evidence = item.evidence;
108
+ try {
109
+ const parsed = JSON.parse(row.evidence_json);
110
+ if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string"))
111
+ evidence = parsed;
112
+ }
113
+ catch {
114
+ evidence = item.evidence;
115
+ }
116
+ return {
117
+ ...item,
118
+ status: row.status,
119
+ summary: row.summary,
120
+ evidence,
121
+ ...(row.block_reason ? { block_reason: row.block_reason } : {}),
122
+ ...(row.user_required ? { user_required: true } : {})
123
+ };
124
+ }
@@ -1,68 +1,34 @@
1
- import fs from "node:fs";
2
1
  import { schemaVersion } from "../domain/contracts.js";
3
- import { validatePlan } from "../domain/validation.js";
4
2
  import { AppError } from "../shared/errors.js";
5
- import { parseJsonObject } from "../shared/json.js";
6
- import { ensureReadableFile } from "../storage/database.js";
7
3
  import { appendAudit } from "./audit.js";
8
4
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
5
  import { requireProjectByRoot } from "./projects.js";
10
6
  import { resolveProjectRoot } from "../storage/paths.js";
11
- import { writePlanFile } from "../protocol/local-files.js";
12
- export function setProtocolPlan(context, input) {
13
- const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
14
- ensureReadableFile(input.file);
15
- const plan = validatePlan(parseJsonObject(fs.readFileSync(input.file, "utf8"), input.file), protocol.id);
16
- writePlanFile(protocol.plan_path, plan);
17
- context.db.run(`INSERT INTO plans (project_id, protocol_id, plan_json, updated_at)
18
- VALUES (?, ?, ?, ?)
19
- ON CONFLICT(project_id, protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.project_id, protocol.id, JSON.stringify(plan), context.now()]);
20
- updateStatePlanSummary(context, protocol, plan);
21
- appendAudit(context, {
22
- protocolId: protocol.id,
23
- projectId: protocol.project_id,
24
- eventType: "plan.set",
25
- payload: { protocol_id: protocol.id, plan_id: plan.plan_id, total: plan.items.length }
26
- });
27
- return { ok: true, protocol_id: protocol.id, plan: summarizePlan(plan) };
28
- }
7
+ import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
29
8
  export function getPlanStatus(context, input) {
30
9
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
31
- const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
10
+ const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
32
11
  return {
33
12
  ok: true,
34
13
  protocol_id: protocol.id,
35
- plan: summarizePlan(plan),
36
- blocked_items: plan.items
14
+ plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }),
15
+ binding: { revision: current.canonical.revision, sha256: current.canonical.sha256, path: protocol.plan_path },
16
+ blocked_items: current.plan.items
37
17
  .filter((item) => item.status === "blocked")
38
- .map((item) => ({
39
- id: item.id,
40
- title: item.title,
41
- reason: item.block_reason ?? "",
42
- user_required: item.user_required ?? false
43
- })),
44
- items: plan.items
18
+ .map((item) => ({ id: item.id, title: item.title, reason: item.block_reason ?? "", user_required: item.user_required ?? false })),
19
+ items: current.plan.items
45
20
  };
46
21
  }
47
22
  export function startPlanItem(context, input) {
48
23
  return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
49
24
  assertDependenciesClosed(plan, item);
50
- return {
51
- ...item,
52
- status: "in_progress",
53
- summary: item.summary || "Started."
54
- };
25
+ return { ...item, status: "in_progress", summary: item.summary || "Started." };
55
26
  });
56
27
  }
57
28
  export function completePlanItem(context, input) {
58
29
  return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
59
30
  assertDependenciesClosed(plan, item);
60
- return {
61
- ...item,
62
- status: "done",
63
- summary: input.summary,
64
- evidence: [...new Set([...item.evidence, ...input.evidence])]
65
- };
31
+ return { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] };
66
32
  });
67
33
  }
68
34
  export function blockPlanItem(context, input) {
@@ -83,48 +49,28 @@ export function skipPlanItem(context, input) {
83
49
  }
84
50
  function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
85
51
  const protocol = scopedProtocol(context, projectRoot, protocolId);
86
- const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
87
- const item = plan.items.find((candidate) => candidate.id === itemId);
88
- if (!item) {
52
+ const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
53
+ const item = current.plan.items.find((candidate) => candidate.id === itemId);
54
+ if (!item)
89
55
  throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
56
+ const nextItem = transform(item, current.plan);
57
+ updatePlanProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path, item: nextItem });
58
+ if (nextItem.status === "done") {
59
+ context.db.run("UPDATE flow_jobs SET status = 'done', finished_at = COALESCE(finished_at, ?), updated_at = ? WHERE project_id = ? AND protocol_id = ? AND plan_item_id = ?", [context.now(), context.now(), protocol.project_id, protocol.id, nextItem.id]);
90
60
  }
91
- const nextPlan = {
92
- ...plan,
93
- items: plan.items.map((candidate) => (candidate.id === itemId ? transform(candidate, plan) : candidate))
94
- };
95
- const validated = validatePlan(nextPlan, protocol.id);
96
- writePlanFile(protocol.plan_path, validated);
97
- context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE project_id = ? AND protocol_id = ?", [
98
- JSON.stringify(validated),
99
- context.now(),
100
- protocol.project_id,
101
- protocol.id
102
- ]);
103
- updateStatePlanSummary(context, protocol, validated);
61
+ updateStatePlanSummary(context, protocol);
104
62
  appendAudit(context, {
105
63
  protocolId: protocol.id,
106
64
  projectId: protocol.project_id,
107
- eventType: "plan.item.updated",
108
- payload: {
109
- protocol_id: protocol.id,
110
- item_id: itemId,
111
- from: item.status,
112
- to: validated.items.find((candidate) => candidate.id === itemId)?.status
113
- }
65
+ eventType: "plan.progress.updated",
66
+ payload: { protocol_id: protocol.id, item_id: itemId, from: item.status, to: nextItem.status }
114
67
  });
115
- return { ok: true, protocol_id: protocol.id, item: validated.items.find((candidate) => candidate.id === itemId) };
68
+ return { ok: true, protocol_id: protocol.id, item: nextItem, plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) };
116
69
  }
117
70
  function scopedProtocol(context, projectRoot, protocolId) {
118
71
  const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
119
72
  return requireProtocol(context, protocolId, project.id);
120
73
  }
121
- function requireStoredPlan(context, projectId, protocolId) {
122
- const row = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
123
- if (!row) {
124
- throw new AppError("not_found", `Plan is not attached to protocol: ${protocolId}`, 1);
125
- }
126
- return validatePlan(parseJsonObject(row.plan_json, `stored plan for ${protocolId}`), protocolId);
127
- }
128
74
  function assertDependenciesClosed(plan, item) {
129
75
  const openDependencies = item.depends_on
130
76
  .map((dependencyId) => plan.items.find((candidate) => candidate.id === dependencyId))
@@ -137,20 +83,12 @@ function assertDependenciesClosed(plan, item) {
137
83
  });
138
84
  }
139
85
  }
140
- function updateStatePlanSummary(context, protocol, plan) {
86
+ function updateStatePlanSummary(context, protocol) {
141
87
  const state = readProtocolRuntimeState(context, protocol).state;
142
88
  persistProtocolState(context, protocol, {
143
89
  ...state,
144
90
  schema_version: state.schema_version || schemaVersion,
145
- plan: summarizePlan(plan),
91
+ plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }),
146
92
  updated_at: context.now()
147
93
  });
148
94
  }
149
- function summarizePlan(plan) {
150
- return {
151
- plan_id: plan.plan_id,
152
- total: plan.items.length,
153
- done: plan.items.filter((item) => item.status === "done").length,
154
- blocked: plan.items.filter((item) => item.status === "blocked").length
155
- };
156
- }
@@ -320,7 +320,8 @@ function projectReferenceTables() {
320
320
  "codex_session_bindings",
321
321
  "project_config",
322
322
  "flow_sessions",
323
- "pending_flow_session_bindings",
323
+ "flow_session_segments",
324
+ "flow_jobs",
324
325
  "codex_hook_events",
325
326
  "worktree_records"
326
327
  ];
@@ -5,10 +5,12 @@ import { spawnSync } from "node:child_process";
5
5
  import { getCliVersionReport } from "./build-info.js";
6
6
  import { requireProjectByRoot } from "./projects.js";
7
7
  import { requireProtocol } from "./protocols.js";
8
- import { validatePlan } from "../domain/validation.js";
8
+ import { validatePlanItem } from "../domain/validation.js";
9
9
  import { AppError } from "../shared/errors.js";
10
10
  import { parseJsonObject } from "../shared/json.js";
11
11
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
12
+ import { planWithProgress } from "./plan-runtime.js";
13
+ import { registerFlowJob } from "./sessions.js";
12
14
  const profiles = {
13
15
  code_implementation: {
14
16
  static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/code.md"]
@@ -37,8 +39,13 @@ export function renderWorkerPrompt(context, input) {
37
39
  });
38
40
  }
39
41
  assertGitFacts(workspaceRoot, index.execution?.git, run.id);
40
- const plan = input.taskFile ? null : readPlan(protocol.plan_path, protocol.id);
41
- const genericTask = input.taskFile ? readWorkerTask(input.taskFile, runHomePath(run), protocol.id) : null;
42
+ const planRuntime = input.taskFile ? null : planWithProgress(context, {
43
+ projectId: protocol.project_id,
44
+ protocolId: protocol.id,
45
+ planPath: protocol.plan_path
46
+ });
47
+ const plan = planRuntime?.plan ?? null;
48
+ const genericTask = input.taskFile ? readWorkerTask(input.taskFile, runHomePath(run)) : null;
42
49
  const item = genericTask?.item ?? plan?.items.find((candidate) => candidate.id === input.planItemId);
43
50
  if (!item)
44
51
  throw new AppError("not_found", `Plan item is not found: ${input.planItemId}`, 1);
@@ -57,6 +64,13 @@ export function renderWorkerPrompt(context, input) {
57
64
  const profile = profiles[input.profile];
58
65
  if (!profile)
59
66
  throw new AppError("prompt_profile_unsupported", `Unsupported prompt profile: ${input.profile}`, 2);
67
+ const job = registerFlowJob(context, {
68
+ projectId: protocol.project_id,
69
+ runId: run.id,
70
+ protocolId: protocol.id,
71
+ planItemId: item.id,
72
+ groupId: item.execution_context.write_scope[0] ?? null
73
+ });
60
74
  const staticInputs = profile.static_files.map((file) => readStaticInput(projectRoot, file));
61
75
  const requiredRead = item.execution_context.required_read.map((file) => checkedReference(projectRoot, runHomePath(run), file, "required_read", true));
62
76
  const discoveryBoundary = item.execution_context.discovery_boundary.map((file) => checkedReference(projectRoot, runHomePath(run), file, "discovery_boundary", false));
@@ -86,8 +100,10 @@ export function renderWorkerPrompt(context, input) {
86
100
  protocol_id: protocol.id,
87
101
  run_id: run.id,
88
102
  plan_id: plan?.plan_id ?? null,
103
+ ...(planRuntime ? { plan_revision: planRuntime.canonical.revision, plan_sha256: planRuntime.canonical.sha256 } : {}),
89
104
  plan_item_id: input.taskFile ? null : item.id,
90
105
  task_id: input.taskFile ? item.id : null,
106
+ job_id: job.job_id,
91
107
  canon_version: readCanonVersion(projectRoot),
92
108
  renderer_version: getCliVersionReport().cli.version,
93
109
  static_inputs: staticInputs.map(({ path: inputPath, sha256 }) => ({ path: inputPath, sha256 })),
@@ -115,13 +131,14 @@ export function renderWorkerPrompt(context, input) {
115
131
  profile: input.profile,
116
132
  plan_item_id: input.taskFile ? null : item.id,
117
133
  task_id: input.taskFile ? item.id : null,
118
- run_id: run.id
134
+ run_id: run.id,
135
+ job_id: job.job_id
119
136
  };
120
137
  }
121
138
  function runHomePath(run) {
122
139
  return run.run_home_path ?? path.dirname(run.run_index_path);
123
140
  }
124
- function readWorkerTask(taskFile, runHome, protocolId) {
141
+ function readWorkerTask(taskFile, runHome) {
125
142
  const resolved = path.resolve(taskFile);
126
143
  assertWithin(runHome, resolved, "worker task file");
127
144
  if (!fs.existsSync(resolved))
@@ -130,13 +147,7 @@ function readWorkerTask(taskFile, runHome, protocolId) {
130
147
  if (task.schema_id !== "dd-flow/worker-task@1" || !task.task || typeof task.task !== "object") {
131
148
  throw new AppError("validation", "Worker task must use dd-flow/worker-task@1 with a task object", 2, { path: taskFile });
132
149
  }
133
- const item = validatePlan({
134
- schema_version: "0.1.0",
135
- plan_id: "worker-task",
136
- protocol_id: protocolId,
137
- title: "worker task",
138
- items: [{ ...task.task, depends_on: [] }]
139
- }, protocolId).items[0];
150
+ const item = validatePlanItem({ ...task.task, depends_on: [] }, 0);
140
151
  return { item, handoff: validateWorkerTaskHandoff(task.handoff, runHome) };
141
152
  }
142
153
  function validateWorkerTaskHandoff(value, runHome) {
@@ -187,14 +198,13 @@ function validateWorkerTaskHandoff(value, runHome) {
187
198
  }
188
199
  function protocolIdForRun(context, projectId, runId) {
189
200
  const run = requireRun(context, projectId, runId);
190
- const subject = parseJsonObject(run.index_json, `run ${run.id}`).subject;
191
- if (subject?.type !== "protocol" || typeof subject.id !== "string") {
201
+ if (run.subject_type !== "protocol") {
192
202
  throw new AppError("prompt_run_subject", "Prompt rendering requires a protocol-owned RUN", 1, { run_id: run.id });
193
203
  }
194
- return subject.id;
204
+ return run.subject_id;
195
205
  }
196
206
  function requireRun(context, projectId, runId) {
197
- const row = context.db.get("SELECT id, project_id, workspace_root, run_index_path, run_home_path, index_json FROM flow_runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
207
+ const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_index_path, run_home_path, index_json FROM flow_runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
198
208
  if (!row)
199
209
  throw new AppError("not_found", `Run is not found: ${runId}`, 1);
200
210
  return row;
@@ -209,11 +219,6 @@ function requireStage(index, stageName, runId) {
209
219
  }
210
220
  return { dir: stage.dir };
211
221
  }
212
- function readPlan(planPath, protocolId) {
213
- if (!fs.existsSync(planPath))
214
- throw new AppError("not_found", `Protocol plan is missing: ${planPath}`, 1);
215
- return validatePlan(parseJsonObject(fs.readFileSync(planPath, "utf8"), planPath), protocolId);
216
- }
217
222
  function readStaticInput(projectRoot, relativePath) {
218
223
  checkedReference(projectRoot, undefined, relativePath, "static profile input", true);
219
224
  const absolute = path.resolve(projectRoot, relativePath);