@deksden-com/dd-flow-cli 0.3.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +74 -5
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +103 -15
  5. package/dist/cli/run-cli.js +307 -27
  6. package/dist/schemas/compatibility.schema.json +81 -2
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +17 -0
  9. package/dist/schemas/global-dashboard-data.schema.json +60 -2
  10. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  11. package/dist/schemas/project-dashboard-data.schema.json +26 -2
  12. package/dist/schemas/project-summary.schema.json +73 -0
  13. package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
  14. package/dist/schemas/status-report.schema.json +4 -2
  15. package/dist/services/cleanup.js +31 -0
  16. package/dist/services/cli-operation-classifier.js +104 -0
  17. package/dist/services/compatibility-preflight.js +124 -0
  18. package/dist/services/config.js +6 -0
  19. package/dist/services/dashboard-targets.js +95 -0
  20. package/dist/services/dashboard.js +376 -59
  21. package/dist/services/engines.js +532 -0
  22. package/dist/services/flow-guidance.js +8 -1
  23. package/dist/services/hooks.js +1 -1
  24. package/dist/services/lanes.js +333 -1
  25. package/dist/services/merge-queue.js +97 -15
  26. package/dist/services/merge-worker.js +36 -2
  27. package/dist/services/migrations.js +231 -0
  28. package/dist/services/project-summary.js +122 -0
  29. package/dist/services/projects.js +41 -6
  30. package/dist/services/protocol-lifecycle.js +144 -0
  31. package/dist/services/protocols.js +29 -7
  32. package/dist/services/sessions.js +21 -4
  33. package/dist/services/status.js +10 -0
  34. package/dist/services/version-status.js +39 -15
  35. package/dist/storage/database.js +25 -0
  36. package/dist/storage/paths.js +12 -0
  37. package/package.json +3 -2
@@ -0,0 +1,231 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ import { requireProjectByRoot } from "./projects.js";
5
+ import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
6
+ export function planMigration(context, input) {
7
+ const rootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
8
+ if (!rootResolution.root) {
9
+ throw new AppError("not_found", `Project root does not exist: ${input.projectRoot}`, 1);
10
+ }
11
+ const project = requireProjectByRoot(context, rootResolution.root);
12
+ const versionStatus = getProjectVersionStatus({
13
+ projectRoot: rootResolution.root,
14
+ rootSource: rootResolution.root_source
15
+ });
16
+ const sourceVersion = input.sourceVersion ?? versionStatus?.memory_bank.version ?? "unknown";
17
+ const targetVersion = input.targetVersion ?? versionStatus?.memory_bank.version ?? "unknown";
18
+ const chain = adjacentMigrationChain(sourceVersion, targetVersion);
19
+ const activeState = activeStateSummary(context, project.id);
20
+ const backup = backupEvidence(input);
21
+ const blockers = [
22
+ ...(chain.status === "unsupported" ? [`unsupported migration path: ${chain.reason}`] : []),
23
+ ...(backup.status === "missing" ? ["backup evidence is required before runtime/home migration"] : []),
24
+ ...(input.allowActive ? [] : activeStateBlockers(activeState))
25
+ ];
26
+ const status = blockers.length > 0 ? "blocked" : chain.steps.length === 0 ? "noop" : "ready";
27
+ return {
28
+ ok: true,
29
+ schema_id: "dd-flow/mb-upgrade-migration-report@1",
30
+ generated_at: context.now(),
31
+ project: {
32
+ id: project.id,
33
+ root: project.root,
34
+ memory_bank_version: versionStatus?.memory_bank.version ?? null
35
+ },
36
+ run: {
37
+ id: input.runId ?? null,
38
+ flow_kind: "mb-upgrade"
39
+ },
40
+ migration: {
41
+ mode: "mb-upgrade-only",
42
+ status,
43
+ source_memory_bank_version: sourceVersion,
44
+ target_memory_bank_version: targetVersion,
45
+ adjacent_only: true,
46
+ chain: chain.steps,
47
+ blocked_reasons: blockers
48
+ },
49
+ engine: {
50
+ router_version: null,
51
+ selected_engine_version: null,
52
+ source_engine_available: "unknown",
53
+ target_engine_available: "unknown"
54
+ },
55
+ backup,
56
+ active_state: activeState,
57
+ primary_data: [
58
+ "project registry records",
59
+ "protocol runtime",
60
+ "run indexes",
61
+ "queues/lanes/sessions/locks when their contracts change",
62
+ "flow-pack manifests and compatibility metadata"
63
+ ],
64
+ derived_artifacts: [
65
+ "project dashboard artifacts",
66
+ "global dashboard data/render outputs",
67
+ "project summary outputs after PRT-059",
68
+ "cached indexes that can be rebuilt"
69
+ ],
70
+ verification: {
71
+ post_upgrade_required: true,
72
+ commands: [
73
+ "dd-flow migration verify --file <report> --json",
74
+ "dd-flow dashboard refresh --project <project>",
75
+ "dd-flow status --project-root <project-root> --json"
76
+ ]
77
+ }
78
+ };
79
+ }
80
+ export function verifyMigrationReport(_context, input) {
81
+ const filePath = path.resolve(input.file);
82
+ const report = readJsonObject(filePath);
83
+ const schemaId = stringValue(report.schema_id);
84
+ if (schemaId !== "dd-flow/mb-upgrade-migration-report@1") {
85
+ throw new AppError("validation", "Migration report schema_id is not dd-flow/mb-upgrade-migration-report@1", 2, {
86
+ file: filePath,
87
+ schema_id: schemaId
88
+ });
89
+ }
90
+ const migration = recordValue(report.migration);
91
+ const backup = recordValue(report.backup);
92
+ const chain = Array.isArray(migration?.chain) ? migration.chain : [];
93
+ const backupStatus = stringValue(backup?.status);
94
+ const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
95
+ const errors = [];
96
+ if (!migration)
97
+ errors.push("migration block is required");
98
+ if (!backup)
99
+ errors.push("backup block is required");
100
+ if (backupStatus !== "present" && backupStatus !== "planned")
101
+ errors.push("backup.status must be present or planned");
102
+ if (migration?.adjacent_only !== true)
103
+ errors.push("migration.adjacent_only must be true");
104
+ if (chain.some((step) => !isAdjacentStep(step)))
105
+ errors.push("all migration.chain entries must be adjacent steps with from/to/status");
106
+ if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0) {
107
+ errors.push("ready migration report must not contain blocked reasons");
108
+ }
109
+ if (errors.length > 0) {
110
+ throw new AppError("migration_report_invalid", "Migration report is not acceptable", 2, { file: filePath, errors });
111
+ }
112
+ return {
113
+ ok: true,
114
+ file: filePath,
115
+ schema_id: schemaId,
116
+ verdict: blockedReasons.length > 0 ? "blocked_report_valid" : "accepted",
117
+ checks: {
118
+ backup: backupStatus,
119
+ adjacent_chain_steps: chain.length,
120
+ blocked_reasons: blockedReasons.length
121
+ }
122
+ };
123
+ }
124
+ function backupEvidence(input) {
125
+ if (!input.backupPath) {
126
+ return {
127
+ status: "missing",
128
+ path: null,
129
+ created_at: null,
130
+ rollback_route: "required before applying runtime/home migration"
131
+ };
132
+ }
133
+ return {
134
+ status: fs.existsSync(path.resolve(input.backupPath)) ? "present" : "planned",
135
+ path: path.resolve(input.backupPath),
136
+ created_at: input.backupCreatedAt ?? null,
137
+ rollback_route: "restore backup before retrying migration"
138
+ };
139
+ }
140
+ function adjacentMigrationChain(source, target) {
141
+ if (source === target)
142
+ return { status: "ok", steps: [] };
143
+ const sourceParts = parseSemver(source);
144
+ const targetParts = parseSemver(target);
145
+ if (!sourceParts || !targetParts) {
146
+ return { status: "unsupported", reason: "source or target version is not semver", steps: [] };
147
+ }
148
+ if (sourceParts.major !== targetParts.major) {
149
+ return { status: "unsupported", reason: "major version migration requires explicit future migration units", steps: [] };
150
+ }
151
+ if (targetParts.minor < sourceParts.minor || (targetParts.minor === sourceParts.minor && targetParts.patch < sourceParts.patch)) {
152
+ return { status: "unsupported", reason: "downgrade migrations are not supported", steps: [] };
153
+ }
154
+ const steps = [];
155
+ let current = { ...sourceParts };
156
+ while (compareParts(current, targetParts) < 0) {
157
+ const next = nextAdjacent(current, targetParts);
158
+ steps.push({
159
+ id: `${formatParts(current)}-to-${formatParts(next)}`,
160
+ from: formatParts(current),
161
+ to: formatParts(next),
162
+ status: "planned"
163
+ });
164
+ current = next;
165
+ }
166
+ return { status: "ok", steps };
167
+ }
168
+ function activeStateSummary(context, projectId) {
169
+ return {
170
+ protocols: context.db.all("SELECT id, status, stage FROM protocols WHERE project_id = ? AND status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC", [projectId]),
171
+ runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE project_id = ? AND status NOT IN ('done', 'cancelled', 'failed') ORDER BY updated_at DESC", [projectId]),
172
+ merge_queue: context.db.all("SELECT protocol_id, status FROM merge_queue WHERE project_id = ? AND status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC", [projectId]),
173
+ lane_locks: context.db.all("SELECT lane, worker_id, status FROM lane_locks WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC", [projectId])
174
+ };
175
+ }
176
+ function activeStateBlockers(active) {
177
+ const blockers = [];
178
+ if (active.protocols.length > 0)
179
+ blockers.push(`active protocols: ${active.protocols.map((item) => item.id).join(", ")}`);
180
+ if (active.runs.length > 0)
181
+ blockers.push(`active runs: ${active.runs.map((item) => item.id).join(", ")}`);
182
+ if (active.merge_queue.length > 0)
183
+ blockers.push(`active merge queue items: ${active.merge_queue.map((item) => item.protocol_id).join(", ")}`);
184
+ if (active.lane_locks.length > 0)
185
+ blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.lane}:${item.worker_id}`).join(", ")}`);
186
+ return blockers;
187
+ }
188
+ function readJsonObject(filePath) {
189
+ let parsed;
190
+ try {
191
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
192
+ }
193
+ catch (error) {
194
+ throw new AppError("invalid_json", `Cannot read migration report: ${filePath}`, 2, { cause: String(error) });
195
+ }
196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
197
+ throw new AppError("validation", "Migration report must be a JSON object", 2, { file: filePath });
198
+ }
199
+ return parsed;
200
+ }
201
+ function isAdjacentStep(value) {
202
+ const step = recordValue(value);
203
+ return Boolean(step && stringValue(step.from) && stringValue(step.to) && stringValue(step.status));
204
+ }
205
+ function recordValue(value) {
206
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
207
+ }
208
+ function stringValue(value) {
209
+ return typeof value === "string" && value.length > 0 ? value : null;
210
+ }
211
+ function parseSemver(value) {
212
+ const match = value.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)/);
213
+ if (!match?.[1] || !match[2] || !match[3])
214
+ return null;
215
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
216
+ }
217
+ function compareParts(left, right) {
218
+ if (left.major !== right.major)
219
+ return left.major - right.major;
220
+ if (left.minor !== right.minor)
221
+ return left.minor - right.minor;
222
+ return left.patch - right.patch;
223
+ }
224
+ function nextAdjacent(current, target) {
225
+ if (current.minor < target.minor)
226
+ return { major: current.major, minor: current.minor + 1, patch: 0 };
227
+ return { major: current.major, minor: current.minor, patch: Math.min(current.patch + 1, target.patch) };
228
+ }
229
+ function formatParts(parts) {
230
+ return `${parts.major}.${parts.minor}.${parts.patch}`;
231
+ }
@@ -0,0 +1,122 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { isActiveProtocolStatus, isActiveQueueStatus } from "../domain/contracts.js";
4
+ import { ensureDir } from "../storage/paths.js";
5
+ import { getCliVersionReport } from "./build-info.js";
6
+ import { projectDashboardHtmlPath, projectSummaryJsonPath } from "./config.js";
7
+ import { queueForProject } from "./merge-queue.js";
8
+ import { activeFlowSessionsForProject } from "./sessions.js";
9
+ import { getProjectVersionStatus } from "./version-status.js";
10
+ export function projectSummaryPath(context, projectId) {
11
+ return projectSummaryJsonPath(context, projectId);
12
+ }
13
+ export function buildProjectSummary(context, project) {
14
+ const versionStatus = getProjectVersionStatus({ projectRoot: fs.existsSync(project.root) ? project.root : null, rootSource: "explicit" });
15
+ const protocols = protocolsForProject(context, project.id);
16
+ const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
17
+ const queue = queueForProject(context, project.id);
18
+ const locks = context.db.all("SELECT lane, worker_id, status, expires_at FROM lane_locks WHERE project_id = ? AND status = 'active'", [project.id]);
19
+ const waiters = context.db.all("SELECT id, status FROM lane_waiters WHERE project_id = ? AND status = 'queued'", [project.id]);
20
+ const sessions = activeFlowSessionsForProject(context, project.id);
21
+ const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
22
+ const cli = getCliVersionReport().cli;
23
+ return {
24
+ schema_id: "dd-flow/project-summary@1",
25
+ schema_version: "1.0.0",
26
+ generated_at: context.now(),
27
+ project_id: project.id,
28
+ name: path.basename(project.root) || project.id,
29
+ root: project.root,
30
+ root_exists: fs.existsSync(project.root),
31
+ status: project.status,
32
+ memorybank_version: versionStatus?.memory_bank.version ?? null,
33
+ memorybank_status: versionStatus?.memory_bank.status ?? "missing",
34
+ flow_pack_status: versionStatus?.flow_pack.status ?? "missing",
35
+ required_engine_range: null,
36
+ cli_version: cli.version,
37
+ engine_version: null,
38
+ engine_status: "unknown",
39
+ summary_path: projectSummaryPath(context, project.id),
40
+ dashboard_path: projectDashboardHtmlPath(context, project.id),
41
+ protocol_counts: {
42
+ active: activeProtocols.length,
43
+ waiting: protocols.filter((protocol) => protocol.status === "waiting_for_user").length,
44
+ done: protocols.filter((protocol) => protocol.status === "closed").length,
45
+ total: protocols.length
46
+ },
47
+ active_protocols: activeProtocols.slice(0, 6).map((protocol) => ({
48
+ id: protocol.id,
49
+ stage: protocol.stage,
50
+ status: protocol.status,
51
+ next_action: protocol.next_action,
52
+ updated_at: protocol.updated_at
53
+ })),
54
+ waiting_items: protocols
55
+ .filter((protocol) => protocol.status === "waiting_for_user")
56
+ .slice(0, 6)
57
+ .map((protocol) => ({ id: protocol.id, stage: protocol.stage, next_action: protocol.next_action })),
58
+ resource_summary: {
59
+ queue: queue.filter((job) => isActiveQueueStatus(job.status)).length,
60
+ locks: locks.length,
61
+ waiters: waiters.length,
62
+ sessions: sessions.length,
63
+ open_defs: openDefs
64
+ },
65
+ warnings: fs.existsSync(project.root) ? [] : [{ code: "project_root_missing", path: project.root }],
66
+ last_activity_at: latestActivity(project, protocols)
67
+ };
68
+ }
69
+ export function publishProjectSummary(context, input) {
70
+ const summary = buildProjectSummary(context, input.project);
71
+ const output = projectSummaryPath(context, input.project.id);
72
+ if (input.write ?? true) {
73
+ writeJsonFile(output, summary);
74
+ }
75
+ return { ok: true, project_id: input.project.id, project_root: input.project.root, summary_path: output, summary };
76
+ }
77
+ export function readPublishedProjectSummary(context, project) {
78
+ const file = projectSummaryPath(context, project.id);
79
+ try {
80
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
81
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ export function projectRegistrySummary(context, project) {
88
+ const published = readPublishedProjectSummary(context, project);
89
+ const versionStatus = getProjectVersionStatus({ projectRoot: fs.existsSync(project.root) ? project.root : null, rootSource: "explicit" });
90
+ return {
91
+ project_id: project.id,
92
+ project_root: project.root,
93
+ memorybank_version: published?.memorybank_version ?? versionStatus?.memory_bank.version ?? null,
94
+ required_engine_range: published?.required_engine_range ?? null,
95
+ last_seen_cli_version: published?.cli_version ?? null,
96
+ last_seen_engine_version: published?.engine_version ?? null,
97
+ summary_path: projectSummaryPath(context, project.id),
98
+ last_activity_at: published?.last_activity_at ?? project.updated_at,
99
+ status: project.status,
100
+ summary_status: published ? "present" : "missing"
101
+ };
102
+ }
103
+ function protocolsForProject(context, projectId) {
104
+ return context.db.all(`SELECT id, status, stage, next_action, blockers_json, active_def_json, updated_at
105
+ FROM protocols WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
106
+ }
107
+ function jsonArray(value) {
108
+ try {
109
+ const parsed = JSON.parse(value);
110
+ return Array.isArray(parsed) ? parsed : [];
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ }
116
+ function latestActivity(project, protocols) {
117
+ return protocols.reduce((latest, protocol) => (protocol.updated_at > latest ? protocol.updated_at : latest), project.updated_at);
118
+ }
119
+ function writeJsonFile(file, value) {
120
+ ensureDir(path.dirname(file));
121
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
122
+ }
@@ -8,6 +8,8 @@ import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProje
8
8
  import { dashboardMarkdownPath, globalDashboardMarkdownPath, readProjectConfig } from "./config.js";
9
9
  import { loadProjectFlowContract } from "../domain/flow-contract.js";
10
10
  import { buildStaticFlowGuidance } from "./flow-guidance.js";
11
+ import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
12
+ import { buildProjectSummary, projectRegistrySummary, readPublishedProjectSummary } from "./project-summary.js";
11
13
  export function registerProject(context, input) {
12
14
  const root = resolveProjectRoot(input.root);
13
15
  const existing = findProjectByRoot(context, root);
@@ -89,10 +91,26 @@ export function resolveProject(context, input) {
89
91
  }
90
92
  const details = { input: input.idOrAlias };
91
93
  if (!isFullEntityId(input.idOrAlias) && !isShortEntityId(input.idOrAlias)) {
92
- details.expected = "Use full id PRJ-NNN-slug, short alias PRJ-NNN, or --root for root-based commands.";
94
+ details.expected = "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, root path, or --root for root-based commands.";
93
95
  }
94
96
  throw new AppError("not_found", `Project is not registered: ${input.idOrAlias}`, 1, details);
95
97
  }
98
+ export function requireProjectByReference(context, reference) {
99
+ const candidates = resolveProjectCandidates(context, reference);
100
+ if (candidates.length === 1) {
101
+ return candidates[0];
102
+ }
103
+ if (candidates.length > 1) {
104
+ throw new AppError("ambiguous_alias", `Project reference is ambiguous: ${reference}`, 1, {
105
+ input: reference,
106
+ candidates: candidates.map(projectSummary)
107
+ });
108
+ }
109
+ throw new AppError("not_found", `Project is not registered: ${reference}`, 1, {
110
+ input: reference,
111
+ expected: "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, or project root path."
112
+ });
113
+ }
96
114
  export function migrateProjectIds(context, input) {
97
115
  const root = resolveProjectRoot(input.root);
98
116
  const existing = findProjectByRoot(context, root);
@@ -160,10 +178,18 @@ export function getProjectStatus(context, input) {
160
178
  const protocols = context.db.all(`SELECT id, status, stage, next_action, updated_at
161
179
  FROM protocols
162
180
  WHERE project_id = ?
163
- ORDER BY updated_at DESC`, [project.id]).map((protocol) => ({
164
- ...protocol,
165
- flow_guidance: buildStaticFlowGuidance({ stage: String(protocol.stage), contract: flowContract })
166
- }));
181
+ ORDER BY updated_at DESC`, [project.id]).map((protocol) => {
182
+ const lifecycle = normalizeProtocolLifecycle({
183
+ rawStage: String(protocol.stage),
184
+ rawStatus: String(protocol.status),
185
+ flowContract
186
+ });
187
+ return {
188
+ ...protocol,
189
+ lifecycle,
190
+ flow_guidance: buildStaticFlowGuidance({ stage: String(protocol.stage), status: String(protocol.status), contract: flowContract })
191
+ };
192
+ });
167
193
  const mergeQueue = context.db.all(`SELECT protocol_id, status, claimed_by_session_id, claimed_at, attempts_count, last_reason, completed_at,
168
194
  created_at, updated_at
169
195
  FROM merge_queue
@@ -177,6 +203,8 @@ export function getProjectStatus(context, input) {
177
203
  return {
178
204
  ok: true,
179
205
  project,
206
+ registry_summary: projectRegistrySummary(context, project),
207
+ project_summary: readPublishedProjectSummary(context, project) ?? buildProjectSummary(context, project),
180
208
  config,
181
209
  dashboard: {
182
210
  project_markdown_path: dashboardMarkdownPath(project.root, config),
@@ -251,13 +279,17 @@ function typedProjectMetadata(project) {
251
279
  return { shortId: parsed.shortId, slug: parsed.slug };
252
280
  }
253
281
  function resolveProjectCandidates(context, idOrAlias) {
282
+ if (looksLikePathReference(idOrAlias)) {
283
+ const byRoot = findProjectByRoot(context, normalizeStoredRoot(idOrAlias));
284
+ return byRoot ? [byRoot] : [];
285
+ }
254
286
  if (isShortEntityId(idOrAlias)) {
255
287
  return context.db.all("SELECT * FROM projects WHERE short_id = ? ORDER BY id ASC", [idOrAlias]);
256
288
  }
257
289
  if (isFullEntityId(idOrAlias)) {
258
290
  return context.db.all("SELECT * FROM projects WHERE id = ? ORDER BY id ASC", [idOrAlias]);
259
291
  }
260
- return [];
292
+ return context.db.all("SELECT * FROM projects WHERE slug = ? ORDER BY id ASC", [idOrAlias]);
261
293
  }
262
294
  function resolveSingleProject(context, idOrAlias) {
263
295
  return resolveProject(context, { idOrAlias }).project;
@@ -272,6 +304,9 @@ function projectSummary(project) {
272
304
  state_root: project.state_root
273
305
  };
274
306
  }
307
+ function looksLikePathReference(value) {
308
+ return path.isAbsolute(value) || value.startsWith(".") || value.includes("/") || value.includes("\\");
309
+ }
275
310
  function projectReferenceTables() {
276
311
  return [
277
312
  "protocols",
@@ -0,0 +1,144 @@
1
+ import { defaultFlowContract, flowContractForState, normalizeStage } from "../domain/flow-contract.js";
2
+ export function normalizeProtocolLifecycle(input) {
3
+ const source = input.state ? "runtime_state" : input.rawStage || input.rawStatus ? "protocol_record" : "missing";
4
+ const contract = input.flowContract ?? (input.state ? flowContractForState(input.state) : defaultFlowContract);
5
+ const rawStage = nonEmpty(input.state?.stage) ?? nonEmpty(input.rawStage) ?? "unregistered";
6
+ const rawStatus = nonEmpty(input.state?.status) ?? nonEmpty(input.rawStatus) ?? "unknown";
7
+ const normalizedRawStage = safeNormalizeStage(rawStage, contract);
8
+ const diagnostics = [];
9
+ const mapped = mapLegacyLifecycle(normalizedRawStage, rawStatus, input.queueStatus ?? null, contract);
10
+ if (normalizedRawStage !== rawStage) {
11
+ diagnostics.push({
12
+ code: "lifecycle_legacy_alias_normalized",
13
+ severity: "info",
14
+ raw_stage: rawStage,
15
+ normalized_stage: normalizedRawStage
16
+ });
17
+ }
18
+ if (mapped.stage === "unknown") {
19
+ diagnostics.push({
20
+ code: "lifecycle_stage_unknown",
21
+ severity: "warning",
22
+ raw_stage: rawStage,
23
+ raw_status: rawStatus
24
+ });
25
+ }
26
+ if (["blocked", "waiting_for_user"].includes(normalizedRawStage) && !mapped.status_reason) {
27
+ diagnostics.push({
28
+ code: "lifecycle_return_stage_missing",
29
+ severity: "warning",
30
+ raw_stage: rawStage,
31
+ summary: `${normalizedRawStage} is a status-like legacy stage without explicit return_to_stage metadata.`
32
+ });
33
+ }
34
+ return {
35
+ flow: nonEmpty(input.flow) ?? "mb_sdlc",
36
+ stage: mapped.stage,
37
+ substage: mapped.substage,
38
+ status: mapped.status,
39
+ status_reason: mapped.status_reason,
40
+ terminal: mapped.terminal,
41
+ legacy_stage: mapped.legacy_stage,
42
+ legacy_status: mapped.legacy_status,
43
+ raw_stage: rawStage,
44
+ raw_status: rawStatus,
45
+ queue_status: input.queueStatus ?? null,
46
+ source,
47
+ diagnostics
48
+ };
49
+ }
50
+ export function lifecycleIsTerminalSuccess(lifecycle) {
51
+ return lifecycle.terminal && lifecycle.status === "done";
52
+ }
53
+ export function lifecycleIsTerminalFailure(lifecycle) {
54
+ return lifecycle.terminal && ["cancelled", "failed"].includes(lifecycle.status);
55
+ }
56
+ export function lifecycleLegacyStage(lifecycle) {
57
+ return lifecycle.legacy_stage ?? lifecycle.raw_stage;
58
+ }
59
+ function mapLegacyLifecycle(stage, rawStatus, queueStatus, contract) {
60
+ if (stage === "registered") {
61
+ return mapped("protocol", null, "registered", "registered", rawStatus, false);
62
+ }
63
+ if (stage === "priming" || stage === "prime") {
64
+ return mapped("specify", "priming", statusFromRaw(rawStatus, "in_progress"), stage, rawStatus, false);
65
+ }
66
+ if (stage === "specify") {
67
+ return mapped("specify", null, statusFromRaw(rawStatus, "in_progress"), null, null, false);
68
+ }
69
+ if (stage === "plan") {
70
+ return mapped("plan", null, statusFromRaw(rawStatus, "in_progress"), null, null, false);
71
+ }
72
+ if (stage === "implementation") {
73
+ return mapped("code", "implementation", statusFromRaw(rawStatus, "in_progress"), "implementation", rawStatus, false);
74
+ }
75
+ if (stage === "readiness") {
76
+ return mapped("code", "readiness", rawStatus === "running" ? "readiness" : statusFromRaw(rawStatus, "readiness"), "readiness", rawStatus, false);
77
+ }
78
+ if (stage === "ready_for_merge") {
79
+ const status = queueStatus && ["ready", "requeued"].includes(queueStatus) ? "ready_for_merge" : "ready_for_merge";
80
+ return mapped("code", null, status, "ready_for_merge", rawStatus, false);
81
+ }
82
+ if (stage === "queued_for_merge") {
83
+ return mapped("merge", null, queueStatus === "claimed" ? "claimed" : "queued", "queued_for_merge", rawStatus, false);
84
+ }
85
+ if (stage === "integration") {
86
+ return mapped("merge", "integration", queueStatus === "claimed" ? "in_progress" : statusFromRaw(rawStatus, "in_progress"), "integration", rawStatus, false);
87
+ }
88
+ if (stage === "blocked") {
89
+ return mapped("unknown", null, "blocked", "blocked", rawStatus, false);
90
+ }
91
+ if (stage === "waiting_for_user") {
92
+ return mapped("unknown", null, "waiting_for_user", "waiting_for_user", rawStatus, false);
93
+ }
94
+ if (stage === "closed") {
95
+ return mapped("closed", null, "done", "closed", rawStatus, true);
96
+ }
97
+ if (stage === "cancelled") {
98
+ return mapped("closed", null, "cancelled", "cancelled", rawStatus, true);
99
+ }
100
+ if (contract.stages[stage]?.terminal) {
101
+ return mapped(stage, null, statusFromRaw(rawStatus, "done"), stage, rawStatus, true);
102
+ }
103
+ if (contract.stages[stage]) {
104
+ return mapped(stage, null, statusFromRaw(rawStatus, "in_progress"), null, null, false);
105
+ }
106
+ return mapped("unknown", null, statusFromRaw(rawStatus, "unknown"), stage, rawStatus, false);
107
+ }
108
+ function mapped(stage, substage, status, legacyStage, legacyStatus, terminal) {
109
+ return {
110
+ stage,
111
+ substage,
112
+ status,
113
+ status_reason: null,
114
+ terminal,
115
+ legacy_stage: legacyStage,
116
+ legacy_status: legacyStatus
117
+ };
118
+ }
119
+ function statusFromRaw(rawStatus, fallback) {
120
+ if (rawStatus === "running")
121
+ return fallback;
122
+ if (rawStatus === "closed")
123
+ return "done";
124
+ if (rawStatus === "registered")
125
+ return "registered";
126
+ if (rawStatus === "waiting_user")
127
+ return "waiting_for_user";
128
+ if (rawStatus === "active")
129
+ return "in_progress";
130
+ if (rawStatus === "unknown")
131
+ return fallback;
132
+ return rawStatus || fallback;
133
+ }
134
+ function safeNormalizeStage(stage, contract) {
135
+ try {
136
+ return normalizeStage(stage, contract);
137
+ }
138
+ catch {
139
+ return stage;
140
+ }
141
+ }
142
+ function nonEmpty(value) {
143
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
144
+ }