@holmes-lab/holmes-kit 0.23.1 → 0.23.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ 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.23.2] - 2026-09-18
9
+
10
+ Three rules that existed only in prose, and the one number a broken parser had published.
11
+
12
+ ### Fixed
13
+ - **The Files-to-Touch parser reads every declared path** (A-SPEC-670). It took only the **first word
14
+ of a list item**, so measured over all 678 approved specs here, **88 specs declared 172 paths that
15
+ were never read** — 24 of them parsed to zero while naming files plainly. Five forms accounted for
16
+ every miss: several paths on one line (71), an indented continuation line (39), the second path on a
17
+ line whose first was read (32), a Korean first word (22), and a prose paragraph instead of a list (5).
18
+ A slashed path is now read wherever it sits; a bare `name.ext` keeps the old position rule, because
19
+ the root-file predicate accepts any extension and without it `module.exports` and a prose `b.ts`
20
+ become declared files. A declared path that does not exist but is the suffix of exactly one
21
+ repository file is reported as `abbreviated` rather than a defect (measured: unique in all 8 cases),
22
+ and two candidates stays an ambiguity where nothing is guessed. Cost over the whole corpus: **zero**
23
+ new `missing` findings, zero new `moved`, 26 abbreviations resolved.
24
+ - **A number 0.23.0 published was the parser's, not the corpus's** (A-SPEC-665, corrected). The
25
+ declaration census read `scanned-source 0 / no-declaration 4` for this repository's 52 unlinked
26
+ specs, and the README said the remaining 8.1% of `codeLinkedPct` was therefore not a missing anchor.
27
+ With the parser fixed the same census reads **3 and 0**: `A-SPEC-495`, `A-SPEC-549.3` and
28
+ `A-SPEC-549.4` declare scanned production files that carry no anchor, and **five real trace gaps**
29
+ were behind them. The 665 chain was unsealed leaf-first and corrected, and its pin no longer names
30
+ spec ids — pinning those ids pinned the defect. Its own rule ("assert the sum and the property, not
31
+ the numbers") is what kept the fix from looking like a violation.
32
+ - **The shipped instruction says how a spec body is written** (A-SPEC-671). `init` wrote into every
33
+ consumer's `AGENTS.md` that specs are authored with four MCP tools, and **none of the 44 tools takes
34
+ section text**. Authoring here means editing the scaffolded markdown, which the same rule read as
35
+ forbidden — and the collision is guaranteed, not occasional: of the four documents `spec_slice_init`
36
+ scaffolds, exactly one section is left blank (the REQ's `Success Criteria`) and that blank is
37
+ precisely what approval refuses. An agent in another workspace hit this, refused to break the rule,
38
+ and asked for a tool that does not exist; its judgement was correct. Rule 1 now separates the two
39
+ acts the tools perform (**scaffold** and **seal**), states that a body is written by editing the
40
+ generated markdown, and says the Success Criteria is empty on purpose. The empty-criteria refusal
41
+ now names the file and the heading. No new tool and no loosened gate: taking bodies as tool
42
+ arguments is a worse authoring surface, and auto-filling the stub would restore the pass nobody
43
+ earned that A-SPEC-505.1 exists to prevent.
44
+
45
+ ### Added
46
+ - **The publish gate reads the docs** (A-SPEC-669). `playbooks/publish/PLAYBOOK.md` step 2.5 has
47
+ demanded a documentation pass since 0.16.0 with nothing enforcing it, and it failed four times —
48
+ 0.16.0, and 0.21.0/0.22.0/0.23.0, which shipped with the README feature list stuck at 0.20.0.
49
+ `verify-release.js` now refuses a missing CHANGELOG entry for the version being published, and an
50
+ entry carrying `### Added` while `README.md` has not changed since the previous release. Validated
51
+ against eight releases before it was written: it refuses exactly 0.21.0, 0.22.0 and 0.23.0 and
52
+ passes the other five. Uncited sealed specs and version-pinned README headings are **reported only**
53
+ — measured poor gates, since 0.20.0 cited none of its 57 sealed specs and was an honest release. The
54
+ drift question (is the old wording still true?) is judgement and is deliberately not automated: a
55
+ machine imitating it would produce a pass nobody earned, which is the 0.16.0 incident itself. With no
56
+ previous tag the README rule is **skipped and says so**.
57
+
8
58
  ## [0.23.1] - 2026-09-18
9
59
 
10
60
  A release that could not be published, and the documentation that had fallen three versions behind.
package/README.md CHANGED
@@ -16,9 +16,11 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 📐 **Declarations are read as written** *(new in 0.23.2)*: `Files to Touch` is where a spec declares the files it will touch, and three things read it — fulfilment advisories, the declaration census and the approval impact note. The parser took only the **first word of a list item**, so measured over 678 approved specs here, **88 specs and 172 paths were declared and never read**; 24 of them parsed to zero while naming files plainly. Several paths on one line, an indented continuation, a Korean first word, a prose paragraph — all invisible. They are read now, wherever they sit, and a bare `name.ext` keeps its old position rule so a property access like `module.exports` is still not a file. A declared path that does not exist but is the suffix of exactly one repository file is reported as an abbreviation rather than a defect; two candidates stays an ambiguity and nothing is guessed. Cost, measured across the whole corpus: **zero** new `missing` findings.
20
+ - 📄 **The publish gate reads the docs** *(new in 0.23.2)*: the publish playbook has demanded "bring README and CHANGELOG up to this release" since 0.16.0 and only prose enforced it, so it failed four times — including 0.21.0, 0.22.0 and 0.23.0, which each shipped with a feature list frozen at 0.20.0. The release gate now refuses two things it can decide: a missing CHANGELOG entry for the version being published, and an entry with `### Added` while `README.md` has not changed since the previous release. Replayed over eight releases it refuses exactly the three that were stale and passes the other five. What needs judgement — is the old wording still true? — stays with the person and is **reported**, never faked; a check that could not run says so instead of reading as a pass.
19
21
  - 🧩 **Your config files survive a re-wire** *(new in 0.23.0)*: `init --agent antigravity` and `init --agent codex` used to replace `.agents/mcp_config.json`, `.agents/hooks.json` and `marketplace.json` **whole**. Measured with a real `--dry-run` before the fix: a neighbour MCP server, a neighbour hook namespace, a neighbour plugin, a marketplace's own name and an operator's `disabled` flag all survived a re-wire at a rate of **zero**. They now survive — only the holmes-kit entry is refreshed, and `init` names what it kept. A `disabled` you set stays set (and `init` says the gate will not run while it stands, rather than switching it back on silently); an existing file that is not readable JSON is refused with a reason instead of being replaced. The Claude wiring already merged; the other two harnesses now have the same discipline.
20
22
  - 🫀 **The MCP supervisor notices a child that died** *(new in 0.23.0)*: under `HOLMES_MCP_AUTORELOAD` the supervisor had no exit handler, so a crashed child left it writing to a dead pipe — its in-flight count never returned to zero, the swap that would have replaced the child never fired, and the server went **permanently deaf** (observed twice in one session, then reproduced on demand against the real class). It now answers every outstanding request with a JSON-RPC error **first** (a client must never wait for ever), then resets, respawns and replays the opening exchange. A child that dies having never answered spends a restart budget, so a broken build stops quickly while one crash under load restarts freely; an intentional swap or shutdown is not counted as an accident.
21
- - 🧮 **Coverage you can explain** *(new in 0.23.0)*: the RTM census already said *where* an unlinked spec is anchored; it now also says *what it declared* — `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` — plus how many specs declare a path no anchor mechanism can read. On this repository, of 52 unlinked approved specs **zero** declare a file the scanner parses, so the remaining 8.1% of `codeLinkedPct` is not a missing anchor. Bucket names state the fact; whether one is a defect stays `traceGaps`'s answer.
23
+ - 🧮 **Coverage you can explain** *(new in 0.23.0, corrected in 0.23.2)*: the RTM census already said *where* an unlinked spec is anchored; it now also says *what it declared* — `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` — plus how many specs declare a path no anchor mechanism can read. Bucket names state the fact; whether one is a defect stays `traceGaps`'s answer. **0.23.0 published a wrong number here**: it read "of 52 unlinked specs, zero declare a file the scanner parses, so the remaining 8.1% is not a missing anchor". That zero was the Files-to-Touch parser's, not the corpus's see the 0.23.2 entry. The same census now reads three, and five real trace gaps were behind it.
22
24
  - 🤖 **A CI matrix that judges every commit, and never reads silence as green** *(new in 0.22.0)*: a maintainer-side runner takes one commit onto a Linux VM (clone → install → build → full suite) and appends exactly one row to a `ci-runs` ledger — for every outcome, including the ones where the run could not judge (`clone-failed`, `install-failed`, `vm-unreachable`). A launchd agent triggers it per commit. The Stop hook reports the matrix's last word and `doctor` gains a `ci matrix` check; a missing row reads as **"not run"**, never as a pass. Workspaces that never adopted the matrix hear nothing about it.
23
25
  - 🔁 **Advisories learn what happened next** *(new in 0.22.0)*: every finding — impact, anchor density, Files-to-Touch fulfilment, trace gap, `kills`-unapplicable — now carries a deterministic id. Sealing records it as `issued`; the next `approval_status` re-runs the **same** functions and records `resolved` when the cause is gone or `persisted` when it is not, at most one row per finding per day. An author who judges a finding unhelpful passes `dismiss: [id]`; an unknown id comes back in `dismissUnknown` rather than inventing a row. `rtm_dashboard`'s census gains the per-kind tallies. This is the numerator every "promote to a hard gate once we know the false-positive rate" sentence was missing.
24
26
  - 🧪 **`kills` that cannot apply say so** *(new in 0.22.0)*: `test_run --mutate` reports mutations that never applied as `unapplied`, separately from `survivors`. Measured here, all 22 `kills` entries in this repository wrote `where` as a file path and `mutate` as prose, so the literal-replacement engine applied **none** of them while the response still read `survivors: []` — the shape of a clean run. Sealing a T-SPEC now reports entries whose `where` is absent from the A-SPEC's anchored source, and the authoring playbook shows the grammar.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- d2304488-mu6dv817
1
+ 86f8d89c-mu6hz0ua
@@ -70,6 +70,7 @@ export declare function shortPathIfSpaced(p: string): string;
70
70
  * space, or win32 where 8.3 resolved it).
71
71
  */
72
72
  export declare function antigravityHookWarnings(packageRoot: string): string[];
73
+ export declare const AGENTS_MD: (enforced: boolean) => string;
73
74
  export declare const MANAGED_BEGIN = "<!-- holmes-kit:managed:begin -->";
74
75
  export declare const MANAGED_END = "<!-- holmes-kit:managed:end -->";
75
76
  /**
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MANAGED_END = exports.MANAGED_BEGIN = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
36
+ exports.MANAGED_END = exports.MANAGED_BEGIN = exports.AGENTS_MD = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
37
37
  exports.antigravityHookCommand = antigravityHookCommand;
38
38
  exports.shortPathIfSpaced = shortPathIfSpaced;
39
39
  exports.antigravityHookWarnings = antigravityHookWarnings;
@@ -201,7 +201,9 @@ This repository operates under **No Spec, No Code** governance. An approved spec
201
201
 
202
202
  1. **Tool-First Principle (NO Workaround Scripts)**:
203
203
  - Creating or mutating \`.ax/specs\` or governance ledgers via arbitrary temporary scripts (\`/tmp/*.js\`) is strictly forbidden.
204
- - Use only \`spec_slice_init\`, \`spec_slice_approve\`, \`spec_create\`, and \`spec_approve\` MCP tools to author and seal specs.
204
+ - \`spec_slice_init\` and \`spec_create\` SCAFFOLD spec documents; \`spec_slice_approve\` and \`spec_approve\` SEAL them. Use these tools for those two acts — never a script.
205
+ - **Writing the body is editing the file.** After scaffolding, fill the \`TODO\` placeholders by editing the generated spec markdown in \`.ax/specs/\` with your normal editor. This is the expected authoring path, not a workaround — no MCP tool takes section text.
206
+ - A REQ's \`## Success Criteria\` is scaffolded EMPTY on purpose, because a generator must not invent what "satisfied" means. Approval is refused until you write observable criteria there.
205
207
 
206
208
  2. **Anchor-First Principle (Mandatory Code Anchors)**:
207
209
  - When creating or modifying source code and test files, line 1 MUST include an explicit anchor comment: \`// @\` + \`implements A-SPEC-XXX\`.
@@ -212,7 +214,7 @@ This repository operates under **No Spec, No Code** governance. An approved spec
212
214
 
213
215
  ## Procedure
214
216
 
215
- 1. Run \`spec_slice_init\` or \`spec_create\` to author REQ -> H-SPEC -> A-SPEC.
217
+ 1. Run \`spec_slice_init\` or \`spec_create\` to scaffold REQ -> H-SPEC -> A-SPEC, then edit the generated markdown to replace every \`TODO\` and to write the REQ's Success Criteria.
216
218
  2. Approve specs via \`spec_slice_approve\` or \`spec_approve\`.
217
219
  3. **Before editing source**, run \`maintenance_analyze({ root, request, persist: true })\` and keep the digest it returns — that persisted analysis is what the pre-edit evidence gate checks.
218
220
  4. **Write tests first** and verify failure (RED stage).
@@ -227,6 +229,7 @@ ${enforced
227
229
  ? `Gates are **enforced**. Tool calls that write un-anchored code or target unapproved specifications will be denied. Unresolved critical findings block completion.`
228
230
  : `This harness does not have measured **hook enforcement points**. Holmes-Kit provides tools and guidance here without active gate blocking.`}
229
231
  `;
232
+ exports.AGENTS_MD = AGENTS_MD;
230
233
  exports.MANAGED_BEGIN = '<!-- holmes-kit:managed:begin -->';
231
234
  exports.MANAGED_END = '<!-- holmes-kit:managed:end -->';
232
235
  const PRESERVED_NOTE = '<!-- kept from this file by holmes-kit init — holmes-kit does not write below this line -->';
@@ -415,7 +418,7 @@ function agentFiles(agent, opts) {
415
418
  return [
416
419
  { path: path.join(target, '.agents', 'hooks.json'), content: hooksJson(packageRoot) },
417
420
  { path: path.join(target, '.agents', 'mcp_config.json'), content: mcpConfig(packageRoot, specsDir, opts.launcher) },
418
- { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.antigravity) },
421
+ { path: path.join(target, 'AGENTS.md'), content: (0, exports.AGENTS_MD)(exports.HARNESS_ENFORCES.antigravity) },
419
422
  ];
420
423
  case 'codex':
421
424
  // @implements A-SPEC-442 (was A-SPEC-266, A-SPEC-441) — Codex plugin, at the layout Codex reads:
@@ -480,7 +483,7 @@ function agentFiles(agent, opts) {
480
483
  },
481
484
  }, null, 2)}\n`,
482
485
  },
483
- { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.codex) },
486
+ { path: path.join(target, 'AGENTS.md'), content: (0, exports.AGENTS_MD)(exports.HARNESS_ENFORCES.codex) },
484
487
  ];
485
488
  default:
486
489
  // 모르는 하네스를 조용히 건너뛰면 "배선했다"는 보고와 실제가 어긋난다.
@@ -19,6 +19,14 @@ export interface FttFulfilment {
19
19
  path: string;
20
20
  line: string;
21
21
  }>;
22
+ /**
23
+ * @implements A-SPEC-670 — a declared path that does not exist but is the suffix of exactly ONE
24
+ * repository file is the same file written short, not a defect — reported, never counted as one.
25
+ */
26
+ abbreviated?: Array<{
27
+ path: string;
28
+ resolvedTo: string;
29
+ }>;
22
30
  }
23
31
  export interface FulfilmentRecord {
24
32
  aspec: string;
@@ -29,9 +37,22 @@ export interface FulfilmentRecord {
29
37
  replica?: string;
30
38
  }
31
39
  /**
32
- * List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
33
- * root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
34
- * their fulfilment is not a single file's existence.
40
+ * @implements A-SPEC-670
41
+ * Every path-shaped token in the section, wherever it sits on the line.
42
+ *
43
+ * REQ-654 read only the first word of a list item. Measured over all 678 A-SPECs on 2026-09-18,
44
+ * that lost 172 declared paths across 88 specs — 24 of which parsed to zero while naming files
45
+ * plainly, so the census called them "no declaration" and one wrong conclusion was published from
46
+ * it. Five forms accounted for every miss: several paths on one line, an indented continuation
47
+ * line, the second path on a line whose first was read, a Korean first word, and a prose paragraph
48
+ * written instead of a list.
49
+ *
50
+ * The widened rule is safe because of WHERE it reads: this is the Files-to-Touch section, whose
51
+ * whole purpose is declaration. One line in the entire corpus carries a negation word, and the path
52
+ * on it is a declaration too — so there is no measured population of "mentioned but not declared".
53
+ *
54
+ * What REQ-654 excluded stays excluded, for its original reason: globs and extension-less paths
55
+ * (directories) are not items, because their fulfilment is not one file's existence.
35
56
  */
36
57
  export declare function fttItems(fttText: string): FttItem[];
37
58
  export declare function fttFulfilment(fttText: string, exists: (rel: string) => boolean, locate: (basename: string) => string[]): FttFulfilment | null;
@@ -57,30 +57,62 @@ const fs = __importStar(require("node:fs"));
57
57
  const path = __importStar(require("node:path"));
58
58
  const scope_judgment_1 = require("../guardrail/scope-judgment");
59
59
  const replica_id_1 = require("../governance/replica-id");
60
- const ITEM_RE = /^\s*[-*]\s+(.*)$/;
61
60
  const NEW_RE = /\((신규|new)\)/i;
62
61
  const ALT_RE = /또는|\bor\b/;
63
62
  const SLASHED = /^[\w@.-]+(?:\/[\w@.-]+)+$/;
64
63
  const HAS_EXT = /\.[A-Za-z][A-Za-z0-9]*$/;
64
+ /** Token boundaries as the corpus actually writes them: backticks, quotes, brackets, `·`, commas. */
65
+ const TOKEN_SPLIT = /[`'"()[\]{}<>,;·、,\s]+/;
65
66
  /**
66
- * List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
67
- * root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
68
- * their fulfilment is not a single file's existence.
67
+ * @implements A-SPEC-670
68
+ * Every path-shaped token in the section, wherever it sits on the line.
69
+ *
70
+ * REQ-654 read only the first word of a list item. Measured over all 678 A-SPECs on 2026-09-18,
71
+ * that lost 172 declared paths across 88 specs — 24 of which parsed to zero while naming files
72
+ * plainly, so the census called them "no declaration" and one wrong conclusion was published from
73
+ * it. Five forms accounted for every miss: several paths on one line, an indented continuation
74
+ * line, the second path on a line whose first was read, a Korean first word, and a prose paragraph
75
+ * written instead of a list.
76
+ *
77
+ * The widened rule is safe because of WHERE it reads: this is the Files-to-Touch section, whose
78
+ * whole purpose is declaration. One line in the entire corpus carries a negation word, and the path
79
+ * on it is a declaration too — so there is no measured population of "mentioned but not declared".
80
+ *
81
+ * What REQ-654 excluded stays excluded, for its original reason: globs and extension-less paths
82
+ * (directories) are not items, because their fulfilment is not one file's existence.
69
83
  */
70
84
  function fttItems(fttText) {
71
85
  const out = [];
86
+ const seen = new Set();
72
87
  for (const raw of String(fttText ?? '').replace(/\\/g, '/').split('\n')) {
73
- const m = ITEM_RE.exec(raw);
74
- if (!m)
88
+ const isListItem = /^\s*[-*]\s+/.test(raw);
89
+ const line = raw.replace(/^\s*[-*]\s+/, '').trim();
90
+ if (line === '')
75
91
  continue;
76
- const line = m[1].trim();
77
- const word = (line.split(/\s+/)[0] ?? '').replace(/^[`'"]+|[`'"]+$/g, '').replace(/^`?([^`]*?)`?\(/, '$1(');
78
- const token = word.replace(/\(.*$/, ''); // `src/b.ts`(new) src/b.ts
79
- if (!token || token.includes('*') || !HAS_EXT.test(token))
80
- continue;
81
- if (!(SLASHED.test(token) || (0, scope_judgment_1.isRootFileToken)(token)))
82
- continue;
83
- out.push({ path: token, line, isNew: NEW_RE.test(line), alternative: ALT_RE.test(line) });
92
+ const isNew = NEW_RE.test(line);
93
+ const alternative = ALT_RE.test(line);
94
+ // Splitting on backticks leaves an empty first piece for `` `a.ts` ``, so "first" counts real
95
+ // words, not array slots — otherwise every backticked root filename loses its position.
96
+ const pieces = line.split(TOKEN_SPLIT).filter((w) => w !== '');
97
+ for (let i = 0; i < pieces.length; i += 1) {
98
+ // Trailing sentence punctuation is not part of a path; a leading `./` is not part of its identity.
99
+ const token = pieces[i].replace(/^\.\//, '').replace(/[.,;:]+$/, '');
100
+ if (!token || token.includes('*') || !HAS_EXT.test(token))
101
+ continue;
102
+ // A SLASHED path is admitted wherever it sits — that is the whole point of REQ-670, and a
103
+ // slash makes it unmistakably a path. A bare `name.ext` is NOT: `isRootFileToken` accepts any
104
+ // extension, so mid-line it would swallow `module.exports` and a prose `b.ts` (both measured
105
+ // in A-SPEC-656's pins). Root-file tokens therefore keep REQ-654's position exactly: the first
106
+ // word of a LIST ITEM. Measured: dropping the list condition admitted `semanticArm.gates` from
107
+ // a continuation line in A-SPEC-488 — one new false "missing" across the whole corpus, and the
108
+ // only one. With the condition restored the widening adds 172 declarations and no new finding.
109
+ if (!SLASHED.test(token) && !(isListItem && i === 0 && (0, scope_judgment_1.isRootFileToken)(token)))
110
+ continue;
111
+ if (seen.has(token))
112
+ continue;
113
+ seen.add(token);
114
+ out.push({ path: token, line, isNew, alternative });
115
+ }
84
116
  }
85
117
  return out;
86
118
  }
@@ -91,6 +123,7 @@ function fttFulfilment(fttText, exists, locate) {
91
123
  const missing = [];
92
124
  const moved = [];
93
125
  const alternatives = [];
126
+ const abbreviated = [];
94
127
  for (const it of items) {
95
128
  if (it.alternative) {
96
129
  alternatives.push({ path: it.path, line: it.line });
@@ -100,14 +133,25 @@ function fttFulfilment(fttText, exists, locate) {
100
133
  continue;
101
134
  const base = it.path.slice(it.path.lastIndexOf('/') + 1);
102
135
  const foundAt = [...new Set(locate(base))].filter((p) => p !== it.path).sort();
136
+ // @implements A-SPEC-670 — `project/root.ts` beside `src/holmes/project/root.ts` is the same
137
+ // file written short, measured 8 times in this corpus and unique every time. Only a UNIQUE
138
+ // suffix match earns this: two candidates is an ambiguity, and a guess there would invent a
139
+ // fact. Anything else keeps its old verdict, so the rule swallows no real absence.
140
+ const suffixHits = foundAt.filter((p) => p.endsWith(`/${it.path}`));
141
+ if (suffixHits.length === 1) {
142
+ abbreviated.push({ path: it.path, resolvedTo: suffixHits[0] });
143
+ continue;
144
+ }
103
145
  if (foundAt.length > 0)
104
146
  moved.push({ path: it.path, foundAt });
105
147
  else
106
148
  missing.push({ path: it.path, isNew: it.isNew });
107
149
  }
108
- if (missing.length === 0 && moved.length === 0 && alternatives.length === 0)
150
+ if (missing.length === 0 && moved.length === 0 && alternatives.length === 0 && abbreviated.length === 0)
109
151
  return null;
110
- return { declared: items.length, missing, moved, alternatives };
152
+ // Present only when it has something to say: an always-there empty array would change the shape
153
+ // every existing caller and pin reads, for a fact that is not there.
154
+ return { declared: items.length, missing, moved, alternatives, ...(abbreviated.length > 0 ? { abbreviated } : {}) };
111
155
  }
112
156
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
113
157
  /** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
@@ -272,7 +272,11 @@ function unactionableCriteriaBlocker(spec) {
272
272
  const quality = (0, acceptance_quality_1.acceptanceQuality)(spec);
273
273
  if (quality === 'stated')
274
274
  return null;
275
+ // @implements A-SPEC-671 — the refusal now says WHERE. It named what was wrong and stopped, and on
276
+ // 2026-09-18 an agent in another workspace read the shipped AGENTS.md rule ("author with these
277
+ // four tools"), found no tool that writes a section body, and halted rather than break the rule.
278
+ // The section is filled by editing the file, so the refusal hands over the file and the heading.
275
279
  return quality === 'absent'
276
- ? `REQ ${spec.id}의 Success Criteria가 비어 있습니다 — 관측 가능한 기준을 채운 뒤 승인하십시오`
280
+ ? `REQ ${spec.id}의 Success Criteria가 비어 있습니다 — \`.ax/specs/01_req/${spec.id}.md\` 의 \`## Success Criteria\` 절을 편집기로 열어 관측 가능한 기준을 채운 뒤 승인하십시오`
277
281
  : `REQ ${spec.id}의 Success Criteria가 전량 보일러플레이트입니다('구현·테스트 완료'는 충족 정의가 아닙니다) — 각 항목이 검증 동사·측정 대상을 갖게 고친 뒤 승인하십시오`;
278
282
  }
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.23.1",
4
+ "version": "0.23.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",