@outerlayer/cli 0.2.0 → 0.4.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 (141) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +226 -59
  3. package/dist/agent-setup-IFH3FB5X.js +197 -0
  4. package/dist/build-TZUUTRGH.js +9 -0
  5. package/dist/build-info.json +1 -1
  6. package/dist/check-WQP2CZDW.js +94 -0
  7. package/dist/chunk-2E54Z3P5.js +232 -0
  8. package/dist/chunk-3TT7RQYB.js +55 -0
  9. package/dist/{chunk-U32VSRLO.js → chunk-3YPKXE5L.js} +90 -632
  10. package/dist/{chunk-Q6CL5THG.js → chunk-5DLBAXY5.js} +208 -61
  11. package/dist/{chunk-R5KBGVII.js → chunk-5QRP3MVS.js} +30 -14
  12. package/dist/chunk-5Y3QQRUH.js +300 -0
  13. package/dist/chunk-6Y63SXFO.js +383 -0
  14. package/dist/chunk-774HEQL6.js +178 -0
  15. package/dist/chunk-7A6RVXPZ.js +2233 -0
  16. package/dist/chunk-7YIOOFVJ.js +71 -0
  17. package/dist/chunk-ABIWDUMS.js +104 -0
  18. package/dist/chunk-AUFG23AA.js +534 -0
  19. package/dist/chunk-B2G7JHHB.js +42 -0
  20. package/dist/chunk-BCJNHMZT.js +34 -0
  21. package/dist/chunk-BCLJSQCV.js +56 -0
  22. package/dist/chunk-BFESLKPP.js +61 -0
  23. package/dist/chunk-BJ3KTMDH.js +63 -0
  24. package/dist/chunk-BKO6JEZI.js +47 -0
  25. package/dist/chunk-BOLTI6LR.js +37 -0
  26. package/dist/chunk-CBPPJSAR.js +89 -0
  27. package/dist/chunk-CBZB6XY6.js +102 -0
  28. package/dist/{chunk-65WXAANR.js → chunk-DVDEBNQJ.js} +17 -2
  29. package/dist/{work-pr-cmd-AZQPWH4H.js → chunk-DVYHCMB2.js} +9 -26
  30. package/dist/{chunk-TFUIDMOB.js → chunk-EABW6AJQ.js} +12 -1
  31. package/dist/{chunk-KFYJV2ZG.js → chunk-EMY4I27X.js} +1 -1
  32. package/dist/{chunk-NTTPJV35.js → chunk-F47JBIAW.js} +3 -1
  33. package/dist/chunk-F6GFZXZ3.js +60 -0
  34. package/dist/{chunk-YOOSBOKS.js → chunk-F7CU5ABH.js} +1 -1
  35. package/dist/chunk-FAJSS2WN.js +1357 -0
  36. package/dist/chunk-FS2VYGZT.js +1764 -0
  37. package/dist/chunk-FV2CCGXK.js +9 -0
  38. package/dist/{chunk-4TNZMO7V.js → chunk-G7FSV7JX.js} +3452 -751
  39. package/dist/{emit-cmd-TWSYYEZI.js → chunk-H5NCNVDA.js} +68 -17
  40. package/dist/chunk-HE2D4EY5.js +83 -0
  41. package/dist/chunk-L2DBRKRA.js +94 -0
  42. package/dist/chunk-LF6MLJCY.js +38 -0
  43. package/dist/chunk-M5POMKKW.js +1051 -0
  44. package/dist/{mcp-install-cmd-FDQH6SEN.js → chunk-MQ3IPHIZ.js} +35 -18
  45. package/dist/{chunk-D77LS3UI.js → chunk-N5FOE5PS.js} +33 -4
  46. package/dist/chunk-N6LURUEF.js +64 -0
  47. package/dist/chunk-NYC54VBD.js +1084 -0
  48. package/dist/chunk-OGFGZQA3.js +23 -0
  49. package/dist/chunk-QOZKPLVJ.js +226 -0
  50. package/dist/chunk-RA3O54FC.js +30 -0
  51. package/dist/chunk-TBT347UY.js +41 -0
  52. package/dist/{chunk-NXLLURA4.js → chunk-UFFXNXLQ.js} +115 -71
  53. package/dist/chunk-USO2DKBD.js +421 -0
  54. package/dist/chunk-W4SQCOZU.js +206 -0
  55. package/dist/{chunk-A3WLZX2F.js → chunk-WIOZAJ2W.js} +15 -2
  56. package/dist/chunk-WO2BXCTQ.js +83 -0
  57. package/dist/{chunk-I3ETLSNE.js → chunk-XCCLFVXM.js} +120 -11
  58. package/dist/{context-materialize-6WKT3RBQ.js → chunk-XDDW4FRS.js} +134 -26
  59. package/dist/chunk-YYYXRJUW.js +101 -0
  60. package/dist/chunk-ZM2IMMYN.js +76 -0
  61. package/dist/chunk-ZMYPLFG3.js +71 -0
  62. package/dist/chunk-ZNA27WEV.js +470 -0
  63. package/dist/{cli-G7DILYJY.js → cli-NHUXABYH.js} +590 -876
  64. package/dist/{paths-D2VGWWFI.js → cli-build-K56DK4DS.js} +1 -1
  65. package/dist/config-XVJYZ4IQ.js +9 -0
  66. package/dist/connect-cmd-OOG4TPU7.js +16 -0
  67. package/dist/context-adopt-UMA4O5NY.js +105 -0
  68. package/dist/context-materialize-G2FUFC72.js +19 -0
  69. package/dist/dist-RW4UPPC2.js +6 -0
  70. package/dist/docker-NEGDLU6D.js +7 -0
  71. package/dist/doctor-53F2PYY7.js +43 -0
  72. package/dist/doctor-UFVL4PZY.js +426 -0
  73. package/dist/{emit-artifact-cmd-UQVT3OKW.js → emit-artifact-cmd-KTYW2TN2.js} +24 -20
  74. package/dist/emit-cmd-DCERAU27.js +9 -0
  75. package/dist/emit-criteria-cmd-VAUUSAYD.js +166 -0
  76. package/dist/{emit-finding-cmd-FDOU2OPD.js → emit-finding-cmd-UR4ID45Z.js} +34 -31
  77. package/dist/{emit-result-cmd-H2T4C2K3.js → emit-result-cmd-FDTUKGKE.js} +34 -62
  78. package/dist/exec-client-4XGXV3XN.js +8 -0
  79. package/dist/guest-init-GENRHNP7.js +8 -0
  80. package/dist/hook-fast-EYENPK6F.js +9 -0
  81. package/dist/{hook-wrap-fast-KXYNX3AD.js → hook-wrap-fast-NPLHJXFK.js} +2 -2
  82. package/dist/host-key-KDYZ5ECC.js +7 -0
  83. package/dist/import-capture-cmd-AVZ4VIVJ.js +68 -0
  84. package/dist/{import-ruler-cmd-7LH2QOMG.js → import-ruler-cmd-GUHL6IY5.js} +1 -1
  85. package/dist/index.js +4 -4
  86. package/dist/init-DXBCOAJK.js +36 -0
  87. package/dist/init-YEXK35CF.js +221 -0
  88. package/dist/init-cmd-NHR7PRSQ.js +135 -0
  89. package/dist/install-cmd-7WXOIFDO.js +55 -0
  90. package/dist/lima-XN65D7GN.js +55 -0
  91. package/dist/login-browser-7XGSV7MA.js +10 -0
  92. package/dist/{config-POF7DEQW.js → logout-cmd-QAUFDJ6J.js} +3 -1
  93. package/dist/{logs-TRNPQM42.js → logs-7RYUH5A6.js} +1 -1
  94. package/dist/loop-VZOLL4WQ.js +43 -0
  95. package/dist/machine-WSG52J75.js +35 -0
  96. package/dist/mcp-install-cmd-RLK4SO3N.js +9 -0
  97. package/dist/{mcp-serve-cmd-57EZZOTL.js → mcp-serve-cmd-JPTP5FMS.js} +20 -9
  98. package/dist/paths-OYKMVYJP.js +6 -0
  99. package/dist/{pidfile-PTW76F56.js → pidfile-UZRH774M.js} +2 -3
  100. package/dist/real-deps-SU24ZA2K.js +21 -0
  101. package/dist/relay-L76HDX72.js +46 -0
  102. package/dist/settings-J2652U5N.js +7 -0
  103. package/dist/starter-pack-LIZYMKYQ.js +10 -0
  104. package/dist/{status-I27IST4L.js → status-2UZKKNB7.js} +30 -13
  105. package/dist/{statusline-fast-3C5OXHDD.js → statusline-fast-SVTMBMD7.js} +3 -2
  106. package/dist/sync-cmd-VPPYCNNM.js +28 -0
  107. package/dist/version-BWM6VLDI.js +6 -0
  108. package/dist/{watch-3DTPJETH.js → watch-5C4ZBBGO.js} +19 -8
  109. package/dist/{work-claim-cmd-5AUNCLS2.js → work-claim-cmd-LSO2B2EX.js} +33 -20
  110. package/dist/work-cmd-UOQO2AD4.js +17 -0
  111. package/dist/work-comment-cmd-WF3EOLAE.js +106 -0
  112. package/dist/{work-launch-LT663PB3.js → work-launch-YI4CJDAP.js} +1 -1
  113. package/dist/work-open-pr-cmd-4SSQZMYG.js +156 -0
  114. package/dist/work-pr-cmd-6AN6RRQJ.js +16 -0
  115. package/package.json +13 -3
  116. package/skill-pack/maintained/amend/SKILL.md +104 -0
  117. package/skill-pack/maintained/emitting-evidence/SKILL.md +99 -0
  118. package/skill-pack/maintained/emitting-evidence/references/agents-snippet.md +20 -0
  119. package/skill-pack/maintained/outerlayer/SKILL.md +55 -0
  120. package/skill-pack/maintained/reporting-findings/SKILL.md +148 -0
  121. package/skill-pack/template/build/SKILL.md +193 -0
  122. package/skill-pack/template/build/references/agent-briefs.md +243 -0
  123. package/skill-pack/template/build/references/criteria-judge.md +91 -0
  124. package/skill-pack/template/build/references/evidence.md +42 -0
  125. package/skill-pack/template/build/references/release.md +74 -0
  126. package/skill-pack/template/build/references/review-briefs.md +275 -0
  127. package/skill-pack/template/build/references/review-loop.md +158 -0
  128. package/skill-pack/template/build/scripts/record-criteria.mjs +235 -0
  129. package/skill-pack/template/spec/SKILL.md +84 -0
  130. package/skill-pack/template/writing-specs/SKILL.md +134 -0
  131. package/dist/chunk-DCNOXRMV.js +0 -589
  132. package/dist/chunk-JJP7YLMN.js +0 -25
  133. package/dist/chunk-OZ7C3XUE.js +0 -34
  134. package/dist/chunk-WQ6VGRGZ.js +0 -150
  135. package/dist/hook-fast-J5LCDHUJ.js +0 -8
  136. package/dist/import-capture-cmd-EUMBGIV3.js +0 -176
  137. package/dist/init-PTBITAUO.js +0 -103
  138. package/dist/login-cmd-IRX6LZT7.js +0 -62
  139. package/dist/loop-ASZRX3CZ.js +0 -648
  140. package/dist/sync-cmd-BBAUZ5JD.js +0 -17
  141. package/dist/work-cmd-2P4BVX47.js +0 -18
@@ -0,0 +1,1084 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { isLinkedRepository } from './chunk-3TT7RQYB.js';
4
+ import { readStatuslineState, STATE_FRESH_MS } from './chunk-F47JBIAW.js';
5
+ import { resolveCredentials, getJson, WorkCommandError } from './chunk-5DLBAXY5.js';
6
+ import { sanitizeTerminal } from './chunk-6Y63SXFO.js';
7
+ import { resolveRepoIdentity, compareVersions, SUPPORTED_VERSIONS } from './chunk-G7FSV7JX.js';
8
+ import { C } from './chunk-YRYMNBGQ.js';
9
+ import { readSettings, settingsPath, isOurStatuslineSlot, statuslineWrappedOriginal, REGISTERED_EVENTS, isOurHookCommand, ourCommandBins, cliBinResolvable, ourDirectScriptBins, wrappedOriginalCommand } from './chunk-5QRP3MVS.js';
10
+ import { readPin, readContextManifest, readDegraded, readSourceCache } from './chunk-WIOZAJ2W.js';
11
+ import { defaultFetch } from './chunk-BCLJSQCV.js';
12
+ import { resolveGatewayCredentials } from './chunk-BOLTI6LR.js';
13
+ import { readCloudConfig } from './chunk-EABW6AJQ.js';
14
+ import { sanitizeForContext, spoolDir, assessSyncHealth, readSyncStatus, SYNC_STALE_AFTER_MS, formatAgo, checkInstalledCliStaleness, workspaceRootOf, hookFiredMarkerPath, refreshCommand } from './chunk-UFFXNXLQ.js';
15
+ import { deadHooksState, preparePackageManagerCommand, contextExcludesPresent } from './chunk-XCCLFVXM.js';
16
+ import { findGitDirs } from './chunk-BJ3KTMDH.js';
17
+ import { homeFromEnv } from './chunk-VNVZDWO3.js';
18
+ import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, accessSync, constants, statSync, readdirSync } from 'fs';
19
+ import { join, dirname } from 'path';
20
+ import { execFileSync } from 'child_process';
21
+
22
+ createRequire(import.meta.url);
23
+ function manifestPath(home = homeFromEnv()) {
24
+ return join(home, ".outerlayer", "hooks-manifest.json");
25
+ }
26
+ function readManifestFile(home) {
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(manifestPath(home), "utf8"));
29
+ if (parsed?.version !== 1) {
30
+ return { manifest: null, newerVersion: typeof parsed?.version === "number" && parsed.version > 1 };
31
+ }
32
+ return {
33
+ manifest: {
34
+ version: 1,
35
+ installs: parsed.installs ?? {},
36
+ wrapped: parsed.wrapped ?? {},
37
+ statusline: parsed.statusline ?? {}
38
+ },
39
+ newerVersion: false
40
+ };
41
+ } catch {
42
+ return { manifest: null, newerVersion: false };
43
+ }
44
+ }
45
+ function readManifest(home = homeFromEnv()) {
46
+ return readManifestFile(home).manifest;
47
+ }
48
+ function writeManifest(home, manifest) {
49
+ const path = manifestPath(home);
50
+ try {
51
+ mkdirSync(dirname(path), { recursive: true });
52
+ const tmp = `${path}.tmp`;
53
+ writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n", { mode: 384 });
54
+ renameSync(tmp, path);
55
+ } catch {
56
+ }
57
+ }
58
+ function emptyManifest() {
59
+ return { version: 1, installs: {}, wrapped: {}, statusline: {} };
60
+ }
61
+ function withoutUpdatedAt(manifest) {
62
+ return JSON.parse(JSON.stringify(manifest), (key, value) => key === "updatedAt" ? void 0 : value);
63
+ }
64
+ function updateManifest(home, opts, mutate) {
65
+ const { manifest, newerVersion } = readManifestFile(home);
66
+ if (newerVersion) return;
67
+ if (!manifest && !opts.createIfMissing) return;
68
+ const next = manifest ?? emptyManifest();
69
+ const before = JSON.stringify(next);
70
+ mutate(next);
71
+ if (manifest && JSON.stringify(withoutUpdatedAt(next)) === JSON.stringify(withoutUpdatedAt(JSON.parse(before)))) return;
72
+ writeManifest(home, next);
73
+ }
74
+ function recordInstall(home, settingsPath2, record, now = Date.now) {
75
+ updateManifest(home, { createIfMissing: true }, (manifest) => {
76
+ manifest.installs[settingsPath2] = {
77
+ cliBin: record.cliBin,
78
+ events: [...record.events],
79
+ updatedAt: new Date(now()).toISOString()
80
+ };
81
+ });
82
+ }
83
+ function recordRemoval(home, settingsPath2) {
84
+ updateManifest(home, { createIfMissing: false }, (manifest) => {
85
+ delete manifest.installs[settingsPath2];
86
+ delete manifest.wrapped[settingsPath2];
87
+ delete manifest.statusline[settingsPath2];
88
+ });
89
+ }
90
+ function recordWrapped(home, settingsPath2, wrapped, now = Date.now) {
91
+ if (wrapped.length === 0) return;
92
+ updateManifest(home, { createIfMissing: true }, (manifest) => {
93
+ const existing = manifest.wrapped[settingsPath2] ?? [];
94
+ const at = new Date(now()).toISOString();
95
+ for (const w of wrapped) {
96
+ if (existing.some((e) => e.event === w.event && e.original === w.original)) continue;
97
+ existing.push({ event: w.event, original: w.original, updatedAt: at });
98
+ }
99
+ manifest.wrapped[settingsPath2] = existing;
100
+ });
101
+ }
102
+ function recordUnwrapped(home, settingsPath2) {
103
+ updateManifest(home, { createIfMissing: false }, (manifest) => {
104
+ delete manifest.wrapped[settingsPath2];
105
+ });
106
+ }
107
+ function recordStatusline(home, settingsPath2, outcome, original, now = Date.now) {
108
+ updateManifest(home, { createIfMissing: true }, (manifest) => {
109
+ manifest.statusline[settingsPath2] = {
110
+ outcome,
111
+ ...original !== void 0 ? { original } : {},
112
+ updatedAt: new Date(now()).toISOString()
113
+ };
114
+ });
115
+ }
116
+ var LAPTOP_CHECKS_NOT_APPLICABLE = {
117
+ name: "Laptop checks",
118
+ status: "skipped",
119
+ detail: "this is a runner host, which runs builds and no sessions of its own, so the Claude Code home, transcripts, hooks, spool, daemon and status line do not apply"
120
+ };
121
+ function runDoctor(env = {}) {
122
+ const home = env.home ?? homeFromEnv();
123
+ const now = env.now ?? Date.now;
124
+ const laptop = env.runnerHost !== true;
125
+ const transcripts = laptop ? scanTranscripts(home) : void 0;
126
+ return [
127
+ ...laptop ? [checkClaudeDir(home), checkTranscripts(transcripts, now), ...checkHooks(home, env.cwd, transcripts, now)] : [LAPTOP_CHECKS_NOT_APPLICABLE],
128
+ ...checkDeadHooksState(env.cwd),
129
+ ...laptop ? [checkSpoolWritable(home)] : [],
130
+ checkLastSync(home, now),
131
+ ...laptop ? [checkDaemon(home)] : [],
132
+ checkCleanupPeriod(home),
133
+ checkDiskHeadroom(home),
134
+ checkClaudeVersion(env),
135
+ checkSettingsValid(home, env.cwd),
136
+ ...laptop ? checkStatusline(home, now, env.cwd) : [],
137
+ ...checkInstalls(env),
138
+ ...checkContextSource(home, env.cwd),
139
+ ...checkInstalledCli(home, env.cwd ?? process.cwd())
140
+ ];
141
+ }
142
+ function checkInstalledCli(home, cwd) {
143
+ const name = "Installed CLI";
144
+ const result = checkInstalledCliStaleness({ home, cwd });
145
+ if (result === null) return [];
146
+ const installedLabel = result.installedId !== null ? sanitizeForContext(result.installedId) : "unknown";
147
+ const workspaceLabel = result.workspaceId !== null ? sanitizeForContext(result.workspaceId) : null;
148
+ const at = result.workspaceDistDir !== null ? ` at ${sanitizeForContext(workspaceRootOf(result.workspaceDistDir))}` : "";
149
+ if (result.stale) {
150
+ return [
151
+ {
152
+ name,
153
+ status: "warn",
154
+ detail: `installed copy (${installedLabel}) is behind the workspace build${at} (${workspaceLabel})`,
155
+ fix: installedCliFix(result.workspaceDistDir, result.workspaceRootTrusted)
156
+ }
157
+ ];
158
+ }
159
+ if (workspaceLabel === null) {
160
+ const detail = result.ambiguousWorkspace ? `installed (${installedLabel}) \u2014 more than one candidate workspace build found nearby; refusing to guess which one to compare against` : `installed (${installedLabel}) \u2014 no workspace build to compare (comparison needs a nearby checkout with a stamped build)`;
161
+ return [{ name, status: "pass", detail }];
162
+ }
163
+ if (result.unorderable) {
164
+ return [
165
+ {
166
+ name,
167
+ status: "pass",
168
+ detail: `installed (${installedLabel}) differs from the workspace build${at} (${workspaceLabel}) \u2014 build order could not be established`
169
+ }
170
+ ];
171
+ }
172
+ if (result.installedAhead) {
173
+ return [{ name, status: "pass", detail: `installed (${installedLabel}) is ahead of the workspace build${at} (${workspaceLabel})` }];
174
+ }
175
+ if (result.installedId !== result.workspaceId) {
176
+ return [{ name, status: "pass", detail: `installed (${installedLabel}) differs from the workspace build${at} (${workspaceLabel})` }];
177
+ }
178
+ return [{ name, status: "pass", detail: `matches the workspace build${at} (${installedLabel})` }];
179
+ }
180
+ function installedCliFix(workspaceDistDir, trusted) {
181
+ const command = refreshCommand(workspaceDistDir, trusted);
182
+ if (command) return `Refresh it: ${command}`;
183
+ const root = sanitizeForContext(workspaceRootOf(workspaceDistDir));
184
+ return `Workspace build found at ${root}. Rebuild it (yarn workspace @outerlayer/cli build) and copy packages/cli/dist to ~/.outerlayer/cli/dist to refresh the installed copy.`;
185
+ }
186
+ function checkStatusline(home, now, cwd) {
187
+ const name = "Status line";
188
+ let installed = false;
189
+ let wrapping;
190
+ let foreign;
191
+ for (const scope of ["user", "project"]) {
192
+ let settings;
193
+ try {
194
+ settings = readSettings(settingsPath(scope, { home, cwd }));
195
+ } catch {
196
+ continue;
197
+ }
198
+ const slot = settings?.statusLine;
199
+ if (!slot || typeof slot !== "object") continue;
200
+ if (isOurStatuslineSlot(slot)) {
201
+ installed = true;
202
+ const original = statuslineWrappedOriginal(slot);
203
+ if (original !== null) wrapping = original;
204
+ break;
205
+ }
206
+ if (typeof slot.command === "string") foreign = slot.command;
207
+ }
208
+ if (!installed) {
209
+ const manifest = readManifest(home);
210
+ const record = ["user", "project"].map((scope) => manifest?.statusline[settingsPath(scope, { home, cwd })]).find((r) => r !== void 0);
211
+ if (record) {
212
+ return [{
213
+ name,
214
+ status: "warn",
215
+ detail: `the OuterLayer segment was installed (recorded ${record.updatedAt}) but the slot no longer holds it \u2014 settings were rewritten or reset${record.original !== void 0 ? `; it wrapped: ${record.original}` : ""}`,
216
+ fix: "Run `outerlayer init` to reinstall the segment."
217
+ }];
218
+ }
219
+ return [
220
+ foreign !== void 0 ? { name, status: "warn", detail: `slot occupied by: ${foreign}`, fix: "Run `outerlayer init` to add the OuterLayer segment (wraps the existing command, never replaces it)." } : { name, status: "warn", detail: "not installed", fix: "Run `outerlayer init`." }
221
+ ];
222
+ }
223
+ const checks = [
224
+ { name, status: "pass", detail: wrapping ? `installed, wrapping: ${wrapping}` : "installed" }
225
+ ];
226
+ const stateName = "Status-line state";
227
+ const state = readStatuslineState(home);
228
+ if (!state) {
229
+ checks.push({ name: stateName, status: "warn", detail: "no state file \u2014 line runs degraded (session cost only)", fix: "Start `outerlayer daemon` (it maintains this file)." });
230
+ return checks;
231
+ }
232
+ const age = now() - Date.parse(state.generatedAt);
233
+ if (!Number.isFinite(age) || age > STATE_FRESH_MS) {
234
+ checks.push({ name: stateName, status: "warn", detail: `state ${formatAgo(age)} \u2014 line runs degraded`, fix: "Restart `outerlayer daemon`." });
235
+ return checks;
236
+ }
237
+ checks.push({ name: stateName, status: "pass", detail: `fresh (${formatAgo(age)})` });
238
+ return checks;
239
+ }
240
+ function checkClaudeDir(home) {
241
+ const dir = join(home, ".claude");
242
+ return existsSync(dir) ? { name: "Claude Code home", status: "pass", detail: dir } : { name: "Claude Code home", status: "fail", detail: `${dir} not found`, fix: "Install Claude Code, then run a session so transcripts exist." };
243
+ }
244
+ function scanTranscripts(home) {
245
+ const root = join(home, ".claude", "projects");
246
+ let count = 0;
247
+ let newest = 0;
248
+ if (!existsSync(root)) return { exists: false, count, newest };
249
+ const walk = (dir) => {
250
+ let entries;
251
+ try {
252
+ entries = readdirSync(dir, { withFileTypes: true });
253
+ } catch {
254
+ return;
255
+ }
256
+ for (const entry of entries) {
257
+ const p = join(dir, entry.name);
258
+ if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.endsWith(".jsonl")) continue;
259
+ let st;
260
+ try {
261
+ st = statSync(p);
262
+ } catch {
263
+ continue;
264
+ }
265
+ if (st.isDirectory()) walk(p);
266
+ else if (entry.name.endsWith(".jsonl") && st.size > 0) {
267
+ count += 1;
268
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
269
+ }
270
+ }
271
+ };
272
+ walk(root);
273
+ return { exists: true, count, newest };
274
+ }
275
+ function checkTranscripts(transcripts, now) {
276
+ if (!transcripts.exists) {
277
+ return { name: "Transcripts", status: "warn", detail: "no projects directory yet", fix: "Run a Claude Code session, then re-check." };
278
+ }
279
+ if (transcripts.count === 0) {
280
+ return { name: "Transcripts", status: "warn", detail: "0 transcripts found", fix: "Run a Claude Code session, then run `outerlayer doctor` again." };
281
+ }
282
+ const age = formatAgeH(now() - transcripts.newest);
283
+ return { name: "Transcripts", status: "pass", detail: `${transcripts.count} transcripts, newest ${age}` };
284
+ }
285
+ function formatAgeH(ms) {
286
+ const ageH = ms / 36e5;
287
+ return ageH < 1 ? "<1h ago" : ageH < 48 ? `${Math.round(ageH)}h ago` : `${Math.round(ageH / 24)}d ago`;
288
+ }
289
+ var HOOK_FIRING_GAP_MS = 24 * 60 * 60 * 1e3;
290
+ function newestHookFiring(home) {
291
+ let newest = null;
292
+ for (const event of REGISTERED_EVENTS) {
293
+ try {
294
+ const ms = statSync(hookFiredMarkerPath(event, home)).mtimeMs;
295
+ if (newest === null || ms > newest) newest = ms;
296
+ } catch {
297
+ }
298
+ }
299
+ return newest;
300
+ }
301
+ function checkHooks(home, cwd, transcripts, now) {
302
+ const lastFired = newestHookFiring(home);
303
+ const firingFresh = lastFired !== null && transcripts.count > 0 && transcripts.newest - lastFired <= HOOK_FIRING_GAP_MS;
304
+ const checks = [checkHooksInstalled(home, cwd, firingFresh)];
305
+ const firing = checkHooksFiring(transcripts, lastFired, now);
306
+ if (firing) checks.push(firing);
307
+ const wrapped = checkWrappedHooks(home, cwd);
308
+ if (wrapped) checks.push(wrapped);
309
+ return checks;
310
+ }
311
+ function isExecutable(path) {
312
+ if (process.platform === "win32") return true;
313
+ try {
314
+ accessSync(path, constants.X_OK);
315
+ return true;
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+ function checkHooksInstalled(home, cwd, firingFresh) {
321
+ const name = "Hooks installed";
322
+ const readablePaths = [];
323
+ for (const scope of ["user", "project"]) {
324
+ const path = settingsPath(scope, { home, cwd });
325
+ let settings;
326
+ try {
327
+ settings = readSettings(path);
328
+ } catch {
329
+ continue;
330
+ }
331
+ readablePaths.push(path);
332
+ if (!settings?.hooks) continue;
333
+ const installed = REGISTERED_EVENTS.filter(
334
+ (event) => (settings.hooks?.[event] ?? []).some((b) => (b.hooks ?? []).some(isOurHookCommand))
335
+ );
336
+ if (installed.length === REGISTERED_EVENTS.length) {
337
+ const missing = ourCommandBins(settings).filter((bin) => !cliBinResolvable(bin));
338
+ if (missing.length > 0) {
339
+ return {
340
+ name,
341
+ status: "fail",
342
+ detail: `hooks in ${path} run ${missing.join(", ")}, which no longer exists \u2014 no session is being captured`,
343
+ fix: "Run `outerlayer init` to reinstall."
344
+ };
345
+ }
346
+ const unexecutable = ourDirectScriptBins(settings).filter((bin) => !isExecutable(bin));
347
+ if (unexecutable.length > 0) {
348
+ return {
349
+ name,
350
+ status: "fail",
351
+ detail: `hooks in ${path} run ${unexecutable.join(", ")} directly, and it is not executable \u2014 every hook fails with "Permission denied" and no session is linked to its work item`,
352
+ fix: "Run `outerlayer init` to rewrite the hooks so they run through node."
353
+ };
354
+ }
355
+ return { name, status: "pass", detail: `${installed.join(", ")} (${scope})` };
356
+ }
357
+ if (installed.length > 0) {
358
+ return { name, status: "warn", detail: `only ${installed.join(", ")} installed`, fix: "Run `outerlayer init` to (re)install all lifecycle hooks." };
359
+ }
360
+ }
361
+ const manifest = readManifest(home);
362
+ const recorded = readablePaths.find((p) => manifest?.installs[p]);
363
+ if (recorded) {
364
+ const record = manifest.installs[recorded];
365
+ return {
366
+ name,
367
+ status: "fail",
368
+ detail: `hooks were installed into ${recorded} (recorded ${record.updatedAt}) but the settings file no longer contains them \u2014 it was rewritten or reset`,
369
+ fix: "Run `outerlayer init` to reinstall."
370
+ };
371
+ }
372
+ if (firingFresh) {
373
+ return { name, status: "pass", detail: "not in user/project settings, but hooks are firing \u2014 installed in another scope (managed settings?)" };
374
+ }
375
+ return { name, status: "fail", detail: "no OuterLayer hooks found", fix: "Run `outerlayer init`." };
376
+ }
377
+ function checkWrappedHooks(home, cwd) {
378
+ const manifest = readManifest(home);
379
+ if (!manifest) return null;
380
+ const missing = [];
381
+ for (const scope of ["user", "project"]) {
382
+ const path = settingsPath(scope, { home, cwd });
383
+ const records = manifest.wrapped[path];
384
+ if (!records || records.length === 0) continue;
385
+ let settings;
386
+ try {
387
+ settings = readSettings(path);
388
+ } catch {
389
+ continue;
390
+ }
391
+ const present = /* @__PURE__ */ new Set();
392
+ const hooks = settings?.hooks ?? {};
393
+ for (const event of Object.keys(hooks)) {
394
+ for (const block of hooks[event] ?? []) {
395
+ for (const h of block.hooks ?? []) {
396
+ const original = wrappedOriginalCommand(h);
397
+ if (original !== null) present.add(`${event}\0${original}`);
398
+ }
399
+ }
400
+ }
401
+ for (const r of records) {
402
+ if (!present.has(`${r.event}\0${r.original}`)) missing.push(`${r.event}: ${r.original}`);
403
+ }
404
+ }
405
+ if (missing.length === 0) return null;
406
+ return {
407
+ name: "Wrapped hooks",
408
+ status: "warn",
409
+ detail: `recorded as wrapped but the settings no longer carry the wrapped form \u2014 ${missing.join("; ")}`,
410
+ fix: "Run `outerlayer hooks wrap` to re-wrap, or `outerlayer hooks unwrap` if the wrap is no longer wanted."
411
+ };
412
+ }
413
+ function checkHooksFiring(transcripts, lastFired, now) {
414
+ const name = "Hooks firing";
415
+ if (transcripts.count === 0) return null;
416
+ if (lastFired === null) {
417
+ return {
418
+ name,
419
+ status: "warn",
420
+ detail: "no firing evidence recorded yet \u2014 every hook run writes a marker, and none exists",
421
+ fix: "Start a Claude Code session, then re-run doctor."
422
+ };
423
+ }
424
+ if (transcripts.newest - lastFired > HOOK_FIRING_GAP_MS) {
425
+ return {
426
+ name,
427
+ status: "fail",
428
+ detail: `sessions as recent as ${formatAgeH(now() - transcripts.newest)}, but no lifecycle hook has fired since ${formatAgeH(now() - lastFired)}`,
429
+ fix: "Hooks are configured but not running \u2014 run `outerlayer init` and check `claude --debug` hook output."
430
+ };
431
+ }
432
+ return { name, status: "pass", detail: `last fired ${formatAgeH(now() - lastFired)}` };
433
+ }
434
+ function checkDeadHooksState(cwd) {
435
+ const dir = cwd ?? process.cwd();
436
+ const state = deadHooksState(dir);
437
+ if (state.hooksDir === void 0) return [];
438
+ const name = "Git hooks directory";
439
+ if (state.unreadableError) {
440
+ return [
441
+ {
442
+ name,
443
+ status: "fail",
444
+ detail: `${sanitizeForContext(state.hooksDir)} could not be read (${state.unreadableError.code ?? state.unreadableError.message}) \u2014 git's hooks status is unknown, not confirmed healthy`,
445
+ fix: "Fix the directory's permissions so it can be listed, then re-run `outerlayer doctor`."
446
+ }
447
+ ];
448
+ }
449
+ if (!state.dead) {
450
+ return [{ name, status: "pass", detail: `hooks present at ${sanitizeForContext(state.hooksDir)}` }];
451
+ }
452
+ const fix = `Run \`${preparePackageManagerCommand(state.repoRoot ?? dir)}\` to (re)generate the hooks directory.`;
453
+ return [
454
+ {
455
+ name,
456
+ status: "fail",
457
+ detail: `${sanitizeForContext(state.hooksDir)} is missing or empty \u2014 core.hooksPath points at a dead directory, so git runs no hooks at all`,
458
+ fix
459
+ }
460
+ ];
461
+ }
462
+ function checkSpoolWritable(home) {
463
+ const dir = spoolDir(home);
464
+ try {
465
+ if (!existsSync(dir)) return { name: "Spool writable", status: "warn", detail: "spool not created yet", fix: "Run `outerlayer init` (creates the spool on first hook)." };
466
+ accessSync(dir, constants.W_OK);
467
+ return { name: "Spool writable", status: "pass", detail: dir };
468
+ } catch {
469
+ return { name: "Spool writable", status: "fail", detail: `${dir} not writable`, fix: "Fix permissions on ~/.outerlayer/spool." };
470
+ }
471
+ }
472
+ function checkLastSync(home, now) {
473
+ const name = "Cloud sync";
474
+ if (!existsSync(join(home, ".outerlayer", "config.json"))) {
475
+ return { name, status: "warn", detail: "not configured \u2014 sessions stay on this machine", fix: "Run `outerlayer login` to connect." };
476
+ }
477
+ const health = assessSyncHealth(readSyncStatus(home), now(), SYNC_STALE_AFTER_MS);
478
+ const detail = [health.headline, ...health.details].join("; ");
479
+ if (health.level === "error") {
480
+ return { name, status: "fail", detail, fix: "Check the destination and API key in ~/.outerlayer/config.json, then run `outerlayer sync` to see the full error." };
481
+ }
482
+ if (health.level === "warn" || health.level === "unknown") {
483
+ return { name, status: "warn", detail, fix: "Run `outerlayer sync` to confirm the connection works." };
484
+ }
485
+ return { name, status: "pass", detail };
486
+ }
487
+ function checkDaemon(home) {
488
+ const pidfile = join(home, ".outerlayer", "daemon.pid");
489
+ const heartbeat = join(home, ".outerlayer", "daemon.heartbeat");
490
+ if (!existsSync(pidfile)) {
491
+ return { name: "Daemon running", status: "warn", detail: "not running", fix: "Start it with `outerlayer daemon` (or a login agent)." };
492
+ }
493
+ try {
494
+ const beatMs = statSync(heartbeat).mtimeMs;
495
+ const staleMin = (Date.now() - beatMs) / 6e4;
496
+ if (staleMin > 30) {
497
+ return { name: "Daemon running", status: "warn", detail: `heartbeat ${Math.round(staleMin)}m stale`, fix: "Restart `outerlayer daemon`." };
498
+ }
499
+ return { name: "Daemon running", status: "pass", detail: "heartbeat fresh" };
500
+ } catch {
501
+ return { name: "Daemon running", status: "warn", detail: "pidfile present, no heartbeat", fix: "Restart `outerlayer daemon`." };
502
+ }
503
+ }
504
+ function checkCleanupPeriod(home) {
505
+ const path = join(home, ".claude", "settings.json");
506
+ let settings;
507
+ try {
508
+ settings = readSettings(path);
509
+ } catch {
510
+ return { name: "Retention (cleanupPeriodDays)", status: "warn", detail: "settings unreadable" };
511
+ }
512
+ const raw = settings?.cleanupPeriodDays;
513
+ const days = typeof raw === "number" ? raw : 30;
514
+ if (days === 0) {
515
+ return {
516
+ name: "Retention (cleanupPeriodDays)",
517
+ status: "fail",
518
+ detail: "set to 0 \u2014 this DISABLES transcript persistence (upstream anthropics/claude-code#23710 and #59248)",
519
+ fix: "Remove cleanupPeriodDays or set it \u226514; run `outerlayer daemon` to mirror before deletion."
520
+ };
521
+ }
522
+ if (days < 14) {
523
+ return { name: "Retention (cleanupPeriodDays)", status: "warn", detail: `${days}d \u2014 transcripts deleted quickly`, fix: "Run `outerlayer daemon` so raws are mirrored before cleanup." };
524
+ }
525
+ return { name: "Retention (cleanupPeriodDays)", status: "pass", detail: `${days}d` };
526
+ }
527
+ function checkDiskHeadroom(home) {
528
+ try {
529
+ accessSync(home, constants.W_OK);
530
+ return { name: "Disk headroom", status: "pass", detail: "home writable (raw mirror capped at 5 GiB, LRU)" };
531
+ } catch {
532
+ return { name: "Disk headroom", status: "fail", detail: "home not writable", fix: "Free space / fix permissions on your home directory." };
533
+ }
534
+ }
535
+ function checkClaudeVersion(env) {
536
+ let version;
537
+ try {
538
+ version = (env.claudeVersion ?? defaultClaudeVersion)();
539
+ } catch {
540
+ return { name: "Claude Code version", status: "warn", detail: "could not run `claude --version`", fix: "Ensure `claude` is on PATH." };
541
+ }
542
+ const parsed = version.match(/\d+\.\d+\.\d+/)?.[0];
543
+ if (!parsed) return { name: "Claude Code version", status: "warn", detail: `unrecognized: ${version}` };
544
+ if (compareVersions(parsed, SUPPORTED_VERSIONS.max) > 0) {
545
+ return { name: "Claude Code version", status: "warn", detail: `${parsed} is newer than validated (${SUPPORTED_VERSIONS.max}) \u2014 parser runs best-effort`, fix: "Update OuterLayer if you see parse warnings." };
546
+ }
547
+ if (compareVersions(parsed, SUPPORTED_VERSIONS.min) < 0) {
548
+ return { name: "Claude Code version", status: "warn", detail: `${parsed} is older than validated (${SUPPORTED_VERSIONS.min})` };
549
+ }
550
+ return { name: "Claude Code version", status: "pass", detail: parsed };
551
+ }
552
+ function checkSettingsValid(home, cwd) {
553
+ for (const scope of ["user", "project"]) {
554
+ const path = settingsPath(scope, { home, cwd });
555
+ if (!existsSync(path)) continue;
556
+ try {
557
+ readSettings(path);
558
+ } catch (err) {
559
+ return { name: "Settings JSON valid", status: "fail", detail: `${path} is corrupt`, fix: err instanceof Error ? err.message : "Fix the JSON syntax." };
560
+ }
561
+ }
562
+ return { name: "Settings JSON valid", status: "pass", detail: "all settings files parse" };
563
+ }
564
+ function checkInstalls(env) {
565
+ let installs;
566
+ try {
567
+ installs = (env.claudeInstalls ?? defaultClaudeInstalls)();
568
+ } catch {
569
+ return [];
570
+ }
571
+ if (installs.length > 1) {
572
+ return [{ name: "Claude Code installs", status: "warn", detail: `multiple found: ${installs.join(", ")}`, fix: "Multiple installs can diverge in version; keep one." }];
573
+ }
574
+ return [];
575
+ }
576
+ function defaultClaudeVersion() {
577
+ return execFileSync("claude", ["--version"], { encoding: "utf8", timeout: 5e3 }).trim();
578
+ }
579
+ function defaultClaudeInstalls() {
580
+ try {
581
+ const out = execFileSync(process.platform === "win32" ? "where" : "which", ["-a", "claude"], { encoding: "utf8", timeout: 5e3 });
582
+ return out.split(/\r?\n/).filter((l) => l.trim().length > 0);
583
+ } catch {
584
+ return [];
585
+ }
586
+ }
587
+ function contextDegradedFix(kind) {
588
+ switch (kind) {
589
+ case "unreachable":
590
+ return "Check network/VPN access to the control plane, then run `outerlayer context materialize`.";
591
+ case "no-access":
592
+ return "Check your git credentials (SSH key or token) and org/SSO access to the control plane, then retry.";
593
+ case "designation-unverified":
594
+ return "Check OuterLayer cloud connectivity/config (~/.outerlayer/config.json), then run `outerlayer context materialize`.";
595
+ case "not-governed":
596
+ return "Expected if the control plane's `governs` list changed intentionally; otherwise re-designate the source.";
597
+ case "adoption-conflict":
598
+ return "Remove the committed AGENTS.md/.claude/.outerlayer files in a commit, then run `outerlayer context materialize`.";
599
+ case "conflict":
600
+ return "Revoke the designation from all but one of the claiming factories, then run `outerlayer context materialize`.";
601
+ }
602
+ }
603
+ function checkContextSource(home, cwd) {
604
+ const dir = cwd ?? process.cwd();
605
+ let repoRoot;
606
+ let gitCommonDir;
607
+ try {
608
+ repoRoot = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
609
+ encoding: "utf8",
610
+ stdio: ["ignore", "pipe", "ignore"],
611
+ timeout: 2e3
612
+ }).trim();
613
+ gitCommonDir = execFileSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
614
+ encoding: "utf8",
615
+ stdio: ["ignore", "pipe", "ignore"],
616
+ timeout: 2e3
617
+ }).trim();
618
+ } catch {
619
+ return [];
620
+ }
621
+ if (!repoRoot || !gitCommonDir) return [];
622
+ const pin = readPin(repoRoot);
623
+ const manifest = readContextManifest(gitCommonDir);
624
+ const degraded = readDegraded(gitCommonDir);
625
+ const sourceCache = readSourceCache(gitCommonDir);
626
+ const looksGoverned = pin !== void 0 || sourceCache?.status === "governed";
627
+ if (!pin && !manifest && !degraded && !looksGoverned) return [];
628
+ const checks = [];
629
+ const name = "Context source";
630
+ if (pin && manifest && pin.sha !== manifest.sha) {
631
+ checks.push({
632
+ name,
633
+ status: "warn",
634
+ detail: `pin (${pin.sha.slice(0, 7)}) and manifest (${manifest.sha.slice(0, 7)}) disagree`,
635
+ fix: "Run `outerlayer context materialize` to reconcile."
636
+ });
637
+ } else if (pin ?? manifest) {
638
+ const sha = (pin ?? manifest).sha;
639
+ checks.push({ name, status: degraded ? "warn" : "pass", detail: `materialized at ${sha.slice(0, 7)}` });
640
+ }
641
+ if (degraded) {
642
+ checks.push({
643
+ name: "Context source degradation",
644
+ status: degraded.kind === "adoption-conflict" || degraded.kind === "conflict" ? "fail" : "warn",
645
+ detail: degraded.message,
646
+ fix: contextDegradedFix(degraded.kind)
647
+ });
648
+ }
649
+ if ((pin || manifest) && !contextExcludesPresent(gitCommonDir)) {
650
+ checks.push({
651
+ name: "Context exclude block",
652
+ status: "warn",
653
+ detail: "materialized paths are not excluded in .git/info/exclude",
654
+ fix: "Run `outerlayer context materialize` to repair it."
655
+ });
656
+ }
657
+ if (pin && !readCloudConfig(home).url) {
658
+ checks.push({
659
+ name,
660
+ status: "warn",
661
+ detail: "a pin exists but OuterLayer cloud sync isn't configured \u2014 the designation cannot be confirmed",
662
+ fix: "Run `outerlayer login` to connect."
663
+ });
664
+ }
665
+ if (looksGoverned) {
666
+ let tracked = "";
667
+ try {
668
+ tracked = execFileSync("git", ["-C", repoRoot, "ls-files", "--", "AGENTS.md", ".claude", ".outerlayer"], {
669
+ encoding: "utf8",
670
+ stdio: ["ignore", "pipe", "ignore"],
671
+ timeout: 2e3
672
+ }).trim();
673
+ } catch {
674
+ tracked = "";
675
+ }
676
+ if (tracked) {
677
+ const paths = tracked.split("\n");
678
+ const sample = paths.slice(0, 3).join(", ") + (paths.length > 3 ? ", \u2026" : "");
679
+ checks.push({
680
+ name,
681
+ status: "fail",
682
+ detail: `committed context files shadow the control plane: ${sample}`,
683
+ fix: "Remove the committed AGENTS.md/.claude/.outerlayer files in a commit, then run `outerlayer context materialize`."
684
+ });
685
+ }
686
+ }
687
+ return checks;
688
+ }
689
+ var PROTECT_DEFAULT_BRANCH_DOCS = "https://docs.outerlayer.ai/github-app-and-build-tokens#protect-the-default-branch";
690
+ var CONNECT_REPOSITORY_DOCS = "https://docs.outerlayer.ai/connect-repository";
691
+ var REPOSITORY_ACCESS_TIMEOUT_MS = 3e4;
692
+ async function githubAppChecks(options = {}) {
693
+ let credentials;
694
+ try {
695
+ credentials = resolveCredentials({ home: options.home, env: options.env });
696
+ } catch {
697
+ return [];
698
+ }
699
+ const fetchImpl = options.fetchImpl ?? defaultFetch;
700
+ const bounded = (input, init) => fetchImpl(input, { ...init, signal: AbortSignal.timeout(REPOSITORY_ACCESS_TIMEOUT_MS) });
701
+ let data;
702
+ try {
703
+ ({ data } = await getJson(credentials.url, "/v1/repositories/access", credentials.apiKey, credentials.appId, bounded));
704
+ } catch (err) {
705
+ return [githubAppRefusalCheck(err)];
706
+ }
707
+ if (!Array.isArray(data)) {
708
+ return [{ name: "GitHub App", status: "warn", detail: "the gateway's answer listed no repositories", fix: "Update the outerlayer CLI and the gateway to the same release." }];
709
+ }
710
+ if (data.length === 0) {
711
+ return [{ name: "GitHub App", status: "pass", detail: "the factory has no connected repositories" }];
712
+ }
713
+ return data.map(repositoryAccessCheck);
714
+ }
715
+ function githubAppRefusalCheck(err) {
716
+ const name = "GitHub App";
717
+ const code = err instanceof WorkCommandError ? err.code : void 0;
718
+ if (code === "repository_tokens_unavailable") {
719
+ return { name, status: "pass", detail: "the gateway has no GitHub App configured, so there is nothing to check" };
720
+ }
721
+ if (code === "git_connection_missing") {
722
+ return {
723
+ name,
724
+ status: "warn",
725
+ detail: "the factory has no GitHub App installation",
726
+ fix: `Connect a repository: ${CONNECT_REPOSITORY_DOCS}`
727
+ };
728
+ }
729
+ const message = sanitizeTerminal(err instanceof Error ? err.message : String(err));
730
+ if (err instanceof WorkCommandError && err.status === 403) {
731
+ return { name, status: "warn", detail: `the key cannot read the factory's repositories: ${message}`, fix: "Use a key that holds git.read." };
732
+ }
733
+ return { name, status: "warn", detail: `could not read the factory's repositories: ${message}`, fix: "Run `outerlayer doctor` again; check the gateway URL in ~/.outerlayer/config.json." };
734
+ }
735
+ function describeMissing(missing) {
736
+ const held = missing.granted === null ? "not granted" : `has ${sanitizeTerminal(missing.granted)}`;
737
+ const app = missing.requestedByApp === false ? "; the App does not request it" : "";
738
+ return `${sanitizeTerminal(missing.permission)}: ${sanitizeTerminal(missing.required)} (${held}${app})`;
739
+ }
740
+ function nameMissing(missing) {
741
+ return missing.map((m) => `${sanitizeTerminal(m.permission)}: ${sanitizeTerminal(m.required)}`).join(", ");
742
+ }
743
+ function missingPermissionFixes(missing, settingsUrl) {
744
+ const notRequested = missing.filter((m) => m.requestedByApp === false);
745
+ const requested = missing.filter((m) => m.requestedByApp === true);
746
+ const unknown = missing.filter((m) => typeof m.requestedByApp !== "boolean");
747
+ const fixes = [];
748
+ if (notRequested.length > 0) {
749
+ fixes.push(
750
+ `The GitHub App's own settings must add ${nameMissing(notRequested)}; there is nothing for an owner to accept until they do.`
751
+ );
752
+ }
753
+ if (requested.length > 0) {
754
+ fixes.push(`An owner of the installation accepts ${nameMissing(requested)} at ${settingsUrl}.`);
755
+ } else if (unknown.length === 0) {
756
+ fixes.push(`Then an owner of the installation accepts them at ${settingsUrl}.`);
757
+ }
758
+ if (unknown.length > 0) {
759
+ fixes.push(
760
+ `If the GitHub App's own settings do not request ${nameMissing(unknown)}, add them there first. Then an owner of the installation accepts them at ${settingsUrl}.`
761
+ );
762
+ }
763
+ return fixes;
764
+ }
765
+ function repositoryAccessCheck(access) {
766
+ const name = `GitHub App: ${sanitizeTerminal(access.repository)}`;
767
+ const settingsUrl = sanitizeTerminal(access.installationSettingsUrl);
768
+ if (!access.inInstallation) {
769
+ return {
770
+ name,
771
+ status: "warn",
772
+ detail: "the GitHub App installation does not include this repository",
773
+ fix: `Add the repository to the installation: ${settingsUrl}`
774
+ };
775
+ }
776
+ const branch = sanitizeTerminal(access.defaultBranch ?? "the default branch");
777
+ const problems = [];
778
+ const fixes = [];
779
+ if (access.missingPermissions.length > 0) {
780
+ problems.push(`the installation has not accepted ${access.missingPermissions.map(describeMissing).join(", ")}`);
781
+ fixes.push(...missingPermissionFixes(access.missingPermissions, settingsUrl));
782
+ }
783
+ if (access.requiresPullRequest === false) {
784
+ problems.push(`${branch} does not require a pull request, so a build's push token can write to it directly`);
785
+ fixes.push(`Require a pull request on ${branch} and keep CI secrets in protected environments: ${PROTECT_DEFAULT_BRANCH_DOCS}`);
786
+ }
787
+ if (problems.length > 0) {
788
+ return { name, status: "warn", detail: problems.join("; "), fix: fixes.join(" ") };
789
+ }
790
+ const pullRequest = access.requiresPullRequest === true ? `${branch} requires a pull request` : `GitHub would not say whether ${branch} requires a pull request`;
791
+ return { name, status: "pass", detail: `every permission builds need is accepted; ${pullRequest}` };
792
+ }
793
+ var CREDENTIAL_CHECK_TIMEOUT_MS = 15e3;
794
+ var LOGIN_FIX = "Run `outerlayer login` to sign in again.";
795
+ var CONNECT_FIX = "Run `outerlayer connect` to choose a factory for this repository.";
796
+ var SETUP_PANEL_FIX = "Link the repository on the factory's setup panel in the dashboard.";
797
+ var MINT_KEY_FIX = "Mint a key for a factory that exists: dashboard \u2192 Settings \u2192 API keys.";
798
+ async function askGateway(fetchImpl, url, path, credential, appId) {
799
+ const headers = { authorization: `Bearer ${credential}` };
800
+ if (appId !== void 0) headers["x-outerlayer-app-id"] = appId;
801
+ try {
802
+ const res = await fetchImpl(`${url.replace(/\/+$/, "")}${path}`, {
803
+ method: "GET",
804
+ headers,
805
+ signal: AbortSignal.timeout(CREDENTIAL_CHECK_TIMEOUT_MS)
806
+ });
807
+ return { status: res.status, body: await res.json().catch(() => void 0) };
808
+ } catch (err) {
809
+ return { failure: sanitizeTerminal(err instanceof Error ? err.message : String(err)) };
810
+ }
811
+ }
812
+ function unansweredDetail(reply) {
813
+ if ("failure" in reply) return `the gateway did not answer: ${reply.failure}`;
814
+ const { status, body } = reply;
815
+ if (status === 408 || status === 429 || status >= 500) return `the gateway did not answer (HTTP ${status})`;
816
+ if (status >= 200 && status < 300 && body === void 0) return `the gateway did not answer with JSON (HTTP ${status})`;
817
+ return void 0;
818
+ }
819
+ function refusalMessage(body) {
820
+ const message = body?.error?.message;
821
+ return typeof message === "string" ? sanitizeTerminal(message) : "";
822
+ }
823
+ var NETWORK_FIX = "Check the network and the gateway address, then run `outerlayer doctor` again.";
824
+ var UPDATE_FIX = "Update the outerlayer CLI and the gateway to the same release.";
825
+ var skippedCheck = (name, detail, fix) => ({ name, status: "skipped", detail, ...fix ? { fix } : {} });
826
+ function withSkippedRest(first, reason) {
827
+ return [first, skippedCheck("Factory", reason), skippedCheck("Repository", reason)];
828
+ }
829
+ function parseListedFactories(body) {
830
+ const rows = body?.data;
831
+ if (!Array.isArray(rows)) return void 0;
832
+ const out = [];
833
+ for (const row of rows) {
834
+ const org = row?.organization;
835
+ if (typeof row?.id !== "string" || typeof row.name !== "string" || typeof org?.name !== "string") return void 0;
836
+ out.push({
837
+ id: row.id,
838
+ name: sanitizeTerminal(row.name),
839
+ organization: sanitizeTerminal(org.name),
840
+ linked: typeof row.linked === "boolean" ? row.linked : null
841
+ });
842
+ }
843
+ return out;
844
+ }
845
+ var factorySlug = (f) => `${f.organization}/${f.name}`;
846
+ async function loginChecks(fetchImpl, url, token, appId, local, inRepository) {
847
+ const query = local === void 0 ? "" : `?${new URLSearchParams({ repository: local }).toString()}`;
848
+ const reply = await askGateway(fetchImpl, url, `/v1/me/factories${query}`, token);
849
+ const name = "Login";
850
+ const unanswered = unansweredDetail(reply);
851
+ if (unanswered !== void 0) {
852
+ return withSkippedRest({ name, status: "warn", detail: unanswered, fix: NETWORK_FIX }, "the login check did not finish");
853
+ }
854
+ const { status, body } = reply;
855
+ if (status === 401) {
856
+ return withSkippedRest({ name, status: "fail", detail: "the gateway refused the saved login", fix: LOGIN_FIX }, "the login check failed");
857
+ }
858
+ const factories = status >= 200 && status < 300 ? parseListedFactories(body) : void 0;
859
+ if (factories === void 0) {
860
+ const detail = `the gateway did not answer the factory list (HTTP ${status})`;
861
+ return withSkippedRest({ name, status: "warn", detail, fix: UPDATE_FIX }, "the login check did not finish");
862
+ }
863
+ const login = { name, status: "pass", detail: "the gateway accepts the saved login" };
864
+ if (appId === void 0) {
865
+ if (!inRepository) {
866
+ const reason = "this directory is not in a git repository, so no factory is chosen";
867
+ return [login, skippedCheck("Factory", reason, "Run this inside a connected repository, or set OUTERLAYER_APP_ID."), skippedCheck("Repository", reason)];
868
+ }
869
+ return [
870
+ login,
871
+ { name: "Factory", status: "fail", detail: "no factory is chosen for this repository", fix: CONNECT_FIX },
872
+ skippedCheck("Repository", "the factory check failed")
873
+ ];
874
+ }
875
+ const factory = factories.find((f) => f.id === appId);
876
+ if (factory === void 0) {
877
+ return [
878
+ login,
879
+ { name: "Factory", status: "fail", detail: `the factory ${sanitizeTerminal(appId)} is not one this login can use`, fix: CONNECT_FIX },
880
+ skippedCheck("Repository", "the factory check failed")
881
+ ];
882
+ }
883
+ const factoryCheck = { name: "Factory", status: "pass", detail: `${factorySlug(factory)} is a factory this login can use` };
884
+ if (local === void 0) return [login, factoryCheck, skippedCheck("Repository", "not inside a git repository with a remote")];
885
+ const shown = sanitizeTerminal(local);
886
+ if (factory.linked === null) {
887
+ return [login, factoryCheck, skippedCheck("Repository", `the link to ${shown} could not be read: this login lacks git.read on ${factorySlug(factory)}`)];
888
+ }
889
+ if (factory.linked) {
890
+ return [login, factoryCheck, { name: "Repository", status: "pass", detail: `${shown} is linked to ${factorySlug(factory)}` }];
891
+ }
892
+ const elsewhere = factories.find((f) => f.id !== factory.id && f.linked === true);
893
+ const fix = elsewhere === void 0 ? SETUP_PANEL_FIX : `Run \`outerlayer connect --factory ${factorySlug(elsewhere)}\` to use the factory this repository is linked to.`;
894
+ return [login, factoryCheck, { name: "Repository", status: "warn", detail: `${shown} is not linked to ${factorySlug(factory)}`, fix }];
895
+ }
896
+ async function factoryKeyChecks(fetchImpl, url, key, appId, source, local) {
897
+ const name = "Factory key";
898
+ if (appId === void 0) {
899
+ return withSkippedRest(
900
+ { name, status: "fail", detail: "the key has no factory id", fix: "Set OUTERLAYER_APP_ID, or save the factory's id as appId in ~/.outerlayer/config.json." },
901
+ "the factory key check failed"
902
+ );
903
+ }
904
+ const factoryPath = `/v1/apps/${encodeURIComponent(appId)}`;
905
+ const reply = await askGateway(fetchImpl, url, factoryPath, key, appId);
906
+ const unanswered = unansweredDetail(reply);
907
+ if (unanswered !== void 0) {
908
+ return withSkippedRest({ name, status: "warn", detail: unanswered, fix: NETWORK_FIX }, "the factory key check did not finish");
909
+ }
910
+ const { status, body } = reply;
911
+ const message = refusalMessage(body);
912
+ const removeKey = `Mint a fresh key under Settings \u2192 API keys, or remove it so a saved login takes over.`;
913
+ if (status === 401) {
914
+ return withSkippedRest(
915
+ { name, status: "fail", detail: "the gateway refused the key", fix: `The key in ${source} is unknown or was revoked. ${removeKey}` },
916
+ "the factory key check failed"
917
+ );
918
+ }
919
+ if (status === 403 && /no longer a member/i.test(message)) {
920
+ return withSkippedRest(
921
+ { name, status: "fail", detail: message, fix: `The key in ${source} belongs to someone who left the organization. ${removeKey}` },
922
+ "the factory key check failed"
923
+ );
924
+ }
925
+ const accepted = { name, status: "pass", detail: `the gateway accepts the key from ${source}` };
926
+ const shownAppId = sanitizeTerminal(appId);
927
+ if (status === 403 && /missing required permission/i.test(message)) {
928
+ const permission = /'([^']+)'/.exec(message)?.[1] ?? "factory.read";
929
+ return [
930
+ accepted,
931
+ { name: "Factory", status: "fail", detail: `the key cannot read the factory ${shownAppId}: ${message}`, fix: `Use a key that holds ${permission}.` },
932
+ skippedCheck("Repository", "the factory check failed")
933
+ ];
934
+ }
935
+ if (status === 404) {
936
+ return [
937
+ accepted,
938
+ { name: "Factory", status: "fail", detail: `the factory ${shownAppId} does not exist`, fix: MINT_KEY_FIX },
939
+ skippedCheck("Repository", "the factory check failed")
940
+ ];
941
+ }
942
+ if (status === 403) {
943
+ return withSkippedRest(
944
+ {
945
+ name,
946
+ status: "fail",
947
+ detail: message || "the gateway refused the key (HTTP 403)",
948
+ fix: `The key in ${source} is not allowed to read this factory. ${removeKey}`
949
+ },
950
+ "the factory key check failed"
951
+ );
952
+ }
953
+ if (status < 200 || status >= 300) {
954
+ return withSkippedRest(
955
+ { name, status: "warn", detail: `the gateway did not answer the key check (HTTP ${status})`, fix: NETWORK_FIX },
956
+ "the factory key check did not finish"
957
+ );
958
+ }
959
+ const row = body?.data;
960
+ if (typeof row?.id !== "string") {
961
+ return withSkippedRest(
962
+ { name, status: "warn", detail: `the gateway did not answer the factory read with a factory (HTTP ${status})`, fix: UPDATE_FIX },
963
+ "the factory key check did not finish"
964
+ );
965
+ }
966
+ const label = [row.display_name, row.name].find((v) => typeof v === "string") ?? appId;
967
+ const factory = { name: "Factory", status: "pass", detail: `${sanitizeTerminal(label)} (${shownAppId}) is readable with this key` };
968
+ if (local === void 0) return [accepted, factory, skippedCheck("Repository", "not inside a git repository with a remote")];
969
+ const links = await askGateway(fetchImpl, url, `${factoryPath}/git/links`, key, appId);
970
+ const shown = sanitizeTerminal(local);
971
+ if ("failure" in links) {
972
+ return [accepted, factory, skippedCheck("Repository", `the link to ${shown} could not be read: the gateway did not answer`)];
973
+ }
974
+ const listed = links.status >= 200 && links.status < 300 ? links.body?.data : void 0;
975
+ if (!Array.isArray(listed)) {
976
+ const detail = links.status === 404 ? "the gateway does not list linked repositories" : links.status === 403 ? `the link to ${shown} could not be read: the key lacks git.read` : `the link to ${shown} could not be read (HTTP ${links.status})`;
977
+ return [accepted, factory, skippedCheck("Repository", detail)];
978
+ }
979
+ const linked = listed;
980
+ if (linked.some((l) => isLinkedRepository(local, l))) {
981
+ return [accepted, factory, { name: "Repository", status: "pass", detail: `${shown} is linked to the factory` }];
982
+ }
983
+ return [accepted, factory, { name: "Repository", status: "warn", detail: `${shown} is not linked to the factory`, fix: SETUP_PANEL_FIX }];
984
+ }
985
+ async function credentialChecks(options = {}) {
986
+ const env = options.env ?? process.env;
987
+ const cwd = options.cwd ?? process.cwd();
988
+ const resolved = resolveGatewayCredentials({ home: options.home, env, cwd });
989
+ if (resolved.credential === void 0 || resolved.url === void 0) {
990
+ const reason = resolved.credential === void 0 ? "no login and no factory key is saved" : "no gateway address is saved";
991
+ return withSkippedRest(skippedCheck("Credential", reason, "Run `outerlayer login` to sign in."), "there is no credential to check");
992
+ }
993
+ const fetchImpl = options.fetchImpl ?? defaultFetch;
994
+ const local = resolveRepoIdentity(cwd).gitRepo;
995
+ if (resolved.kind === "login") {
996
+ return loginChecks(fetchImpl, resolved.url, resolved.credential, resolved.appId, local, findGitDirs(cwd) !== void 0);
997
+ }
998
+ const source = env.OUTERLAYER_API_KEY ? "OUTERLAYER_API_KEY" : "~/.outerlayer/config.json";
999
+ return factoryKeyChecks(fetchImpl, resolved.url, resolved.credential, resolved.appId, source, local);
1000
+ }
1001
+ function doctorExitCode(checks) {
1002
+ return checks.some((c) => c.status === "fail") ? 1 : 0;
1003
+ }
1004
+
1005
+ // src/doctor-report.ts
1006
+ async function gatherDoctorChecks() {
1007
+ const { defaultRunnerConfigPath, hasRunnerBlock, loadRunnerConfig, runnerVmOf } = await import('./config-XVJYZ4IQ.js');
1008
+ const configPath = defaultRunnerConfigPath();
1009
+ const checks = runDoctor({ runnerHost: hasRunnerBlock(configPath) });
1010
+ const credential = await credentialChecks();
1011
+ checks.push(...credential);
1012
+ if (credential[0]?.status === "pass") checks.push(...await githubAppChecks());
1013
+ const runnerVm = runnerVmOf(configPath);
1014
+ if (runnerVm !== void 0) {
1015
+ const { realLima } = await import('./lima-XN65D7GN.js');
1016
+ const { runnerVmCheck } = await import('./doctor-53F2PYY7.js');
1017
+ checks.push(runnerVmCheck(configPath, { lima: realLima(), home: homeFromEnv(), instance: runnerVm.instance }));
1018
+ } else if (hasRunnerBlock(configPath)) {
1019
+ const { runnerHostChecks } = await import('./doctor-UFVL4PZY.js');
1020
+ const hooks = (() => {
1021
+ try {
1022
+ return loadRunnerConfig(configPath).hooks;
1023
+ } catch {
1024
+ return void 0;
1025
+ }
1026
+ })();
1027
+ const gateway = (() => {
1028
+ try {
1029
+ const { url, apiKey, appId } = loadRunnerConfig(configPath);
1030
+ return { url, apiKey, appId };
1031
+ } catch {
1032
+ return void 0;
1033
+ }
1034
+ })();
1035
+ const repos = (() => {
1036
+ try {
1037
+ return loadRunnerConfig(configPath).repos;
1038
+ } catch {
1039
+ return void 0;
1040
+ }
1041
+ })();
1042
+ const runner = (() => {
1043
+ try {
1044
+ const { concurrency, build } = loadRunnerConfig(configPath);
1045
+ return { concurrency, buildMemory: build?.memory ?? "8g" };
1046
+ } catch {
1047
+ return void 0;
1048
+ }
1049
+ })();
1050
+ checks.push(...await runnerHostChecks({ configPath, hooks, gateway, repos, runner }));
1051
+ }
1052
+ return checks;
1053
+ }
1054
+ function summarizeChecks(checks) {
1055
+ const failures = checks.filter((c) => c.status === "fail").length;
1056
+ const warnings = checks.filter((c) => c.status === "warn").length;
1057
+ const skipped = checks.filter((c) => c.status === "skipped").length;
1058
+ return { passed: checks.length - failures - warnings - skipped, warnings, failures, skipped };
1059
+ }
1060
+ function icon(status) {
1061
+ if (status === "pass") return `${C.green}\u2713${C.reset}`;
1062
+ if (status === "warn") return `${C.yellow}!${C.reset}`;
1063
+ if (status === "skipped") return `${C.dim}-${C.reset}`;
1064
+ return `${C.red}\u2717${C.reset}`;
1065
+ }
1066
+ function summaryText(summary) {
1067
+ return `${summary.passed} passed, ${summary.warnings} warnings, ${summary.failures} failures, ${summary.skipped} skipped`;
1068
+ }
1069
+ function renderDoctorText(checks) {
1070
+ const summary = summarizeChecks(checks);
1071
+ let out = "\nOuterLayer doctor\n\n";
1072
+ for (const c of checks) {
1073
+ out += ` ${icon(c.status)} ${c.name}${C.dim} \u2014 ${c.detail}${C.reset}
1074
+ `;
1075
+ if (c.fix && c.status !== "pass") out += ` ${C.dim}\u21B3 ${c.fix}${C.reset}
1076
+ `;
1077
+ }
1078
+ out += `
1079
+ ${summary.failures === 0 ? `${C.green}\u2713${C.reset}` : `${C.red}\u2717${C.reset}`} ${summaryText(summary)}
1080
+ `;
1081
+ return out;
1082
+ }
1083
+
1084
+ export { doctorExitCode, gatherDoctorChecks, readManifest, recordInstall, recordRemoval, recordStatusline, recordUnwrapped, recordWrapped, renderDoctorText, summarizeChecks, summaryText };