@event4u/agent-config 9.7.0 → 9.8.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +5 -5
  4. package/dist/agent-src/commands/commit/in-chunks.md +8 -0
  5. package/dist/agent-src/commands/commit.md +17 -0
  6. package/dist/agent-src/commands/pr/create.md +4 -0
  7. package/dist/agent-src/contexts/override-system.md +75 -5
  8. package/dist/agent-src/rules/context-hygiene.md +2 -0
  9. package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
  10. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  11. package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
  12. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -0
  14. package/dist/agent-src/rules/language-and-tone.md +2 -0
  15. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  16. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  17. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  18. package/dist/agent-src/rules/no-roadmap-references.md +3 -0
  19. package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
  20. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  21. package/dist/agent-src/rules/output-discipline.md +2 -0
  22. package/dist/agent-src/rules/persona-governance.md +2 -0
  23. package/dist/agent-src/rules/preservation-guard.md +3 -0
  24. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  25. package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
  26. package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
  27. package/dist/agent-src/rules/skill-quality.md +2 -0
  28. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  29. package/dist/agent-src/rules/source-of-truth.md +2 -0
  30. package/dist/agent-src/rules/telegraph-speak.md +2 -0
  31. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  32. package/dist/agent-src/rules/tool-safety.md +1 -0
  33. package/dist/agent-src/rules/verify-before-complete.md +2 -0
  34. package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
  35. package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
  36. package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
  37. package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
  38. package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
  39. package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
  40. package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
  41. package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
  42. package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
  43. package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
  44. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
  45. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
  46. package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
  47. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
  48. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
  49. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
  50. package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
  52. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  53. package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
  54. package/dist/cli/configRoot.js +112 -0
  55. package/dist/cli/configRoot.js.map +1 -0
  56. package/dist/cli/configRoot.test.js +97 -0
  57. package/dist/cli/configRoot.test.js.map +1 -0
  58. package/dist/cli/help.js +4 -1
  59. package/dist/cli/help.js.map +1 -1
  60. package/dist/cli/main.js +19 -2
  61. package/dist/cli/main.js.map +1 -1
  62. package/dist/cli/registry.js +1 -0
  63. package/dist/cli/registry.js.map +1 -1
  64. package/dist/discovery/deprecation-report.md +1 -1
  65. package/dist/discovery/discovery-manifest.json +161 -44
  66. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  67. package/dist/discovery/discovery-manifest.summary.md +4 -4
  68. package/dist/discovery/orphan-report.md +1 -1
  69. package/dist/discovery/packs.json +13 -8
  70. package/dist/discovery/trust-report.md +11 -11
  71. package/dist/discovery/workspaces.json +11 -6
  72. package/dist/install/install.mjs +16 -0
  73. package/dist/mcp/registry-manifest.json +3 -3
  74. package/dist/router.json +1 -1
  75. package/dist/server/app.js +10 -0
  76. package/dist/server/app.js.map +1 -1
  77. package/dist/server/routes/ping.js +18 -0
  78. package/dist/server/routes/ping.js.map +1 -1
  79. package/dist/server/schemas/settings.js +8 -0
  80. package/dist/server/schemas/settings.js.map +1 -1
  81. package/dist/server/serverInfo.js +6 -2
  82. package/dist/server/serverInfo.js.map +1 -1
  83. package/dist/server/token.js +11 -4
  84. package/dist/server/token.js.map +1 -1
  85. package/dist/server/writeRoot.js +15 -1
  86. package/dist/server/writeRoot.js.map +1 -1
  87. package/dist/shared/capabilities.js +33 -0
  88. package/dist/shared/capabilities.js.map +1 -0
  89. package/dist/shared/capabilities.test.js +39 -0
  90. package/dist/shared/capabilities.test.js.map +1 -0
  91. package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
  92. package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
  93. package/dist/ui/index.html +14 -5
  94. package/docs/CLAIMS.md +62 -37
  95. package/docs/architecture.md +2 -2
  96. package/docs/benchmark.md +137 -0
  97. package/docs/catalog.md +8 -3
  98. package/docs/comparison.yaml +44 -2
  99. package/docs/contracts/local-server-ports.md +53 -0
  100. package/docs/contracts/no-runtime-boundary.md +1 -1
  101. package/docs/contracts/subagent-boundary.md +15 -0
  102. package/docs/customization.md +14 -3
  103. package/docs/decisions/ADR-016-installer-architecture.md +2 -0
  104. package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
  105. package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
  106. package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
  107. package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
  108. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
  109. package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
  110. package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
  111. package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
  112. package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
  113. package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
  114. package/docs/decisions/INDEX.md +21 -0
  115. package/docs/decisions/engine-reclassification-2026-07.md +104 -0
  116. package/docs/distribution/awesome-list-submission.md +1 -1
  117. package/docs/distribution/registries.md +36 -27
  118. package/docs/enforcement-by-host.md +7 -0
  119. package/docs/featured-skills.md +2 -2
  120. package/docs/getting-started-by-role.md +1 -1
  121. package/docs/getting-started.md +1 -1
  122. package/docs/governance-advantage.md +1 -1
  123. package/docs/guides/gated-platform-reads.md +221 -0
  124. package/docs/positioning-evidence.md +1 -1
  125. package/docs/proof.md +92 -16
  126. package/docs/threat-model.md +1 -0
  127. package/package.json +5 -2
  128. package/src/config/agent-settings.template.yml +43 -0
  129. package/src/config/reach-channels.yml +217 -0
  130. package/src/config/reach-prescriptions-intake.yml +200 -0
  131. package/src/scripts/_dispatch.bash +15 -0
  132. package/src/scripts/_lib/exec_evidence.ts +251 -0
  133. package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
  134. package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
  135. package/src/scripts/_lib/kernel_rules.ts +46 -0
  136. package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
  137. package/src/scripts/_lib/risk_paths.ts +160 -0
  138. package/src/scripts/_lib/secret_detector.test.ts +103 -0
  139. package/src/scripts/_lib/secret_detector.ts +319 -0
  140. package/src/scripts/_lib/tool_probe.ts +359 -0
  141. package/src/scripts/adr/regenerate_index.ts +81 -6
  142. package/src/scripts/audit_adr_coverage.ts +33 -0
  143. package/src/scripts/build_proof.ts +162 -0
  144. package/src/scripts/check_adr_frontmatter.ts +195 -0
  145. package/src/scripts/check_artefact_count_messaging.ts +4 -0
  146. package/src/scripts/check_backstop_debt.ts +200 -0
  147. package/src/scripts/check_claims.ts +188 -8
  148. package/src/scripts/check_comparison.ts +12 -0
  149. package/src/scripts/check_enforcement_coverage.ts +592 -0
  150. package/src/scripts/check_kernel_rule_bundle.ts +16 -12
  151. package/src/scripts/check_reach_channels.ts +407 -0
  152. package/src/scripts/check_reach_staleness.ts +364 -0
  153. package/src/scripts/check_secret_leak.test.ts +47 -0
  154. package/src/scripts/check_secret_leak.ts +290 -0
  155. package/src/scripts/code_graph/build.ts +0 -0
  156. package/src/scripts/code_graph/cli.ts +207 -0
  157. package/src/scripts/code_graph/detect.ts +134 -0
  158. package/src/scripts/code_graph/extract.ts +386 -0
  159. package/src/scripts/code_graph/loader.ts +123 -0
  160. package/src/scripts/code_graph/query.ts +176 -0
  161. package/src/scripts/code_graph/sanitize.ts +30 -0
  162. package/src/scripts/code_graph/types.ts +83 -0
  163. package/src/scripts/code_graph/validate.ts +63 -0
  164. package/src/scripts/external_sources_denylist.json +3 -1
  165. package/src/scripts/generate_subagent_floor.ts +194 -0
  166. package/src/scripts/hook_manifest.yaml +13 -3
  167. package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
  168. package/src/scripts/iron_law_sha.ts +3 -12
  169. package/src/scripts/lint_override_kernel_guard.ts +219 -0
  170. package/src/scripts/measure_rule_budget.ts +3 -12
  171. package/src/scripts/postinstall_gui.ts +129 -0
  172. package/src/scripts/reach_doctor.ts +1651 -0
  173. package/src/scripts/release.ts +24 -3
  174. package/src/scripts/risk_escalation_shadow.ts +160 -0
  175. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  176. package/src/scripts/schemas/reach-channels.schema.json +134 -0
  177. package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
  178. package/src/scripts/schemas/rule.schema.json +10 -0
  179. package/src/scripts/skill_linter.ts +55 -0
  180. package/src/scripts/validate_reach_prescriptions.ts +751 -0
  181. package/src/server/schemas/settings.ts +16 -0
  182. package/src/shared/capabilities.test.ts +43 -0
  183. package/src/shared/capabilities.ts +75 -0
  184. package/src/templates/minimal/agents-overrides-readme.md +20 -0
  185. package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Tool probe — five-state health classification for an upstream CLI backend
3
+ * (road-to-internet-reach Phase 2, step 1).
4
+ *
5
+ * The reach layer never wraps or proxies a tool; it only answers "is this
6
+ * backend usable right now, and if not, what does the human run to fix it?".
7
+ * This module is that answer for exactly ONE backend descriptor. Iterating a
8
+ * registry, rendering a table and choosing an active backend all belong to the
9
+ * caller (`reach_doctor.ts`), which follows the `hooks_doctor.ts` payload /
10
+ * exit-code conventions.
11
+ *
12
+ * Taxonomy (the whole contract):
13
+ *
14
+ * ok — the binary resolves AND the side-effect-free probe exits 0.
15
+ * missing — the binary does not resolve on PATH at all.
16
+ * broken — the binary resolves but the probe fails in a way that means a
17
+ * damaged install: exit 126 / 127, an `EACCES` on a path that
18
+ * passed the executable check, or a resolvable shim whose
19
+ * interpreter is gone (the **stale-shim** case: resolution finds
20
+ * the file, executing it fails `ENOENT`).
21
+ * timeout — the probe exceeded its per-probe deadline.
22
+ * error — anything else, captured and attributed to this descriptor only
23
+ * (including a probe that ran and exited non-zero with a code
24
+ * other than 126 / 127 — the tool is installed but disagrees).
25
+ *
26
+ * Non-negotiables:
27
+ *
28
+ * - **Every spawn goes through `hardenedSpawnEnv()`** (ADR-123): the probe
29
+ * runs external binaries, so the loader / git-config / runtime-auto-exec
30
+ * injection vectors are scrubbed from the child env. PATH resolution walks
31
+ * the *same* hardened env, so "what we resolved" and "what we executed"
32
+ * can never diverge.
33
+ * - **Side-effect-free probes only.** The caller supplies the args
34
+ * (`--version`, `--help`, a status subcommand). This module never invents
35
+ * args and never runs an install / update / login / write command.
36
+ * - **Retry applies to `timeout` ONLY** — exactly one retry, never for
37
+ * `missing`, `broken` or `error`. A missing binary will still be missing.
38
+ * - **Never throws.** A malformed descriptor, an invalid arg, a hostile
39
+ * filesystem — all become a returned `error` result. One bad backend must
40
+ * not take down a caller iterating over many.
41
+ * - **No network, no config reading.** The descriptor is passed in; the
42
+ * `fix` prescription is echoed from it. This module invents no install
43
+ * command of its own.
44
+ *
45
+ * Child stdio is fully ignored: the verdict is the exit status, capturing
46
+ * output would risk pulling credentials / fetched content into a report, and
47
+ * an ignored stdio cannot hang on a grandchild holding the pipe open after a
48
+ * timeout kill.
49
+ */
50
+
51
+ import { spawnSync } from 'node:child_process';
52
+ import type { SpawnSyncOptions } from 'node:child_process';
53
+ import * as fs from 'node:fs';
54
+ import * as path from 'node:path';
55
+
56
+ import { hardenedSpawnEnv } from './spawn_env.js';
57
+
58
+ /** The five states. Nothing else is representable. */
59
+ export type ToolProbeStatus = 'ok' | 'missing' | 'broken' | 'timeout' | 'error';
60
+
61
+ /**
62
+ * A platform-keyed prescription, e.g.
63
+ * `{ darwin: 'brew install gh@2.62.0', default: 'see docs/…' }`.
64
+ * Resolved by `process.platform`, falling back to `default`. The strings are
65
+ * the caller's — this module only selects and echoes.
66
+ */
67
+ export type FixPrescription = string | Readonly<Record<string, string>>;
68
+
69
+ /** One backend to probe. Registry-shaped (snake_case), never guessed. */
70
+ export interface ToolProbeDescriptor {
71
+ /** Stable id echoed into the result (channel / backend name). */
72
+ name: string;
73
+ /** Binary name resolved on PATH, or an explicit absolute / relative path. */
74
+ bin: string;
75
+ /** Side-effect-free probe args. Empty means "run the bare binary". */
76
+ probe_args?: readonly string[];
77
+ /** Per-probe deadline. Defaults to {@link DEFAULT_TIMEOUT_MS}. */
78
+ timeout_ms?: number;
79
+ /** Human-run prescription echoed on `missing` / `broken`. */
80
+ fix?: FixPrescription;
81
+ /** Env overrides handed to `hardenedSpawnEnv()` (e.g. a scoped PATH). */
82
+ env?: Readonly<Record<string, string>>;
83
+ /** Working directory for the probe. Defaults to the caller's cwd. */
84
+ cwd?: string;
85
+ }
86
+
87
+ /** JSON-serialisable verdict for exactly one descriptor. */
88
+ export interface ToolProbeResult {
89
+ name: string;
90
+ bin: string;
91
+ status: ToolProbeStatus;
92
+ /** Resolved binary path, or `null` when resolution failed. */
93
+ path: string | null;
94
+ /** Child exit code, or `null` when the child never reported one. */
95
+ exit_code: number | null;
96
+ /** Terminating signal, or `null`. `SIGTERM` accompanies a timeout kill. */
97
+ signal: string | null;
98
+ /** Probe invocations actually performed (2 only on the timeout retry). */
99
+ attempts: number;
100
+ /** Deadline applied per attempt. */
101
+ timeout_ms: number;
102
+ /** Short, output-free explanation of the verdict. */
103
+ diagnostic: string;
104
+ /** Echoed prescription — populated for `missing` / `broken` only. */
105
+ fix: string | null;
106
+ }
107
+
108
+ /** Default per-probe deadline: generous for a cold `--version`, still bounded. */
109
+ export const DEFAULT_TIMEOUT_MS = 5_000;
110
+
111
+ /** Exit codes that mean "resolvable but damaged" rather than "disagrees". */
112
+ const BROKEN_EXIT_CODES: ReadonlySet<number> = new Set([126, 127]);
113
+
114
+ /** Resolve the platform-appropriate prescription; `null` when none was given. */
115
+ function resolveFix(fix: FixPrescription | undefined): string | null {
116
+ if (fix === undefined) return null;
117
+ if (typeof fix === 'string') return fix.trim() === '' ? null : fix;
118
+ const exact = fix[process.platform];
119
+ if (typeof exact === 'string' && exact.trim() !== '') return exact;
120
+ const fallback = fix['default'];
121
+ if (typeof fallback === 'string' && fallback.trim() !== '') return fallback;
122
+ return null;
123
+ }
124
+
125
+ function isExecutableFile(candidate: string): boolean {
126
+ try {
127
+ if (!fs.statSync(candidate).isFile()) return false;
128
+ } catch {
129
+ return false;
130
+ }
131
+ if (process.platform === 'win32') {
132
+ // X_OK is not meaningful on Windows; extension carries executability.
133
+ return true;
134
+ }
135
+ try {
136
+ fs.accessSync(candidate, fs.constants.X_OK);
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function looksLikePath(bin: string): boolean {
144
+ return bin.includes('/') || (process.platform === 'win32' && bin.includes('\\'));
145
+ }
146
+
147
+ /**
148
+ * PATH resolution over the **hardened** env — the same env the probe will run
149
+ * under. Returns the resolved path, or `null` for `missing`.
150
+ */
151
+ function resolveBinary(bin: string, env: NodeJS.ProcessEnv): string | null {
152
+ if (bin.trim() === '') return null;
153
+ if (looksLikePath(bin)) {
154
+ const direct = path.resolve(bin);
155
+ return isExecutableFile(direct) ? direct : null;
156
+ }
157
+ const pathValue = env['PATH'] ?? env['Path'] ?? '';
158
+ const extensions =
159
+ process.platform === 'win32'
160
+ ? (env['PATHEXT'] ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((e) => e !== '')
161
+ : [''];
162
+ for (const dir of pathValue.split(path.delimiter)) {
163
+ if (dir === '') continue;
164
+ for (const ext of extensions) {
165
+ const candidate = path.join(dir, `${bin}${ext}`);
166
+ if (isExecutableFile(candidate)) return candidate;
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ interface Attempt {
173
+ status: number | null;
174
+ signal: string | null;
175
+ errorCode: string | null;
176
+ timedOut: boolean;
177
+ }
178
+
179
+ function runOnce(
180
+ resolved: string,
181
+ args: readonly string[],
182
+ timeoutMs: number,
183
+ env: NodeJS.ProcessEnv,
184
+ cwd: string | undefined,
185
+ ): Attempt {
186
+ const options: SpawnSyncOptions = {
187
+ env,
188
+ timeout: timeoutMs,
189
+ // See the module header: verdict is the exit status, never the output.
190
+ stdio: ['ignore', 'ignore', 'ignore'],
191
+ windowsHide: true,
192
+ };
193
+ if (cwd !== undefined) options.cwd = cwd;
194
+ const result = spawnSync(resolved, [...args], options);
195
+ // Node attaches the libuv code (`ENOENT` / `EACCES` / `ETIMEDOUT`) to the
196
+ // spawn error; it is not part of the declared `Error` shape.
197
+ const raw = result.error as (Error & { code?: unknown }) | undefined;
198
+ const errorCode = typeof raw?.code === 'string' ? raw.code : null;
199
+ return {
200
+ status: result.status ?? null,
201
+ signal: result.signal ?? null,
202
+ errorCode,
203
+ // Node reports a deadline kill as ETIMEDOUT; the SIGTERM+no-status
204
+ // shape is the belt-and-braces reading for hosts that omit the code.
205
+ timedOut:
206
+ errorCode === 'ETIMEDOUT' ||
207
+ (result.status === null && result.signal === 'SIGTERM' && errorCode === null),
208
+ };
209
+ }
210
+
211
+ function classify(resolved: string, attempt: Attempt, timeoutMs: number, attempts: number): {
212
+ status: ToolProbeStatus;
213
+ diagnostic: string;
214
+ } {
215
+ if (attempt.timedOut) {
216
+ return {
217
+ status: 'timeout',
218
+ diagnostic: `probe exceeded the ${timeoutMs}ms deadline after ${attempts} attempt(s)`,
219
+ };
220
+ }
221
+ if (attempt.errorCode === 'ENOENT') {
222
+ // Resolution found the file, exec said it is not there → the shebang
223
+ // interpreter is gone. This is the stale-shim case.
224
+ return {
225
+ status: 'broken',
226
+ diagnostic: `resolved at ${resolved} but exec failed ENOENT — stale shim (interpreter missing)`,
227
+ };
228
+ }
229
+ if (attempt.errorCode === 'EACCES') {
230
+ return {
231
+ status: 'broken',
232
+ diagnostic: `resolved at ${resolved} but exec failed EACCES — not executable as installed`,
233
+ };
234
+ }
235
+ if (attempt.errorCode !== null) {
236
+ return {
237
+ status: 'error',
238
+ diagnostic: `probe spawn failed ${attempt.errorCode}`,
239
+ };
240
+ }
241
+ if (attempt.status === 0) {
242
+ return { status: 'ok', diagnostic: `probe exited 0 (${resolved})` };
243
+ }
244
+ if (attempt.status !== null && BROKEN_EXIT_CODES.has(attempt.status)) {
245
+ return {
246
+ status: 'broken',
247
+ diagnostic: `probe exited ${attempt.status} — damaged install (command not found / not executable inside the wrapper)`,
248
+ };
249
+ }
250
+ if (attempt.status !== null) {
251
+ return { status: 'error', diagnostic: `probe exited ${attempt.status}` };
252
+ }
253
+ return {
254
+ status: 'error',
255
+ diagnostic: `probe reported no exit status${attempt.signal !== null ? ` (signal ${attempt.signal})` : ''}`,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Probe one backend. Returns a verdict; **never throws**.
261
+ *
262
+ * @param descriptor the backend to probe. Everything the probe needs —
263
+ * binary, side-effect-free args, deadline, and the `fix` prescription echoed
264
+ * on `missing` / `broken` — comes from here. No config or network read.
265
+ */
266
+ export function probeTool(descriptor: ToolProbeDescriptor): ToolProbeResult {
267
+ // Types are a compile-time promise, not a runtime one: a descriptor built
268
+ // from a YAML registry can carry anything (or be nullish), so every read
269
+ // below is defensive and the whole body is inside the never-throw guard.
270
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
271
+ const base: ToolProbeResult = {
272
+ name: '(unnamed)',
273
+ bin: '',
274
+ status: 'error',
275
+ path: null,
276
+ exit_code: null,
277
+ signal: null,
278
+ attempts: 0,
279
+ timeout_ms: timeoutMs,
280
+ diagnostic: 'probe did not run',
281
+ fix: null,
282
+ };
283
+
284
+ try {
285
+ const name = typeof descriptor?.name === 'string' ? descriptor.name : '(unnamed)';
286
+ const bin = typeof descriptor?.bin === 'string' ? descriptor.bin : '';
287
+ base.name = name;
288
+ base.bin = bin;
289
+
290
+ const requested = descriptor.timeout_ms;
291
+ if (typeof requested === 'number' && Number.isFinite(requested) && requested > 0) {
292
+ timeoutMs = Math.floor(requested);
293
+ }
294
+ base.timeout_ms = timeoutMs;
295
+ const fix = resolveFix(descriptor.fix);
296
+
297
+ if (bin.trim() === '') {
298
+ return {
299
+ ...base,
300
+ status: 'error',
301
+ diagnostic: 'descriptor carries no binary to probe',
302
+ };
303
+ }
304
+
305
+ const env = hardenedSpawnEnv({ ...(descriptor.env ?? {}) });
306
+ const resolved = resolveBinary(bin, env);
307
+ if (resolved === null) {
308
+ return {
309
+ ...base,
310
+ status: 'missing',
311
+ diagnostic: `'${bin}' does not resolve on PATH`,
312
+ fix,
313
+ };
314
+ }
315
+
316
+ const args = descriptor.probe_args ?? [];
317
+ const cwd = descriptor.cwd;
318
+
319
+ let attempts = 0;
320
+ let attempt = runOnce(resolved, args, timeoutMs, env, cwd);
321
+ attempts += 1;
322
+ // Retry ONLY a timeout, and only once. `missing` / `broken` / `error`
323
+ // are stable verdicts — retrying them just burns the deadline twice.
324
+ if (attempt.timedOut) {
325
+ attempt = runOnce(resolved, args, timeoutMs, env, cwd);
326
+ attempts += 1;
327
+ }
328
+
329
+ const verdict = classify(resolved, attempt, timeoutMs, attempts);
330
+ return {
331
+ ...base,
332
+ status: verdict.status,
333
+ path: resolved,
334
+ exit_code: attempt.status,
335
+ signal: attempt.signal,
336
+ attempts,
337
+ diagnostic: verdict.diagnostic,
338
+ fix: verdict.status === 'missing' || verdict.status === 'broken' ? fix : null,
339
+ };
340
+ } catch (err) {
341
+ // The never-throw guarantee. An invalid arg, a RangeError deadline, a
342
+ // filesystem that lies — all land here as this descriptor's `error`.
343
+ const message = err instanceof Error ? err.message : String(err);
344
+ return {
345
+ ...base,
346
+ status: 'error',
347
+ timeout_ms: timeoutMs,
348
+ diagnostic: `probe raised unexpectedly: ${message}`,
349
+ };
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Probe many descriptors, isolating failures: one hostile descriptor becomes
355
+ * its own `error` row and every other backend is still reported.
356
+ */
357
+ export function probeTools(descriptors: readonly ToolProbeDescriptor[]): ToolProbeResult[] {
358
+ return descriptors.map((descriptor) => probeTool(descriptor));
359
+ }
@@ -159,9 +159,15 @@ export function scan(d: string): [Row[], Row[], string[]] {
159
159
  for (const r of num) {
160
160
  const s = r.supersedes ?? '—';
161
161
  if (s && s !== '—') {
162
- const t = _zfill3(_lstripZeros(s.replaceAll('ADR-', '')));
163
- if (!nums.has(t)) {
164
- errs.push(`${r.path}: supersedes ADR-${t} not found`);
162
+ // Comma-separated refs; a parenthesized annotation per ref marks
163
+ // partial supersession (house precedent: ADR-098's superseded_by).
164
+ for (const ref of s.split(',')) {
165
+ const bare = ref.replace(/\(.*?\)/g, '').trim();
166
+ if (!bare) continue;
167
+ const t = _zfill3(_lstripZeros(bare.replaceAll('ADR-', '')));
168
+ if (!nums.has(t)) {
169
+ errs.push(`${r.path}: supersedes ADR-${t} not found`);
170
+ }
165
171
  }
166
172
  }
167
173
  }
@@ -179,21 +185,90 @@ export function row(r: Row): string {
179
185
  );
180
186
  }
181
187
 
188
+ /**
189
+ * Decision records that live outside the ADR directory.
190
+ *
191
+ * Six files under `agents/settings/contexts/` are titled `# ADR — …`, carry a
192
+ * decision status, and had **zero** presence in this index — ADRs by every
193
+ * property except location. A decision nobody can find is a trap: later work
194
+ * contradicts it without knowing it existed, which is the failure ADR-124 paid
195
+ * for with a hand-built 44-entry archaeology sweep.
196
+ *
197
+ * Listing beats relocating, deliberately. Moving them would mean renumbering six
198
+ * records and rewriting ~15 inbound references, ten of them inside frozen
199
+ * archived roadmaps whose paths were correct when written — rewriting history to
200
+ * fix an index. The harm is findability, not location, so the index reaches out
201
+ * instead. A scan also keeps working for the next file filed in the wrong place,
202
+ * which a one-time move would not.
203
+ */
204
+ const OUTSIDE_ROOTS = ['agents/settings/contexts'];
205
+ const DECISION_TITLE = /^#\s*ADR\s*[—–-]\s*(.+)$/m;
206
+
207
+ export interface OutsideRow {
208
+ path: string;
209
+ title: string;
210
+ status: string;
211
+ }
212
+
213
+ export function scanOutside(repoRoot: string, roots: readonly string[] = OUTSIDE_ROOTS): OutsideRow[] {
214
+ const out: OutsideRow[] = [];
215
+ for (const rel of roots) {
216
+ const dir = _pyPathJoin(repoRoot, rel);
217
+ let names: string[];
218
+ try {
219
+ names = fs.readdirSync(dir);
220
+ } catch {
221
+ continue;
222
+ }
223
+ for (const name of names.slice().sort()) {
224
+ if (!name.endsWith('.md')) continue;
225
+ const text = fs.readFileSync(_pyPathJoin(dir, name), 'utf-8');
226
+ const m = DECISION_TITLE.exec(text);
227
+ if (!m) continue;
228
+ const status =
229
+ /^\s*>?\s*\*\*Status:\*\*\s*(.+)$/m.exec(text)?.[1] ??
230
+ /^status:\s*(.+)$/m.exec(text)?.[1] ??
231
+ '—';
232
+ out.push({
233
+ path: `${rel}/${name}`,
234
+ title: (m[1] ?? name).trim(),
235
+ status: status.trim().replace(/\s*·.*$/, ''),
236
+ });
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
182
242
  /** Mirror `render(num, leg)`. */
183
- export function render(num: Row[], leg: Row[]): string {
243
+ export function render(num: Row[], leg: Row[], outside: OutsideRow[] = []): string {
184
244
  const out = [
185
245
  '# ADR Index',
186
246
  '',
187
247
  '_Auto-generated by `scripts/adr/regenerate_index.py`. Do not edit._',
188
248
  '',
189
249
  ];
190
- if (num.length === 0 && leg.length === 0) {
250
+ if (num.length === 0 && leg.length === 0 && outside.length === 0) {
191
251
  return [...out, 'No ADRs yet.', ''].join('\n');
192
252
  }
193
253
  out.push(HEAD, ...num.map(row));
194
254
  if (leg.length > 0) {
195
255
  out.push('', '## Unnumbered (legacy)', '', HEAD, ...leg.map(row));
196
256
  }
257
+ if (outside.length > 0) {
258
+ out.push(
259
+ '',
260
+ '## Decision records outside this directory',
261
+ '',
262
+ 'Scanned, not hand-listed. These files are titled `# ADR — …` and record a',
263
+ 'decision, but live elsewhere in the tree. They are indexed here so a decision',
264
+ 'cannot be unfindable merely because of where it was filed; their paths are',
265
+ 'unchanged and their inbound references still resolve.',
266
+ '',
267
+ '| File | Title | Status |',
268
+ '|---|---|---|',
269
+ ...outside.map((o) => `| \`${o.path}\` | ${o.title} | ${o.status} |`),
270
+ );
271
+ }
197
272
  return [...out, ''].join('\n');
198
273
  }
199
274
 
@@ -229,7 +304,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
229
304
  if (errs.length > 0) {
230
305
  return 2;
231
306
  }
232
- const rendered = render(num, leg);
307
+ const rendered = render(num, leg, scanOutside(process.cwd()));
233
308
  const idx = _pyPathJoin(d, 'INDEX.md');
234
309
  if (check) {
235
310
  let cur = '';
@@ -57,8 +57,34 @@ export const AREAS: Record<string, AreaMeta> = {
57
57
  contract: 'smoke-contracts.md',
58
58
  scope: 'Per-tier smoke contracts, baseline locks, regression gates.',
59
59
  },
60
+ memory: {
61
+ contract: 'memory-visibility-v1.md',
62
+ scope: 'Memory / knowledge surfaces. RETIRED area — its single ADR is superseded by the agent-memory-layer removal; listed so the directory is audited rather than invisible.',
63
+ },
60
64
  };
61
65
 
66
+ /**
67
+ * Area directories present on disk but absent from `AREAS`.
68
+ *
69
+ * `docs/adrs/memory/` sat on disk unlisted, and therefore unaudited, for an
70
+ * unknown period: the audit iterates a hardcoded inventory and had no way to
71
+ * notice a directory nobody told it about. Adding `memory` fixes the instance —
72
+ * this fixes the class. A coverage audit blind to a whole area is the same defect
73
+ * shape as a gate watching a path that no longer exists.
74
+ */
75
+ export function unlistedAreaDirs(adrsRoot: string): string[] {
76
+ try {
77
+ return fs
78
+ .readdirSync(adrsRoot, { withFileTypes: true })
79
+ .filter((e) => e.isDirectory())
80
+ .map((e) => e.name)
81
+ .filter((n) => !(n in AREAS))
82
+ .sort();
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
62
88
  // ^(\d{4})-([a-z0-9-]+)\.md$
63
89
  const NAMED = /^(\d{4})-([a-z0-9-]+)\.md$/;
64
90
  // ^---\n(.*?)\n--- (DOTALL)
@@ -252,6 +278,13 @@ export function cmd_report(): number {
252
278
  export function cmd_check(): number {
253
279
  let hard = 0;
254
280
  let warn = 0;
281
+ for (const dir of unlistedAreaDirs(ADR_ROOT)) {
282
+ process.stderr.write(
283
+ `❌ ${dir}/: area directory exists on disk but is absent from AREAS — it is not audited at all. ` +
284
+ `Add it to the inventory (or delete the directory).\n`,
285
+ );
286
+ hard += 1;
287
+ }
255
288
  for (const [area, meta] of Object.entries(AREAS)) {
256
289
  const [adrs, errs] = scan_area(area);
257
290
  for (const e of errs) {