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