@holmes-lab/holmes-kit 0.26.0 → 0.26.2

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.
@@ -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,115 @@ 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
+ // @implements A-SPEC-700.2 — name the tool that runs the whole plan under one approval. APPENDED:
234
+ // the per-move sentence stays, because `spec_renumber` is still how one family is moved by hand.
235
+ return parts.join('; ') + (plan.moves.length > 0
236
+ ? '; then re-seal with spec_approve, and merge (spec_reconcile plan → apply runs every move under one approval)' : '');
237
+ }
@@ -0,0 +1,25 @@
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;
17
+ /**
18
+ * The local half of the comparison, spelled the way the remote half is: store-relative posix path,
19
+ * BOM stripped, CRLF folded, `collisionKeyOf` as the identity. Regular files only — a FIFO named
20
+ * `*.md` blocks `open(2)` forever (doctor round-6), so the type is asked before the read.
21
+ */
22
+ export declare function collectLocalSpecEntries(specsRoot: string): {
23
+ entries: IdCollisionEntry[];
24
+ skipped: number;
25
+ };
@@ -0,0 +1,236 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.collectRemoteAddedSpecs = collectRemoteAddedSpecs;
37
+ exports.collectLocalSpecEntries = collectLocalSpecEntries;
38
+ // @implements A-SPEC-254.2
39
+ /**
40
+ * The merge-time half of REQ-254: which spec documents did each remote-tracking ref ADD since this
41
+ * checkout and that ref parted?
42
+ *
43
+ * `detectIdCollisions` shipped with doctor walking `.ax/specs` alone, so a number another machine
44
+ * took stayed invisible until git refused the push. Measured 2026-09-20: the Mac Studio committed
45
+ * REQ-694 at 11:05, a Windows checkout allocated REQ-694 at 11:18, neither could see the other, and
46
+ * it surfaced as an add/add conflict on `.ax/specs/01_req/REQ-694.md`. What was missing was
47
+ * collection, not judgement — so this module collects and judges nothing.
48
+ *
49
+ * WHY "ADDED SINCE THE MERGE-BASE" AND NOT "SAME ID, DIFFERENT CONTENT". The same spec edited on one
50
+ * side (a re-seal after a correction, a body that was expanded) is ordinary divergence, and calling
51
+ * it a collision would cry wolf before every pull. A collision is two sides taking the same NEW
52
+ * number without seeing each other. A spec's path is derived from its number, so that set is exactly
53
+ * what git would report as an add/add conflict — asked before the merge instead of after it.
54
+ *
55
+ * NO NETWORK. Remote-tracking refs are read as they stand; `fetch` is the caller's business.
56
+ * Detection tied to the network cannot prepare a merge offline (REQ-254: local-first).
57
+ *
58
+ * READ-ONLY. The only git subcommands are for-each-ref, merge-base, diff, ls-tree and cat-file.
59
+ */
60
+ const node_child_process_1 = require("node:child_process");
61
+ const fs = __importStar(require("node:fs"));
62
+ const path = __importStar(require("node:path"));
63
+ const spec_parser_1 = require("./spec-parser");
64
+ const id_collision_1 = require("./id-collision");
65
+ const root_1 = require("../project/root");
66
+ const SPECS = '.ax/specs';
67
+ const defaultGit = (root, args, input) => (0, node_child_process_1.execFileSync)('git', ['-C', root, ...args], {
68
+ input, stdio: ['pipe', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(process.env), maxBuffer: 256 * 1024 * 1024,
69
+ });
70
+ const lines = (buf) => buf.toString('utf8').split('\n').map((l) => l.trim()).filter((l) => l !== '');
71
+ const storeRelative = (repoPath) => repoPath.slice(SPECS.length + 1);
72
+ /**
73
+ * Parse `git cat-file --batch` output: `<sha> <type> <size>\n<bytes>\n` per object, or
74
+ * `<name> missing\n`. Sizes are BYTES, so this walks the Buffer — a string walk would drift on the
75
+ * first multi-byte character, and these documents are mostly Korean.
76
+ */
77
+ function readBatch(out, count) {
78
+ const blobs = [];
79
+ let at = 0;
80
+ for (let i = 0; i < count && at < out.length; i++) {
81
+ const eol = out.indexOf(0x0a, at);
82
+ if (eol === -1)
83
+ break;
84
+ const header = out.subarray(at, eol).toString('utf8');
85
+ at = eol + 1;
86
+ if (header.endsWith(' missing')) {
87
+ blobs.push(null);
88
+ continue;
89
+ }
90
+ const size = Number(header.split(' ')[2]);
91
+ if (!Number.isFinite(size)) {
92
+ blobs.push(null);
93
+ continue;
94
+ }
95
+ blobs.push(out.subarray(at, at + size));
96
+ at += size + 1;
97
+ }
98
+ while (blobs.length < count)
99
+ blobs.push(null);
100
+ return blobs;
101
+ }
102
+ function collectRemoteAddedSpecs(root, git = defaultGit) {
103
+ const result = { refs: [], entries: [], baseFiles: new Set(), skipped: 0 };
104
+ // `--show-prefix` also tells us where the workspace sits inside the repository. A workspace that is
105
+ // not the repository root would need its paths re-based; that shape is reported, not guessed at.
106
+ let prefix;
107
+ try {
108
+ prefix = git(root, ['rev-parse', '--show-prefix']).toString('utf8').trim();
109
+ }
110
+ catch {
111
+ result.unavailable = 'not a git repository — nothing to compare against';
112
+ return result;
113
+ }
114
+ if (prefix !== '') {
115
+ result.unavailable = 'the workspace is not the repository root — nothing to compare against';
116
+ return result;
117
+ }
118
+ // `%(symref)` is non-empty for origin/HEAD: a symbolic ref names another ref, and counting it
119
+ // would read every document twice and inflate the ref count a PASS reports.
120
+ const refs = lines(git(root, ['for-each-ref', '--format=%(refname:short)%09%(symref)', 'refs/remotes']))
121
+ .map((l) => l.split('\t')).filter(([, symref]) => !symref).map(([name]) => name).sort();
122
+ if (refs.length === 0) {
123
+ result.unavailable = 'no remote-tracking refs — nothing to compare against';
124
+ return result;
125
+ }
126
+ const seenBlob = new Set();
127
+ for (const ref of refs) {
128
+ let base;
129
+ try {
130
+ base = git(root, ['merge-base', 'HEAD', ref]).toString('utf8').trim();
131
+ }
132
+ catch {
133
+ result.skipped++;
134
+ continue;
135
+ } // unrelated history, or no HEAD yet
136
+ if (base === '') {
137
+ result.skipped++;
138
+ continue;
139
+ }
140
+ result.refs.push(ref);
141
+ for (const p of lines(git(root, ['ls-tree', '-r', '--name-only', base, '--', SPECS]))) {
142
+ if (p.endsWith('.md'))
143
+ result.baseFiles.add(storeRelative(p));
144
+ }
145
+ const added = lines(git(root, ['diff', '--name-only', '--diff-filter=A', '--no-renames', base, ref, '--', SPECS]))
146
+ .filter((p) => p.endsWith('.md'));
147
+ if (added.length === 0)
148
+ continue;
149
+ // ONE process per ref, not one per document.
150
+ const blobs = readBatch(git(root, ['cat-file', '--batch'], added.map((p) => `${ref}:${p}`).join('\n') + '\n'), added.length);
151
+ added.forEach((repoPath, i) => {
152
+ const blob = blobs[i];
153
+ if (blob === null) {
154
+ result.skipped++;
155
+ return;
156
+ }
157
+ try {
158
+ // Folded exactly where doctor's local walker folds (BOM stripped, CRLF → LF), so a CRLF
159
+ // working-tree copy and the LF blob of the same document get the same key.
160
+ const folded = blob.toString('utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
161
+ const spec = (0, spec_parser_1.parseSpec)(folded);
162
+ if (!spec.id) {
163
+ result.skipped++;
164
+ return;
165
+ }
166
+ const contentDigest = (0, id_collision_1.collisionKeyOf)(folded, spec);
167
+ const dedupe = `${repoPath}\u0000${contentDigest}`;
168
+ if (seenBlob.has(dedupe))
169
+ return; // the same document reached through two refs
170
+ seenBlob.add(dedupe);
171
+ result.entries.push({
172
+ file: `${ref}:${storeRelative(repoPath)}`,
173
+ id: spec.id,
174
+ approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
175
+ contentDigest,
176
+ });
177
+ }
178
+ catch {
179
+ result.skipped++;
180
+ }
181
+ });
182
+ }
183
+ return result;
184
+ }
185
+ // @implements A-SPEC-700.2
186
+ /**
187
+ * The local half of the comparison, spelled the way the remote half is: store-relative posix path,
188
+ * BOM stripped, CRLF folded, `collisionKeyOf` as the identity. Regular files only — a FIFO named
189
+ * `*.md` blocks `open(2)` forever (doctor round-6), so the type is asked before the read.
190
+ */
191
+ function collectLocalSpecEntries(specsRoot) {
192
+ const entries = [];
193
+ let skipped = 0;
194
+ const walk = (dir) => {
195
+ let listed;
196
+ try {
197
+ listed = fs.readdirSync(dir, { withFileTypes: true });
198
+ }
199
+ catch {
200
+ skipped++;
201
+ return;
202
+ }
203
+ for (const e of listed.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
204
+ const p = path.join(dir, e.name);
205
+ if (e.isDirectory()) {
206
+ walk(p);
207
+ continue;
208
+ }
209
+ if (!e.name.endsWith('.md'))
210
+ continue;
211
+ if (!e.isFile()) {
212
+ skipped++;
213
+ continue;
214
+ }
215
+ try {
216
+ const folded = fs.readFileSync(p, 'utf8').replace(/^/, '').replace(/\r\n/g, '\n');
217
+ const spec = (0, spec_parser_1.parseSpec)(folded);
218
+ if (!spec.id) {
219
+ skipped++;
220
+ continue;
221
+ }
222
+ entries.push({
223
+ file: path.relative(specsRoot, p).split(path.sep).join('/'),
224
+ id: spec.id,
225
+ approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
226
+ contentDigest: (0, id_collision_1.collisionKeyOf)(folded, spec),
227
+ });
228
+ }
229
+ catch {
230
+ skipped++;
231
+ }
232
+ }
233
+ };
234
+ walk(specsRoot);
235
+ return { entries, skipped };
236
+ }
@@ -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.2",
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",