@deksden-com/dd-flow-cli 0.3.1 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add branch/workspace protocol context for agents: `protocol branch-status`, `branch_context` in protocol and merge JSON outputs, and branch bundle status with eligible, not-ready and claimed protocol lists.
8
+
9
+ Add `merge bundle status|claim|complete|fail` commands so a merge worker can integrate one feature branch containing multiple ready protocols under the existing merge lane lock.
10
+
11
+ ### Patch Changes
12
+
13
+ - cfdcf1d: Fix compatibility status reporting so selected compatible engines advertise `normal_write` in `allowed_modes`; blocked operations are now reported only when a write is actually blocked by missing or incompatible engine resolution.
14
+
3
15
  ## 0.3.1
4
16
 
5
17
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.3.1",
4
- "cli_commit": "b677e824e7d3b1e6553e7963eeceeb1db02e86db",
5
- "built_at": "2026-07-03T20:02:54.356Z",
3
+ "cli_version": "0.4.0",
4
+ "cli_commit": "3ab5ed2a5efcd2d1aa8a13a530b94995abe2bd3b",
5
+ "built_at": "2026-07-05T17:44:47.134Z",
6
6
  "built_with_canon": {
7
- "version": "2.9.0",
8
- "commit": "7e8b51c1321e290d18df3572d6ab26ed32b85403",
7
+ "version": "2.10.0",
8
+ "commit": "228a069278ead592d2bee435a33e0da90536227b",
9
9
  "flow_contract": "dd-flow-canonical-2026-06",
10
10
  "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
11
  "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
package/dist/cli/help.js CHANGED
@@ -24,7 +24,7 @@ Core commands:
24
24
  id next Preview next typed ids for protocol or run allocation.
25
25
  plan set/status/item Attach and update protocol plans.
26
26
  lane workspace/lock/status Manage shared workspace lanes and leases.
27
- merge status/one-shot Inspect or claim one merge job from the current session.
27
+ merge status/one-shot/bundle Inspect, claim, or complete merge work from the current session.
28
28
  merge-worker status/start/stop Manage long-lived project merge workers.
29
29
  merge-queue status/next Claim, wait for, complete, or fail merge jobs.
30
30
  session register/status/stop Register flow sessions and stop workers.
@@ -173,6 +173,8 @@ Examples:
173
173
  Usage:
174
174
  dd-flow protocol register <handshake-id> --project-root <root> [--workspace-path <checkout>] --json
175
175
  dd-flow protocol status <protocol-id> --json
176
+ dd-flow protocol branch-status <protocol-id> --json
177
+ dd-flow protocol branch-status --project-root <root> --path <workspace> --json
176
178
  dd-flow protocol ready --project-root <root> --json
177
179
  dd-flow protocol blockers <protocol-id> --project-root <root> --json
178
180
  dd-flow protocol implement <protocol-id> --project-root <root> [--force --reason <text>] --json
@@ -181,7 +183,7 @@ Usage:
181
183
  dd-flow protocol ready-for-merge <protocol-id> --json
182
184
  dd-flow protocol cancel <protocol-id> --reason <text> [--close-sessions true|false] [--cancel-queue true|false] [--release-locks true|false] [--worktree keep|remove] [--force] --json
183
185
 
184
- ready, blockers and implement read protocol markdown frontmatter plus runtime state. implement is a preflight/guidance command: it never performs implementation, refuses unresolved blocked_by_protocols unless --force --reason is supplied, refuses terminal protocols and reports the expected next prompt/stage. transition validates the snapshotted flow contract stored in protocol runtime state, writes an audit event and updates lifecycle fields from the payload. --json-file is accepted as a compatibility alias for --payload-file. sync-from-run repairs legacy protocol/run mismatch from concrete RUN evidence instead of requiring manual runtime JSON edits.
186
+ ready, blockers and implement read protocol markdown frontmatter plus runtime state. branch-status is read-only and reports the current protocol/workspace branch context: sibling protocols in the same branch/worktree, queue status, active sessions and merge bundle eligibility. implement is a preflight/guidance command: it never performs implementation, refuses unresolved blocked_by_protocols unless --force --reason is supplied, refuses terminal protocols and reports the expected next prompt/stage. transition validates the snapshotted flow contract stored in protocol runtime state, writes an audit event and updates lifecycle fields from the payload. --json-file is accepted as a compatibility alias for --payload-file. sync-from-run repairs legacy protocol/run mismatch from concrete RUN evidence instead of requiring manual runtime JSON edits.
185
187
 
186
188
  The project_root is the stable project identity used for queues, sessions, lanes, and dashboards. Runtime state is stored under DD_FLOW_HOME/projects/<PRJ-ID-slug>/runtime so feature worktree removal does not break status, cancel, or merge finalization. The optional workspace_path records the concrete checkout where the agent works. With --worktree remove, cancel removes the disposable git worktree recorded in runtime state or worktree records; with --force it also force-deletes the local feature branch when present.`
187
189
  ],
@@ -299,10 +301,14 @@ Related lane commands:
299
301
  `dd-flow merge - current-session merge entrypoint helpers
300
302
 
301
303
  Usage:
302
- dd-flow merge status --project-root <root> --json
304
+ dd-flow merge status --project-root <root> [--path <workspace>] --json
303
305
  dd-flow merge one-shot --project-root <root> --worker-id <id> [--path <workspace>] --json
306
+ dd-flow merge bundle status --project-root <root> --path <workspace> --json
307
+ dd-flow merge bundle claim --project-root <root> --worker-id <id> --path <workspace> --json
308
+ dd-flow merge bundle complete --project-root <root> --worker-id <id> --path <workspace> --summary <text> --json
309
+ dd-flow merge bundle fail --project-root <root> --worker-id <id> --path <workspace> --reason <text> --requeue true|false --json
304
310
 
305
- merge status reports active merge worker, claimed protocols, active merge lane lock and queue state. merge one-shot is status-only when a long-lived worker, claimed protocol or active lock exists; otherwise it acquires the merge lane, claims at most one ready protocol, and returns queue_item/protocol/claim plus legacy job for the shared merge job prompt. If no protocol is ready, it releases the lock and returns queue_item: null and job: null.`
311
+ merge status reports active merge worker, claimed protocols, active merge lane lock, queue state and branch_context when --path is provided. merge one-shot keeps legacy single-protocol behavior and also returns branch_context. merge bundle commands operate on all ready protocols in the current branch/worktree: status is read-only, claim requires the merge lane lock owner, complete closes all claimed protocols after branch integration, and fail requeues or fails the claimed bundle.`
306
312
  ],
307
313
  [
308
314
  "merge-worker",
@@ -330,6 +336,10 @@ Usage:
330
336
 
331
337
  The worker must own the merge lane from the registered merge workspace before claiming, completing, or failing queued protocols. Outputs use queue_item/protocol/claim as the primary contract and retain job as a compatibility alias. note updates the final completion summary after post-complete cleanup and does not require a lane lock. --path defaults to the current working directory. Use --worker-id as the owner identity.
332
338
 
339
+ For branch-level integration prefer:
340
+ dd-flow merge bundle status --project-root "$PWD" --path "$PWD" --json
341
+ dd-flow merge bundle claim --project-root "$PWD" --worker-id merge-worker --path "$PWD" --json
342
+
333
343
  Examples:
334
344
  dd-flow merge-queue wait-next --project-root "$PWD" --worker-id merge-worker --path "$PWD" --timeout 1200 --poll-interval 10 --acquire-lock true --json
335
345
  dd-flow merge-queue complete PRT-123 --worker-id merge-worker --path "$PWD" --summary "merged locally; cleanup follows" --json
@@ -5,7 +5,7 @@ import { writeJson } from "../shared/json.js";
5
5
  import { archiveProject, getProjectStatus, migrateProjectIds, registerProject, resolveProject } from "../services/projects.js";
6
6
  import { cancelProtocol, getProtocolBlockers, getProtocolStatus, getReadyProtocols, implementProtocol, readyForMerge, registerProtocol, requireProtocol, syncProtocolFromRun, transitionProtocol } from "../services/protocols.js";
7
7
  import { blockPlanItem, completePlanItem, getPlanStatus, setProtocolPlan, skipPlanItem, startPlanItem } from "../services/plans.js";
8
- import { cancelMergeQueueJob, claimNextMergeJob, completeMergeJob, failMergeJob, getMergeQueueStatus, noteMergeJob, waitNextMergeJob } from "../services/merge-queue.js";
8
+ import { cancelMergeQueueJob, claimNextMergeJob, claimMergeBundle, completeMergeBundle, completeMergeJob, failMergeBundle, failMergeJob, getMergeBundleStatus, getMergeQueueStatus, noteMergeJob, waitNextMergeJob } from "../services/merge-queue.js";
9
9
  import { getMergeWorkerStatus, oneShotMergeClaim, startMergeWorker, stopProjectMergeWorker } from "../services/merge-worker.js";
10
10
  import { cleanupApply, cleanupScan } from "../services/cleanup.js";
11
11
  import { getCodexHomeStatus, getCodexHooksStatus, handleCodexHook, initCodexHome, installCodexHooks, planCodexHome, printCodexHomeEnv, printCodexHooks, removeCodexHome, removeCodexHooks } from "../services/hooks.js";
@@ -18,6 +18,7 @@ import { getCliVersionReport } from "../services/build-info.js";
18
18
  import { autoRefreshDashboards, dashboardData, getCmuxStatus, openDashboard, openGlobalDashboard, openProtocolDashboard, refreshAllDashboards, refreshDashboard, refreshGlobalDashboard, renderDashboard, renderGlobalDashboard } from "../services/dashboard.js";
19
19
  import { dashboardTargetSummary, preferredDashboardCommand, relatedDashboardCommands, resolveDashboardTarget } from "../services/dashboard-targets.js";
20
20
  import { getFlowSessionStatus, registerFlowSession, stopFlowSession, stopMergeWorker } from "../services/sessions.js";
21
+ import { getProtocolBranchContext } from "../services/branch-context.js";
21
22
  import { attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, listFlowRuns, startFlowRun } from "../services/runs.js";
22
23
  import { previewNextEntityId } from "../services/ids.js";
23
24
  import { validateSchema } from "../services/schema-validation.js";
@@ -223,6 +224,18 @@ function renderHumanResult(args, result) {
223
224
  const job = record.job;
224
225
  lines.push(`job: ${String(job.protocol_id ?? "none")} ${String(job.status ?? "")}`.trim());
225
226
  }
227
+ const branchContext = recordObject(record.branch_context);
228
+ const mergeBundle = recordObject(branchContext?.merge_bundle);
229
+ if (branchContext) {
230
+ lines.push(`branch: ${String(branchContext.feature_branch ?? branchContext.grouping_key ?? "unknown")}`);
231
+ const protocols = Array.isArray(branchContext.protocols) ? branchContext.protocols.length : 0;
232
+ lines.push(`branch_protocols: ${protocols}`);
233
+ if (mergeBundle) {
234
+ const eligible = Array.isArray(mergeBundle.eligible_protocols) ? mergeBundle.eligible_protocols.length : 0;
235
+ const notReady = Array.isArray(mergeBundle.not_ready_protocols) ? mergeBundle.not_ready_protocols.length : 0;
236
+ lines.push(`bundle: ${String(mergeBundle.reason ?? "unknown")} ready=${eligible} not_ready=${notReady}`);
237
+ }
238
+ }
226
239
  if (record.stopped)
227
240
  lines.push(`stopped: ${String(record.reason ?? "true")}`);
228
241
  if (record.timed_out)
@@ -422,6 +435,14 @@ async function dispatch(args, context, io) {
422
435
  if (family === "protocol" && command === "status") {
423
436
  return getProtocolStatus(context, { protocolId: requiredPosition(parsed, 0, "protocol-id") });
424
437
  }
438
+ if (family === "protocol" && command === "branch-status") {
439
+ const protocolId = parsed.positional[0];
440
+ return getProtocolBranchContext(context, {
441
+ ...(protocolId ? { protocolId } : {}),
442
+ projectRoot: optionalOption(parsed, "project-root"),
443
+ workspacePath: optionalOption(parsed, "path")
444
+ });
445
+ }
425
446
  if (family === "protocol" && command === "ready") {
426
447
  return getReadyProtocols(context, { projectRoot: requiredOption(parsed, "project-root") });
427
448
  }
@@ -655,7 +676,38 @@ function dispatchMergeQueue(context, command, parsed) {
655
676
  }
656
677
  function dispatchMerge(context, command, parsed) {
657
678
  if (command === "status") {
658
- return getMergeWorkerStatus(context, { projectRoot: requiredOption(parsed, "project-root") });
679
+ return getMergeWorkerStatus(context, { projectRoot: requiredOption(parsed, "project-root"), workspacePath: optionalOption(parsed, "path") });
680
+ }
681
+ if (command === "bundle") {
682
+ const action = requiredPosition(parsed, 0, "bundle action");
683
+ if (action === "status") {
684
+ return getMergeBundleStatus(context, { projectRoot: requiredOption(parsed, "project-root"), workspacePath: workspacePathOption(parsed) });
685
+ }
686
+ if (action === "claim") {
687
+ return claimMergeBundle(context, {
688
+ projectRoot: requiredOption(parsed, "project-root"),
689
+ workerId: requiredWorkerId(parsed),
690
+ workspacePath: workspacePathOption(parsed)
691
+ });
692
+ }
693
+ if (action === "complete") {
694
+ return completeMergeBundle(context, {
695
+ projectRoot: requiredOption(parsed, "project-root"),
696
+ workerId: requiredWorkerId(parsed),
697
+ workspacePath: workspacePathOption(parsed),
698
+ summary: requiredOption(parsed, "summary")
699
+ });
700
+ }
701
+ if (action === "fail") {
702
+ return failMergeBundle(context, {
703
+ projectRoot: requiredOption(parsed, "project-root"),
704
+ workerId: requiredWorkerId(parsed),
705
+ workspacePath: workspacePathOption(parsed),
706
+ reason: requiredOption(parsed, "reason"),
707
+ requeue: parseBoolean(requiredOption(parsed, "requeue"), "requeue")
708
+ });
709
+ }
710
+ throw new AppError("usage", `Unknown merge bundle action: ${action}`, 2);
659
711
  }
660
712
  if (command === "one-shot") {
661
713
  return oneShotMergeClaim(context, {
@@ -0,0 +1,254 @@
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 protocol = context.db.get(`SELECT id, project_id, project_root, status, stage, next_action, workspace_json, route_json, active_def_json, updated_at
83
+ FROM protocols
84
+ WHERE id = ?`, [input.protocolId]);
85
+ if (!protocol) {
86
+ throw new AppError("not_found", `Protocol is not registered: ${input.protocolId}`, 1, { protocol_id: input.protocolId });
87
+ }
88
+ const project = requireProjectByRoot(context, protocol.project_root);
89
+ const workspace = parseRecord(protocol.workspace_json);
90
+ const worktree = context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]);
91
+ const workspacePath = normalizePath(input.workspacePath ?? stringValue(worktree?.worktree_path) ?? stringValue(workspace.worktree_path));
92
+ const git = gitFacts(workspacePath ?? project.root);
93
+ const featureBranch = stringValue(worktree?.feature_branch) ?? stringValue(workspace.feature_branch) ?? stringValue(git.actual_branch);
94
+ return {
95
+ project,
96
+ protocol,
97
+ featureBranch,
98
+ workspacePath,
99
+ integrationBranch: stringValue(worktree?.integration_branch) ?? stringValue(workspace.integration_branch),
100
+ groupingKey: groupingKey(project.id, featureBranch, workspacePath),
101
+ git: { ...git, matches_runtime: branchMatches(featureBranch, stringValue(git.actual_branch)) }
102
+ };
103
+ }
104
+ if (!input.projectRoot) {
105
+ throw new AppError("usage", "branch context requires <protocol-id> or --project-root", 2);
106
+ }
107
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
108
+ const workspacePath = normalizePath(input.workspacePath ?? project.root);
109
+ const git = gitFacts(workspacePath ?? project.root);
110
+ const featureBranch = stringValue(git.actual_branch);
111
+ return {
112
+ project,
113
+ protocol: null,
114
+ featureBranch,
115
+ workspacePath,
116
+ integrationBranch: null,
117
+ groupingKey: groupingKey(project.id, featureBranch, workspacePath),
118
+ git: { ...git, matches_runtime: true }
119
+ };
120
+ }
121
+ function summarizeProtocol(row, queue, worktree, activeSessions) {
122
+ const workspace = parseRecord(row.workspace_json);
123
+ const featureBranch = stringValue(worktree?.feature_branch) ?? stringValue(workspace.feature_branch);
124
+ const worktreePath = normalizePath(stringValue(worktree?.worktree_path) ?? stringValue(workspace.worktree_path));
125
+ const activeDef = parseJsonArray(row.active_def_json);
126
+ const queueStatus = queue?.status ?? null;
127
+ const bundleBlockers = bundleBlockersFor(row.stage, queueStatus, activeDef.length);
128
+ return {
129
+ id: row.id,
130
+ stage: row.stage,
131
+ status: row.status,
132
+ lifecycle: normalizeProtocolLifecycle({ rawStage: row.stage, rawStatus: row.status, queueStatus }),
133
+ queue_status: queueStatus,
134
+ queue_claimed_by: queue?.claimed_by_session_id ?? null,
135
+ active_sessions: activeSessions,
136
+ integration_branch: stringValue(worktree?.integration_branch) ?? stringValue(workspace.integration_branch),
137
+ feature_branch: featureBranch,
138
+ worktree_path: worktreePath,
139
+ bundle_eligible: ["ready_for_merge", "queued_for_merge"].includes(row.stage) && ["ready", "requeued"].includes(queueStatus ?? "") && activeDef.length === 0,
140
+ bundle_blockers: bundleBlockers,
141
+ updated_at: row.updated_at
142
+ };
143
+ }
144
+ function protocolMatchesBranch(item, resolved) {
145
+ if (resolved.featureBranch && item.feature_branch) {
146
+ return item.feature_branch === resolved.featureBranch;
147
+ }
148
+ if (resolved.workspacePath && item.worktree_path) {
149
+ return item.worktree_path === resolved.workspacePath;
150
+ }
151
+ return false;
152
+ }
153
+ function gitFacts(workspacePath) {
154
+ if (!fs.existsSync(workspacePath)) {
155
+ return { status: "missing_workspace", actual_branch: null, dirty: null, path: workspacePath };
156
+ }
157
+ const branch = spawnSync("git", ["-C", workspacePath, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
158
+ if (branch.status !== 0) {
159
+ return { status: "not_git_checkout", actual_branch: null, dirty: null, path: workspacePath };
160
+ }
161
+ const status = spawnSync("git", ["-C", workspacePath, "status", "--porcelain"], { encoding: "utf8" });
162
+ return {
163
+ status: "ok",
164
+ actual_branch: branch.stdout.trim() || null,
165
+ dirty: status.status === 0 ? status.stdout.trim().length > 0 : null,
166
+ path: workspacePath
167
+ };
168
+ }
169
+ function branchDiagnostics(resolved) {
170
+ const diagnostics = [];
171
+ if (resolved.git.status !== "ok") {
172
+ diagnostics.push({ code: "git_workspace_degraded", severity: "warning", summary: `Git workspace status is ${String(resolved.git.status)}` });
173
+ }
174
+ if (resolved.featureBranch && resolved.git.actual_branch && resolved.featureBranch !== resolved.git.actual_branch) {
175
+ diagnostics.push({
176
+ code: "branch_runtime_mismatch",
177
+ severity: "error",
178
+ summary: `Runtime branch ${resolved.featureBranch} does not match Git branch ${String(resolved.git.actual_branch)}.`
179
+ });
180
+ }
181
+ return diagnostics;
182
+ }
183
+ function bundleBlockersFor(stage, queueStatus, activeDefCount) {
184
+ const blockers = [];
185
+ if (activeDefCount > 0)
186
+ blockers.push("active_def");
187
+ if (stage === "blocked")
188
+ blockers.push("protocol_blocked");
189
+ if (queueStatus === "claimed")
190
+ blockers.push("already_claimed");
191
+ return blockers;
192
+ }
193
+ function bundleReason(eligible, notReady, claimed, blocked, diagnostics) {
194
+ if (diagnostics.length > 0)
195
+ return diagnostics[0] ?? "diagnostic_blocker";
196
+ if (claimed.length > 0)
197
+ return "branch_has_claimed_protocols";
198
+ if (blocked.length > 0)
199
+ return "branch_has_blocked_protocols";
200
+ if (notReady.length > 0)
201
+ return "branch_has_not_ready_protocols";
202
+ if (eligible.length === 0)
203
+ return "no_ready_protocols";
204
+ return "unknown";
205
+ }
206
+ function terminalStage(stage) {
207
+ return ["closed", "cancelled"].includes(stage);
208
+ }
209
+ function groupingKey(projectId, featureBranch, workspacePath) {
210
+ if (featureBranch)
211
+ return `${projectId}:branch:${featureBranch}`;
212
+ if (workspacePath)
213
+ return `${projectId}:worktree:${workspacePath}`;
214
+ return `${projectId}:unknown`;
215
+ }
216
+ function branchMatches(expected, actual) {
217
+ if (!expected || !actual)
218
+ return null;
219
+ return expected === actual;
220
+ }
221
+ function normalizePath(value) {
222
+ if (!value)
223
+ return null;
224
+ const absolute = path.resolve(value);
225
+ if (fs.existsSync(absolute))
226
+ return fs.realpathSync(absolute);
227
+ const parent = path.dirname(absolute);
228
+ if (fs.existsSync(parent))
229
+ return path.join(fs.realpathSync(parent), path.basename(absolute));
230
+ return absolute;
231
+ }
232
+ function parseRecord(value) {
233
+ try {
234
+ const parsed = JSON.parse(value);
235
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
236
+ }
237
+ catch {
238
+ return {};
239
+ }
240
+ }
241
+ function parseJsonArray(value) {
242
+ if (typeof value !== "string")
243
+ return [];
244
+ try {
245
+ const parsed = JSON.parse(value);
246
+ return Array.isArray(parsed) ? parsed : [];
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ }
252
+ function stringValue(value) {
253
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
254
+ }
@@ -27,6 +27,7 @@ export function preflightCliCompatibility(context, args, env = process.env) {
27
27
  }
28
28
  export function compatibilityReport(selection, classification) {
29
29
  const build = getCliBuildInfo();
30
+ const normalWriteAllowed = selection.status === "selected" && Boolean(selection.selected);
30
31
  return {
31
32
  verdict: selection.status === "selected" ? "ok" : "incompatible",
32
33
  memorybank_version: selection.memory_bank_version,
@@ -36,9 +37,11 @@ export function compatibilityReport(selection, classification) {
36
37
  engine_resolution: selection.status,
37
38
  required_engine_range: selection.required_range,
38
39
  recommended_engine_version: selection.recommended_version,
39
- allowed_modes: ["read_only_diagnostics", "mb_upgrade"],
40
+ allowed_modes: normalWriteAllowed
41
+ ? ["read_only_diagnostics", "normal_write", "mb_upgrade"]
42
+ : ["read_only_diagnostics", "mb_upgrade"],
40
43
  operation_mode: classification.mode,
41
- blocked_operation: classification.mode === "normal_write" ? classification.operation : null,
44
+ blocked_operation: classification.mode === "normal_write" && !normalWriteAllowed ? classification.operation : null,
42
45
  install_hint: selection.install_hint,
43
46
  project_root: selection.project_root,
44
47
  diagnostics: selection.diagnostics
@@ -6,6 +6,7 @@ import { persistProtocolState, protocolRunDiagnostics, readProtocolRuntimeState,
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner, waitAcquireLaneLock } from "./lanes.js";
8
8
  import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
9
+ import { getProtocolBranchContext, requireClaimableBranchBundle } from "./branch-context.js";
9
10
  const mergeLockTtlSeconds = 300;
10
11
  export function getMergeQueueStatus(context, input) {
11
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -14,6 +15,9 @@ export function getMergeQueueStatus(context, input) {
14
15
  queue: queueForProject(context, project.id)
15
16
  };
16
17
  }
18
+ export function getMergeBundleStatus(context, input) {
19
+ return getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
20
+ }
17
21
  export function claimNextMergeJob(context, input) {
18
22
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
19
23
  const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
@@ -69,6 +73,67 @@ export function claimNextMergeJob(context, input) {
69
73
  claimed: Boolean(claimedProtocolId)
70
74
  });
71
75
  }
76
+ export function claimMergeBundle(context, input) {
77
+ const { project, protocolIds } = requireClaimableBranchBundle(context, {
78
+ projectRoot: input.projectRoot,
79
+ workspacePath: input.workspacePath
80
+ });
81
+ const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
82
+ if (stopState.stopped) {
83
+ throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim a merge bundle", 1, {
84
+ worker_id: input.workerId,
85
+ status: stopState.status,
86
+ reason: stopState.reason
87
+ });
88
+ }
89
+ requireLaneLockOwner(context, {
90
+ projectRoot: project.root,
91
+ lane: "merge",
92
+ workerId: input.workerId,
93
+ workspacePath: input.workspacePath
94
+ });
95
+ const now = context.now();
96
+ context.db.exec("BEGIN IMMEDIATE");
97
+ try {
98
+ for (const protocolId of protocolIds) {
99
+ const update = context.db.run(`UPDATE merge_queue
100
+ SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
101
+ WHERE protocol_id = ? AND project_id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, protocolId, project.id]);
102
+ if (update.changes !== 1) {
103
+ throw new AppError("merge_bundle_claim_race", "Branch bundle changed while claiming", 1, {
104
+ protocol_id: protocolId,
105
+ protocol_ids: protocolIds
106
+ });
107
+ }
108
+ transitionClaimedProtocolToIntegration(context, protocolId, input.workerId, now);
109
+ appendAudit(context, {
110
+ protocolId,
111
+ projectId: project.id,
112
+ eventType: "merge_bundle.claimed_protocol",
113
+ payload: { protocol_id: protocolId, worker_id: input.workerId, protocol_ids: protocolIds }
114
+ });
115
+ }
116
+ appendAudit(context, {
117
+ projectId: project.id,
118
+ eventType: "merge_bundle.claimed",
119
+ payload: { project_id: project.id, worker_id: input.workerId, protocol_ids: protocolIds, workspace_path: input.workspacePath }
120
+ });
121
+ context.db.exec("COMMIT");
122
+ }
123
+ catch (error) {
124
+ context.db.exec("ROLLBACK");
125
+ throw error;
126
+ }
127
+ return mergeBundleResult(context, {
128
+ ok: true,
129
+ outcome: "claimed",
130
+ claimed: true,
131
+ projectRoot: project.root,
132
+ workspacePath: input.workspacePath,
133
+ workerId: input.workerId,
134
+ protocolIds
135
+ });
136
+ }
72
137
  export async function waitNextMergeJob(context, input) {
73
138
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
74
139
  const timeoutSeconds = input.timeoutSeconds ?? 0;
@@ -251,6 +316,71 @@ export function completeMergeJob(context, input) {
251
316
  stop_after_current
252
317
  });
253
318
  }
319
+ export function completeMergeBundle(context, input) {
320
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
321
+ requireLaneLockOwner(context, {
322
+ projectRoot: project.root,
323
+ lane: "merge",
324
+ workerId: input.workerId,
325
+ workspacePath: input.workspacePath
326
+ });
327
+ const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
328
+ if (protocolIds.length === 0) {
329
+ throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
330
+ worker_id: input.workerId,
331
+ workspace_path: input.workspacePath
332
+ });
333
+ }
334
+ const now = context.now();
335
+ context.db.exec("BEGIN IMMEDIATE");
336
+ try {
337
+ for (const protocolId of protocolIds) {
338
+ const protocol = requireProtocol(context, protocolId);
339
+ const job = requireClaimedJob(context, protocolId, input.workerId);
340
+ context.db.run(`UPDATE merge_queue
341
+ SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
342
+ WHERE id = ?`, [input.summary, now, now, job.id]);
343
+ const state = readProtocolRuntimeState(context, protocol).state;
344
+ const flowContract = loadProjectFlowContract(protocol.project_root);
345
+ const targetStage = flowContract.merge_queue.complete.target_stage;
346
+ const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
347
+ persistProtocolState(context, protocol, {
348
+ ...state,
349
+ stage: targetStage,
350
+ status: targetStatus,
351
+ next_action: flowContract.merge_queue.complete.next_action,
352
+ updated_at: now
353
+ });
354
+ appendAudit(context, {
355
+ protocolId,
356
+ projectId: project.id,
357
+ eventType: "merge_bundle.completed_protocol",
358
+ payload: { protocol_id: protocolId, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
359
+ });
360
+ }
361
+ appendAudit(context, {
362
+ projectId: project.id,
363
+ eventType: "merge_bundle.completed",
364
+ payload: { project_id: project.id, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
365
+ });
366
+ context.db.exec("COMMIT");
367
+ }
368
+ catch (error) {
369
+ context.db.exec("ROLLBACK");
370
+ throw error;
371
+ }
372
+ const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
373
+ return mergeBundleResult(context, {
374
+ ok: true,
375
+ outcome: "merged",
376
+ merged: true,
377
+ projectRoot: project.root,
378
+ workspacePath: input.workspacePath,
379
+ workerId: input.workerId,
380
+ protocolIds,
381
+ stop_after_current
382
+ });
383
+ }
254
384
  export function noteMergeJob(context, input) {
255
385
  const protocol = requireProtocol(context, input.protocolId);
256
386
  const job = queueJobByProtocol(context, protocol.id);
@@ -319,6 +449,72 @@ export function failMergeJob(context, input) {
319
449
  stop_after_current
320
450
  });
321
451
  }
452
+ export function failMergeBundle(context, input) {
453
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
454
+ requireLaneLockOwner(context, {
455
+ projectRoot: project.root,
456
+ lane: "merge",
457
+ workerId: input.workerId,
458
+ workspacePath: input.workspacePath
459
+ });
460
+ const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
461
+ if (protocolIds.length === 0) {
462
+ throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
463
+ worker_id: input.workerId,
464
+ workspace_path: input.workspacePath
465
+ });
466
+ }
467
+ const now = context.now();
468
+ const status = input.requeue ? "requeued" : "failed";
469
+ context.db.exec("BEGIN IMMEDIATE");
470
+ try {
471
+ for (const protocolId of protocolIds) {
472
+ const protocol = requireProtocol(context, protocolId);
473
+ const job = requireClaimedJob(context, protocolId, input.workerId);
474
+ context.db.run(`UPDATE merge_queue
475
+ SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
476
+ WHERE id = ?`, [status, input.reason, now, job.id]);
477
+ if (input.requeue) {
478
+ const state = readProtocolRuntimeState(context, protocol).state;
479
+ persistProtocolState(context, protocol, {
480
+ ...state,
481
+ stage: "ready_for_merge",
482
+ status: "running",
483
+ next_action: "merge_requeued",
484
+ updated_at: now
485
+ });
486
+ }
487
+ appendAudit(context, {
488
+ protocolId,
489
+ projectId: project.id,
490
+ eventType: "merge_bundle.failed_protocol",
491
+ reason: input.reason,
492
+ payload: { protocol_id: protocolId, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
493
+ });
494
+ }
495
+ appendAudit(context, {
496
+ projectId: project.id,
497
+ eventType: "merge_bundle.failed",
498
+ reason: input.reason,
499
+ payload: { project_id: project.id, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
500
+ });
501
+ context.db.exec("COMMIT");
502
+ }
503
+ catch (error) {
504
+ context.db.exec("ROLLBACK");
505
+ throw error;
506
+ }
507
+ const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
508
+ return mergeBundleResult(context, {
509
+ ok: true,
510
+ outcome: input.requeue ? "requeued" : "failed",
511
+ projectRoot: project.root,
512
+ workspacePath: input.workspacePath,
513
+ workerId: input.workerId,
514
+ protocolIds,
515
+ stop_after_current
516
+ });
517
+ }
322
518
  export function cancelMergeQueueJob(context, input) {
323
519
  const protocol = requireProtocol(context, input.protocolId);
324
520
  const reason = input.reason.trim();
@@ -407,6 +603,23 @@ function mergeQueueResult(job, extra) {
407
603
  job: queueItem
408
604
  };
409
605
  }
606
+ function mergeBundleResult(context, input) {
607
+ const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
608
+ return {
609
+ ...input,
610
+ protocol_ids: input.protocolIds,
611
+ branch_context: branchContext,
612
+ queue_items: input.protocolIds.map((protocolId) => queueJobByProtocol(context, protocolId) ?? null)
613
+ };
614
+ }
615
+ function claimedBundleProtocolIds(context, input) {
616
+ const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
617
+ const protocols = Array.isArray(branchContext.protocols) ? branchContext.protocols : [];
618
+ return protocols
619
+ .filter((protocol) => protocol.queue_status === "claimed" && protocol.queue_claimed_by === input.workerId)
620
+ .map((protocol) => String(protocol.id))
621
+ .filter(Boolean);
622
+ }
410
623
  function requireClaimedJob(context, protocolId, sessionId) {
411
624
  const job = queueJobByProtocol(context, protocolId);
412
625
  if (!job || job.status !== "claimed") {
@@ -7,13 +7,17 @@ import { requireProjectByRoot } from "./projects.js";
7
7
  import { buildStaticFlowGuidance } from "./flow-guidance.js";
8
8
  import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
9
  import { registerFlowSession, stopMergeWorker } from "./sessions.js";
10
+ import { getProtocolBranchContext } from "./branch-context.js";
10
11
  export function getMergeWorkerStatus(context, input) {
11
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
12
13
  return {
13
14
  ok: true,
14
15
  project: { id: project.id, root: project.root },
15
16
  merge_worker: detectMergeWorkerState(context, project.id),
16
- queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job }))
17
+ queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job })),
18
+ branch_context: input.workspacePath
19
+ ? getProtocolBranchContext(context, { projectRoot: project.root, workspacePath: input.workspacePath }).branch_context
20
+ : null
17
21
  };
18
22
  }
19
23
  export function startMergeWorker(context, input) {
@@ -29,7 +33,8 @@ export function startMergeWorker(context, input) {
29
33
  outcome: "status_only",
30
34
  reason: "merge_worker_already_active",
31
35
  merge_worker: detection,
32
- queue: queueForProject(context, project.id)
36
+ queue: queueForProject(context, project.id),
37
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
33
38
  };
34
39
  }
35
40
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
@@ -98,7 +103,8 @@ export function oneShotMergeClaim(context, input) {
98
103
  outcome: "status_only",
99
104
  reason: "active_merge_worker_or_lock",
100
105
  merge_worker: detection,
101
- queue: queueForProject(context, project.id)
106
+ queue: queueForProject(context, project.id),
107
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
102
108
  };
103
109
  }
104
110
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
@@ -143,6 +149,7 @@ export function oneShotMergeClaim(context, input) {
143
149
  : null,
144
150
  job: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
145
151
  lock: claimed.job ? lock.lock : null,
152
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context,
146
153
  flow_guidance: claimed.job ? withJobGuidance(context, { ...claimed.job }).flow_guidance : undefined
147
154
  };
148
155
  }
@@ -15,6 +15,7 @@ import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } f
15
15
  import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
16
16
  import { buildProtocolFlowGuidance } from "./flow-guidance.js";
17
17
  import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
18
+ import { getProtocolBranchContext } from "./branch-context.js";
18
19
  export function registerProtocol(context, input) {
19
20
  const projectRoot = resolveProjectRoot(input.projectRoot);
20
21
  const project = requireProjectByRoot(context, projectRoot);
@@ -99,6 +100,7 @@ export function getProtocolStatus(context, input) {
99
100
  state,
100
101
  plan: plan ? summarizePlan(plan) : state.plan,
101
102
  merge_queue: queue,
103
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
102
104
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
103
105
  worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]),
104
106
  hook_status: hookStatusForProject(context, protocol.project_id),
@@ -203,6 +205,7 @@ export function implementProtocol(context, input) {
203
205
  blockers,
204
206
  active_sessions: activeSessions,
205
207
  merge_queue: queue ?? null,
208
+ branch_context: protocol ? getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context : null,
206
209
  lifecycle,
207
210
  terminal,
208
211
  related_context: document.related_context,
@@ -306,6 +309,7 @@ export function readyForMerge(context, input) {
306
309
  protocol_id: protocol.id,
307
310
  queue_status: existingQueueJob.status,
308
311
  state,
312
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
309
313
  lifecycle: normalizeProtocolLifecycle({ state, queueStatus: existingQueueJob.status }),
310
314
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: existingQueueJob.status })
311
315
  };
@@ -358,6 +362,7 @@ export function readyForMerge(context, input) {
358
362
  protocol_id: protocol.id,
359
363
  queue_status: "ready",
360
364
  state: nextState,
365
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
361
366
  lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: "ready" }),
362
367
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
363
368
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {