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