@inventarch/cli 0.0.0-stage → 1.0.0

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 (110) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +19 -2
  4. package/SPEC.md +30 -0
  5. package/assets/base/71f0665cb1ffc3f3964fc7710528451f635d664bdf4b8c95005ee97c21db5928.ia.tgz +0 -0
  6. package/assets/base.json +6 -0
  7. package/assets/host/43a1f8c471141b1f2f46cef4411a9a61c00da1c1aa19d277189ae2423282bfd6.tgz +0 -0
  8. package/assets/host.json +6 -0
  9. package/assets/vocabulary.json +3268 -0
  10. package/dist/args.d.ts +62 -0
  11. package/dist/args.d.ts.map +1 -0
  12. package/dist/args.js +117 -0
  13. package/dist/args.js.map +1 -0
  14. package/dist/briefing.d.ts +76 -0
  15. package/dist/briefing.d.ts.map +1 -0
  16. package/dist/briefing.js +105 -0
  17. package/dist/briefing.js.map +1 -0
  18. package/dist/channel.d.ts +22 -0
  19. package/dist/channel.d.ts.map +1 -0
  20. package/dist/channel.js +74 -0
  21. package/dist/channel.js.map +1 -0
  22. package/dist/claude-cli.d.ts +35 -0
  23. package/dist/claude-cli.d.ts.map +1 -0
  24. package/dist/claude-cli.js +101 -0
  25. package/dist/claude-cli.js.map +1 -0
  26. package/dist/commands.d.ts +39 -0
  27. package/dist/commands.d.ts.map +1 -0
  28. package/dist/commands.js +436 -0
  29. package/dist/commands.js.map +1 -0
  30. package/dist/compile.d.ts +39 -0
  31. package/dist/compile.d.ts.map +1 -0
  32. package/dist/compile.js +136 -0
  33. package/dist/compile.js.map +1 -0
  34. package/dist/consumer.d.ts +137 -0
  35. package/dist/consumer.d.ts.map +1 -0
  36. package/dist/consumer.js +366 -0
  37. package/dist/consumer.js.map +1 -0
  38. package/dist/decline.d.ts +7 -0
  39. package/dist/decline.d.ts.map +1 -0
  40. package/dist/decline.js +78 -0
  41. package/dist/decline.js.map +1 -0
  42. package/dist/distribute.d.ts +163 -0
  43. package/dist/distribute.d.ts.map +1 -0
  44. package/dist/distribute.js +664 -0
  45. package/dist/distribute.js.map +1 -0
  46. package/dist/doctor-user.d.ts +45 -0
  47. package/dist/doctor-user.d.ts.map +1 -0
  48. package/dist/doctor-user.js +193 -0
  49. package/dist/doctor-user.js.map +1 -0
  50. package/dist/doctor.d.ts +57 -0
  51. package/dist/doctor.d.ts.map +1 -0
  52. package/dist/doctor.js +754 -0
  53. package/dist/doctor.js.map +1 -0
  54. package/dist/format.d.ts +25 -0
  55. package/dist/format.d.ts.map +1 -0
  56. package/dist/format.js +168 -0
  57. package/dist/format.js.map +1 -0
  58. package/dist/home-remedy.d.ts +10 -0
  59. package/dist/home-remedy.d.ts.map +1 -0
  60. package/dist/home-remedy.js +26 -0
  61. package/dist/home-remedy.js.map +1 -0
  62. package/dist/host-projection.d.ts +11 -0
  63. package/dist/host-projection.d.ts.map +1 -0
  64. package/dist/host-projection.js +73 -0
  65. package/dist/host-projection.js.map +1 -0
  66. package/dist/host.d.ts +162 -0
  67. package/dist/host.d.ts.map +1 -0
  68. package/dist/host.js +599 -0
  69. package/dist/host.js.map +1 -0
  70. package/dist/init.d.ts +217 -0
  71. package/dist/init.d.ts.map +1 -0
  72. package/dist/init.js +840 -0
  73. package/dist/init.js.map +1 -0
  74. package/dist/inspect.d.ts +32 -0
  75. package/dist/inspect.d.ts.map +1 -0
  76. package/dist/inspect.js +226 -0
  77. package/dist/inspect.js.map +1 -0
  78. package/dist/main.d.ts +24 -0
  79. package/dist/main.d.ts.map +1 -0
  80. package/dist/main.js +215 -0
  81. package/dist/main.js.map +1 -0
  82. package/dist/operation-help.d.ts +14 -0
  83. package/dist/operation-help.d.ts.map +1 -0
  84. package/dist/operation-help.js +75 -0
  85. package/dist/operation-help.js.map +1 -0
  86. package/dist/pack.d.ts +17 -0
  87. package/dist/pack.d.ts.map +1 -0
  88. package/dist/pack.js +121 -0
  89. package/dist/pack.js.map +1 -0
  90. package/dist/render.d.ts +165 -0
  91. package/dist/render.d.ts.map +1 -0
  92. package/dist/render.js +277 -0
  93. package/dist/render.js.map +1 -0
  94. package/dist/session.d.ts +13 -0
  95. package/dist/session.d.ts.map +1 -0
  96. package/dist/session.js +25 -0
  97. package/dist/session.js.map +1 -0
  98. package/dist/user-host.d.ts +59 -0
  99. package/dist/user-host.d.ts.map +1 -0
  100. package/dist/user-host.js +228 -0
  101. package/dist/user-host.js.map +1 -0
  102. package/dist/validate.d.ts +53 -0
  103. package/dist/validate.d.ts.map +1 -0
  104. package/dist/validate.js +220 -0
  105. package/dist/validate.js.map +1 -0
  106. package/dist/vocabulary.d.ts +55 -0
  107. package/dist/vocabulary.d.ts.map +1 -0
  108. package/dist/vocabulary.js +179 -0
  109. package/dist/vocabulary.js.map +1 -0
  110. package/package.json +153 -3
package/dist/doctor.js ADDED
@@ -0,0 +1,754 @@
1
+ /**
2
+ * `ia doctor`: docs/specs/consumer-cli-contract/README.md §2.12.
3
+ *
4
+ * Five statuses, five count keys, one bucket per row, and no implicit repair: where a remedy exists this command
5
+ * prints the exact command, and where the remedy is on the other binary it says so. `unknown` means a check could
6
+ * not be performed and is never reported as `ok` — which is why every installation row is `unknown` without a
7
+ * workspace rather than a cheerful pass.
8
+ *
9
+ * Host state is observed, never assumed (docs/specs/host-registration/README.md §7). `readInstalledState`
10
+ * with `hosts: true` compares each host's ownership state against the files it wrote and against this installation's
11
+ * payload pin: `registered` is `ok` and its detail says "written; not observed answering", because doctor never
12
+ * starts a server and a written registration is not a server that answered; `stale` is `fail`, so doctor exits 1
13
+ * (REQ-HRC-5), and its detail names each reason and the repair that works for it; no host state at all is `info`.
14
+ * Projection drift is one row per managed file. An installation that carries no host payload is a note, not a crash:
15
+ * the release comparison is skipped and the row says so. Observation is total — a malformed state file is a `fail`
16
+ * row, never an exception — and nothing here writes, the host home included. Repairs are worded by `ia host`'s own
17
+ * `stateRepair` and `modifiedRepair`, so the two verbs cannot disagree, and every `ia host` command printed carries
18
+ * `--root` when doctor was given one.
19
+ *
20
+ * The support row compares an observed platform and version against the declaration at
21
+ * docs/reports/open-source-v1/2026-09-30/decisions.md:13. It says "matches the declared target", never "qualified", because
22
+ * that declaration says the minimum must be validated before being advertised. A platform outside it is `warn`,
23
+ * never `fail`, and the word "unsupported" is not used about it.
24
+ *
25
+ * The environment is a parameter rather than a read of the process, so this verb is testable and so the contract's
26
+ * §7 examples can state which machine's Node version and platform they show.
27
+ *
28
+ * docs/specs/host-plugin-distribution/README.md §7.3 adds rows that exist with or without a workspace
29
+ * (`doctor-user.ts`: install channel, IA home, Claude plugin, §11's cached update check and local language
30
+ * compatibility, initialization decision) and, per host, a warning when
31
+ * a registration pins its payload outside the current IA home (§3). `--host <id>` adds §8.2's `session` briefing and
32
+ * §7.3's `nextActions` to the JSON envelope (`briefing.ts`); neither adds a write, so doctor stays read-only
33
+ * (Amendment item 4).
34
+ *
35
+ * docs/specs/registry/README.md §4 ("Doctor") adds a `registry-<provider>` row for each provider the
36
+ * workspace's lock names in its requests and packages: the base §4's precedence chooses and the level that chose it.
37
+ * The portable lock is read on its own, so a fresh clone, or an installation whose activation pointer is missing,
38
+ * unusable or drifted from, keeps its rows. Choosing reads configuration files only, so doctor never contacts a
39
+ * registry.
40
+ */
41
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
42
+ import { homedir } from 'node:os';
43
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
44
+ import { WORKSPACE_PROJECTION_MARKER } from '@inventarch/compliance';
45
+ // `within` judges nesting on disk, so a home or cache spelled in another case or normalization is still where it is (#315).
46
+ import { pathKey, within } from '@inventarch/db';
47
+ import { decodeDistributionJson, INSTALL_PATHS } from '@inventarch/db/distribution';
48
+ import { verifyHostCache } from '@inventarch/distribution/host';
49
+ import { hostPayloadPath, legacyHostHome } from '@inventarch/distribution/host-home';
50
+ import { resolveIaHome } from '@inventarch/distribution/ia-home';
51
+ import { observeProjection } from '@inventarch/distribution/projection';
52
+ import { registryChooser, registryLocation } from '@inventarch/distribution/registry';
53
+ import { readInstalledState, readWorkspaceLock, validateWorkspace } from '@inventarch/distribution/services';
54
+ import { brief } from './briefing.js';
55
+ import { discoverRoot, iaHomeOf } from './consumer.js';
56
+ import { UNMAPPED } from './distribute.js';
57
+ import { userRows } from './doctor-user.js';
58
+ import { renderProjectionFor } from './host-projection.js';
59
+ import { HOST_LOCK, HOSTS_AREA, JOURNALS, MCP_PATH, modifiedRepair, pinnedRelease, projectionRepair, recoverCommand, refusedPath, rootedNext, SETTINGS, STATE, stateRepair, } from './host.js';
60
+ import { codeOf } from './session.js';
61
+ import { atom, document, entry, fieldRows, sectionLabel, words } from './render.js';
62
+ /** decisions.md:13. Linux and Windows on x64 and macOS on arm64, on Node 22 with 22.22.0 the initial minimum; pnpm is contributor-only. */
63
+ export const SUPPORTED_TARGETS = [
64
+ { platform: 'linux', arch: 'x64' },
65
+ { platform: 'win32', arch: 'x64' },
66
+ { platform: 'darwin', arch: 'arm64' },
67
+ ];
68
+ const PLATFORM_NAMES = { linux: 'Linux', win32: 'Windows', darwin: 'macOS' };
69
+ /** The declared target in words, built from the list the verdict reads, so the text cannot drift from the check. */
70
+ const targetNames = () => {
71
+ const names = SUPPORTED_TARGETS.map((target) => `${PLATFORM_NAMES[target.platform] ?? target.platform} ${target.arch}`);
72
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : (names[0] ?? '');
73
+ };
74
+ export const SUPPORTED_MAJOR = 22;
75
+ export const MINIMUM_NODE = '22.22.0';
76
+ const INSTALL_LOCK = '.ia/distributions/install-lock.json';
77
+ const CACHE = '.ia/distributions/cache';
78
+ const ARCHIVE = /^[a-f0-9]{64}\.ia\.tgz$/;
79
+ const versionOrder = (value) => (/^v?\d+\.\d+\.\d+/.exec(value)?.[0] ?? '0.0.0').replace(/^v/, '').split('.').map(Number);
80
+ const atLeast = (value, minimum) => {
81
+ const left = versionOrder(value), right = versionOrder(minimum);
82
+ for (let index = 0; index < 3; index += 1) {
83
+ if ((left[index] ?? 0) !== (right[index] ?? 0))
84
+ return (left[index] ?? 0) > (right[index] ?? 0);
85
+ }
86
+ return true;
87
+ };
88
+ const count = (entries) => ({
89
+ ok: entries.filter((check) => check.status === 'ok').length,
90
+ warn: entries.filter((check) => check.status === 'warn').length,
91
+ fail: entries.filter((check) => check.status === 'fail').length,
92
+ unknown: entries.filter((check) => check.status === 'unknown').length,
93
+ info: entries.filter((check) => check.status === 'info').length,
94
+ });
95
+ const environment = (request) => {
96
+ const { version, platform, arch } = request.runtime;
97
+ const major = versionOrder(version)[0] ?? 0;
98
+ const supported = SUPPORTED_TARGETS.some((target) => target.platform === platform && target.arch === arch) &&
99
+ major === SUPPORTED_MAJOR &&
100
+ atLeast(version, MINIMUM_NODE);
101
+ const checks = [
102
+ {
103
+ id: 'node',
104
+ section: 'Environment',
105
+ title: 'Node',
106
+ status: major >= SUPPORTED_MAJOR ? 'ok' : 'fail',
107
+ detail: `${version} on ${platform} ${arch}`,
108
+ remedy: major >= SUPPORTED_MAJOR ? null : `Install Node ${SUPPORTED_MAJOR} (${MINIMUM_NODE} or later)`,
109
+ },
110
+ {
111
+ id: 'support-target',
112
+ section: 'Environment',
113
+ title: 'Support target',
114
+ status: supported ? 'ok' : 'warn',
115
+ detail: supported
116
+ ? `${platform} ${arch} on Node ${major} matches the declared target`
117
+ : `${platform} ${arch} on Node ${version} is outside the declared target of ${targetNames()} on Node ${SUPPORTED_MAJOR} from ${MINIMUM_NODE}; it is not qualified`,
118
+ remedy: !supported && platform === 'darwin' && arch === 'x64'
119
+ ? 'If this Mac has Apple silicon, this x64 build of Node runs under Rosetta: install the arm64 build. Intel Macs are outside the declared target.'
120
+ : null,
121
+ },
122
+ ];
123
+ try {
124
+ const catalogue = JSON.parse(readFileSync(resolve(request.packageRoot, 'assets/vocabulary.json'), 'utf8'));
125
+ checks.push({
126
+ id: 'vocabulary',
127
+ section: 'Environment',
128
+ title: 'Vocabulary',
129
+ status: 'ok',
130
+ detail: `${catalogue.words.length} words, source digest ${catalogue.sourceDigest.slice(0, 12)}`,
131
+ remedy: null,
132
+ });
133
+ }
134
+ catch {
135
+ checks.push({
136
+ id: 'vocabulary',
137
+ section: 'Environment',
138
+ title: 'Vocabulary',
139
+ status: 'unknown',
140
+ detail: 'The shipped catalogue could not be read from this installation',
141
+ remedy: 'Reinstall the package so assets/vocabulary.json travels with it',
142
+ });
143
+ }
144
+ // pnpm is a contributor requirement, not a consumer one, so its absence is never a verdict about this run.
145
+ const agent = request.env['npm_config_user_agent'];
146
+ const pnpm = agent === undefined ? undefined : /pnpm\/(\S+)/.exec(agent)?.[1];
147
+ checks.push({
148
+ id: 'package-manager',
149
+ section: 'Environment',
150
+ title: 'Package manager',
151
+ status: 'info',
152
+ detail: pnpm === undefined
153
+ ? 'Not reported by this invocation; pnpm 10.33.0 is a contributor requirement, not a consumer one'
154
+ : `pnpm ${pnpm} invoked this command`,
155
+ remedy: null,
156
+ });
157
+ return checks;
158
+ };
159
+ const directory = (path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
160
+ // ---- Host rows: host registration spec §7 ----------------------------------------------------------------------
161
+ const PAYLOAD = /^[a-f0-9]{64}$/;
162
+ const short = (digest) => (digest === null ? 'unknown' : digest.slice(0, 12));
163
+ /** An installation-section row; every host and projection row is one. */
164
+ const installation = (id, title, status, detail, remedy) => ({
165
+ id,
166
+ section: 'Installation',
167
+ title,
168
+ status,
169
+ detail,
170
+ remedy,
171
+ });
172
+ const commandsFor = (root) => {
173
+ const prose = (host, text) => (root === undefined ? text : rootedNext(text, host, root));
174
+ return { prose, apply: (host) => prose(host, `"ia host ${host} --apply"`).slice(1, -1) };
175
+ };
176
+ /**
177
+ * §3.3: the payload this installation pins, and whether the host home holds it. Cheap on purpose — one stat and
178
+ * one directory listing, no verification (each host row verifies the payload its registration uses). A `.stage-*`
179
+ * directory is an unfinished materialization, never a payload, so only 64-hex names are listed.
180
+ */
181
+ function payloadRow(request, pinned) {
182
+ if (pinned.release === null)
183
+ return installation('host-payload', 'Host payload', 'info', `This installation carries no host payload (${pinned.code}); ia host cannot register a host, ` +
184
+ 'and registered releases are not compared', 'Reinstall the package so assets/host travels with it');
185
+ let home;
186
+ try {
187
+ home = resolveIaHome(request.env, request.home ?? homedir()).home;
188
+ }
189
+ catch (error) {
190
+ return installation('host-payload', 'Host payload', 'unknown', `Not checked; ${codeOf(error, 'the IA home could not be resolved')}: IA_HOME must be absolute`, 'Set IA_HOME to an absolute directory, or unset it to use ~/.ia.');
191
+ }
192
+ const hosts = join(home, 'hosts');
193
+ let others = [];
194
+ try {
195
+ others = readdirSync(hosts).filter((name) => PAYLOAD.test(name) && name !== pinned.release);
196
+ }
197
+ catch {
198
+ others = [];
199
+ }
200
+ const noun = others.length === 1 ? 'payload' : 'payloads';
201
+ const listed = others.length === 0 ? '' : `; ${others.length} other ${noun} there: ${others.sort().map(short).join(', ')}`;
202
+ const present = directory(hostPayloadPath(home, pinned.release));
203
+ const state = present ? 'present' : 'not materialized; ia host materializes it on apply';
204
+ return installation('host-payload', 'Host payload', 'info', `Release ${short(pinned.release)}, ${state} in ${hosts}${listed}`, null);
205
+ }
206
+ /**
207
+ * Spec §8: the host lock and the three host journals, as the install lock and journal have their rows. A held lock is
208
+ * `warn`, because a live run holds it too; its recovery clears only a dead holder's. A journal is `fail`, because
209
+ * every `ia host` plan refuses until its own recovery runs (`assertHostRegistrationIdle`).
210
+ */
211
+ function hostTransactionRows(root) {
212
+ const held = existsSync(resolve(root, HOST_LOCK));
213
+ return [
214
+ installation('host-lock', 'Host lock', held ? 'warn' : 'info', held
215
+ ? `${HOST_LOCK} is held by another ia host run or was left by a killed one; the remedy clears a dead holder's lock and refuses a live one`
216
+ : 'Not held', held ? recoverCommand('recover-host', root) : null),
217
+ ...JOURNALS.filter(([path]) => existsSync(resolve(root, path))).map(([path, command]) => installation(`host-journal:${path}`, 'Host journal', 'fail', `${path} exists; an ia host transaction was interrupted`, recoverCommand(command, root))),
218
+ ];
219
+ }
220
+ /** Whether a user-owned JSON file the host set edits still parses; a file that is absent does. */
221
+ function parses(root, path) {
222
+ try {
223
+ decodeDistributionJson(readFileSync(resolve(root, path), 'utf8'));
224
+ return true;
225
+ }
226
+ catch (error) {
227
+ return error.code === 'ENOENT';
228
+ }
229
+ }
230
+ /**
231
+ * `launcher-unverified` covers two different facts, told apart here by verifying once more: a payload that fails
232
+ * verification, and one that verifies as another release than the state recorded. Only a stale row pays for it.
233
+ */
234
+ function unverified(cache, recorded) {
235
+ if (cache === null)
236
+ return 'the payload fails verification';
237
+ try {
238
+ const found = verifyHostCache(cache).release;
239
+ return found === recorded
240
+ ? `the payload at ${cache} did not verify when observed`
241
+ : `the payload at ${cache} verifies, but as release ${short(found)} rather than the recorded ${short(recorded)}`;
242
+ }
243
+ catch (error) {
244
+ return `the payload at ${cache} fails verification (${codeOf(error, 'unreadable')})`;
245
+ }
246
+ }
247
+ /**
248
+ * Which ownership file `state-invalid` means. `observeHosts` reports an unreadable `<host>-workspace.json` with no
249
+ * elements at all; otherwise it is the guard's state (Claude only) or the projection's, and the elements that were
250
+ * still read tell those apart except when the guard was read and the projection was not.
251
+ */
252
+ function unreadableStates(observed) {
253
+ if (observed.elements.length === 0)
254
+ return [STATE.mcp(observed.host)];
255
+ if (observed.host === 'codex' || !observed.elements.includes('hooks'))
256
+ return [STATE.projection(observed.host)];
257
+ if (observed.elements.includes('projection'))
258
+ return [STATE.hooks];
259
+ return [STATE.hooks, STATE.projection(observed.host)];
260
+ }
261
+ /**
262
+ * The repair a stale host needs before `ia host <host> --apply` can succeed, worded as `ia host` words it
263
+ * (`stateRepair`, `modifiedRepair`), or '' when the remedy alone converges.
264
+ */
265
+ function repairOf(root, observed) {
266
+ const host = observed.host, apply = `ia host ${host} --apply`;
267
+ if (observed.reasons.includes('state-invalid')) {
268
+ const [first, second] = unreadableStates(observed);
269
+ if (second === undefined)
270
+ return stateRepair(host, first, apply) ?? '';
271
+ return (`One of ${first} and ${second} cannot be read. ` +
272
+ `If it is the first: ${stateRepair(host, first, apply)} If it is the second: ${stateRepair(host, second, apply)}`);
273
+ }
274
+ // A file that no longer parses is repaired by making it parse; apply then names anything left to do.
275
+ const unparsed = [
276
+ ...(host === 'claude' && observed.reasons.includes('mcp-modified') && !parses(root, MCP_PATH.claude)
277
+ ? [MCP_PATH.claude]
278
+ : []),
279
+ ...(observed.reasons.includes('guard-modified') && !parses(root, SETTINGS) ? [SETTINGS] : []),
280
+ ];
281
+ if (unparsed.length > 0)
282
+ return `Make ${unparsed.join(' and ')} parse as JSON, then run "${apply}".`;
283
+ const modified = [
284
+ ...(observed.reasons.includes('mcp-modified') ? ['mcp'] : []),
285
+ ...(observed.reasons.includes('guard-modified') ? ['hooks'] : []),
286
+ ];
287
+ return modified.length === 0 ? '' : modifiedRepair(host, modified);
288
+ }
289
+ /** A stale host: each reason as a fact, then the repair that works first, where the remedy alone would refuse. */
290
+ function staleDetail(root, observed, pinned) {
291
+ const facts = observed.reasons.map((reason) => {
292
+ switch (reason) {
293
+ case 'release':
294
+ return `registered for release ${short(observed.release)}; this installation pins ${short(pinned.release)}`;
295
+ case 'launcher-missing':
296
+ return `launcher ${observed.launcher} is missing`;
297
+ case 'launcher-unverified':
298
+ return unverified(observed.cache, observed.release);
299
+ case 'mcp-modified':
300
+ return `${MCP_PATH[observed.host]} does not hold the ia-workspace entry ia host writes for this registration`;
301
+ case 'guard-modified':
302
+ return `${SETTINGS} does not hold the IA guard group ia host writes for this registration`;
303
+ case 'guard-form':
304
+ return `${SETTINGS} holds the IA guard group in a form ia host no longer writes; re-applying rewrites it`;
305
+ case 'node-missing':
306
+ return 'the Node executable the registration runs no longer exists; the remedy records the running one';
307
+ case 'guard-release':
308
+ return 'the guard pins a different release from the MCP entry';
309
+ case 'guard-launcher':
310
+ return "the guard's launcher is missing or fails verification";
311
+ case 'state-invalid':
312
+ return `an ownership file under ${HOSTS_AREA} cannot be read`;
313
+ }
314
+ });
315
+ const repair = repairOf(root, observed);
316
+ return `stale (${observed.reasons.join(', ')}): ${facts.join('; ')}.${repair === '' ? '' : ` ${repair}`}`;
317
+ }
318
+ function hostRow(root, observed, pinned, commands) {
319
+ const host = observed.host;
320
+ // Written is all doctor can know: it never starts a server, so it never says one answered (spec §7).
321
+ if (observed.status === 'registered')
322
+ return installation(`host-${host}`, `Host ${host}`, 'ok', `registered (${observed.elements.join(', ')}); written; not observed answering; cache ${observed.cache}`, null);
323
+ const detail = commands.prose(host, staleDetail(root, observed, pinned));
324
+ return installation(`host-${host}`, `Host ${host}`, 'fail', detail, commands.apply(host));
325
+ }
326
+ /**
327
+ * Spec §7: one row per managed file that drifted. `changed` (a hand edit) and `unmanaged` (a file without the marker
328
+ * where the projection writes) make apply refuse, so they fail and name the same repair `ia host` does; `missing`
329
+ * and `outdated` are what the next apply rewrites; `unowned` is a marked file this workspace's state does not list.
330
+ */
331
+ function driftRow(host, drift, artifacts, commands) {
332
+ const id = `projection-${host}:${drift.path}`, title = `Projection ${host}`, apply = commands.apply(host), unlisted = `${drift.path} unowned: marked but not listed in this workspace's projection state`;
333
+ switch (drift.drift) {
334
+ case 'changed':
335
+ return installation(id, title, 'fail', `${drift.path} changed since ia host wrote it; move or delete it, then run the remedy`, apply);
336
+ case 'unmanaged':
337
+ return installation(id, title, 'fail', `${drift.path} unmanaged: it lacks the ia host marker where the projection writes; move or delete it, then run the remedy`, apply);
338
+ case 'missing':
339
+ return installation(id, title, 'warn', `${drift.path} missing; the remedy writes it again`, apply);
340
+ case 'outdated':
341
+ return installation(id, title, 'warn', `${drift.path} outdated: the current records render it differently`, apply);
342
+ case 'unowned':
343
+ // A marked file the projection would write is adopted by the next apply; any other one ia host never touches.
344
+ if (artifacts === null)
345
+ return installation(id, title, 'warn', unlisted, null);
346
+ return artifacts.some((artifact) => artifact.path === drift.path)
347
+ ? installation(id, title, 'warn', `${unlisted}; the remedy adopts it`, apply)
348
+ : installation(id, title, 'warn', `${unlisted}; ia host leaves it untouched, so delete it if nothing uses it`, null);
349
+ }
350
+ }
351
+ /** The projection rows for one host that owns a projection. Never throws: a failure to observe is itself a row. */
352
+ function projectionRows(root, host, commands) {
353
+ const title = `Projection ${host}`, checks = [];
354
+ let artifacts = null;
355
+ try {
356
+ artifacts = renderProjectionFor(root, host);
357
+ }
358
+ catch (error) {
359
+ // Without a rendering, hand edits and missing files are still observed; only `outdated` cannot be.
360
+ const why = codeOf(error, 'the projection could not be rendered');
361
+ const detail = `Not compared with the current records; ${why}`;
362
+ checks.push(installation(`projection-${host}-render`, title, 'unknown', detail, 'ia validate'));
363
+ }
364
+ try {
365
+ for (const drift of observeProjection({ root, host, artifacts, marker: WORKSPACE_PROJECTION_MARKER }))
366
+ checks.push(driftRow(host, drift, artifacts, commands));
367
+ }
368
+ catch (error) {
369
+ // The mechanism locates the failure at the file it could not read: the ownership state, or a managed file.
370
+ const state = STATE.projection(host), path = refusedPath(error) ?? state, repair = projectionRepair(host, path, `ia host ${host} --apply`);
371
+ const detail = commands.prose(host, `${path} cannot be read (${codeOf(error, 'an unexpected error')}). ${repair}`);
372
+ checks.push(installation(path === state ? `projection-${host}` : `projection-${host}:${path}`, title, 'fail', detail, commands.apply(host)));
373
+ }
374
+ return checks;
375
+ }
376
+ /** Spec §7's rows for a workspace whose installed state could be read. */
377
+ function hostRows(root, observed, pinned, commands) {
378
+ if (observed.length === 0) {
379
+ const none = 'No host registered; run "ia host claude" or "ia host codex" to plan one';
380
+ return [installation('host', 'Host', 'info', commands.prose('codex', commands.prose('claude', none)), null)];
381
+ }
382
+ return observed.flatMap((host) => [
383
+ hostRow(root, host, pinned, commands),
384
+ ...(host.elements.includes('projection') ? projectionRows(root, host.host, commands) : []),
385
+ ]);
386
+ }
387
+ /**
388
+ * Host plugin distribution spec §3 and §7.3 `ia-home` warn: a registration made before the IA home moved still pins
389
+ * its payload where it was materialized, usually M5.3's per-OS data directory. It keeps working, so the row warns;
390
+ * re-applying materializes the payload in the current home and re-pins it. No command moves or deletes the old one.
391
+ */
392
+ function movedRows(request, observed, commands) {
393
+ const user = request.home ?? homedir();
394
+ let home;
395
+ try {
396
+ home = resolveIaHome(request.env, user).home;
397
+ }
398
+ catch {
399
+ return []; // The ia-home row already fails with the reason.
400
+ }
401
+ const legacy = legacyHostHome(request.env, process.platform, user);
402
+ return observed.flatMap((host) => host.cache === null || inside(home, host.cache)
403
+ ? []
404
+ : [
405
+ installation(`ia-home-moved-${host.host}`, 'IA home moved', 'warn', `Host ${host.host} pins its payload under ${host.cache}, outside the IA home ${home}${inside(legacy, host.cache) ? ' (the M5.3 per-OS location)' : ''}`, commands.apply(host.host)),
406
+ ]);
407
+ }
408
+ // ---- Registry rows: registry spec §4 ---------------------------------------------------------------------------
409
+ /**
410
+ * One row per provider the lock names, in provider order. One chooser serves the report, as for any command that
411
+ * chooses for several ids (`registryChooser`), so a configuration file that reads cleanly is read once; a refusal is
412
+ * not cached, so a file that refuses does so again for each provider. A refusal is a warning, not a failure: the
413
+ * installed generation is intact, and only the next install, update or restore that routes through registries
414
+ * refuses. An unmapped provider's remedy is the one `ia install` names, so the two verbs cannot disagree; any other
415
+ * refusal keeps the service's code and message in its detail.
416
+ */
417
+ function registryRows(request, root, lock) {
418
+ const choose = registryChooser({ root, env: request.env, cwd: request.cwd, home: request.home ?? homedir() });
419
+ // The first id of each provider stands for it; a refusal names that id.
420
+ const providers = new Map();
421
+ for (const id of [
422
+ ...lock.requests.map((dependency) => dependency.id),
423
+ ...lock.packages.map((locked) => locked.id),
424
+ ].sort()) {
425
+ const provider = id.split('/')[0];
426
+ if (!providers.has(provider))
427
+ providers.set(provider, id);
428
+ }
429
+ return [...providers]
430
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
431
+ .map(([provider, id]) => {
432
+ try {
433
+ const choice = choose(id);
434
+ const detail = `${registryLocation(choice.base)} (from ${choice.level}: ${choice.source})`;
435
+ return installation(`registry-${provider}`, `Registry ${provider}`, 'info', detail, null);
436
+ }
437
+ catch (error) {
438
+ const code = codeOf(error, 'IA-CLI-FAILED'), message = error instanceof Error ? error.message : String(error);
439
+ // A Node error's message already starts with its code ("EACCES: permission denied, open …").
440
+ const detail = message.startsWith(`${code}: `) ? message : `${code}: ${message}`;
441
+ return installation(`registry-${provider}`, `Registry ${provider}`, 'warn', detail, code === 'IA-DIST-REGISTRY-UNMAPPED' ? UNMAPPED : null);
442
+ }
443
+ });
444
+ }
445
+ /**
446
+ * The workspace's portable lock, whose requests and packages name the providers. It is read on its own rather than taken
447
+ * from the installed generation, which a fresh clone does not have yet and which cannot be read when the activation
448
+ * pointer is missing or unusable or the lock drifted from it. An absent lock names no provider. A lock that cannot be
449
+ * read names none either and adds no row, as before. The generation row is then `unknown`, but its detail carries only
450
+ * the code of the installed-state read, so nothing in the report names the lock.
451
+ */
452
+ function portableLock(root) {
453
+ try {
454
+ return readWorkspaceLock({ root });
455
+ }
456
+ catch {
457
+ return undefined;
458
+ }
459
+ }
460
+ export function collectDoctor(request) {
461
+ const checks = [...environment(request)];
462
+ const supplied = request.root === undefined
463
+ ? undefined
464
+ : isAbsolute(request.root)
465
+ ? resolve(request.root)
466
+ : resolve(request.cwd, request.root);
467
+ // A supplied or discovered root opens at its real path, as the workspace verbs open it (`requireRoot`).
468
+ const found = supplied ?? discoverRoot(request.cwd, iaHomeOf(request.env, request.home));
469
+ const root = found !== undefined && directory(found) ? realpathSync(found) : found;
470
+ const usable = root !== undefined && directory(root);
471
+ checks.push({
472
+ id: 'root',
473
+ section: 'Workspace',
474
+ title: 'Root',
475
+ status: usable ? 'ok' : 'warn',
476
+ detail: usable
477
+ ? root
478
+ : supplied === undefined
479
+ ? 'No .ia/src directory here or in any parent'
480
+ : `${supplied} is not an existing directory`,
481
+ remedy: usable ? null : 'ia init',
482
+ });
483
+ const pending = usable && existsSync(resolve(root, INSTALL_PATHS.pending));
484
+ const pinned = pinnedRelease(request.packageRoot);
485
+ // The observed hosts, or the code that kept the installed state from being read; undefined until it is read.
486
+ let observed;
487
+ const unchecked = (id, section, title, why) => ({
488
+ id,
489
+ section,
490
+ title,
491
+ status: 'unknown',
492
+ detail: `Not checked; ${why}`,
493
+ remedy: null,
494
+ });
495
+ if (!usable)
496
+ checks.push(unchecked('records', 'Workspace', 'Records', 'no workspace'));
497
+ else if (pending)
498
+ checks.push(unchecked('records', 'Workspace', 'Records', 'installation recovery is required first'));
499
+ else {
500
+ try {
501
+ const admission = validateWorkspace({ root });
502
+ const errors = admission.findings.filter((finding) => finding.severity === 'error').length;
503
+ const warnings = admission.findings.filter((finding) => finding.severity === 'warning').length;
504
+ checks.push({
505
+ id: 'records',
506
+ section: 'Workspace',
507
+ title: 'Records',
508
+ status: errors === 0 ? 'ok' : 'fail',
509
+ detail: `${admission.records} records, ${errors} errors, ${warnings} warnings at revision ${admission.revision.slice(0, 12)}`,
510
+ remedy: errors === 0 ? null : 'ia validate',
511
+ });
512
+ }
513
+ catch (error) {
514
+ checks.push({
515
+ id: 'records',
516
+ section: 'Workspace',
517
+ title: 'Records',
518
+ status: 'unknown',
519
+ detail: `Not checked; ${codeOf(error, 'the workspace could not be read')}`,
520
+ remedy: null,
521
+ });
522
+ }
523
+ }
524
+ if (!usable) {
525
+ for (const [id, title] of [
526
+ ['generation', 'Generation'],
527
+ ['pending', 'Pending state'],
528
+ ['install-lock', 'Install lock'],
529
+ ['cache', 'Cache'],
530
+ ])
531
+ checks.push(unchecked(id, 'Installation', title, 'no workspace'));
532
+ }
533
+ else {
534
+ if (pending)
535
+ checks.push(unchecked('generation', 'Installation', 'Generation', 'installation recovery is required first'));
536
+ else {
537
+ try {
538
+ // Spec §7: hosts are observed against this installation's pin; without one, the release comparison is skipped.
539
+ const installed = readInstalledState({ root, hosts: true, hostRelease: pinned.release });
540
+ observed = installed.hosts ?? [];
541
+ checks.push({
542
+ id: 'generation',
543
+ section: 'Installation',
544
+ title: 'Generation',
545
+ status: 'info',
546
+ detail: installed.pointer === undefined
547
+ ? 'None installed'
548
+ : `${installed.pointer.generation.slice(0, 12)}, counter ${installed.pointer.counter}`,
549
+ remedy: null,
550
+ });
551
+ }
552
+ catch (error) {
553
+ observed = codeOf(error, 'the installed state could not be read');
554
+ checks.push({
555
+ id: 'generation',
556
+ section: 'Installation',
557
+ title: 'Generation',
558
+ status: 'unknown',
559
+ detail: `Not checked; ${observed}`,
560
+ remedy: `ia-distribution recover --root ${root}`,
561
+ });
562
+ }
563
+ // Registry spec §4: after the generation row, the registry each provider in the workspace's lock routes to. An
564
+ // interrupted apply has none yet, because its recovery may put the previous lock back.
565
+ const lock = portableLock(root);
566
+ if (lock !== undefined)
567
+ checks.push(...registryRows(request, root, lock));
568
+ }
569
+ checks.push({
570
+ id: 'pending',
571
+ section: 'Installation',
572
+ title: 'Pending state',
573
+ status: pending ? 'fail' : 'ok',
574
+ detail: pending ? `${INSTALL_PATHS.pending} exists; an apply was interrupted` : 'No interrupted transaction',
575
+ remedy: pending ? `ia-distribution recover --root ${root}` : null,
576
+ });
577
+ const held = existsSync(resolve(root, INSTALL_LOCK));
578
+ checks.push({
579
+ id: 'install-lock',
580
+ section: 'Installation',
581
+ title: 'Install lock',
582
+ status: held ? 'warn' : 'info',
583
+ detail: held ? `${INSTALL_LOCK} is held by another installer or was left behind` : 'Not held',
584
+ remedy: held ? `ia-distribution recover --root ${root}` : null,
585
+ });
586
+ const cache = resolve(root, CACHE);
587
+ const archives = directory(cache) ? readdirSync(cache).filter((name) => ARCHIVE.test(name)).length : 0;
588
+ checks.push({
589
+ id: 'cache',
590
+ section: 'Installation',
591
+ title: 'Cache',
592
+ status: 'info',
593
+ detail: `${archives} ${archives === 1 ? 'archive' : 'archives'} in ${CACHE}`,
594
+ remedy: null,
595
+ });
596
+ }
597
+ // Spec §7. Host state is relative to a workspace and is read with the installed state, so it waits on both.
598
+ if (!usable)
599
+ checks.push(unchecked('host', 'Installation', 'Host', 'no workspace'));
600
+ else if (pending)
601
+ checks.push(unchecked('host', 'Installation', 'Host', 'installation recovery is required first'));
602
+ else if (observed === undefined || typeof observed === 'string')
603
+ checks.push(unchecked('host', 'Installation', 'Host', observed ?? 'the installed state was not read'));
604
+ else
605
+ checks.push(payloadRow(request, pinned), ...hostTransactionRows(root), ...hostRows(root, observed, pinned, commandsFor(supplied)), ...movedRows(request, observed, commandsFor(supplied)));
606
+ // Host plugin distribution spec §7.3 and §8.2: rows that exist with or without a workspace, and the briefing. One
607
+ // report has one root. Without --root it was discovered upward from the cwd, which is how the §8.1 hook runs doctor.
608
+ // A supplied --root is literal, as for every other verb: it is a workspace only when it holds .ia/src itself. When it
609
+ // does not but lies inside a workspace, that workspace is only named (`enclosing`), never described or offered init.
610
+ const workspaceRoot = supplied === undefined
611
+ ? usable
612
+ ? root
613
+ : undefined
614
+ : directory(resolve(supplied, '.ia/src'))
615
+ ? supplied
616
+ : undefined;
617
+ const enclosing = supplied !== undefined && workspaceRoot === undefined && directory(supplied)
618
+ ? discoverRoot(supplied, iaHomeOf(request.env, request.home))
619
+ : undefined;
620
+ const version = request.version ?? '0.0.0';
621
+ const user = userRows({
622
+ env: request.env,
623
+ home: request.home ?? homedir(),
624
+ packageRoot: request.packageRoot,
625
+ version,
626
+ directory: supplied ?? request.cwd,
627
+ supplied,
628
+ root: workspaceRoot,
629
+ enclosing,
630
+ now: request.now ?? new Date(),
631
+ channel: request.channel,
632
+ });
633
+ checks.push(...user.checks);
634
+ const briefing = request.host === undefined
635
+ ? undefined
636
+ : briefingFor(request.host, checks, user, { root: workspaceRoot, supplied, enclosing: enclosing ?? null }, version);
637
+ return { checks, counts: count(checks), ...(briefing === undefined ? {} : { briefing }) };
638
+ }
639
+ /**
640
+ * §8.2's host state from the rows already collected. `unknown` when the host set was not observed — an interrupted
641
+ * installation, or installed state that could not be read — so the briefing never calls a host missing that doctor did
642
+ * not look at. Stale covers the host row itself, a payload pinned outside the IA home (§3 names re-applying as its
643
+ * remedy) and any failing projection row, which `ia host <host> --apply` also repairs.
644
+ */
645
+ function hostState(host, checks) {
646
+ const pending = checks.find((check) => check.id === 'pending');
647
+ const unobserved = checks.find((check) => check.id === 'host' && check.status === 'unknown');
648
+ if (unobserved !== undefined || pending?.status === 'fail')
649
+ return {
650
+ status: 'unknown',
651
+ detail: unobserved === undefined ? null : unobserved.detail.replace(/^Not checked; /, ''),
652
+ recovery: pending?.status === 'fail' ? pending.remedy : null,
653
+ };
654
+ const hostCheck = checks.find((check) => check.id === `host-${host}`);
655
+ const failing = checks.filter((check) => (check.id === `host-${host}` && check.status !== 'ok') ||
656
+ check.id === `ia-home-moved-${host}` ||
657
+ (check.id.startsWith(`projection-${host}`) && check.status === 'fail'));
658
+ if (failing.length > 0)
659
+ return { status: 'stale', detail: failing.map((check) => check.detail).join('; '), recovery: null };
660
+ return { status: hostCheck === undefined ? 'absent' : 'registered', detail: null, recovery: null };
661
+ }
662
+ /** §8.2's input from the rows already collected: nothing is observed twice. */
663
+ function briefingFor(host, checks, user, where, version) {
664
+ const state = hostState(host, checks);
665
+ return brief({
666
+ host,
667
+ version,
668
+ channel: user.facts.channel,
669
+ plugin: user.facts.plugin,
670
+ ...where,
671
+ frameworkSource: user.facts.frameworkSource,
672
+ decision: user.facts.decision,
673
+ records: checks.find((check) => check.id === 'records' && check.status !== 'unknown')?.detail ?? null,
674
+ hostStatus: state.status,
675
+ hostDetail: state.detail,
676
+ recovery: state.recovery,
677
+ // §11.1 and Amendment item 4: the cached check and the refresh argv, both read without writing anything.
678
+ updates: user.facts.updates,
679
+ refresh: user.facts.refresh,
680
+ });
681
+ }
682
+ /** §2.12: 0 when no check failed. `unknown` and `info` never decide the class; only `fail` does. */
683
+ export const doctorExit = (view) => (view.counts.fail === 0 ? 0 : 1);
684
+ export function doctorEnvelope(view) {
685
+ return {
686
+ version: 1,
687
+ checks: view.checks.map((check) => ({
688
+ id: check.id,
689
+ title: check.title,
690
+ status: check.status,
691
+ detail: check.detail,
692
+ remedy: check.remedy,
693
+ })),
694
+ counts: view.counts,
695
+ ...(view.briefing === undefined ? {} : { session: view.briefing.session, nextActions: view.briefing.nextActions }),
696
+ };
697
+ }
698
+ const SYMBOLS = {
699
+ ok: 'success',
700
+ warn: 'warning',
701
+ fail: 'error',
702
+ unknown: 'unknown',
703
+ info: 'info',
704
+ };
705
+ const plural = (value, one, many) => `${value} ${value === 1 ? one : many}`;
706
+ export function renderDoctor(view, caps) {
707
+ const sections = ['Environment', 'Workspace', 'Installation'];
708
+ const blocks = sections.map((section) => {
709
+ const rows = view.checks
710
+ .filter((check) => check.section === section)
711
+ .map((check) => ({
712
+ symbol: SYMBOLS[check.status],
713
+ label: check.title,
714
+ value: words(check.detail),
715
+ // A remedy is a command, so it is one unbreakable token run and is never wrapped into an unrunnable line.
716
+ ...(check.remedy === null ? {} : { action: [atom(check.remedy, 'cyan', 0)] }),
717
+ }));
718
+ return [sectionLabel(section, caps), ...fieldRows(rows, { depth: 1 }, caps)];
719
+ });
720
+ const counts = view.counts;
721
+ const totals = `${counts.ok} ok, ${plural(counts.warn, 'warning', 'warnings')}, ${counts.unknown} not checked, ${plural(counts.info, 'note', 'notes')}, ${counts.fail} failed.`;
722
+ return document([...blocks, entry([words(totals, 'dim')], { depth: 0 }, caps)], { leadingBlank: true });
723
+ }
724
+ export function runDoctor(context) {
725
+ const { host, args, caps, json } = context;
726
+ const view = collectDoctor({
727
+ cwd: host.cwd,
728
+ root: args.value('root'),
729
+ packageRoot: host.packageRoot,
730
+ runtime: { version: process.version, platform: process.platform, arch: process.arch },
731
+ env: host.env,
732
+ home: homedir(),
733
+ version: host.version,
734
+ host: args.value('host'),
735
+ });
736
+ const exitCode = doctorExit(view);
737
+ return json
738
+ ? { exitCode, stdout: JSON.stringify(doctorEnvelope(view)) + '\n', stderr: '' }
739
+ : { exitCode, stdout: renderDoctor(view, caps), stderr: '' };
740
+ }
741
+ /**
742
+ * `within`, except that a path doctor cannot examine (a link loop, no permission) is compared by spelling, so a diagnosis
743
+ * never fails on its subject.
744
+ */
745
+ function inside(parent, child) {
746
+ try {
747
+ return within(parent, child);
748
+ }
749
+ catch {
750
+ const path = relative(pathKey(resolve(parent)), pathKey(resolve(child)));
751
+ return path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith('..' + sep));
752
+ }
753
+ }
754
+ //# sourceMappingURL=doctor.js.map