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

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 (43) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +58 -32
  5. package/dist/cli/run-cli.js +282 -50
  6. package/dist/domain/entity-ids.js +4 -4
  7. package/dist/domain/flow-contract.js +502 -36
  8. package/dist/domain/validation.js +34 -0
  9. package/dist/protocol/local-files.js +8 -6
  10. package/dist/schemas/code-stage-report.schema.json +197 -2
  11. package/dist/schemas/flow-contract.schema.json +126 -0
  12. package/dist/schemas/flow-run-index-v3.schema.json +203 -0
  13. package/dist/schemas/flow-run-index.schema.json +22 -2
  14. package/dist/schemas/flow-run.schema.json +36 -0
  15. package/dist/schemas/merge-stage-report.schema.json +213 -2
  16. package/dist/schemas/plan-stage-report.schema.json +156 -2
  17. package/dist/schemas/release-impact.schema.json +16 -0
  18. package/dist/services/audit.js +3 -3
  19. package/dist/services/branch-context.js +266 -0
  20. package/dist/services/canon.js +0 -1
  21. package/dist/services/cleanup.js +6 -6
  22. package/dist/services/cli-operation-classifier.js +1 -1
  23. package/dist/services/compatibility-preflight.js +8 -77
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +123 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +240 -20
  29. package/dist/services/merge-worker.js +12 -4
  30. package/dist/services/migrations.js +64 -0
  31. package/dist/services/plans.js +23 -16
  32. package/dist/services/projects.js +2 -2
  33. package/dist/services/prompts.js +322 -0
  34. package/dist/services/protocols.js +78 -42
  35. package/dist/services/run-projection.js +80 -0
  36. package/dist/services/runs.js +360 -22
  37. package/dist/services/schema-validation.js +35 -12
  38. package/dist/services/sessions.js +81 -3
  39. package/dist/services/status.js +32 -1
  40. package/dist/services/usage.js +233 -0
  41. package/dist/services/worktrees.js +24 -19
  42. package/dist/storage/database.js +223 -9
  43. package/package.json +1 -1
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "dd-flow/plan-stage-report@1",
3
+ "$id": "dd-flow/plan-stage-report@2",
4
4
  "title": "dd-flow plan stage report data",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
+ "allOf": [
8
+ {
9
+ "if": {
10
+ "properties": { "schema_id": { "const": "dd-flow/plan-stage-report@2" } },
11
+ "required": ["schema_id"]
12
+ },
13
+ "then": { "required": ["flow_flags"] }
14
+ }
15
+ ],
7
16
  "required": [
8
17
  "schema_id",
9
18
  "generated_at",
@@ -17,7 +26,7 @@
17
26
  ],
18
27
  "properties": {
19
28
  "schema_id": {
20
- "const": "dd-flow/plan-stage-report@1"
29
+ "enum": ["dd-flow/plan-stage-report@1", "dd-flow/plan-stage-report@2"]
21
30
  },
22
31
  "generated_at": {
23
32
  "type": "string",
@@ -88,6 +97,7 @@
88
97
  "reason": {"type": "string"}
89
98
  }
90
99
  },
100
+ "flow_flags": { "$ref": "#/definitions/flowFlags" },
91
101
  "graph": {
92
102
  "type": "object",
93
103
  "additionalProperties": false,
@@ -153,6 +163,50 @@
153
163
  }
154
164
  }
155
165
  },
166
+ "review_gates": {
167
+ "type": "array",
168
+ "items": { "$ref": "#/definitions/reviewGate" }
169
+ },
170
+ "design_aspect_traceability": {
171
+ "type": "object",
172
+ "additionalProperties": true,
173
+ "properties": {
174
+ "source": {"type": "string"},
175
+ "verdict": {"type": "string"},
176
+ "summary": {"type": "string"},
177
+ "design_aspects": {
178
+ "type": "array",
179
+ "items": {"type": "string"}
180
+ },
181
+ "requirements": {
182
+ "type": "array",
183
+ "items": {"type": "string"}
184
+ },
185
+ "verification_seeds": {
186
+ "type": "array",
187
+ "items": {"type": "string"}
188
+ },
189
+ "gaps": {
190
+ "type": "array",
191
+ "items": {"type": "string"}
192
+ }
193
+ }
194
+ },
195
+ "testing_system": {
196
+ "type": "object",
197
+ "additionalProperties": true,
198
+ "properties": {
199
+ "applicability": {"type": "string"},
200
+ "applicability_reason": {"type": "string"},
201
+ "stages": {"type": "array"},
202
+ "test_levels": {"type": "object"},
203
+ "datasets": {"type": "array"},
204
+ "scenario_links": {"type": "array"},
205
+ "design_decision_coverage": {"type": "array"},
206
+ "gaps": {"type": "array"}
207
+ }
208
+ },
209
+ "aspect_map": { "$ref": "#/definitions/aspectMap" },
156
210
  "sdlc_contours": {
157
211
  "type": "object",
158
212
  "additionalProperties": true,
@@ -183,6 +237,7 @@
183
237
  "next_delivery_action": {"type": "string"}
184
238
  }
185
239
  },
240
+ "policy_context": { "$ref": "#/definitions/policyContext" },
186
241
  "links": {
187
242
  "type": "object",
188
243
  "additionalProperties": { "$ref": "#/definitions/link" },
@@ -250,6 +305,105 @@
250
305
  "degraded_reason": { "type": "string" },
251
306
  "source": { "type": "string" }
252
307
  }
308
+ },
309
+ "reviewGate": {
310
+ "type": "object",
311
+ "additionalProperties": true,
312
+ "required": ["id", "label", "stage", "applicability", "verdict", "summary"],
313
+ "properties": {
314
+ "id": { "type": "string", "minLength": 1 },
315
+ "label": { "type": "string", "minLength": 1 },
316
+ "stage": { "type": "string", "minLength": 1 },
317
+ "applicability": { "type": "string", "minLength": 1 },
318
+ "verdict": { "type": "string", "minLength": 1 },
319
+ "summary": { "type": "string", "minLength": 1 },
320
+ "evidence": {
321
+ "type": "array",
322
+ "items": { "type": "string", "minLength": 1 }
323
+ }
324
+ }
325
+ },
326
+ "aspectMap": {
327
+ "type": "object",
328
+ "additionalProperties": true,
329
+ "required": ["path", "schema_id", "status"],
330
+ "properties": {
331
+ "path": { "type": "string", "minLength": 1 },
332
+ "schema_id": { "type": "string", "minLength": 1 },
333
+ "status": { "type": "string", "minLength": 1 },
334
+ "summary": { "type": "string" },
335
+ "counts": {
336
+ "type": "object",
337
+ "additionalProperties": true,
338
+ "properties": {
339
+ "applicable": { "type": "integer", "minimum": 0 },
340
+ "not_applicable": { "type": "integer", "minimum": 0 },
341
+ "unknown": { "type": "integer", "minimum": 0 }
342
+ }
343
+ }
344
+ }
345
+ },
346
+ "policyContext": {
347
+ "type": "object",
348
+ "additionalProperties": true,
349
+ "required": ["sources", "git", "checks", "delivery", "gaps"],
350
+ "properties": {
351
+ "sources": { "type": "object", "additionalProperties": true },
352
+ "git": {
353
+ "type": "object",
354
+ "additionalProperties": true,
355
+ "properties": {
356
+ "workspace_route": { "type": "string" },
357
+ "delivery_strategy": {
358
+ "type": "string",
359
+ "enum": ["direct_commit", "direct_commit_push", "feature_merge", "pull_request", "merge_queue", "squash_merge", "rebase_ff", "release_branch", "external_handoff", "local_only", "no_git"]
360
+ },
361
+ "fixation_required": { "type": "boolean" },
362
+ "result_evidence_required": { "type": "array", "items": { "type": "string" } }
363
+ }
364
+ },
365
+ "checks": { "type": "object", "additionalProperties": true },
366
+ "delivery": { "type": "object", "additionalProperties": true },
367
+ "gaps": { "type": "object", "additionalProperties": true }
368
+ }
369
+ },
370
+ "flowFlags": {
371
+ "type": "object",
372
+ "additionalProperties": false,
373
+ "required": ["flow_kind", "snapshot_revision", "resolution_status", "values", "snapshot_checksum"],
374
+ "properties": {
375
+ "contract": { "type": "object", "additionalProperties": true },
376
+ "flow_kind": { "type": "string", "minLength": 1 },
377
+ "preset": { "type": "object", "additionalProperties": true },
378
+ "snapshot_revision": { "type": "integer", "minimum": 1 },
379
+ "resolution_status": { "type": "string", "enum": ["valid", "legacy_incomplete", "reconciliation_required"] },
380
+ "values": { "type": "object", "additionalProperties": { "$ref": "#/definitions/flagValue" } },
381
+ "snapshot_checksum": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
382
+ "floors_applied": { "type": "array", "items": { "type": "string" } },
383
+ "resolved_at": { "type": "string", "format": "date-time" }
384
+ }
385
+ },
386
+ "flagValue": {
387
+ "type": "object",
388
+ "additionalProperties": false,
389
+ "required": ["value", "value_type", "owner", "source", "rationale", "resolved_at"],
390
+ "properties": {
391
+ "value": {},
392
+ "value_type": { "type": "string", "minLength": 1 },
393
+ "owner": { "type": "string", "minLength": 1 },
394
+ "source": {
395
+ "type": "object",
396
+ "additionalProperties": false,
397
+ "required": ["kind", "ref"],
398
+ "properties": {
399
+ "kind": { "type": "string", "minLength": 1 },
400
+ "ref": { "type": "string", "minLength": 1 },
401
+ "revision": { "type": ["integer", "null"] }
402
+ }
403
+ },
404
+ "rationale": { "type": "string", "minLength": 1 },
405
+ "resolved_at": { "type": "string", "format": "date-time" }
406
+ }
253
407
  }
254
408
  }
255
409
  }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "dd-flow/release-impact@1",
4
+ "title": "Canonical release impact",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_id", "from_version", "to_version", "minimum_upgrade_mode", "domains", "path_migration", "runtime_migration", "required_checks"],
8
+ "properties": {
9
+ "schema_id": {"const": "dd-flow/release-impact@1"},
10
+ "from_version": {"type": "string"}, "to_version": {"type": "string"},
11
+ "minimum_upgrade_mode": {"enum": ["patch", "targeted_minor", "full_migration"]},
12
+ "domains": {"type": "array", "items": {"type": "string"}},
13
+ "path_migration": {"type": "boolean"}, "runtime_migration": {"type": "boolean"},
14
+ "required_checks": {"type": "array", "items": {"type": "string"}, "minItems": 1}, "notes": {"type": "string"}
15
+ }
16
+ }
@@ -11,9 +11,9 @@ export function appendAudit(context, input) {
11
11
  context.now()
12
12
  ]);
13
13
  }
14
- export function getAuditEvents(context, protocolId) {
14
+ export function getAuditEvents(context, projectId, protocolId) {
15
15
  return context.db.all(`SELECT id, protocol_id, project_id, event_type, forced, reason, payload_json, created_at
16
16
  FROM audit_events
17
- WHERE protocol_id = ?
18
- ORDER BY id ASC`, [protocolId]);
17
+ WHERE project_id = ? AND protocol_id = ?
18
+ ORDER BY id ASC`, [projectId, protocolId]);
19
19
  }
@@ -0,0 +1,266 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { resolveProjectRoot } from "../storage/paths.js";
6
+ import { requireProjectByRoot } from "./projects.js";
7
+ import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
8
+ export function getProtocolBranchContext(context, input) {
9
+ const resolved = resolveBranchContextInput(context, input);
10
+ const rows = context.db.all(`SELECT id, project_id, project_root, status, stage, next_action, workspace_json, route_json, active_def_json, updated_at
11
+ FROM protocols
12
+ WHERE project_id = ?
13
+ ORDER BY updated_at ASC, id ASC`, [resolved.project.id]);
14
+ const queues = new Map(context.db
15
+ .all(`SELECT protocol_id, status, claimed_by_session_id, claimed_at, attempts_count, updated_at
16
+ FROM merge_queue
17
+ WHERE project_id = ?`, [resolved.project.id])
18
+ .map((row) => [row.protocol_id, row]));
19
+ const worktrees = new Map(context.db
20
+ .all(`SELECT protocol_id, integration_branch, feature_branch, base_ref, worktree_path, status
21
+ FROM worktree_records
22
+ WHERE project_id = ?`, [resolved.project.id])
23
+ .map((row) => [row.protocol_id, row]));
24
+ const sessions = context.db.all(`SELECT protocol_id, worker_id, flow_kind, status, current_stage, next_action
25
+ FROM flow_sessions
26
+ WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [resolved.project.id]);
27
+ const sessionsByProtocol = new Map();
28
+ for (const session of sessions) {
29
+ if (!session.protocol_id)
30
+ continue;
31
+ sessionsByProtocol.set(session.protocol_id, [...(sessionsByProtocol.get(session.protocol_id) ?? []), session]);
32
+ }
33
+ const candidates = rows.map((row) => summarizeProtocol(row, queues.get(row.id), worktrees.get(row.id), sessionsByProtocol.get(row.id) ?? []));
34
+ const protocols = candidates.filter((candidate) => protocolMatchesBranch(candidate, resolved));
35
+ const eligible = protocols.filter((item) => item.bundle_eligible).map((item) => item.id);
36
+ const notReady = protocols.filter((item) => !item.bundle_eligible && !terminalStage(item.stage) && item.queue_status !== "claimed").map((item) => item.id);
37
+ const claimed = protocols.filter((item) => item.queue_status === "claimed").map((item) => item.id);
38
+ const blocked = protocols.filter((item) => item.stage === "blocked" || item.bundle_blockers.length > 0).map((item) => item.id);
39
+ const diagnostics = branchDiagnostics(resolved);
40
+ const diagnosticBlockers = diagnostics.filter((item) => item.severity === "error").map((item) => String(item.code));
41
+ const claimable = eligible.length > 0 && notReady.length === 0 && claimed.length === 0 && blocked.length === 0 && diagnosticBlockers.length === 0;
42
+ return {
43
+ ok: true,
44
+ branch_context: {
45
+ current_protocol_id: resolved.protocol?.id ?? null,
46
+ project_id: resolved.project.id,
47
+ project_root: resolved.project.root,
48
+ integration_branch: resolved.integrationBranch,
49
+ feature_branch: resolved.featureBranch,
50
+ worktree_path: resolved.workspacePath,
51
+ grouping_key: resolved.groupingKey,
52
+ git: resolved.git,
53
+ diagnostics,
54
+ protocols,
55
+ merge_bundle: {
56
+ eligible_protocols: eligible,
57
+ not_ready_protocols: notReady,
58
+ claimed_protocols: claimed,
59
+ blocked_protocols: blocked,
60
+ claimable,
61
+ reason: claimable ? "claimable" : bundleReason(eligible, notReady, claimed, blocked, diagnosticBlockers)
62
+ }
63
+ }
64
+ };
65
+ }
66
+ export function requireClaimableBranchBundle(context, input) {
67
+ const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
68
+ const contextBody = branchContext.branch_context;
69
+ const bundle = contextBody.merge_bundle;
70
+ if (bundle.claimable !== true) {
71
+ throw new AppError("merge_bundle_not_claimable", "Branch bundle is not claimable", 1, { branch_context: contextBody });
72
+ }
73
+ const protocolIds = Array.isArray(bundle.eligible_protocols) ? bundle.eligible_protocols.map(String) : [];
74
+ if (protocolIds.length === 0) {
75
+ throw new AppError("merge_bundle_empty", "Branch bundle has no ready protocols", 1, { branch_context: contextBody });
76
+ }
77
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
78
+ return { project, branchContext, protocolIds };
79
+ }
80
+ function resolveBranchContextInput(context, input) {
81
+ if (input.protocolId) {
82
+ const scopedProject = input.projectRoot
83
+ ? requireProjectByRoot(context, resolveProjectRoot(input.projectRoot))
84
+ : null;
85
+ const matches = context.db.all(scopedProject
86
+ ? `SELECT id, project_id, project_root, status, stage, next_action, workspace_json, route_json, active_def_json, updated_at
87
+ FROM protocols WHERE project_id = ? AND id = ? ORDER BY project_id`
88
+ : `SELECT id, project_id, project_root, status, stage, next_action, workspace_json, route_json, active_def_json, updated_at
89
+ FROM protocols WHERE id = ? ORDER BY project_id`, scopedProject ? [scopedProject.id, input.protocolId] : [input.protocolId]);
90
+ if (matches.length > 1) {
91
+ throw new AppError("ambiguous_protocol", `Protocol id is ambiguous without project context: ${input.protocolId}`, 1, {
92
+ protocol_id: input.protocolId,
93
+ candidates: matches.map((item) => ({ project_id: item.project_id, project_root: item.project_root }))
94
+ });
95
+ }
96
+ const protocol = matches[0];
97
+ if (!protocol) {
98
+ throw new AppError("not_found", `Protocol is not registered: ${input.protocolId}`, 1, { protocol_id: input.protocolId });
99
+ }
100
+ const project = scopedProject ?? requireProjectByRoot(context, protocol.project_root);
101
+ const workspace = parseRecord(protocol.workspace_json);
102
+ const worktree = context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
103
+ const workspacePath = normalizePath(input.workspacePath ?? stringValue(worktree?.worktree_path) ?? stringValue(workspace.worktree_path));
104
+ const git = gitFacts(workspacePath ?? project.root);
105
+ const featureBranch = stringValue(worktree?.feature_branch) ?? stringValue(workspace.feature_branch) ?? stringValue(git.actual_branch);
106
+ return {
107
+ project,
108
+ protocol,
109
+ featureBranch,
110
+ workspacePath,
111
+ integrationBranch: stringValue(worktree?.integration_branch) ?? stringValue(workspace.integration_branch),
112
+ groupingKey: groupingKey(project.id, featureBranch, workspacePath),
113
+ git: { ...git, matches_runtime: branchMatches(featureBranch, stringValue(git.actual_branch)) }
114
+ };
115
+ }
116
+ if (!input.projectRoot) {
117
+ throw new AppError("usage", "branch context requires <protocol-id> or --project-root", 2);
118
+ }
119
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
120
+ const workspacePath = normalizePath(input.workspacePath ?? project.root);
121
+ const git = gitFacts(workspacePath ?? project.root);
122
+ const featureBranch = stringValue(git.actual_branch);
123
+ return {
124
+ project,
125
+ protocol: null,
126
+ featureBranch,
127
+ workspacePath,
128
+ integrationBranch: null,
129
+ groupingKey: groupingKey(project.id, featureBranch, workspacePath),
130
+ git: { ...git, matches_runtime: true }
131
+ };
132
+ }
133
+ function summarizeProtocol(row, queue, worktree, activeSessions) {
134
+ const workspace = parseRecord(row.workspace_json);
135
+ const featureBranch = stringValue(worktree?.feature_branch) ?? stringValue(workspace.feature_branch);
136
+ const worktreePath = normalizePath(stringValue(worktree?.worktree_path) ?? stringValue(workspace.worktree_path));
137
+ const activeDef = parseJsonArray(row.active_def_json);
138
+ const queueStatus = queue?.status ?? null;
139
+ const bundleBlockers = bundleBlockersFor(row.stage, queueStatus, activeDef.length);
140
+ return {
141
+ id: row.id,
142
+ stage: row.stage,
143
+ status: row.status,
144
+ lifecycle: normalizeProtocolLifecycle({ rawStage: row.stage, rawStatus: row.status, queueStatus }),
145
+ queue_status: queueStatus,
146
+ queue_claimed_by: queue?.claimed_by_session_id ?? null,
147
+ active_sessions: activeSessions,
148
+ integration_branch: stringValue(worktree?.integration_branch) ?? stringValue(workspace.integration_branch),
149
+ feature_branch: featureBranch,
150
+ worktree_path: worktreePath,
151
+ bundle_eligible: ["ready_for_merge", "queued_for_merge"].includes(row.stage) && ["ready", "requeued"].includes(queueStatus ?? "") && activeDef.length === 0,
152
+ bundle_blockers: bundleBlockers,
153
+ updated_at: row.updated_at
154
+ };
155
+ }
156
+ function protocolMatchesBranch(item, resolved) {
157
+ if (resolved.featureBranch && item.feature_branch) {
158
+ return item.feature_branch === resolved.featureBranch;
159
+ }
160
+ if (resolved.workspacePath && item.worktree_path) {
161
+ return item.worktree_path === resolved.workspacePath;
162
+ }
163
+ return false;
164
+ }
165
+ function gitFacts(workspacePath) {
166
+ if (!fs.existsSync(workspacePath)) {
167
+ return { status: "missing_workspace", actual_branch: null, dirty: null, path: workspacePath };
168
+ }
169
+ const branch = spawnSync("git", ["-C", workspacePath, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
170
+ if (branch.status !== 0) {
171
+ return { status: "not_git_checkout", actual_branch: null, dirty: null, path: workspacePath };
172
+ }
173
+ const status = spawnSync("git", ["-C", workspacePath, "status", "--porcelain"], { encoding: "utf8" });
174
+ return {
175
+ status: "ok",
176
+ actual_branch: branch.stdout.trim() || null,
177
+ dirty: status.status === 0 ? status.stdout.trim().length > 0 : null,
178
+ path: workspacePath
179
+ };
180
+ }
181
+ function branchDiagnostics(resolved) {
182
+ const diagnostics = [];
183
+ if (resolved.git.status !== "ok") {
184
+ diagnostics.push({ code: "git_workspace_degraded", severity: "warning", summary: `Git workspace status is ${String(resolved.git.status)}` });
185
+ }
186
+ if (resolved.featureBranch && resolved.git.actual_branch && resolved.featureBranch !== resolved.git.actual_branch) {
187
+ diagnostics.push({
188
+ code: "branch_runtime_mismatch",
189
+ severity: "error",
190
+ summary: `Runtime branch ${resolved.featureBranch} does not match Git branch ${String(resolved.git.actual_branch)}.`
191
+ });
192
+ }
193
+ return diagnostics;
194
+ }
195
+ function bundleBlockersFor(stage, queueStatus, activeDefCount) {
196
+ const blockers = [];
197
+ if (activeDefCount > 0)
198
+ blockers.push("active_def");
199
+ if (stage === "blocked")
200
+ blockers.push("protocol_blocked");
201
+ if (queueStatus === "claimed")
202
+ blockers.push("already_claimed");
203
+ return blockers;
204
+ }
205
+ function bundleReason(eligible, notReady, claimed, blocked, diagnostics) {
206
+ if (diagnostics.length > 0)
207
+ return diagnostics[0] ?? "diagnostic_blocker";
208
+ if (claimed.length > 0)
209
+ return "branch_has_claimed_protocols";
210
+ if (blocked.length > 0)
211
+ return "branch_has_blocked_protocols";
212
+ if (notReady.length > 0)
213
+ return "branch_has_not_ready_protocols";
214
+ if (eligible.length === 0)
215
+ return "no_ready_protocols";
216
+ return "unknown";
217
+ }
218
+ function terminalStage(stage) {
219
+ return ["closed", "cancelled"].includes(stage);
220
+ }
221
+ function groupingKey(projectId, featureBranch, workspacePath) {
222
+ if (featureBranch)
223
+ return `${projectId}:branch:${featureBranch}`;
224
+ if (workspacePath)
225
+ return `${projectId}:worktree:${workspacePath}`;
226
+ return `${projectId}:unknown`;
227
+ }
228
+ function branchMatches(expected, actual) {
229
+ if (!expected || !actual)
230
+ return null;
231
+ return expected === actual;
232
+ }
233
+ function normalizePath(value) {
234
+ if (!value)
235
+ return null;
236
+ const absolute = path.resolve(value);
237
+ if (fs.existsSync(absolute))
238
+ return fs.realpathSync(absolute);
239
+ const parent = path.dirname(absolute);
240
+ if (fs.existsSync(parent))
241
+ return path.join(fs.realpathSync(parent), path.basename(absolute));
242
+ return absolute;
243
+ }
244
+ function parseRecord(value) {
245
+ try {
246
+ const parsed = JSON.parse(value);
247
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
248
+ }
249
+ catch {
250
+ return {};
251
+ }
252
+ }
253
+ function parseJsonArray(value) {
254
+ if (typeof value !== "string")
255
+ return [];
256
+ try {
257
+ const parsed = JSON.parse(value);
258
+ return Array.isArray(parsed) ? parsed : [];
259
+ }
260
+ catch {
261
+ return [];
262
+ }
263
+ }
264
+ function stringValue(value) {
265
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
266
+ }
@@ -217,7 +217,6 @@ function assessLayout(root, layout, memorybankRoot) {
217
217
  "protocol/index.md",
218
218
  "dd-flow/mb-init.md",
219
219
  "dd-flow/mb-upgrade.md",
220
- "dd-flow/mb-upgrade-review.md",
221
220
  "dd-flow/mb-distill.md"
222
221
  ];
223
222
  for (const relativePath of requiredFiles) {
@@ -31,7 +31,7 @@ export function cleanupScan(context, input) {
31
31
  });
32
32
  }
33
33
  if (terminal) {
34
- const queue = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ? AND status IN ('ready', 'requeued', 'claimed')", [protocol.id]);
34
+ const queue = context.db.get("SELECT status FROM merge_queue WHERE project_id = ? AND protocol_id = ? AND status IN ('ready', 'requeued', 'claimed')", [project.id, protocol.id]);
35
35
  if (queue) {
36
36
  pushFinding(findings, actions, "active_queue_for_terminal_protocol", "warning", {
37
37
  protocol_id: protocol.id,
@@ -40,7 +40,7 @@ export function cleanupScan(context, input) {
40
40
  });
41
41
  }
42
42
  }
43
- const fullProtocol = requireProtocol(context, protocol.id);
43
+ const fullProtocol = requireProtocol(context, protocol.id, project.id);
44
44
  const state = readProtocolRuntimeState(context, fullProtocol).state;
45
45
  const diagnostics = protocolRunDiagnostics(context, fullProtocol, state).diagnostics;
46
46
  for (const diagnostic of diagnostics.filter((entry) => entry.code === "protocol_run_stage_mismatch")) {
@@ -55,7 +55,7 @@ export function cleanupScan(context, input) {
55
55
  }
56
56
  }
57
57
  const orphanQueueJobs = context.db.all(`SELECT mq.protocol_id, mq.status FROM merge_queue mq
58
- LEFT JOIN protocols p ON p.id = mq.protocol_id
58
+ LEFT JOIN protocols p ON p.project_id = mq.project_id AND p.id = mq.protocol_id
59
59
  WHERE mq.project_id = ?
60
60
  AND mq.status IN ('ready', 'requeued')
61
61
  AND p.id IS NULL
@@ -72,7 +72,7 @@ export function cleanupScan(context, input) {
72
72
  ORDER BY updated_at ASC, session_id ASC`, [project.id]);
73
73
  for (const session of staleSessions) {
74
74
  const protocol = session.protocol_id
75
- ? context.db.get("SELECT status, stage FROM protocols WHERE id = ?", [session.protocol_id])
75
+ ? context.db.get("SELECT status, stage FROM protocols WHERE project_id = ? AND id = ?", [project.id, session.protocol_id])
76
76
  : undefined;
77
77
  const missingWorkspace = !fs.existsSync(session.workspace_path);
78
78
  const terminalProtocol = Boolean(protocol && (["closed", "cancelled"].includes(protocol.status) || ["closed", "cancelled"].includes(protocol.stage)));
@@ -112,7 +112,7 @@ export function cleanupScan(context, input) {
112
112
  });
113
113
  }
114
114
  const staleWorktrees = context.db.all(`SELECT wr.protocol_id, wr.worktree_path FROM worktree_records wr
115
- JOIN protocols p ON p.id = wr.protocol_id
115
+ JOIN protocols p ON p.project_id = wr.project_id AND p.id = wr.protocol_id
116
116
  WHERE wr.project_id = ?
117
117
  AND wr.status = 'active'
118
118
  AND (p.status IN ('closed', 'cancelled') OR p.stage IN ('closed', 'cancelled') OR wr.worktree_path = '')
@@ -242,7 +242,7 @@ function validateNoDestructiveSurprises(context, plan, force) {
242
242
  }
243
243
  function applyAction(context, action, reason, force, now) {
244
244
  if (action.kind === "repair_protocol_state" && action.protocol_id) {
245
- const protocol = requireProtocol(context, action.protocol_id);
245
+ const protocol = requireProtocol(context, action.protocol_id, action.project_id);
246
246
  const beforePath = protocol.state_path;
247
247
  const existed = fs.existsSync(beforePath);
248
248
  readProtocolRuntimeState(context, protocol);
@@ -69,7 +69,7 @@ function isReadOnlyDiagnostic(family, command, action) {
69
69
  if (family === "project")
70
70
  return command === "status" || command === "resolve";
71
71
  if (family === "protocol")
72
- return ["status", "ready", "blockers", "implement"].includes(command ?? "");
72
+ return ["status", "branch-status", "ready", "blockers", "implement"].includes(command ?? "");
73
73
  if (family === "plan")
74
74
  return command === "status";
75
75
  if (family === "run")