@holmes-lab/holmes-kit 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ 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.14.0] - 2026-09-06
9
+
10
+ Closes the gate's oldest blind spot: it judged *where* a change landed, never *what kind* of change it
11
+ was. A dependency swap inside an approved scope — a real architecture change — passed unreviewed. This
12
+ release adds the **spec-evolution trigger**, then hardens it (and the autonomy layer it reports through)
13
+ against everything a post-ship adversarial audit could find. Additive and observe-first: nothing new
14
+ blocks a turn.
15
+
16
+ ### Added
17
+
18
+ - **Spec-evolution trigger — external-dependency drift** (REQ-559): when a changed, in-scope source
19
+ file (inside an approved A-SPEC's scope, carrying its anchor) **newly introduces an external
20
+ dependency**, Holmes-Kit raises a **spec-reappraisal**. Manual mode warns at the turn boundary;
21
+ under autonomy it also files the drift into the out-of-band `holmes-kit approve` queue, so an
22
+ autonomous agent cannot quietly re-architect a slice. The detector (`dependencyDelta` /
23
+ `externalImportsOf`) is pure, TS/JS + Python, and judges only *newly added* bare (non-relative)
24
+ top-level packages — builtins and stdlib are not drift. Every path fails open: the signal never
25
+ affects the stop verdict.
26
+
27
+ ### Fixed
28
+
29
+ - **The autonomy posture is now visible to hook-side processes** (REQ-560): a hook process never
30
+ inherits the MCP server's environment, so a project that opted in with `init --autonomy` (the switch
31
+ is persisted into `.mcp.json`) got **no session-start banner and no queue escalation** — the switch
32
+ was real but invisible to every hook surface. `autonomousApprovalEnabled` and the banner now also
33
+ judge the project default by reading `.mcp.json` directly (read-only; the file is agent-write
34
+ protected, so this opens no self-grant path, and the owner's edit now takes effect without a restart).
35
+ - **Dependency detection hardened against real-world source** (REQ-560): prettier-style multiline
36
+ imports (`import {\n a,\n} from 'pkg'`) were missed entirely — the most common shape in formatted
37
+ code — and a pure reformat could even report a phantom new dependency. CRLF files broke comment
38
+ stripping, so a commented-out `require()` fired a false drift on every turn. A `/*` inside a string
39
+ swallowed the real imports after it. Python `import a, b` saw only the first package. Strings,
40
+ template literals, docstrings and `x.require()` calls no longer produce phantom dependencies, the
41
+ Python stdlib and Node builtin lists were widened, and a pathological file (30k unclosed `/*`) went
42
+ from 2.4s to 12ms.
43
+ - **Reappraisal escalation respects decisions and non-ASCII paths** (REQ-560): an owner-decided
44
+ (granted/denied) reappraisal is no longer re-queued on the next turn while the tree stays dirty, and
45
+ an already-pending one is not duplicated. `git status`/`show` now run with `core.quotepath=false`, so
46
+ Korean- and space-named sources are seen at all — the same blindness affected the ART-8 and ART-1
47
+ evidence collectors, which are fixed alongside. The signal is also emitted on degraded-yield turns.
48
+ - **Evidence runs are posture-neutral** (REQ-561): `test_run` handed the MCP server's
49
+ `HOLMES_AUTONOMOUS_APPROVAL` to its jest children, so on a project with autonomy enabled the
50
+ autonomy branch skipped the elicitation path and 9 elicitation tests went red **on a tree whose full
51
+ suite was green** — the gate became unusable for exactly the users who opted into autonomy. The
52
+ switch now joins the scrub list (`HOLMES_SPECS`, `HOLMES_GATE_BYPASS`, …): a posture is an approval
53
+ channel, never a property of the code under test.
54
+
8
55
  ## [0.13.0] - 2026-09-06
9
56
 
10
57
  Autonomy grows from a single env switch into a **three-layer, always-bounded** posture — so a team can
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ---
16
16
 
17
- ### 🛡️ Currently Supported Features (v0.13.x Production Features)
17
+ ### 🛡️ Currently Supported Features (v0.14.x Production Features)
18
18
 
19
19
  - 📋 **Requirements & Specification Governance**: Strict **"No Spec, No Code"** enforcement with 4-tier spec chain traceability (`REQ ➔ H-SPEC ➔ A-SPEC ➔ T-SPEC`) and `// @implements A-SPEC-XXX` code anchors (comma-lists and every anchor in a file participate in the gate).
20
20
  - 🔴 **Inbuilt TDD — RED-first, enforced not asked** *(new in 0.9.0)*: the test-first discipline is a holmes-installed `holmes-tdd-slice` skill **and** a new constitution article **ART-8**. A changed A-SPEC must show a recorded `red-assertion → green` sequence in the ledger; a `red-error` (a test that could not run) is not a valid RED, so "the covering test failed *correctly*" is judged mechanically, not on trust. `test_run` classifies each covered file (`red-assertion`/`red-error`/`green`) and records per-A-SPEC outcomes the Stop hook reads. Ships at `redFirstEvidence: track` (observe-first, non-blocking; `strict`/`off` per repo), evidence-gated and jest-only for now. A T-SPEC may also declare `kills:` mutations and `test_run --mutate` reports which SURVIVED (a coverage gap). Where superpowers *asks* for RED-first and discriminating power, holmes-kit *proves* them.
@@ -22,7 +22,8 @@
22
22
  - ⬆️ **Zero-config upgrades** *(new in 0.11.0)*: `holmes-kit upgrade` moves **every** wired workspace to the latest in one command — plan → confirm → install → re-pin all recorded workspaces (`--dry-run`/`--yes` supported). Preparation is automatic (each `init` records the workspace; a session whose pin is behind nudges you to upgrade); the re-pin **write** stays your explicit choice, never a silent auto-install. Opt out of the nudge with `HOLMES_NO_AUTO_REPIN`.
23
23
  - 📊 **World-top-tier RTM dashboard** *(new in 0.12.0–0.12.1)*: `holmes-kit serve` — ask to *see* the RTM heatmap and the `rtm_dashboard` MCP tool launches the server idempotently and hands back the URL plus an honesty **census** (requirement/pipeline counts, coverage %, what's excluded). The heatmap is a real **2D coverage matrix** (requirements × pipeline stages, rows seriated by completeness, sequential-ramp cells with the percent printed in each). Drilling into a symbol renders that function's **CFG as a layered DAG** with **PDG (data/control-dependence) colour overlays**, served by `/api/cfg?file=&symbol=` from the same engine the taint lane uses — a non-CFG language is named, never faked. Tokenised palette (sequential ramp, status colours, UI/mono pairing) with light/dark.
24
24
  - 🔢 **Sensible spec numbering** *(new in 0.12.1)*: a brand-new project's first slice is now **REQ-100**, not REQ-201 — `spec_slice_init` shares the same id allocator as the reverse-draft path (`nextIdBase`, floor 100). Existing projects are untouched: the next id is always `max(existing)+1`, so a repo already numbering from 201 keeps the exact same sequence. Numbering past 999 yields 4-digit ids cleanly, and ADR references now recognise 4-digit ADRs (`ADR-1000+`).
25
- - 🤖 **Autonomous Approval — three layers, always bounded** *(reworked in 0.13.0; foundation 0.8.0)*: for teams that want the agent to self-drive the SDLC, autonomy is a posture the agent holds at two scopes — a **project default** you opt into at `holmes-kit init --autonomy` (persisted as the `HOLMES_AUTONOMOUS_APPROVAL` env in `.mcp.json`), and a **per-session envelope** you grant on the spot with `holmes-kit autonomy on --for 2h` (an expiring marker under the agent-write-protected `.ax/state/`). Under either, the agent seals **low-risk** specs itself (ledgered under an `autonomous:<client>` actor); every **governance-critical, high-risk, or irreversible** decision — `gate-behavior`/breaking A-SPECs, architecture/gate/taint files, and every upstream `REQ`/`H-SPEC`/`C-SPEC` — is instead **refused and routed to the out-of-band `holmes-kit approve` queue** for a human, never silently self-approved. The active posture is **surfaced at every session start** so it can't be forgotten, and an agent can never grant it to itself: the env is env-only (blocked like `HOLMES_ROLE`), the session command needs a real TTY or an out-of-band `HOLMES_APPROVAL`, and the marker lives where agents can't write. Off = byte-identical to a fully human-gated project. *(new in 0.10.0)* `HOLMES_ELICIT=off` routes every decision straight to the same queue.
25
+ - 🤖 **Autonomous Approval — three layers, always bounded** *(reworked in 0.13.0; foundation 0.8.0)*: for teams that want the agent to self-drive the SDLC, autonomy is a posture the agent holds at two scopes — a **project default** you opt into at `holmes-kit init --autonomy` (persisted as the `HOLMES_AUTONOMOUS_APPROVAL` env in `.mcp.json`), and a **per-session envelope** you grant on the spot with `holmes-kit autonomy on --for 2h` (an expiring marker under the agent-write-protected `.ax/state/`). Under either, the agent seals **low-risk** specs itself (ledgered under an `autonomous:<client>` actor); every **governance-critical, high-risk, or irreversible** decision — `gate-behavior`/breaking A-SPECs, architecture/gate/taint files, and every upstream `REQ`/`H-SPEC`/`C-SPEC` — is instead **refused and routed to the out-of-band `holmes-kit approve` queue** for a human, never silently self-approved. The active posture is **surfaced at every session start** so it can't be forgotten *(0.14.0: the hook side now reads the project default out of `.mcp.json` directly, so an `init --autonomy` project sees its banner and escalations without an env round-trip)*, and an agent can never grant it to itself: the env is env-only (blocked like `HOLMES_ROLE`), the session command needs a real TTY or an out-of-band `HOLMES_APPROVAL`, and the marker lives where agents can't write. Off = byte-identical to a fully human-gated project. *(new in 0.10.0)* `HOLMES_ELICIT=off` routes every decision straight to the same queue.
26
+ - 🧭 **Spec-Evolution Trigger** *(new in 0.14.0)*: the gate used to judge only *where* a change lands (file ∈ Files-to-Touch, anchored, approved) — never *what kind* of change it is, so a real architecture swap inside an approved scope passed unreviewed. Now, when a changed in-scope source **newly introduces an external dependency** (a swapped engine, a new runtime), Holmes-Kit raises a **spec-reappraisal**: manual mode warns at the turn boundary, and under autonomy it also files the drift in the out-of-band `holmes-kit approve` queue so the owner sees it — a decided reappraisal is never re-raised for the same drift. Observe-first by design: it never blocks a turn. Detection is TS/JS + Python, string- and comment-safe (prettier multiline imports, CRLF files, docstrings and template literals all judged correctly).
26
27
  - 🚢 **Release Autonomy + Docs-Currency Gate** *(new in 0.13.0)*: publishing is irreversible and outward, so `npm publish` stays **human-approved by default** — but a deterministic classifier (`releaseAutonomy`, reusing the same per-spec risk grade) lets a **low-risk** release (patch/minor, every spec auto-grade, autonomy on) self-publish under the ledger, while a **major** bump, any `gate-behavior`/security/architecture spec, or an upstream `REQ`/`H-SPEC` forces HITL. The `holmes-publish` playbook also gains a **docs-currency gate**: before any release it diffs the specs since the last tag and blocks if a user-facing change never reached `README`/`CHANGELOG` — a stale doc is a false claim.
27
28
  - 🌐 **English CLI & hook surface** *(new in 0.13.0)*: the operator-facing CLI and hook messages — `doctor` output, the CLI usage/errors, the hook `deny` reasons and ART citations, and the interactive `approve`/`init`/`upgrade`/`semantic-key` prompts — are now English, guarded by a hangul-absence test over the **rendered runtime output** (not just a source scan, which misses `\u`-escaped strings). The MCP tool responses (`spec_create`/`spec_approve`/ledger/review) are still being migrated and are next.
28
29
  - 🪧 **Session Banner + Update Notice** *(new in 0.8.0; refresh implemented + made uniform in 0.12.2)*: every session start emits an English intro (version + governance rule + npm URL) to both the human transcript and the agent context (SessionStart hook + MCP `instructions`); when a newer published version is on npm, an install-mode-aware `holmes-kit upgrade` command is appended. The registry refresh (dist-tags query → cached in `~/.holmes/update-check.json`) is detached, TTL-gated, and fail-silent, and now fires from **every harness's MCP-server startup** — not just Claude's SessionStart hook — so Claude / Antigravity / Codex are notified alike. Opts out via `HOLMES_NO_UPDATE_CHECK`/`CI`. Upgrade execution stays your explicit choice (`holmes-kit upgrade`), never a silent auto-install.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 55b0a37-mtp8gdtd
1
+ b352d54-mtpi1283
@@ -14,6 +14,7 @@ export declare const SESSION_AUTONOMY_MARKER: readonly [".ax", "state", "autonom
14
14
  export declare function sessionAutonomyActive(root: string, now: string): boolean;
15
15
  /** Whether autonomous approval is enabled — the out-of-band env switch, OR a valid session envelope
16
16
  * (A-SPEC-553.1). `root`/`now` are optional so env-only callers are byte-identical. */
17
+ export declare function projectDefaultAutonomy(root: string): boolean;
17
18
  export declare function autonomousApprovalEnabled(env: NodeJS.ProcessEnv, root?: string, now?: string): boolean;
18
19
  export declare function autonomyBanner(env: NodeJS.ProcessEnv, root: string, now: string): string;
19
20
  export declare function autonomyMarkerShouldPrune(root: string, now: string): boolean;
@@ -37,6 +37,7 @@ exports.SESSION_AUTONOMY_MARKER = exports.AUTONOMY_ENV = void 0;
37
37
  exports.isHighRiskPath = isHighRiskPath;
38
38
  exports.specApprovalAutonomy = specApprovalAutonomy;
39
39
  exports.sessionAutonomyActive = sessionAutonomyActive;
40
+ exports.projectDefaultAutonomy = projectDefaultAutonomy;
40
41
  exports.autonomousApprovalEnabled = autonomousApprovalEnabled;
41
42
  exports.autonomyBanner = autonomyBanner;
42
43
  exports.autonomyMarkerShouldPrune = autonomyMarkerShouldPrune;
@@ -158,17 +159,41 @@ function sessionAutonomyActive(root, now) {
158
159
  }
159
160
  /** Whether autonomous approval is enabled — the out-of-band env switch, OR a valid session envelope
160
161
  * (A-SPEC-553.1). `root`/`now` are optional so env-only callers are byte-identical. */
162
+ // @implements A-SPEC-560.2 — hook-side processes never inherit the MCP server's env, so the project
163
+ // default (persisted by `init --autonomy` into .mcp.json) must be judged by READING the file. The
164
+ // post-ship audit's live evidence: a session with .mcp.json=1 showed no banner and never escalated —
165
+ // the switch was real but invisible to every hook-side surface. Pure read-only judgement: .mcp.json
166
+ // is agent-write-protected, so this opens no self-grant channel; a live read also removes the
167
+ // restart-lag footgun (the owner's edit takes effect at the next hook run).
168
+ function projectDefaultAutonomy(root) {
169
+ try {
170
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, '.mcp.json'), 'utf8'));
171
+ for (const server of Object.values(parsed.mcpServers ?? {})) {
172
+ const v = server?.env?.[exports.AUTONOMY_ENV];
173
+ if (typeof v === 'string' && v !== '')
174
+ return true; // same truthiness convention as the env switch
175
+ }
176
+ return false;
177
+ }
178
+ catch {
179
+ return false; // absent / unparseable file is no signal — never a throw
180
+ }
181
+ }
161
182
  function autonomousApprovalEnabled(env, root, now) {
162
183
  const v = env[exports.AUTONOMY_ENV];
163
184
  if (typeof v === 'string' && v !== '')
164
185
  return true;
186
+ if (root !== undefined && projectDefaultAutonomy(root))
187
+ return true; // @implements A-SPEC-560.2
165
188
  return root !== undefined && now !== undefined && sessionAutonomyActive(root, now);
166
189
  }
167
190
  // @implements A-SPEC-554.1 — the session-start REMINDER (the headless hook surfaces, it never decides).
168
191
  // Only ON postures get a line; OFF is silent (a permanent line is one nobody reads). Wired below.
169
192
  function autonomyBanner(env, root, now) {
170
193
  const v = env[exports.AUTONOMY_ENV];
171
- if (typeof v === 'string' && v !== '') {
194
+ // @implements A-SPEC-560.2 the project default is visible via env (server-side) OR .mcp.json
195
+ // (hook-side); either way the banner names the same posture.
196
+ if ((typeof v === 'string' && v !== '') || projectDefaultAutonomy(root)) {
172
197
  return '[Holmes-Kit] autonomous spec approval: ON (project default) — governance-critical, high-risk and irreversible specs still ask you.';
173
198
  }
174
199
  if (sessionAutonomyActive(root, now)) {
@@ -0,0 +1,3 @@
1
+ export type DepLang = 'ts' | 'py';
2
+ export declare function externalImportsOf(source: string, lang: DepLang): Set<string>;
3
+ export declare function dependencyDelta(before: string, after: string, lang: DepLang): string[];
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ // @implements A-SPEC-559.1
3
+ // @implements A-SPEC-560.1
4
+ // Pure external-dependency-delta detector: given a source file's content, extract its EXTERNAL
5
+ // (non-relative, "bare") import specifiers normalized to the top-level package, and report which
6
+ // external packages a before→after change newly introduces. The repo convention is `importTargetOf`'s
7
+ // (`guardrail/forbidden-edges.ts`): a `.`-prefixed specifier is relative (internal); anything else is
8
+ // external. Pure, deterministic, never throws — the git/env/queue wiring lives in the Stop hook.
9
+ //
10
+ // Hardened after the post-ship adversarial audit (REQ-560): prettier-style MULTILINE imports are the
11
+ // most common real-world form and the original line-anchored regex missed them entirely; CRLF files
12
+ // broke line-comment stripping into false deltas; a string-embedded `/*` swallowed real imports; and
13
+ // the un-anchored require()/import() patterns matched inside string literals (our own test fixtures
14
+ // self-triggered). The pipeline below is: normalize CRLF → strip line-START block comments only →
15
+ // strip template/triple-quoted literals → strip line comments (URL-preserving) → match, with a
16
+ // quote-parity rejection for require()/import() so string-embedded calls stay silent.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.externalImportsOf = externalImportsOf;
19
+ exports.dependencyDelta = dependencyDelta;
20
+ // A new import of one of these is a language builtin, not an added third-party dependency, so it is
21
+ // NOT an architecture-drift signal. The lists are deliberately common-case, not exhaustive: a missed
22
+ // builtin only produces an observe-first warning, never a block.
23
+ const TS_BUILTINS = new Set([
24
+ 'fs', 'path', 'os', 'crypto', 'util', 'events', 'stream', 'http', 'https', 'url', 'child_process',
25
+ 'process', 'assert', 'buffer', 'net', 'tls', 'zlib', 'querystring', 'string_decoder', 'timers',
26
+ 'tty', 'dgram', 'dns', 'readline', 'repl', 'vm', 'worker_threads', 'cluster', 'perf_hooks',
27
+ 'async_hooks', 'v8', 'module', 'constants', 'punycode', 'inspector', 'diagnostics_channel', 'test',
28
+ ]);
29
+ const PY_STDLIB = new Set([
30
+ 'os', 'sys', 're', 'json', 'typing', 'pathlib', 'subprocess', 'time', 'datetime', 'collections',
31
+ 'itertools', 'functools', 'math', 'random', 'logging', 'io', 'abc', 'enum', 'dataclasses', 'asyncio',
32
+ 'contextlib', 'hashlib', 'base64', 'tempfile', 'shutil', 'glob', 'argparse', 'unittest', 'threading',
33
+ 'queue', 'socket', 'struct', 'copy', 'warnings', 'traceback', 'inspect', 'textwrap', 'operator',
34
+ 'importlib', 'urllib', 'pickle', 'csv', 'uuid', 'string', 'multiprocessing', 'concurrent', 'sqlite3',
35
+ 'secrets', 'signal', 'types', 'ast', 'email', 'http', 'xml', 'array',
36
+ ]);
37
+ /** Is position `idx` of `line` inside a single- or double-quoted string? (parity of quotes before it) */
38
+ function insideString(line, idx) {
39
+ let sq = 0, dq = 0;
40
+ for (let i = 0; i < idx; i++) {
41
+ const c = line[i];
42
+ if (c === "'")
43
+ sq += 1;
44
+ else if (c === '"')
45
+ dq += 1;
46
+ }
47
+ return sq % 2 === 1 || dq % 2 === 1;
48
+ }
49
+ /** Top-level package of a TS/JS specifier: `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`. */
50
+ function tsTopLevel(spec) {
51
+ const parts = spec.split('/');
52
+ return spec.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
53
+ }
54
+ function externalImportsOf(source, lang) {
55
+ const out = new Set();
56
+ try {
57
+ let src = source.replace(/\r\n?/g, '\n'); // CRLF/CR → LF first
58
+ if (lang === 'ts') {
59
+ // Line-START-anchored block comments only: kills a commented-out import block WITHOUT letting a
60
+ // string-embedded "/*" swallow the real code after it (and keeps the scan linear — the engine
61
+ // only attempts matches at line starts).
62
+ src = src.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, '');
63
+ src = src.replace(/`[^`]*`/g, '``'); // template literals are data
64
+ src = src.replace(/(?<!:)\/\/.*$/gm, ''); // line comments; `https://` survives
65
+ const add = (raw) => {
66
+ if (raw === undefined || raw === '' || raw.startsWith('.') || raw.startsWith('node:'))
67
+ return;
68
+ const pkg = tsTopLevel(raw);
69
+ if (pkg !== '' && !TS_BUILTINS.has(pkg))
70
+ out.add(pkg);
71
+ };
72
+ // A from-clause ENDS some line — any non-quote prefix, so prettier's `} from 'pkg'` is caught.
73
+ for (const m of src.matchAll(/^[^'"`\n]*\bfrom\s*['"]([^'"\n]+)['"]/gm))
74
+ add(m[1]);
75
+ for (const m of src.matchAll(/^\s*import\s*['"]([^'"\n]+)['"]/gm))
76
+ add(m[1]); // side-effect import
77
+ // require()/dynamic import() are position-free, so reject matches sitting inside a string
78
+ // literal (quote parity before the match on its line) and method calls (`loader.require`).
79
+ for (const line of src.split('\n')) {
80
+ for (const m of line.matchAll(/(?<!\.)\brequire\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) {
81
+ if (!insideString(line, m.index ?? 0))
82
+ add(m[1]);
83
+ }
84
+ for (const m of line.matchAll(/\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) {
85
+ if (!insideString(line, m.index ?? 0))
86
+ add(m[1]);
87
+ }
88
+ }
89
+ }
90
+ else {
91
+ src = src.replace(/"""[\s\S]*?"""/g, '').replace(/'''[\s\S]*?'''/g, ''); // docstrings are data
92
+ src = src.split('\n').map((l) => l.replace(/#.*$/, '')).join('\n');
93
+ const add = (raw) => {
94
+ if (raw === undefined || raw === '' || raw.startsWith('.'))
95
+ return;
96
+ const pkg = raw.split('.')[0];
97
+ if (pkg !== '' && !PY_STDLIB.has(pkg))
98
+ out.add(pkg);
99
+ };
100
+ for (const m of src.matchAll(/^\s*import\s+(.+)$/gm)) {
101
+ // `import a, b as c` — every comma part counts; sanitize each to a leading dotted name so a
102
+ // trailing `;`/comment fragment can never fabricate a package token.
103
+ for (const part of m[1].split(','))
104
+ add(/^[A-Za-z_][\w.]*/.exec(part.trim())?.[0]);
105
+ }
106
+ for (const m of src.matchAll(/^\s*from\s+([.\w]+)\s+import\b/gm))
107
+ add(m[1]);
108
+ }
109
+ }
110
+ catch {
111
+ return out; // never throw — observe-first
112
+ }
113
+ return out;
114
+ }
115
+ function dependencyDelta(before, after, lang) {
116
+ const had = externalImportsOf(before, lang);
117
+ return [...externalImportsOf(after, lang)].filter((x) => !had.has(x)).sort();
118
+ }
@@ -114,6 +114,27 @@ export declare function changedAnchoredAspecs(root: string): string[] | undefine
114
114
  * which produces no violation. Failing closed here would block every ungoverned scratch directory.
115
115
  */
116
116
  export declare function unanchoredChangedSources(root: string): string[] | undefined;
117
+ export interface DependencyReappraisal {
118
+ file: string;
119
+ aspecs: string[];
120
+ packages: string[];
121
+ }
122
+ export interface ReappraisalIO {
123
+ status?: () => string | undefined;
124
+ show?: (rel: string) => string | undefined;
125
+ read?: (rel: string) => string | undefined;
126
+ }
127
+ export declare function dependencyReappraisals(root: string, isApproved: (id: string) => boolean, io?: ReappraisalIO): DependencyReappraisal[];
128
+ export declare function reappraisalWarningLine(reaps: DependencyReappraisal[]): string | null;
129
+ export declare function escalateReappraisals(root: string, reaps: DependencyReappraisal[], opts: {
130
+ autonomous: boolean;
131
+ enqueue: (root: string, req: {
132
+ kind: string;
133
+ target: string;
134
+ why: string;
135
+ }) => boolean;
136
+ readQueueLines?: (root: string) => string[];
137
+ }): number;
117
138
  /**
118
139
  * @implements A-SPEC-453
119
140
  * ART-5 evidence: approvals that happened without the approving act.
@@ -36,6 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MAX_CONSECUTIVE_BLOCKS = void 0;
37
37
  exports.changedAnchoredAspecs = changedAnchoredAspecs;
38
38
  exports.unanchoredChangedSources = unanchoredChangedSources;
39
+ exports.dependencyReappraisals = dependencyReappraisals;
40
+ exports.reappraisalWarningLine = reappraisalWarningLine;
41
+ exports.escalateReappraisals = escalateReappraisals;
39
42
  exports.unrecordedApprovals = unrecordedApprovals;
40
43
  exports.rolledBackLedgers = rolledBackLedgers;
41
44
  exports.governanceLostPreflight = governanceLostPreflight;
@@ -50,6 +53,9 @@ exports.readGuardCount = readGuardCount;
50
53
  exports.writeGuardCount = writeGuardCount;
51
54
  const fs = __importStar(require("node:fs"));
52
55
  const npx_bin_1 = require("../project/npx-bin");
56
+ const approval_queue_1 = require("../governance/approval-queue");
57
+ const dependency_delta_1 = require("../guardrail/dependency-delta");
58
+ const autonomy_1 = require("../governance/autonomy");
53
59
  const risk_gate_1 = require("../guardrail/risk-gate");
54
60
  const json_state_1 = require("../project/json-state");
55
61
  const node_child_process_1 = require("node:child_process");
@@ -74,7 +80,9 @@ function changedAnchoredAspecs(root) {
74
80
  const VENDORED = /^(?:reference|node_modules|dist|build|vendor|third_party)\//;
75
81
  let raw;
76
82
  try {
77
- raw = (0, node_child_process_1.execFileSync)('git', ['status', '--porcelain', '-uall'], {
83
+ // @implements A-SPEC-560.3 quotepath OFF, or a non-ASCII-named source arrives octal-escaped
84
+ // and its ART-8 evidence silently vanishes (shared defect with the reappraisal detector).
85
+ raw = (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'status', '--porcelain', '-uall'], {
78
86
  cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
79
87
  });
80
88
  }
@@ -120,7 +128,8 @@ function unanchoredChangedSources(root) {
120
128
  try {
121
129
  // `-uall`: without it git folds a wholly-untracked directory into one `?? src/` line and the
122
130
  // files inside it are never seen — which is precisely where a bypassed write lands.
123
- raw = (0, node_child_process_1.execFileSync)('git', ['status', '--porcelain', '-uall'], {
131
+ // @implements A-SPEC-560.3 quotepath OFF (same octal-escape blindness as the siblings).
132
+ raw = (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'status', '--porcelain', '-uall'], {
124
133
  cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
125
134
  });
126
135
  }
@@ -148,6 +157,141 @@ function unanchoredChangedSources(root) {
148
157
  }
149
158
  return out;
150
159
  }
160
+ const REAPPRAISAL_VENDORED = /^(?:reference|node_modules|dist|build|vendor|third_party)\//;
161
+ function depLangOf(rel) {
162
+ if (/\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(rel))
163
+ return 'ts';
164
+ if (/\.py$/.test(rel))
165
+ return 'py';
166
+ return undefined;
167
+ }
168
+ function dependencyReappraisals(root, isApproved, io = {}) {
169
+ // @implements A-SPEC-560.3 — quotepath OFF: with the default core.quotepath, a Korean- (or any
170
+ // non-ASCII/quote-) named file arrives octal-escaped, the read then ENOENTs, and the signal is
171
+ // silently lost. Measured: 2 of 4 drifting files detected before this flag.
172
+ const status = io.status ?? (() => {
173
+ try {
174
+ return (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'status', '--porcelain', '-uall'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() });
175
+ }
176
+ catch {
177
+ return undefined;
178
+ }
179
+ });
180
+ const show = io.show ?? ((rel) => {
181
+ try {
182
+ return (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'show', `HEAD:${rel}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() });
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ });
188
+ const read = io.read ?? ((rel) => {
189
+ try {
190
+ return fs.readFileSync(path.join(root, rel), 'utf8');
191
+ }
192
+ catch {
193
+ return undefined;
194
+ }
195
+ });
196
+ let raw;
197
+ try {
198
+ raw = status();
199
+ }
200
+ catch {
201
+ raw = undefined;
202
+ }
203
+ if (raw === undefined)
204
+ return [];
205
+ const out = [];
206
+ for (const line of raw.split('\n')) {
207
+ if (line.trim() === '')
208
+ continue;
209
+ let rel = line.slice(3).trim().replace(/^"|"$/g, '');
210
+ if (rel.includes(' -> '))
211
+ rel = rel.split(' -> ')[1]; // renames name the destination
212
+ const lang = depLangOf(rel);
213
+ if (lang === undefined || REAPPRAISAL_VENDORED.test(rel))
214
+ continue;
215
+ let after;
216
+ try {
217
+ after = read(rel);
218
+ }
219
+ catch {
220
+ after = undefined;
221
+ }
222
+ if (after === undefined)
223
+ continue; // deleted / unreadable
224
+ const aspecs = [...new Set([...after.matchAll(/@implements\s+(A-SPEC-\d+(?:\.\d+)?)/g)].map((m) => m[1]))].filter(isApproved).sort();
225
+ if (aspecs.length === 0)
226
+ continue; // not inside an APPROVED A-SPEC scope
227
+ let before;
228
+ try {
229
+ before = show(rel);
230
+ }
231
+ catch {
232
+ before = undefined;
233
+ }
234
+ if (before === undefined)
235
+ continue; // no HEAD version = a new file, not in-scope drift
236
+ let packages = [];
237
+ try {
238
+ packages = (0, dependency_delta_1.dependencyDelta)(before, after, lang);
239
+ }
240
+ catch {
241
+ packages = [];
242
+ }
243
+ if (packages.length > 0)
244
+ out.push({ file: rel, aspecs, packages });
245
+ }
246
+ return out;
247
+ }
248
+ function reappraisalWarningLine(reaps) {
249
+ if (reaps.length === 0)
250
+ return null;
251
+ return reaps.map((r) => `[Holmes-Kit] spec-reappraisal: ${r.file} introduces external dependency ${r.packages.join(', ')} inside approved ${r.aspecs.join(', ')} scope — this looks like an architecture change; update the spec or open a sub-slice.`).join('\n');
252
+ }
253
+ // @implements A-SPEC-560.3 — decision-respecting escalation: a reappraisal the owner already decided
254
+ // (granted/denied in the queue ledger) must not re-pend every turn while the tree stays dirty, and an
255
+ // already-pending one needs no duplicate append. A reader failure fails OPEN (the signal survives).
256
+ function escalateReappraisals(root, reaps, opts) {
257
+ if (!opts.autonomous)
258
+ return 0;
259
+ const readLines = opts.readQueueLines ?? ((r) => fs.readFileSync(path.join(r, approval_queue_1.QUEUE_RELPATH), 'utf8').split('\n'));
260
+ let lines;
261
+ try {
262
+ lines = readLines(root);
263
+ }
264
+ catch {
265
+ lines = [];
266
+ } // a broken reader fails OPEN — the signal survives
267
+ let n = 0;
268
+ for (const r of reaps) {
269
+ try {
270
+ const id = (0, approval_queue_1.approvalRequestId)('spec-reappraisal', r.file);
271
+ let decided = false;
272
+ let lastEvent;
273
+ for (const raw of lines) {
274
+ if (raw.trim() === '')
275
+ continue;
276
+ try {
277
+ const e = JSON.parse(raw);
278
+ if (e.id !== id)
279
+ continue;
280
+ lastEvent = String(e.event);
281
+ if (lastEvent === 'granted' || lastEvent === 'denied')
282
+ decided = true;
283
+ }
284
+ catch { /* a corrupt line is not this id's history */ }
285
+ }
286
+ if (decided || lastEvent === 'requested')
287
+ continue; // decided, or already pending — no re-append
288
+ if (opts.enqueue(root, { kind: 'spec-reappraisal', target: r.file, why: `new external dependency ${r.packages.join(', ')} inside approved ${r.aspecs.join(', ')} — spec update or sub-slice needed` }))
289
+ n += 1;
290
+ }
291
+ catch { /* enqueue failure is best-effort; the warning still surfaced */ }
292
+ }
293
+ return n;
294
+ }
151
295
  /**
152
296
  * @implements A-SPEC-453
153
297
  * ART-5 evidence: approvals that happened without the approving act.
@@ -769,6 +913,24 @@ if (require.main === module) {
769
913
  let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec });
770
914
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
771
915
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
916
+ // @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
917
+ // in-scope source that newly introduces an external dependency is an architecture-drift signal.
918
+ // Manual mode warns; autonomy mode also enqueues it so the owner sees the drift async. Fail-open.
919
+ // @implements A-SPEC-560.3 — placed BEFORE the guard decisions and their early returns, so a
920
+ // degraded-yield turn (guard state unwritable) still surfaces the drift; stderr-only, so no
921
+ // verdict path is affected.
922
+ try {
923
+ const approvedIds = new Set(specs.filter((s) => s.type === 'A-SPEC' && s.status === 'approved').map((s) => s.id));
924
+ const reaps = dependencyReappraisals(stopProjectRoot(), (id) => approvedIds.has(id));
925
+ const line = reappraisalWarningLine(reaps);
926
+ if (line)
927
+ process.stderr.write(`${line}\n`);
928
+ const nowIso = new Date().toISOString();
929
+ if (line && (0, autonomy_1.autonomousApprovalEnabled)(process.env, stopProjectRoot(), nowIso)) {
930
+ escalateReappraisals(stopProjectRoot(), reaps, { autonomous: true, enqueue: approval_queue_1.enqueueApprovalRequest });
931
+ }
932
+ }
933
+ catch { /* the reappraisal signal is advisory; a failure never affects the stop verdict */ }
772
934
  if (out.tracked && out.tracked.length > 0) {
773
935
  process.stderr.write(`[Holmes-Kit] ART-8 RED-first (track): ${out.tracked.map((t) => t.detail).join(' | ')}\n`);
774
936
  }
@@ -77,7 +77,15 @@ function cleanSubprocessEnv(env = process.env) {
77
77
  * evidence run would disarm every test that verifies a gate, and that contamination masks red as
78
78
  * green — the worse direction of the two.
79
79
  */
80
- const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE']);
80
+ // @implements A-SPEC-561.1
81
+ // `HOLMES_AUTONOMOUS_APPROVAL` joins the list for the SAME reason, pointing the same way as the
82
+ // HOLMES_SPECS incident above: measured 2026-09-06, a project that opted into autonomy
83
+ // (`init --autonomy` persists the switch into .mcp.json) starts its MCP server with that env, the
84
+ // server's `test_run` handed it to the jest children, and REQ-551's autonomy branch then skipped the
85
+ // elicitation path — 9 elicitation tests red on a tree whose full suite was green, twice, and
86
+ // identically on the previous commit. A posture is an approval channel, never a property of the code
87
+ // under test; an evidence run must judge the same in an autonomous workspace and a human-gated one.
88
+ const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE', 'HOLMES_AUTONOMOUS_APPROVAL']);
81
89
  function cleanTestEnv(env = process.env) {
82
90
  const cleaned = cleanSubprocessEnv(env);
83
91
  for (const k of Object.keys(cleaned)) {
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.13.0",
4
+ "version": "0.14.0",
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",