@ybotok/loam 0.0.0 → 0.1.0-beta.2

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 (126) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +246 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +142 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +240 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1313 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +388 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +83 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/docs-repo-gate.js +104 -0
  19. package/dist/commands/docs-repo-gate.js.map +1 -0
  20. package/dist/commands/doctor.js +85 -0
  21. package/dist/commands/doctor.js.map +1 -0
  22. package/dist/commands/explore.js +195 -0
  23. package/dist/commands/explore.js.map +1 -0
  24. package/dist/commands/format.js +17 -0
  25. package/dist/commands/format.js.map +1 -0
  26. package/dist/commands/gherkin.js +309 -0
  27. package/dist/commands/gherkin.js.map +1 -0
  28. package/dist/commands/init.js +250 -0
  29. package/dist/commands/init.js.map +1 -0
  30. package/dist/commands/instructions.js +70 -0
  31. package/dist/commands/instructions.js.map +1 -0
  32. package/dist/commands/list.js +291 -0
  33. package/dist/commands/list.js.map +1 -0
  34. package/dist/commands/migrate-openspec.js +950 -0
  35. package/dist/commands/migrate-openspec.js.map +1 -0
  36. package/dist/commands/new.js +413 -0
  37. package/dist/commands/new.js.map +1 -0
  38. package/dist/commands/rebase.js +356 -0
  39. package/dist/commands/rebase.js.map +1 -0
  40. package/dist/commands/show.js +320 -0
  41. package/dist/commands/show.js.map +1 -0
  42. package/dist/commands/status.js +254 -0
  43. package/dist/commands/status.js.map +1 -0
  44. package/dist/commands/unarchive.js +317 -0
  45. package/dist/commands/unarchive.js.map +1 -0
  46. package/dist/commands/validate.js +1591 -0
  47. package/dist/commands/validate.js.map +1 -0
  48. package/dist/commands/verify.js +775 -0
  49. package/dist/commands/verify.js.map +1 -0
  50. package/dist/commands/vouch.js +343 -0
  51. package/dist/commands/vouch.js.map +1 -0
  52. package/dist/core/agent.js +2065 -0
  53. package/dist/core/agent.js.map +1 -0
  54. package/dist/core/agents-stamp.js +68 -0
  55. package/dist/core/agents-stamp.js.map +1 -0
  56. package/dist/core/arch.js +130 -0
  57. package/dist/core/arch.js.map +1 -0
  58. package/dist/core/brief.js +733 -0
  59. package/dist/core/brief.js.map +1 -0
  60. package/dist/core/coherence.js +577 -0
  61. package/dist/core/coherence.js.map +1 -0
  62. package/dist/core/concurrency.js +51 -0
  63. package/dist/core/concurrency.js.map +1 -0
  64. package/dist/core/config.js +180 -0
  65. package/dist/core/config.js.map +1 -0
  66. package/dist/core/delta.js +476 -0
  67. package/dist/core/delta.js.map +1 -0
  68. package/dist/core/dependencies.js +356 -0
  69. package/dist/core/dependencies.js.map +1 -0
  70. package/dist/core/docs.js +144 -0
  71. package/dist/core/docs.js.map +1 -0
  72. package/dist/core/doctor.js +559 -0
  73. package/dist/core/doctor.js.map +1 -0
  74. package/dist/core/document-bytes.js +59 -0
  75. package/dist/core/document-bytes.js.map +1 -0
  76. package/dist/core/explore.js +225 -0
  77. package/dist/core/explore.js.map +1 -0
  78. package/dist/core/fleet-context.js +225 -0
  79. package/dist/core/fleet-context.js.map +1 -0
  80. package/dist/core/frontmatter.js +199 -0
  81. package/dist/core/frontmatter.js.map +1 -0
  82. package/dist/core/gherkin.js +463 -0
  83. package/dist/core/gherkin.js.map +1 -0
  84. package/dist/core/health.js +67 -0
  85. package/dist/core/health.js.map +1 -0
  86. package/dist/core/ids.js +122 -0
  87. package/dist/core/ids.js.map +1 -0
  88. package/dist/core/issue.js +9 -0
  89. package/dist/core/issue.js.map +1 -0
  90. package/dist/core/json.js +74 -0
  91. package/dist/core/json.js.map +1 -0
  92. package/dist/core/likec4.js +479 -0
  93. package/dist/core/likec4.js.map +1 -0
  94. package/dist/core/maturity.js +66 -0
  95. package/dist/core/maturity.js.map +1 -0
  96. package/dist/core/openapi-merge.js +552 -0
  97. package/dist/core/openapi-merge.js.map +1 -0
  98. package/dist/core/openapi.js +255 -0
  99. package/dist/core/openapi.js.map +1 -0
  100. package/dist/core/openspec-inventory.js +1440 -0
  101. package/dist/core/openspec-inventory.js.map +1 -0
  102. package/dist/core/path-safety.js +168 -0
  103. package/dist/core/path-safety.js.map +1 -0
  104. package/dist/core/provenance.js +720 -0
  105. package/dist/core/provenance.js.map +1 -0
  106. package/dist/core/records.js +33 -0
  107. package/dist/core/records.js.map +1 -0
  108. package/dist/core/repo.js +436 -0
  109. package/dist/core/repo.js.map +1 -0
  110. package/dist/core/report.js +47 -0
  111. package/dist/core/report.js.map +1 -0
  112. package/dist/core/results.js +199 -0
  113. package/dist/core/results.js.map +1 -0
  114. package/dist/core/spec.js +481 -0
  115. package/dist/core/spec.js.map +1 -0
  116. package/dist/core/staging.js +890 -0
  117. package/dist/core/staging.js.map +1 -0
  118. package/dist/core/status.js +1104 -0
  119. package/dist/core/status.js.map +1 -0
  120. package/dist/core/steps.js +72 -0
  121. package/dist/core/steps.js.map +1 -0
  122. package/dist/core/verify.js +807 -0
  123. package/dist/core/verify.js.map +1 -0
  124. package/dist/core/version.js +24 -0
  125. package/dist/core/version.js.map +1 -0
  126. package/package.json +63 -4
@@ -0,0 +1,559 @@
1
+ /** Read-only installation/repository diagnostics for `loam doctor`. */
2
+ import { constants, existsSync } from "node:fs";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { CONFIG_FILENAME, configPath, parseConfig } from "./config.js";
6
+ import { LOAM_VERSION } from "./version.js";
7
+ import { agentsPath, docsRepoState, landscapePath, listFeatures, listServices } from "./repo.js";
8
+ import { loadFile } from "./likec4.js";
9
+ import { conflictMarkerLines } from "./fleet-context.js";
10
+ import { AGENT_TOOLS, DELIVERIES, plannedCommandFiles } from "./agent.js";
11
+ import { agentsStaleFinding, agentsStampLine, agentsStampVersion, versionTrails, } from "./agents-stamp.js";
12
+ import { scanWritePathResidue } from "./staging.js";
13
+ /**
14
+ * Read and validate the config the way every other command does — through
15
+ * `parseConfig`, never through a second implementation.
16
+ *
17
+ * doctor used to carry its own validator. It agreed with `loadConfig` on the
18
+ * fields both happened to check and disagreed everywhere else: doctor accepted
19
+ * a `gherkinDir` of `"../shared"` that `loadConfig` refused outright, so
20
+ * `doctor` reported a healthy repo in which no command could run. Two
21
+ * validators are two opinions about the same file, and the one the user reads
22
+ * is never the one the commands obey.
23
+ */
24
+ async function inspectConfig(cwd) {
25
+ const path = configPath(cwd);
26
+ let raw;
27
+ try {
28
+ raw = await readFile(path, "utf8");
29
+ }
30
+ catch (error) {
31
+ const code = error.code;
32
+ if (code === "ENOENT")
33
+ return { status: "missing", config: null, error: null };
34
+ return {
35
+ status: "invalid",
36
+ config: null,
37
+ error: error instanceof Error ? error.message : String(error),
38
+ };
39
+ }
40
+ try {
41
+ return { status: "valid", error: null, config: parseConfig(raw, dirname(path)) };
42
+ }
43
+ catch (error) {
44
+ return {
45
+ status: "invalid",
46
+ config: null,
47
+ error: error instanceof Error ? error.message : String(error),
48
+ };
49
+ }
50
+ }
51
+ async function canAccess(path, mode) {
52
+ try {
53
+ await access(path, mode);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ /**
61
+ * Read the landscape, don't just stat it. `doctor` reported `landscape: yes`
62
+ * for a file full of conflict markers — the check answered "is there a file",
63
+ * which is not the question anyone runs doctor to ask.
64
+ */
65
+ async function inspectLandscape(path, findings) {
66
+ let source;
67
+ try {
68
+ source = await readFile(path, "utf8");
69
+ }
70
+ catch (error) {
71
+ findings.push({
72
+ severity: "blocker",
73
+ code: "doctor.landscape-unreadable",
74
+ message: `${path} exists but could not be read: ${error instanceof Error ? error.message : String(error)}`,
75
+ fix: `Check permissions on ${path}.`,
76
+ });
77
+ return;
78
+ }
79
+ // The three-way merge left its markers in the file. Checked BEFORE the
80
+ // parser, because a conflicted landscape does parse sometimes — both sides of
81
+ // a conflict can be syntactically valid LikeC4 — and "your map contains two
82
+ // halves of two different maps" is a more useful sentence than any parser
83
+ // error. This is the failure mode of onboarding a fleet: ten people adopt ten
84
+ // services into one landscape.likec4 in the same week.
85
+ //
86
+ // The scan itself is fleet-context's, which is where the same rule already
87
+ // grades every other document loam reads. doctor kept a second copy of it,
88
+ // and a second copy of a rule is a second chance to spell it differently;
89
+ // what doctor owns is what a conflicted landscape COSTS, which is the finding
90
+ // below and not the search.
91
+ const conflicted = conflictMarkerLines(source);
92
+ if (conflicted.length > 0) {
93
+ findings.push({
94
+ severity: "blocker",
95
+ code: "doctor.landscape-merge-conflict",
96
+ message: `architecture/landscape.likec4 still contains merge conflict markers ` +
97
+ `(line${conflicted.length === 1 ? "" : "s"} ${conflicted.join(", ")}).`,
98
+ fix: `Resolve the conflict in ${path} — keep BOTH services' elements and edges, then re-run \`loam doctor\`.`,
99
+ });
100
+ return;
101
+ }
102
+ const doc = await loadFile(path);
103
+ if (doc.errors.length > 0) {
104
+ const first = doc.errors[0];
105
+ findings.push({
106
+ severity: "blocker",
107
+ code: "doctor.landscape-invalid",
108
+ message: `architecture/landscape.likec4 does not parse: ${first.message}` +
109
+ (first.line === undefined ? "" : ` (line ${first.line})`) +
110
+ (doc.errors.length > 1 ? ` — and ${doc.errors.length - 1} more` : ""),
111
+ fix: `Fix ${path}; every fleet-wide check is blind until it parses.`,
112
+ });
113
+ }
114
+ }
115
+ /**
116
+ * The tool ids loam.json records `init` as having written files for, or null
117
+ * when it records none we can plan for.
118
+ *
119
+ * `parseConfig` checks `agentTools`' SHAPE and deliberately not its contents —
120
+ * a config written by a newer binary may name a tool this one has never heard
121
+ * of, and refusing to load it would break every command, not just this check.
122
+ * So the registry filter belongs here: `plannedCommandFiles` throws on an id it
123
+ * does not know, and a config from the future must not turn the read-only
124
+ * preflight into a crash. A tool we cannot plan files for is a tool we have
125
+ * nothing to say about.
126
+ */
127
+ function recordedTools(config) {
128
+ const ids = (config?.agentTools ?? []).filter((t) => t in AGENT_TOOLS);
129
+ return ids.length === 0 ? null : ids;
130
+ }
131
+ /**
132
+ * Which tools this repo has agent files for, asked of the layout itself: a tool
133
+ * counts when any file it would be given is already there.
134
+ *
135
+ * The fallback for a loam.json written before `agentTools` existed, and the
136
+ * only answer a hand-written one can have — so its absence must never be the
137
+ * thing that reports a problem. This asks a narrower question than `init`'s
138
+ * own scan, and on purpose: init scans for a tool's dot-directory to decide
139
+ * what a repo would LIKE, while doctor grades what loam has already put there.
140
+ * A repo that runs Claude Code and took `--no-commands --no-skills` has chosen
141
+ * to have no loam files, and is not behind the binary for it.
142
+ */
143
+ function detectedTools(repoRoot) {
144
+ return Object.keys(AGENT_TOOLS).filter((id) => plannedCommandFiles(repoRoot, [id]).some((f) => existsSync(f.path)));
145
+ }
146
+ /**
147
+ * Which deliveries this repo actually HOLDS for one tool — a delivery counts
148
+ * when at least one of its files is on disk.
149
+ *
150
+ * `loam init --no-skills` (or `--no-commands`) records the tool in `agentTools`
151
+ * and writes one delivery, so planning both and subtracting what exists reported
152
+ * every skill file as missing on a brand-new repo — under a message saying it
153
+ * "was initialized by an older loam" and a `fix` that re-runs init without the
154
+ * flag that was the whole point. Which deliveries a repo asked for is written
155
+ * down nowhere, and must not be: the files ARE the record (there is no state
156
+ * file loam can disagree with). So the question is asked of them.
157
+ *
158
+ * A tool with NO files at all is the other story — recorded, then deleted — and
159
+ * the caller falls back to the full plan there, which is what the finding's
160
+ * "or they were deleted" already says.
161
+ */
162
+ function heldDeliveries(repoRoot, tool) {
163
+ const held = DELIVERIES.filter((d) => plannedCommandFiles(repoRoot, [tool], [d]).some((f) => existsSync(f.path)));
164
+ return held.length > 0 ? held : DELIVERIES;
165
+ }
166
+ /**
167
+ * Of the planned files that ARE here, the ones whose stamp is missing or older
168
+ * than this binary.
169
+ *
170
+ * The stamp is the ONLY thing read out of a generated file — never the body.
171
+ * Comparing bodies would yield "your file differs from the template", one step
172
+ * from offering to rewrite it, which is the thing loam refuses: a team's edits
173
+ * to a generated file outrank ours. A stamp is a different claim — it says
174
+ * which loam wrote these instructions — and it can be false while every edit
175
+ * around it is legitimate. Same doctrine as AGENTS.md (agents-stamp.ts), same
176
+ * repair: a human looks, and bumps the stamp or deletes the file so a re-run of
177
+ * `loam init` lays down the current one.
178
+ *
179
+ * Absence of a stamp counts, because that is what every file written before
180
+ * stamping existed looks like — precisely the "initialized by an older loam"
181
+ * population this check is for.
182
+ */
183
+ async function staleAgentFiles(repoRoot, planned) {
184
+ // Read them together rather than one after another. `planned` is bounded by
185
+ // the tool registry — every delivery of every tool loam knows, a couple of
186
+ // hundred small files — so there is nothing here worth a concurrency cap, and
187
+ // `Promise.all` preserves input order, which is the only property the caller
188
+ // asks of the list it gets back.
189
+ const graded = await Promise.all(planned.map(async (file) => {
190
+ let text;
191
+ try {
192
+ text = await readFile(file.path, "utf8");
193
+ }
194
+ catch {
195
+ // Unreadable is not stale: it is either the missing-file finding above or
196
+ // a permissions problem, and neither is answered by editing a stamp.
197
+ return null;
198
+ }
199
+ const stamp = agentsStampVersion(text);
200
+ if (stamp === null || versionTrails(stamp, LOAM_VERSION)) {
201
+ return relative(repoRoot, file.path).split(/[\\/]/).join("/");
202
+ }
203
+ return null;
204
+ }));
205
+ return graded.filter((p) => p !== null);
206
+ }
207
+ /**
208
+ * What the running binary would write for this repo's tools that is not here,
209
+ * and what is here under an older loam's name.
210
+ *
211
+ * Presence is `existsSync`, the same probe `init` uses to compute its `skipped`
212
+ * list, so doctor cannot disagree with init about which files init would write.
213
+ */
214
+ async function inspectAgentSurface(repoRoot, config, docsDir, findings) {
215
+ const recorded = recordedTools(config);
216
+ const tools = recorded ?? detectedTools(repoRoot);
217
+ const planned = tools.flatMap((id) => plannedCommandFiles(repoRoot, [id], heldDeliveries(repoRoot, id)));
218
+ const present = planned.filter((f) => existsSync(f.path));
219
+ const missingFiles = planned
220
+ .filter((f) => !existsSync(f.path))
221
+ .map((f) => relative(repoRoot, f.path).split(/[\\/]/).join("/"));
222
+ const staleFiles = await staleAgentFiles(repoRoot, present);
223
+ if (missingFiles.length > 0) {
224
+ // A warning, never a blocker: an out-of-date command set is a repo whose
225
+ // agents have fewer entry points than they could, not one where anything
226
+ // refuses to run. doctor's blockers are for the second kind.
227
+ //
228
+ // The fix spells `--docs` and `--tools` and deliberately NOT `--service`:
229
+ // `init` spreads the committed config forward, and a `--docs`-less re-run
230
+ // keeps the pointer the repo already commits, so the binding survives
231
+ // untouched. Every interpolation here stands for exactly one argument —
232
+ // test/agent-commands-runnable.test.ts parses this string, and a `${flags}`
233
+ // holding three of them is a command it cannot check.
234
+ findings.push({
235
+ severity: "warning",
236
+ code: "doctor.agent-files-missing",
237
+ message: `${missingFiles.length} of the ${planned.length} command and skill files loam v${LOAM_VERSION} `
238
+ + `lays down for ${tools.join(", ")} are not in this repo — it was initialized by an older loam, `
239
+ + `or they were deleted: ${missingFiles.join(", ")}`,
240
+ fix: `Re-run \`loam init --docs ${config?.docsDirAsWritten ?? "<dir>"} --tools ${tools.join(",")}\` `
241
+ + "here — it writes only the files that are absent and leaves every existing one untouched, and "
242
+ + "it keeps this repo's service binding. Nothing is regenerated, so leaving this standing costs "
243
+ + "entry points and nothing else.",
244
+ });
245
+ }
246
+ if (staleFiles.length > 0) {
247
+ // Also a warning, and for a stronger reason than the one above: these files
248
+ // are the team's now. Only a person can say whether their edits still mean
249
+ // what the current binary's tables say, so loam reports and stops.
250
+ findings.push({
251
+ severity: "warning",
252
+ code: "doctor.agent-files-stale",
253
+ message: `${staleFiles.length} of the ${planned.length} command and skill files here carry no `
254
+ + `\`${agentsStampLine(LOAM_VERSION)}\` stamp, or one older than this loam — the protocol and `
255
+ + `code tables they instruct an agent with may describe a loam that no longer exists: `
256
+ + `${staleFiles.join(", ")}`,
257
+ fix: "Read each against the body this loam writes, then set its stamp line to "
258
+ + `\`${agentsStampLine(LOAM_VERSION)}\`; or delete the file and re-run \`loam init\` to lay `
259
+ + "down the current one. loam never rewrites a file that exists — your edits outrank the "
260
+ + "template, which is why the stamp is a human's claim and not a refresh.",
261
+ });
262
+ }
263
+ // AGENTS.md lives in the DOCS repo, not here: it travels with the thing it
264
+ // describes. The comparison is `agentsStaleFinding`'s, not a second copy of
265
+ // it — doctor only adds the `fix` column its own findings owe the reader.
266
+ const agentsFile = docsDir === null ? null : agentsPath(docsDir);
267
+ let agentsText = null;
268
+ if (agentsFile !== null) {
269
+ try {
270
+ agentsText = await readFile(agentsFile, "utf8");
271
+ }
272
+ catch {
273
+ // Unreadable reads as absent, exactly as `validate --all` treats it: no
274
+ // file, no contract to have drifted. An unreadable docs repo is already
275
+ // its own finding.
276
+ }
277
+ }
278
+ const stale = agentsStaleFinding(agentsText, LOAM_VERSION);
279
+ if (stale !== null) {
280
+ findings.push({
281
+ severity: "warning",
282
+ code: stale.code,
283
+ message: stale.message,
284
+ fix: `Review ${agentsFile} against the current \`loam --help\`, then set its stamp line to `
285
+ + `\`${agentsStampLine(LOAM_VERSION)}\`. loam never rewrites that file — bumping the stamp IS `
286
+ + "the record that somebody looked.",
287
+ });
288
+ }
289
+ return {
290
+ tools,
291
+ toolsSource: recorded === null ? "disk" : "config",
292
+ plannedFiles: planned.length,
293
+ missingFiles,
294
+ staleFiles,
295
+ stamp: {
296
+ path: agentsFile,
297
+ present: agentsText !== null,
298
+ version: agentsText === null ? null : agentsStampVersion(agentsText),
299
+ stale: stale !== null,
300
+ },
301
+ };
302
+ }
303
+ /**
304
+ * Grade what a write that did not finish left in the docs repo.
305
+ *
306
+ * Three of the four are blockers, which is unusual for doctor and deliberate:
307
+ * they are not "you have fewer entry points than you could", they are "the
308
+ * living docs may be half-written and the next reader cannot tell". Before this
309
+ * existed, a SIGKILL between two of archive's renames left `doctor: healthy:
310
+ * true` over merged spec.md + openapi.yaml and an unmerged landscape, and the
311
+ * next `loam archive` reported loam's own half-merge as the author's bug.
312
+ *
313
+ * The temp files are the exception: a `.loam-*.tmp` was never linked into place,
314
+ * so nothing reads it and nothing depends on it. It is litter, not damage.
315
+ */
316
+ function gradeWritePathResidue(docsDir, residue, findings) {
317
+ if (residue.lock !== null) {
318
+ const lockFile = join(docsDir, residue.lock.path);
319
+ findings.push({
320
+ // Held by a live process is a fact about right now — wait and re-run.
321
+ // Held by a process that no longer exists is damage: nothing will ever
322
+ // release it, and every archive and unarchive refuses `docs-busy` until
323
+ // somebody deletes it.
324
+ severity: residue.lock.stale ? "blocker" : "warning",
325
+ code: "doctor.docs-locked",
326
+ message: residue.lock.stale
327
+ ? `${residue.lock.path} is held by ${residue.lock.holder}, a process that no longer exists on this host — every \`loam archive\` and \`loam unarchive\` will refuse with \`docs-busy\` until it is gone.`
328
+ : `${residue.lock.path} is held by ${residue.lock.holder}; another archive or unarchive is running against this docs repo.`,
329
+ fix: residue.lock.stale
330
+ ? `Delete ${lockFile} — its holder is dead, so nothing is going to release it. Check \`loam doctor\` again afterwards for an interrupted commit.`
331
+ : "Wait for it to finish and re-run; nothing is read or written while it is held.",
332
+ });
333
+ }
334
+ if (residue.intentUnreadable) {
335
+ findings.push({
336
+ severity: "blocker",
337
+ code: "doctor.commit-unreadable",
338
+ message: `${docsDir} holds a .loam-commit that cannot be read — a commit was interrupted and the one record of which files it had already written is unreadable.`,
339
+ fix: "Hand this to a human: compare the living docs against version control before running any loam command that writes. `loam archive` and `loam unarchive` both refuse with `commit-interrupted` while it is there.",
340
+ });
341
+ }
342
+ else if (residue.intent !== null) {
343
+ const i = residue.intent;
344
+ // The command is spelled out per branch rather than interpolated into one
345
+ // sentence: `loam ${i.command} ${i.feature}` reads fine to a person and is
346
+ // opaque to test/agent-commands-runnable.test.ts, which parses the commands
347
+ // loam prints against the real program. Same discipline as spelling `code:`
348
+ // literally — be visible to your own guard.
349
+ const repair = i.command === "archive"
350
+ ? `\`loam archive ${i.feature}\` — it recovers first, under the lock, putting the half-written files back from the snapshot`
351
+ : `\`loam unarchive ${i.feature}\` — it recovers first, under the lock, finishing the restore (an interrupted restore is FINISHED, never undone: the merged text it was replacing is written down nowhere)`;
352
+ findings.push({
353
+ severity: "blocker",
354
+ code: "doctor.commit-interrupted",
355
+ message: `A ${i.command === "archive" ? `\`loam archive ${i.feature}\`` : `\`loam unarchive ${i.feature}\``} `
356
+ + `was killed mid-commit (${i.host}, pid ${i.pid}, ${i.at}) — `
357
+ + `${i.files.length} file(s) may be half-written: ${i.files.map((f) => f.path).join(", ")}`,
358
+ fix: `Re-run ${repair}, and refuses with \`commit-interrupted\` rather than guessing if a file has been edited since.`,
359
+ });
360
+ }
361
+ if (residue.temps.length > 0) {
362
+ findings.push({
363
+ severity: "warning",
364
+ code: "doctor.staging-temps",
365
+ message: `${residue.temps.length} orphaned staging file(s) under ${docsDir}: ${residue.temps.join(", ")} — a killed writer's scratch, never linked into place.`,
366
+ fix: "The next `loam archive` or `loam unarchive` removes its own; delete any that remain. Nothing reads them, so they cost disk and nothing else.",
367
+ });
368
+ }
369
+ }
370
+ export async function diagnose(cwd = process.cwd()) {
371
+ const inspected = await inspectConfig(cwd);
372
+ const path = configPath(cwd);
373
+ const findings = [];
374
+ if (inspected.status === "missing") {
375
+ findings.push({
376
+ severity: "blocker",
377
+ code: "doctor.config-missing",
378
+ message: `No ${CONFIG_FILENAME} found at or above ${cwd}.`,
379
+ fix: "Run `loam init --docs <path-to-docs-repo>` here (add `--create` to make a new docs repo).",
380
+ });
381
+ }
382
+ else if (inspected.status === "invalid") {
383
+ findings.push({
384
+ severity: "blocker",
385
+ code: "doctor.config-invalid",
386
+ message: `Invalid ${CONFIG_FILENAME}: ${inspected.error ?? "unknown error"}`,
387
+ fix: `Repair ${path} — or delete it and re-run \`loam init\`.`,
388
+ });
389
+ }
390
+ const docsDir = inspected.config?.docsDir ?? null;
391
+ const state = docsDir === null ? null : docsRepoState(docsDir);
392
+ const exists = state !== null && state.kind !== "missing";
393
+ const readable = exists && docsDir !== null ? await canAccess(docsDir, constants.R_OK) : false;
394
+ const writable = exists && docsDir !== null ? await canAccess(docsDir, constants.W_OK) : false;
395
+ const servicesDir = state?.kind === "ok";
396
+ const landscapeFile = docsDir === null ? null : landscapePath(docsDir);
397
+ const landscape = landscapeFile === null ? false : await canAccess(landscapeFile, constants.F_OK);
398
+ // The docs-repo verdict comes from `docsRepoState`, the same function
399
+ // `listServices` refuses on, so doctor cannot call a repo healthy that the
400
+ // enumeration will not read.
401
+ if (state?.kind === "missing") {
402
+ findings.push({
403
+ severity: "blocker",
404
+ code: "doctor.docs-missing",
405
+ message: `Configured docsDir is missing or is not a directory: ${docsDir}`,
406
+ fix: `Fix "docsDir" in ${path}, clone the docs repo to ${docsDir}, or run \`loam init --docs <dir> --create\`.`,
407
+ });
408
+ }
409
+ else if (state?.kind === "no-services") {
410
+ findings.push({
411
+ severity: "blocker",
412
+ code: "doctor.services-missing",
413
+ message: `Required services/ directory is missing under ${docsDir} — that path is not a docs repo.`,
414
+ fix: `Point "docsDir" in ${path} at the shared docs repo, or run \`loam init --docs ${docsDir} --create\`.`,
415
+ });
416
+ }
417
+ // An absolute docsDir in a committed config names a directory that exists on
418
+ // exactly one machine. It is a warning and not a blocker because it works
419
+ // perfectly — for the person who ran `loam init`, and for nobody who clones
420
+ // the repo afterwards.
421
+ const asWritten = inspected.config?.docsDirAsWritten;
422
+ const configRoot = inspected.config?.root;
423
+ if (asWritten !== undefined
424
+ && configRoot !== undefined
425
+ && isAbsolute(asWritten)
426
+ && resolve(asWritten) !== resolve(configRoot)) {
427
+ findings.push({
428
+ severity: "warning",
429
+ code: "doctor.docs-absolute",
430
+ message: `"docsDir" is stored as an absolute path (${asWritten}); ` +
431
+ `${CONFIG_FILENAME} is committed, so it will not resolve on anyone else's machine.`,
432
+ fix: `Rewrite "docsDir" in ${path} as a path relative to that file (e.g. "../docs").`,
433
+ });
434
+ }
435
+ if (exists && !readable) {
436
+ findings.push({
437
+ severity: "warning",
438
+ code: "doctor.docs-unreadable",
439
+ message: `docsDir is not readable by this process: ${docsDir}`,
440
+ fix: `Grant read access to ${docsDir} (check ownership and mode).`,
441
+ });
442
+ }
443
+ if (exists && !writable) {
444
+ findings.push({
445
+ severity: "warning",
446
+ code: "doctor.docs-readonly",
447
+ message: `docsDir is not writable; read-only commands work, archive/init do not: ${docsDir}`,
448
+ fix: `Grant write access to ${docsDir} if you need \`loam new\`, \`loam adopt\` or \`loam archive\`.`,
449
+ });
450
+ }
451
+ if (exists && !landscape) {
452
+ findings.push({
453
+ severity: "warning",
454
+ code: "doctor.landscape-missing",
455
+ message: `${landscapeFile} is missing — nothing cross-service can be checked without it.`,
456
+ fix: "Create architecture/landscape.likec4 with one element per service and an edge per call (`loam init --create` writes the empty map).",
457
+ });
458
+ }
459
+ else if (exists && landscapeFile !== null && readable) {
460
+ await inspectLandscape(landscapeFile, findings);
461
+ }
462
+ // What a killed writer left behind. Read from `scanWritePathResidue`, whose
463
+ // spellings of `.loam-lock` / `.loam-commit` / the temp-file pattern are
464
+ // staging's own — doctor grades, it does not re-spell. This is the surface
465
+ // that used to report `healthy: true` over half-merged docs.
466
+ const writePath = docsDir !== null && exists && readable
467
+ ? await scanWritePathResidue(docsDir)
468
+ : null;
469
+ if (docsDir !== null && writePath !== null)
470
+ gradeWritePathResidue(docsDir, writePath, findings);
471
+ let services = [];
472
+ let activeFeatures = [];
473
+ // Whether the fleet was actually READ, which is a narrower question than
474
+ // whether reading it was attempted — hence set inside the try and not from
475
+ // the guard, so an enumeration that threw leaves this false alongside its own
476
+ // `doctor.inventory-unreadable`.
477
+ let inventoried = false;
478
+ if (docsDir !== null && readable && servicesDir) {
479
+ try {
480
+ [services, activeFeatures] = await Promise.all([
481
+ listServices(docsDir),
482
+ listFeatures(docsDir),
483
+ ]);
484
+ inventoried = true;
485
+ }
486
+ catch (error) {
487
+ findings.push({
488
+ severity: "warning",
489
+ code: "doctor.inventory-unreadable",
490
+ message: `Could not inventory the docs repo: ${error instanceof Error ? error.message : String(error)}`,
491
+ fix: `Check that ${docsDir}/services and ${docsDir}/features are readable directories.`,
492
+ });
493
+ }
494
+ }
495
+ const configuredService = inspected.config?.service ?? null;
496
+ const currentService = configuredService === null
497
+ ? { configured: null, status: "unbound" }
498
+ : services.some((service) => service.id === configuredService)
499
+ ? { configured: configuredService, status: "matched" }
500
+ : { configured: configuredService, status: "unknown" };
501
+ if (currentService.status === "unbound") {
502
+ findings.push({
503
+ severity: "warning",
504
+ code: "doctor.service-unbound",
505
+ message: "loam.json has no service binding; service-repo checks need `service`.",
506
+ fix: `Run \`loam init --docs ${inspected.config?.docsDirAsWritten ?? "<dir>"} --service <id>\` here.`,
507
+ });
508
+ }
509
+ else if (currentService.status === "unknown" && inventoried) {
510
+ // Only when there was an inventory to be absent from. This finding is a
511
+ // claim about a fleet that was read; with no docsDir, an unreadable one, or
512
+ // an enumeration that threw, `services` is empty for a reason that has
513
+ // nothing to do with `service`, and doctor pointed the reader at the one
514
+ // config field that was fine while the blocker it had already filed named
515
+ // the real one. `currentService.status` still reports `unknown` — the
516
+ // envelope describes what loam could and could not match, and an unread
517
+ // fleet matches nothing.
518
+ findings.push({
519
+ severity: "warning",
520
+ code: "doctor.service-unknown",
521
+ message: `Configured service '${configuredService}' is not present under services/.`,
522
+ // `--service <id>`, not a positional: `loam adopt` takes no argument, so
523
+ // the spelling this finding used to print was refused by commander with
524
+ // "too many arguments" — as the FIRST instruction a freshly bound service
525
+ // repo ever receives. test/agent-commands-runnable.test.ts parses every
526
+ // `loam …` loam prints against the real program so it cannot recur.
527
+ fix: `Run \`loam adopt --service ${configuredService}\` to onboard it, or fix "service" in ${path}.`,
528
+ });
529
+ }
530
+ // The slash commands (and everything else `init` lays down for a tool) belong
531
+ // to the repo `init` ran in — the directory holding loam.json, which is not
532
+ // the cwd now that config discovery walks upward.
533
+ const agents = await inspectAgentSurface(dirname(path), inspected.config, docsDir, findings);
534
+ return {
535
+ healthy: !findings.some((finding) => finding.severity === "blocker"),
536
+ runtime: {
537
+ package: "@ybotok/loam",
538
+ version: LOAM_VERSION,
539
+ node: process.version,
540
+ platform: process.platform,
541
+ arch: process.arch,
542
+ },
543
+ config: { path, status: inspected.status, error: inspected.error },
544
+ docs: {
545
+ path: docsDir,
546
+ exists,
547
+ readable,
548
+ writable,
549
+ servicesDir,
550
+ landscape,
551
+ },
552
+ counts: { services: services.length, activeFeatures: activeFeatures.length },
553
+ currentService,
554
+ agents,
555
+ writePath,
556
+ findings,
557
+ };
558
+ }
559
+ //# sourceMappingURL=doctor.js.map