@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 +47 -0
- package/README.md +3 -2
- package/dist/.build-id +1 -1
- package/dist/holmes/governance/autonomy.d.ts +1 -0
- package/dist/holmes/governance/autonomy.js +26 -1
- package/dist/holmes/guardrail/dependency-delta.d.ts +3 -0
- package/dist/holmes/guardrail/dependency-delta.js +118 -0
- package/dist/holmes/hooks/stop.d.ts +21 -0
- package/dist/holmes/hooks/stop.js +164 -2
- package/dist/holmes/project/root.js +9 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|