@ybotok/loam 0.0.0 → 0.1.0-beta.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 (106) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +241 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +138 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +226 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1297 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +361 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +78 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/doctor.js +85 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/gherkin.js +270 -0
  21. package/dist/commands/gherkin.js.map +1 -0
  22. package/dist/commands/init.js +250 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/list.js +361 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/migrate-openspec.js +933 -0
  27. package/dist/commands/migrate-openspec.js.map +1 -0
  28. package/dist/commands/new.js +412 -0
  29. package/dist/commands/new.js.map +1 -0
  30. package/dist/commands/rebase.js +358 -0
  31. package/dist/commands/rebase.js.map +1 -0
  32. package/dist/commands/show.js +325 -0
  33. package/dist/commands/show.js.map +1 -0
  34. package/dist/commands/status.js +270 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/unarchive.js +310 -0
  37. package/dist/commands/unarchive.js.map +1 -0
  38. package/dist/commands/validate.js +1609 -0
  39. package/dist/commands/validate.js.map +1 -0
  40. package/dist/commands/verify.js +759 -0
  41. package/dist/commands/verify.js.map +1 -0
  42. package/dist/commands/vouch.js +325 -0
  43. package/dist/commands/vouch.js.map +1 -0
  44. package/dist/core/agent.js +1823 -0
  45. package/dist/core/agent.js.map +1 -0
  46. package/dist/core/agents-stamp.js +68 -0
  47. package/dist/core/agents-stamp.js.map +1 -0
  48. package/dist/core/arch.js +104 -0
  49. package/dist/core/arch.js.map +1 -0
  50. package/dist/core/brief.js +733 -0
  51. package/dist/core/brief.js.map +1 -0
  52. package/dist/core/coherence.js +553 -0
  53. package/dist/core/coherence.js.map +1 -0
  54. package/dist/core/config.js +172 -0
  55. package/dist/core/config.js.map +1 -0
  56. package/dist/core/delta.js +457 -0
  57. package/dist/core/delta.js.map +1 -0
  58. package/dist/core/dependencies.js +352 -0
  59. package/dist/core/dependencies.js.map +1 -0
  60. package/dist/core/docs.js +143 -0
  61. package/dist/core/docs.js.map +1 -0
  62. package/dist/core/doctor.js +544 -0
  63. package/dist/core/doctor.js.map +1 -0
  64. package/dist/core/fleet-context.js +272 -0
  65. package/dist/core/fleet-context.js.map +1 -0
  66. package/dist/core/frontmatter.js +203 -0
  67. package/dist/core/frontmatter.js.map +1 -0
  68. package/dist/core/gherkin.js +521 -0
  69. package/dist/core/gherkin.js.map +1 -0
  70. package/dist/core/health.js +71 -0
  71. package/dist/core/health.js.map +1 -0
  72. package/dist/core/ids.js +93 -0
  73. package/dist/core/ids.js.map +1 -0
  74. package/dist/core/issue.js +9 -0
  75. package/dist/core/issue.js.map +1 -0
  76. package/dist/core/json.js +67 -0
  77. package/dist/core/json.js.map +1 -0
  78. package/dist/core/likec4.js +474 -0
  79. package/dist/core/likec4.js.map +1 -0
  80. package/dist/core/openapi-merge.js +519 -0
  81. package/dist/core/openapi-merge.js.map +1 -0
  82. package/dist/core/openapi.js +243 -0
  83. package/dist/core/openapi.js.map +1 -0
  84. package/dist/core/openspec-inventory.js +1403 -0
  85. package/dist/core/openspec-inventory.js.map +1 -0
  86. package/dist/core/path-safety.js +109 -0
  87. package/dist/core/path-safety.js.map +1 -0
  88. package/dist/core/provenance.js +695 -0
  89. package/dist/core/provenance.js.map +1 -0
  90. package/dist/core/repo.js +362 -0
  91. package/dist/core/repo.js.map +1 -0
  92. package/dist/core/report.js +38 -0
  93. package/dist/core/report.js.map +1 -0
  94. package/dist/core/results.js +201 -0
  95. package/dist/core/results.js.map +1 -0
  96. package/dist/core/spec.js +481 -0
  97. package/dist/core/spec.js.map +1 -0
  98. package/dist/core/staging.js +783 -0
  99. package/dist/core/staging.js.map +1 -0
  100. package/dist/core/status.js +1015 -0
  101. package/dist/core/status.js.map +1 -0
  102. package/dist/core/verify.js +793 -0
  103. package/dist/core/verify.js.map +1 -0
  104. package/dist/core/version.js +8 -0
  105. package/dist/core/version.js.map +1 -0
  106. package/package.json +63 -4
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * Where does this work stand, and what is the next thing to do?
3
+ *
4
+ * Every other command answers a question you already know how to ask: validate
5
+ * grades a target you name, delta briefs a service you name, verify checks a
6
+ * feature you name. The one thing loam could not answer was the question an
7
+ * agent asks FIRST — the one it has when it joins a repository halfway, or
8
+ * comes back after losing its session. That knowledge existed, but only as
9
+ * prose inside the slash commands ("1. scaffold, 2. author, 3. validate"),
10
+ * which is to say it existed for whoever had read them recently and for nobody
11
+ * else.
12
+ *
13
+ * The obvious implementation is a state file — a `.loam/state.json` recording
14
+ * which step each feature reached. It is rejected here for the reason verify.ts
15
+ * refuses a second mutable database: the moment a second record of the truth
16
+ * exists, an author who edits a file by hand, a merge, a rebase or a
17
+ * `git checkout` desynchronises it, and the state file's answer is confidently
18
+ * wrong exactly when somebody trusts it. So `status` stores nothing and caches
19
+ * nothing. It is a projection: every invocation re-derives the whole answer
20
+ * from the files, and a file edited in another window is visible on the next
21
+ * run with no invalidation step for anyone to forget.
22
+ *
23
+ * It also grades nothing itself. Every verdict here is somebody else's
24
+ * function: `featureCoherence` (what `loam archive` refuses on) plus
25
+ * `featureProvenance` and `requirementsMissingScenarios` (`loam validate
26
+ * --feature`'s other error sources, as far as they exist as shared functions),
27
+ * and `readVerificationState` + `featureChecklist`, the pair `loam verify`
28
+ * uses. A second opinion about whether a feature holds together would be a
29
+ * second answer to grade a pull request against, and the one a reader consults
30
+ * is never the one the gate obeys (doctor.ts learnt this with its own config
31
+ * validator). A check that lives inline inside `validate` cannot be inherited
32
+ * at all, which is why `stage` never rests on `checks.coherent` alone.
33
+ *
34
+ * Which is why the rule this module is held to is one-directional: **status may
35
+ * be more pessimistic than the gates, never greener.** `validate` and `archive`
36
+ * answer two different questions — is the DOCUMENT valid, is the MERGE safe
37
+ * (issue.ts) — and a projection that reported only one of them told an author
38
+ * "ship it" about a feature the other one refuses. So `stage` and `next[]` take
39
+ * the UNION: an error `validate` grades and a warning `archive` gates on both
40
+ * make a feature `draft`, and test/status-agrees-with-gate.test.ts holds the
41
+ * direction on a matrix of deliberately-damaged trees.
42
+ *
43
+ * ## The status vocabulary
44
+ *
45
+ * Five values, closed, and an agent is expected to branch on them:
46
+ *
47
+ * `missing` the artifact is owed and is not on disk; nothing stands in the
48
+ * way of writing it now.
49
+ * `blocked` the work is stalled on something that is NOT this artifact — an
50
+ * artifact it is derived from is absent, or another feature in
51
+ * flight has to archive first.
52
+ * `draft` it is on disk and the shared checks report an error against it:
53
+ * what exists is wrong.
54
+ * `ready` it is on disk and clean, but its obligation is not discharged —
55
+ * something outside the documents (code, a test run, a recording)
56
+ * still has to answer it.
57
+ * `done` nothing is owed here.
58
+ *
59
+ * The two pairs that look alike are the point of the set. `missing` and
60
+ * `blocked` both mean "not written", and they are different instructions: one
61
+ * says write it now, the other says you cannot yet and names what comes first —
62
+ * an agent that cannot tell them apart authors a verification record against a
63
+ * checklist that does not exist. `draft` and `ready` both mean "on disk", and
64
+ * they are different instructions too: `draft` says the document is wrong and
65
+ * the fix is in this repository, `ready` says the document is right and the
66
+ * world has not caught up — the code is not built, or the claims are not
67
+ * answered. Collapsing those two would send an agent to re-edit a spec that is
68
+ * already correct.
69
+ *
70
+ * A value unreachable in a scope is simply never emitted there, deliberately:
71
+ * the fleet form never says `draft`, because grading is a per-feature cost it
72
+ * refuses to pay.
73
+ *
74
+ * ## `next[]`
75
+ *
76
+ * The payload's reason to exist. Each entry carries a stable `code` to branch
77
+ * on, one sentence a person can read, and the literal command to run — ordered,
78
+ * first entry first. `command` is the loam invocation that carries the step
79
+ * forward: the one that does the work where loam does it (`rebase`, `archive`),
80
+ * and the one that states or grades the work where the author does it (`delta`,
81
+ * `validate`, `verify`). It is never a command that would write the artifact
82
+ * behind the author's back — loam does not author.
83
+ *
84
+ * `next` is never empty. A feature with nothing outstanding still has a next
85
+ * step (ship it), and a repository with nothing outstanding still has one (keep
86
+ * the fleet green) — an empty array would be indistinguishable from a bug in
87
+ * the caller's own parsing.
88
+ *
89
+ * ## Why the fleet form is cheaper than the feature form
90
+ *
91
+ * `loam status` with no feature runs over every active feature, and deriving a
92
+ * coherence report or a verification checklist per feature means spinning a
93
+ * fresh LikeC4 workspace per feature. On a fleet-sized repository that is the
94
+ * cost that gets a command switched off — `list` made the same call about its
95
+ * verification column. So the fleet form grades nothing: it reports artifact
96
+ * PRESENCE, the dependency order, and a verification record only where one
97
+ * already exists, and its `next[]` hands off to `loam status <FEAT>`, which is
98
+ * where the checks actually run.
99
+ */
100
+ import { existsSync } from "node:fs";
101
+ import { join } from "node:path";
102
+ import { featureCoherence, livingMergeConflicts, unknownDeltaServices } from "./coherence.js";
103
+ import { analyzeDependencies } from "./dependencies.js";
104
+ import { FleetContext } from "./fleet-context.js";
105
+ import { gatesArchive } from "./issue.js";
106
+ import { repoPath } from "./json.js";
107
+ import { featureProvenance } from "./provenance.js";
108
+ import { compareIds, featurePaths, featureSpecPaths, featureSpecServices, listFeatures, listServices, servicePaths, } from "./repo.js";
109
+ import { requirementsMissingScenarios } from "./spec.js";
110
+ import { COMMIT_INTENT, readCommitIntent } from "./staging.js";
111
+ import { attestedNotice, featureChecklist, readVerificationState, tallyRecord, verificationPath, verificationVerdict, } from "./verify.js";
112
+ /** The closed set. The module header says what each value instructs. */
113
+ export const ARTIFACT_STATUSES = ["missing", "blocked", "draft", "ready", "done"];
114
+ /**
115
+ * The artifact kinds a feature can owe, in the order the cycle authors them.
116
+ * `spec`, `arch-spec` and `openapi` are per-service and repeat once per service
117
+ * the feature touches; the rest are feature-wide.
118
+ */
119
+ export const ARTIFACT_IDS = [
120
+ "intent",
121
+ "delta",
122
+ "spec",
123
+ "arch-spec",
124
+ "openapi",
125
+ "verification",
126
+ ];
127
+ /* ------------------------------------------------------------------ */
128
+ /* The one state that outranks everything else */
129
+ /* ------------------------------------------------------------------ */
130
+ /**
131
+ * The intent journal, if a commit left one behind. Null on every healthy repo,
132
+ * which is every repo except one whose last write was killed.
133
+ *
134
+ * Cheap on purpose: an `existsSync` and, at most, one small JSON read. `doctor`
135
+ * pays for the whole `scanWritePathResidue` walk because it is a diagnosis;
136
+ * `status` is run before every step of the loop and asks the one question whose
137
+ * answer invalidates all its others.
138
+ */
139
+ async function readInterruptedCommit(docsDir) {
140
+ const intent = await readCommitIntent(docsDir);
141
+ if (intent === null) {
142
+ if (!existsSync(join(docsDir, COMMIT_INTENT)))
143
+ return null;
144
+ return {
145
+ command: null,
146
+ feature: null,
147
+ host: null,
148
+ pid: null,
149
+ at: null,
150
+ files: [],
151
+ unreadable: true,
152
+ recover: null,
153
+ };
154
+ }
155
+ return {
156
+ command: intent.command,
157
+ feature: intent.feature,
158
+ host: intent.host,
159
+ pid: intent.pid,
160
+ at: intent.at,
161
+ files: intent.files.map((f) => f.path),
162
+ unreadable: false,
163
+ // The recovering command is the interrupted one re-run: it repairs first,
164
+ // under the lock, from the snapshot it took before the commit. Spelled per
165
+ // branch rather than interpolated, like doctor's own repair line, so
166
+ // test/agent-commands-runnable.test.ts can parse it against the real program.
167
+ recover: intent.command === "archive" ? `loam archive ${intent.feature}` : `loam unarchive ${intent.feature}`,
168
+ };
169
+ }
170
+ /**
171
+ * The step that has to come before every other one. Always first, and never
172
+ * elided by the fleet form's cap: nothing else in the list can be done until
173
+ * somebody knows whether the living docs are the docs.
174
+ */
175
+ function recoverStep(i) {
176
+ return {
177
+ code: "next.recover-commit",
178
+ statement: i.unreadable
179
+ ? `${COMMIT_INTENT} in this docs repo cannot be read — a commit was interrupted and the one record of which files it had already written is unreadable. Nothing below can be trusted until a human compares the living docs against version control.`
180
+ : `A \`${i.recover}\` was killed mid-commit (${i.host}, pid ${i.pid}, ${i.at}) — ${i.files.length} file(s) may be half-written: ${i.files.join(", ")}. Everything below is derived from those files.`,
181
+ // No command loam offers repairs an unreadable journal — saying otherwise
182
+ // would send an agent to run something that refuses.
183
+ command: i.recover ?? "loam doctor --json",
184
+ };
185
+ }
186
+ /* ------------------------------------------------------------------ */
187
+ /* One feature */
188
+ /* ------------------------------------------------------------------ */
189
+ /**
190
+ * Everything about one feature in flight. `service` narrows the per-service
191
+ * artifacts and the per-service steps; the feature's own `services` list stays
192
+ * complete either way, because a narrowed view that also hid which other
193
+ * services exist would read as a feature that touches exactly one.
194
+ */
195
+ export async function featureStatus(docsDir, feature, opts = {}) {
196
+ const context = opts.context ?? new FleetContext();
197
+ const interrupted = await readInterruptedCommit(docsDir);
198
+ const services = await featureSpecServices(feature.dir, context);
199
+ const narrowed = opts.service;
200
+ const inView = narrowed === undefined ? services : services.filter((s) => s === narrowed);
201
+ const scans = feature.archived ? [] : await scanDeltas(docsDir, feature, services, context);
202
+ const read = await verificationState(docsDir, feature);
203
+ const verification = read.state;
204
+ // An archived feature is history: its delta is already folded into the living
205
+ // docs, so coherence would grade the change against a world that has absorbed
206
+ // it and report contradictions that are only the merge having happened. The
207
+ // same reason `list` never calls an archived record stale.
208
+ const findings = feature.archived ? [] : await featureFindings(docsDir, feature, scans, context);
209
+ // `verify.scenario-attested` rides in the same array an agent already parses,
210
+ // under verify's own code — a feature confirmed the short way has to be
211
+ // MARKED here, not merely absent from `stage: done`. It is a warning and it
212
+ // gates nothing, so `coherent` is untouched.
213
+ if (!feature.archived && read.notice !== null) {
214
+ findings.push({ severity: read.notice.severity, code: read.notice.code, message: read.notice.message });
215
+ }
216
+ const errors = findings.filter((f) => f.severity === "error");
217
+ const warnings = findings.filter((f) => f.severity === "warn").length;
218
+ // The union of the two gates, and the set every verdict below is drawn from.
219
+ const blocking = findings.filter(unshippable);
220
+ const governs = governedServices(scans);
221
+ // The whole feature is graded, then the view is narrowed — never the other
222
+ // way round. `--service` is a lens on the table and the steps; a rollup that
223
+ // moved with it would report a feature `ready` because the one service you
224
+ // asked about happens to be written, while three others have nothing.
225
+ const graded = featureArtifacts(docsDir, feature, services, blocking, verification, await contractsInFlight(docsDir, feature.id, context), governs);
226
+ const artifacts = narrowed === undefined ? graded : graded.filter((a) => a.service === null || a.service === narrowed);
227
+ // The dependency index rather than the `*-pending` issue codes: those name the
228
+ // other feature in prose, and prose is not an API — dependencies.ts opens with
229
+ // exactly that argument. An archived feature waits on nothing; it landed.
230
+ const graph = feature.archived
231
+ ? null
232
+ : await analyzeDependencies(docsDir, feature.id, context);
233
+ const blockedBy = graph?.nodes.find((n) => n.id === feature.id)?.dependsOn ?? [];
234
+ return {
235
+ interrupted,
236
+ feature: {
237
+ id: feature.id,
238
+ dirName: feature.dirName,
239
+ path: repoPath(docsDir, feature.dir),
240
+ archived: feature.archived,
241
+ stage: featureStage(feature, graded, services, blocking.length, blockedBy, verification, interrupted),
242
+ services,
243
+ blockedBy,
244
+ },
245
+ service: narrowed ?? null,
246
+ artifacts,
247
+ checks: {
248
+ ran: !feature.archived,
249
+ coherent: errors.length === 0,
250
+ errors: errors.length,
251
+ warnings,
252
+ gating: findings.filter((f) => f.gates === true).length,
253
+ issues: findings,
254
+ },
255
+ verification,
256
+ next: featureNext(feature, inView, artifacts, findings, blockedBy, verification, scans, opts.boundService, interrupted),
257
+ };
258
+ }
259
+ /**
260
+ * Everything `loam validate --feature` and `loam archive` would say about this
261
+ * feature, in one list, through the very functions they call.
262
+ *
263
+ * Coherence alone was not it. `validate --feature` also runs `featureProvenance`
264
+ * over intent.md and the scenario-coverage check over every per-service delta,
265
+ * and both grade ERRORS — so a feature whose requirements carry no scenario at
266
+ * all read `coherent: true` here while `validate` exited 1 on the same tree.
267
+ * The coherence findings carry `gates` resolved, because archive's question is
268
+ * not severity's (issue.ts) and no consumer should re-derive it.
269
+ */
270
+ async function featureFindings(docsDir, feature, scans, context) {
271
+ const issues = await featureCoherence(docsDir, feature.dir, feature.id, undefined, context);
272
+ const out = issues.map((i) => ({
273
+ severity: i.severity,
274
+ code: i.code,
275
+ gates: gatesArchive(i),
276
+ ...(i.subject === undefined ? {} : { subject: i.subject }),
277
+ message: i.message,
278
+ }));
279
+ out.push(...(await featureProvenance(feature.dir, feature.id)).filter((f) => f.severity !== "ok"));
280
+ // The two refusals `archive` makes at PLAN time, from the same functions it
281
+ // calls. They are not coherence issues — one is about a service id that means
282
+ // nothing, the other about a LIVING document nobody wrote — so they reach
283
+ // archive outside `featureCoherence`, and a projection that only inherited
284
+ // coherence printed "ship it" over both. Errors: each is a refusal, and the
285
+ // rule this module is held to is one-directional.
286
+ out.push(...(await unknownDeltaServices(docsDir, feature.dir, feature.id, undefined, context)));
287
+ out.push(...(await livingMergeConflicts(docsDir, await featureSpecServices(feature.dir, context), context)));
288
+ for (const scan of scans) {
289
+ if (scan.bare.length === 0)
290
+ continue;
291
+ out.push({
292
+ severity: "error",
293
+ code: "requirements.missing-scenarios",
294
+ subject: scan.service,
295
+ message: `${scan.label}: ${scan.bare.length} requirement(s) without a scenario`,
296
+ details: scan.bare.map((r) => r.name),
297
+ });
298
+ }
299
+ return out;
300
+ }
301
+ /**
302
+ * Would either gate refuse this? Severity answers `validate`'s question and
303
+ * `gates` answers archive's; a finding that fails EITHER is one an author has
304
+ * work to do about, and collapsing them to `severity === "error"` is how
305
+ * `status` came to print "ship it" beside a warning archive exits 1 on.
306
+ */
307
+ function unshippable(f) {
308
+ return f.severity === "error" || f.gates === true;
309
+ }
310
+ /**
311
+ * The feature's own rollup, worst first. The order is the order the work has to
312
+ * happen in, so the winning branch is also the phase the feature is in:
313
+ * unwritten artifacts come before an ordering problem (you can author while
314
+ * another feature is still in flight), an ordering problem comes before this
315
+ * feature's own errors (fixing them may be pointless until the other one
316
+ * lands), and everything comes before the record — answers written over a
317
+ * broken feature are answers to questions that are about to change.
318
+ *
319
+ * The record is excluded from the `missing` branch on purpose. It is not
320
+ * something an author writes: it is the done-check's output, and counting its
321
+ * absence as unwritten work would report every correctly-authored feature as
322
+ * `missing` right up until somebody ran `loam verify` — which is the state
323
+ * `ready` exists to name, and which the fleet form already names correctly.
324
+ */
325
+ function featureStage(feature, artifacts, services, blocking, blockedBy, verification, interrupted) {
326
+ // Ahead of `archived`, and ahead of everything else: a killed commit may be
327
+ // this very feature's, half-applied, so "it shipped" is a claim about files
328
+ // nobody has established the contents of. `blocked` is the honest word — the
329
+ // work is stalled on something that is not this feature's artifact — and it
330
+ // is what stops the projection printing `done` over a half-merged repo.
331
+ if (interrupted !== null)
332
+ return "blocked";
333
+ if (feature.archived)
334
+ return "done";
335
+ // A feature with no `specs/<service>/` changes nothing. There is no artifact
336
+ // row to be missing — the rows are per service — so the emptiness itself is
337
+ // the finding, and `next.touch-service` is the step.
338
+ if (services.length === 0)
339
+ return "missing";
340
+ if (artifacts.some((a) => a.id !== "verification" && a.status === "missing"))
341
+ return "missing";
342
+ if (blockedBy.length > 0)
343
+ return "blocked";
344
+ if (blocking > 0)
345
+ return "draft";
346
+ return fullyVerified(verification) ? "done" : "ready";
347
+ }
348
+ /**
349
+ * Is the done-check discharged? `verificationVerdict`'s answer and nobody
350
+ * else's — status re-deriving it would be a fourth place the words "verified"
351
+ * are defined, and the day one of them changed (a scenario claim on an agent's
352
+ * word stopped counting) the reader's copy would be the one still saying yes.
353
+ * `attested` is deliberately NOT `done` here: it is complete work resting on an
354
+ * assertion, which is what `ready` has always meant — the document is right and
355
+ * the world has not caught up.
356
+ */
357
+ function fullyVerified(v) {
358
+ return v.verdict === "verified";
359
+ }
360
+ /**
361
+ * Which artifact an error names.
362
+ *
363
+ * Attribution is by code family, because that is the only machine-readable
364
+ * pointer an `Issue` carries: `subject` narrows to a service, never to a file.
365
+ * The families are unambiguous — `c4-api.*`/`c4.*` and the two `delta.*` codes
366
+ * about the LikeC4 document itself grade the architecture axis, `openapi.*`
367
+ * grades the contract, and the remaining `delta.*` codes are the delta-shape
368
+ * checks, every one of which is about a requirement in a spec delta.
369
+ *
370
+ * An error that maps to nothing (or whose subject is unknown) still lands in
371
+ * `checks.issues` and still drives `next.fix-coherence` — it just does not turn
372
+ * any single artifact `draft`, because guessing which file to blame is how a
373
+ * reader gets sent to edit the wrong one.
374
+ */
375
+ function faultedArtifact(code) {
376
+ if (code === "delta.invalid" || code === "delta.nothing-tagged")
377
+ return "delta";
378
+ if (code.startsWith("c4-api.") || code.startsWith("c4."))
379
+ return "delta";
380
+ if (code.startsWith("openapi."))
381
+ return "openapi";
382
+ if (code.startsWith("delta.") || code.startsWith("spec-api."))
383
+ return "spec";
384
+ // The two families validate contributes: the frontmatter checks read only
385
+ // intent.md, and a requirement with no scenario is a requirement in a spec
386
+ // delta. Both name exactly one file, so both may turn a row `draft`.
387
+ if (code.startsWith("frontmatter."))
388
+ return "intent";
389
+ if (code === "requirements.missing-scenarios")
390
+ return "spec";
391
+ return null;
392
+ }
393
+ /**
394
+ * The artifact table. Every entry is graded by two questions in order — is it
395
+ * there, and does anything the shared checks reported name it — so a file that
396
+ * exists and is wrong can never be reported the way one that was never written
397
+ * is.
398
+ */
399
+ function featureArtifacts(docsDir, feature, services, blocking, verification, contracted, governs) {
400
+ const paths = featurePaths(feature.dir);
401
+ const rel = (abs) => repoPath(docsDir, abs);
402
+ const faults = blocking.map((f) => ({ artifact: faultedArtifact(f.code), subject: f.subject }));
403
+ const faulted = (id, service) => faults.some((f) => f.artifact === id && (service === null || f.subject === service));
404
+ const out = [
405
+ fileState("intent", null, rel(paths.intent), existsSync(paths.intent), true, faulted("intent", null)),
406
+ fileState("delta", null, rel(paths.delta), existsSync(paths.delta), false, faulted("delta", null)),
407
+ ];
408
+ for (const svc of services) {
409
+ const p = featureSpecPaths(feature.dir, svc);
410
+ const specFault = faulted("spec", svc);
411
+ out.push(fileState("spec", svc, rel(p.spec), existsSync(p.spec), true, specFault));
412
+ out.push(fileState("arch-spec", svc, rel(p.archSpec), existsSync(p.archSpec), false, specFault));
413
+ out.push(fileState("openapi", svc, rel(p.openapi), existsSync(p.openapi), owesContract(docsDir, svc, contracted, governs.has(svc)), faulted("openapi", svc)));
414
+ }
415
+ // The record is the one artifact with a real prerequisite: its checklist is
416
+ // derived from the delta and the per-service deltas, so with none of them
417
+ // written there is nothing to answer — authoring one is impossible rather
418
+ // than merely premature, which is the whole distinction `blocked` carries.
419
+ const feeders = out.filter((a) => a.id !== "intent" && a.id !== "arch-spec" && a.exists);
420
+ out.push({
421
+ id: "verification",
422
+ service: null,
423
+ path: rel(verificationPath(feature.dir)),
424
+ exists: verification.state !== "absent",
425
+ required: true,
426
+ status: feeders.length === 0 ? "blocked" : verificationStatus(verification),
427
+ blockedBy: feeders.length === 0 ? ["delta", "spec", "openapi"] : [],
428
+ });
429
+ return out;
430
+ }
431
+ function fileState(id, service, path, exists, required, faulted) {
432
+ const status = !exists
433
+ ? required
434
+ ? "missing"
435
+ : "done"
436
+ : faulted
437
+ ? "draft"
438
+ : "done";
439
+ return { id, service, path, exists, required, status, blockedBy: [] };
440
+ }
441
+ /**
442
+ * Does this feature owe `svc` an openapi.yaml?
443
+ *
444
+ * Keyed on the living CONTRACT FILE, never on `services/<svc>/` merely existing
445
+ * as a directory. A service adopted without a contract is precisely the case
446
+ * where a feature that governs operations has to write one, and the directory
447
+ * test answered "none owed" there — printing `(not written — none owed)` beside
448
+ * the `spec-api.op-undefined` finding, in the same payload, about that very
449
+ * file, while suppressing the only step that names it.
450
+ *
451
+ * Two ways to owe nothing, both about somebody else discharging it: the living
452
+ * docs already carry the contract, or another feature in flight brings it — the
453
+ * same softening coherence applies with its `*-pending` codes, where an
454
+ * operation "defined by another feature still in flight" is an ordering fact
455
+ * and not a hole. And one way to owe nothing at all: a service with no living
456
+ * contract that this feature sends no operation to. A UI or a worker nobody
457
+ * calls has no API to write down, which is the reading `validate` and `list`
458
+ * already take — demanding one from a brand-new one of those would be loam
459
+ * telling an author to invent a contract.
460
+ */
461
+ function owesContract(docsDir, svc, contracted, governsOperations) {
462
+ if (existsSync(servicePaths(docsDir, svc).openapi))
463
+ return false;
464
+ if (contracted.has(svc))
465
+ return false;
466
+ return !existsSync(servicePaths(docsDir, svc).dir) || governsOperations;
467
+ }
468
+ /**
469
+ * Services some OTHER active feature already carries an `openapi.yaml` for.
470
+ * Enumeration only — the feature list and its `specs/` subdirectories are
471
+ * already in the request-scoped index by the time anything asks.
472
+ */
473
+ async function contractsInFlight(docsDir, self, context) {
474
+ const out = new Set();
475
+ for (const f of await listFeatures(docsDir, {}, context)) {
476
+ if (f.id === self)
477
+ continue;
478
+ for (const svc of f.services) {
479
+ if (existsSync(featureSpecPaths(f.dir, svc).openapi))
480
+ out.add(svc);
481
+ }
482
+ }
483
+ return out;
484
+ }
485
+ /**
486
+ * The record's own rung. `stale` and `unreadable` are `draft` because both mean
487
+ * the file on disk is the thing to fix; unconfirmed claims are `ready` because
488
+ * the file is fine and the answer lives in the code.
489
+ */
490
+ function verificationStatus(v) {
491
+ if (v.state === "absent")
492
+ return "missing";
493
+ if (v.state === "unreadable" || v.state === "stale")
494
+ return "draft";
495
+ return fullyVerified(v) ? "done" : "ready";
496
+ }
497
+ /**
498
+ * Read the record and, only when there is one, ask whether it still answers
499
+ * this feature. The checklist is derived on demand and never speculatively: it
500
+ * loads the delta into a LikeC4 workspace, and paying that in order to print
501
+ * "no record" is the cost `list` refused for its verification column.
502
+ *
503
+ * An archived feature's record is frozen history — archive merged its
504
+ * operations into the living contract, so a re-derived checklist can only
505
+ * mismatch, and calling it stale would slander every feature that shipped.
506
+ */
507
+ async function verificationState(docsDir, feature) {
508
+ const empty = {
509
+ recorded: null,
510
+ verdict: "unverified",
511
+ claims: 0,
512
+ confirmed: 0,
513
+ unconfirmed: 0,
514
+ unanswered: 0,
515
+ attested: 0,
516
+ };
517
+ const read = await readVerificationState(feature.dir);
518
+ if (read.state === "absent")
519
+ return { state: { state: "absent", ...empty }, notice: null };
520
+ // A record whose summary contradicts its own claims is refused by the reader
521
+ // itself (`verify.record-miscounted`), so this branch reports zeros rather
522
+ // than salvaging counts from a file nobody can say which half of was edited.
523
+ // Where there ARE counts they are `tallyRecord`'s, never the summary's — the
524
+ // block used to be copied out verbatim, and a record `verify --json` calls
525
+ // unverified read here as "11 of 11 confirmed".
526
+ if (read.state === "unreadable")
527
+ return { state: { state: "unreadable", ...empty }, notice: null };
528
+ const v = read.verification;
529
+ const stale = feature.archived
530
+ ? false
531
+ : (await featureChecklist(docsDir, feature.dir, feature.id)).digest !== v.checklist;
532
+ const tally = tallyRecord(v);
533
+ return {
534
+ state: {
535
+ state: stale ? "stale" : "recorded",
536
+ recorded: v.recorded,
537
+ verdict: verificationVerdict(tally, stale),
538
+ claims: tally.claims,
539
+ confirmed: tally.confirmed,
540
+ unconfirmed: tally.unconfirmed,
541
+ unanswered: tally.unanswered,
542
+ attested: tally.attested,
543
+ },
544
+ notice: attestedNotice(v.claims, feature.id),
545
+ };
546
+ }
547
+ /* ------------------------------------------------------------------ */
548
+ /* next[] — one feature */
549
+ /* ------------------------------------------------------------------ */
550
+ /** Codes whose one fix is `loam rebase`: a pin nobody ever wrote, on either axis. */
551
+ const UNPINNED = new Set(["delta.baseline-missing", "openapi.baseline-missing"]);
552
+ function featureNext(feature, services, artifacts, findings, blockedBy, verification, scans, boundService, interrupted) {
553
+ const id = feature.id;
554
+ // Before the archived shortcut: the interrupted commit may be this feature's
555
+ // own, and "it shipped" is not a thing to tell somebody whose living docs are
556
+ // half-written.
557
+ const first = interrupted === null ? [] : [recoverStep(interrupted)];
558
+ if (feature.archived) {
559
+ return [
560
+ ...first,
561
+ {
562
+ code: "next.archived",
563
+ statement: `${id} is archived — it shipped, and its delta is already folded into the living docs.`,
564
+ command: `loam show ${id} --json`,
565
+ },
566
+ ];
567
+ }
568
+ const steps = [...first];
569
+ const owed = (kind, service) => artifacts.find((a) => a.id === kind && a.status === "missing" && (service === undefined || a.service === service));
570
+ const intent = owed("intent");
571
+ if (intent !== undefined) {
572
+ steps.push({
573
+ code: "next.author-intent",
574
+ statement: `Write ${intent.path} — what ${id} is for, in prose, before anything is derived from it.`,
575
+ command: `loam validate ${id} --json`,
576
+ artifact: "intent",
577
+ path: intent.path,
578
+ });
579
+ }
580
+ if (services.length === 0) {
581
+ steps.push({
582
+ code: "next.touch-service",
583
+ statement: `${id} carries no per-service delta — add specs/<service>/spec.md for every service it changes.`,
584
+ command: "loam list services --json",
585
+ });
586
+ }
587
+ for (const svc of services) {
588
+ const spec = owed("spec", svc);
589
+ if (spec !== undefined) {
590
+ steps.push({
591
+ code: "next.author-spec",
592
+ statement: `Write ${spec.path} — the ADDED/MODIFIED/REMOVED requirements ${id} makes to ${svc}, each with a scenario.`,
593
+ command: `loam delta ${id} --service ${svc} --json`,
594
+ artifact: "spec",
595
+ service: svc,
596
+ path: spec.path,
597
+ });
598
+ }
599
+ const api = owed("openapi", svc);
600
+ if (api !== undefined) {
601
+ steps.push({
602
+ code: "next.author-openapi",
603
+ // Not "the service is not in the living docs": a service adopted
604
+ // without a contract is the commoner case, and that wording sent a
605
+ // reader looking for a directory that was right there.
606
+ statement: `Write ${api.path} — nothing gives ${svc} a living openapi.yaml, so this feature is the only thing that can define the operations it governs.`,
607
+ command: `loam delta ${id} --service ${svc} --json`,
608
+ artifact: "openapi",
609
+ service: svc,
610
+ path: api.path,
611
+ });
612
+ }
613
+ }
614
+ // A requirement with no scenario yields no generated .feature and no
615
+ // scenario.tested claim: a promise that reaches neither the suite nor the
616
+ // done-check, and that nothing downstream will ever mention again. One entry
617
+ // per service rather than per requirement — a delta with forty bare
618
+ // requirements would otherwise BE the payload (validate caps its details for
619
+ // the same reason).
620
+ for (const gap of scans) {
621
+ if (gap.bare.length === 0 || !services.includes(gap.service))
622
+ continue;
623
+ steps.push({
624
+ code: "next.author-scenarios",
625
+ statement: `${gap.bare.length} requirement(s) in ${gap.service}'s delta have no '#### Scenario:' ` +
626
+ `(starting with '${gap.bare[0].name}') — they generate no test and no verify claim.`,
627
+ command: `loam delta ${id} --service ${gap.service} --json`,
628
+ artifact: "spec",
629
+ service: gap.service,
630
+ path: gap.path,
631
+ });
632
+ }
633
+ if (findings.some((f) => UNPINNED.has(f.code))) {
634
+ steps.push({
635
+ code: "next.rebase",
636
+ statement: `${id} carries requirements or operations with no baseline pin — until they have one the merge cannot tell what it EDITS from what it merely quotes.`,
637
+ command: `loam rebase ${id}`,
638
+ });
639
+ }
640
+ if (blockedBy.length > 0) {
641
+ steps.push({
642
+ code: "next.archive-first",
643
+ statement: `${id} builds on ${blockedBy.join(", ")}, which ${blockedBy.length === 1 ? "has" : "have"} to archive first.`,
644
+ command: `loam dependencies ${id} --json`,
645
+ });
646
+ }
647
+ // Everything either gate refuses on, not just the errors: a warning marked
648
+ // `gates: true` stops archive exactly as hard, and leaving it out of the
649
+ // steps was the other half of leaving it out of the stage.
650
+ const blocking = findings.filter(unshippable);
651
+ if (blocking.length > 0) {
652
+ const gating = blocking.filter((f) => f.gates === true && f.severity !== "error").length;
653
+ steps.push({
654
+ code: "next.fix-coherence",
655
+ statement: `${id} has ${blocking.length} finding(s) that stop it, starting with ${blocking[0].code}` +
656
+ (gating > 0
657
+ ? ` — ${gating} of them ${gating === 1 ? "is a warning that GATES" : "are warnings that GATE"} archive, ` +
658
+ "which `validate` grades valid, so its exit code alone will not show you why archive refuses."
659
+ : " — the axes disagree, and archive refuses."),
660
+ command: `loam validate ${id} --json`,
661
+ });
662
+ }
663
+ // The implementation half of the forward flow, which next[] never named at
664
+ // all: an agent read "author the spec" and then "record the verification",
665
+ // with nothing in between saying where a test comes from — so the only way
666
+ // left to answer a scenario claim was its own word, through `--record`.
667
+ steps.push(...testStep(id, services, scans, verification, boundService));
668
+ steps.push(...verifyStep(id, artifacts, verification, boundService, services));
669
+ // Always last, and always present: a feature whose every artifact holds and
670
+ // whose record is complete still has one thing left to do, and an empty
671
+ // `next[]` would leave a caller unable to tell "finished" from "this command
672
+ // had nothing to say".
673
+ steps.push({
674
+ code: "next.archive",
675
+ statement: steps.length === 0
676
+ ? `${id} is authored, coherent and verified — ship it.`
677
+ : `${id} ships once everything above is done; the dry run writes nothing and lists every file the merge would touch.`,
678
+ command: `loam archive ${id} --dry-run --json`,
679
+ });
680
+ return steps;
681
+ }
682
+ /**
683
+ * The done-check step, or none. Four different situations reach `loam verify`
684
+ * and they are not the same work — nothing recorded, a file that will not read,
685
+ * a record the feature has moved out from under, and claims still open — so the
686
+ * statement says which. The code splits only where the WORK splits: starting a
687
+ * verification and finishing one are different jobs, and an agent that treats a
688
+ * stale record as a finished one ships a feature nobody checked.
689
+ */
690
+ function verifyStep(id, artifacts, verification, boundService, services) {
691
+ const record = artifacts.find((a) => a.id === "verification");
692
+ const open = verification.unconfirmed + verification.unanswered;
693
+ // The code is spelled at each call site as a `code:` property rather than
694
+ // passed positionally, and the helper takes the whole pair: test/codes-drift.ts
695
+ // collects the vocabulary by reading the source for that exact shape, so a
696
+ // factory with a `code` PARAMETER would hide every code it emits from the one
697
+ // guard that checks they are documented. Same DRY-ness, visible to the guard.
698
+ const step = (s) => [
699
+ { ...s, command: `loam verify ${id} --json`, artifact: "verification", path: record.path },
700
+ ];
701
+ if (record.status === "blocked") {
702
+ return step({
703
+ code: "next.verify",
704
+ statement: `${id} has nothing to derive a checklist from yet — write the delta and the requirement deltas first.`,
705
+ });
706
+ }
707
+ if (verification.state === "absent") {
708
+ return step({
709
+ code: "next.verify",
710
+ statement: `Nothing has been verified for ${id} — derive the checklist and answer every claim with evidence.`,
711
+ });
712
+ }
713
+ if (verification.state === "unreadable") {
714
+ return step({
715
+ code: "next.verify",
716
+ statement: `${record.path} exists but does not read as a record — repair or delete it; nothing will overwrite it while it is unreadable.`,
717
+ });
718
+ }
719
+ if (verification.state === "stale") {
720
+ return step({
721
+ code: "next.verify",
722
+ statement: `${id} moved after ${record.path} was written — the record answers a checklist that is no longer the one being asked.`,
723
+ });
724
+ }
725
+ if (open > 0) {
726
+ // The recording form only when this repository is one of the services that
727
+ // owes an answer: `verify --record --service` binds the answers to THIS
728
+ // repo's HEAD and refuses anywhere else, so handing a docs-repo reader that
729
+ // command would name a step it cannot take.
730
+ return boundService !== undefined && services.includes(boundService)
731
+ ? [
732
+ {
733
+ code: "next.attest-service",
734
+ statement: `${open} of ${verification.claims} claim(s) on ${id} are open, and this repository is '${boundService}' — ` +
735
+ `answer the ones filed under it, bound to this commit; the rest belong to their own repositories.`,
736
+ command: `loam verify ${id} --record answers.json --service ${boundService}`,
737
+ artifact: "verification",
738
+ service: boundService,
739
+ path: record.path,
740
+ },
741
+ ]
742
+ : step({
743
+ code: "next.verify-unconfirmed",
744
+ statement: `${open} of ${verification.claims} claim(s) on ${id} are not confirmed — close them from each affected service's own repository.`,
745
+ });
746
+ }
747
+ if (verification.claims === 0) {
748
+ return step({
749
+ code: "next.verify",
750
+ statement: `${record.path} answers no claims at all — nothing was asked, so nothing was checked; re-derive the checklist and see what this feature actually promises.`,
751
+ });
752
+ }
753
+ if (verification.verdict === "attested") {
754
+ return [
755
+ {
756
+ code: "next.verify-attested",
757
+ statement: `${verification.attested} of ${id}'s scenario claim(s) are confirmed on an agent's word, not on a test run ` +
758
+ `(verify.scenario-attested) — answer them from a cucumber report, and the record stops resting on an assertion.`,
759
+ command: `loam verify ${id} --results <cucumber-report.json>`,
760
+ artifact: "verification",
761
+ path: record.path,
762
+ },
763
+ ];
764
+ }
765
+ return [];
766
+ }
767
+ /**
768
+ * Where a test comes from. `loam gherkin` writes the .feature files whose green
769
+ * run is the only thing that may answer a `scenario.tested` claim, and it runs
770
+ * from the service's own repository — so the step exists wherever there are
771
+ * scenarios to generate from, and names the repository to run it in when that
772
+ * is not this one.
773
+ */
774
+ function testStep(id, services, scans, verification, boundService) {
775
+ if (fullyVerified(verification))
776
+ return [];
777
+ const withScenarios = [
778
+ ...new Set(scans.filter((s) => s.scenarios > 0 && services.includes(s.service)).map((s) => s.service)),
779
+ ];
780
+ if (withScenarios.length === 0)
781
+ return [];
782
+ const svc = boundService !== undefined && withScenarios.includes(boundService) ? boundService : withScenarios[0];
783
+ const here = svc === boundService;
784
+ return [
785
+ {
786
+ code: "next.generate-tests",
787
+ statement: `${id}'s delta for ${svc} carries scenarios that no test run has answered yet — generate the suite, then implement against it` +
788
+ (here
789
+ ? "."
790
+ : `. \`loam gherkin\` writes into the service's own repository, so run it from ${svc}'s${withScenarios.length > 1 ? ` (and likewise for ${withScenarios.slice(1).join(", ")})` : ""}.`),
791
+ command: `loam gherkin ${id} --service ${svc}`,
792
+ artifact: "spec",
793
+ service: svc,
794
+ },
795
+ ];
796
+ }
797
+ async function scanDeltas(docsDir, feature, services, context) {
798
+ const out = [];
799
+ for (const svc of services) {
800
+ const p = featureSpecPaths(feature.dir, svc);
801
+ for (const [file, label] of [
802
+ [p.spec, `${svc}: requirements`],
803
+ [p.archSpec, `${svc}: arch requirements`],
804
+ ]) {
805
+ if (!existsSync(file))
806
+ continue;
807
+ const reqs = await context.readRequirements(file);
808
+ out.push({
809
+ service: svc,
810
+ path: repoPath(docsDir, file),
811
+ label,
812
+ bare: requirementsMissingScenarios(reqs),
813
+ scenarios: reqs.reduce((n, r) => n + r.scenarios.length, 0),
814
+ operations: file === p.spec
815
+ ? reqs.filter((r) => r.kind !== "REMOVED").reduce((n, r) => n + r.operations.length, 0)
816
+ : 0,
817
+ });
818
+ }
819
+ }
820
+ return out;
821
+ }
822
+ /** Services this feature sends at least one operation to — see {@link owesContract}. */
823
+ function governedServices(scans) {
824
+ return new Set(scans.filter((s) => s.operations > 0).map((s) => s.service));
825
+ }
826
+ /* ------------------------------------------------------------------ */
827
+ /* The whole repository */
828
+ /* ------------------------------------------------------------------ */
829
+ /**
830
+ * The fleet view: how much of `services/` anyone has written down, every
831
+ * feature in flight with the stage it has reached, and the order they have to
832
+ * land in. Nothing here is graded — the module header says why — so the stages
833
+ * are drawn from artifact presence and from a record that already exists, and
834
+ * `next[]` hands off to `loam status <FEAT>` for everything else.
835
+ */
836
+ export async function fleetStatus(docsDir, opts = {}) {
837
+ const context = opts.context ?? new FleetContext();
838
+ const interrupted = await readInterruptedCommit(docsDir);
839
+ const narrowed = opts.service;
840
+ const all = await listServices(docsDir, context);
841
+ const services = narrowed === undefined ? all : all.filter((s) => s.id === narrowed);
842
+ const graph = await analyzeDependencies(docsDir, undefined, context);
843
+ const entries = await listFeatures(docsDir, {}, context);
844
+ const inScope = narrowed === undefined ? entries : entries.filter((f) => f.services.includes(narrowed));
845
+ const features = await Promise.all(inScope.map(async (f) => fleetFeature(docsDir, f, graph, await contractsInFlight(docsDir, f.id, context), context)));
846
+ features.sort((a, b) => compareIds(a.id, b.id));
847
+ return {
848
+ interrupted,
849
+ services: {
850
+ total: services.length,
851
+ undocumented: services.filter(undocumented).length,
852
+ draft: services.filter((s) => !undocumented(s) && !vouched(s)).length,
853
+ vouched: services.filter(vouched).length,
854
+ },
855
+ features,
856
+ order: graph.order.filter((id) => features.some((f) => f.id === id)),
857
+ service: narrowed ?? null,
858
+ next: fleetNext(services, features, graph, interrupted),
859
+ };
860
+ }
861
+ /** Nothing is written down: no living spec at all. Every other absence is partial adoption. */
862
+ function undocumented(s) {
863
+ return !s.has.spec;
864
+ }
865
+ /** A person stamped it — `loam vouch`'s exact postcondition, read back off the frontmatter. */
866
+ function vouched(s) {
867
+ return s.status === "verified" && s.sources.stamped;
868
+ }
869
+ async function fleetFeature(docsDir, feature, graph, contracted, context) {
870
+ const paths = featurePaths(feature.dir);
871
+ const missing = [];
872
+ if (!existsSync(paths.intent))
873
+ missing.push("intent");
874
+ if (feature.services.length === 0)
875
+ missing.push("spec");
876
+ // Requirement text, not a coherence run: `Operations:` lines are read off the
877
+ // same cached parse `list` already pays for, and the contract question needs
878
+ // them (owesContract). No LikeC4 workspace is spun here, which is the cost
879
+ // this form actually refuses.
880
+ const governs = governedServices(await scanDeltas(docsDir, feature, feature.services, context));
881
+ for (const svc of feature.services) {
882
+ const p = featureSpecPaths(feature.dir, svc);
883
+ if (!existsSync(p.spec))
884
+ missing.push(`${svc}/spec`);
885
+ // The same question featureArtifacts asks, through the same function: the
886
+ // two forms must never disagree about whether a feature owes a contract.
887
+ if (owesContract(docsDir, svc, contracted, governs.has(svc)) && !existsSync(p.openapi)) {
888
+ missing.push(`${svc}/openapi`);
889
+ }
890
+ }
891
+ const verification = (await verificationState(docsDir, feature)).state;
892
+ const blockedBy = graph.nodes.find((n) => n.id === feature.id)?.dependsOn ?? [];
893
+ // No `draft` here, ever: that verdict needs the coherence run this form
894
+ // refuses to pay for, and inferring it from presence alone would be a second
895
+ // opinion about validity — the one thing status must never invent.
896
+ const stage = missing.length > 0
897
+ ? "missing"
898
+ : blockedBy.length > 0
899
+ ? "blocked"
900
+ : fullyVerified(verification)
901
+ ? "done"
902
+ : "ready";
903
+ return {
904
+ id: feature.id,
905
+ dirName: feature.dirName,
906
+ path: repoPath(docsDir, feature.dir),
907
+ stage,
908
+ services: feature.services,
909
+ blockedBy,
910
+ missing,
911
+ verification,
912
+ };
913
+ }
914
+ /** How urgent a stage is at fleet level. See {@link fleetNext}. */
915
+ const STAGE_RANK = {
916
+ done: 0,
917
+ ready: 1,
918
+ draft: 2,
919
+ missing: 3,
920
+ blocked: 4,
921
+ };
922
+ /**
923
+ * How many per-service and per-feature steps the fleet form will print before
924
+ * it stops and says how many it left out. A fleet mid-adoption produced 135
925
+ * entries under the heading "the next thing to do" — a list nobody reads is a
926
+ * list that is not navigation, and the first ten are the ten that matter
927
+ * because the ordering below is already most-unblocking-first.
928
+ *
929
+ * Exported so the test asserts the cap through the constant: a literal 10 in a
930
+ * fixture is a second declaration of the limit, free to disagree the day this
931
+ * one moves.
932
+ */
933
+ export const FLEET_NEXT_LIMIT = 10;
934
+ /**
935
+ * Fleet-level steps, most-unblocking first. A feature ready to ship comes
936
+ * before one that still needs authoring, because archiving it is what releases
937
+ * everything queued behind it; a blocked feature comes last, because there is
938
+ * nothing to do about it from here — its prerequisite is already higher up the
939
+ * list under its own entry.
940
+ *
941
+ * The gate is the LAST entry and it is always there. `next.fleet-clean` used to
942
+ * be the only route to `loam validate --all`, and it was emitted only when
943
+ * nothing was in flight — so a repository that FAILS the fleet gate, which is
944
+ * every repository with work in it, was handed a green-looking list that never
945
+ * once named the command CI runs. This form grades nothing; the least it can do
946
+ * is say where grading happens.
947
+ */
948
+ function fleetNext(services, features, graph, interrupted) {
949
+ const steps = [];
950
+ for (const s of services.filter(undocumented)) {
951
+ steps.push({
952
+ code: "next.adopt",
953
+ statement: `services/${s.id}/ has no spec.md — nothing about ${s.id} is written down, so no feature can be graded against it.`,
954
+ command: `loam adopt --service ${s.id} --json`,
955
+ service: s.id,
956
+ });
957
+ }
958
+ // Adoption does not end at spec.md. A service with a living spec and no
959
+ // model.likec4 is the state archive itself warns about (`service.no-model`)
960
+ // and the fleet gate reports — invisible here until now, because this form
961
+ // looked at exactly one bit of each service's artifact set.
962
+ for (const s of services.filter((x) => !undocumented(x) && !x.has.model)) {
963
+ steps.push({
964
+ code: "next.complete-service",
965
+ statement: `services/${s.id}/ has a spec.md but no model.likec4 — the fleet gate reports the service incomplete until something models it.`,
966
+ command: `loam validate --service ${s.id} --json`,
967
+ service: s.id,
968
+ });
969
+ }
970
+ const ordered = [...features].sort((a, b) => STAGE_RANK[a.stage] - STAGE_RANK[b.stage] ||
971
+ graph.order.indexOf(a.id) - graph.order.indexOf(b.id) ||
972
+ compareIds(a.id, b.id));
973
+ for (const f of ordered) {
974
+ if (f.stage === "done") {
975
+ steps.push({
976
+ code: "next.archive",
977
+ statement: `${f.id} is authored and verified — ship it, and everything waiting on it is released.`,
978
+ command: `loam archive ${f.id} --dry-run --json`,
979
+ });
980
+ continue;
981
+ }
982
+ steps.push({
983
+ code: "next.feature",
984
+ statement: f.stage === "blocked"
985
+ ? `${f.id} is waiting on ${f.blockedBy.join(", ")}.`
986
+ : `${f.id} is at '${f.stage}'${f.missing.length > 0 ? ` — missing ${f.missing.join(", ")}` : ""}.`,
987
+ command: `loam status ${f.id} --json`,
988
+ });
989
+ }
990
+ const elided = Math.max(0, steps.length - FLEET_NEXT_LIMIT);
991
+ // Outside the cap, not merely first inside it: the one step that must never
992
+ // be the one this list left out.
993
+ const out = [...(interrupted === null ? [] : [recoverStep(interrupted)]), ...steps.slice(0, FLEET_NEXT_LIMIT)];
994
+ if (elided > 0) {
995
+ out.push({
996
+ code: "next.elided",
997
+ statement: `${elided} more step(s) of the same kinds were left out — this list is ordered most-unblocking first, so work down it and run status again.`,
998
+ command: "loam list --json",
999
+ });
1000
+ }
1001
+ out.push(steps.length === 0
1002
+ ? {
1003
+ code: "next.fleet-clean",
1004
+ statement: "Nothing is in flight and every service is written down — keep the fleet green, then start the next feature.",
1005
+ command: "loam validate --all --json",
1006
+ }
1007
+ : {
1008
+ code: "next.fleet-gate",
1009
+ statement: `${features.length} feature(s) in flight over ${services.length} service(s) — ` +
1010
+ "status grades none of them; `validate --all` is the gate this repository has to pass, and it is what CI runs.",
1011
+ command: "loam validate --all --json",
1012
+ });
1013
+ return out;
1014
+ }
1015
+ //# sourceMappingURL=status.js.map