@holmes-lab/holmes-kit 0.26.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,69 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.26.1] - 2026-09-20
9
+
10
+ Three judgements that were computed and reached nobody now reach a person, and a spec number another
11
+ machine took is seen before the merge instead of after the conflict. Work from two machines: the
12
+ Windows checkout found that the renumber tool had been dead there, and lost a slice to the collision
13
+ that tool exists to repair.
14
+
15
+ ### Added
16
+ - **A spec id another workspace took is seen BEFORE the merge** (A-SPEC-254.2, A-SPEC-700, A-SPEC-700.1).
17
+ `doctor --target` gains `spec id collision with remote refs`. Measured 2026-09-20: two machines
18
+ allocated `REQ-694` thirteen minutes apart, neither could see the other, and it surfaced only as an
19
+ add/add conflict after the push was refused — costing a rebuilt slice and five out-of-band
20
+ approvals. The check reads each remote-tracking ref **as last fetched** (no network; `fetch` stays
21
+ yours) and judges only documents each side **added** since they parted: a spec one side merely
22
+ *edited* is ordinary divergence, not a collision, so this does not cry wolf before every pull. On a
23
+ collision the advice is the plan, not a shrug —
24
+ `Before merging — move 901 → 903: spec_renumber(oldBase=901, newBase=903); then re-seal …`.
25
+ The side that moves is the one **not published yet** (a public number may already be cited), and
26
+ the destination is free on **both** sides. "Published" is judged by equal content, never by path:
27
+ a spec's path is derived from its number, so a colliding number is on the remote by definition.
28
+ No git, or no remote, is a PASS that says so.
29
+ - **`architecture.cycleIgnore` in `ax.config.json`** (A-SPEC-694). The named exception the cycle
30
+ ratchet promised in 0.19 finally has a place to be written: path prefixes whose cycles are not the
31
+ project's to fix (vendored trees). A cycle is excepted only when **every** member lies under one;
32
+ one that straddles project code is still reported. `reference` does not pardon `reference-impl/`,
33
+ a prefix meaning "everything" is dropped, and a broken config excepts nothing. Excepted cycles stay
34
+ in the observation ledger, counted.
35
+ - **The release gate says how far the GitHub README has fallen behind** (A-SPEC-696). Syncing the
36
+ README that npm's `homepage` points at is a playbook step that runs *after* the publish, so no gate
37
+ could see it — and measured 2026-09-20 it had been skipped for seventeen README-changing releases.
38
+ The *next* release's gate now compares the remote README with each release tag's and reports
39
+ `matches v0.19.3 — 17 release(s) with README changes behind`, `diverged`, or `could not check (…)`.
40
+ Report only: nothing in that block can refuse a release.
41
+
42
+ ### Fixed
43
+ - **The cycle ratchet had never spoken to anyone** (A-SPEC-694). Since the commit that introduced it,
44
+ the Stop hook computed the code cycles, wrote them to the observation ledger, and then called the
45
+ verdict **without them** — so the `ART-2` tracked line was dead code in the product, and 0.26.0's
46
+ widening of the ratchet to eight language families landed on the same dead path. Every unit test
47
+ passed because each handed the evidence in directly; the RED for this fix runs the *built hook as a
48
+ process*. The line is now emitted, bounded (five cycles, five members each, the rest **counted**):
49
+ measured here, one vendored cycle has 46 members. Still non-blocking — the hook supplies `track`.
50
+ - **`npm publish --dry-run` could not pass the release gate** (A-SPEC-695). npm exports
51
+ `npm_config_dry_run=true` to `prepublishOnly`; the gate handed it to jest; and the suite's own
52
+ `npx -p <tarball>` then installed nothing, so a green tree was refused as "not publishable". The
53
+ evidence environment now drops that one key — named, not a `npm_config_*` sweep, which would take
54
+ the registry and proxy settings with it.
55
+ - **`spec_renumber` was dead on Windows** (A-SPEC-699). The frontmatter fence and the `depends_on`
56
+ block were LF-only and `core.autocrlf=true` makes every spec CRLF, so the tool read **zero** specs
57
+ and reported "nothing to move" — indistinguishable from an empty store. It now reads them, and its
58
+ refusal carries how many it could not read.
59
+ - doctor's id-collision advice still read "manual until REQ-255" after REQ-255 had shipped; it names
60
+ `spec_renumber` now (A-SPEC-700.1).
61
+
62
+ ### Changed
63
+ - Two Windows reds were the tests' own defects, not the product's (A-SPEC-697, A-SPEC-698): an
64
+ overlap fixture matched a `/`-joined suffix against a `path.join` path, so the refusal it exists to
65
+ assert had never been exercised on win32; and a supervisor teardown now retries the directory
66
+ removal Windows briefly holds after the owning process is gone.
67
+ - A-SPEC-692's `breaking_change` said "tests only" while its own Files to Touch listed three product
68
+ handlers; corrected to `gate-behavior` and re-sealed. A-SPEC-700's chain was corrected to describe
69
+ a planner over the existing detector rather than a second detector.
70
+
8
71
  ## [0.26.0] - 2026-09-20
9
72
 
10
73
  Architecture you declare is now judged in every language the graph can follow, and the verdict
package/README.md CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 🔢 **A spec number another machine took is seen before the merge** *(new in 0.26.1)*: two checkouts allocated `REQ-694` thirteen minutes apart, neither could see the other, and it surfaced only as an add/add conflict after the push was refused — a rebuilt slice and five out-of-band approvals. `doctor --target` now reads each remote-tracking ref **as last fetched** (no network) and judges only the documents each side *added* since they parted, so a spec one side merely edited is divergence, not an alarm. On a collision the advice is the plan: `Before merging — move 901 → 903: spec_renumber(oldBase=901, newBase=903); then re-seal`. The side that moves is the one not published yet, the destination is free on both sides, and "published" means equal content — never path, because a spec's path is its number.
20
+ - 🔁 **The cycle ratchet finally speaks — and takes a named exception** *(new in 0.26.1)*: since the commit that introduced it, the Stop hook computed the code cycles and called the verdict without them, so the `ART-2` line had never been printed; every unit test passed because each handed the evidence in directly. It is emitted now, bounded (five cycles, five members each, the rest counted — one vendored cycle here has 46 members), and `ax.config.json` gains `architecture.cycleIgnore`: prefixes for trees that are not yours to fix. A cycle is excepted only when every member lies under one, `reference` does not pardon `reference-impl/`, a broken config excepts nothing, and excepted cycles stay counted in the ledger.
19
21
  - 🧬 **Declare your architecture, in every language the graph can follow** *(new in 0.26.0)*: a C-SPEC's `## Forbidden Edges` takes a third rule kind beside `import` and `call` — `- inherits src/app -x-> ConcreteBase`, an exact match enforced at the pre-edit gate. Nothing is inferred: the rule reads the graph's `inherits` edges and never asks which class is "abstract", which is what makes it hold in Go, where interface satisfaction cannot be inferred at all. A project that already breaks the rule it wants adopts it with a **baseline that expires** — `- allow until 2026-09-27 : inherits src/legacy/a.ts -> LegacyBase`, exact on kind, file and target, never a prefix. Past its date the allowance still holds, because an expiry that blocked would turn the rule into a barrier to adoption; it is **reported** instead, carrying `(reconfirmed n since <date>)` so a twelfth renewal cannot hide.
20
22
  - 🏛️ **The architecture verdict reaches the person closing the turn** *(new in 0.26.0)*: the scan-wide judgement existed and nothing in the product called it — measured, zero consumers outside the tests — so an expired baseline was neither blocked nor seen, and a time-boxed allowance could not be told from a permanent pardon. The Stop hook now prints `ARCH` lines on the non-blocking `tracked` channel: violations no baseline covers, expired baselines with their renewal count, and **which rule kind could not be judged in which extension** (`inherits rules NOT judged in: .js, .py, .tsx`) — asked per kind, over the files a rule of that kind actually judges, because a pooled answer called a language *judged* for a rule it could not see. No rule declared, nothing said and nothing paid: the judgement shares the one scan the cycle ratchet already makes (measured here: scan 6.6 s, judgement 0.14 s).
21
23
  - ♻️ **The cycle ratchet judges every language it can resolve** *(new in 0.26.0)*: the Stop hook had re-implemented import resolution beside the graph builder's — relative specifiers only, four extensions. Measured on fixtures shaped the way each language requires: 16 import edges, 2 survived; 9 planted cycles, 1 found. It cost nothing in an all-TypeScript tree (919 edges either way), which is how it survived to 0.25, and everything in a Java, Go, Python or Rust consumer. There is now **one** resolver, consumed by both; a language whose specifiers do not resolve stays `unavailable` rather than reading as clean, and a wide Go package cycle is capped per cycle with the dropped members counted.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- a53bfcd1-mu95v98e
1
+ 256f1396-mu9du216
@@ -754,10 +754,12 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
754
754
  // (id count) and stays untouched. OWN try/catch (round-1): sharing the block above meant an
755
755
  // unrelated upstream throw silently erased these checks — a failure here must surface as WARN,
756
756
  // never as a vanished line.
757
+ // @implements A-SPEC-254.2 — the local walk's entries, kept for the remote-ref check below. `undefined`
758
+ // means the walk itself failed, and then that check says so rather than judging an empty store.
759
+ let localIdEntries;
757
760
  try {
758
- const { detectIdCollisions } = require('../spec/id-collision');
761
+ const { detectIdCollisions, collisionKeyOf } = require('../spec/id-collision');
759
762
  const { parseSpec } = require('../spec/spec-parser');
760
- const crypto = require('node:crypto');
761
763
  const specsRoot = path.join(target, '.ax', 'specs');
762
764
  const entries = [];
763
765
  let skipped = 0; // would-be specs present but not judged — a PASS must name its reduced scope (round-1)
@@ -769,13 +771,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
769
771
  // (mirrors parseSpec), per-line trailing whitespace and blank lines dropped (formatting is not
770
772
  // substance for an advisory WARN), plus the substantive frontmatter (type/title/dependsOn) via
771
773
  // JSON array serialization — no field aliasing. The SEAL digest is untouched, as before.
772
- const collisionKey = (folded, spec) => {
773
- const fence = /^---\n[\s\S]*?\n---\n?/.exec(folded);
774
- const body = (fence ? folded.slice(fence[0].length) : folded)
775
- .split('\n').map((l) => l.trimEnd()).filter((l) => l !== '').join('\n');
776
- return 'sha256:' + crypto.createHash('sha256')
777
- .update(JSON.stringify([spec.type, spec.title, [...spec.dependsOn].sort(), body])).digest('hex');
778
- };
774
+ // @implements A-SPEC-254.2 that key now lives in `id-collision.ts` as `collisionKeyOf`, because the
775
+ // remote-ref collector below must produce the SAME key for the same document. It was moved, not
776
+ // rewritten: the round-1..3 reasoning above still describes it.
777
+ const collisionKey = collisionKeyOf;
779
778
  // Symlinked directories are FOLLOWED (round-2): `Dirent.isDirectory()` is false for a dir
780
779
  // symlink, so specs behind one silently left the judgment with skipped=0 — an unqualified PASS
781
780
  // over an unexamined store. The realpath visited-set breaks symlink cycles. An UNREADABLE or
@@ -880,6 +879,7 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
880
879
  }
881
880
  };
882
881
  collect(specsRoot);
882
+ localIdEntries = entries;
883
883
  // No cross-reference to `spec readability` (round-3): that walker does not follow dir
884
884
  // symlinks, so the pointer was false for symlink-reached files — doctor contradicting itself.
885
885
  const scopeNote = skipped > 0 ? ` (${skipped} outside the verdict)` : '';
@@ -889,7 +889,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
889
889
  add('spec id preemption collision', collisions.length === 0 ? 'PASS' : 'WARN', collisions.length === 0
890
890
  ? `No id is claimed by two different specs${scopeNote}`
891
891
  : collisions.map((c) => `${c.id}: ${c.files.join(' ↔ ')} (${c.detail})`).join('; ') + scopeNote, collisions.length === 0 ? undefined
892
- : 'Two specs preempted the same number renumber one (manual until REQ-255) and re-seal.');
892
+ // @implements A-SPEC-700.1 this line still told people to renumber BY HAND "until REQ-255" long after
893
+ // REQ-255 shipped, sending people to handwork the tool replaces. No plan is offered HERE:
894
+ // both copies sit in one store, so there is no published/unpublished side to choose by.
895
+ : 'Two specs preempted the same number — move one family with spec_renumber (dry-run first), then re-seal.');
893
896
  add('spec id family coexistence', families.length === 0 ? 'PASS' : 'WARN', families.length === 0
894
897
  ? `No bare id coexists with its dot-suffix family${scopeNote}`
895
898
  : families.map((f) => `${f.id}: ${f.files.join(', ')}`).join('; ') + scopeNote, families.length === 0 ? undefined
@@ -903,6 +906,46 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
903
906
  add('spec id preemption collision', 'WARN', `could not run the check: ${msg}`);
904
907
  add('spec id family coexistence', 'WARN', `could not run the check: ${msg}`);
905
908
  }
909
+ // @implements A-SPEC-254.2 — the MERGE-TIME half of REQ-254. The walk above sees this checkout
910
+ // only, so a number another machine took stayed invisible until git refused the push (measured
911
+ // 2026-09-20: REQ-694 allocated twice, thirteen minutes apart, surfacing as an add/add conflict).
912
+ // Judged: documents each side ADDED since it parted from a remote-tracking ref — an EDITED spec
913
+ // is ordinary divergence, not a collision. Refs are read as they stand (no network; `fetch` is
914
+ // the caller's), the PASS names the refs it looked at so a stale ref cannot read as "no collision
915
+ // with the remote", and no git / no remote is a PASS with its reason (REQ-128). OWN try, for the
916
+ // round-1 reason: a failure here must not erase the two lines above.
917
+ try {
918
+ if (localIdEntries === undefined)
919
+ throw new Error('the local spec walk did not complete');
920
+ const { detectIdCollisions } = require('../spec/id-collision');
921
+ const { collectRemoteAddedSpecs } = require('../spec/remote-spec-refs');
922
+ const remote = collectRemoteAddedSpecs(target);
923
+ if (remote.unavailable !== undefined) {
924
+ add('spec id collision with remote refs', 'PASS', remote.unavailable);
925
+ }
926
+ else {
927
+ const addedLocally = localIdEntries.filter((e) => !remote.baseFiles.has(e.file));
928
+ const found = detectIdCollisions([...addedLocally, ...remote.entries]).filter((i) => i.kind === 'id-collision');
929
+ // @implements A-SPEC-700.1 — the planner's first caller. WHICH number moves (the side not
930
+ // published yet) and WHERE (free on both sides) was decided by hand on 2026-09-20; it is a rule.
931
+ const { planIdReconcile, reconcileInputsFrom, reconcileAdvice } = require('../spec/id-collision');
932
+ const advice = found.length === 0 ? '' : reconcileAdvice(planIdReconcile(reconcileInputsFrom({ issues: found, local: localIdEntries, addedLocally, remote })));
933
+ // Names, bounded: a repository with dozens of stale branches must not turn one check into a
934
+ // paragraph. The count is always exact; the names are the first few, sorted.
935
+ const named = remote.refs.slice(0, 3).join(', ') + (remote.refs.length > 3 ? ` +${remote.refs.length - 3} more` : '');
936
+ const scope = ` (compared with ${remote.refs.length} remote-tracking ref(s) as last fetched: ${named || 'none comparable'}`
937
+ + `${remote.skipped > 0 ? `; ${remote.skipped} unreadable` : ''})`;
938
+ add('spec id collision with remote refs', found.length === 0 ? 'PASS' : 'WARN', found.length === 0
939
+ ? `No id added here was also added on a remote-tracking ref${scope}`
940
+ : found.map((c) => `${c.id}: ${c.files.join(' ↔ ')}`).join('; ') + scope, found.length === 0 ? undefined
941
+ : (advice !== '' ? `Before merging — ${advice}.`
942
+ : 'Another workspace took the same number. Move the side that is NOT published yet with spec_renumber BEFORE merging, then re-seal.'));
943
+ }
944
+ }
945
+ catch (e) {
946
+ const msg = String(e instanceof Error ? e.message : e).split('\n')[0];
947
+ add('spec id collision with remote refs', 'WARN', `could not run the check: ${msg}`);
948
+ }
906
949
  }
907
950
  // @implements A-SPEC-176
908
951
  // `list()` drops any file it cannot parse, which is correct for a gate but leaves the author
@@ -25,3 +25,30 @@ export interface ProfileDriftInput {
25
25
  * is why the "current document" case is tested — a check that always speaks is not a check.
26
26
  */
27
27
  export declare function profileDriftFindings(input: ProfileDriftInput): string[];
28
+ /**
29
+ * @implements A-SPEC-696
30
+ * Which release is the GitHub README a copy of — and how many README-changing releases has it missed?
31
+ *
32
+ * Step 6 of the publish playbook (sync the README npm's `homepage` points at) is prose, and it runs
33
+ * AFTER the publish, so that release's own gate can never check it. Measured 2026-09-20: the remote
34
+ * README was byte-identical to v0.19.0 and v0.19.3 while six release lines shipped, and nothing
35
+ * said so. The NEXT release's gate can see it: had the last sync happened, the remote would equal
36
+ * the latest tag's README.
37
+ *
38
+ * Pure — the caller does the network and the git. One sentence or `null`; `null` means there is
39
+ * nothing a person needs to do. A remote that could not be read is NEVER `null`: silence would read
40
+ * as "in step", which is exactly the silence six releases passed through.
41
+ *
42
+ * Byte comparison after folding line endings, and nothing cleverer. "Mostly the same" is not judged:
43
+ * a remote that matches no tag is reported as DIVERGED, not as behind, because the natural response
44
+ * to "behind" is an overwrite and someone may have edited the page on GitHub.
45
+ */
46
+ export declare function externalReadmeLag(input: {
47
+ remote: string | null;
48
+ reason?: string;
49
+ /** Release tags with their README, OLDEST FIRST. */
50
+ tagged: ReadonlyArray<{
51
+ tag: string;
52
+ readme: string;
53
+ }>;
54
+ }): string | null;
@@ -11,6 +11,7 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.repoTargetFrom = repoTargetFrom;
13
13
  exports.profileDriftFindings = profileDriftFindings;
14
+ exports.externalReadmeLag = externalReadmeLag;
14
15
  /**
15
16
  * The GitHub target this project publishes to, read from `package.json`.
16
17
  *
@@ -66,3 +67,47 @@ function isShipped(version) {
66
67
  return false;
67
68
  return Number(m[1]) > 0 || Number(m[2]) > 0;
68
69
  }
70
+ /**
71
+ * @implements A-SPEC-696
72
+ * Which release is the GitHub README a copy of — and how many README-changing releases has it missed?
73
+ *
74
+ * Step 6 of the publish playbook (sync the README npm's `homepage` points at) is prose, and it runs
75
+ * AFTER the publish, so that release's own gate can never check it. Measured 2026-09-20: the remote
76
+ * README was byte-identical to v0.19.0 and v0.19.3 while six release lines shipped, and nothing
77
+ * said so. The NEXT release's gate can see it: had the last sync happened, the remote would equal
78
+ * the latest tag's README.
79
+ *
80
+ * Pure — the caller does the network and the git. One sentence or `null`; `null` means there is
81
+ * nothing a person needs to do. A remote that could not be read is NEVER `null`: silence would read
82
+ * as "in step", which is exactly the silence six releases passed through.
83
+ *
84
+ * Byte comparison after folding line endings, and nothing cleverer. "Mostly the same" is not judged:
85
+ * a remote that matches no tag is reported as DIVERGED, not as behind, because the natural response
86
+ * to "behind" is an overwrite and someone may have edited the page on GitHub.
87
+ */
88
+ function externalReadmeLag(input) {
89
+ if (input.remote === null)
90
+ return `could not check the GitHub README (${input.reason ?? 'unknown'})`;
91
+ const tagged = input.tagged ?? [];
92
+ if (tagged.length === 0)
93
+ return null;
94
+ // A CRLF working tree and the LF copy GitHub serves are one document (the lesson A-SPEC-692 left).
95
+ const fold = (text) => String(text).replace(/\r\n/g, '\n');
96
+ const remote = fold(input.remote);
97
+ // The NEWEST matching tag: v0.19.0 and v0.19.3 shared a README, and naming the older one would
98
+ // overstate the lag.
99
+ let at = -1;
100
+ for (let i = 0; i < tagged.length; i++)
101
+ if (fold(tagged[i].readme) === remote)
102
+ at = i;
103
+ if (at === -1)
104
+ return 'GitHub README matches no release tag — diverged, or edited on GitHub';
105
+ if (at === tagged.length - 1)
106
+ return null;
107
+ // Only releases that CHANGED the README count. A test-only patch release moves nothing a reader
108
+ // sees, and counting it would warn when there is nothing to sync.
109
+ const missed = tagged.slice(at + 1).filter((t) => fold(t.readme) !== remote).length;
110
+ if (missed === 0)
111
+ return null;
112
+ return `GitHub README matches ${tagged[at].tag} — ${missed} release(s) with README changes behind`;
113
+ }
@@ -25,6 +25,17 @@ export interface AxConfig {
25
25
  storage: {
26
26
  specStore: string;
27
27
  };
28
+ architecture: {
29
+ /**
30
+ * @implements A-SPEC-694
31
+ * Areas whose code cycles are a NAMED EXCEPTION to the ART-2 ratchet — vendored trees the project
32
+ * cannot fix. A cycle is excepted only when EVERY member lies under one of these prefixes; one
33
+ * that straddles project code is still reported. Excepted cycles stay in the observation ledger,
34
+ * counted. It lives here, in a root file whose change passes a human, because an agent that
35
+ * could pardon its own violations would make the exception worthless.
36
+ */
37
+ cycleIgnore: string[];
38
+ };
28
39
  }
29
40
  export declare const DEFAULT_CONFIG: AxConfig;
30
41
  export declare function loadConfig(root: string): AxConfig;
@@ -50,6 +50,7 @@ exports.DEFAULT_CONFIG = {
50
50
  guardrail: { enforcement: 'block', enforceHighRisk: true, overrideRequiresAdr: true, preEditEvidence: 'warn', redFirstEvidence: 'track' },
51
51
  highRiskDomains: ['@auth', '@payment'],
52
52
  storage: { specStore: 'local-markdown' },
53
+ architecture: { cycleIgnore: [] },
53
54
  };
54
55
  function deepMerge(base, ov) {
55
56
  if (ov == null || typeof ov !== 'object')
@@ -69,7 +70,16 @@ function loadConfig(root) {
69
70
  // Fall back to the strict DEFAULT_CONFIG (enforcement:'block') so a broken file cannot silently
70
71
  // weaken or disable governance — a bad config degrades to the safest posture, not to an exception.
71
72
  try {
72
- return structuredClone(deepMerge(exports.DEFAULT_CONFIG, JSON.parse(fs.readFileSync(f, 'utf8'))));
73
+ const merged = structuredClone(deepMerge(exports.DEFAULT_CONFIG, JSON.parse(fs.readFileSync(f, 'utf8'))));
74
+ // @implements A-SPEC-694 — `deepMerge` copies a non-object override as it arrived, so
75
+ // `"architecture": "x"` or `"cycleIgnore": "reference"` would reach the ratchet as written. An
76
+ // exception list must never fail TOWARD a wider exception: anything that is not an array of
77
+ // strings reads as no exception at all.
78
+ const declared = merged.architecture?.cycleIgnore;
79
+ merged.architecture = {
80
+ cycleIgnore: Array.isArray(declared) ? declared.filter((x) => typeof x === 'string') : [],
81
+ };
82
+ return merged;
73
83
  }
74
84
  catch {
75
85
  return structuredClone(exports.DEFAULT_CONFIG);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The two ends around a judgement that was already right.
3
+ *
4
+ * `cycleRatchetViolations(current, allowed)` has honoured a named exception since A-SPEC-574.4, and
5
+ * the Stop hook has supplied `allowed: []` since the same day — there was nowhere to name one. And
6
+ * what the judgement found had no bounded way to be said: `trackedLines` joins every detail of an
7
+ * article into ONE line, and measured 2026-09-20 this repository carries a 46-member cycle inside a
8
+ * vendored tree. Wiring the evidence without these two would have printed thousands of characters a
9
+ * turn about code nobody here can change — "a rule that blocks adoption is not a rule, it is a
10
+ * barrier" (REQ-574), in its observational form.
11
+ *
12
+ * Pure, and it judges nothing: one function fills the list the judgement reads, the other renders
13
+ * what the judgement returned. The caps bound RENDERING only — no verdict reads them (REQ-569 S5).
14
+ */
15
+ import { type Cycle, type CycleViolation } from './cycle-detect';
16
+ /** How many cycles one ART-2 line names before it counts the rest. */
17
+ export declare const CYCLE_LINE_CAP = 5;
18
+ /** How many members of one cycle are named before the rest are counted. The ledger keeps them all. */
19
+ export declare const CYCLE_LINE_MEMBERS = 5;
20
+ /**
21
+ * The keys of the cycles that lie ENTIRELY inside ignored areas — what the hook supplies as `allowed`.
22
+ *
23
+ * Entirely, because a cycle that straddles project code and a vendored tree is the project's to
24
+ * fix: if one ignored member were enough, the declaration would become a way to hide the project's
25
+ * own cycles behind a neighbour.
26
+ */
27
+ export declare function ignoredCycleKeys(cycles: readonly Cycle[], prefixes: readonly string[]): string[];
28
+ /**
29
+ * The ratchet's violations as `tracked` details of bounded length.
30
+ *
31
+ * The wording is A-SPEC-574.4's (`code import cycle: a -> b -> a`), kept so nothing that reads it
32
+ * has to change. What is folded is COUNTED, per cycle and across cycles: a truncation that does not
33
+ * announce itself reads as the whole list. The full membership is in the observation ledger.
34
+ */
35
+ export declare function cycleStatusDetails(violations: readonly CycleViolation[], scopeSuffix: string): string[];
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CYCLE_LINE_MEMBERS = exports.CYCLE_LINE_CAP = void 0;
4
+ exports.ignoredCycleKeys = ignoredCycleKeys;
5
+ exports.cycleStatusDetails = cycleStatusDetails;
6
+ // @implements A-SPEC-694
7
+ /**
8
+ * The two ends around a judgement that was already right.
9
+ *
10
+ * `cycleRatchetViolations(current, allowed)` has honoured a named exception since A-SPEC-574.4, and
11
+ * the Stop hook has supplied `allowed: []` since the same day — there was nowhere to name one. And
12
+ * what the judgement found had no bounded way to be said: `trackedLines` joins every detail of an
13
+ * article into ONE line, and measured 2026-09-20 this repository carries a 46-member cycle inside a
14
+ * vendored tree. Wiring the evidence without these two would have printed thousands of characters a
15
+ * turn about code nobody here can change — "a rule that blocks adoption is not a rule, it is a
16
+ * barrier" (REQ-574), in its observational form.
17
+ *
18
+ * Pure, and it judges nothing: one function fills the list the judgement reads, the other renders
19
+ * what the judgement returned. The caps bound RENDERING only — no verdict reads them (REQ-569 S5).
20
+ */
21
+ const cycle_detect_1 = require("./cycle-detect");
22
+ /** How many cycles one ART-2 line names before it counts the rest. */
23
+ exports.CYCLE_LINE_CAP = 5;
24
+ /** How many members of one cycle are named before the rest are counted. The ledger keeps them all. */
25
+ exports.CYCLE_LINE_MEMBERS = 5;
26
+ /**
27
+ * A prefix as the scanner spells paths: forward slashes, no leading `./`, no trailing `/`.
28
+ *
29
+ * A prefix that normalises to nothing (`''`, `.`, `/`, `./`) means "everything" and is DROPPED. One
30
+ * line that switches the whole ratchet off is not an exception, it is a switch, and a switch is a
31
+ * different decision from the one this field records.
32
+ */
33
+ function normalisePrefix(raw) {
34
+ if (typeof raw !== 'string')
35
+ return null;
36
+ const p = raw.replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '').replace(/^\/+/, '');
37
+ return p === '' || p === '.' ? null : p;
38
+ }
39
+ /** At a path boundary only — `reference` must not pardon `reference-impl/` (shape is not location). */
40
+ const under = (file, prefix) => file === prefix || file.startsWith(`${prefix}/`);
41
+ /**
42
+ * The keys of the cycles that lie ENTIRELY inside ignored areas — what the hook supplies as `allowed`.
43
+ *
44
+ * Entirely, because a cycle that straddles project code and a vendored tree is the project's to
45
+ * fix: if one ignored member were enough, the declaration would become a way to hide the project's
46
+ * own cycles behind a neighbour.
47
+ */
48
+ function ignoredCycleKeys(cycles, prefixes) {
49
+ const areas = (Array.isArray(prefixes) ? prefixes : []).map(normalisePrefix).filter((p) => p !== null);
50
+ if (areas.length === 0)
51
+ return [];
52
+ return cycles
53
+ .filter((c) => c.files.length > 0 && c.files.every((f) => areas.some((a) => under(f, a))))
54
+ .map((c) => (0, cycle_detect_1.cycleKey)(c.files));
55
+ }
56
+ /**
57
+ * The ratchet's violations as `tracked` details of bounded length.
58
+ *
59
+ * The wording is A-SPEC-574.4's (`code import cycle: a -> b -> a`), kept so nothing that reads it
60
+ * has to change. What is folded is COUNTED, per cycle and across cycles: a truncation that does not
61
+ * announce itself reads as the whole list. The full membership is in the observation ledger.
62
+ */
63
+ function cycleStatusDetails(violations, scopeSuffix) {
64
+ const details = violations.slice(0, exports.CYCLE_LINE_CAP).map((v) => {
65
+ const shown = v.files.slice(0, exports.CYCLE_LINE_MEMBERS);
66
+ const folded = v.files.length - shown.length;
67
+ const more = folded > 0 ? ` -> (+${folded} more)` : '';
68
+ return `code import cycle: ${shown.join(' -> ')}${more} -> ${v.files[0]}${scopeSuffix}`;
69
+ });
70
+ const foldedCycles = violations.length - exports.CYCLE_LINE_CAP;
71
+ if (foldedCycles > 0)
72
+ details.push(`… and ${foldedCycles} more import cycle(s)${scopeSuffix}`);
73
+ return details;
74
+ }
@@ -92,6 +92,7 @@ const dist_freshness_1 = require("../project/dist-freshness");
92
92
  const analysis_currency_1 = require("../project/analysis-currency");
93
93
  const forbidden_edges_1 = require("../cpg/forbidden-edges");
94
94
  const forbidden_edge_report_1 = require("../cpg/forbidden-edge-report");
95
+ const cycle_report_1 = require("../cpg/cycle-report");
95
96
  // @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
96
97
  // (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
97
98
  // judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
@@ -668,8 +669,10 @@ function evaluateStop(specs, evidence) {
668
669
  if (evidence?.cycles !== undefined && evidence.cycles.mode === 'track') {
669
670
  const scope = evidence.cycles.scope.unavailable.length > 0
670
671
  ? ` (NOT judged: ${evidence.cycles.scope.unavailable.join(', ')})` : '';
671
- const t = (0, cycle_detect_1.cycleRatchetViolations)(evidence.cycles.current, evidence.cycles.allowed)
672
- .map((x) => ({ article: 'ART-2', detail: `code ${x.detail}${scope}` }));
672
+ // @implements A-SPEC-694 rendered through `cycleStatusDetails`, which bounds the line: every
673
+ // detail of an article is joined into ONE line, and a 46-member cycle was measured in this tree.
674
+ const t = (0, cycle_report_1.cycleStatusDetails)((0, cycle_detect_1.cycleRatchetViolations)(evidence.cycles.current, evidence.cycles.allowed), scope)
675
+ .map((detail) => ({ article: 'ART-2', detail }));
673
676
  if (t.length)
674
677
  tracked = [...(tracked ?? []), ...t];
675
678
  }
@@ -1299,6 +1302,14 @@ if (require.main === module) {
1299
1302
  cycles = scanCodeCycles(root, sharedScan);
1300
1303
  if (cycles === undefined)
1301
1304
  throw new Error('cycle scan unavailable');
1305
+ // @implements A-SPEC-694 — the named exception A-SPEC-574.4 promised finally has a source.
1306
+ // A config that cannot be read excepts NOTHING: this list must never fail toward wider.
1307
+ try {
1308
+ cycles.allowed = (0, cycle_report_1.ignoredCycleKeys)(cycles.current, (0, config_1.loadConfig)(root).architecture.cycleIgnore);
1309
+ }
1310
+ catch {
1311
+ cycles.allowed = [];
1312
+ }
1302
1313
  // @implements A-SPEC-578.1 — record the observation the promotion criterion waits on.
1303
1314
  // EVERY run, including a clean one: a false-positive rate is violations over chances, and a
1304
1315
  // ledger that only speaks when something is wrong keeps the numerator and drops the
@@ -1426,7 +1437,11 @@ if (require.main === module) {
1426
1437
  catch {
1427
1438
  semantic = undefined;
1428
1439
  }
1429
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}), ...(analysis ? { analysis } : {}), ...(semantic ? { semantic } : {}), ...(forbiddenEdges ? { forbiddenEdges } : {}) });
1440
+ // @implements A-SPEC-694 `cycles` rides in the evidence below. It was computed and written
1441
+ // to the observation ledger from the founding commit on (071bbf0c) and never handed over, so
1442
+ // the ART-2 track branch and the constitution's strict branch were dead code in the product:
1443
+ // the ratchet had never spoken to anyone. The mode it carries is still the constant `track`.
1444
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}), ...(analysis ? { analysis } : {}), ...(semantic ? { semantic } : {}), ...(forbiddenEdges ? { forbiddenEdges } : {}), ...(cycles ? { cycles } : {}) });
1430
1445
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
1431
1446
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
1432
1447
  // @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
@@ -70,20 +70,22 @@ export declare function createSpecLifecycleHandlers(context: SpecLifecycleContex
70
70
  newBase: string;
71
71
  dryRun?: boolean;
72
72
  }): Promise<{
73
+ unreadable?: string[] | undefined;
74
+ ok: boolean;
75
+ reason: string;
76
+ dryRun?: undefined;
77
+ plan?: undefined;
78
+ movedSpecs?: undefined;
79
+ } | {
73
80
  ok: boolean;
74
81
  dryRun: boolean;
75
82
  plan: import("../../spec/renumber").RenumberPlan;
76
- reason?: undefined;
77
83
  movedSpecs?: undefined;
78
84
  } | {
79
85
  ok: boolean;
80
86
  dryRun: boolean;
81
87
  movedSpecs: number;
82
88
  plan: import("../../spec/renumber").RenumberPlan;
83
- reason?: undefined;
84
- } | {
85
- ok: boolean;
86
- reason: string;
87
89
  }>;
88
90
  /**
89
91
  * @implements A-SPEC-538.1
@@ -181,13 +181,26 @@ function createSpecLifecycleHandlers(context) {
181
181
  }
182
182
  }
183
183
  const projectRoot = path.resolve(specsRoot, '..', '..');
184
+ const read = (0, renumber_1.readSpecsForRenumber)(specsRoot);
184
185
  const plan = (0, renumber_1.planRenumber)({
185
- specs: (0, renumber_1.readSpecsForRenumber)(specsRoot),
186
+ specs: read.specs,
186
187
  sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
187
188
  oldBase: String(a.oldBase), newBase: String(a.newBase),
188
189
  });
190
+ // @implements A-SPEC-699 — a refusal that cannot say whether the store was empty or merely
191
+ // unreadable sends the caller to the source. Measured 2026-09-20: this tool answered
192
+ // "nothing to move" while 300-odd specs sat in front of it, and the answer alone could not
193
+ // distinguish the two. Carry the counts, and name the files while there are few.
189
194
  if (plan.refusal)
190
- return { ok: false, reason: plan.refusal };
195
+ return {
196
+ ok: false,
197
+ reason: plan.refusal
198
+ + ` (읽은 스펙 ${read.specs.length}개`
199
+ + (read.unreadable.length > 0
200
+ ? `, 프론트매터를 읽지 못한 파일 ${read.unreadable.length}개: ${read.unreadable.slice(0, 5).join(', ')}${read.unreadable.length > 5 ? ' …' : ''})`
201
+ : ')'),
202
+ ...(read.unreadable.length > 0 ? { unreadable: read.unreadable } : {}),
203
+ };
191
204
  if (a.dryRun !== false)
192
205
  return { ok: true, dryRun: true, plan };
193
206
  // @implements A-SPEC-638 — in a registered workspace the raw publication is preceded by a
@@ -728,20 +728,22 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
728
728
  newBase: string;
729
729
  dryRun?: boolean;
730
730
  }) => Promise<{
731
+ unreadable?: string[] | undefined;
732
+ ok: boolean;
733
+ reason: string;
734
+ dryRun?: undefined;
735
+ plan?: undefined;
736
+ movedSpecs?: undefined;
737
+ } | {
731
738
  ok: boolean;
732
739
  dryRun: boolean;
733
740
  plan: import("../spec/renumber").RenumberPlan;
734
- reason?: undefined;
735
741
  movedSpecs?: undefined;
736
742
  } | {
737
743
  ok: boolean;
738
744
  dryRun: boolean;
739
745
  movedSpecs: number;
740
746
  plan: import("../spec/renumber").RenumberPlan;
741
- reason?: undefined;
742
- } | {
743
- ok: boolean;
744
- reason: string;
745
747
  }>;
746
748
  spec_unseal: (a: {
747
749
  root?: string;
@@ -86,7 +86,17 @@ function cleanSubprocessEnv(env = process.env) {
86
86
  // elicitation path — 9 elicitation tests red on a tree whose full suite was green, twice, and
87
87
  // identically on the previous commit. A posture is an approval channel, never a property of the code
88
88
  // under test; an evidence run must judge the same in an autonomous workspace and a human-gated one.
89
- const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE', 'HOLMES_AUTONOMOUS_APPROVAL']);
89
+ // @implements A-SPEC-695
90
+ // `NPM_CONFIG_DRY_RUN` is the fourth of the same family and the first that is not ours. Measured
91
+ // 2026-09-20: `npm publish --dry-run` — the command the publish playbook itself prescribes — exports
92
+ // `npm_config_dry_run=true` to `prepublishOnly`; the release gate handed it to jest; and the
93
+ // mcp-launcher suite's own `npx -p <tarball>` then installed NOTHING, so the handshake returned null
94
+ // and the gate refused a green tree twice with "this source is not publishable". Load was suspected
95
+ // first (load 38, the Linux CI running); the failure reproduces alone in 0.25 s with the variable set.
96
+ // "Do nothing" is the one npm setting that contradicts what an evidence run is for. It is named, not
97
+ // swept: a `/^npm_config_/` sweep would take the registry, the cache and the proxy with it, and a
98
+ // suite behind a private registry would go red for a reason that has nothing to do with the tree.
99
+ const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE', 'HOLMES_AUTONOMOUS_APPROVAL', 'NPM_CONFIG_DRY_RUN']);
90
100
  function cleanTestEnv(env = process.env) {
91
101
  const cleaned = cleanSubprocessEnv(env);
92
102
  for (const k of Object.keys(cleaned)) {
@@ -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
+ };
@@ -489,9 +489,17 @@ function readSourcesForRenumber(projectRoot) {
489
489
  }
490
490
  return out;
491
491
  }
492
- /** Spec documents as the planner needs them, with store-relative POSIX paths. */
492
+ /**
493
+ * Spec documents as the planner needs them, with store-relative POSIX paths.
494
+ *
495
+ * @implements A-SPEC-699 — `unreadable` exists because the absence of it cost a day. A `.md` whose
496
+ * frontmatter would not parse used to be skipped by a bare `continue`, so "this store holds no
497
+ * specs" and "I could not read any of these specs" reached the caller as the same answer. They are
498
+ * different facts and the caller has to be able to tell them apart.
499
+ */
493
500
  function readSpecsForRenumber(specsRoot) {
494
501
  const out = [];
502
+ const unreadable = [];
495
503
  for (const abs of walk(specsRoot).concat((function md(dir, acc = []) {
496
504
  let entries;
497
505
  try {
@@ -511,21 +519,32 @@ function readSpecsForRenumber(specsRoot) {
511
519
  })(specsRoot))) {
512
520
  if (!abs.endsWith('.md'))
513
521
  continue;
522
+ const rel = path.relative(specsRoot, abs).split(path.sep).join('/');
514
523
  let text;
515
524
  try {
516
525
  text = fs.readFileSync(abs, 'utf8');
517
526
  }
518
527
  catch {
528
+ unreadable.push(rel);
519
529
  continue;
520
530
  }
521
- const fm = /^---\n([\s\S]*?)\n---/.exec(text);
522
- if (!fm)
531
+ // @implements A-SPEC-699 — `\r?\n`, not `\n`. A checkout with core.autocrlf=true holds every
532
+ // spec as CRLF, and an LF-only fence matched none of them.
533
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
534
+ if (!fm) {
535
+ unreadable.push(rel);
523
536
  continue;
537
+ }
524
538
  const field = (k) => (new RegExp(`^${k}:[ \\t]*(.+)$`, 'm').exec(fm[1])?.[1] ?? '').trim();
525
539
  const id = field('id');
526
- if (id === '')
540
+ if (id === '') {
541
+ unreadable.push(rel);
527
542
  continue;
528
- const deps = (/^depends_on:\n((?:[ \t]*-[ \t]*.+\n?)+)/m.exec(fm[1])?.[1] ?? '')
543
+ }
544
+ // @implements A-SPEC-699 — the fence is not the only LF-only site. Fixing it alone would leave
545
+ // this one matching nothing on CRLF, so every family would come back with no edges and both
546
+ // unsealOrder and approveOrder would arrive empty: the same silence, one layer down.
547
+ const deps = (/^depends_on:\r?\n((?:[ \t]*-[ \t]*.+\r?\n?)+)/m.exec(fm[1])?.[1] ?? '')
529
548
  .split('\n').map((l) => l.replace(/^[ \t]*-[ \t]*/, '').trim()).filter((x) => x !== '');
530
549
  const inline = /^depends_on:[ \t]*\[(.*)\]/m.exec(fm[1])?.[1];
531
550
  out.push({
@@ -536,5 +555,5 @@ function readSpecsForRenumber(specsRoot) {
536
555
  body: text,
537
556
  });
538
557
  }
539
- return out;
558
+ return { specs: out, unreadable };
540
559
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.26.0",
4
+ "version": "0.26.1",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",