@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,270 @@
1
+ import { loadConfig } from "../core/config.js";
2
+ import { FleetContext } from "../core/fleet-context.js";
3
+ import { emitJson, fail, reportNoConfig } from "../core/json.js";
4
+ import { findingJson } from "../core/report.js";
5
+ import { DocsRepoUnavailableError, featureCandidates, missingFeatureMessage, resolveFeature, } from "../core/repo.js";
6
+ import { featureStatus, fleetStatus, } from "../core/status.js";
7
+ import { docsRepoReady, reportDocsRepoError } from "./validate.js";
8
+ export function registerStatus(program) {
9
+ program
10
+ .command("status")
11
+ .argument("[feature]", "feature id or directory name; omit for the whole repository")
12
+ .description("Report where the work stands and what to do next, derived from the files")
13
+ .option("--json", "emit the machine contract instead of the human view")
14
+ .option("--service <id>", "narrow the per-service view to one service")
15
+ .action(async (featureArg, opts) => {
16
+ const json = opts.json === true;
17
+ const config = await loadConfig();
18
+ if (!config) {
19
+ reportNoConfig(json);
20
+ return;
21
+ }
22
+ const { docsDir } = config;
23
+ // A docsDir that is not a docs repo is refused, never rendered as a repo
24
+ // with nothing in it — the doctrine validate.ts's docsRepoReady exists
25
+ // for. `status` counts services, so it needs services/ in both modes.
26
+ if (!docsRepoReady(json, docsDir, "services"))
27
+ return;
28
+ const context = new FleetContext();
29
+ // What the refusal below says it was answering when it gave up.
30
+ let scope = "this repository";
31
+ try {
32
+ if (featureArg === undefined) {
33
+ const report = await fleetStatus(docsDir, { service: opts.service, context });
34
+ // A `--service` that names nothing is refused rather than answered
35
+ // with an empty fleet: "no services and no features" and "you
36
+ // misspelled it" are opposite facts, and only one of them is worth
37
+ // acting on.
38
+ if (opts.service !== undefined && report.services.total === 0) {
39
+ fail(json, "unknown-service", `No service '${opts.service}' under ${docsDir}/services/.`);
40
+ return;
41
+ }
42
+ if (json)
43
+ emitJson({ command: "status", docsDir, scope: "fleet", ...fleetJson(report) });
44
+ else
45
+ printFleet(report);
46
+ return;
47
+ }
48
+ // Archived features are IN scope: an agent asking where FEAT-101 stands
49
+ // after it shipped deserves "it shipped", not "no such feature" — the
50
+ // same reading `show` takes. An argument naming two directories is
51
+ // still answered (status writes nothing), with the tie broken the way
52
+ // resolveFeature always breaks it.
53
+ const feature = await resolveFeature(docsDir, featureArg, "include", context);
54
+ if (!feature) {
55
+ fail(json, "unknown-target", await missingFeatureMessage(docsDir, featureArg, context));
56
+ return;
57
+ }
58
+ scope = feature.id;
59
+ // `config.service` is not a lens like `--service`: it says which
60
+ // repository this IS, and three of the steps (`loam gherkin`, `verify
61
+ // --record --service`) refuse anywhere else. Without it status handed a
62
+ // docs-repo reader commands only a service repo can run, and never told
63
+ // a service repo that the work in front of it was its own.
64
+ const report = await featureStatus(docsDir, feature, {
65
+ service: opts.service,
66
+ boundService: config.service,
67
+ context,
68
+ });
69
+ if (opts.service !== undefined && !report.feature.services.includes(opts.service)) {
70
+ fail(json, "unknown-service", `${feature.id} carries no delta for '${opts.service}'. It touches: ${report.feature.services.length > 0 ? report.feature.services.join(", ") : "(no services)"}.`);
71
+ return;
72
+ }
73
+ const ambiguous = await featureCandidates(docsDir, featureArg, "include", context);
74
+ if (json) {
75
+ emitJson({ command: "status", docsDir, scope: "feature", ...featureJson(report) });
76
+ }
77
+ else {
78
+ printFeature(report, ambiguous.length > 1 ? ambiguous.map((c) => c.dirName) : []);
79
+ }
80
+ }
81
+ catch (err) {
82
+ if (err instanceof DocsRepoUnavailableError) {
83
+ reportDocsRepoError(json, err);
84
+ return;
85
+ }
86
+ // One unreadable artifact must not escape as a stack trace and an
87
+ // `internal` envelope that names nothing: the projection is
88
+ // all-or-nothing (a status missing one artifact reads as a feature that
89
+ // does not owe it), so the honest answer is a refusal naming the path.
90
+ //
91
+ // A filesystem failure is recognised by its errno, NOT by carrying a
92
+ // `path`. Node reports EISDIR from `read()` with no path at all — the
93
+ // directory opened, the read failed — so the whole point of this branch
94
+ // was defeated by the commonest way a malformed docs repo breaks: one
95
+ // directory where a file belongs killed BOTH forms of status with a bare
96
+ // `internal: EISDIR`, naming neither the file nor the feature. Anything
97
+ // without an errno is a real bug and still escapes.
98
+ const e = err;
99
+ if (e.path === undefined && typeof e.errno !== "number")
100
+ throw err;
101
+ const why = err instanceof Error ? err.message : String(err);
102
+ fail(json, "repository-unavailable", e.path !== undefined
103
+ ? `${e.path} could not be read, so the status of ${scope} would be missing an artifact. ${why}`
104
+ : // Naming the target's own directory here would be a guess, and a
105
+ // guess that is wrong precisely when it matters: one malformed
106
+ // feature makes every OTHER feature's status fail too, and each
107
+ // would have accused its own files. Say what is known instead.
108
+ `A file under ${docsDir} could not be read, so the status of ${scope} would be missing an artifact. ${why}. ` +
109
+ `The failure named no path — that is how Node reports a directory sitting where a file belongs.`);
110
+ }
111
+ // No exit code is set on any path above that succeeded, deliberately.
112
+ // `doctor` exits 1 because a broken installation makes every later
113
+ // command lie, and `validate` exits 1 because it IS the fleet gate. This
114
+ // command is a question, and its answer is equally true whether the
115
+ // feature is finished or not — an agent runs it precisely BECAUSE work is
116
+ // outstanding, and exiting 1 there would make every `set -e` script and
117
+ // every CI step read "there is work to do" as a failure.
118
+ });
119
+ }
120
+ /* ------------------------------------------------------------------ */
121
+ /* JSON */
122
+ /* ------------------------------------------------------------------ */
123
+ /**
124
+ * `issues` go out through `findingJson`, the same serializer `validate` uses:
125
+ * an `Issue` is a `Finding` without details, and one breach reported by two
126
+ * commands must arrive in one shape or a consumer needs two parsers.
127
+ */
128
+ function featureJson(r) {
129
+ return {
130
+ interrupted: r.interrupted,
131
+ feature: r.feature,
132
+ service: r.service,
133
+ artifacts: r.artifacts,
134
+ checks: { ...r.checks, issues: r.checks.issues.map(findingJson) },
135
+ verification: r.verification,
136
+ next: r.next,
137
+ };
138
+ }
139
+ function fleetJson(r) {
140
+ return {
141
+ interrupted: r.interrupted,
142
+ service: r.service,
143
+ services: r.services,
144
+ features: r.features,
145
+ order: r.order,
146
+ next: r.next,
147
+ };
148
+ }
149
+ /* ------------------------------------------------------------------ */
150
+ /* Text */
151
+ /* ------------------------------------------------------------------ */
152
+ function pad(values) {
153
+ return Math.max(0, ...values.map((v) => v.length));
154
+ }
155
+ /**
156
+ * The half-merged-repo banner, printed FIRST and on stderr.
157
+ *
158
+ * stderr, alone among this command's output, because it is not part of the
159
+ * answer: it is the reason the answer may be wrong. A reader piping `status`
160
+ * into a pager still sees it, and the human view stays the shape every other
161
+ * run has. `--json` carries the same fact in `interrupted`.
162
+ */
163
+ function printInterrupted(i) {
164
+ console.error(i.unreadable
165
+ ? "⚠ this docs repo holds a .loam-commit that cannot be read — a commit was interrupted and nothing here can be trusted until a human compares the living docs against version control."
166
+ : `⚠ 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 them.`);
167
+ console.error("");
168
+ }
169
+ /** `spec (payment-service)` — the artifact id plus the service, when it has one. */
170
+ function artifactLabel(a) {
171
+ return a.service === null ? a.id : `${a.id} (${a.service})`;
172
+ }
173
+ /**
174
+ * The counts come from the record's `claims:` array (verify's `tallyRecord`),
175
+ * never from its `summary:` block. The verdict is printed beside the state
176
+ * because they answer different questions and only together are they honest:
177
+ * `recorded 4/4 confirmed` used to be the whole line for a feature `verify`
178
+ * calls `attested` — complete, and resting on somebody's word about a test.
179
+ */
180
+ function verificationLine(v) {
181
+ if (v.state === "absent")
182
+ return "none";
183
+ if (v.state === "unreadable")
184
+ return "unreadable";
185
+ return (`${v.state} · ${v.verdict} ${v.confirmed}/${v.claims} confirmed` +
186
+ (v.unanswered > 0 ? ` · ${v.unanswered} unanswered` : "") +
187
+ (v.attested > 0 ? ` · ${v.attested} scenario(s) on an agent's word, not a test run` : "") +
188
+ ` (recorded ${v.recorded ?? "?"})`);
189
+ }
190
+ /**
191
+ * The steps, numbered, with the command on its own indented line — doctor's
192
+ * layout, for doctor's reason: what to do and what to type are two different
193
+ * sentences, and someone scanning a half-built feature reads down the `run:`
194
+ * column.
195
+ */
196
+ function printNext(steps) {
197
+ console.log("\n next");
198
+ for (const [i, step] of steps.entries()) {
199
+ console.log(` ${i + 1}. ${step.code}: ${step.statement}`);
200
+ console.log(` run: ${step.command}`);
201
+ }
202
+ }
203
+ function printFeature(r, ambiguous) {
204
+ if (r.interrupted !== null)
205
+ printInterrupted(r.interrupted);
206
+ const f = r.feature;
207
+ console.log(`loam status ${f.id} — ${f.stage}${f.archived ? " (archived)" : ""}`);
208
+ console.log(` feature ${f.dirName} ${f.path}`);
209
+ console.log(` services ${f.services.length > 0 ? f.services.join(", ") : "(none)"}`);
210
+ if (r.service !== null)
211
+ console.log(` narrowed to ${r.service}`);
212
+ if (f.blockedBy.length > 0)
213
+ console.log(` waiting on ${f.blockedBy.join(", ")}`);
214
+ console.log(` coherence ${r.checks.ran
215
+ ? `${r.checks.errors} error(s) · ${r.checks.warnings} warning(s) · ${r.checks.gating} gate archive`
216
+ : "not run (archived)"}`);
217
+ console.log(` verification ${verificationLine(r.verification)}`);
218
+ if (ambiguous.length > 0) {
219
+ console.log(` note '${f.id}' also names ${ambiguous.filter((d) => d !== f.dirName).join(", ")}`);
220
+ }
221
+ console.log("\n artifacts");
222
+ const labels = r.artifacts.map(artifactLabel);
223
+ const labelWidth = pad(labels);
224
+ const statusWidth = pad(r.artifacts.map((a) => a.status));
225
+ for (const [i, a] of r.artifacts.entries()) {
226
+ // `done` on a file that is not there is the one row a reader misreads: it
227
+ // means the feature owes no such artifact, not that one was written. The
228
+ // JSON contract says it with `exists`; the human view has to say it in
229
+ // words, or the table quietly claims files that do not exist.
230
+ const note = a.blockedBy.length > 0
231
+ ? ` (needs ${a.blockedBy.join(", ")})`
232
+ : !a.exists && a.status === "done"
233
+ ? " (not written — none owed)"
234
+ : "";
235
+ console.log(` ${a.status.padEnd(statusWidth)} ${labels[i].padEnd(labelWidth)} ${a.path}${note}`.trimEnd());
236
+ }
237
+ if (r.checks.issues.length > 0) {
238
+ console.log("\n findings");
239
+ for (const issue of r.checks.issues) {
240
+ console.log(` ${issue.severity === "error" ? "✗" : "⚠"} ${issue.code}: ${issue.message}`);
241
+ }
242
+ }
243
+ printNext(r.next);
244
+ }
245
+ function printFleet(r) {
246
+ if (r.interrupted !== null)
247
+ printInterrupted(r.interrupted);
248
+ const s = r.services;
249
+ console.log(`loam status — ${s.total} service(s) · ${r.features.length} feature(s) in flight`);
250
+ console.log(` services ${s.undocumented} undocumented · ${s.draft} draft · ${s.vouched} vouched`);
251
+ if (r.service !== null)
252
+ console.log(` narrowed to ${r.service}`);
253
+ if (r.order.length > 1)
254
+ console.log(` order ${r.order.join(" → ")}`);
255
+ if (r.features.length > 0) {
256
+ console.log("\n features");
257
+ const ids = pad(r.features.map((f) => f.id));
258
+ const stages = pad(r.features.map((f) => f.stage));
259
+ for (const f of r.features) {
260
+ const detail = f.blockedBy.length > 0
261
+ ? `waiting on ${f.blockedBy.join(", ")}`
262
+ : f.missing.length > 0
263
+ ? `missing ${f.missing.join(", ")}`
264
+ : f.services.join(", ");
265
+ console.log(` ${f.stage.padEnd(stages)} ${f.id.padEnd(ids)} ${detail}`.trimEnd());
266
+ }
267
+ }
268
+ printNext(r.next);
269
+ }
270
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam status` — the read-only projection. Nothing here computes anything: the\n * derivation lives in core/status.ts (and, underneath it, in the same coherence\n * and verification calls `validate`, `verify` and `archive` make). This file\n * resolves the target, refuses the arguments it cannot honour, and renders.\n *\n * It reports and does not gate, which is why it never sets a non-zero exit code\n * on a successful run — see `registerStatus`.\n */\nimport type { Command } from \"commander\";\nimport { loadConfig } from \"../core/config.js\";\nimport { FleetContext } from \"../core/fleet-context.js\";\nimport { emitJson, fail, reportNoConfig } from \"../core/json.js\";\nimport { findingJson } from \"../core/report.js\";\nimport {\n DocsRepoUnavailableError,\n featureCandidates,\n missingFeatureMessage,\n resolveFeature,\n} from \"../core/repo.js\";\nimport {\n featureStatus,\n fleetStatus,\n type ArtifactState,\n type FeatureStatusReport,\n type FleetStatusReport,\n type InterruptedCommit,\n type NextStep,\n type VerificationState,\n} from \"../core/status.js\";\nimport { docsRepoReady, reportDocsRepoError } from \"./validate.js\";\n\ninterface StatusOptions {\n json?: boolean;\n service?: string;\n}\n\nexport function registerStatus(program: Command): void {\n program\n .command(\"status\")\n .argument(\"[feature]\", \"feature id or directory name; omit for the whole repository\")\n .description(\"Report where the work stands and what to do next, derived from the files\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .option(\"--service <id>\", \"narrow the per-service view to one service\")\n .action(async (featureArg: string | undefined, opts: StatusOptions) => {\n const json = opts.json === true;\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n const { docsDir } = config;\n // A docsDir that is not a docs repo is refused, never rendered as a repo\n // with nothing in it — the doctrine validate.ts's docsRepoReady exists\n // for. `status` counts services, so it needs services/ in both modes.\n if (!docsRepoReady(json, docsDir, \"services\")) return;\n const context = new FleetContext();\n // What the refusal below says it was answering when it gave up.\n let scope = \"this repository\";\n\n try {\n if (featureArg === undefined) {\n const report = await fleetStatus(docsDir, { service: opts.service, context });\n // A `--service` that names nothing is refused rather than answered\n // with an empty fleet: \"no services and no features\" and \"you\n // misspelled it\" are opposite facts, and only one of them is worth\n // acting on.\n if (opts.service !== undefined && report.services.total === 0) {\n fail(json, \"unknown-service\", `No service '${opts.service}' under ${docsDir}/services/.`);\n return;\n }\n if (json) emitJson({ command: \"status\", docsDir, scope: \"fleet\", ...fleetJson(report) });\n else printFleet(report);\n return;\n }\n\n // Archived features are IN scope: an agent asking where FEAT-101 stands\n // after it shipped deserves \"it shipped\", not \"no such feature\" — the\n // same reading `show` takes. An argument naming two directories is\n // still answered (status writes nothing), with the tie broken the way\n // resolveFeature always breaks it.\n const feature = await resolveFeature(docsDir, featureArg, \"include\", context);\n if (!feature) {\n fail(json, \"unknown-target\", await missingFeatureMessage(docsDir, featureArg, context));\n return;\n }\n scope = feature.id;\n // `config.service` is not a lens like `--service`: it says which\n // repository this IS, and three of the steps (`loam gherkin`, `verify\n // --record --service`) refuse anywhere else. Without it status handed a\n // docs-repo reader commands only a service repo can run, and never told\n // a service repo that the work in front of it was its own.\n const report = await featureStatus(docsDir, feature, {\n service: opts.service,\n boundService: config.service,\n context,\n });\n if (opts.service !== undefined && !report.feature.services.includes(opts.service)) {\n fail(\n json,\n \"unknown-service\",\n `${feature.id} carries no delta for '${opts.service}'. It touches: ${\n report.feature.services.length > 0 ? report.feature.services.join(\", \") : \"(no services)\"\n }.`,\n );\n return;\n }\n const ambiguous = await featureCandidates(docsDir, featureArg, \"include\", context);\n if (json) {\n emitJson({ command: \"status\", docsDir, scope: \"feature\", ...featureJson(report) });\n } else {\n printFeature(report, ambiguous.length > 1 ? ambiguous.map((c) => c.dirName) : []);\n }\n } catch (err) {\n if (err instanceof DocsRepoUnavailableError) {\n reportDocsRepoError(json, err);\n return;\n }\n // One unreadable artifact must not escape as a stack trace and an\n // `internal` envelope that names nothing: the projection is\n // all-or-nothing (a status missing one artifact reads as a feature that\n // does not owe it), so the honest answer is a refusal naming the path.\n //\n // A filesystem failure is recognised by its errno, NOT by carrying a\n // `path`. Node reports EISDIR from `read()` with no path at all — the\n // directory opened, the read failed — so the whole point of this branch\n // was defeated by the commonest way a malformed docs repo breaks: one\n // directory where a file belongs killed BOTH forms of status with a bare\n // `internal: EISDIR`, naming neither the file nor the feature. Anything\n // without an errno is a real bug and still escapes.\n const e = err as NodeJS.ErrnoException;\n if (e.path === undefined && typeof e.errno !== \"number\") throw err;\n const why = err instanceof Error ? err.message : String(err);\n fail(\n json,\n \"repository-unavailable\",\n e.path !== undefined\n ? `${e.path} could not be read, so the status of ${scope} would be missing an artifact. ${why}`\n : // Naming the target's own directory here would be a guess, and a\n // guess that is wrong precisely when it matters: one malformed\n // feature makes every OTHER feature's status fail too, and each\n // would have accused its own files. Say what is known instead.\n `A file under ${docsDir} could not be read, so the status of ${scope} would be missing an artifact. ${why}. ` +\n `The failure named no path — that is how Node reports a directory sitting where a file belongs.`,\n );\n }\n // No exit code is set on any path above that succeeded, deliberately.\n // `doctor` exits 1 because a broken installation makes every later\n // command lie, and `validate` exits 1 because it IS the fleet gate. This\n // command is a question, and its answer is equally true whether the\n // feature is finished or not — an agent runs it precisely BECAUSE work is\n // outstanding, and exiting 1 there would make every `set -e` script and\n // every CI step read \"there is work to do\" as a failure.\n });\n}\n\n/* ------------------------------------------------------------------ */\n/* JSON */\n/* ------------------------------------------------------------------ */\n\n/**\n * `issues` go out through `findingJson`, the same serializer `validate` uses:\n * an `Issue` is a `Finding` without details, and one breach reported by two\n * commands must arrive in one shape or a consumer needs two parsers.\n */\nfunction featureJson(r: FeatureStatusReport): Record<string, unknown> {\n return {\n interrupted: r.interrupted,\n feature: r.feature,\n service: r.service,\n artifacts: r.artifacts,\n checks: { ...r.checks, issues: r.checks.issues.map(findingJson) },\n verification: r.verification,\n next: r.next,\n };\n}\n\nfunction fleetJson(r: FleetStatusReport): Record<string, unknown> {\n return {\n interrupted: r.interrupted,\n service: r.service,\n services: r.services,\n features: r.features,\n order: r.order,\n next: r.next,\n };\n}\n\n/* ------------------------------------------------------------------ */\n/* Text */\n/* ------------------------------------------------------------------ */\n\nfunction pad(values: string[]): number {\n return Math.max(0, ...values.map((v) => v.length));\n}\n\n/**\n * The half-merged-repo banner, printed FIRST and on stderr.\n *\n * stderr, alone among this command's output, because it is not part of the\n * answer: it is the reason the answer may be wrong. A reader piping `status`\n * into a pager still sees it, and the human view stays the shape every other\n * run has. `--json` carries the same fact in `interrupted`.\n */\nfunction printInterrupted(i: InterruptedCommit): void {\n console.error(\n i.unreadable\n ? \"⚠ this docs repo holds a .loam-commit that cannot be read — a commit was interrupted and nothing here can be trusted until a human compares the living docs against version control.\"\n : `⚠ 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 them.`,\n );\n console.error(\"\");\n}\n\n/** `spec (payment-service)` — the artifact id plus the service, when it has one. */\nfunction artifactLabel(a: ArtifactState): string {\n return a.service === null ? a.id : `${a.id} (${a.service})`;\n}\n\n/**\n * The counts come from the record's `claims:` array (verify's `tallyRecord`),\n * never from its `summary:` block. The verdict is printed beside the state\n * because they answer different questions and only together are they honest:\n * `recorded 4/4 confirmed` used to be the whole line for a feature `verify`\n * calls `attested` — complete, and resting on somebody's word about a test.\n */\nfunction verificationLine(v: VerificationState): string {\n if (v.state === \"absent\") return \"none\";\n if (v.state === \"unreadable\") return \"unreadable\";\n return (\n `${v.state} · ${v.verdict} ${v.confirmed}/${v.claims} confirmed` +\n (v.unanswered > 0 ? ` · ${v.unanswered} unanswered` : \"\") +\n (v.attested > 0 ? ` · ${v.attested} scenario(s) on an agent's word, not a test run` : \"\") +\n ` (recorded ${v.recorded ?? \"?\"})`\n );\n}\n\n/**\n * The steps, numbered, with the command on its own indented line — doctor's\n * layout, for doctor's reason: what to do and what to type are two different\n * sentences, and someone scanning a half-built feature reads down the `run:`\n * column.\n */\nfunction printNext(steps: NextStep[]): void {\n console.log(\"\\n next\");\n for (const [i, step] of steps.entries()) {\n console.log(` ${i + 1}. ${step.code}: ${step.statement}`);\n console.log(` run: ${step.command}`);\n }\n}\n\nfunction printFeature(r: FeatureStatusReport, ambiguous: string[]): void {\n if (r.interrupted !== null) printInterrupted(r.interrupted);\n const f = r.feature;\n console.log(`loam status ${f.id} — ${f.stage}${f.archived ? \" (archived)\" : \"\"}`);\n console.log(` feature ${f.dirName} ${f.path}`);\n console.log(` services ${f.services.length > 0 ? f.services.join(\", \") : \"(none)\"}`);\n if (r.service !== null) console.log(` narrowed to ${r.service}`);\n if (f.blockedBy.length > 0) console.log(` waiting on ${f.blockedBy.join(\", \")}`);\n console.log(\n ` coherence ${\n r.checks.ran\n ? `${r.checks.errors} error(s) · ${r.checks.warnings} warning(s) · ${r.checks.gating} gate archive`\n : \"not run (archived)\"\n }`,\n );\n console.log(` verification ${verificationLine(r.verification)}`);\n if (ambiguous.length > 0) {\n console.log(` note '${f.id}' also names ${ambiguous.filter((d) => d !== f.dirName).join(\", \")}`);\n }\n\n console.log(\"\\n artifacts\");\n const labels = r.artifacts.map(artifactLabel);\n const labelWidth = pad(labels);\n const statusWidth = pad(r.artifacts.map((a) => a.status));\n for (const [i, a] of r.artifacts.entries()) {\n // `done` on a file that is not there is the one row a reader misreads: it\n // means the feature owes no such artifact, not that one was written. The\n // JSON contract says it with `exists`; the human view has to say it in\n // words, or the table quietly claims files that do not exist.\n const note =\n a.blockedBy.length > 0\n ? ` (needs ${a.blockedBy.join(\", \")})`\n : !a.exists && a.status === \"done\"\n ? \" (not written — none owed)\"\n : \"\";\n console.log(\n ` ${a.status.padEnd(statusWidth)} ${labels[i]!.padEnd(labelWidth)} ${a.path}${note}`.trimEnd(),\n );\n }\n\n if (r.checks.issues.length > 0) {\n console.log(\"\\n findings\");\n for (const issue of r.checks.issues) {\n console.log(` ${issue.severity === \"error\" ? \"✗\" : \"⚠\"} ${issue.code}: ${issue.message}`);\n }\n }\n\n printNext(r.next);\n}\n\nfunction printFleet(r: FleetStatusReport): void {\n if (r.interrupted !== null) printInterrupted(r.interrupted);\n const s = r.services;\n console.log(`loam status — ${s.total} service(s) · ${r.features.length} feature(s) in flight`);\n console.log(\n ` services ${s.undocumented} undocumented · ${s.draft} draft · ${s.vouched} vouched`,\n );\n if (r.service !== null) console.log(` narrowed to ${r.service}`);\n if (r.order.length > 1) console.log(` order ${r.order.join(\" → \")}`);\n\n if (r.features.length > 0) {\n console.log(\"\\n features\");\n const ids = pad(r.features.map((f) => f.id));\n const stages = pad(r.features.map((f) => f.stage));\n for (const f of r.features) {\n const detail =\n f.blockedBy.length > 0\n ? `waiting on ${f.blockedBy.join(\", \")}`\n : f.missing.length > 0\n ? `missing ${f.missing.join(\", \")}`\n : f.services.join(\", \");\n console.log(` ${f.stage.padEnd(stages)} ${f.id.padEnd(ids)} ${detail}`.trimEnd());\n }\n }\n\n printNext(r.next);\n}\n"]}
@@ -0,0 +1,310 @@
1
+ import { existsSync, lstatSync } from "node:fs";
2
+ import { readdir, readFile, rename, rmdir } from "node:fs/promises";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { loadConfig } from "../core/config.js";
5
+ import { emitJson, fail, repoPath, reportNoConfig } from "../core/json.js";
6
+ import { resolveInside, resolvePortableFileInside } from "../core/path-safety.js";
7
+ import { featuresDir as featuresRoot, resolveFeature } from "../core/repo.js";
8
+ import { sayRecovery } from "./archive.js";
9
+ import { acquireDocsLock, clearCommitIntent, DocsBusyError, InterruptedCommitError, message, planWrite, quietRm, recoverInterruptedCommit, rollbackError, rollbackStaged, sha256, stageWrites, swapStaged, writeCommitIntent, SNAPSHOT_DIR, SNAPSHOT_MANIFEST, SNAPSHOT_VERSION, } from "../core/staging.js";
10
+ /**
11
+ * A commit-phase failure with its stable `--json` code attached — the mirror of
12
+ * archive's discipline. `restore-failed` answers "can I trust the repo?" with
13
+ * yes: nothing was restored, or everything was rolled back, and re-running can
14
+ * work. `rollback-incomplete` is the code that demands a human: the restore
15
+ * failed AND some files could not be put back — the message lists them.
16
+ * Anything ELSE that escapes runUnarchive never touched the commit phase, so
17
+ * the action handler reports it as `restore-failed`.
18
+ */
19
+ class RestoreFailure extends Error {
20
+ code;
21
+ constructor(code, msg) {
22
+ super(msg);
23
+ this.code = code;
24
+ }
25
+ }
26
+ export function registerUnarchive(program) {
27
+ program
28
+ .command("unarchive")
29
+ .argument("<featureId>", "feature id, e.g. FEAT-101")
30
+ .description("Take back a `loam archive`: restore the living docs and re-open the feature")
31
+ .option("--json", "emit the machine contract instead of the human view")
32
+ .option("--force", "restore even if the living docs changed after the archive (discards those changes)")
33
+ .action(async (featureId, opts) => {
34
+ const json = opts.json === true;
35
+ try {
36
+ await runUnarchive(featureId, json, opts.force === true);
37
+ }
38
+ catch (err) {
39
+ const code = err instanceof RestoreFailure
40
+ ? err.code
41
+ : err instanceof InterruptedCommitError
42
+ ? "commit-interrupted"
43
+ : "restore-failed";
44
+ fail(json, code, `unarchive ${featureId} failed: ${message(err)}`);
45
+ }
46
+ });
47
+ }
48
+ async function runUnarchive(featureId, json, force) {
49
+ const config = await loadConfig();
50
+ if (!config) {
51
+ reportNoConfig(json);
52
+ return;
53
+ }
54
+ // The same lock archive takes, for the same reason: unarchive rewrites the
55
+ // living spec, contract and landscape from a snapshot, and an archive landing
56
+ // inside that window would have its merge silently reverted by our restore —
57
+ // or ours by its merge. One writer per docs repo, whichever direction it
58
+ // writes in.
59
+ let release;
60
+ try {
61
+ release = await acquireDocsLock(config.docsDir);
62
+ }
63
+ catch (err) {
64
+ if (err instanceof DocsBusyError)
65
+ throw new RestoreFailure("docs-busy", err.message);
66
+ throw err;
67
+ }
68
+ try {
69
+ await unarchiveLocked(config.docsDir, featureId, json, force);
70
+ }
71
+ finally {
72
+ await release();
73
+ }
74
+ }
75
+ async function unarchiveLocked(docsDir, featureId, json, force) {
76
+ // Same first move as archive, under the same lock: a commit killed between two
77
+ // renames is exactly the state an undo must not be computed on top of.
78
+ const recovered = await recoverInterruptedCommit(docsDir);
79
+ if (recovered !== null && !json)
80
+ sayRecovery(recovered);
81
+ const featuresDir = featuresRoot(docsDir);
82
+ const archiveDir = join(featuresDir, "archive");
83
+ const feature = await resolveFeature(docsDir, featureId, "only");
84
+ if (!feature) {
85
+ fail(json, "unknown-target", `No archived feature '${featureId}' under ${archiveDir}.`);
86
+ return;
87
+ }
88
+ const dest = join(featuresDir, feature.dirName);
89
+ // Refuse to clobber. A feature that is active again carries its own delta, and
90
+ // dropping the archived copy on top of it would bury work in flight. Resolved
91
+ // by the feature's OWN id, not the raw argument — the collision is with
92
+ // whatever answers to this feature's name, however the caller spelled it.
93
+ const active = await resolveFeature(docsDir, feature.id, "exclude");
94
+ if (active !== null || existsSync(dest)) {
95
+ const name = active?.dirName ?? feature.dirName;
96
+ fail(json, "feature-active", `unarchive ${feature.id} — BLOCKED: features/${name} already exists. Remove or rename it, then re-run.`);
97
+ return;
98
+ }
99
+ const manifest = await readManifest(feature.dir, docsDir, feature.id, feature.dirName);
100
+ if (manifest === null) {
101
+ fail(json, "snapshot-missing", `unarchive ${feature.id} — BLOCKED: features/archive/${feature.dirName}/${SNAPSHOT_DIR}/ is missing or was written by a different loam. ` +
102
+ `It was archived before loam recorded what it overwrote, and the previous text of a MODIFIED requirement exists nowhere else — ` +
103
+ `restore the living docs from version control instead.`);
104
+ return;
105
+ }
106
+ // What archive wrote is recorded, so a file edited since can be told from one
107
+ // this archive is still the top of. Restoring over a later change is silent data
108
+ // loss, so it takes --force to say it was meant.
109
+ const writes = [];
110
+ const drifted = [];
111
+ // Pre-images whose bytes are not the bytes archive wrote down. Collected
112
+ // whole, then refused together: "which files" is what makes the message
113
+ // actionable, and no destination is staged until every one is accounted for.
114
+ const corrupt = [];
115
+ for (const entry of manifest.files) {
116
+ const current = existsSync(entry.target) ? await readFile(entry.target) : null;
117
+ const now = current === null ? null : sha256(current);
118
+ // Drift is "restoring this would destroy something", so a file already
119
+ // holding its OWN pre-image is not drifted — putting it back writes the
120
+ // same bytes. It is also the state an interrupted restore leaves behind
121
+ // (the repair finishes the swaps it can, then this command re-runs): the
122
+ // narrower test used to call every repaired file a later change and demand
123
+ // `--force` to discard this command's own half-finished undo.
124
+ const restored = entry.existed ? now === entry.before : now === null;
125
+ if (now !== entry.after && !restored)
126
+ drifted.push(entry.path);
127
+ let before = null;
128
+ if (entry.snapshot !== null) {
129
+ before = await readFile(entry.snapshot);
130
+ // The one check that was missing entirely. `after` describes what archive
131
+ // WROTE and is checked above; nothing described the bytes it would
132
+ // RESTORE, so a pre-image edited inside the archived feature — a bad
133
+ // merge, a rebase, an editor — was written back verbatim and this command
134
+ // said "the living docs are back to what they said before the archive"
135
+ // over text nobody ever wrote.
136
+ if (sha256(before) !== entry.before)
137
+ corrupt.push(entry.path);
138
+ }
139
+ // A restore whose destination is GONE is a create, and takes the same
140
+ // no-clobber swap an archive's creates take: between the manifest read and
141
+ // the swap, another writer may have put that path back, and a rename would
142
+ // bury it. The same helper both commands use, so neither can decide
143
+ // differently about the same file.
144
+ writes.push(planWrite(entry.target, before));
145
+ }
146
+ if (corrupt.length > 0) {
147
+ fail(json, "snapshot-corrupt", `unarchive ${feature.id} — BLOCKED: ${corrupt.length} snapshot pre-image(s) no longer match the digest archive ` +
148
+ `recorded for them, so restoring them would write text nobody authored: ${corrupt.join(", ")}. ` +
149
+ `--force does not override this — it discards LATER changes to the living docs, and the damage here is to the ` +
150
+ `undo itself. Restore the living docs from version control instead.`);
151
+ return;
152
+ }
153
+ if (drifted.length > 0 && !force) {
154
+ fail(json, "snapshot-stale", `unarchive ${feature.id} — BLOCKED: ${drifted.length} file(s) changed since the archive, so this is no longer an undo: ` +
155
+ `${drifted.join(", ")}. Re-run with --force to restore anyway, discarding those changes.`);
156
+ return;
157
+ }
158
+ if (!json) {
159
+ console.log(`unarchive ${feature.id}\n`);
160
+ if (drifted.length > 0) {
161
+ console.log(`⚠ discarding later changes to ${drifted.length} file(s) (--force):`);
162
+ for (const p of drifted)
163
+ console.log(` ⚠ ${p}`);
164
+ console.log("");
165
+ }
166
+ }
167
+ const staged = await stageWrites(writes);
168
+ try {
169
+ // The same journal archive writes, in the other direction: a restore killed
170
+ // between two renames leaves the docs half-back, and the merged text it was
171
+ // replacing is recorded nowhere — so a repair FINISHES this restore rather
172
+ // than undoing it, from the same digest-checked pre-images.
173
+ await writeCommitIntent(docsDir, { command: "unarchive", restore: "after", feature: feature.id, moveFrom: feature.dir, moveTo: dest }, staged);
174
+ await swapStaged(staged);
175
+ await rename(feature.dir, dest);
176
+ await clearCommitIntent(docsDir);
177
+ }
178
+ catch (err) {
179
+ // The code is a caller's answer to "can I trust the repo?": restore-failed
180
+ // means yes (rolled back), rollback-incomplete means look at it by hand —
181
+ // rollbackError's message lists the files that need one.
182
+ const failures = await rollbackStaged(staged);
183
+ await clearCommitIntent(docsDir);
184
+ const wrapped = rollbackError(err, failures);
185
+ throw new RestoreFailure(failures.length > 0 ? "rollback-incomplete" : "restore-failed", wrapped.message);
186
+ }
187
+ // The snapshot describes an archive that no longer exists; the empty directories
188
+ // are what the merge left behind. Both are tidying — neither can fail the undo.
189
+ await quietRm(join(dest, SNAPSHOT_DIR));
190
+ for (const w of writes)
191
+ if (w.content === null)
192
+ await pruneEmptyDirs(dirname(w.path), docsDir);
193
+ await pruneEmptyDirs(archiveDir, docsDir);
194
+ const restored = writes.filter((w) => w.content !== null).map((w) => repoPath(docsDir, w.path));
195
+ const removed = writes.filter((w) => w.content === null).map((w) => repoPath(docsDir, w.path));
196
+ if (json) {
197
+ emitJson({
198
+ feature: feature.id,
199
+ dirName: feature.dirName,
200
+ path: repoPath(docsDir, dest),
201
+ restored,
202
+ removed,
203
+ discarded: drifted,
204
+ ...(recovered === null ? {} : { recovered }),
205
+ });
206
+ return;
207
+ }
208
+ for (const p of restored)
209
+ console.log(` restored: ${p}`);
210
+ for (const p of removed)
211
+ console.log(` removed: ${p}`);
212
+ console.log(`\n reopened: features/archive/${feature.dirName} → features/${feature.dirName}`);
213
+ console.log(" the living docs are back to what they said before the archive.");
214
+ }
215
+ async function readManifest(featureDir, docsDir, featureId, dirName) {
216
+ try {
217
+ const path = resolveInside(featureDir, join(SNAPSHOT_DIR, SNAPSHOT_MANIFEST), "snapshot manifest path");
218
+ if (!existsSync(path))
219
+ return null;
220
+ const parsed = JSON.parse(await readFile(path, "utf8"));
221
+ if (!isRecord(parsed))
222
+ return null;
223
+ if (parsed.version !== SNAPSHOT_VERSION)
224
+ return null;
225
+ if (parsed.feature !== featureId || parsed.dirName !== dirName)
226
+ return null;
227
+ if (typeof parsed.archivedAt !== "string" || !isCanonicalIsoDate(parsed.archivedAt))
228
+ return null;
229
+ if (!Array.isArray(parsed.files))
230
+ return null;
231
+ const seen = new Set();
232
+ const files = [];
233
+ for (const raw of parsed.files) {
234
+ if (!isRecord(raw))
235
+ return null;
236
+ if (typeof raw.path !== "string" || typeof raw.existed !== "boolean")
237
+ return null;
238
+ if (typeof raw.after !== "string" || !/^[0-9a-f]{64}$/.test(raw.after))
239
+ return null;
240
+ // A manifest that claims a pre-image must say what it should hash to, and
241
+ // one that claims none must not. Either way it is a shape question, so it
242
+ // is answered here with the rest of them; whether the bytes MATCH is a
243
+ // different answer with its own code (`snapshot-corrupt`).
244
+ if (raw.existed ? typeof raw.before !== "string" || !/^[0-9a-f]{64}$/.test(raw.before) : raw.before !== null) {
245
+ return null;
246
+ }
247
+ const before = raw.existed ? raw.before : null;
248
+ if (seen.has(raw.path))
249
+ return null;
250
+ seen.add(raw.path);
251
+ const target = resolvePortableFileInside(docsDir, raw.path, `snapshot path '${raw.path}'`);
252
+ let snapshot = null;
253
+ const snapshotRel = `${SNAPSHOT_DIR}/files/${raw.path}`;
254
+ if (raw.existed) {
255
+ // Resolve from the feature directory, which always exists. The `files/`
256
+ // directory legitimately does not exist when every archive write was a
257
+ // creation; anchoring here permits that case while still inspecting an
258
+ // existing `files` component for symlink escape.
259
+ snapshot = resolvePortableFileInside(featureDir, snapshotRel, `snapshot pre-image '${raw.path}'`);
260
+ // Archive writes plain files. A missing pre-image or a symlink (even an
261
+ // internally-contained one) is not the byte snapshot this manifest
262
+ // claims, so refuse before any destination is staged.
263
+ if (!existsSync(snapshot) || !lstatSync(snapshot).isFile())
264
+ return null;
265
+ }
266
+ else {
267
+ const unexpected = resolvePortableFileInside(featureDir, snapshotRel, `snapshot pre-image '${raw.path}'`);
268
+ if (existsSync(unexpected))
269
+ return null;
270
+ }
271
+ files.push({ path: raw.path, existed: raw.existed, after: raw.after, before, target, snapshot });
272
+ }
273
+ return {
274
+ version: SNAPSHOT_VERSION,
275
+ feature: featureId,
276
+ dirName,
277
+ archivedAt: parsed.archivedAt,
278
+ files,
279
+ };
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ }
285
+ function isRecord(value) {
286
+ return typeof value === "object" && value !== null && !Array.isArray(value);
287
+ }
288
+ function isCanonicalIsoDate(value) {
289
+ const millis = Date.parse(value);
290
+ return Number.isFinite(millis) && new Date(millis).toISOString() === value;
291
+ }
292
+ /** Remove `dir` and each empty ancestor, stopping short of `stopAt`. Best effort. */
293
+ async function pruneEmptyDirs(dir, stopAt) {
294
+ let cur = dir;
295
+ for (;;) {
296
+ const rel = relative(stopAt, cur);
297
+ if (rel === "" || rel.startsWith(".."))
298
+ return;
299
+ try {
300
+ if ((await readdir(cur)).length > 0)
301
+ return;
302
+ await rmdir(cur);
303
+ }
304
+ catch {
305
+ return;
306
+ }
307
+ cur = dirname(cur);
308
+ }
309
+ }
310
+ //# sourceMappingURL=unarchive.js.map