@holmes-lab/holmes-kit 0.25.1 → 0.26.1

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 (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +10 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.js +7 -26
  5. package/dist/holmes/cli/doctor.js +53 -10
  6. package/dist/holmes/cli/index.js +8 -1
  7. package/dist/holmes/cli/release-docs.d.ts +27 -0
  8. package/dist/holmes/cli/release-docs.js +45 -0
  9. package/dist/holmes/config/config.d.ts +11 -0
  10. package/dist/holmes/config/config.js +11 -1
  11. package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
  12. package/dist/holmes/cpg/cycle-observation.js +25 -2
  13. package/dist/holmes/cpg/cycle-report.d.ts +35 -0
  14. package/dist/holmes/cpg/cycle-report.js +74 -0
  15. package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
  16. package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
  17. package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
  18. package/dist/holmes/cpg/forbidden-edges.js +135 -2
  19. package/dist/holmes/cpg/import-resolver.d.ts +12 -0
  20. package/dist/holmes/cpg/import-resolver.js +215 -0
  21. package/dist/holmes/cpg/language-capability.d.ts +14 -0
  22. package/dist/holmes/cpg/language-capability.js +37 -13
  23. package/dist/holmes/cpg/proposed-content.d.ts +7 -1
  24. package/dist/holmes/cpg/proposed-content.js +7 -0
  25. package/dist/holmes/governance/approval-queue.d.ts +33 -0
  26. package/dist/holmes/governance/approval-queue.js +85 -0
  27. package/dist/holmes/hooks/pre-tool-use.js +8 -1
  28. package/dist/holmes/hooks/session-start.js +39 -0
  29. package/dist/holmes/hooks/stop.d.ts +38 -2
  30. package/dist/holmes/hooks/stop.js +169 -50
  31. package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
  32. package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
  33. package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
  34. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +7 -5
  35. package/dist/holmes/mcp/handlers/spec-lifecycle.js +15 -2
  36. package/dist/holmes/mcp/handlers.d.ts +7 -5
  37. package/dist/holmes/project/report-briefing.d.ts +48 -0
  38. package/dist/holmes/project/report-briefing.js +70 -0
  39. package/dist/holmes/project/resolved-reports.d.ts +20 -0
  40. package/dist/holmes/project/resolved-reports.js +7 -0
  41. package/dist/holmes/project/root.js +11 -1
  42. package/dist/holmes/rtm/rtm-builder.js +6 -141
  43. package/dist/holmes/spec/id-collision.d.ts +98 -0
  44. package/dist/holmes/spec/id-collision.js +149 -1
  45. package/dist/holmes/spec/remote-spec-refs.d.ts +16 -0
  46. package/dist/holmes/spec/remote-spec-refs.js +148 -0
  47. package/dist/holmes/spec/renumber.d.ts +12 -2
  48. package/dist/holmes/spec/renumber.js +25 -6
  49. package/package.json +1 -1
  50. package/playbooks/publish/PLAYBOOK.md +18 -0
@@ -46,6 +46,7 @@ exports.addDecisionEdges = addDecisionEdges;
46
46
  const path = __importStar(require("node:path"));
47
47
  const fs = __importStar(require("node:fs"));
48
48
  const language_capability_1 = require("../cpg/language-capability");
49
+ const import_resolver_1 = require("../cpg/import-resolver");
49
50
  /**
50
51
  * @implements A-SPEC-281
51
52
  * Build one fact's provenance from the shared observation context plus what this call site knows.
@@ -429,146 +430,10 @@ function fsReadGoMod(p) {
429
430
  }
430
431
  }
431
432
  function addImportEdges(scanned, graph, opts) {
432
- const known = new Set(scanned.map((f) => f.sourcePath));
433
- const EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx', '.py',
434
- '/index.ts', '/index.js', '/__init__.py'];
435
- const firstKnown = (base) => EXTENSIONS.map((ext) => `${base}${ext}`).find((candidate) => known.has(candidate)) ?? null;
436
- // @implements A-SPEC-525.1 — the five languages' resolution rules, selected by the IMPORTING
437
- // file's extension because resolution IS per-language semantics. The founding rule is unchanged
438
- // everywhere: resolve only to files the scan actually contains; the ambiguous resolve to none.
439
- const knownList = [...known];
440
- const uniqueSuffix = (suffix) => {
441
- const hits = knownList.filter((k) => k === suffix || k.endsWith(`/${suffix}`));
442
- return hits.length === 1 ? hits[0] : null; // two files may not become a guess
443
- };
444
- // go.mod discovery: injected via opts when the caller knows it; otherwise walked up from the
445
- // first Go file's ABSOLUTE path (bounded), so production builds resolve without extra wiring.
446
- let goModCache;
447
- const goModuleOf = () => {
448
- if (opts?.goModule)
449
- return opts.goModule;
450
- if (goModCache !== undefined)
451
- return goModCache ?? undefined;
452
- goModCache = null;
453
- const anyGo = scanned.find((f) => /\.go$/.test(f.sourcePath) && f.path);
454
- if (anyGo) {
455
- let dir = path.dirname(anyGo.path);
456
- for (let hops = 0; hops < 12; hops++) {
457
- try {
458
- const txt = fsReadGoMod(path.join(dir, 'go.mod'));
459
- if (txt !== null) {
460
- goModCache = txt;
461
- break;
462
- }
463
- }
464
- catch { /* keep walking */ }
465
- const up = path.dirname(dir);
466
- if (up === dir)
467
- break;
468
- dir = up;
469
- }
470
- }
471
- return goModCache ?? undefined;
472
- };
473
- const goPackage = (spec) => {
474
- const mod = goModuleOf();
475
- if (!mod || !(spec === mod || spec.startsWith(`${mod}/`)))
476
- return []; // external, honestly
477
- const dir = spec === mod ? '' : spec.slice(mod.length + 1);
478
- const prefix = dir === '' ? '' : `${dir}/`;
479
- return knownList.filter((k) => k.startsWith(prefix) && k.endsWith('.go')
480
- && !k.endsWith('_test.go') && !k.slice(prefix.length).includes('/'));
481
- };
482
- const rustResolve = (fromFile, spec) => {
483
- const segs = spec.split('::');
484
- const head = segs.shift();
485
- let baseDir;
486
- if (head === 'crate') {
487
- // the crate root is the src/ directory nearest above the importing file
488
- const m = fromFile.match(/^(.*?src)\//);
489
- baseDir = m ? m[1] : 'src';
490
- }
491
- else if (head === 'super') {
492
- baseDir = path.posix.dirname(path.posix.dirname(fromFile));
493
- while (segs[0] === 'super') {
494
- // @implements A-SPEC-527.1 — super:: above the crate root is an rustc error; resolving
495
- // it pinned '.' and produced edges to repo-root files (adversarial sweep). Refuse.
496
- if (baseDir === '.' || baseDir === '/')
497
- return null;
498
- segs.shift();
499
- baseDir = path.posix.dirname(baseDir);
500
- }
501
- }
502
- else if (head === 'self') {
503
- baseDir = path.posix.dirname(fromFile);
504
- }
505
- else {
506
- return null; // external crate
507
- }
508
- const tryPath = (parts) => {
509
- if (parts.length === 0)
510
- return null;
511
- const base = path.posix.normalize(path.posix.join(baseDir, ...parts));
512
- if (known.has(`${base}.rs`))
513
- return `${base}.rs`;
514
- if (known.has(`${base}/mod.rs`))
515
- return `${base}/mod.rs`;
516
- return null;
517
- };
518
- // the last segment may be an ITEM, not a module — drop it once and retry
519
- return tryPath(segs) ?? tryPath(segs.slice(0, -1));
520
- };
521
- const resolve = (fromFile, spec) => {
522
- // @implements A-SPEC-525.1 — language branches BEFORE the JS/Python-shaped fallthrough.
523
- if (/\.go$/.test(fromFile))
524
- return null; // Go fans out separately (a package is its files)
525
- if (/\.rs$/.test(fromFile))
526
- return rustResolve(fromFile, spec);
527
- if (/\.java$/.test(fromFile))
528
- return uniqueSuffix(`${spec.split('.').join('/')}.java`);
529
- if (/\.cs$/.test(fromFile))
530
- return uniqueSuffix(`${spec.split('.').join('/')}.cs`);
531
- if (/\.(cpp|cc|cxx|hpp|h)$/.test(fromFile)) {
532
- // @implements A-SPEC-527.1 — an ABSOLUTE include is not a repository coordinate: joining
533
- // it into the repo frame let `#include "/etc/passwd"` match a coincidentally-shaped
534
- // scanned file (adversarial sweep). Absolute means absolute; it resolves to nothing here.
535
- if (spec.startsWith('/'))
536
- return null;
537
- // extension preserved: the dotted split below would butcher `util/env.h`
538
- const relative = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
539
- if (known.has(relative))
540
- return relative;
541
- return known.has(spec) ? spec : null; // repo-root-relative include
542
- }
543
- // @implements A-SPEC-406
544
- // A specifier without a leading dot used to be refused outright, on the ground that it names an
545
- // external package. That holds for `node:fs` and `js-yaml`; it does NOT hold for Python, which
546
- // writes intra-repository modules absolutely — `from src.core.memory_audit import X`. Measured
547
- // on the jarvis corpus: 1060 specifiers, 0 relative, 542 naming a file the scan already had, and
548
- // 0 import edges in the graph. The whole import layer was missing for that language because the
549
- // resolver's shape was JS's.
550
- //
551
- // The rule the original was written under is unchanged and is what makes this safe: resolve
552
- // ONLY to a file the scan actually contains. `os.path` becomes `os/path.py`, which is in no
553
- // scan, so it still emits nothing. Nothing is invented; the dotted form is simply also read.
554
- if (!spec.startsWith('.')) {
555
- if (spec.includes(':'))
556
- return null; // `node:fs` and friends are never a repository path
557
- return firstKnown(path.posix.normalize(spec.split('.').filter(Boolean).join('/')));
558
- }
559
- // @implements A-SPEC-289
560
- // Python writes a relative import as `.b` / `..pkg.mod`, where the leading dots are LEVELS and
561
- // the remaining dots are path separators — not the `./b` form JS uses. Treating `.b` as a path
562
- // yields the literal name `.b`, which matches nothing. Detected by the absence of a slash: a
563
- // specifier that already contains one is a JS-style path and is left alone.
564
- let relative = spec;
565
- if (!spec.includes('/')) {
566
- const dots = spec.match(/^\.+/)[0].length;
567
- const rest = spec.slice(dots).split('.').filter(Boolean).join('/');
568
- relative = `${'../'.repeat(Math.max(0, dots - 1)) || './'}${rest}`;
569
- }
570
- return firstKnown(path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), relative)));
571
- };
433
+ // @implements A-SPEC-688 the resolution rules moved to `cpg/import-resolver` so the cycle
434
+ // ratchet consumes the SAME code instead of re-deriving a weaker copy. The rules themselves are
435
+ // unchanged; this call is where they used to be spelled out.
436
+ const { resolve, fanOut } = (0, import_resolver_1.createImportResolver)(scanned, { goModule: opts?.goModule });
572
437
  graph.transaction(() => {
573
438
  for (const f of scanned) {
574
439
  for (const e of f.edges ?? []) {
@@ -578,7 +443,7 @@ function addImportEdges(scanned, graph, opts) {
578
443
  // language's semantics, not an invention), so one specifier fans out to each scanned
579
444
  // file of the package directory.
580
445
  if (/\.go$/.test(f.sourcePath)) {
581
- for (const target of goPackage(e.to)) {
446
+ for (const target of fanOut(f.sourcePath, e.to)) {
582
447
  graph.addNode(`FILE:${f.sourcePath}`, 'FILE', f.sourcePath, fact(opts, f.sourcePath, 'ast-scan', 1));
583
448
  graph.addNode(`FILE:${target}`, 'FILE', target, fact(opts, target, 'ast-scan', 1));
584
449
  graph.addEdge(`FILE:${f.sourcePath}`, `FILE:${target}`, 'imports', f.sourcePath, fact(opts, `${f.sourcePath} -> ${e.to}`, 'import-resolution', 1));
@@ -1,3 +1,4 @@
1
+ import type { Spec } from './spec-parser';
1
2
  /**
2
3
  * Distributed id-preemption detection (REQ-254): two disconnected workspaces each see the same local
3
4
  * max and issue the same spec number, and the collision is silent at create AND at push — the
@@ -37,3 +38,100 @@ export interface IdCollisionIssue {
37
38
  detail: string;
38
39
  }
39
40
  export declare function detectIdCollisions(entries: IdCollisionEntry[]): IdCollisionIssue[];
41
+ /**
42
+ * Which colliding number moves, and where to.
43
+ *
44
+ * `detectIdCollisions` above (REQ-254) says a number is claimed twice; `spec_renumber` (REQ-255)
45
+ * can move a family. Nothing joined them, so doctor's advice still reads "renumber one (manual
46
+ * until REQ-255)" — stale, since REQ-255 shipped — and the decision fell to a person every time.
47
+ * Measured 2026-09-20: two machines each allocated REQ-694, and recovering it by hand cost a
48
+ * rebuilt slice and five out-of-band approvals.
49
+ *
50
+ * This is the rule, not the execution: it consumes the detector's issues and returns destinations.
51
+ * Applying them stays with `spec_renumber`, so adopting the entity store later swaps the engine
52
+ * without touching this.
53
+ */
54
+ export interface ReconcileMove {
55
+ from: string;
56
+ to: string;
57
+ reason: string;
58
+ }
59
+ export interface ReconcileBlock {
60
+ base: string;
61
+ reason: string;
62
+ }
63
+ export interface ReconcilePlan {
64
+ moves: ReconcileMove[];
65
+ unresolved: ReconcileBlock[];
66
+ }
67
+ export interface ReconcileInput {
68
+ /** Output of `detectIdCollisions`; entries that are not `id-collision` are ignored. */
69
+ issues: readonly IdCollisionIssue[];
70
+ /** Every base this checkout holds. */
71
+ localBases: ReadonlySet<string>;
72
+ /** Every base the remote-tracking refs hold. */
73
+ remoteBases: ReadonlySet<string>;
74
+ /**
75
+ * The bases of THIS CHECKOUT that are already on the remote — not "what the remote holds".
76
+ *
77
+ * A colliding base is by definition published on the remote side, so the only open question is
78
+ * whether the LOCAL one is too. This input was first called `pushedBases` ("bases already
79
+ * published"), which reads as the remote's set; a caller filling it that way puts every collision
80
+ * in it and receives an empty plan, silently. A contract that makes the natural caller wrong is the
81
+ * defect, so the name now says whose publication it means. A base in here must not move — a public
82
+ * number may already be cited — and the plan says so instead of choosing.
83
+ */
84
+ localPublishedBases: ReadonlySet<string>;
85
+ }
86
+ /** "REQ-694" -> "694"; "T-SPEC-700.1" -> "700"; anything else has no base. */
87
+ export declare function baseOfSpecId(id: string): string | undefined;
88
+ export declare function planIdReconcile(input: ReconcileInput): ReconcilePlan;
89
+ /**
90
+ * The collision identity of one spec document: the WHOLE BODY, canonicalised.
91
+ *
92
+ * Moved here from doctor's collection layer, unchanged, because a second collector now needs it —
93
+ * the one that reads remote-tracking refs. Two copies of an identity function is how the same
94
+ * document comes to have two keys, and then every shared spec reads as a collision.
95
+ *
96
+ * What it hashes is the record of three adversarial rounds (see doctor.ts): not a composite of
97
+ * parser splits (each split blind spot leaked, one at a time), but everything after the frontmatter
98
+ * with CRLF folded by the caller, per-line trailing whitespace and blank lines dropped, plus the
99
+ * substantive frontmatter (type, title, sorted depends_on) through JSON array serialisation so no
100
+ * field can alias another. The SEAL digest is a separate signal and is not part of this key.
101
+ *
102
+ * `folded` must already be CRLF-folded — both collectors fold exactly where they read.
103
+ */
104
+ export declare function collisionKeyOf(folded: string, spec: Spec): string;
105
+ /**
106
+ * The planner's inputs, derived from what the two collectors already know.
107
+ *
108
+ * `planIdReconcile` shipped with no caller. This is the first half of giving it one; it stays pure
109
+ * (entries in, sets out) so the rule it feeds remains assertable without a repository.
110
+ *
111
+ * THE ONE DECISION HERE is what "published" means for a LOCAL base. It cannot be "the path exists on
112
+ * the remote": a spec's path is derived from its number, so a colliding number has that path on the
113
+ * remote by definition, and reading it that way marks every collision as published on both sides —
114
+ * the exact misreading `localPublishedBases` was renamed to prevent (A-SPEC-700). A local document
115
+ * is published when some remote ref holds the SAME CONTENT at the same path.
116
+ */
117
+ export declare function reconcileInputsFrom(input: {
118
+ issues: readonly IdCollisionIssue[];
119
+ /** Every local entry — all of them claim a number, whether or not they were added recently. */
120
+ local: readonly IdCollisionEntry[];
121
+ /** The local entries that were not present at a merge-base. */
122
+ addedLocally: readonly IdCollisionEntry[];
123
+ /** `collectRemoteAddedSpecs` output: `entries[].file` is `<ref>:<store-relative path>`. */
124
+ remote: {
125
+ entries: readonly IdCollisionEntry[];
126
+ baseFiles: ReadonlySet<string>;
127
+ };
128
+ }): ReconcileInput;
129
+ /**
130
+ * The plan, worded for the person who has to act on it. Empty plan → empty string, so the caller
131
+ * can tell "nothing to say" apart and fall back to a general sentence.
132
+ *
133
+ * The engine's name appears HERE and nowhere in the planner: `planIdReconcile` says what moves
134
+ * where, and only this sentence says with what — so swapping `spec_renumber` for `entity_renumber`
135
+ * after adoption is a one-line change to wording, not to the rule.
136
+ */
137
+ export declare function reconcileAdvice(plan: ReconcilePlan): string;
@@ -1,7 +1,46 @@
1
1
  "use strict";
2
- // @implements A-SPEC-254.1
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
+ })();
3
35
  Object.defineProperty(exports, "__esModule", { value: true });
4
36
  exports.detectIdCollisions = detectIdCollisions;
37
+ exports.baseOfSpecId = baseOfSpecId;
38
+ exports.planIdReconcile = planIdReconcile;
39
+ exports.collisionKeyOf = collisionKeyOf;
40
+ exports.reconcileInputsFrom = reconcileInputsFrom;
41
+ exports.reconcileAdvice = reconcileAdvice;
42
+ // @implements A-SPEC-254.1
43
+ const crypto = __importStar(require("node:crypto"));
5
44
  // Only A-SPEC and T-SPEC may carry dot sub-numbers (the engine enforces this), so only they can
6
45
  // have a bare/dotted family split. REQ/H-SPEC ids are structurally exempt.
7
46
  const DOTTED = /^([AT]-SPEC-\d+)\.\d+$/;
@@ -84,3 +123,112 @@ function detectIdCollisions(entries) {
84
123
  }
85
124
  return issues.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.kind < b.kind ? -1 : 1));
86
125
  }
126
+ /** "REQ-694" -> "694"; "T-SPEC-700.1" -> "700"; anything else has no base. */
127
+ function baseOfSpecId(id) {
128
+ return /-(\d{3,})(?:\.\d+)?$/.exec(id)?.[1];
129
+ }
130
+ function planIdReconcile(input) {
131
+ const { issues, localBases, remoteBases, localPublishedBases } = input;
132
+ const bases = new Set();
133
+ for (const issue of issues) {
134
+ if (issue.kind !== 'id-collision')
135
+ continue;
136
+ const base = baseOfSpecId(issue.id);
137
+ if (base !== undefined)
138
+ bases.add(base);
139
+ }
140
+ const moves = [];
141
+ const unresolved = [];
142
+ // Destinations must clear BOTH sides, and each other: choosing from the local maximum alone walks
143
+ // straight into the next merge, and two collisions resolved in one pass must not land together.
144
+ const taken = new Set([...localBases, ...remoteBases]);
145
+ for (const base of [...bases].sort((a, b) => Number(a) - Number(b))) {
146
+ if (localPublishedBases.has(base)) {
147
+ unresolved.push({ base, reason: 'both sides are published; a person has to choose which one moves' });
148
+ continue;
149
+ }
150
+ let candidate = Number(base) + 1;
151
+ while (taken.has(String(candidate)))
152
+ candidate++;
153
+ const to = String(candidate);
154
+ taken.add(to);
155
+ moves.push({ from: base, to, reason: 'local base is not published yet' });
156
+ }
157
+ return { moves, unresolved };
158
+ }
159
+ // @implements A-SPEC-254.2
160
+ /**
161
+ * The collision identity of one spec document: the WHOLE BODY, canonicalised.
162
+ *
163
+ * Moved here from doctor's collection layer, unchanged, because a second collector now needs it —
164
+ * the one that reads remote-tracking refs. Two copies of an identity function is how the same
165
+ * document comes to have two keys, and then every shared spec reads as a collision.
166
+ *
167
+ * What it hashes is the record of three adversarial rounds (see doctor.ts): not a composite of
168
+ * parser splits (each split blind spot leaked, one at a time), but everything after the frontmatter
169
+ * with CRLF folded by the caller, per-line trailing whitespace and blank lines dropped, plus the
170
+ * substantive frontmatter (type, title, sorted depends_on) through JSON array serialisation so no
171
+ * field can alias another. The SEAL digest is a separate signal and is not part of this key.
172
+ *
173
+ * `folded` must already be CRLF-folded — both collectors fold exactly where they read.
174
+ */
175
+ function collisionKeyOf(folded, spec) {
176
+ const fence = /^---\n[\s\S]*?\n---\n?/.exec(folded);
177
+ const body = (fence ? folded.slice(fence[0].length) : folded)
178
+ .split('\n').map((l) => l.trimEnd()).filter((l) => l !== '').join('\n');
179
+ return 'sha256:' + crypto.createHash('sha256')
180
+ .update(JSON.stringify([spec.type, spec.title, [...spec.dependsOn].sort(), body])).digest('hex');
181
+ }
182
+ // @implements A-SPEC-700.1
183
+ /**
184
+ * The planner's inputs, derived from what the two collectors already know.
185
+ *
186
+ * `planIdReconcile` shipped with no caller. This is the first half of giving it one; it stays pure
187
+ * (entries in, sets out) so the rule it feeds remains assertable without a repository.
188
+ *
189
+ * THE ONE DECISION HERE is what "published" means for a LOCAL base. It cannot be "the path exists on
190
+ * the remote": a spec's path is derived from its number, so a colliding number has that path on the
191
+ * remote by definition, and reading it that way marks every collision as published on both sides —
192
+ * the exact misreading `localPublishedBases` was renamed to prevent (A-SPEC-700). A local document
193
+ * is published when some remote ref holds the SAME CONTENT at the same path.
194
+ */
195
+ function reconcileInputsFrom(input) {
196
+ const basesOf = (ids) => {
197
+ const out = new Set();
198
+ for (const id of ids) {
199
+ const b = baseOfSpecId(id);
200
+ if (b !== undefined)
201
+ out.add(b);
202
+ }
203
+ return out;
204
+ };
205
+ // A store-relative path names its spec id: `01_req/REQ-900.md` → `REQ-900`.
206
+ const idOfPath = (file) => file.slice(file.lastIndexOf('/') + 1).replace(/\.md$/, '');
207
+ const remotePathOf = (file) => file.slice(file.indexOf(':') + 1);
208
+ const remoteKeys = new Set(input.remote.entries.map((e) => `${remotePathOf(e.file)}\u0000${e.contentDigest}`));
209
+ const published = input.addedLocally.filter((e) => remoteKeys.has(`${e.file}\u0000${e.contentDigest}`));
210
+ return {
211
+ issues: input.issues,
212
+ localBases: basesOf(input.local.map((e) => e.id)),
213
+ remoteBases: basesOf([...[...input.remote.baseFiles].map(idOfPath), ...input.remote.entries.map((e) => e.id)]),
214
+ localPublishedBases: basesOf(published.map((e) => e.id)),
215
+ };
216
+ }
217
+ // @implements A-SPEC-700.1
218
+ /**
219
+ * The plan, worded for the person who has to act on it. Empty plan → empty string, so the caller
220
+ * can tell "nothing to say" apart and fall back to a general sentence.
221
+ *
222
+ * The engine's name appears HERE and nowhere in the planner: `planIdReconcile` says what moves
223
+ * where, and only this sentence says with what — so swapping `spec_renumber` for `entity_renumber`
224
+ * after adoption is a one-line change to wording, not to the rule.
225
+ */
226
+ function reconcileAdvice(plan) {
227
+ const parts = [
228
+ ...plan.moves.map((m) => `move ${m.from} → ${m.to}: spec_renumber(oldBase=${m.from}, newBase=${m.to})`),
229
+ ...plan.unresolved.map((u) => `${u.base}: both sides are published — a person has to choose which one moves`),
230
+ ];
231
+ if (parts.length === 0)
232
+ return '';
233
+ return parts.join('; ') + (plan.moves.length > 0 ? '; then re-seal with spec_approve, and merge' : '');
234
+ }
@@ -0,0 +1,16 @@
1
+ import { type IdCollisionEntry } from './id-collision';
2
+ export interface RemoteAddedSpecs {
3
+ /** The remote-tracking refs that were compared — a PASS must be able to name what it looked at. */
4
+ refs: string[];
5
+ /** Documents each ref added since the merge-base, `file` spelled `<ref>:<store-relative path>`. */
6
+ entries: IdCollisionEntry[];
7
+ /** Store-relative spec paths already present at a merge-base: local files NOT in here are "added locally". */
8
+ baseFiles: Set<string>;
9
+ /** Blobs and refs that could not be read — a reduced scope is said, never hidden. */
10
+ skipped: number;
11
+ /** Why nothing was compared (no git, no remote-tracking ref). An answer, not a refusal (REQ-128). */
12
+ unavailable?: string;
13
+ }
14
+ /** Runs one git command and returns its stdout as a Buffer. Injectable so a failure can be staged. */
15
+ export type GitRunner = (root: string, args: string[], input?: string) => Buffer;
16
+ export declare function collectRemoteAddedSpecs(root: string, git?: GitRunner): RemoteAddedSpecs;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectRemoteAddedSpecs = collectRemoteAddedSpecs;
4
+ // @implements A-SPEC-254.2
5
+ /**
6
+ * The merge-time half of REQ-254: which spec documents did each remote-tracking ref ADD since this
7
+ * checkout and that ref parted?
8
+ *
9
+ * `detectIdCollisions` shipped with doctor walking `.ax/specs` alone, so a number another machine
10
+ * took stayed invisible until git refused the push. Measured 2026-09-20: the Mac Studio committed
11
+ * REQ-694 at 11:05, a Windows checkout allocated REQ-694 at 11:18, neither could see the other, and
12
+ * it surfaced as an add/add conflict on `.ax/specs/01_req/REQ-694.md`. What was missing was
13
+ * collection, not judgement — so this module collects and judges nothing.
14
+ *
15
+ * WHY "ADDED SINCE THE MERGE-BASE" AND NOT "SAME ID, DIFFERENT CONTENT". The same spec edited on one
16
+ * side (a re-seal after a correction, a body that was expanded) is ordinary divergence, and calling
17
+ * it a collision would cry wolf before every pull. A collision is two sides taking the same NEW
18
+ * number without seeing each other. A spec's path is derived from its number, so that set is exactly
19
+ * what git would report as an add/add conflict — asked before the merge instead of after it.
20
+ *
21
+ * NO NETWORK. Remote-tracking refs are read as they stand; `fetch` is the caller's business.
22
+ * Detection tied to the network cannot prepare a merge offline (REQ-254: local-first).
23
+ *
24
+ * READ-ONLY. The only git subcommands are for-each-ref, merge-base, diff, ls-tree and cat-file.
25
+ */
26
+ const node_child_process_1 = require("node:child_process");
27
+ const spec_parser_1 = require("./spec-parser");
28
+ const id_collision_1 = require("./id-collision");
29
+ const root_1 = require("../project/root");
30
+ const SPECS = '.ax/specs';
31
+ const defaultGit = (root, args, input) => (0, node_child_process_1.execFileSync)('git', ['-C', root, ...args], {
32
+ input, stdio: ['pipe', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(process.env), maxBuffer: 256 * 1024 * 1024,
33
+ });
34
+ const lines = (buf) => buf.toString('utf8').split('\n').map((l) => l.trim()).filter((l) => l !== '');
35
+ const storeRelative = (repoPath) => repoPath.slice(SPECS.length + 1);
36
+ /**
37
+ * Parse `git cat-file --batch` output: `<sha> <type> <size>\n<bytes>\n` per object, or
38
+ * `<name> missing\n`. Sizes are BYTES, so this walks the Buffer — a string walk would drift on the
39
+ * first multi-byte character, and these documents are mostly Korean.
40
+ */
41
+ function readBatch(out, count) {
42
+ const blobs = [];
43
+ let at = 0;
44
+ for (let i = 0; i < count && at < out.length; i++) {
45
+ const eol = out.indexOf(0x0a, at);
46
+ if (eol === -1)
47
+ break;
48
+ const header = out.subarray(at, eol).toString('utf8');
49
+ at = eol + 1;
50
+ if (header.endsWith(' missing')) {
51
+ blobs.push(null);
52
+ continue;
53
+ }
54
+ const size = Number(header.split(' ')[2]);
55
+ if (!Number.isFinite(size)) {
56
+ blobs.push(null);
57
+ continue;
58
+ }
59
+ blobs.push(out.subarray(at, at + size));
60
+ at += size + 1;
61
+ }
62
+ while (blobs.length < count)
63
+ blobs.push(null);
64
+ return blobs;
65
+ }
66
+ function collectRemoteAddedSpecs(root, git = defaultGit) {
67
+ const result = { refs: [], entries: [], baseFiles: new Set(), skipped: 0 };
68
+ // `--show-prefix` also tells us where the workspace sits inside the repository. A workspace that is
69
+ // not the repository root would need its paths re-based; that shape is reported, not guessed at.
70
+ let prefix;
71
+ try {
72
+ prefix = git(root, ['rev-parse', '--show-prefix']).toString('utf8').trim();
73
+ }
74
+ catch {
75
+ result.unavailable = 'not a git repository — nothing to compare against';
76
+ return result;
77
+ }
78
+ if (prefix !== '') {
79
+ result.unavailable = 'the workspace is not the repository root — nothing to compare against';
80
+ return result;
81
+ }
82
+ // `%(symref)` is non-empty for origin/HEAD: a symbolic ref names another ref, and counting it
83
+ // would read every document twice and inflate the ref count a PASS reports.
84
+ const refs = lines(git(root, ['for-each-ref', '--format=%(refname:short)%09%(symref)', 'refs/remotes']))
85
+ .map((l) => l.split('\t')).filter(([, symref]) => !symref).map(([name]) => name).sort();
86
+ if (refs.length === 0) {
87
+ result.unavailable = 'no remote-tracking refs — nothing to compare against';
88
+ return result;
89
+ }
90
+ const seenBlob = new Set();
91
+ for (const ref of refs) {
92
+ let base;
93
+ try {
94
+ base = git(root, ['merge-base', 'HEAD', ref]).toString('utf8').trim();
95
+ }
96
+ catch {
97
+ result.skipped++;
98
+ continue;
99
+ } // unrelated history, or no HEAD yet
100
+ if (base === '') {
101
+ result.skipped++;
102
+ continue;
103
+ }
104
+ result.refs.push(ref);
105
+ for (const p of lines(git(root, ['ls-tree', '-r', '--name-only', base, '--', SPECS]))) {
106
+ if (p.endsWith('.md'))
107
+ result.baseFiles.add(storeRelative(p));
108
+ }
109
+ const added = lines(git(root, ['diff', '--name-only', '--diff-filter=A', '--no-renames', base, ref, '--', SPECS]))
110
+ .filter((p) => p.endsWith('.md'));
111
+ if (added.length === 0)
112
+ continue;
113
+ // ONE process per ref, not one per document.
114
+ const blobs = readBatch(git(root, ['cat-file', '--batch'], added.map((p) => `${ref}:${p}`).join('\n') + '\n'), added.length);
115
+ added.forEach((repoPath, i) => {
116
+ const blob = blobs[i];
117
+ if (blob === null) {
118
+ result.skipped++;
119
+ return;
120
+ }
121
+ try {
122
+ // Folded exactly where doctor's local walker folds (BOM stripped, CRLF → LF), so a CRLF
123
+ // working-tree copy and the LF blob of the same document get the same key.
124
+ const folded = blob.toString('utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
125
+ const spec = (0, spec_parser_1.parseSpec)(folded);
126
+ if (!spec.id) {
127
+ result.skipped++;
128
+ return;
129
+ }
130
+ const contentDigest = (0, id_collision_1.collisionKeyOf)(folded, spec);
131
+ const dedupe = `${repoPath}\u0000${contentDigest}`;
132
+ if (seenBlob.has(dedupe))
133
+ return; // the same document reached through two refs
134
+ seenBlob.add(dedupe);
135
+ result.entries.push({
136
+ file: `${ref}:${storeRelative(repoPath)}`,
137
+ id: spec.id,
138
+ approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
139
+ contentDigest,
140
+ });
141
+ }
142
+ catch {
143
+ result.skipped++;
144
+ }
145
+ });
146
+ }
147
+ return result;
148
+ }
@@ -126,5 +126,15 @@ export declare function readSourcesForRenumber(projectRoot: string): {
126
126
  file: string;
127
127
  text: string;
128
128
  }[];
129
- /** Spec documents as the planner needs them, with store-relative POSIX paths. */
130
- export declare function readSpecsForRenumber(specsRoot: string): RenumberSpec[];
129
+ /**
130
+ * Spec documents as the planner needs them, with store-relative POSIX paths.
131
+ *
132
+ * @implements A-SPEC-699 — `unreadable` exists because the absence of it cost a day. A `.md` whose
133
+ * frontmatter would not parse used to be skipped by a bare `continue`, so "this store holds no
134
+ * specs" and "I could not read any of these specs" reached the caller as the same answer. They are
135
+ * different facts and the caller has to be able to tell them apart.
136
+ */
137
+ export declare function readSpecsForRenumber(specsRoot: string): {
138
+ specs: RenumberSpec[];
139
+ unreadable: string[];
140
+ };