@deksden-com/dd-flow-cli 0.2.0 → 0.3.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/README.md +2 -0
- package/dist/build-info.json +10 -6
- package/dist/cli/help.js +70 -13
- package/dist/cli/run-cli.js +140 -10
- package/dist/schemas/compatibility.schema.json +26 -0
- package/dist/schemas/flow-guidance.schema.json +73 -0
- package/dist/schemas/flow-run-index.schema.json +30 -4
- package/dist/schemas/global-dashboard-data.schema.json +68 -0
- package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
- package/dist/schemas/plan-stage-report.schema.json +83 -0
- package/dist/schemas/project-dashboard-data.schema.json +98 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
- package/dist/schemas/protocol-dashboard-data.schema.json +89 -0
- package/dist/schemas/status-report.schema.json +34 -0
- package/dist/schemas/version-report.schema.json +22 -0
- package/dist/services/build-info.js +26 -3
- package/dist/services/canon.js +93 -22
- package/dist/services/cleanup.js +14 -1
- package/dist/services/config.js +25 -0
- package/dist/services/dashboard.js +655 -10
- package/dist/services/flow-guidance.js +214 -0
- package/dist/services/ids.js +106 -0
- package/dist/services/merge-queue.js +9 -1
- package/dist/services/merge-worker.js +31 -2
- package/dist/services/projects.js +7 -1
- package/dist/services/protocols.js +637 -6
- package/dist/services/runs.js +98 -21
- package/dist/services/schema-validation.js +84 -4
- package/dist/services/status.js +189 -1
- package/dist/services/version-status.js +28 -2
- package/dist/storage/database.js +6 -0
- package/dist/storage/paths.js +21 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
4
5
|
import { canTransition } from "../domain/contracts.js";
|
|
5
6
|
import { defaultFlowContract, flowContractForState, loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
6
7
|
import { requireStage } from "../domain/validation.js";
|
|
@@ -12,6 +13,7 @@ import { appendAudit, getAuditEvents } from "./audit.js";
|
|
|
12
13
|
import { requireProjectByRoot } from "./projects.js";
|
|
13
14
|
import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } from "../protocol/local-files.js";
|
|
14
15
|
import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
|
|
16
|
+
import { buildProtocolFlowGuidance } from "./flow-guidance.js";
|
|
15
17
|
export function registerProtocol(context, input) {
|
|
16
18
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
17
19
|
const project = requireProjectByRoot(context, projectRoot);
|
|
@@ -84,13 +86,17 @@ export function getProtocolStatus(context, input) {
|
|
|
84
86
|
const runtime = readProtocolRuntimeState(context, protocol);
|
|
85
87
|
const state = runtime.state;
|
|
86
88
|
const plan = readPlanFile(protocol.plan_path);
|
|
89
|
+
const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
|
|
90
|
+
const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
|
|
87
91
|
return {
|
|
88
92
|
ok: true,
|
|
89
93
|
protocol: protocolStatusPayload(protocol),
|
|
90
|
-
diagnostics: runtime.diagnostics,
|
|
94
|
+
diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics],
|
|
95
|
+
latest_run: runDiagnostics.latest_run,
|
|
91
96
|
state,
|
|
92
97
|
plan: plan ? summarizePlan(plan) : state.plan,
|
|
93
|
-
merge_queue:
|
|
98
|
+
merge_queue: queue,
|
|
99
|
+
flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
|
|
94
100
|
worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]),
|
|
95
101
|
hook_status: hookStatusForProject(context, protocol.project_id),
|
|
96
102
|
codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
|
|
@@ -100,6 +106,131 @@ export function getProtocolStatus(context, input) {
|
|
|
100
106
|
audit: getAuditEvents(context, protocol.id)
|
|
101
107
|
};
|
|
102
108
|
}
|
|
109
|
+
export function getReadyProtocols(context, input) {
|
|
110
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
111
|
+
const protocols = protocolDocumentsForProject(project.root).map((document) => protocolSetMemberStatus(context, project.id, document));
|
|
112
|
+
const boards = protocolSetBoardsForProject(context, project.id, project.root);
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
project_root: project.root,
|
|
116
|
+
protocols,
|
|
117
|
+
ready: protocols.filter((item) => item.set_status === "ready").map((item) => item.id),
|
|
118
|
+
blocked: protocols.filter((item) => item.set_status === "blocked").map((item) => item.id),
|
|
119
|
+
running: protocols.filter((item) => item.set_status === "running" || item.set_status === "claimed").map((item) => item.id),
|
|
120
|
+
done: protocols.filter((item) => item.set_status === "done").map((item) => item.id),
|
|
121
|
+
protocol_set_boards: boards
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function getProtocolBlockers(context, input) {
|
|
125
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
126
|
+
const document = requireProtocolDocument(project.root, input.protocolId);
|
|
127
|
+
const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
project_root: project.root,
|
|
131
|
+
protocol_id: document.id,
|
|
132
|
+
protocol_path: document.path,
|
|
133
|
+
protocol_set: document.protocol_set,
|
|
134
|
+
blocked_by_protocols: document.blocked_by_protocols,
|
|
135
|
+
blockers,
|
|
136
|
+
blocked: blockers.some((blocker) => !blocker.resolved)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function implementProtocol(context, input) {
|
|
140
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
141
|
+
const document = requireProtocolDocument(project.root, input.protocolId);
|
|
142
|
+
const protocol = findProtocol(context, document.id);
|
|
143
|
+
const runtime = protocol ? readProtocolRuntimeState(context, protocol) : null;
|
|
144
|
+
const state = runtime?.state ?? null;
|
|
145
|
+
const terminal = protocolTerminalStatus(context, project.id, project.root, document.id, state);
|
|
146
|
+
const activeSessions = activeFlowSessionBindingsForProject(context, project.id).filter((session) => session.protocol_id === document.id);
|
|
147
|
+
const queue = context.db.get("SELECT status, claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [document.id]);
|
|
148
|
+
const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
|
|
149
|
+
const unresolved = blockers.filter((blocker) => !blocker.resolved);
|
|
150
|
+
const diagnostics = [
|
|
151
|
+
...(runtime?.diagnostics ?? []),
|
|
152
|
+
...(protocol ? protocolRunDiagnostics(context, protocol, state ?? undefined).diagnostics : []),
|
|
153
|
+
...protocolDocumentDiagnostics(document)
|
|
154
|
+
];
|
|
155
|
+
if (terminal.terminal) {
|
|
156
|
+
throw new AppError("protocol_terminal", "Protocol is terminal and must not be implemented again", 1, {
|
|
157
|
+
protocol_id: document.id,
|
|
158
|
+
terminal
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (unresolved.length > 0 && !input.force) {
|
|
162
|
+
throw new AppError("protocol_blocked", "Protocol is blocked by unresolved protocols; use --force --reason to override", 1, {
|
|
163
|
+
protocol_id: document.id,
|
|
164
|
+
blockers: unresolved
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (activeSessions.length > 0 && !input.force) {
|
|
168
|
+
throw new AppError("protocol_claimed", "Protocol already has an active flow session; use --force --reason to override", 1, {
|
|
169
|
+
protocol_id: document.id,
|
|
170
|
+
active_sessions: activeSessions
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (input.force && (!input.reason || input.reason.trim().length === 0)) {
|
|
174
|
+
throw new AppError("validation", "Forced protocol implement preflight requires --reason", 2);
|
|
175
|
+
}
|
|
176
|
+
const currentStage = state?.stage ?? "unregistered";
|
|
177
|
+
const recommendedPrompt = promptForImplementationStage(currentStage);
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
mode: "preflight",
|
|
181
|
+
protocol_id: document.id,
|
|
182
|
+
project_root: project.root,
|
|
183
|
+
protocol_path: document.path,
|
|
184
|
+
protocol_set: document.protocol_set,
|
|
185
|
+
force: input.force,
|
|
186
|
+
reason: input.reason ?? null,
|
|
187
|
+
runtime: protocol
|
|
188
|
+
? {
|
|
189
|
+
registered: true,
|
|
190
|
+
status: protocol.status,
|
|
191
|
+
stage: protocol.stage,
|
|
192
|
+
next_action: protocol.next_action,
|
|
193
|
+
state_path: protocol.state_path,
|
|
194
|
+
plan_path: protocol.plan_path
|
|
195
|
+
}
|
|
196
|
+
: { registered: false, status: "missing", stage: "unregistered", next_action: "register_protocol" },
|
|
197
|
+
blockers,
|
|
198
|
+
active_sessions: activeSessions,
|
|
199
|
+
merge_queue: queue ?? null,
|
|
200
|
+
terminal,
|
|
201
|
+
related_context: document.related_context,
|
|
202
|
+
coding_standards_sources: document.coding_standards_sources,
|
|
203
|
+
diagnostics,
|
|
204
|
+
flow_guidance: {
|
|
205
|
+
current_stage: currentStage,
|
|
206
|
+
recommended_next_action: protocol ? recommendedNextActionForImplementationStage(currentStage) : "register_protocol",
|
|
207
|
+
recommended_prompt: recommendedPrompt,
|
|
208
|
+
required_predecessor_evidence: currentStage === "unregistered" ? [`dd-flow protocol register ${document.id} --project-root ${JSON.stringify(project.root)} --json`] : [],
|
|
209
|
+
guards: [
|
|
210
|
+
{
|
|
211
|
+
id: "protocol_blockers_resolved",
|
|
212
|
+
status: unresolved.length === 0 || input.force ? "pass" : "fail",
|
|
213
|
+
summary: unresolved.length === 0
|
|
214
|
+
? "No unresolved blocked_by_protocols dependencies."
|
|
215
|
+
: input.force
|
|
216
|
+
? "Unresolved blockers were force-overridden."
|
|
217
|
+
: "Protocol has unresolved blocked_by_protocols dependencies."
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: "protocol_not_terminal",
|
|
221
|
+
status: terminal.terminal ? "fail" : "pass",
|
|
222
|
+
summary: terminal.terminal ? `Protocol is terminal via ${terminal.source}.` : "Protocol is not terminal."
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "protocol_not_claimed",
|
|
226
|
+
status: activeSessions.length === 0 || input.force ? "pass" : "fail",
|
|
227
|
+
summary: activeSessions.length === 0 ? "No active flow sessions claim this protocol." : "Active flow session already claims this protocol."
|
|
228
|
+
}
|
|
229
|
+
],
|
|
230
|
+
blocked_if_missing: protocol ? [] : ["runtime protocol registration"]
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
103
234
|
export function transitionProtocol(context, input) {
|
|
104
235
|
const protocol = requireProtocol(context, input.protocolId);
|
|
105
236
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
@@ -134,13 +265,24 @@ export function transitionProtocol(context, input) {
|
|
|
134
265
|
...(input.reason ? { reason: input.reason } : {}),
|
|
135
266
|
payload: { from, to, payload, flow_contract_id: flowContract.id, flow_contract_version: flowContract.version }
|
|
136
267
|
});
|
|
137
|
-
|
|
268
|
+
const runDiagnostics = protocolRunDiagnostics(context, protocol, nextState);
|
|
269
|
+
const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
protocol_id: protocol.id,
|
|
273
|
+
from,
|
|
274
|
+
to,
|
|
275
|
+
forced: input.force,
|
|
276
|
+
state: nextState,
|
|
277
|
+
flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null })
|
|
278
|
+
};
|
|
138
279
|
}
|
|
139
280
|
export function readyForMerge(context, input) {
|
|
140
281
|
const protocol = requireProtocol(context, input.protocolId);
|
|
141
282
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
142
283
|
const flowContract = flowContractForState(state);
|
|
143
284
|
const stage = requireStage(state.stage, flowContract);
|
|
285
|
+
const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
|
|
144
286
|
const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
|
|
145
287
|
protocol.id
|
|
146
288
|
]);
|
|
@@ -151,13 +293,22 @@ export function readyForMerge(context, input) {
|
|
|
151
293
|
eventType: "protocol.ready_for_merge_unchanged",
|
|
152
294
|
payload: { protocol_id: protocol.id, queue_status: existingQueueJob.status, flow_contract_id: flowContract.id }
|
|
153
295
|
});
|
|
154
|
-
return {
|
|
296
|
+
return {
|
|
297
|
+
ok: true,
|
|
298
|
+
protocol_id: protocol.id,
|
|
299
|
+
queue_status: existingQueueJob.status,
|
|
300
|
+
state,
|
|
301
|
+
flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: existingQueueJob.status })
|
|
302
|
+
};
|
|
155
303
|
}
|
|
156
304
|
if (!flowContract.readiness.allowed_from.includes(stage)) {
|
|
157
305
|
throw new AppError("readiness_invalid_stage", "Protocol state does not allow ready-for-merge", 1, {
|
|
158
306
|
stage: state.stage,
|
|
159
307
|
allowed: flowContract.readiness.allowed_from,
|
|
160
|
-
flow_contract_id: flowContract.id
|
|
308
|
+
flow_contract_id: flowContract.id,
|
|
309
|
+
latest_run: runDiagnostics.latest_run,
|
|
310
|
+
diagnostics: runDiagnostics.diagnostics,
|
|
311
|
+
recommended_commands: recommendedSyncCommands(protocol, runDiagnostics.latest_run)
|
|
161
312
|
});
|
|
162
313
|
}
|
|
163
314
|
const missing = readinessMissingFields(state);
|
|
@@ -193,7 +344,102 @@ export function readyForMerge(context, input) {
|
|
|
193
344
|
eventType: "protocol.ready_for_merge",
|
|
194
345
|
payload: { protocol_id: protocol.id, queue_status: "ready", flow_contract_id: flowContract.id }
|
|
195
346
|
});
|
|
196
|
-
return {
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
protocol_id: protocol.id,
|
|
350
|
+
queue_status: "ready",
|
|
351
|
+
state: nextState,
|
|
352
|
+
flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
export function syncProtocolFromRun(context, input) {
|
|
356
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
357
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
358
|
+
const run = resolveLinkedRun(context, protocol, input.runId);
|
|
359
|
+
if (run.subject_type !== "protocol" || run.subject_id !== protocol.id) {
|
|
360
|
+
throw new AppError("protocol_run_subject_mismatch", "Run subject does not match protocol", 1, {
|
|
361
|
+
protocol_id: protocol.id,
|
|
362
|
+
run_id: run.id,
|
|
363
|
+
subject: { type: run.subject_type, id: run.subject_id }
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const index = parseLinkedRunIndex(run.index_json, run.id);
|
|
367
|
+
const flowContract = flowContractForState(state);
|
|
368
|
+
const targetStage = input.target === "auto" ? inferSyncTarget(run, index) : requireStage(input.target, flowContract);
|
|
369
|
+
if (!targetStage) {
|
|
370
|
+
throw new AppError("sync_target_ambiguous", "Cannot infer protocol sync target from run evidence", 1, {
|
|
371
|
+
protocol_id: protocol.id,
|
|
372
|
+
run: linkedRunSummary(run),
|
|
373
|
+
stage_chain: stageChain(index),
|
|
374
|
+
allowed_targets: Object.keys(flowContract.stages)
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
requireStage(targetStage, flowContract);
|
|
378
|
+
const now = context.now();
|
|
379
|
+
const nextState = {
|
|
380
|
+
...state,
|
|
381
|
+
stage: targetStage,
|
|
382
|
+
status: statusForStage(targetStage, flowContract),
|
|
383
|
+
next_action: nextActionForSyncedTarget(targetStage),
|
|
384
|
+
updated_at: now
|
|
385
|
+
};
|
|
386
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
387
|
+
try {
|
|
388
|
+
persistProtocolState(context, protocol, nextState);
|
|
389
|
+
if (targetStage === "ready_for_merge") {
|
|
390
|
+
context.db.run(`INSERT INTO merge_queue
|
|
391
|
+
(protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
|
|
392
|
+
VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
|
|
393
|
+
ON CONFLICT(protocol_id) DO UPDATE SET
|
|
394
|
+
status = CASE WHEN merge_queue.status IN ('merged', 'cancelled', 'failed') THEN merge_queue.status ELSE excluded.status END,
|
|
395
|
+
updated_at = excluded.updated_at`, [protocol.id, protocol.project_id, "ready", now, now]);
|
|
396
|
+
}
|
|
397
|
+
if (targetStage === "closed") {
|
|
398
|
+
context.db.run(`UPDATE merge_queue
|
|
399
|
+
SET status = CASE WHEN status IN ('merged', 'cancelled', 'failed') THEN status ELSE 'merged' END,
|
|
400
|
+
last_reason = COALESCE(last_reason, ?),
|
|
401
|
+
completed_at = COALESCE(completed_at, ?),
|
|
402
|
+
updated_at = ?
|
|
403
|
+
WHERE protocol_id = ?`, [`synced from ${run.id}`, now, now, protocol.id]);
|
|
404
|
+
}
|
|
405
|
+
appendAudit(context, {
|
|
406
|
+
protocolId: protocol.id,
|
|
407
|
+
projectId: protocol.project_id,
|
|
408
|
+
eventType: "protocol.synced_from_run",
|
|
409
|
+
payload: {
|
|
410
|
+
protocol_id: protocol.id,
|
|
411
|
+
run_id: run.id,
|
|
412
|
+
from: state.stage,
|
|
413
|
+
to: targetStage,
|
|
414
|
+
target: input.target,
|
|
415
|
+
run: linkedRunSummary(run),
|
|
416
|
+
stage_chain: stageChain(index)
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
context.db.exec("COMMIT");
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
context.db.exec("ROLLBACK");
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
protocol_id: protocol.id,
|
|
428
|
+
run_id: run.id,
|
|
429
|
+
from: state.stage,
|
|
430
|
+
to: targetStage,
|
|
431
|
+
state: nextState,
|
|
432
|
+
flow_guidance: buildProtocolFlowGuidance({
|
|
433
|
+
state: nextState,
|
|
434
|
+
latestRun: { ...linkedRunSummary(run), stage_chain: stageChain(index) },
|
|
435
|
+
queueStatus: targetStage === "ready_for_merge" ? "ready" : null
|
|
436
|
+
}),
|
|
437
|
+
evidence_used: {
|
|
438
|
+
run: linkedRunSummary(run),
|
|
439
|
+
stage_chain: stageChain(index)
|
|
440
|
+
},
|
|
441
|
+
diagnostics: protocolRunDiagnostics(context, protocol, nextState).diagnostics
|
|
442
|
+
};
|
|
197
443
|
}
|
|
198
444
|
export function cancelProtocol(context, input) {
|
|
199
445
|
const protocol = requireProtocol(context, input.protocolId);
|
|
@@ -508,6 +754,33 @@ export function readProtocolRuntimeState(context, protocol) {
|
|
|
508
754
|
});
|
|
509
755
|
return { state, diagnostics };
|
|
510
756
|
}
|
|
757
|
+
export function protocolRunDiagnostics(context, protocol, state) {
|
|
758
|
+
const runs = linkedRunsForProtocol(context, protocol, 5);
|
|
759
|
+
const latest = runs[0];
|
|
760
|
+
if (!latest) {
|
|
761
|
+
return { latest_run: null, diagnostics: [] };
|
|
762
|
+
}
|
|
763
|
+
const warnings = [];
|
|
764
|
+
const latestRun = linkedRunSummary(latest);
|
|
765
|
+
const latestIndex = safeLinkedRunIndex(latest.index_json, latest.id, warnings);
|
|
766
|
+
const latestWithStages = {
|
|
767
|
+
...latestRun,
|
|
768
|
+
stage_chain: latestIndex ? stageChain(latestIndex) : []
|
|
769
|
+
};
|
|
770
|
+
const diagnostics = [...warnings];
|
|
771
|
+
if (state && isProtocolRunStageMismatch(state.stage, latest, latestIndex)) {
|
|
772
|
+
diagnostics.push({
|
|
773
|
+
code: "protocol_run_stage_mismatch",
|
|
774
|
+
severity: mismatchSeverity(latest, latestIndex),
|
|
775
|
+
protocol_id: protocol.id,
|
|
776
|
+
protocol_stage: state.stage,
|
|
777
|
+
protocol_status: state.status,
|
|
778
|
+
latest_run: latestWithStages,
|
|
779
|
+
recommended_commands: recommendedSyncCommands(protocol, latestWithStages)
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return { latest_run: latestWithStages, diagnostics };
|
|
783
|
+
}
|
|
511
784
|
export function persistProtocolState(context, protocol, state) {
|
|
512
785
|
ensureDir(path.dirname(protocol.state_path));
|
|
513
786
|
writeState(protocol.state_path, state);
|
|
@@ -537,6 +810,260 @@ export function persistProtocolState(context, protocol, state) {
|
|
|
537
810
|
function findProtocol(context, protocolId) {
|
|
538
811
|
return context.db.get("SELECT * FROM protocols WHERE id = ?", [protocolId]);
|
|
539
812
|
}
|
|
813
|
+
function protocolDocumentsForProject(projectRoot) {
|
|
814
|
+
const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol");
|
|
815
|
+
if (!fs.existsSync(protocolRoot))
|
|
816
|
+
return [];
|
|
817
|
+
return fs
|
|
818
|
+
.readdirSync(protocolRoot, { withFileTypes: true })
|
|
819
|
+
.filter((entry) => entry.isFile() && entry.name.startsWith("PRT-") && entry.name.endsWith(".md"))
|
|
820
|
+
.map((entry) => readProtocolDocument(path.join(protocolRoot, entry.name)))
|
|
821
|
+
.filter((document) => Boolean(document))
|
|
822
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
823
|
+
}
|
|
824
|
+
function requireProtocolDocument(projectRoot, protocolId) {
|
|
825
|
+
const file = path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
|
|
826
|
+
if (!fs.existsSync(file)) {
|
|
827
|
+
throw new AppError("protocol_file_not_found", `Protocol markdown is not found: ${protocolId}`, 1, {
|
|
828
|
+
protocol_id: protocolId,
|
|
829
|
+
expected_path: file
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
const document = readProtocolDocument(file);
|
|
833
|
+
if (!document) {
|
|
834
|
+
throw new AppError("protocol_frontmatter_invalid", `Protocol markdown frontmatter is invalid: ${protocolId}`, 1, {
|
|
835
|
+
protocol_id: protocolId,
|
|
836
|
+
path: file
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return document;
|
|
840
|
+
}
|
|
841
|
+
function readProtocolDocument(file) {
|
|
842
|
+
const text = fs.readFileSync(file, "utf8");
|
|
843
|
+
const match = text.match(/^---\n([\s\S]*?)\n---/);
|
|
844
|
+
let frontmatter = {};
|
|
845
|
+
if (match?.[1]) {
|
|
846
|
+
try {
|
|
847
|
+
const parsed = parseYaml(match[1]);
|
|
848
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
849
|
+
frontmatter = parsed;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const basename = path.basename(file, ".md");
|
|
857
|
+
const fileField = stringValue(frontmatter.file);
|
|
858
|
+
const id = basename;
|
|
859
|
+
const protocolSet = stringValue(frontmatter.protocol_set);
|
|
860
|
+
const relatedContext = {};
|
|
861
|
+
for (const key of [
|
|
862
|
+
"related_epics",
|
|
863
|
+
"related_features",
|
|
864
|
+
"related_specs",
|
|
865
|
+
"related_adrs",
|
|
866
|
+
"related_scenarios",
|
|
867
|
+
"source_user_input",
|
|
868
|
+
"related_files"
|
|
869
|
+
]) {
|
|
870
|
+
const value = frontmatter[key];
|
|
871
|
+
if (Array.isArray(value))
|
|
872
|
+
relatedContext[key] = value.filter((item) => typeof item === "string");
|
|
873
|
+
}
|
|
874
|
+
if (fileField)
|
|
875
|
+
relatedContext.file = fileField;
|
|
876
|
+
return {
|
|
877
|
+
id,
|
|
878
|
+
path: file,
|
|
879
|
+
frontmatter,
|
|
880
|
+
protocol_set: protocolSet,
|
|
881
|
+
blocked_by_protocols: stringArray(frontmatter.blocked_by_protocols),
|
|
882
|
+
related_context: relatedContext,
|
|
883
|
+
coding_standards_sources: codingStandardsSources(projectRootFromProtocolFile(file), frontmatter)
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
function protocolDocumentDiagnostics(document) {
|
|
887
|
+
const diagnostics = [];
|
|
888
|
+
if (document.protocol_set) {
|
|
889
|
+
const setPath = path.isAbsolute(document.protocol_set)
|
|
890
|
+
? document.protocol_set
|
|
891
|
+
: path.join(projectRootFromProtocolFile(document.path), document.protocol_set);
|
|
892
|
+
if (!fs.existsSync(setPath)) {
|
|
893
|
+
diagnostics.push({
|
|
894
|
+
code: "protocol_set_file_missing",
|
|
895
|
+
severity: "warning",
|
|
896
|
+
protocol_id: document.id,
|
|
897
|
+
protocol_set: document.protocol_set,
|
|
898
|
+
expected_path: setPath
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return diagnostics;
|
|
903
|
+
}
|
|
904
|
+
function protocolSetMemberStatus(context, projectId, document) {
|
|
905
|
+
const runtime = findProtocol(context, document.id);
|
|
906
|
+
const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
|
|
907
|
+
const queue = context.db.get("SELECT status, claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [document.id]);
|
|
908
|
+
const activeSessions = activeFlowSessionBindingsForProject(context, projectId).filter((session) => session.protocol_id === document.id);
|
|
909
|
+
const projectRoot = projectRootFromProtocolFile(document.path);
|
|
910
|
+
const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, projectId, projectRoot, id));
|
|
911
|
+
const terminal = protocolTerminalStatus(context, projectId, projectRoot, document.id, state);
|
|
912
|
+
const setStatus = terminal.success
|
|
913
|
+
? "done"
|
|
914
|
+
: queue?.status === "claimed" || activeSessions.length > 0
|
|
915
|
+
? "claimed"
|
|
916
|
+
: state && ["implementation", "readiness", "plan", "specify", "integration", "queued_for_merge", "ready_for_merge"].includes(state.stage)
|
|
917
|
+
? "running"
|
|
918
|
+
: blockers.some((blocker) => !blocker.resolved)
|
|
919
|
+
? "blocked"
|
|
920
|
+
: "ready";
|
|
921
|
+
return {
|
|
922
|
+
id: document.id,
|
|
923
|
+
path: document.path,
|
|
924
|
+
protocol_set: document.protocol_set,
|
|
925
|
+
set_status: setStatus,
|
|
926
|
+
blocked_by_protocols: document.blocked_by_protocols,
|
|
927
|
+
blockers,
|
|
928
|
+
runtime: runtime
|
|
929
|
+
? { registered: true, status: runtime.status, stage: runtime.stage, next_action: runtime.next_action, updated_at: runtime.updated_at }
|
|
930
|
+
: { registered: false, status: "missing", stage: "unregistered", next_action: "register_protocol" },
|
|
931
|
+
active_sessions: activeSessions,
|
|
932
|
+
merge_queue: queue ?? null,
|
|
933
|
+
terminal
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
export function protocolSetBoardsForProject(context, projectId, projectRoot) {
|
|
937
|
+
const members = protocolDocumentsForProject(projectRoot)
|
|
938
|
+
.filter((document) => document.protocol_set)
|
|
939
|
+
.map((document) => protocolSetMemberStatus(context, projectId, document));
|
|
940
|
+
const setIds = [...new Set(members.map((member) => member.protocol_set).filter((value) => typeof value === "string"))];
|
|
941
|
+
return setIds.map((setId) => {
|
|
942
|
+
const setMembers = members.filter((member) => member.protocol_set === setId);
|
|
943
|
+
return {
|
|
944
|
+
protocol_set: setId,
|
|
945
|
+
summary: {
|
|
946
|
+
ready: setMembers.filter((member) => member.set_status === "ready").length,
|
|
947
|
+
blocked: setMembers.filter((member) => member.set_status === "blocked").length,
|
|
948
|
+
running: setMembers.filter((member) => member.set_status === "running" || member.set_status === "claimed").length,
|
|
949
|
+
done: setMembers.filter((member) => member.set_status === "done").length,
|
|
950
|
+
total: setMembers.length
|
|
951
|
+
},
|
|
952
|
+
members: setMembers
|
|
953
|
+
};
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
export function protocolSetBoardForProtocol(context, projectId, projectRoot, protocolId) {
|
|
957
|
+
const protocolFile = path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
|
|
958
|
+
const document = fs.existsSync(protocolFile) ? readProtocolDocument(protocolFile) : null;
|
|
959
|
+
if (!document?.protocol_set)
|
|
960
|
+
return null;
|
|
961
|
+
return protocolSetBoardsForProject(context, projectId, projectRoot).find((board) => board.protocol_set === document.protocol_set) ?? null;
|
|
962
|
+
}
|
|
963
|
+
function blockerStatus(context, projectId, projectRoot, blockerId) {
|
|
964
|
+
const runtime = findProtocol(context, blockerId);
|
|
965
|
+
const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
|
|
966
|
+
const terminal = protocolTerminalStatus(context, projectId, projectRoot, blockerId, state);
|
|
967
|
+
return {
|
|
968
|
+
id: blockerId,
|
|
969
|
+
resolved: terminal.success,
|
|
970
|
+
source: terminal.source,
|
|
971
|
+
status: runtime?.status ?? terminal.status ?? "unknown",
|
|
972
|
+
stage: runtime?.stage ?? terminal.stage ?? "unknown",
|
|
973
|
+
lifecycle: terminal.lifecycle,
|
|
974
|
+
reason: terminal.reason
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
function protocolTerminalStatus(context, projectId, projectRoot, protocolId, state) {
|
|
978
|
+
if (state) {
|
|
979
|
+
if (state.stage === "closed" || state.status === "closed") {
|
|
980
|
+
return { terminal: true, success: true, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is closed" };
|
|
981
|
+
}
|
|
982
|
+
if (state.stage === "cancelled" || state.status === "cancelled") {
|
|
983
|
+
return { terminal: true, success: false, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is cancelled" };
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const queue = context.db.get("SELECT status FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [
|
|
987
|
+
projectId,
|
|
988
|
+
protocolId
|
|
989
|
+
]);
|
|
990
|
+
if (queue && ["merged"].includes(queue.status)) {
|
|
991
|
+
return {
|
|
992
|
+
terminal: true,
|
|
993
|
+
success: true,
|
|
994
|
+
source: "merge_queue",
|
|
995
|
+
status: queue.status,
|
|
996
|
+
...(state?.stage ? { stage: state.stage } : {}),
|
|
997
|
+
reason: "merge queue is merged"
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const protocolFile = path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
|
|
1001
|
+
const document = fs.existsSync(protocolFile) ? readProtocolDocument(protocolFile) : null;
|
|
1002
|
+
const lifecycle = document ? stringValue(document.frontmatter.protocol_lifecycle).toUpperCase() : "";
|
|
1003
|
+
if (["MERGED", "CLOSED", "DONE"].includes(lifecycle)) {
|
|
1004
|
+
return { terminal: true, success: true, source: "frontmatter", lifecycle, reason: `protocol_lifecycle is ${lifecycle}` };
|
|
1005
|
+
}
|
|
1006
|
+
if (["CANCELLED"].includes(lifecycle)) {
|
|
1007
|
+
return { terminal: true, success: false, source: "frontmatter", lifecycle, reason: `protocol_lifecycle is ${lifecycle}` };
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
terminal: false,
|
|
1011
|
+
success: false,
|
|
1012
|
+
source: state ? "runtime_state" : document ? "frontmatter" : "missing",
|
|
1013
|
+
...(state?.status ? { status: state.status } : {}),
|
|
1014
|
+
...(state?.stage ? { stage: state.stage } : {}),
|
|
1015
|
+
...(lifecycle ? { lifecycle } : {}),
|
|
1016
|
+
reason: state ? "protocol is not terminal" : document ? "protocol_lifecycle is not terminal success" : "protocol document/runtime not found"
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
function promptForImplementationStage(stage) {
|
|
1020
|
+
if (stage === "unregistered" || stage === "registered" || stage === "priming" || stage === "specify" || stage === "waiting_for_user") {
|
|
1021
|
+
return ".memory-bank/dd-flow/protocol-implement.md";
|
|
1022
|
+
}
|
|
1023
|
+
if (stage === "plan")
|
|
1024
|
+
return ".memory-bank/dd-flow/plan.md";
|
|
1025
|
+
if (["implementation", "readiness"].includes(stage))
|
|
1026
|
+
return ".memory-bank/dd-flow/code.md";
|
|
1027
|
+
if (["ready_for_merge", "queued_for_merge", "integration"].includes(stage))
|
|
1028
|
+
return ".memory-bank/dd-flow/merge.md";
|
|
1029
|
+
return ".memory-bank/dd-flow/protocol-implement.md";
|
|
1030
|
+
}
|
|
1031
|
+
function recommendedNextActionForImplementationStage(stage) {
|
|
1032
|
+
if (stage === "registered" || stage === "priming" || stage === "specify" || stage === "waiting_for_user")
|
|
1033
|
+
return "continue_specify";
|
|
1034
|
+
if (stage === "plan")
|
|
1035
|
+
return "run_plan_or_code_handoff";
|
|
1036
|
+
if (["implementation", "readiness"].includes(stage))
|
|
1037
|
+
return "run_code_flow";
|
|
1038
|
+
if (["ready_for_merge", "queued_for_merge", "integration"].includes(stage))
|
|
1039
|
+
return "run_merge_flow";
|
|
1040
|
+
if (stage === "unregistered")
|
|
1041
|
+
return "register_protocol";
|
|
1042
|
+
return `continue_${stage}`;
|
|
1043
|
+
}
|
|
1044
|
+
function codingStandardsSources(projectRoot, frontmatter) {
|
|
1045
|
+
const candidates = [
|
|
1046
|
+
".memory-bank/spec/engineering/coding-standards.md",
|
|
1047
|
+
".memory-bank/mbb/coding-standards-guide.md",
|
|
1048
|
+
".memory-bank/mbb/code-contracts-guide.md",
|
|
1049
|
+
"CONTRIBUTING.md",
|
|
1050
|
+
"README.md"
|
|
1051
|
+
];
|
|
1052
|
+
const related = [
|
|
1053
|
+
...stringArray(frontmatter.related_specs),
|
|
1054
|
+
...stringArray(frontmatter.related_files)
|
|
1055
|
+
].filter((item) => item.includes("coding") || item.includes("code-contract"));
|
|
1056
|
+
return [...new Set([...related, ...candidates])].filter((candidate) => fs.existsSync(path.isAbsolute(candidate) ? candidate : path.join(projectRoot, candidate)));
|
|
1057
|
+
}
|
|
1058
|
+
function projectRootFromProtocolFile(file) {
|
|
1059
|
+
return path.dirname(path.dirname(path.dirname(file)));
|
|
1060
|
+
}
|
|
1061
|
+
function stringValue(value) {
|
|
1062
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : "";
|
|
1063
|
+
}
|
|
1064
|
+
function stringArray(value) {
|
|
1065
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
|
|
1066
|
+
}
|
|
540
1067
|
function inferProtocolId(projectRoot, handshakeId) {
|
|
541
1068
|
const directDir = path.join(projectRoot, ".memory-bank", "protocol", handshakeId);
|
|
542
1069
|
if (fs.existsSync(directDir)) {
|
|
@@ -604,3 +1131,107 @@ function readinessMissingFields(state) {
|
|
|
604
1131
|
}
|
|
605
1132
|
return missing;
|
|
606
1133
|
}
|
|
1134
|
+
function linkedRunsForProtocol(context, protocol, limit) {
|
|
1135
|
+
return context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1136
|
+
FROM flow_runs
|
|
1137
|
+
WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
|
|
1138
|
+
ORDER BY updated_at DESC, id DESC
|
|
1139
|
+
LIMIT ?`, [protocol.project_id, protocol.id, limit]);
|
|
1140
|
+
}
|
|
1141
|
+
function resolveLinkedRun(context, protocol, runIdOrAlias) {
|
|
1142
|
+
const matches = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1143
|
+
FROM flow_runs
|
|
1144
|
+
WHERE project_id = ? AND (id = ? OR short_id = ?)
|
|
1145
|
+
ORDER BY updated_at DESC, id DESC`, [protocol.project_id, runIdOrAlias, runIdOrAlias]);
|
|
1146
|
+
if (matches.length === 1)
|
|
1147
|
+
return matches[0];
|
|
1148
|
+
if (matches.length > 1) {
|
|
1149
|
+
throw new AppError("ambiguous_alias", `Run alias is ambiguous: ${runIdOrAlias}`, 1, {
|
|
1150
|
+
candidates: matches.map(linkedRunSummary)
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
throw new AppError("not_found", `Run is not registered: ${runIdOrAlias}`, 1, { run_id: runIdOrAlias });
|
|
1154
|
+
}
|
|
1155
|
+
function parseLinkedRunIndex(text, runId) {
|
|
1156
|
+
const value = JSON.parse(text);
|
|
1157
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1158
|
+
throw new AppError("validation", "Run index is invalid", 2, { run_id: runId });
|
|
1159
|
+
}
|
|
1160
|
+
return value;
|
|
1161
|
+
}
|
|
1162
|
+
function safeLinkedRunIndex(text, runId, diagnostics) {
|
|
1163
|
+
try {
|
|
1164
|
+
return parseLinkedRunIndex(text, runId);
|
|
1165
|
+
}
|
|
1166
|
+
catch (error) {
|
|
1167
|
+
diagnostics.push({
|
|
1168
|
+
code: "run_index_invalid",
|
|
1169
|
+
severity: "warning",
|
|
1170
|
+
run_id: runId,
|
|
1171
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1172
|
+
});
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
function linkedRunSummary(run) {
|
|
1177
|
+
return {
|
|
1178
|
+
id: run.id,
|
|
1179
|
+
short_id: run.short_id,
|
|
1180
|
+
flow_kind: run.flow_kind,
|
|
1181
|
+
status: run.status,
|
|
1182
|
+
verdict: run.verdict,
|
|
1183
|
+
next_action: run.next_action,
|
|
1184
|
+
run_index_path: run.run_index_path,
|
|
1185
|
+
updated_at: run.updated_at,
|
|
1186
|
+
completed_at: run.completed_at
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
function stageChain(index) {
|
|
1190
|
+
return (Array.isArray(index.stage_runs) ? index.stage_runs : [])
|
|
1191
|
+
.map((stage) => `${String(stage.stage ?? "unknown")}:${String(stage.status ?? "unknown")}`);
|
|
1192
|
+
}
|
|
1193
|
+
function hasDoneStage(index, names) {
|
|
1194
|
+
return Boolean(index?.stage_runs?.some((stage) => typeof stage.stage === "string" && names.includes(stage.stage) && stage.status === "done"));
|
|
1195
|
+
}
|
|
1196
|
+
function inferSyncTarget(run, index) {
|
|
1197
|
+
if (hasDoneStage(index, ["merge"]) || run.verdict === "merged")
|
|
1198
|
+
return "closed";
|
|
1199
|
+
if (hasDoneStage(index, ["code", "readiness", "implementation"]))
|
|
1200
|
+
return "ready_for_merge";
|
|
1201
|
+
if (hasDoneStage(index, ["plan"]))
|
|
1202
|
+
return "implementation";
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
function nextActionForSyncedTarget(targetStage) {
|
|
1206
|
+
if (targetStage === "closed")
|
|
1207
|
+
return "none";
|
|
1208
|
+
if (targetStage === "ready_for_merge")
|
|
1209
|
+
return "run_merge_flow";
|
|
1210
|
+
if (targetStage === "implementation")
|
|
1211
|
+
return "run_code_flow";
|
|
1212
|
+
if (targetStage === "readiness")
|
|
1213
|
+
return "run_readiness_gate";
|
|
1214
|
+
return `continue_${targetStage}`;
|
|
1215
|
+
}
|
|
1216
|
+
function isProtocolRunStageMismatch(stage, run, index) {
|
|
1217
|
+
if (["closed", "cancelled"].includes(stage))
|
|
1218
|
+
return false;
|
|
1219
|
+
const mergeDone = hasDoneStage(index, ["merge"]) || run.verdict === "merged";
|
|
1220
|
+
if (mergeDone && stage !== "closed")
|
|
1221
|
+
return true;
|
|
1222
|
+
const codeDone = hasDoneStage(index, ["code", "readiness", "implementation"]);
|
|
1223
|
+
if (codeDone && ["registered", "priming", "prime", "specify", "plan", "implementation"].includes(stage))
|
|
1224
|
+
return true;
|
|
1225
|
+
const planDone = hasDoneStage(index, ["plan"]);
|
|
1226
|
+
return planDone && ["registered", "priming", "prime", "specify"].includes(stage);
|
|
1227
|
+
}
|
|
1228
|
+
function mismatchSeverity(run, index) {
|
|
1229
|
+
return hasDoneStage(index, ["merge"]) || run.verdict === "merged" ? "error" : "warning";
|
|
1230
|
+
}
|
|
1231
|
+
function recommendedSyncCommands(protocol, latestRun) {
|
|
1232
|
+
if (!latestRun || typeof latestRun.id !== "string")
|
|
1233
|
+
return [];
|
|
1234
|
+
return [
|
|
1235
|
+
`dd-flow protocol sync-from-run ${protocol.id} --run ${latestRun.id} --target auto --project-root ${JSON.stringify(protocol.project_root)} --json`
|
|
1236
|
+
];
|
|
1237
|
+
}
|