@deksden-com/dd-flow-cli 0.1.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 (35) hide show
  1. package/README.md +274 -0
  2. package/dist/cli/help.js +308 -0
  3. package/dist/cli/run-cli.js +945 -0
  4. package/dist/cli.js +4 -0
  5. package/dist/domain/contracts.js +57 -0
  6. package/dist/domain/entity-ids.js +47 -0
  7. package/dist/domain/flow-contract.js +233 -0
  8. package/dist/domain/validation.js +91 -0
  9. package/dist/protocol/local-files.js +141 -0
  10. package/dist/runtime/context.js +11 -0
  11. package/dist/schemas/code-stage-report.schema.json +181 -0
  12. package/dist/schemas/flow-run-index.schema.json +129 -0
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
  15. package/dist/schemas/merge-stage-report.schema.json +135 -0
  16. package/dist/services/audit.js +19 -0
  17. package/dist/services/cleanup.js +310 -0
  18. package/dist/services/config.js +143 -0
  19. package/dist/services/dashboard.js +436 -0
  20. package/dist/services/hooks.js +929 -0
  21. package/dist/services/lanes.js +327 -0
  22. package/dist/services/memory-permissions.js +344 -0
  23. package/dist/services/merge-queue.js +333 -0
  24. package/dist/services/plans.js +149 -0
  25. package/dist/services/projects.js +286 -0
  26. package/dist/services/protocols.js +606 -0
  27. package/dist/services/runs.js +359 -0
  28. package/dist/services/schema-validation.js +185 -0
  29. package/dist/services/sessions.js +365 -0
  30. package/dist/services/worktrees.js +204 -0
  31. package/dist/shared/errors.js +14 -0
  32. package/dist/shared/json.js +17 -0
  33. package/dist/storage/database.js +325 -0
  34. package/dist/storage/paths.js +56 -0
  35. package/package.json +44 -0
@@ -0,0 +1,606 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { canTransition } from "../domain/contracts.js";
5
+ import { defaultFlowContract, flowContractForState, loadProjectFlowContract } from "../domain/flow-contract.js";
6
+ import { requireStage } from "../domain/validation.js";
7
+ import { AppError } from "../shared/errors.js";
8
+ import { parseJsonObject } from "../shared/json.js";
9
+ import { ensureReadableFile } from "../storage/database.js";
10
+ import { ensureDir, resolveProjectRoot, runtimePlanJsonPath, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
11
+ import { appendAudit, getAuditEvents } from "./audit.js";
12
+ import { requireProjectByRoot } from "./projects.js";
13
+ import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } from "../protocol/local-files.js";
14
+ import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
15
+ export function registerProtocol(context, input) {
16
+ const projectRoot = resolveProjectRoot(input.projectRoot);
17
+ const project = requireProjectByRoot(context, projectRoot);
18
+ const now = context.now();
19
+ const workspacePath = input.workspacePath ? resolveProjectRoot(input.workspacePath) : undefined;
20
+ const idRoot = workspacePath ?? projectRoot;
21
+ const protocolId = inferProtocolId(idRoot, input.handshakeId);
22
+ const runtimeDir = runtimeProtocolDir(context.ddFlowHome, project.id, protocolId);
23
+ const state = ensureRuntimeProtocolFiles({
24
+ runtimeDir,
25
+ protocolId,
26
+ projectRoot,
27
+ handshakeId: input.handshakeId,
28
+ workspacePath,
29
+ now
30
+ });
31
+ const existing = findProtocol(context, protocolId);
32
+ const params = [
33
+ protocolId,
34
+ input.handshakeId,
35
+ project.id,
36
+ projectRoot,
37
+ state.status,
38
+ state.stage,
39
+ state.next_action,
40
+ JSON.stringify(state.route),
41
+ JSON.stringify(state.workspace),
42
+ JSON.stringify(state.blockers),
43
+ JSON.stringify(state.active_def),
44
+ runtimeStateJsonPath(context.ddFlowHome, project.id, protocolId),
45
+ runtimePlanJsonPath(context.ddFlowHome, project.id, protocolId),
46
+ existing?.created_at ?? now,
47
+ now
48
+ ];
49
+ context.db.run(`INSERT INTO protocols
50
+ (id, handshake_id, project_id, project_root, status, stage, next_action,
51
+ route_json, workspace_json, blockers_json, active_def_json, state_path, plan_path,
52
+ created_at, updated_at)
53
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
54
+ ON CONFLICT(id) DO UPDATE SET
55
+ handshake_id = excluded.handshake_id,
56
+ project_id = excluded.project_id,
57
+ project_root = excluded.project_root,
58
+ status = excluded.status,
59
+ stage = excluded.stage,
60
+ next_action = excluded.next_action,
61
+ route_json = excluded.route_json,
62
+ workspace_json = excluded.workspace_json,
63
+ blockers_json = excluded.blockers_json,
64
+ active_def_json = excluded.active_def_json,
65
+ state_path = excluded.state_path,
66
+ plan_path = excluded.plan_path,
67
+ updated_at = excluded.updated_at`, params);
68
+ appendAudit(context, {
69
+ protocolId,
70
+ projectId: project.id,
71
+ eventType: existing ? "protocol.registration_refreshed" : "protocol.registered",
72
+ payload: {
73
+ protocol_id: protocolId,
74
+ handshake_id: input.handshakeId,
75
+ project_root: projectRoot,
76
+ workspace_path: workspacePath ?? null,
77
+ runtime_dir: runtimeDir
78
+ }
79
+ });
80
+ return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId)), state };
81
+ }
82
+ export function getProtocolStatus(context, input) {
83
+ const protocol = requireProtocol(context, input.protocolId);
84
+ const runtime = readProtocolRuntimeState(context, protocol);
85
+ const state = runtime.state;
86
+ const plan = readPlanFile(protocol.plan_path);
87
+ return {
88
+ ok: true,
89
+ protocol: protocolStatusPayload(protocol),
90
+ diagnostics: runtime.diagnostics,
91
+ state,
92
+ plan: plan ? summarizePlan(plan) : state.plan,
93
+ merge_queue: context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]),
94
+ worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]),
95
+ hook_status: hookStatusForProject(context, protocol.project_id),
96
+ codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
97
+ flow_sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
98
+ codex_session_bindings: activeCodexSessionBindingsForProject(context, protocol.project_id).filter((binding) => binding.protocol_id === protocol.id),
99
+ codex_hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
100
+ audit: getAuditEvents(context, protocol.id)
101
+ };
102
+ }
103
+ export function transitionProtocol(context, input) {
104
+ const protocol = requireProtocol(context, input.protocolId);
105
+ const state = readProtocolRuntimeState(context, protocol).state;
106
+ const flowContract = flowContractForState(state);
107
+ const from = requireStage(state.stage, flowContract);
108
+ const to = requireStage(input.to, flowContract);
109
+ if (!input.force && !canTransition(from, to, flowContract)) {
110
+ throw new AppError("invalid_transition", `Transition ${from} -> ${to} requires --force --reason`, 1);
111
+ }
112
+ if (input.force && (!input.reason || input.reason.trim().length === 0)) {
113
+ throw new AppError("validation", "Forced transitions require --reason", 2);
114
+ }
115
+ ensureReadableFile(input.jsonFile);
116
+ const payload = parseJsonObject(fs.readFileSync(input.jsonFile, "utf8"), input.jsonFile);
117
+ const nextState = {
118
+ ...state,
119
+ stage: to,
120
+ status: statusForStage(to, flowContract),
121
+ next_action: typeof payload.next_action === "string" ? payload.next_action : state.next_action,
122
+ route: objectOrExisting(payload.route, state.route),
123
+ workspace: objectOrExisting(payload.workspace, state.workspace),
124
+ blockers: Array.isArray(payload.blockers) ? payload.blockers : state.blockers,
125
+ active_def: Array.isArray(payload.active_def) ? payload.active_def : state.active_def,
126
+ updated_at: context.now()
127
+ };
128
+ persistProtocolState(context, protocol, nextState);
129
+ appendAudit(context, {
130
+ protocolId: protocol.id,
131
+ projectId: protocol.project_id,
132
+ eventType: "protocol.transition",
133
+ forced: input.force,
134
+ ...(input.reason ? { reason: input.reason } : {}),
135
+ payload: { from, to, payload, flow_contract_id: flowContract.id, flow_contract_version: flowContract.version }
136
+ });
137
+ return { ok: true, protocol_id: protocol.id, from, to, forced: input.force, state: nextState };
138
+ }
139
+ export function readyForMerge(context, input) {
140
+ const protocol = requireProtocol(context, input.protocolId);
141
+ const state = readProtocolRuntimeState(context, protocol).state;
142
+ const flowContract = flowContractForState(state);
143
+ const stage = requireStage(state.stage, flowContract);
144
+ if (!flowContract.readiness.allowed_from.includes(stage)) {
145
+ throw new AppError("readiness_invalid_stage", "Protocol state does not allow ready-for-merge", 1, {
146
+ stage: state.stage,
147
+ allowed: flowContract.readiness.allowed_from,
148
+ flow_contract_id: flowContract.id
149
+ });
150
+ }
151
+ const missing = readinessMissingFields(state);
152
+ if (missing.length > 0) {
153
+ throw new AppError("readiness_missing_fields", "Protocol is missing explicit readiness fields", 1, { missing });
154
+ }
155
+ if (state.active_def.length > 0) {
156
+ throw new AppError("readiness_blocked", "Protocol has open deferrals", 1, { active_def: state.active_def });
157
+ }
158
+ const plan = readPlanFile(protocol.plan_path);
159
+ const openRequired = plan?.items.filter((item) => item.required && !["done", "skipped"].includes(item.status)) ?? [];
160
+ if (openRequired.length > 0) {
161
+ throw new AppError("readiness_plan_open", "Protocol has open required plan items", 1, {
162
+ items: openRequired.map((item) => ({ id: item.id, status: item.status }))
163
+ });
164
+ }
165
+ const now = context.now();
166
+ const nextStage = flowContract.readiness.target_stage;
167
+ const nextState = { ...state, stage: nextStage, status: statusForStage(nextStage, flowContract), updated_at: now };
168
+ persistProtocolState(context, protocol, nextState);
169
+ const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
170
+ protocol.id
171
+ ]);
172
+ if (existingQueueJob && ["claimed", "merged", "cancelled", "failed"].includes(existingQueueJob.status)) {
173
+ appendAudit(context, {
174
+ protocolId: protocol.id,
175
+ projectId: protocol.project_id,
176
+ eventType: "protocol.ready_for_merge_unchanged",
177
+ payload: { protocol_id: protocol.id, queue_status: existingQueueJob.status, flow_contract_id: flowContract.id }
178
+ });
179
+ return { ok: true, protocol_id: protocol.id, queue_status: existingQueueJob.status, state: nextState };
180
+ }
181
+ context.db.run(`INSERT INTO merge_queue
182
+ (protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
183
+ VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
184
+ ON CONFLICT(protocol_id) DO UPDATE SET
185
+ status = excluded.status,
186
+ claimed_by_session_id = NULL,
187
+ claimed_at = NULL,
188
+ completed_at = NULL,
189
+ updated_at = excluded.updated_at`, [protocol.id, protocol.project_id, "ready", now, now]);
190
+ appendAudit(context, {
191
+ protocolId: protocol.id,
192
+ projectId: protocol.project_id,
193
+ eventType: "protocol.ready_for_merge",
194
+ payload: { protocol_id: protocol.id, queue_status: "ready", flow_contract_id: flowContract.id }
195
+ });
196
+ return { ok: true, protocol_id: protocol.id, queue_status: "ready", state: nextState };
197
+ }
198
+ export function cancelProtocol(context, input) {
199
+ const protocol = requireProtocol(context, input.protocolId);
200
+ const reason = input.reason.trim();
201
+ if (!reason) {
202
+ throw new AppError("validation", "protocol cancel requires --reason", 2);
203
+ }
204
+ const state = readProtocolRuntimeState(context, protocol).state;
205
+ const now = context.now();
206
+ const worktree = context.db.get("SELECT worktree_path, status FROM worktree_records WHERE protocol_id = ?", [protocol.id]);
207
+ const worktreeRemoval = input.worktree === "remove" ? removeProtocolWorktreeBeforeCancel(protocol, state, worktree, input.force) : undefined;
208
+ context.db.exec("BEGIN IMMEDIATE");
209
+ try {
210
+ const nextState = {
211
+ ...state,
212
+ stage: "cancelled",
213
+ status: "cancelled",
214
+ next_action: "cancelled",
215
+ updated_at: now
216
+ };
217
+ persistProtocolState(context, protocol, nextState);
218
+ let cancelledQueue = { ok: true, skipped: true, reason: "option_disabled" };
219
+ if (input.cancelQueue) {
220
+ cancelledQueue = cancelQueueForProtocol(context, protocol, reason, input.force);
221
+ }
222
+ let closedSessions = 0;
223
+ if (input.closeSessions) {
224
+ const result = context.db.run(`UPDATE flow_sessions
225
+ SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
226
+ WHERE project_id = ?
227
+ AND protocol_id = ?
228
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, protocol.project_id, protocol.id]);
229
+ closedSessions = Number(result.changes);
230
+ }
231
+ const lockRelease = input.releaseLocks
232
+ ? releaseRelatedMergeLocks(context, protocol, reason)
233
+ : { ok: true, skipped: true, reason: "option_disabled" };
234
+ let worktreeOutcome = { ok: true, skipped: true, reason: input.worktree === "keep" ? "keep_requested" : "no_active_worktree" };
235
+ if (input.worktree === "remove") {
236
+ const nextWorktreeStatus = worktreeRemoval?.status ?? "removed";
237
+ const result = worktree
238
+ ? context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ?
239
+ WHERE protocol_id = ? AND status = 'active'`, [nextWorktreeStatus, now, now, protocol.id])
240
+ : { changes: 0 };
241
+ worktreeOutcome = {
242
+ ok: true,
243
+ changed: result.changes === 1,
244
+ status: nextWorktreeStatus,
245
+ reason: worktreeRemoval?.reason ?? "path_absent_or_no_record",
246
+ source: worktreeRemoval?.source ?? "none",
247
+ path: worktreeRemoval?.path ?? null,
248
+ physical_removed: worktreeRemoval?.physical_removed ?? false,
249
+ branch: worktreeRemoval?.branch ?? { ok: true, skipped: true, reason: "no_feature_branch" }
250
+ };
251
+ }
252
+ appendAudit(context, {
253
+ protocolId: protocol.id,
254
+ projectId: protocol.project_id,
255
+ eventType: "protocol.cancelled",
256
+ reason,
257
+ forced: input.force,
258
+ payload: {
259
+ protocol_id: protocol.id,
260
+ close_sessions: input.closeSessions,
261
+ cancel_queue: input.cancelQueue,
262
+ release_locks: input.releaseLocks,
263
+ worktree: input.worktree,
264
+ closed_sessions: closedSessions,
265
+ queue: cancelledQueue,
266
+ lock_release: lockRelease,
267
+ worktree_outcome: worktreeOutcome
268
+ }
269
+ });
270
+ context.db.exec("COMMIT");
271
+ return {
272
+ ok: true,
273
+ protocol_id: protocol.id,
274
+ state: nextState,
275
+ queue: cancelledQueue,
276
+ closed_sessions: closedSessions,
277
+ lock_release: lockRelease,
278
+ worktree_outcome: worktreeOutcome,
279
+ worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]) ?? null
280
+ };
281
+ }
282
+ catch (error) {
283
+ context.db.exec("ROLLBACK");
284
+ throw error;
285
+ }
286
+ }
287
+ export function requireProtocol(context, protocolId) {
288
+ const protocol = findProtocol(context, protocolId);
289
+ if (!protocol) {
290
+ throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1);
291
+ }
292
+ return protocol;
293
+ }
294
+ function cancelQueueForProtocol(context, protocol, reason, force) {
295
+ const job = context.db.get("SELECT id, status, claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
296
+ if (!job) {
297
+ return { ok: true, skipped: true, reason: "no_queue_job" };
298
+ }
299
+ if (job.status === "cancelled") {
300
+ return { ok: true, cancelled: false, reason: "already_cancelled", job };
301
+ }
302
+ if (job.status === "claimed" && !force) {
303
+ throw new AppError("merge_job_claimed", "Claimed queue cancellation requires --force --reason", 1, {
304
+ protocol_id: protocol.id,
305
+ claimed_by_session_id: job.claimed_by_session_id
306
+ });
307
+ }
308
+ if (["merged", "failed"].includes(job.status) && !force) {
309
+ throw new AppError("merge_job_terminal", `Queue job is ${job.status}; cancellation requires --force --reason`, 1, {
310
+ protocol_id: protocol.id,
311
+ status: job.status
312
+ });
313
+ }
314
+ context.db.run("UPDATE merge_queue SET status = 'cancelled', last_reason = ?, updated_at = ? WHERE id = ?", [
315
+ reason,
316
+ context.now(),
317
+ job.id
318
+ ]);
319
+ appendAudit(context, {
320
+ protocolId: protocol.id,
321
+ projectId: protocol.project_id,
322
+ eventType: "merge_queue.cancelled",
323
+ reason,
324
+ forced: force,
325
+ payload: { protocol_id: protocol.id, from: job.status, source: "protocol_cancel" }
326
+ });
327
+ return { ok: true, cancelled: true, from: job.status };
328
+ }
329
+ function removeProtocolWorktreeBeforeCancel(protocol, state, record, force) {
330
+ const stateWorkspace = state.workspace;
331
+ const rawPath = record?.worktree_path || (typeof stateWorkspace.worktree_path === "string" ? stateWorkspace.worktree_path : "");
332
+ const source = record?.worktree_path ? "record" : "state";
333
+ if (!rawPath) {
334
+ return {
335
+ status: "removed",
336
+ reason: "path_absent_or_no_record",
337
+ source: "state",
338
+ path: "",
339
+ physical_removed: false,
340
+ branch: { ok: true, skipped: true, reason: "no_feature_branch" }
341
+ };
342
+ }
343
+ const worktreePath = path.resolve(rawPath);
344
+ const projectRoot = path.resolve(protocol.project_root);
345
+ if (worktreePath === projectRoot) {
346
+ throw new AppError("worktree_is_project_root", "Refusing to remove the stable project root as a feature worktree", 1, {
347
+ path: worktreePath,
348
+ project_root: projectRoot
349
+ });
350
+ }
351
+ const cwd = path.resolve(process.cwd());
352
+ if (cwd === worktreePath || cwd.startsWith(`${worktreePath}${path.sep}`)) {
353
+ throw new AppError("worktree_current_cwd", "Refusing to remove the current process working directory", 1, {
354
+ path: worktreePath,
355
+ cwd
356
+ });
357
+ }
358
+ const existsBefore = fs.existsSync(worktreePath);
359
+ if (existsBefore && !force) {
360
+ const dirty = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { encoding: "utf8" });
361
+ if (dirty.status === 0 && dirty.stdout.trim().length > 0) {
362
+ throw new AppError("worktree_dirty", "Refusing to remove dirty worktree without --force --reason", 1, {
363
+ path: worktreePath
364
+ });
365
+ }
366
+ }
367
+ let physicalRemoved = false;
368
+ if (existsBefore) {
369
+ const args = ["-C", projectRoot, "worktree", "remove"];
370
+ if (force)
371
+ args.push("--force");
372
+ args.push(worktreePath);
373
+ const removed = spawnSync("git", args, { encoding: "utf8" });
374
+ if (removed.status !== 0) {
375
+ throw new AppError("worktree_remove_failed", "Git failed to remove the feature worktree", 1, {
376
+ path: worktreePath,
377
+ exit_code: removed.status,
378
+ stderr: removed.stderr.trim()
379
+ });
380
+ }
381
+ physicalRemoved = !fs.existsSync(worktreePath);
382
+ }
383
+ const featureBranch = record?.feature_branch ||
384
+ (typeof stateWorkspace.feature_branch === "string" && stateWorkspace.feature_branch.trim()
385
+ ? stateWorkspace.feature_branch.trim()
386
+ : "");
387
+ const branch = removeLocalFeatureBranch(projectRoot, featureBranch, force);
388
+ const existsAfter = fs.existsSync(worktreePath);
389
+ return {
390
+ status: existsAfter ? "kept" : "removed",
391
+ reason: existsAfter ? "physical_checkout_kept" : existsBefore ? "physical_checkout_removed" : "path_absent",
392
+ source,
393
+ path: worktreePath,
394
+ physical_removed: physicalRemoved,
395
+ branch
396
+ };
397
+ }
398
+ function removeLocalFeatureBranch(projectRoot, branch, force) {
399
+ if (!branch)
400
+ return { ok: true, skipped: true, reason: "no_feature_branch" };
401
+ const listed = spawnSync("git", ["-C", projectRoot, "branch", "--list", branch], { encoding: "utf8" });
402
+ if (listed.status !== 0 || listed.stdout.trim().length === 0) {
403
+ return { ok: true, skipped: true, reason: "branch_absent", branch };
404
+ }
405
+ const deleted = spawnSync("git", ["-C", projectRoot, "branch", force ? "-D" : "-d", branch], { encoding: "utf8" });
406
+ if (deleted.status !== 0) {
407
+ return {
408
+ ok: false,
409
+ skipped: true,
410
+ reason: force ? "branch_delete_failed" : "branch_not_merged",
411
+ branch,
412
+ stderr: deleted.stderr.trim()
413
+ };
414
+ }
415
+ return { ok: true, skipped: false, reason: force ? "branch_force_deleted" : "branch_deleted", branch };
416
+ }
417
+ function releaseRelatedMergeLocks(context, protocol, reason) {
418
+ const workers = context.db.all(`SELECT DISTINCT worker_id FROM flow_sessions
419
+ WHERE project_id = ? AND protocol_id = ? AND worker_id IS NOT NULL`, [protocol.project_id, protocol.id]).map((row) => row.worker_id).filter((workerId) => Boolean(workerId));
420
+ const job = context.db.get("SELECT claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
421
+ if (job?.claimed_by_session_id) {
422
+ workers.push(job.claimed_by_session_id);
423
+ }
424
+ const uniqueWorkers = [...new Set(workers)];
425
+ const released = [];
426
+ const now = context.now();
427
+ for (const workerId of uniqueWorkers) {
428
+ const lock = context.db.get(`SELECT id, worker_id FROM lane_locks
429
+ WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND worker_id = ?
430
+ ORDER BY updated_at DESC, id DESC LIMIT 1`, [protocol.project_id, workerId]);
431
+ if (!lock) {
432
+ continue;
433
+ }
434
+ context.db.run(`UPDATE lane_locks SET status = 'released', released_at = ?, reason = ?, updated_at = ? WHERE id = ?`, [now, reason, now, lock.id]);
435
+ appendAudit(context, {
436
+ projectId: protocol.project_id,
437
+ eventType: "lane_lock.released",
438
+ reason,
439
+ payload: { project_id: protocol.project_id, lane: "merge", worker_id: workerId, lock_id: lock.id, source: "protocol_cancel" }
440
+ });
441
+ released.push({ lock_id: lock.id, worker_id: workerId });
442
+ }
443
+ return { ok: true, released: released.length, locks: released };
444
+ }
445
+ export function readProtocolRuntimeState(context, protocol) {
446
+ const diagnostics = [];
447
+ try {
448
+ return { state: readStateFile(protocol.state_path, protocol.id), diagnostics };
449
+ }
450
+ catch {
451
+ const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
452
+ if (protocol.state_path !== stableStatePath && fs.existsSync(stableStatePath)) {
453
+ diagnostics.push({
454
+ code: "runtime_state_relocated",
455
+ severity: "warning",
456
+ old_path: protocol.state_path,
457
+ path: stableStatePath,
458
+ source: "stable_runtime_state",
459
+ recommended_action: "legacy protocol state_path was moved to stable runtime storage"
460
+ });
461
+ const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
462
+ context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE id = ?", [stableStatePath, stablePlanPath, protocol.id]);
463
+ protocol.state_path = stableStatePath;
464
+ protocol.plan_path = stablePlanPath;
465
+ return { state: readStateFile(stableStatePath, protocol.id), diagnostics };
466
+ }
467
+ diagnostics.push({
468
+ code: "runtime_state_missing",
469
+ severity: "warning",
470
+ path: protocol.state_path,
471
+ source: "protocols.state_path",
472
+ recommended_action: "runtime state reconstructed from SQLite; run cleanup scan/apply if this persists"
473
+ });
474
+ }
475
+ const plan = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocol.id]);
476
+ const planSummary = plan
477
+ ? planSummaryForState(JSON.parse(plan.plan_json))
478
+ : { plan_id: null, total: 0, done: 0, blocked: 0 };
479
+ const flowContract = loadProjectFlowContract(protocol.project_root);
480
+ const state = {
481
+ schema_version: "0.1.0",
482
+ protocol_id: protocol.id,
483
+ project_root: protocol.project_root,
484
+ status: protocol.status,
485
+ stage: requireStage(protocol.stage, flowContract),
486
+ next_action: protocol.next_action,
487
+ route: JSON.parse(protocol.route_json),
488
+ workspace: JSON.parse(protocol.workspace_json),
489
+ plan: planSummary,
490
+ blockers: JSON.parse(protocol.blockers_json),
491
+ active_def: JSON.parse(protocol.active_def_json),
492
+ flow_contract: flowContract,
493
+ updated_at: protocol.updated_at
494
+ };
495
+ const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
496
+ const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
497
+ ensureDir(path.dirname(stableStatePath));
498
+ writeState(stableStatePath, state);
499
+ context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE id = ?", [stableStatePath, stablePlanPath, protocol.id]);
500
+ const repairedFrom = protocol.state_path;
501
+ protocol.state_path = stableStatePath;
502
+ protocol.plan_path = stablePlanPath;
503
+ appendAudit(context, {
504
+ protocolId: protocol.id,
505
+ projectId: protocol.project_id,
506
+ eventType: "protocol.runtime_state_repaired",
507
+ payload: { protocol_id: protocol.id, path: stableStatePath, repaired_from: repairedFrom, source: "sqlite" }
508
+ });
509
+ return { state, diagnostics };
510
+ }
511
+ export function persistProtocolState(context, protocol, state) {
512
+ ensureDir(path.dirname(protocol.state_path));
513
+ writeState(protocol.state_path, state);
514
+ context.db.run(`UPDATE protocols SET
515
+ status = ?, stage = ?, next_action = ?, route_json = ?, workspace_json = ?,
516
+ blockers_json = ?, active_def_json = ?, updated_at = ?
517
+ WHERE id = ?`, [
518
+ state.status,
519
+ state.stage,
520
+ state.next_action,
521
+ JSON.stringify(state.route),
522
+ JSON.stringify(state.workspace),
523
+ JSON.stringify(state.blockers),
524
+ JSON.stringify(state.active_def),
525
+ state.updated_at,
526
+ protocol.id
527
+ ]);
528
+ if (state.status === "closed" || state.stage === "closed") {
529
+ const now = context.now();
530
+ context.db.run(`UPDATE flow_sessions
531
+ SET status = 'stopped', stop_reason = 'protocol closed', updated_at = ?, stopped_at = ?
532
+ WHERE project_id = ?
533
+ AND protocol_id = ?
534
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [now, now, protocol.project_id, protocol.id]);
535
+ }
536
+ }
537
+ function findProtocol(context, protocolId) {
538
+ return context.db.get("SELECT * FROM protocols WHERE id = ?", [protocolId]);
539
+ }
540
+ function inferProtocolId(projectRoot, handshakeId) {
541
+ const directDir = path.join(projectRoot, ".memory-bank", "protocol", handshakeId);
542
+ if (fs.existsSync(directDir)) {
543
+ return handshakeId;
544
+ }
545
+ if (handshakeId.startsWith("PRT-")) {
546
+ return handshakeId;
547
+ }
548
+ return `PRT-${handshakeId.replace(/[^a-zA-Z0-9_-]+/g, "-")}`;
549
+ }
550
+ function statusForStage(stage, flowContract = defaultFlowContract) {
551
+ if (stage === "blocked" ||
552
+ stage === "waiting_for_user" ||
553
+ stage === "closed" ||
554
+ stage === "cancelled" ||
555
+ stage === "ready_for_merge" ||
556
+ flowContract.stages[stage]?.terminal) {
557
+ return stage;
558
+ }
559
+ return "running";
560
+ }
561
+ function objectOrExisting(value, existing) {
562
+ return value && typeof value === "object" && !Array.isArray(value) ? value : existing;
563
+ }
564
+ function protocolStatusPayload(protocol) {
565
+ return {
566
+ id: protocol.id,
567
+ handshake_id: protocol.handshake_id,
568
+ project_id: protocol.project_id,
569
+ project_root: protocol.project_root,
570
+ status: protocol.status,
571
+ stage: protocol.stage,
572
+ next_action: protocol.next_action,
573
+ route: JSON.parse(protocol.route_json),
574
+ workspace: JSON.parse(protocol.workspace_json),
575
+ state_path: protocol.state_path,
576
+ plan_path: protocol.plan_path,
577
+ updated_at: protocol.updated_at
578
+ };
579
+ }
580
+ function summarizePlan(plan) {
581
+ return planSummaryForState(plan);
582
+ }
583
+ function planSummaryForState(plan) {
584
+ return {
585
+ plan_id: plan.plan_id,
586
+ total: plan.items.length,
587
+ done: plan.items.filter((item) => item.status === "done").length,
588
+ blocked: plan.items.filter((item) => item.status === "blocked").length
589
+ };
590
+ }
591
+ function readinessMissingFields(state) {
592
+ const missing = [];
593
+ if (!state.protocol_id)
594
+ missing.push("protocol_id");
595
+ if (!state.project_root)
596
+ missing.push("project_root");
597
+ if (state.route.git === "feature_worktree") {
598
+ if (typeof state.workspace.feature_branch !== "string" || state.workspace.feature_branch.length === 0) {
599
+ missing.push("workspace.feature_branch");
600
+ }
601
+ if (typeof state.workspace.worktree_path !== "string" || state.workspace.worktree_path.length === 0) {
602
+ missing.push("workspace.worktree_path");
603
+ }
604
+ }
605
+ return missing;
606
+ }