@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +76 -5
  3. package/dist/build-info.json +10 -6
  4. package/dist/cli/help.js +165 -20
  5. package/dist/cli/run-cli.js +427 -17
  6. package/dist/schemas/compatibility.schema.json +105 -0
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +90 -0
  9. package/dist/schemas/flow-run-index.schema.json +30 -4
  10. package/dist/schemas/global-dashboard-data.schema.json +126 -0
  11. package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
  12. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  13. package/dist/schemas/plan-stage-report.schema.json +83 -0
  14. package/dist/schemas/project-dashboard-data.schema.json +122 -0
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
  16. package/dist/schemas/project-summary.schema.json +73 -0
  17. package/dist/schemas/protocol-dashboard-data.schema.json +112 -0
  18. package/dist/schemas/status-report.schema.json +38 -2
  19. package/dist/schemas/version-report.schema.json +22 -0
  20. package/dist/services/build-info.js +26 -3
  21. package/dist/services/canon.js +93 -22
  22. package/dist/services/cleanup.js +45 -1
  23. package/dist/services/cli-operation-classifier.js +104 -0
  24. package/dist/services/compatibility-preflight.js +124 -0
  25. package/dist/services/config.js +31 -0
  26. package/dist/services/dashboard-targets.js +95 -0
  27. package/dist/services/dashboard.js +972 -10
  28. package/dist/services/engines.js +532 -0
  29. package/dist/services/flow-guidance.js +221 -0
  30. package/dist/services/hooks.js +1 -1
  31. package/dist/services/ids.js +106 -0
  32. package/dist/services/lanes.js +333 -1
  33. package/dist/services/merge-queue.js +106 -16
  34. package/dist/services/merge-worker.js +67 -4
  35. package/dist/services/migrations.js +231 -0
  36. package/dist/services/project-summary.js +122 -0
  37. package/dist/services/projects.js +44 -3
  38. package/dist/services/protocol-lifecycle.js +144 -0
  39. package/dist/services/protocols.js +660 -7
  40. package/dist/services/runs.js +98 -21
  41. package/dist/services/schema-validation.js +84 -4
  42. package/dist/services/sessions.js +21 -4
  43. package/dist/services/status.js +199 -1
  44. package/dist/services/version-status.js +59 -9
  45. package/dist/storage/database.js +31 -0
  46. package/dist/storage/paths.js +33 -0
  47. package/package.json +3 -2
@@ -4,6 +4,8 @@ import { appendAudit } from "./audit.js";
4
4
  import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock, expireProjectLaneLocks } from "./lanes.js";
5
5
  import { claimNextMergeJob, queueForProject } from "./merge-queue.js";
6
6
  import { requireProjectByRoot } from "./projects.js";
7
+ import { buildStaticFlowGuidance } from "./flow-guidance.js";
8
+ import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
7
9
  import { registerFlowSession, stopMergeWorker } from "./sessions.js";
8
10
  export function getMergeWorkerStatus(context, input) {
9
11
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -11,7 +13,7 @@ export function getMergeWorkerStatus(context, input) {
11
13
  ok: true,
12
14
  project: { id: project.id, root: project.root },
13
15
  merge_worker: detectMergeWorkerState(context, project.id),
14
- queue: queueForProject(context, project.id)
16
+ queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job }))
15
17
  };
16
18
  }
17
19
  export function startMergeWorker(context, input) {
@@ -19,7 +21,16 @@ export function startMergeWorker(context, input) {
19
21
  const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
20
22
  const detection = detectMergeWorkerState(context, project.id);
21
23
  if (detection.state !== "clear") {
22
- return { ok: true, started: false, reason: "merge_worker_already_active", merge_worker: detection, queue: queueForProject(context, project.id) };
24
+ return {
25
+ ok: true,
26
+ started: false,
27
+ claimed: false,
28
+ mode: "status_only",
29
+ outcome: "status_only",
30
+ reason: "merge_worker_already_active",
31
+ merge_worker: detection,
32
+ queue: queueForProject(context, project.id)
33
+ };
23
34
  }
24
35
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
25
36
  const registered = registerFlowSession(context, {
@@ -80,7 +91,15 @@ export function oneShotMergeClaim(context, input) {
80
91
  const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
81
92
  const detection = detectMergeWorkerState(context, project.id);
82
93
  if (detection.state !== "clear") {
83
- return { ok: true, claimed: false, mode: "status_only", reason: "active_merge_worker_or_lock", merge_worker: detection, queue: queueForProject(context, project.id) };
94
+ return {
95
+ ok: true,
96
+ claimed: false,
97
+ mode: "status_only",
98
+ outcome: "status_only",
99
+ reason: "active_merge_worker_or_lock",
100
+ merge_worker: detection,
101
+ queue: queueForProject(context, project.id)
102
+ };
84
103
  }
85
104
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
86
105
  const lock = acquireLaneLock(context, {
@@ -101,7 +120,51 @@ export function oneShotMergeClaim(context, input) {
101
120
  reason: "one-shot merge no job"
102
121
  });
103
122
  }
104
- return { ok: true, claimed: Boolean(claimed.job), mode: "one_shot", job: claimed.job ?? null, lock: claimed.job ? lock.lock : null };
123
+ return {
124
+ ok: true,
125
+ claimed: Boolean(claimed.job),
126
+ mode: "one_shot",
127
+ outcome: claimed.job ? "claimed" : "empty",
128
+ queue_item: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
129
+ protocol: claimed.job
130
+ ? {
131
+ id: String(claimed.job.protocol_id ?? ""),
132
+ queue_status: String(claimed.job.status ?? ""),
133
+ project_id: String(claimed.job.project_id ?? "")
134
+ }
135
+ : null,
136
+ claim: claimed.job?.claimed_by_session_id
137
+ ? {
138
+ protocol_id: String(claimed.job.protocol_id ?? ""),
139
+ worker_id: String(claimed.job.claimed_by_session_id ?? ""),
140
+ claimed_at: claimed.job.claimed_at ?? null,
141
+ status: claimed.job.status ?? null
142
+ }
143
+ : null,
144
+ job: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
145
+ lock: claimed.job ? lock.lock : null,
146
+ flow_guidance: claimed.job ? withJobGuidance(context, { ...claimed.job }).flow_guidance : undefined
147
+ };
148
+ }
149
+ function withJobGuidance(context, job) {
150
+ const protocolId = typeof job.protocol_id === "string" ? job.protocol_id : "";
151
+ if (!protocolId)
152
+ return job;
153
+ try {
154
+ const protocol = requireProtocol(context, protocolId);
155
+ const state = readProtocolRuntimeState(context, protocol).state;
156
+ return {
157
+ ...job,
158
+ flow_guidance: buildStaticFlowGuidance({
159
+ stage: state.stage,
160
+ ...(state.flow_contract ? { contract: state.flow_contract } : {}),
161
+ queueStatus: String(job.status ?? "")
162
+ })
163
+ };
164
+ }
165
+ catch {
166
+ return job;
167
+ }
105
168
  }
106
169
  function detectMergeWorkerState(context, projectId) {
107
170
  expireProjectLaneLocks(context, projectId);
@@ -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
+ }
@@ -6,6 +6,10 @@ import { projectRuntimeRoot, resolveProjectRoot } from "../storage/paths.js";
6
6
  import { appendAudit } from "./audit.js";
7
7
  import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
8
8
  import { dashboardMarkdownPath, globalDashboardMarkdownPath, readProjectConfig } from "./config.js";
9
+ import { loadProjectFlowContract } from "../domain/flow-contract.js";
10
+ import { buildStaticFlowGuidance } from "./flow-guidance.js";
11
+ import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
12
+ import { buildProjectSummary, projectRegistrySummary, readPublishedProjectSummary } from "./project-summary.js";
9
13
  export function registerProject(context, input) {
10
14
  const root = resolveProjectRoot(input.root);
11
15
  const existing = findProjectByRoot(context, root);
@@ -87,10 +91,26 @@ export function resolveProject(context, input) {
87
91
  }
88
92
  const details = { input: input.idOrAlias };
89
93
  if (!isFullEntityId(input.idOrAlias) && !isShortEntityId(input.idOrAlias)) {
90
- 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.";
91
95
  }
92
96
  throw new AppError("not_found", `Project is not registered: ${input.idOrAlias}`, 1, details);
93
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
+ }
94
114
  export function migrateProjectIds(context, input) {
95
115
  const root = resolveProjectRoot(input.root);
96
116
  const existing = findProjectByRoot(context, root);
@@ -154,10 +174,22 @@ export function getProjectStatus(context, input) {
154
174
  if (!project) {
155
175
  throw new AppError("not_found", `Project is not registered: ${root}`, 1);
156
176
  }
177
+ const flowContract = loadProjectFlowContract(root);
157
178
  const protocols = context.db.all(`SELECT id, status, stage, next_action, updated_at
158
179
  FROM protocols
159
180
  WHERE project_id = ?
160
- ORDER BY updated_at DESC`, [project.id]);
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
+ });
161
193
  const mergeQueue = context.db.all(`SELECT protocol_id, status, claimed_by_session_id, claimed_at, attempts_count, last_reason, completed_at,
162
194
  created_at, updated_at
163
195
  FROM merge_queue
@@ -171,6 +203,8 @@ export function getProjectStatus(context, input) {
171
203
  return {
172
204
  ok: true,
173
205
  project,
206
+ registry_summary: projectRegistrySummary(context, project),
207
+ project_summary: readPublishedProjectSummary(context, project) ?? buildProjectSummary(context, project),
174
208
  config,
175
209
  dashboard: {
176
210
  project_markdown_path: dashboardMarkdownPath(project.root, config),
@@ -245,13 +279,17 @@ function typedProjectMetadata(project) {
245
279
  return { shortId: parsed.shortId, slug: parsed.slug };
246
280
  }
247
281
  function resolveProjectCandidates(context, idOrAlias) {
282
+ if (looksLikePathReference(idOrAlias)) {
283
+ const byRoot = findProjectByRoot(context, normalizeStoredRoot(idOrAlias));
284
+ return byRoot ? [byRoot] : [];
285
+ }
248
286
  if (isShortEntityId(idOrAlias)) {
249
287
  return context.db.all("SELECT * FROM projects WHERE short_id = ? ORDER BY id ASC", [idOrAlias]);
250
288
  }
251
289
  if (isFullEntityId(idOrAlias)) {
252
290
  return context.db.all("SELECT * FROM projects WHERE id = ? ORDER BY id ASC", [idOrAlias]);
253
291
  }
254
- return [];
292
+ return context.db.all("SELECT * FROM projects WHERE slug = ? ORDER BY id ASC", [idOrAlias]);
255
293
  }
256
294
  function resolveSingleProject(context, idOrAlias) {
257
295
  return resolveProject(context, { idOrAlias }).project;
@@ -266,6 +304,9 @@ function projectSummary(project) {
266
304
  state_root: project.state_root
267
305
  };
268
306
  }
307
+ function looksLikePathReference(value) {
308
+ return path.isAbsolute(value) || value.startsWith(".") || value.includes("/") || value.includes("\\");
309
+ }
269
310
  function projectReferenceTables() {
270
311
  return [
271
312
  "protocols",