@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.
- package/CHANGELOG.md +138 -0
- package/COMPARISON.md +63 -0
- package/LICENSE +21 -0
- package/MIGRATING-from-OpenSpec.md +202 -0
- package/README.md +246 -9
- package/SCHEMA.md +425 -0
- package/SECURITY.md +9 -0
- package/dist/cli.js +142 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.js +240 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/archive.js +1313 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/delta.js +388 -0
- package/dist/commands/delta.js.map +1 -0
- package/dist/commands/dependencies.js +83 -0
- package/dist/commands/dependencies.js.map +1 -0
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +309 -0
- package/dist/commands/gherkin.js.map +1 -0
- package/dist/commands/init.js +250 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +291 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/migrate-openspec.js +950 -0
- package/dist/commands/migrate-openspec.js.map +1 -0
- package/dist/commands/new.js +413 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/rebase.js +356 -0
- package/dist/commands/rebase.js.map +1 -0
- package/dist/commands/show.js +320 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/status.js +254 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/unarchive.js +317 -0
- package/dist/commands/unarchive.js.map +1 -0
- package/dist/commands/validate.js +1591 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/verify.js +775 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/vouch.js +343 -0
- package/dist/commands/vouch.js.map +1 -0
- package/dist/core/agent.js +2065 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents-stamp.js +68 -0
- package/dist/core/agents-stamp.js.map +1 -0
- package/dist/core/arch.js +130 -0
- package/dist/core/arch.js.map +1 -0
- package/dist/core/brief.js +733 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/coherence.js +577 -0
- package/dist/core/coherence.js.map +1 -0
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +180 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delta.js +476 -0
- package/dist/core/delta.js.map +1 -0
- package/dist/core/dependencies.js +356 -0
- package/dist/core/dependencies.js.map +1 -0
- package/dist/core/docs.js +144 -0
- package/dist/core/docs.js.map +1 -0
- package/dist/core/doctor.js +559 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +225 -0
- package/dist/core/fleet-context.js.map +1 -0
- package/dist/core/frontmatter.js +199 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/gherkin.js +463 -0
- package/dist/core/gherkin.js.map +1 -0
- package/dist/core/health.js +67 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/ids.js +122 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/issue.js +9 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/likec4.js +479 -0
- package/dist/core/likec4.js.map +1 -0
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +552 -0
- package/dist/core/openapi-merge.js.map +1 -0
- package/dist/core/openapi.js +255 -0
- package/dist/core/openapi.js.map +1 -0
- package/dist/core/openspec-inventory.js +1440 -0
- package/dist/core/openspec-inventory.js.map +1 -0
- package/dist/core/path-safety.js +168 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/provenance.js +720 -0
- package/dist/core/provenance.js.map +1 -0
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +436 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/report.js +47 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/results.js +199 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/spec.js +481 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/staging.js +890 -0
- package/dist/core/staging.js.map +1 -0
- package/dist/core/status.js +1104 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +807 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/core/version.js +24 -0
- package/dist/core/version.js.map +1 -0
- 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
|