@holmes-lab/holmes-kit 0.25.0 → 0.26.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 (33) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +8 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.js +7 -26
  5. package/dist/holmes/cli/index.js +8 -1
  6. package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
  7. package/dist/holmes/cpg/cycle-observation.js +25 -2
  8. package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
  9. package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
  10. package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
  11. package/dist/holmes/cpg/forbidden-edges.js +135 -2
  12. package/dist/holmes/cpg/import-resolver.d.ts +12 -0
  13. package/dist/holmes/cpg/import-resolver.js +215 -0
  14. package/dist/holmes/cpg/language-capability.d.ts +14 -0
  15. package/dist/holmes/cpg/language-capability.js +37 -13
  16. package/dist/holmes/cpg/proposed-content.d.ts +7 -1
  17. package/dist/holmes/cpg/proposed-content.js +7 -0
  18. package/dist/holmes/governance/approval-queue.d.ts +33 -0
  19. package/dist/holmes/governance/approval-queue.js +85 -0
  20. package/dist/holmes/hooks/pre-tool-use.js +8 -1
  21. package/dist/holmes/hooks/session-start.js +39 -0
  22. package/dist/holmes/hooks/stop.d.ts +38 -2
  23. package/dist/holmes/hooks/stop.js +153 -49
  24. package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
  25. package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
  26. package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
  27. package/dist/holmes/project/report-briefing.d.ts +48 -0
  28. package/dist/holmes/project/report-briefing.js +70 -0
  29. package/dist/holmes/project/resolved-reports.d.ts +20 -0
  30. package/dist/holmes/project/resolved-reports.js +7 -0
  31. package/dist/holmes/rtm/rtm-builder.js +6 -141
  32. package/package.json +1 -1
  33. package/playbooks/publish/PLAYBOOK.md +18 -0
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createImportResolver = createImportResolver;
37
+ // @implements A-SPEC-688
38
+ /**
39
+ * The ONE place an import specifier becomes a scanned file path.
40
+ *
41
+ * It used to be two. `rtm/addImportEdges` grew the per-language rules (see A-SPEC-289, A-SPEC-406,
42
+ * A-SPEC-525.1, A-SPEC-527.1) while the Stop hook's cycle ratchet re-implemented a crippled
43
+ * version beside it — relative specifiers only, four extensions. Measured 2026-09-18 on fixtures
44
+ * shaped the way each language's rules require: this resolver reached 18 of 20 specifiers and
45
+ * found all 9 planted cycles; the ratchet's copy reached 2 and found 1. The defect cost nothing in
46
+ * this repository (all TypeScript, where the two agree exactly: 919 edges each), which is why it
47
+ * survived to v0.23.2 — and everything in a consumer written in Java, Go, Python or Rust.
48
+ *
49
+ * Extracted here rather than left in `rtm/` because resolution is a fact about CODE, not about the
50
+ * requirement graph, and because `rtm/` and `hooks/` both already import `cpg/` — so this placement
51
+ * adds no layer edge. Choosing the direction at design time is the cheap moment; that is the whole
52
+ * subject of the slice this module belongs to.
53
+ *
54
+ * The founding rule is unchanged and is what makes every branch safe: resolve ONLY to a file the
55
+ * scan actually contains. An unresolvable specifier emits nothing rather than a guess.
56
+ *
57
+ * The rules below are MOVED, not rewritten. Their originating specs are cited by number rather
58
+ * than anchored, because the authority for this file is A-SPEC-688 alone.
59
+ */
60
+ const fs = __importStar(require("node:fs"));
61
+ const path = __importStar(require("node:path"));
62
+ function fsReadGoMod(p) {
63
+ try {
64
+ return fs.readFileSync(p, 'utf8').match(/^module\s+(\S+)/m)?.[1] ?? null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function createImportResolver(scanned, opts) {
71
+ const known = new Set(scanned.map((f) => f.sourcePath));
72
+ const EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx', '.py',
73
+ '/index.ts', '/index.js', '/__init__.py'];
74
+ const firstKnown = (base) => EXTENSIONS.map((ext) => `${base}${ext}`).find((candidate) => known.has(candidate)) ?? null;
75
+ // A-SPEC-525.1 — the five languages' resolution rules, selected by the IMPORTING file's
76
+ // extension because resolution IS per-language semantics. The founding rule is unchanged
77
+ // everywhere: resolve only to files the scan actually contains; the ambiguous resolve to none.
78
+ const knownList = [...known];
79
+ const uniqueSuffix = (suffix) => {
80
+ const hits = knownList.filter((k) => k === suffix || k.endsWith(`/${suffix}`));
81
+ return hits.length === 1 ? hits[0] : null; // two files may not become a guess
82
+ };
83
+ // go.mod discovery: injected via opts when the caller knows it; otherwise walked up from the
84
+ // first Go file's ABSOLUTE path (bounded), so production builds resolve without extra wiring.
85
+ let goModCache;
86
+ const goModuleOf = () => {
87
+ if (opts?.goModule)
88
+ return opts.goModule;
89
+ if (goModCache !== undefined)
90
+ return goModCache ?? undefined;
91
+ goModCache = null;
92
+ const anyGo = scanned.find((f) => /\.go$/.test(f.sourcePath) && f.path);
93
+ if (anyGo) {
94
+ let dir = path.dirname(anyGo.path);
95
+ for (let hops = 0; hops < 12; hops++) {
96
+ try {
97
+ const txt = fsReadGoMod(path.join(dir, 'go.mod'));
98
+ if (txt !== null) {
99
+ goModCache = txt;
100
+ break;
101
+ }
102
+ }
103
+ catch { /* keep walking */ }
104
+ const up = path.dirname(dir);
105
+ if (up === dir)
106
+ break;
107
+ dir = up;
108
+ }
109
+ }
110
+ return goModCache ?? undefined;
111
+ };
112
+ const goPackage = (spec) => {
113
+ const mod = goModuleOf();
114
+ if (!mod || !(spec === mod || spec.startsWith(`${mod}/`)))
115
+ return []; // external, honestly
116
+ const dir = spec === mod ? '' : spec.slice(mod.length + 1);
117
+ const prefix = dir === '' ? '' : `${dir}/`;
118
+ return knownList.filter((k) => k.startsWith(prefix) && k.endsWith('.go')
119
+ && !k.endsWith('_test.go') && !k.slice(prefix.length).includes('/'));
120
+ };
121
+ const rustResolve = (fromFile, spec) => {
122
+ const segs = spec.split('::');
123
+ const head = segs.shift();
124
+ let baseDir;
125
+ if (head === 'crate') {
126
+ // the crate root is the src/ directory nearest above the importing file
127
+ const m = fromFile.match(/^(.*?src)\//);
128
+ baseDir = m ? m[1] : 'src';
129
+ }
130
+ else if (head === 'super') {
131
+ baseDir = path.posix.dirname(path.posix.dirname(fromFile));
132
+ while (segs[0] === 'super') {
133
+ // A-SPEC-527.1 — super:: above the crate root is an rustc error; resolving it pinned '.'
134
+ // and produced edges to repo-root files (adversarial sweep). Refuse.
135
+ if (baseDir === '.' || baseDir === '/')
136
+ return null;
137
+ segs.shift();
138
+ baseDir = path.posix.dirname(baseDir);
139
+ }
140
+ }
141
+ else if (head === 'self') {
142
+ baseDir = path.posix.dirname(fromFile);
143
+ }
144
+ else {
145
+ return null; // external crate
146
+ }
147
+ const tryPath = (parts) => {
148
+ if (parts.length === 0)
149
+ return null;
150
+ const base = path.posix.normalize(path.posix.join(baseDir, ...parts));
151
+ if (known.has(`${base}.rs`))
152
+ return `${base}.rs`;
153
+ if (known.has(`${base}/mod.rs`))
154
+ return `${base}/mod.rs`;
155
+ return null;
156
+ };
157
+ // the last segment may be an ITEM, not a module — drop it once and retry
158
+ return tryPath(segs) ?? tryPath(segs.slice(0, -1));
159
+ };
160
+ const resolve = (fromFile, spec) => {
161
+ // A-SPEC-525.1 — language branches BEFORE the JS/Python-shaped fallthrough.
162
+ if (/\.go$/.test(fromFile))
163
+ return null; // Go fans out separately (a package is its files)
164
+ if (/\.rs$/.test(fromFile))
165
+ return rustResolve(fromFile, spec);
166
+ if (/\.java$/.test(fromFile))
167
+ return uniqueSuffix(`${spec.split('.').join('/')}.java`);
168
+ if (/\.cs$/.test(fromFile))
169
+ return uniqueSuffix(`${spec.split('.').join('/')}.cs`);
170
+ if (/\.(cpp|cc|cxx|hpp|h)$/.test(fromFile)) {
171
+ // A-SPEC-527.1 — an ABSOLUTE include is not a repository coordinate: joining it into the
172
+ // repo frame let `#include "/etc/passwd"` match a coincidentally-shaped scanned file
173
+ // (adversarial sweep). Absolute means absolute; it resolves to nothing here.
174
+ if (spec.startsWith('/'))
175
+ return null;
176
+ // extension preserved: the dotted split below would butcher `util/env.h`
177
+ const relative = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
178
+ if (known.has(relative))
179
+ return relative;
180
+ return known.has(spec) ? spec : null; // repo-root-relative include
181
+ }
182
+ // A-SPEC-406
183
+ // A specifier without a leading dot used to be refused outright, on the ground that it names an
184
+ // external package. That holds for `node:fs` and `js-yaml`; it does NOT hold for Python, which
185
+ // writes intra-repository modules absolutely — `from src.core.memory_audit import X`. Measured
186
+ // on the jarvis corpus: 1060 specifiers, 0 relative, 542 naming a file the scan already had, and
187
+ // 0 import edges in the graph. The whole import layer was missing for that language because the
188
+ // resolver's shape was JS's.
189
+ //
190
+ // The rule the original was written under is unchanged and is what makes this safe: resolve
191
+ // ONLY to a file the scan actually contains. `os.path` becomes `os/path.py`, which is in no
192
+ // scan, so it still emits nothing. Nothing is invented; the dotted form is simply also read.
193
+ if (!spec.startsWith('.')) {
194
+ if (spec.includes(':'))
195
+ return null; // `node:fs` and friends are never a repository path
196
+ return firstKnown(path.posix.normalize(spec.split('.').filter(Boolean).join('/')));
197
+ }
198
+ // A-SPEC-289
199
+ // Python writes a relative import as `.b` / `..pkg.mod`, where the leading dots are LEVELS and
200
+ // the remaining dots are path separators — not the `./b` form JS uses. Treating `.b` as a path
201
+ // yields the literal name `.b`, which matches nothing. Detected by the absence of a slash: a
202
+ // specifier that already contains one is a JS-style path and is left alone.
203
+ let relative = spec;
204
+ if (!spec.includes('/')) {
205
+ const dots = spec.match(/^\.+/)[0].length;
206
+ const rest = spec.slice(dots).split('.').filter(Boolean).join('/');
207
+ relative = `${'../'.repeat(Math.max(0, dots - 1)) || './'}${rest}`;
208
+ }
209
+ return firstKnown(path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), relative)));
210
+ };
211
+ return {
212
+ resolve,
213
+ fanOut: (fromFile, spec) => (/\.go$/.test(fromFile) ? goPackage(spec) : []),
214
+ };
215
+ }
@@ -54,6 +54,20 @@ export interface LanguageGap {
54
54
  * meet would bury the ones this answer actually rests on. A language with nothing missing drops out
55
55
  * entirely, so the report shrinks to nothing as extraction catches up rather than becoming noise.
56
56
  */
57
+ /**
58
+ * @implements A-SPEC-689
59
+ * The gap shape for ONE capability, as a pure function of it.
60
+ *
61
+ * Split out so the concept can be pinned on a constructed capability instead of on whichever real
62
+ * language happens to lag. That was already the stated intent of the sibling test, but not its
63
+ * practice: the pin was moved from `.java` to `.cjs` when Java graduated, and `.cjs` has now
64
+ * graduated too, which would have moved it a third time — to nothing, because no advertised
65
+ * extension lags any more. A test that must be rewritten every time the product improves is not
66
+ * pinning the product; it is following it.
67
+ *
68
+ * Returns null when the capability has no gap at all.
69
+ */
70
+ export declare function gapOf(ext: string, cap: LanguageCapability): LanguageGap | null;
57
71
  export declare function capabilityGapsFor(sourcePaths: readonly string[]): LanguageGap[];
58
72
  /** Every advertised extension must have an entry; a blank would be a silent overclaim. */
59
73
  export declare const ADVERTISED_EXTENSIONS: readonly string[];
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ADVERTISED_EXTENSIONS = exports.LANGUAGE_CAPABILITY = exports.EXTRACTABLE_RELATIONS = void 0;
37
37
  exports.capabilityFor = capabilityFor;
38
+ exports.gapOf = gapOf;
38
39
  exports.capabilityGapsFor = capabilityGapsFor;
39
40
  exports.languageFamilyOf = languageFamilyOf;
40
41
  // @implements A-SPEC-289
@@ -67,8 +68,9 @@ exports.EXTRACTABLE_RELATIONS = ['calls', 'imports', 'inherits'];
67
68
  // @implements A-SPEC-289 — imports now resolve to FILE nodes when the specifier is relative and
68
69
  // names a scanned file; bare specifiers stay external and unresolved by design.
69
70
  const TS_FAMILY = { symbols: true, relations: ['calls', 'imports', 'inherits'], graphResolved: ['calls', 'imports', 'inherits'] };
70
- /** Symbols and calls only no import edges, no inheritance. */
71
- const CALLS_ONLY = { symbols: true, relations: ['calls'], graphResolved: ['calls'] };
71
+ // @implements A-SPEC-689 CALLS_ONLY used to live here, for `.cjs` alone. `.cjs` graduated once it
72
+ // was measured rather than assumed, leaving the shape with no member; the history it carried now
73
+ // lives on the `.cjs` cell itself, where the correction can be read next to the value.
72
74
  // @implements A-SPEC-511.1
73
75
  /**
74
76
  * Calls AND inheritance. The five non-TS/Python languages moved here once their inheritance was
@@ -98,8 +100,16 @@ const FULL_RELATIONS = {
98
100
  exports.LANGUAGE_CAPABILITY = {
99
101
  '.ts': TS_FAMILY, '.mts': TS_FAMILY, '.cts': TS_FAMILY, '.tsx': TS_FAMILY,
100
102
  '.js': TS_FAMILY, '.mjs': TS_FAMILY, '.jsx': TS_FAMILY,
101
- // CommonJS `require()` is not recovered as an import, unlike ESM `import` — measured.
102
- '.cjs': CALLS_ONLY,
103
+ // @implements A-SPEC-689 — `.cjs` is the TS family, measured rather than assumed.
104
+ // This cell said CALLS_ONLY on the strength of `require('os')` producing no import. That much is
105
+ // true and stays pinned: an external package has no node to point at. But it was written as a
106
+ // statement about `require()` itself, and it is not. Measured 2026-09-18: a RELATIVE
107
+ // `require('./b')` IS recovered as an import, and `class Child extends Base` as an inherit, in
108
+ // `.cjs` exactly as in `.js`. The table under-declared two relations for as long as the only
109
+ // pinned case was the bare one — an error with no visible symptom, which is why it lasted. The
110
+ // sibling test now pins both forms, so the claim above this table is finally true in BOTH
111
+ // directions.
112
+ '.cjs': TS_FAMILY,
103
113
  // @implements A-SPEC-286 — the first language whose inheritance is both extracted AND resolved.
104
114
  '.py': { symbols: true, relations: ['calls', 'imports', 'inherits'], graphResolved: ['calls', 'imports', 'inherits'] },
105
115
  // @implements A-SPEC-511.1 — inheritance recovered: Java extends/implements, C# base_list,
@@ -122,6 +132,27 @@ function capabilityFor(ext) {
122
132
  * meet would bury the ones this answer actually rests on. A language with nothing missing drops out
123
133
  * entirely, so the report shrinks to nothing as extraction catches up rather than becoming noise.
124
134
  */
135
+ /**
136
+ * @implements A-SPEC-689
137
+ * The gap shape for ONE capability, as a pure function of it.
138
+ *
139
+ * Split out so the concept can be pinned on a constructed capability instead of on whichever real
140
+ * language happens to lag. That was already the stated intent of the sibling test, but not its
141
+ * practice: the pin was moved from `.java` to `.cjs` when Java graduated, and `.cjs` has now
142
+ * graduated too, which would have moved it a third time — to nothing, because no advertised
143
+ * extension lags any more. A test that must be rewritten every time the product improves is not
144
+ * pinning the product; it is following it.
145
+ *
146
+ * Returns null when the capability has no gap at all.
147
+ */
148
+ function gapOf(ext, cap) {
149
+ const gap = {
150
+ ext,
151
+ missing: exports.EXTRACTABLE_RELATIONS.filter((r) => !cap.relations.includes(r)),
152
+ extractedButUnresolved: cap.relations.filter((r) => !cap.graphResolved.includes(r)),
153
+ };
154
+ return gap.missing.length > 0 || gap.extractedButUnresolved.length > 0 ? gap : null;
155
+ }
125
156
  function capabilityGapsFor(sourcePaths) {
126
157
  const present = new Set();
127
158
  for (const p of sourcePaths) {
@@ -130,15 +161,8 @@ function capabilityGapsFor(sourcePaths) {
130
161
  present.add(ext);
131
162
  }
132
163
  return [...present].sort()
133
- .map((ext) => {
134
- const cap = capabilityFor(ext);
135
- return {
136
- ext,
137
- missing: exports.EXTRACTABLE_RELATIONS.filter((r) => !cap.relations.includes(r)),
138
- extractedButUnresolved: cap.relations.filter((r) => !cap.graphResolved.includes(r)),
139
- };
140
- })
141
- .filter((gap) => gap.missing.length > 0 || gap.extractedButUnresolved.length > 0);
164
+ .map((ext) => gapOf(ext, capabilityFor(ext)))
165
+ .filter((gap) => gap !== null);
142
166
  }
143
167
  /** Every advertised extension must have an entry; a blank would be a silent overclaim. */
144
168
  exports.ADVERTISED_EXTENSIONS = cpg_scanner_1.SCANNABLE_EXTENSIONS;
@@ -1,10 +1,16 @@
1
1
  import { CodeEdge } from './language-parser';
2
- import { ForbiddenEdgeRule } from './forbidden-edges';
2
+ import { ForbiddenEdgeRule, type BaselineEntry } from './forbidden-edges';
3
3
  import { RequiredCallRule } from './required-calls';
4
4
  /** What the gate could actually see. `none` means no verdict was formed, not that nothing was wrong. */
5
5
  export type JudgedAs = 'full' | 'fragment' | 'none';
6
6
  export interface ProposedContentCheck {
7
7
  forbidden: ForbiddenEdgeRule[];
8
+ /**
9
+ * @implements A-SPEC-691
10
+ * Violations the project declared it already carries. Optional so every existing caller keeps
11
+ * working unchanged — absent means "no allowances", which is what the gate did before.
12
+ */
13
+ baselines?: BaselineEntry[];
8
14
  required: RequiredCallRule[];
9
15
  sourcePath: string;
10
16
  content: string;
@@ -51,6 +51,13 @@ function checkProposedContent(opts) {
51
51
  const asScanned = [{ path: sourcePath, sourcePath, symbols: [], edges, implementsSpecs: [] }];
52
52
  const violations = [];
53
53
  for (const v of (0, forbidden_edges_1.findForbiddenEdgeViolations)(asScanned, forbidden ?? [])) {
54
+ // @implements A-SPEC-691 — a violation the project already declared it carries is not a denial.
55
+ // Without this the gate would be live while the escape hatch stayed dead: a project with legacy
56
+ // could declare a rule and then be unable to edit the files it named. "A rule that blocks
57
+ // adoption is not a rule, it is a barrier" (REQ-574). The predicate is SHARED with the
58
+ // scan-wide path, so a baseline cannot mean one thing in a report and another at the gate.
59
+ if ((0, forbidden_edges_1.isBaselined)(v, opts.baselines ?? []))
60
+ continue;
54
61
  violations.push(`${v.sourcePath}: ${v.rule} (${v.kind} → ${v.to})`);
55
62
  }
56
63
  // Obligations only where the whole file is visible — see the asymmetry above.
@@ -202,3 +202,36 @@ export declare function queueHint(root: string, req: {
202
202
  } & RequestDetails, opts?: {
203
203
  baseReasonBytes?: number;
204
204
  }): string;
205
+ /**
206
+ * @implements A-SPEC-690
207
+ * Every request an operator can name — the candidate set a REFERENCE resolves against.
208
+ *
209
+ * The tracked queue is a decision inbox and deliberately excludes gate refusals (A-SPEC-563.1,
210
+ * measured 1,412 requests against 8 decisions). That routing is right and this does not undo it:
211
+ * the LIST still shows the inbox. But a reference is not a list — it is the operator naming the
212
+ * id the refusal just printed, and A-SPEC-576.1 already says such an id "must never make
213
+ * ungrantable". It did: `--grant <id>` resolved against the queue alone, so a shell refusal failed
214
+ * at reference resolution and never reached the refusal-log fallback that approve.ts already has.
215
+ *
216
+ * Measured 2026-09-19: `readRefusals` found req-9e92e52bd233 while `approve --grant` refused it.
217
+ */
218
+ export declare function grantableRequests(root: string): PendingRequest[];
219
+ /**
220
+ * @implements A-SPEC-690
221
+ * Which occurrences a decision has NOT already covered.
222
+ *
223
+ * Shared with `approve.ts`'s own lookup so the two never disagree about whether a request is open.
224
+ * A decision records HOW MANY occurrences it covered, so a retry is everything past that count —
225
+ * millisecond ties (measured flaky under the full suite) cannot lose it. Decisions written before
226
+ * the count existed fall back to the timestamp rule.
227
+ */
228
+ export declare function openOccurrences(all: readonly RefusalRecord[], decided?: {
229
+ ts: string;
230
+ covered?: number;
231
+ }): RefusalRecord[];
232
+ /**
233
+ * @implements A-SPEC-690
234
+ * One id's refusals, in the shape a queue entry has — so the decision surface reads the same
235
+ * whichever store the request came from.
236
+ */
237
+ export declare function asPendingRequest(id: string, occurrences: readonly RefusalRecord[]): PendingRequest;
@@ -44,6 +44,9 @@ exports.enqueueApprovalRequestDetailed = enqueueApprovalRequestDetailed;
44
44
  exports.appendQueueEvent = appendQueueEvent;
45
45
  exports.readQueue = readQueue;
46
46
  exports.queueHint = queueHint;
47
+ exports.grantableRequests = grantableRequests;
48
+ exports.openOccurrences = openOccurrences;
49
+ exports.asPendingRequest = asPendingRequest;
47
50
  // @implements A-SPEC-244, A-SPEC-626
48
51
  const execution_context_1 = require("../project/execution-context");
49
52
  const root_1 = require("../project/root");
@@ -503,3 +506,85 @@ function queueHint(root, req, opts) {
503
506
  ? hint
504
507
  : denialLine;
505
508
  }
509
+ /**
510
+ * @implements A-SPEC-690
511
+ * Every request an operator can name — the candidate set a REFERENCE resolves against.
512
+ *
513
+ * The tracked queue is a decision inbox and deliberately excludes gate refusals (A-SPEC-563.1,
514
+ * measured 1,412 requests against 8 decisions). That routing is right and this does not undo it:
515
+ * the LIST still shows the inbox. But a reference is not a list — it is the operator naming the
516
+ * id the refusal just printed, and A-SPEC-576.1 already says such an id "must never make
517
+ * ungrantable". It did: `--grant <id>` resolved against the queue alone, so a shell refusal failed
518
+ * at reference resolution and never reached the refusal-log fallback that approve.ts already has.
519
+ *
520
+ * Measured 2026-09-19: `readRefusals` found req-9e92e52bd233 while `approve --grant` refused it.
521
+ */
522
+ function grantableRequests(root) {
523
+ const state = readQueue(root, { includeAllKinds: true });
524
+ const seen = new Set(state.pending.map((p) => p.id));
525
+ let refusals;
526
+ // Fail toward REFUSAL, never toward approval: an unreadable log removes candidates, so a
527
+ // reference stops resolving and no grant goes out. The opposite bias would turn a read error
528
+ // into an approval path.
529
+ try {
530
+ refusals = readRefusals(root);
531
+ }
532
+ catch {
533
+ refusals = [];
534
+ }
535
+ const byId = new Map();
536
+ for (const r of refusals) {
537
+ const list = byId.get(r.id);
538
+ if (list)
539
+ list.push(r);
540
+ else
541
+ byId.set(r.id, [r]);
542
+ }
543
+ const out = [...state.pending];
544
+ for (const [id, all] of byId) {
545
+ // The queue copy carries the decision history the single-use rule reads; the refusal copy does
546
+ // not. When both exist the queue wins, or granting once would stop closing the request.
547
+ if (seen.has(id))
548
+ continue;
549
+ const live = openOccurrences(all, state.decisions[id]);
550
+ if (live.length === 0)
551
+ continue;
552
+ out.push(asPendingRequest(id, live));
553
+ }
554
+ return out;
555
+ }
556
+ /**
557
+ * @implements A-SPEC-690
558
+ * Which occurrences a decision has NOT already covered.
559
+ *
560
+ * Shared with `approve.ts`'s own lookup so the two never disagree about whether a request is open.
561
+ * A decision records HOW MANY occurrences it covered, so a retry is everything past that count —
562
+ * millisecond ties (measured flaky under the full suite) cannot lose it. Decisions written before
563
+ * the count existed fall back to the timestamp rule.
564
+ */
565
+ function openOccurrences(all, decided) {
566
+ if (!decided)
567
+ return [...all];
568
+ if (typeof decided.covered === 'number')
569
+ return all.slice(decided.covered);
570
+ return all.filter((r) => r.ts > decided.ts);
571
+ }
572
+ /**
573
+ * @implements A-SPEC-690
574
+ * One id's refusals, in the shape a queue entry has — so the decision surface reads the same
575
+ * whichever store the request came from.
576
+ */
577
+ function asPendingRequest(id, occurrences) {
578
+ const latest = occurrences[occurrences.length - 1];
579
+ return {
580
+ id,
581
+ // The NEWEST occurrence is what the operator just saw, so it is what the decision names.
582
+ kind: latest.kind,
583
+ target: latest.target,
584
+ why: latest.why,
585
+ count: occurrences.length,
586
+ firstTs: occurrences[0].ts,
587
+ lastTs: latest.ts,
588
+ ...(latest.replica ? { replica: latest.replica } : {}),
589
+ };
590
+ }
@@ -1156,15 +1156,22 @@ function evaluateHook(input, specsDir, opts) {
1156
1156
  if (proposed && (0, write_target_1.specTargetOf)(opts.projectRoot, specsDir, p) === null) {
1157
1157
  const forbidden = [];
1158
1158
  const required = [];
1159
+ // @implements A-SPEC-691 — the allowances travel with the rules. They come out of the SAME
1160
+ // section and the same parse, so a rule and the baseline that tempers it can never be read
1161
+ // from documents that disagree.
1162
+ const baselines = [];
1159
1163
  for (const sp of specs) {
1160
1164
  if (sp.type !== 'C-SPEC' || sp.status !== 'approved')
1161
1165
  continue;
1162
- forbidden.push(...(0, forbidden_edges_1.parseForbiddenEdges)(sp.sections?.['Forbidden Edges'] ?? '').rules);
1166
+ const parsed = (0, forbidden_edges_1.parseForbiddenEdges)(sp.sections?.['Forbidden Edges'] ?? '');
1167
+ forbidden.push(...parsed.rules);
1168
+ baselines.push(...parsed.baselines);
1163
1169
  required.push(...(0, required_calls_1.parseRequiredCalls)(sp.sections?.['Layer Rules'] ?? '').rules);
1164
1170
  }
1165
1171
  if (forbidden.length > 0 || required.length > 0) {
1166
1172
  const { violations } = (0, proposed_content_1.checkProposedContent)({
1167
1173
  forbidden,
1174
+ baselines,
1168
1175
  required,
1169
1176
  // @implements A-SPEC-231 — the path is normalised before the prefix test. Adversarial
1170
1177
  // pass 3, 2026-08-22: `./src/a/x.ts` and `src//a/x.ts` both walked past a rule scoped to
@@ -197,6 +197,45 @@ if (require.main === module) {
197
197
  }
198
198
  }
199
199
  catch { /* the banner is never a gate */ }
200
+ // @implements A-SPEC-687 — the other end of the loop `report` opened. Until now nothing read
201
+ // reported.jsonl, so a consumer who took the trouble to report a defect learned nothing when
202
+ // it was fixed. A LOCAL set intersection: a file this machine wrote against a list that
203
+ // shipped in the package. Nothing is sent; there is no device id and no MAC address, because
204
+ // a fingerprint identifies a DEFECT and not a person. Said once, then never again.
205
+ try {
206
+ const { RESOLVED_REPORTS } = require('../project/resolved-reports');
207
+ const { briefingLines, reportedFrom, announcedFrom, announcedTo } = require('../project/report-briefing');
208
+ const dir = path.join(process.cwd(), '.ax', 'reports');
209
+ const read = (f) => { try {
210
+ return fs.readFileSync(path.join(dir, f), 'utf8');
211
+ }
212
+ catch {
213
+ return null;
214
+ } };
215
+ const announced = announcedFrom(read('briefed.json'));
216
+ const lines = briefingLines({
217
+ reported: reportedFrom(read('reported.jsonl')),
218
+ resolved: RESOLVED_REPORTS,
219
+ installed: pkgVersion(),
220
+ shipped: RESOLVED_REPORTS.map((r) => r.version),
221
+ announced,
222
+ });
223
+ if (lines.length > 0) {
224
+ out.hookSpecificOutput.additionalContext += '\n[Holmes-Kit] ' + lines.join('\n[Holmes-Kit] ');
225
+ // Written ONLY when something was said: a marker recording an announcement that never
226
+ // happened would silence a future briefing that should have spoken.
227
+ const said = new Set(announced);
228
+ for (const r of RESOLVED_REPORTS)
229
+ if (lines.some((l) => l.includes(r.fingerprint)))
230
+ said.add(r.fingerprint);
231
+ try {
232
+ fs.mkdirSync(dir, { recursive: true });
233
+ fs.writeFileSync(path.join(dir, 'briefed.json'), announcedTo(said));
234
+ }
235
+ catch { /* the briefing is delivered; recording it is best-effort */ }
236
+ }
237
+ }
238
+ catch { /* the banner is never a gate */ }
200
239
  process.stdout.write(JSON.stringify({ hookSpecificOutput: out.hookSpecificOutput }));
201
240
  if (out.shouldRefresh) {
202
241
  // Detached, unref'd child so the session start does not wait on the network. The refresh
@@ -5,6 +5,10 @@ import { type KnownDefectJudgement } from '../rtm/known-defects';
5
5
  import { type CiVerdict } from '../project/ci-runs';
6
6
  import { type DistVerdict } from '../project/dist-freshness';
7
7
  import { type AnalysisVerdict } from '../project/analysis-currency';
8
+ import { judgeForbiddenEdges } from '../cpg/forbidden-edges';
9
+ import type { ScannedFile } from '../cpg/cpg-scanner';
10
+ /** @implements A-SPEC-693 */
11
+ export type ForbiddenEdgeJudgement = ReturnType<typeof judgeForbiddenEdges>;
8
12
  export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
9
13
  export declare function collectSemanticCoverage(root: string, tier?: {
10
14
  tier: string;
@@ -55,6 +59,11 @@ export interface StopEvidence {
55
59
  * turn's source edits. Absent when the workspace never analysed anything.
56
60
  */
57
61
  analysis?: AnalysisVerdict;
62
+ /**
63
+ * @implements A-SPEC-693 — the scan-wide forbidden-edge judgement over the approved C-SPECs.
64
+ * Absent when the project declares no rule, or when the hook could not look; never a block.
65
+ */
66
+ forbiddenEdges?: ForbiddenEdgeJudgement;
58
67
  /** @implements A-SPEC-683 — the rendered coverage line the refresh child recorded; already judged. */
59
68
  semantic?: string;
60
69
  /** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
@@ -315,7 +324,34 @@ export declare function __setWiredSpecsForTest(v: string | undefined): void;
315
324
  export declare function guardCountOrZero(sessionId: string, read?: (id: string) => number): number;
316
325
  export declare function readGuardCount(sessionId: string): number;
317
326
  /**
318
- * Exported for the §19 race discriminator: the persistence layer was the untested half (round-11),
319
- * and a test that can only reach it through a spawned hook cannot pin what the lock does.
327
+ * @implements A-SPEC-693
328
+ * The scan-wide forbidden-edge judgement, over the SAME rules the pre-edit gate reads: approved
329
+ * C-SPECs only, rules and baselines out of one parse of one section.
330
+ *
331
+ * Returns `undefined` when no rule is declared — the project never adopted this, so it pays nothing
332
+ * and hears nothing. The scan is handed in rather than made here: the Stop hook already scans once a
333
+ * turn for the cycle ratchet, and scanning is that hook's dominant cost.
334
+ */
335
+ export declare function collectForbiddenEdgeJudgement(specs: readonly Spec[], scanned: ScannedFile[], now: Date): ForbiddenEdgeJudgement | undefined;
336
+ /**
337
+ * @implements A-SPEC-688
338
+ * The code graph's cycles, judged where the code exists — extracted from the hook body so the
339
+ * judgement is observable without driving a whole Stop turn.
340
+ *
341
+ * Returns `undefined` when the scan could not run at all. That is NOT "no cycles": the caller must
342
+ * keep the article silent rather than report a clean tree, because "we could not look" and "there
343
+ * is nothing there" are different facts (REQ-574).
344
+ *
345
+ * @implements A-SPEC-693 — `shared` is a scan the caller already made. When given it is BELIEVED
346
+ * and no second scan runs, so one turn pays for one scan however many judgements read it.
320
347
  */
348
+ export declare function scanCodeCycles(root: string, shared?: ScannedFile[]): {
349
+ current: import('../cpg/cycle-detect').Cycle[];
350
+ allowed: string[];
351
+ mode: 'strict' | 'track' | 'off';
352
+ scope: {
353
+ judged: string[];
354
+ unavailable: string[];
355
+ };
356
+ } | undefined;
321
357
  export declare function writeGuardCount(sessionId: string, n: number): boolean;