@holmes-lab/holmes-kit 0.20.0 → 0.20.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 +84 -0
- package/README.md +1 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/doctor-check.d.ts +15 -0
- package/dist/holmes/cli/doctor-check.js +2 -0
- package/dist/holmes/cli/doctor.d.ts +2 -8
- package/dist/holmes/cli/doctor.js +12 -3
- package/dist/holmes/cli/gitignore-merge.js +10 -0
- package/dist/holmes/cli/npx-cache-check.d.ts +16 -0
- package/dist/holmes/cli/npx-cache-check.js +130 -0
- package/dist/holmes/cli/probe-process.d.ts +17 -2
- package/dist/holmes/cli/probe-process.js +44 -11
- package/dist/holmes/governance/approval-queue.js +5 -1
- package/dist/holmes/governance/autonomy.js +6 -0
- package/dist/holmes/guardrail/scope-judgment.d.ts +12 -1
- package/dist/holmes/guardrail/scope-judgment.js +31 -2
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +17 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +51 -2
- package/dist/holmes/mcp/handlers.d.ts +8 -0
- package/dist/holmes/mcp/handlers.js +3 -1
- package/dist/holmes/mcp/server-specs-dir.d.ts +2 -0
- package/dist/holmes/mcp/server-specs-dir.js +71 -0
- package/dist/holmes/mcp/server.js +10 -1
- package/dist/holmes/project/execution-context.js +4 -3
- package/dist/holmes/project/root.d.ts +9 -0
- package/dist/holmes/project/root.js +18 -0
- package/dist/holmes/review/test-runner.js +4 -1
- package/dist/holmes/rtm/test-scope.js +4 -1
- package/dist/holmes/spec/entity-integration.d.ts +9 -0
- package/dist/holmes/spec/entity-integration.js +20 -7
- package/dist/holmes/spec/entity-store.js +5 -3
- package/dist/holmes/spec/entity-transaction.d.ts +16 -0
- package/dist/holmes/spec/entity-transaction.js +42 -2
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,90 @@ 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.20.2] - 2026-09-16
|
|
9
|
+
|
|
10
|
+
Two findings the Windows cycle recorded and could not close, closed — without giving autonomy or the
|
|
11
|
+
drift check one inch of slack.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **A sealed spec can now admit a root-level file** (A-SPEC-654). `Files to Touch` tokens had to contain
|
|
15
|
+
a slash, so a spec listing `- jest.config.js` produced no token at all and its own anchored write was
|
|
16
|
+
refused as "outside its Files to Touch scope" — measured 2026-09-15 on Windows, where A-SPEC-652.4 had
|
|
17
|
+
to be redesigned around it. A list item whose first word is a filename with an extension is now a token;
|
|
18
|
+
a filename mentioned inside prose, a version-like item (`- 0.20.1`) and a bare word still yield nothing,
|
|
19
|
+
and the enforcer still admits exactly that one path.
|
|
20
|
+
- **A CRLF checkout is no longer reported as skill drift.** Under `core.autocrlf=true`, git rewrote the
|
|
21
|
+
skill files the installer writes with LF, so `doctor` reported three untouched skills as `drifted` and
|
|
22
|
+
advised a refresh that produced LF for the next checkout to convert straight back (measured 2026-09-16,
|
|
23
|
+
Windows). `init`'s `.gitattributes` block now declares `.claude/skills/**/SKILL.md` and
|
|
24
|
+
`.agents/skills/**/SKILL.md` as `text eol=lf`, so checkout stops re-creating the difference. One
|
|
25
|
+
`holmes-kit skills refresh` converges an existing CRLF working tree.
|
|
26
|
+
|
|
27
|
+
### Unchanged on purpose
|
|
28
|
+
- **Autonomy did not widen with the new tokens.** Measured before the change: 36 A-SPECs in this
|
|
29
|
+
repository have prose-only Files to Touch and 8 of them would have gained tokens — one mentioning
|
|
30
|
+
`handlers.ts` in a sentence would have flipped from human approval to self-approval. The grader now
|
|
31
|
+
requires at least one directory-scoped token, so a spec whose tokens are all root-level files still asks
|
|
32
|
+
a human: a root-level file is project-wide configuration. A test walks every A-SPEC in the store and
|
|
33
|
+
asserts each verdict equals the pre-change verdict.
|
|
34
|
+
- **The drift comparison is still byte-exact.** Folding CRLF into it was implemented, measured against
|
|
35
|
+
A-SPEC-190 §9a — whitespace-touched files are `drifted` *and a refresh reclaims them* — and reverted:
|
|
36
|
+
it would have made the first of those three mutations invisible rather than recoverable. That refusal
|
|
37
|
+
is pinned in the new suite.
|
|
38
|
+
|
|
39
|
+
### Measured
|
|
40
|
+
- macOS: 503 suites / 6,461 passed / 11 skipped; official `test_run` green, last-green recorded.
|
|
41
|
+
- Still open: the Windows residual single-assertion reds. They need the failure text from the Windows box
|
|
42
|
+
(`npx jest --json`); nothing in this repository names why they fail, and guessing from a macOS run would
|
|
43
|
+
be inventing evidence.
|
|
44
|
+
|
|
45
|
+
## [0.20.1] - 2026-09-16
|
|
46
|
+
|
|
47
|
+
The Windows release. 0.20.0's external matrix was run natively on Windows 11 (Node 24, npm 12, pytest 9)
|
|
48
|
+
and it found defects no POSIX run could see; this patch fixes them, then reconciles the result with the
|
|
49
|
+
POSIX suite. Eighteen A-SPECs (651.1–651.5, 652.1–652.6, 653), each RED-first with an official run.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- **Every 3-way merge on Windows failed** (A-SPEC-652.6). `entity_integrate`'s merge tool pointed git's
|
|
53
|
+
global config at `\\.\nul`, which Windows git cannot open, so each merge reported
|
|
54
|
+
`merge-tool-failure`. It now uses `NUL` on win32 (`os.devNull` elsewhere).
|
|
55
|
+
- **One path, two spellings** (A-SPEC-651.1). Reaching a workspace through an 8.3 short name
|
|
56
|
+
(`C:\Users\SUNGNA~1\…`) made entity adoption refuse its own store as `foreign-store` and keyed pytest
|
|
57
|
+
outcomes by a `../../…` path. Entity binding, execution context and the pytest cwd now judge with the
|
|
58
|
+
same canonical spelling `resolveProjectRoot` uses.
|
|
59
|
+
- **The MCP server opens the right spec store** (A-SPEC-652.2). The store is resolved from
|
|
60
|
+
`CLAUDE_PROJECT_DIR`, then the nearest `.mcp.json`/`.ax` ancestor, then the cwd, and reported as an
|
|
61
|
+
absolute path; refusals name it.
|
|
62
|
+
- **Lock retirement survives Windows' transient `EPERM`** (A-SPEC-652.5): the rename retries briefly, and a
|
|
63
|
+
reader treats those codes as a generation change instead of an error.
|
|
64
|
+
- **A doctor probe that times out is retried once** and the failure reason is written into the detail
|
|
65
|
+
(A-SPEC-651.2) — a healthy install no longer exits 1 on a cold npx cache.
|
|
66
|
+
- **`spec_slice_init` never overwrites** (A-SPEC-651.5): numbering counts both stores and an existing file
|
|
67
|
+
refuses the whole slice; **`spec_slice_approve` picks the mirror T-SPEC** (`A-SPEC-652.1 → T-SPEC-652.1`)
|
|
68
|
+
instead of the first base-number match, and refuses by name when there is none (A-SPEC-652.1).
|
|
69
|
+
- **`test-support` modules no longer ship** in `dist` or the npm package.
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
- **`review-resolve` requests reach the decision inbox** (A-SPEC-651.4): resolving a critical finding
|
|
73
|
+
through `review_record` files a request `holmes-kit approve` can grant, and the grant is consumed.
|
|
74
|
+
- **`doctor` names corrupted npx cache entries** (`npx cache` check, A-SPEC-652.2) — the cause of an MCP
|
|
75
|
+
server that dies at session start with `ENOENT … _npx/<hash>/package.json`. Filesystem reads only
|
|
76
|
+
(measured 0.18 ms).
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
- **The test-case counter recognises the reporting skip** (A-SPEC-653): `posixOnly(reason)(…)` and its
|
|
80
|
+
siblings count as cases for ART-4, so a platform-conditional suite reports `skipped` on the other OS
|
|
81
|
+
instead of returning early and reading as a pass. Three suites converted.
|
|
82
|
+
- Windows Jest budgets scale by a measured factor of 4 (test infrastructure only, A-SPEC-652.4).
|
|
83
|
+
- `doctor`'s `Check`/`Level` types moved to their own module (an import cycle removed, A-SPEC-653).
|
|
84
|
+
|
|
85
|
+
### Measured, stated limits
|
|
86
|
+
- Windows native external matrix (`EVIDENCE-windows-native-matrix-2026-09-14.md`): every step passes from the
|
|
87
|
+
long-name path; the full Windows Jest run still carries pre-existing single-assertion reds that these
|
|
88
|
+
slices did not scope (`EVIDENCE-windows-followup-2026-09-15.md`). macOS: 501 suites green.
|
|
89
|
+
- Open findings: the write gate cannot admit a root-level file named in Files to Touch (a token needs a
|
|
90
|
+
slash); `doctor`'s recovery-skill drift check compares bytes and reads CRLF checkouts as drifted.
|
|
91
|
+
|
|
8
92
|
## [0.20.0] - 2026-09-14
|
|
9
93
|
|
|
10
94
|
The Concurrent Multi-Agent Workspace release. Several agents, machines and clones now work on one spec
|
package/README.md
CHANGED
|
@@ -302,7 +302,7 @@ Full per-cell bases (and every stated limit) live in [docs/language-support.md](
|
|
|
302
302
|
| **macOS** | Apple Silicon (arm64) / Intel (x64) | ✅ Tier 1 | macOS 12+ (Full hook enforcement) |
|
|
303
303
|
| **Linux** | x86_64 / arm64 | ✅ Tier 1 | Ubuntu, Debian, Fedora, Arch, RHEL. **Field-validated 2026-09-14** (Ubuntu 24.04 aarch64, OrbStack): tarball install, `init --agent all`, doctor, pytest RED→GREEN, Git integration, renumber, derived-state recovery and the approval round-trip, all from the external matrix probe |
|
|
304
304
|
| **Windows (WSL2)** | x86_64 | ✅ Tier 1 | WSL2 Ubuntu/Debian recommended |
|
|
305
|
-
| **Windows Native** | x86_64 | ✅ Tier 1 | Windows 10/11 (Node.js 20+; prebuilt natives, no build tools needed in the common case). **Field-validated 2026-
|
|
305
|
+
| **Windows Native** | x86_64 | ✅ Tier 1 | Windows 10/11 (Node.js 20+; prebuilt natives, no build tools needed in the common case). **Field-validated 2026-09-14/15** (Windows 11, Node 24, npm 12, pytest 9): tarball install, `init --agent all`, doctor, pytest RED→GREEN, adoption, Git integration, renumber, derived-state recovery and the approval round-trip — the external matrix passes from the long-name path; 0.20.1 fixes the defects it found (8.3 short paths, the merge tool's global config, transient `EPERM`, npx cache corruption). Under **npm 12** declare `allowScripts` for `better-sqlite3` and `sharp` in the consuming project (doctor prints the exact entry). |
|
|
306
306
|
|
|
307
307
|
> **Runtime Requirement**: Node.js `>= 20.0.0` (LTS recommended)
|
|
308
308
|
>
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5796f530-mu3b082d
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The doctor's verdict shape, in a module of its own.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-09-16: `npx-cache-check.ts` imported `type Check` from `doctor.ts` while `doctor.ts`
|
|
5
|
+
* imported the check — an import cycle (A-SPEC-574.3), red on every POSIX run after the Windows
|
|
6
|
+
* merge. A check module must be able to name the shape it returns without importing the runner.
|
|
7
|
+
*/
|
|
8
|
+
export type Level = 'PASS' | 'WARN' | 'FAIL';
|
|
9
|
+
export interface Check {
|
|
10
|
+
name: string;
|
|
11
|
+
level: Level;
|
|
12
|
+
detail: string;
|
|
13
|
+
fix?: string;
|
|
14
|
+
remediate?: () => boolean;
|
|
15
|
+
}
|
|
@@ -11,14 +11,8 @@ export declare function resolvesToPackage(filePath: string, packageRoot: string)
|
|
|
11
11
|
* hooks fail OPEN, so a broken install is silent: governance simply never fires. This command makes
|
|
12
12
|
* that loud. Every check reports PASS/WARN/FAIL plus a concrete remediation.
|
|
13
13
|
*/
|
|
14
|
-
|
|
15
|
-
export
|
|
16
|
-
name: string;
|
|
17
|
-
level: Level;
|
|
18
|
-
detail: string;
|
|
19
|
-
fix?: string;
|
|
20
|
-
remediate?: () => boolean;
|
|
21
|
-
}
|
|
14
|
+
import type { Check, Level } from './doctor-check';
|
|
15
|
+
export type { Check, Level } from './doctor-check';
|
|
22
16
|
export interface DoctorOptions {
|
|
23
17
|
run?: ProbeRunner;
|
|
24
18
|
autoFix?: boolean;
|
|
@@ -64,6 +64,7 @@ const install_scripts_policy_1 = require("../project/install-scripts-policy");
|
|
|
64
64
|
const native_deps_1 = require("./native-deps");
|
|
65
65
|
const tier_1 = require("../semantic/tier");
|
|
66
66
|
const probe_process_1 = require("./probe-process");
|
|
67
|
+
const npx_cache_check_1 = require("./npx-cache-check");
|
|
67
68
|
const path = __importStar(require("node:path"));
|
|
68
69
|
const role_policy_1 = require("../governance/role-policy");
|
|
69
70
|
const blind_spots_1 = require("../guardrail/blind-spots");
|
|
@@ -515,6 +516,11 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
515
516
|
// before it answers (a false FAIL this check produced on its first run).
|
|
516
517
|
if (built) {
|
|
517
518
|
checks.push(await mcpHandshakeCheck(packageRoot));
|
|
519
|
+
// @implements A-SPEC-652.2 — the handshake above proves THIS install; the wiring may run a
|
|
520
|
+
// cached npx copy instead, and a corrupt cache entry dies before any handshake (measured
|
|
521
|
+
// 2026-09-15: "Connection closed" at session start, npm ENOENT on the entry's package.json).
|
|
522
|
+
if (target)
|
|
523
|
+
checks.push((0, npx_cache_check_1.checkNpxCache)(target, { env: process.env, homedir: os.homedir(), platform: process.platform, exists: fs.existsSync, readFile: (p) => fs.readFileSync(p, 'utf8'), readdir: (p) => fs.readdirSync(p) }));
|
|
518
524
|
// @implements A-SPEC-323
|
|
519
525
|
// Every wired harness, actually started. The check above proves THIS INSTALL can serve; these
|
|
520
526
|
// prove the wiring each harness will really run reaches a server. Measured: 367ms for three.
|
|
@@ -1340,9 +1346,11 @@ cwd) {
|
|
|
1340
1346
|
return;
|
|
1341
1347
|
done = true;
|
|
1342
1348
|
clearTimeout(timer);
|
|
1343
|
-
|
|
1349
|
+
// @implements A-SPEC-651.2 — the platform the spawn was adapted for is the platform the cleanup
|
|
1350
|
+
// is judged on, and an unconfirmed cleanup names its reason (timeout vs refusal vs no close).
|
|
1351
|
+
void (0, probe_process_1.stopProbeProcess)(child, { platform }).then(released => resolve(released
|
|
1344
1352
|
? { name: 'mcp wiring spawn', level, detail, fix }
|
|
1345
|
-
: { name: 'mcp wiring spawn', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed`,
|
|
1353
|
+
: { name: 'mcp wiring spawn', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed (${(0, probe_process_1.lastProbeStopReason)() ?? 'unknown'})`,
|
|
1346
1354
|
fix: `${fix ?? ''} Close the remaining diagnostic process for \`${command}\`, then retry doctor.` }));
|
|
1347
1355
|
};
|
|
1348
1356
|
const timer = setTimeout(() => finish('FAIL', `wiring did not answer initialize within ${timeoutMs / 1000}s: \`${quoted}\``, `Re-run \`holmes-kit init\` in the target to rewrite the wiring for \`${command}\`, then re-run doctor.`), timeoutMs);
|
|
@@ -1414,9 +1422,10 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
|
|
|
1414
1422
|
removed = true;
|
|
1415
1423
|
}
|
|
1416
1424
|
catch { /* signal cleanup retains the path for a later attempt */ }
|
|
1425
|
+
const why = !released ? ((0, probe_process_1.lastProbeStopReason)() ?? 'unknown') : 'temp dir not removed';
|
|
1417
1426
|
resolve(released && removed
|
|
1418
1427
|
? { name: 'MCP server', level, detail, fix }
|
|
1419
|
-
: { name: 'MCP server', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed`,
|
|
1428
|
+
: { name: 'MCP server', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed (${why})`,
|
|
1420
1429
|
fix: 'Close the remaining diagnostic process and retry doctor.' });
|
|
1421
1430
|
});
|
|
1422
1431
|
};
|
|
@@ -61,6 +61,16 @@ exports.ATTRIBUTE_LINES = [
|
|
|
61
61
|
'# 남는 체인 이음새는 `holmes-kit ledger rechain`의 일이다.',
|
|
62
62
|
'.ax/ledger/*.jsonl merge=union',
|
|
63
63
|
'.ax/approvals/queue.jsonl merge=union',
|
|
64
|
+
// @implements A-SPEC-654
|
|
65
|
+
// The installer writes these with LF and doctor compares them byte for byte (A-SPEC-190 §9a: a
|
|
66
|
+
// whitespace-touched file is `drifted` so a refresh can reclaim it). Under `core.autocrlf=true`
|
|
67
|
+
// checkout rewrote them to CRLF, so three untouched skills read `drifted` and the advised refresh
|
|
68
|
+
// produced LF for the next checkout to convert straight back — measured 2026-09-16 on Windows.
|
|
69
|
+
// Declaring the files settles it where the disagreement is, instead of teaching the drift check to
|
|
70
|
+
// ignore a difference it exists to catch.
|
|
71
|
+
'# @implements A-SPEC-654 — 설치기가 LF 로 쓰는 파일은 체크아웃에서 CRLF 로 바뀌지 않는다.',
|
|
72
|
+
'.claude/skills/**/SKILL.md text eol=lf',
|
|
73
|
+
'.agents/skills/**/SKILL.md text eol=lf',
|
|
64
74
|
];
|
|
65
75
|
/** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
|
|
66
76
|
* and user-content preservation are inherited rather than re-implemented. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Check } from './doctor-check';
|
|
2
|
+
export interface NpxCacheDeps {
|
|
3
|
+
env: NodeJS.ProcessEnv;
|
|
4
|
+
homedir: string;
|
|
5
|
+
platform: NodeJS.Platform;
|
|
6
|
+
exists: (p: string) => boolean;
|
|
7
|
+
readFile: (p: string) => string;
|
|
8
|
+
readdir: (p: string) => string[];
|
|
9
|
+
}
|
|
10
|
+
/** The `-p <pkg>@<ver>` pin of the target's holmes-kit wiring, or null when it is not an npx pin. */
|
|
11
|
+
export declare function npxPinOf(target: string, deps: Pick<NpxCacheDeps, 'exists' | 'readFile'>): {
|
|
12
|
+
pkg: string;
|
|
13
|
+
version: string;
|
|
14
|
+
} | null;
|
|
15
|
+
export declare function npmCacheRoot(deps: Pick<NpxCacheDeps, 'env' | 'homedir' | 'platform'>): string;
|
|
16
|
+
export declare function checkNpxCache(target: string, deps: NpxCacheDeps): Check;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.npxPinOf = npxPinOf;
|
|
37
|
+
exports.npmCacheRoot = npmCacheRoot;
|
|
38
|
+
exports.checkNpxCache = checkNpxCache;
|
|
39
|
+
// @implements A-SPEC-652.2
|
|
40
|
+
/**
|
|
41
|
+
* doctor: is the npx cache entry the wiring will run intact?
|
|
42
|
+
*
|
|
43
|
+
* Measured 2026-09-15 on Windows: `.mcp.json` wires `npx -y -p @holmes-lab/holmes-kit@0.19.4
|
|
44
|
+
* holmes-mcp`; npx resolved it to `_npx/a739130c5322c549`, whose root `package.json` had gone, and
|
|
45
|
+
* died with `npm error ENOENT … package.json` before any handshake. The harness reported only
|
|
46
|
+
* "Connection closed". Six entries in that cache had the same shape (two of them this package).
|
|
47
|
+
*
|
|
48
|
+
* Filesystem only — no npm spawn: the cache root is `npm_config_cache`, else the platform default
|
|
49
|
+
* (`%LOCALAPPDATA%\npm-cache` on win32, `~/.npm` elsewhere). Only entries holding the PINNED version
|
|
50
|
+
* of the pinned package are judged; a corrupt entry of another version is not what this wiring runs.
|
|
51
|
+
*/
|
|
52
|
+
const path = __importStar(require("node:path"));
|
|
53
|
+
const NAME = 'npx cache';
|
|
54
|
+
/** The `-p <pkg>@<ver>` pin of the target's holmes-kit wiring, or null when it is not an npx pin. */
|
|
55
|
+
function npxPinOf(target, deps) {
|
|
56
|
+
const mcpPath = path.join(target, '.mcp.json');
|
|
57
|
+
if (!deps.exists(mcpPath))
|
|
58
|
+
return null;
|
|
59
|
+
let server;
|
|
60
|
+
try {
|
|
61
|
+
server = JSON.parse(deps.readFile(mcpPath)).mcpServers?.['holmes-kit'];
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!server || !/^npx(\.cmd)?$/i.test(String(server.command ?? '')) || !Array.isArray(server.args))
|
|
67
|
+
return null;
|
|
68
|
+
const at = server.args.indexOf('-p');
|
|
69
|
+
const spec = at >= 0 ? server.args[at + 1] : undefined;
|
|
70
|
+
const m = spec ? /^(@?[^@]+)@([0-9][^\s]*)$/.exec(spec) : null;
|
|
71
|
+
return m ? { pkg: m[1], version: m[2] } : null;
|
|
72
|
+
}
|
|
73
|
+
function npmCacheRoot(deps) {
|
|
74
|
+
const configured = deps.env.npm_config_cache;
|
|
75
|
+
if (configured && configured.trim() !== '')
|
|
76
|
+
return configured;
|
|
77
|
+
if (deps.platform === 'win32') {
|
|
78
|
+
const local = deps.env.LOCALAPPDATA && deps.env.LOCALAPPDATA.trim() !== '' ? deps.env.LOCALAPPDATA : path.join(deps.homedir, 'AppData', 'Local');
|
|
79
|
+
return path.join(local, 'npm-cache');
|
|
80
|
+
}
|
|
81
|
+
return path.join(deps.homedir, '.npm');
|
|
82
|
+
}
|
|
83
|
+
function checkNpxCache(target, deps) {
|
|
84
|
+
const pin = npxPinOf(target, deps);
|
|
85
|
+
if (!pin)
|
|
86
|
+
return { name: NAME, level: 'PASS', detail: 'the holmes-kit wiring is not an npx pin — nothing cached to judge' };
|
|
87
|
+
const npxDir = path.join(npmCacheRoot(deps), '_npx');
|
|
88
|
+
if (!deps.exists(npxDir))
|
|
89
|
+
return { name: NAME, level: 'PASS', detail: `${pin.pkg}@${pin.version} is not cached yet (${npxDir} absent) — the first launch installs it` };
|
|
90
|
+
let hashes = [];
|
|
91
|
+
try {
|
|
92
|
+
hashes = deps.readdir(npxDir);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { name: NAME, level: 'PASS', detail: `${npxDir} could not be listed — nothing to judge` };
|
|
96
|
+
}
|
|
97
|
+
const matching = [];
|
|
98
|
+
const corrupt = [];
|
|
99
|
+
for (const hash of hashes) {
|
|
100
|
+
const entry = path.join(npxDir, hash);
|
|
101
|
+
const pkgJson = path.join(entry, 'node_modules', ...pin.pkg.split('/'), 'package.json');
|
|
102
|
+
if (!deps.exists(pkgJson))
|
|
103
|
+
continue;
|
|
104
|
+
let version;
|
|
105
|
+
try {
|
|
106
|
+
version = JSON.parse(deps.readFile(pkgJson)).version;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (version !== pin.version)
|
|
112
|
+
continue;
|
|
113
|
+
matching.push(hash);
|
|
114
|
+
if (!deps.exists(path.join(entry, 'package.json')))
|
|
115
|
+
corrupt.push(hash);
|
|
116
|
+
}
|
|
117
|
+
if (matching.length === 0)
|
|
118
|
+
return { name: NAME, level: 'PASS', detail: `${pin.pkg}@${pin.version} is not cached yet under ${npxDir} — the first launch installs it` };
|
|
119
|
+
if (corrupt.length === 0)
|
|
120
|
+
return { name: NAME, level: 'PASS', detail: `${matching.length} intact npx cache entr${matching.length === 1 ? 'y' : 'ies'} for ${pin.pkg}@${pin.version}` };
|
|
121
|
+
const dirs = corrupt.map((h) => path.join(npxDir, h));
|
|
122
|
+
const remove = deps.platform === 'win32'
|
|
123
|
+
? dirs.map((d) => `Remove-Item -Recurse -Force "${d}"`).join('; ')
|
|
124
|
+
: dirs.map((d) => `rm -rf ${d}`).join(' && ');
|
|
125
|
+
return {
|
|
126
|
+
name: NAME, level: 'FAIL',
|
|
127
|
+
detail: `npx cache entr${corrupt.length === 1 ? 'y' : 'ies'} ${corrupt.join(', ')} for ${pin.pkg}@${pin.version} ${corrupt.length === 1 ? 'is' : 'are'} missing its package.json — the wiring dies with \`npm error ENOENT\` before the handshake and the harness reports only "Connection closed" (${dirs.join(', ')})`,
|
|
128
|
+
fix: `Delete the corrupt entr${corrupt.length === 1 ? 'y' : 'ies'} and let the next launch reinstall: ${remove}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import { type ChildProcess } from 'node:child_process';
|
|
1
|
+
import { execFile as nodeExecFile, type ChildProcess } from 'node:child_process';
|
|
2
2
|
/** Each POSIX probe gets its own process group; Windows taskkill targets only its owned PID. */
|
|
3
3
|
export declare const PROBE_SPAWN_OPTIONS: {
|
|
4
4
|
detached: boolean;
|
|
5
5
|
windowsHide: boolean;
|
|
6
6
|
};
|
|
7
|
+
/**
|
|
8
|
+
* A-SPEC-651.2 — budgets measured, not guessed. taskkill /T /F finished in 0.56–0.67 s on a quiet
|
|
9
|
+
* box (2026-09-14); the old 2.5 s budget, spent once, read "cleanup could not be confirmed" on a
|
|
10
|
+
* healthy install in 1 of 4 quiet doctor runs and 2 of 3 loaded ones. 8 s each, and a timed-out
|
|
11
|
+
* taskkill gets exactly one more try — a refused one does not (A-SPEC-592: a failed tree kill is never
|
|
12
|
+
* success).
|
|
13
|
+
*/
|
|
14
|
+
export declare const PROBE_KILL_TIMEOUT_MS = 8000;
|
|
15
|
+
export declare const PROBE_CLOSE_TIMEOUT_MS = 8000;
|
|
16
|
+
export interface ProbeStopDeps {
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
execFile?: typeof nodeExecFile;
|
|
19
|
+
}
|
|
20
|
+
/** Why the most recent stopProbeProcess answered false; undefined after a confirmed cleanup. */
|
|
21
|
+
export declare function lastProbeStopReason(): string | undefined;
|
|
7
22
|
export declare function trackProbeProcess(child: ChildProcess): void;
|
|
8
|
-
export declare function stopProbeProcess(child?: ChildProcess): Promise<boolean>;
|
|
23
|
+
export declare function stopProbeProcess(child?: ChildProcess, deps?: ProbeStopDeps): Promise<boolean>;
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PROBE_SPAWN_OPTIONS = void 0;
|
|
3
|
+
exports.PROBE_CLOSE_TIMEOUT_MS = exports.PROBE_KILL_TIMEOUT_MS = exports.PROBE_SPAWN_OPTIONS = void 0;
|
|
4
|
+
exports.lastProbeStopReason = lastProbeStopReason;
|
|
4
5
|
exports.trackProbeProcess = trackProbeProcess;
|
|
5
6
|
exports.stopProbeProcess = stopProbeProcess;
|
|
6
7
|
// @implements A-SPEC-592
|
|
8
|
+
// @implements A-SPEC-651.2
|
|
7
9
|
const node_child_process_1 = require("node:child_process");
|
|
8
10
|
/** Each POSIX probe gets its own process group; Windows taskkill targets only its owned PID. */
|
|
9
11
|
exports.PROBE_SPAWN_OPTIONS = { detached: process.platform !== 'win32', windowsHide: true };
|
|
12
|
+
/**
|
|
13
|
+
* A-SPEC-651.2 — budgets measured, not guessed. taskkill /T /F finished in 0.56–0.67 s on a quiet
|
|
14
|
+
* box (2026-09-14); the old 2.5 s budget, spent once, read "cleanup could not be confirmed" on a
|
|
15
|
+
* healthy install in 1 of 4 quiet doctor runs and 2 of 3 loaded ones. 8 s each, and a timed-out
|
|
16
|
+
* taskkill gets exactly one more try — a refused one does not (A-SPEC-592: a failed tree kill is never
|
|
17
|
+
* success).
|
|
18
|
+
*/
|
|
19
|
+
exports.PROBE_KILL_TIMEOUT_MS = 8000;
|
|
20
|
+
exports.PROBE_CLOSE_TIMEOUT_MS = 8000;
|
|
10
21
|
const states = new WeakMap();
|
|
22
|
+
let lastReason;
|
|
23
|
+
/** Why the most recent stopProbeProcess answered false; undefined after a confirmed cleanup. */
|
|
24
|
+
function lastProbeStopReason() { return lastReason; }
|
|
11
25
|
function trackProbeProcess(child) {
|
|
12
26
|
if (states.has(child))
|
|
13
27
|
return;
|
|
@@ -15,30 +29,45 @@ function trackProbeProcess(child) {
|
|
|
15
29
|
states.set(child, state);
|
|
16
30
|
child.once('close', () => { state.closed = true; });
|
|
17
31
|
}
|
|
18
|
-
|
|
32
|
+
const isTimeout = (error) => {
|
|
33
|
+
const e = error;
|
|
34
|
+
return !!e && (e.killed === true || e.code === null || e.code === undefined);
|
|
35
|
+
};
|
|
36
|
+
async function stopProbeProcess(child, deps = {}) {
|
|
37
|
+
lastReason = undefined;
|
|
19
38
|
if (!child)
|
|
20
39
|
return true;
|
|
21
40
|
trackProbeProcess(child);
|
|
41
|
+
const platform = deps.platform ?? process.platform;
|
|
42
|
+
const execFile = deps.execFile ?? node_child_process_1.execFile;
|
|
22
43
|
let closed = states.get(child).closed;
|
|
23
44
|
let onClose = () => { };
|
|
24
45
|
let closeTimer;
|
|
25
46
|
const closure = new Promise(resolve => {
|
|
26
47
|
onClose = () => { closed = true; clearTimeout(closeTimer); resolve(true); };
|
|
27
48
|
child.once('close', onClose);
|
|
28
|
-
closeTimer = setTimeout(() => resolve(false),
|
|
49
|
+
closeTimer = setTimeout(() => resolve(false), exports.PROBE_CLOSE_TIMEOUT_MS);
|
|
29
50
|
if (closed || !child.pid)
|
|
30
51
|
onClose();
|
|
31
52
|
});
|
|
32
53
|
let stopped = true;
|
|
54
|
+
let stopReason;
|
|
33
55
|
try {
|
|
34
|
-
if (child.pid &&
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
resolve(!error);
|
|
38
|
-
});
|
|
56
|
+
if (child.pid && platform === 'win32' && !closed) {
|
|
57
|
+
const taskkill = () => new Promise(resolve => {
|
|
58
|
+
execFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: exports.PROBE_KILL_TIMEOUT_MS }, error => resolve(error ?? null));
|
|
39
59
|
});
|
|
60
|
+
let error = await taskkill();
|
|
61
|
+
if (error && isTimeout(error))
|
|
62
|
+
error = await taskkill(); // one retry, for a timeout only
|
|
63
|
+
if (error) {
|
|
64
|
+
stopped = false;
|
|
65
|
+
stopReason = isTimeout(error)
|
|
66
|
+
? `taskkill timed out twice (${exports.PROBE_KILL_TIMEOUT_MS} ms each)`
|
|
67
|
+
: `taskkill exited ${String(error.code)}`;
|
|
68
|
+
}
|
|
40
69
|
}
|
|
41
|
-
else if (child.pid &&
|
|
70
|
+
else if (child.pid && platform !== 'win32') {
|
|
42
71
|
const signalGroup = (signal) => {
|
|
43
72
|
try {
|
|
44
73
|
process.kill(-child.pid, signal);
|
|
@@ -56,8 +85,9 @@ async function stopProbeProcess(child) {
|
|
|
56
85
|
signalGroup('SIGKILL');
|
|
57
86
|
}
|
|
58
87
|
}
|
|
59
|
-
catch {
|
|
88
|
+
catch (error) {
|
|
60
89
|
stopped = false;
|
|
90
|
+
stopReason = `kill failed: ${error.message}`;
|
|
61
91
|
}
|
|
62
92
|
// Terminate the tree before closing stdin: an EOF could make the wrapper exit first,
|
|
63
93
|
// leaving Windows taskkill with no parent from which to enumerate its descendants.
|
|
@@ -69,5 +99,8 @@ async function stopProbeProcess(child) {
|
|
|
69
99
|
child.removeListener('close', onClose);
|
|
70
100
|
if (!released)
|
|
71
101
|
child.unref();
|
|
72
|
-
|
|
102
|
+
const ok = stopped && released;
|
|
103
|
+
if (!ok)
|
|
104
|
+
lastReason = stopReason ?? `child never closed within ${exports.PROBE_CLOSE_TIMEOUT_MS} ms`;
|
|
105
|
+
return ok;
|
|
73
106
|
}
|
|
@@ -78,8 +78,12 @@ exports.QUEUE_RELPATH = path.join('.ax', 'approvals', 'queue.jsonl');
|
|
|
78
78
|
// single-shot gate refusals nobody ever meant to decide, burying the real inbox (spec-approve 9,
|
|
79
79
|
// config-write 1) and accumulating raw shell commands in a tracked file. Only these kinds seek a
|
|
80
80
|
// decision; everything else routes to the local per-replica refusal log below. Wired RED-first.
|
|
81
|
+
// @implements A-SPEC-651.4 — `review-resolve` is a decision too: resolving an open critical needs a
|
|
82
|
+
// human, review_record's refusal points that human at `holmes-kit approve`, and without this entry the
|
|
83
|
+
// request went to the refusal log so the inbox stayed empty (measured 2026-09-14 on Windows: two
|
|
84
|
+
// expired session dialogs, then `approve --list` answered "No requests awaiting approval").
|
|
81
85
|
exports.DECISION_KINDS = new Set([
|
|
82
|
-
'spec-approve', 'config-write', 'autonomy-grant', 'spec-reappraisal',
|
|
86
|
+
'spec-approve', 'config-write', 'autonomy-grant', 'spec-reappraisal', 'review-resolve',
|
|
83
87
|
]);
|
|
84
88
|
const REFUSALS_FILE_RE = /^refusals\.([^.]+)\.jsonl$/;
|
|
85
89
|
function refusalsFilename(replica) {
|
|
@@ -178,6 +178,12 @@ function specApprovalAutonomy(spec, resolveParent, descendants) {
|
|
|
178
178
|
return 'hitl';
|
|
179
179
|
if (paths.some(isHighRiskPath))
|
|
180
180
|
return 'hitl'; // gate/governance/taint file
|
|
181
|
+
// @implements A-SPEC-654 — a ROOT-level file is project-wide configuration (the test runner, the
|
|
182
|
+
// manifest, the ignore policy): admitting it at the gate was the point of that slice, but grading
|
|
183
|
+
// it auto would be a widening nobody asked for. A spec earns self-approval only when it also names
|
|
184
|
+
// a directory-scoped surface — the same doubt `paths.length === 0` already encodes.
|
|
185
|
+
if (!paths.some((p) => p.includes('/')))
|
|
186
|
+
return 'hitl';
|
|
181
187
|
return 'auto';
|
|
182
188
|
}
|
|
183
189
|
default:
|
|
@@ -29,7 +29,18 @@ export declare function isTestPath(relPath: string): boolean;
|
|
|
29
29
|
* one bounded fragment, so no ReDoS surface.
|
|
30
30
|
*/
|
|
31
31
|
export declare function matchesFtt(token: string, relPath: string): boolean;
|
|
32
|
-
|
|
32
|
+
export declare function isRootFileToken(token: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none.
|
|
35
|
+
*
|
|
36
|
+
* @implements A-SPEC-654
|
|
37
|
+
* The slashed match alone could not see a ROOT-level file, so a sealed spec listing `- jest.config.js`
|
|
38
|
+
* admitted nothing at all and its own anchored write was refused as out of scope (measured 2026-09-15 on
|
|
39
|
+
* Windows; A-SPEC-652.4 was redesigned around it). A bare filename counts only as a LIST ITEM's first
|
|
40
|
+
* word: `- \`tsconfig.test.json\` (신규)` is a token, while `\`cfg.ts\`, \`ddg.ts\` 를 고친다` — prose, and a
|
|
41
|
+
* word carrying a comma — stays what it always was, nothing. The enforcer is unchanged: a root token
|
|
42
|
+
* admits that exact path (`matchesFtt`), and the grader requires a directory-scoped token of its own.
|
|
43
|
+
*/
|
|
33
44
|
export declare function fttPathTokens(fttText: string): string[];
|
|
34
45
|
export interface ScopeInput {
|
|
35
46
|
relPath: string;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
26
|
exports.isTestPath = isTestPath;
|
|
27
27
|
exports.matchesFtt = matchesFtt;
|
|
28
|
+
exports.isRootFileToken = isRootFileToken;
|
|
28
29
|
exports.fttPathTokens = fttPathTokens;
|
|
29
30
|
exports.judgeScope = judgeScope;
|
|
30
31
|
/** Repo test conventions — the single census every dispensation consumer shares. */
|
|
@@ -63,9 +64,37 @@ function matchesFtt(token, relPath) {
|
|
|
63
64
|
return relPath === tok; // file literal: that file only
|
|
64
65
|
return relPath === tok || relPath.startsWith(tok + '/'); // dir literal: its subtree
|
|
65
66
|
}
|
|
66
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* @implements A-SPEC-654
|
|
69
|
+
* A repository-ROOT file named as a Files-to-Touch item: a bare filename with an extension that starts
|
|
70
|
+
* with a letter, so a version (`0.20.1`) is not a file and a bare word (`README`) is not either.
|
|
71
|
+
*/
|
|
72
|
+
const ROOT_FILE = /^[\w@.-]+\.[A-Za-z][A-Za-z0-9]*$/;
|
|
73
|
+
function isRootFileToken(token) {
|
|
74
|
+
return !token.includes('/') && ROOT_FILE.test(token);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none.
|
|
78
|
+
*
|
|
79
|
+
* @implements A-SPEC-654
|
|
80
|
+
* The slashed match alone could not see a ROOT-level file, so a sealed spec listing `- jest.config.js`
|
|
81
|
+
* admitted nothing at all and its own anchored write was refused as out of scope (measured 2026-09-15 on
|
|
82
|
+
* Windows; A-SPEC-652.4 was redesigned around it). A bare filename counts only as a LIST ITEM's first
|
|
83
|
+
* word: `- \`tsconfig.test.json\` (신규)` is a token, while `\`cfg.ts\`, \`ddg.ts\` 를 고친다` — prose, and a
|
|
84
|
+
* word carrying a comma — stays what it always was, nothing. The enforcer is unchanged: a root token
|
|
85
|
+
* admits that exact path (`matchesFtt`), and the grader requires a directory-scoped token of its own.
|
|
86
|
+
*/
|
|
67
87
|
function fttPathTokens(fttText) {
|
|
68
|
-
|
|
88
|
+
const tokens = [...(fttText.match(/[\w@.*-]+(?:\/[\w@.*-]+)+/g) ?? [])];
|
|
89
|
+
for (const line of fttText.split('\n')) {
|
|
90
|
+
const item = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
91
|
+
if (!item)
|
|
92
|
+
continue;
|
|
93
|
+
const word = (item[1].trim().split(/\s+/)[0] ?? '').replace(/^[`'"]+|[`'"]+$/g, '');
|
|
94
|
+
if (isRootFileToken(word) && !tokens.includes(word))
|
|
95
|
+
tokens.push(word);
|
|
96
|
+
}
|
|
97
|
+
return tokens;
|
|
69
98
|
}
|
|
70
99
|
// @implements A-SPEC-508.1
|
|
71
100
|
function judgeScope(input) {
|
|
@@ -16,6 +16,15 @@ export interface SliceOrchestrationContext {
|
|
|
16
16
|
}): Promise<unknown>;
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* @implements A-SPEC-652.1 — the T-SPEC that belongs to an A-SPEC, by the mirror rule the
|
|
21
|
+
* author-slice playbook states (the chain inherits the parent's number, dot included): the
|
|
22
|
+
* mirror id first, then a T-SPEC that declares `depends_on` on this exact A-SPEC, then — for an
|
|
23
|
+
* UNDOTTED id only — the old base-number match that kept legacy `T-SPEC-1291`-style ids working.
|
|
24
|
+
* Measured 2026-09-14: `spec_slice_approve('A-SPEC-651.2')` sealed `T-SPEC-651.1`, because the old
|
|
25
|
+
* `find` accepted any T-SPEC whose base number matched and .1 came first.
|
|
26
|
+
*/
|
|
27
|
+
export declare function tspecFor(aspec: Spec, specs: Spec[]): Spec | undefined;
|
|
19
28
|
export declare function createSliceOrchestrationHandlers(context: SliceOrchestrationContext): {
|
|
20
29
|
spec_slice_init(a: {
|
|
21
30
|
root?: string;
|
|
@@ -25,8 +34,16 @@ export declare function createSliceOrchestrationHandlers(context: SliceOrchestra
|
|
|
25
34
|
filesToTouch: string[];
|
|
26
35
|
}): Promise<{
|
|
27
36
|
ok: boolean;
|
|
37
|
+
reason: string;
|
|
38
|
+
existing: string[];
|
|
28
39
|
specsCreated: string[];
|
|
29
40
|
unreadable: string[];
|
|
41
|
+
} | {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
specsCreated: string[];
|
|
44
|
+
unreadable: string[];
|
|
45
|
+
reason?: undefined;
|
|
46
|
+
existing?: undefined;
|
|
30
47
|
}>;
|
|
31
48
|
spec_slice_approve(a: {
|
|
32
49
|
root?: string;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.tspecFor = tspecFor;
|
|
36
37
|
exports.createSliceOrchestrationHandlers = createSliceOrchestrationHandlers;
|
|
37
38
|
// @implements A-SPEC-609, A-SPEC-100.2, A-SPEC-156, A-SPEC-203, A-SPEC-204, A-SPEC-298, A-SPEC-299, A-SPEC-536.1, A-SPEC-546.1, A-SPEC-565.2, A-SPEC-587.2, A-SPEC-501.1
|
|
38
39
|
const fs = __importStar(require("node:fs"));
|
|
@@ -42,6 +43,30 @@ const root_1 = require("../../project/root");
|
|
|
42
43
|
const yaml_scalar_1 = require("../../spec/yaml-scalar");
|
|
43
44
|
const draft_1 = require("../../reverse/draft");
|
|
44
45
|
const anchor_comment_1 = require("../../rtm/anchor-comment");
|
|
46
|
+
/**
|
|
47
|
+
* @implements A-SPEC-652.1 — the T-SPEC that belongs to an A-SPEC, by the mirror rule the
|
|
48
|
+
* author-slice playbook states (the chain inherits the parent's number, dot included): the
|
|
49
|
+
* mirror id first, then a T-SPEC that declares `depends_on` on this exact A-SPEC, then — for an
|
|
50
|
+
* UNDOTTED id only — the old base-number match that kept legacy `T-SPEC-1291`-style ids working.
|
|
51
|
+
* Measured 2026-09-14: `spec_slice_approve('A-SPEC-651.2')` sealed `T-SPEC-651.1`, because the old
|
|
52
|
+
* `find` accepted any T-SPEC whose base number matched and .1 came first.
|
|
53
|
+
*/
|
|
54
|
+
function tspecFor(aspec, specs) {
|
|
55
|
+
const number = aspec.id.replace(/^A-SPEC-/, '');
|
|
56
|
+
const mirror = specs.find((s) => s.type === 'T-SPEC' && s.id === `T-SPEC-${number}`);
|
|
57
|
+
if (mirror)
|
|
58
|
+
return mirror;
|
|
59
|
+
const dependent = specs.filter((s) => s.type === 'T-SPEC' && s.dependsOn.includes(aspec.id)).sort((a, b) => a.id.localeCompare(b.id))[0];
|
|
60
|
+
if (dependent)
|
|
61
|
+
return dependent;
|
|
62
|
+
if (number.includes('.'))
|
|
63
|
+
return undefined;
|
|
64
|
+
// Undotted only: the old base-number match (`T-SPEC-700.1` for `A-SPEC-700`) and the pre-REQ-187
|
|
65
|
+
// convention that appended one digit (`T-SPEC-1291` for `A-SPEC-129`).
|
|
66
|
+
const mainId = (id) => id.split('.')[0].replace(/\D/g, '');
|
|
67
|
+
const legacy = new RegExp(`^T-SPEC-${number}\\d$`);
|
|
68
|
+
return specs.find((s) => s.type === 'T-SPEC' && (mainId(s.id) === number || legacy.test(s.id)));
|
|
69
|
+
}
|
|
45
70
|
function createSliceOrchestrationHandlers(context) {
|
|
46
71
|
return {
|
|
47
72
|
async spec_slice_init(a) {
|
|
@@ -51,7 +76,11 @@ function createSliceOrchestrationHandlers(context) {
|
|
|
51
76
|
// @implements A-SPEC-546.1 — align with the sibling reverse-draft path (nextIdBase, floor 100)
|
|
52
77
|
// instead of a hard-coded 200 floor, so a fresh project starts at REQ-100, not REQ-201. Existing
|
|
53
78
|
// projects are unchanged: nextIdBase returns max(existing)+1, which dominates the floor.
|
|
54
|
-
|
|
79
|
+
// @implements A-SPEC-651.5 — the number is never below EITHER store's max: the bound store's
|
|
80
|
+
// (the contract above) and the store the files are written to. Measured 2026-09-14: the bound
|
|
81
|
+
// store was empty, the id fell to 100 and the sealed REQ-100.md of the target was overwritten.
|
|
82
|
+
const targetIds = fs.existsSync(specsDir) ? (await new spec_store_1.LocalMarkdownRepository(specsDir).list()).map((s) => s.id) : [];
|
|
83
|
+
const nextId = (0, draft_1.nextIdBase)([...existing.map((s) => s.id), ...targetIds]);
|
|
55
84
|
const reqId = `REQ-${nextId}`;
|
|
56
85
|
const hspecId = `H-SPEC-${nextId}`;
|
|
57
86
|
const aspecId = `A-SPEC-${nextId}`;
|
|
@@ -194,6 +223,16 @@ depends_on:
|
|
|
194
223
|
When operation is performed
|
|
195
224
|
Then boundary limits are respected
|
|
196
225
|
`;
|
|
226
|
+
// @implements A-SPEC-651.5 — all or nothing, judged BEFORE the first byte: a target path that
|
|
227
|
+
// already exists (readable or not — the number cannot see an unparseable file) refuses the
|
|
228
|
+
// whole chain and names the files, so a sealed document is never replaced by a stub.
|
|
229
|
+
const targets = [
|
|
230
|
+
['01_req', `${reqId}.md`], ['02_h-spec/functional', `${hspecId}.md`], ['03_a-spec', `${aspecId}.md`], ['05_t-spec', `${tspecId}.md`],
|
|
231
|
+
];
|
|
232
|
+
const existingFiles = targets.filter(([dir, name]) => fs.existsSync(path.join(specsDir, dir, name))).map(([dir, name]) => `${dir}/${name}`);
|
|
233
|
+
if (existingFiles.length > 0) {
|
|
234
|
+
return { ok: false, reason: `spec_slice_init: ${existingFiles.length} target file(s) already exist — nothing written`, existing: existingFiles, specsCreated: [], unreadable: [] };
|
|
235
|
+
}
|
|
197
236
|
fs.mkdirSync(path.join(specsDir, '01_req'), { recursive: true });
|
|
198
237
|
fs.mkdirSync(path.join(specsDir, '02_h-spec', 'functional'), { recursive: true });
|
|
199
238
|
fs.mkdirSync(path.join(specsDir, '03_a-spec'), { recursive: true });
|
|
@@ -225,12 +264,16 @@ depends_on:
|
|
|
225
264
|
return false;
|
|
226
265
|
});
|
|
227
266
|
const idsToApprove = [];
|
|
267
|
+
// @implements A-SPEC-652.1 — a dotted A-SPEC with no mirror and no dependent T-SPEC is named
|
|
268
|
+
// in `refused` AFTER the rest of the chain is sealed: the code gate stays shut without a
|
|
269
|
+
// T-SPEC, and a silent `ok:true` would read as "the slice is approved".
|
|
270
|
+
let missingTspec;
|
|
228
271
|
if (inSlice.length > 0) {
|
|
229
272
|
const aspec = inSlice.find((s) => s.type === 'A-SPEC') ?? inSlice[0];
|
|
230
273
|
const mainId = (id) => id.split('.')[0].replace(/\D/g, '');
|
|
231
274
|
const req = specs.find((s) => aspec.dependsOn.includes(s.id) || mainId(s.id) === mainId(aspec.id));
|
|
232
275
|
const hspec = specs.find((s) => s.type === 'H-SPEC' && (s.dependsOn.includes(req?.id ?? '') || mainId(s.id) === mainId(aspec.id)));
|
|
233
|
-
const tspec =
|
|
276
|
+
const tspec = tspecFor(aspec, specs);
|
|
234
277
|
if (req)
|
|
235
278
|
idsToApprove.push(req.id);
|
|
236
279
|
if (hspec)
|
|
@@ -238,6 +281,8 @@ depends_on:
|
|
|
238
281
|
idsToApprove.push(aspec.id);
|
|
239
282
|
if (tspec)
|
|
240
283
|
idsToApprove.push(tspec.id);
|
|
284
|
+
else if (aspec.type === 'A-SPEC' && aspec.id.includes('.'))
|
|
285
|
+
missingTspec = `T-SPEC-${aspec.id.replace(/^A-SPEC-/, '')}`;
|
|
241
286
|
}
|
|
242
287
|
else {
|
|
243
288
|
const matches = specs.filter((s) => s.id.includes(a.sliceName));
|
|
@@ -281,6 +326,10 @@ depends_on:
|
|
|
281
326
|
for (const id of idsToApprove.slice(approvedSpecs.length + alreadySealed.length + refused.length)) {
|
|
282
327
|
refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
|
|
283
328
|
}
|
|
329
|
+
if (missingTspec !== undefined) {
|
|
330
|
+
const aspecId = `A-SPEC-${missingTspec.replace(/^T-SPEC-/, '')}`;
|
|
331
|
+
refused.push({ id: missingTspec, reason: `no T-SPEC mirrors ${aspecId} or depends on it — write ${missingTspec} first` });
|
|
332
|
+
}
|
|
284
333
|
// `ok` means the slice is approved — freshly or already. Anything less is not a success.
|
|
285
334
|
return { ok: refused.length === 0 && approvedSpecs.length + alreadySealed.length > 0, approvedSpecs, alreadySealed, refused };
|
|
286
335
|
},
|
|
@@ -590,9 +590,17 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
590
590
|
objective: string;
|
|
591
591
|
filesToTouch: string[];
|
|
592
592
|
}) => Promise<{
|
|
593
|
+
ok: boolean;
|
|
594
|
+
reason: string;
|
|
595
|
+
existing: string[];
|
|
596
|
+
specsCreated: string[];
|
|
597
|
+
unreadable: string[];
|
|
598
|
+
} | {
|
|
593
599
|
ok: boolean;
|
|
594
600
|
specsCreated: string[];
|
|
595
601
|
unreadable: string[];
|
|
602
|
+
reason?: undefined;
|
|
603
|
+
existing?: undefined;
|
|
596
604
|
}>;
|
|
597
605
|
spec_slice_approve: (a: {
|
|
598
606
|
root?: string;
|
|
@@ -326,7 +326,9 @@ function assertSpecStoreReachable(tool, store, root) {
|
|
|
326
326
|
// The tool name is NOT prefixed here: the raw-handler wrapper already prefixes it, and doing it
|
|
327
327
|
// twice produced `spec_list: spec_list: ...` in the installed server (seen in the probe).
|
|
328
328
|
void tool;
|
|
329
|
-
|
|
329
|
+
// @implements A-SPEC-652.2 — the ABSOLUTE path, so the operator can see which directory was tried
|
|
330
|
+
// (a relative `.ax/specs` in this message told nobody where the server had looked, 2026-09-14).
|
|
331
|
+
throw new HandlerRefusal(`이 서버가 바인딩된 스펙 디렉터리 ${path.resolve(store.specsRoot)} 가 존재하지 않습니다 —`
|
|
330
332
|
+ ` 요청한 root ${root} 에는 스펙 저장소가 있습니다. 빈 답을 돌려주면 '스펙이 없다'로 읽히므로`
|
|
331
333
|
+ ' 거부합니다. 서버를 프로젝트 루트에서 시작하거나 HOLMES_SPECS 로 스펙 경로를 지정하십시오.');
|
|
332
334
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_SERVER_SPECS = void 0;
|
|
37
|
+
exports.resolveServerSpecsDir = resolveServerSpecsDir;
|
|
38
|
+
// @implements A-SPEC-652.2
|
|
39
|
+
/**
|
|
40
|
+
* Where the MCP server's spec store is. `HOLMES_SPECS` is written RELATIVE by `init` (`.ax/specs`,
|
|
41
|
+
* so the wiring file stays portable across machines) and used to be resolved against whatever cwd
|
|
42
|
+
* the server got. Measured 2026-09-14: a session's server refused every store-bound tool with
|
|
43
|
+
* "바인딩된 스펙 디렉터리 .ax/specs 가 존재하지 않습니다" — its cwd was not the project.
|
|
44
|
+
*
|
|
45
|
+
* The base for a relative path is, in order: `CLAUDE_PROJECT_DIR` (Claude Code sets it for every
|
|
46
|
+
* stdio MCP server to the stable project root — code.claude.com/docs/en/mcp), then the nearest
|
|
47
|
+
* ancestor of cwd holding `.mcp.json` or `.ax` (a server started in a sub-directory, or by a harness
|
|
48
|
+
* that sets no variable), then cwd itself (the old behaviour, for a project that has not run `init`).
|
|
49
|
+
* The answer is always absolute, so every refusal can name the directory it looked at.
|
|
50
|
+
*/
|
|
51
|
+
const fs = __importStar(require("node:fs"));
|
|
52
|
+
const path = __importStar(require("node:path"));
|
|
53
|
+
exports.DEFAULT_SERVER_SPECS = '.ax/specs';
|
|
54
|
+
function resolveServerSpecsDir(env, cwd, exists = fs.existsSync) {
|
|
55
|
+
const configured = env.HOLMES_SPECS && env.HOLMES_SPECS.trim() !== '' ? env.HOLMES_SPECS : exports.DEFAULT_SERVER_SPECS;
|
|
56
|
+
if (path.isAbsolute(configured))
|
|
57
|
+
return path.normalize(configured);
|
|
58
|
+
const projectDir = env.CLAUDE_PROJECT_DIR;
|
|
59
|
+
if (projectDir && projectDir.trim() !== '' && exists(projectDir))
|
|
60
|
+
return path.resolve(projectDir, configured);
|
|
61
|
+
let dir = path.resolve(cwd);
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (exists(path.join(dir, '.mcp.json')) || exists(path.join(dir, '.ax')))
|
|
64
|
+
return path.resolve(dir, configured);
|
|
65
|
+
const parent = path.dirname(dir);
|
|
66
|
+
if (parent === dir)
|
|
67
|
+
break;
|
|
68
|
+
dir = parent;
|
|
69
|
+
}
|
|
70
|
+
return path.resolve(cwd, configured);
|
|
71
|
+
}
|
|
@@ -11,7 +11,16 @@ const tool_schemas_1 = require("./tool-schemas");
|
|
|
11
11
|
Object.defineProperty(exports, "HOOK_ENFORCED_TOOLS", { enumerable: true, get: function () { return tool_schemas_1.HOOK_ENFORCED_TOOLS; } });
|
|
12
12
|
const validate_args_1 = require("./validate-args");
|
|
13
13
|
// @implements A-SPEC-100.2
|
|
14
|
-
|
|
14
|
+
// @implements A-SPEC-652.2 — the store is opened at an ABSOLUTE path resolved against the project
|
|
15
|
+
// root (CLAUDE_PROJECT_DIR, then the nearest .mcp.json/.ax ancestor, then cwd), and the path is
|
|
16
|
+
// logged once to stderr (stdout is the protocol channel) so a mis-bound server is diagnosable.
|
|
17
|
+
const { resolveServerSpecsDir } = require('./server-specs-dir');
|
|
18
|
+
const SPECS_DIR = resolveServerSpecsDir(process.env, process.cwd());
|
|
19
|
+
try {
|
|
20
|
+
process.stderr.write(`[Holmes-Kit] spec store: ${SPECS_DIR}\n`);
|
|
21
|
+
}
|
|
22
|
+
catch { /* a diagnostic must never keep the server from starting */ }
|
|
23
|
+
const store = new spec_store_1.LocalMarkdownRepository(SPECS_DIR);
|
|
15
24
|
// @implements A-SPEC-263.1 — the elicitation approval channel's wiring. The capability arrives at
|
|
16
25
|
// initialize, AFTER this factory runs, so it is consulted lazily at CALL time.
|
|
17
26
|
// @implements A-SPEC-497.1 — the three fates, told apart at THIS seam (the only place the SDK error
|
|
@@ -59,7 +59,8 @@ function validateExecution(value) {
|
|
|
59
59
|
const scope = new node_async_hooks_1.AsyncLocalStorage();
|
|
60
60
|
/** Internal entry-point boundary; an unavailable operator identity explicitly clears ambient scope. */
|
|
61
61
|
function withOwnedExecution(root, execution, act) {
|
|
62
|
-
|
|
62
|
+
// @implements A-SPEC-651.1 — the scope's root and every root compared against it share one spelling.
|
|
63
|
+
const owned = execution ? { root: (0, root_1.canonicalPath)(root), execution: validateExecution(execution) } : undefined;
|
|
63
64
|
return scope.run(owned, act);
|
|
64
65
|
}
|
|
65
66
|
/** Writers may inherit only the context of the actual checkout that owns them. */
|
|
@@ -69,7 +70,7 @@ function executionForRoot(root) {
|
|
|
69
70
|
return undefined;
|
|
70
71
|
let actual = path.resolve(root);
|
|
71
72
|
if (fs.existsSync(actual))
|
|
72
|
-
actual =
|
|
73
|
+
actual = (0, root_1.canonicalPath)(actual);
|
|
73
74
|
if (actual !== active.root)
|
|
74
75
|
throw new workspace_identity_1.WorkspaceIdentityError('foreign-execution', 'The writer belongs to a different execution workspace.');
|
|
75
76
|
return active.execution;
|
|
@@ -102,7 +103,7 @@ function createExecutionScope(storeRoot, observe) {
|
|
|
102
103
|
if (identity.state !== 'registered')
|
|
103
104
|
throw new workspace_identity_1.WorkspaceIdentityError('unregistered-replica', 'Register this replica through workspace_identity before governance writes.');
|
|
104
105
|
const execution = validateExecution({ schema: 'holmes-execution/1', workspaceId: identity.workspaceId, replicaId: identity.replicaId, runId });
|
|
105
|
-
return scope.run({ root:
|
|
106
|
+
return scope.run({ root: (0, root_1.canonicalPath)(bound.root), execution }, invoke);
|
|
106
107
|
};
|
|
107
108
|
}
|
|
108
109
|
/** Validate observation attribution before an append; absence is never inferred from bad input. */
|
|
@@ -45,6 +45,15 @@ export declare const MARKER = ".ax";
|
|
|
45
45
|
* makes: one spelling per directory, the spelling every path key is built from.
|
|
46
46
|
*/
|
|
47
47
|
export declare const canonicalPath: (p: string) => string;
|
|
48
|
+
/**
|
|
49
|
+
* @implements A-SPEC-651.1 — "the same directory" judged by ONE spelling. Every path that names a
|
|
50
|
+
* workspace or a store is canonicalised the way `resolveProjectRoot` canonicalises (`realpath.native`,
|
|
51
|
+
* so 8.3 aliases and symlinks collapse); a path that does not exist compares by its resolved spelling.
|
|
52
|
+
* `realpath` is injectable so the 8.3 scenario runs on every OS in the tests. Measured 2026-09-14
|
|
53
|
+
* (WIN0200-20260914-SHORTPATH): `entityStoreBinding` compared JS `fs.realpathSync` output against a
|
|
54
|
+
* native-canonical root, so a workspace entered as `C:\Users\SUNGNA~1\…` was refused as foreign.
|
|
55
|
+
*/
|
|
56
|
+
export declare function sameCanonicalDirectory(a: string, b: string, realpath?: (p: string) => string): boolean;
|
|
48
57
|
export declare function resolveProjectRoot(dir: string, opts?: {
|
|
49
58
|
includeGit?: boolean;
|
|
50
59
|
}): ProjectRoot;
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.canonicalPath = exports.MARKER = void 0;
|
|
37
37
|
exports.cleanSubprocessEnv = cleanSubprocessEnv;
|
|
38
38
|
exports.cleanTestEnv = cleanTestEnv;
|
|
39
|
+
exports.sameCanonicalDirectory = sameCanonicalDirectory;
|
|
39
40
|
exports.resolveProjectRoot = resolveProjectRoot;
|
|
40
41
|
// @implements A-SPEC-205, A-SPEC-627
|
|
41
42
|
// @implements A-SPEC-128
|
|
@@ -111,6 +112,23 @@ exports.MARKER = '.ax';
|
|
|
111
112
|
const canonicalPath = (p) => fs.realpathSync.native(p);
|
|
112
113
|
exports.canonicalPath = canonicalPath;
|
|
113
114
|
const canonical = exports.canonicalPath;
|
|
115
|
+
/**
|
|
116
|
+
* @implements A-SPEC-651.1 — "the same directory" judged by ONE spelling. Every path that names a
|
|
117
|
+
* workspace or a store is canonicalised the way `resolveProjectRoot` canonicalises (`realpath.native`,
|
|
118
|
+
* so 8.3 aliases and symlinks collapse); a path that does not exist compares by its resolved spelling.
|
|
119
|
+
* `realpath` is injectable so the 8.3 scenario runs on every OS in the tests. Measured 2026-09-14
|
|
120
|
+
* (WIN0200-20260914-SHORTPATH): `entityStoreBinding` compared JS `fs.realpathSync` output against a
|
|
121
|
+
* native-canonical root, so a workspace entered as `C:\Users\SUNGNA~1\…` was refused as foreign.
|
|
122
|
+
*/
|
|
123
|
+
function sameCanonicalDirectory(a, b, realpath = exports.canonicalPath) {
|
|
124
|
+
const spell = (p) => { try {
|
|
125
|
+
return realpath(p);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return path.resolve(p);
|
|
129
|
+
} };
|
|
130
|
+
return spell(a) === spell(b);
|
|
131
|
+
}
|
|
114
132
|
function gitTopLevel(dir) {
|
|
115
133
|
try {
|
|
116
134
|
// stderr silenced: "not a git repository" is an expected branch here, not a fault worth printing.
|
|
@@ -475,7 +475,10 @@ function runPytest(files, mode, cwd) {
|
|
|
475
475
|
return outcomes;
|
|
476
476
|
};
|
|
477
477
|
try {
|
|
478
|
-
|
|
478
|
+
// @implements A-SPEC-651.1 — launch from the canonical spelling: pytest keys its JUnit `file`
|
|
479
|
+
// attribute relative to its rootdir, and an 8.3 invocation dir made that key climb out of the
|
|
480
|
+
// project (`../../…/tests/test_calc.py`, measured 2026-09-14 on Windows).
|
|
481
|
+
const out = (0, node_child_process_1.execFileSync)(python, args, { cwd: fs.existsSync(cwd) ? (0, root_1.canonicalPath)(cwd) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
479
482
|
return { passed: true, tail: tailOf(out), executed: read(), outcomes: readOutcomes(true) };
|
|
480
483
|
}
|
|
481
484
|
catch (e) {
|
|
@@ -172,7 +172,10 @@ const FRAMEWORKS = [
|
|
|
172
172
|
suiteSkip: /\bdescribe\s*(?:\.\s*skip|\[\s*['"]skip['"]\s*\])|\bxdescribe\b/,
|
|
173
173
|
// Head must be a STANDALONE identifier (not `.test(`/`.it(` method calls) followed directly by
|
|
174
174
|
// `(` or by `.each(...)(`: `it.skip(`/`it.todo(`/`xit(` never match — no subtraction needed.
|
|
175
|
-
|
|
175
|
+
// @implements A-SPEC-653 — the platform helpers of A-SPEC-220.3 (`posixOnly('why')(…)`) are heads too:
|
|
176
|
+
// a POSIX-only suite written with the REPORTING skip must not read as "no real cases" (measured: that
|
|
177
|
+
// is why two suites had used a bare early return instead). `.skip` on a helper is still not a head.
|
|
178
|
+
head: /(?<![.\w])(?:it|test|fit|(?:posixOnly|posixOnlyNonRoot|posixOnlyWithCommand|win32Only)\s*\([^)]*\))(?:\s*\.\s*each\s*\([^)]*\)\s*)?\s*\(/g,
|
|
176
179
|
},
|
|
177
180
|
{
|
|
178
181
|
id: 'pytest',
|
|
@@ -103,6 +103,15 @@ export interface EntityGitFileMerge {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
/** Merge already-normalized bytes. Scratch files are not a materialized Holmes workspace. */
|
|
106
|
+
/**
|
|
107
|
+
* @implements A-SPEC-652.6 — the environment `git merge-file` runs in: the caller's GIT_* stripped,
|
|
108
|
+
* the system and global configs blocked, the ceiling at the scratch parent. The "empty global
|
|
109
|
+
* config" is `os.devNull` on POSIX; on Windows `os.devNull` is `\\.\nul`, which git cannot open as
|
|
110
|
+
* a config file (`fatal: unable to access '\\.\nul': Invalid argument`, status 128 — measured
|
|
111
|
+
* 2026-09-15), so every content merge there was reported as a merge-tool-failure conflict. `NUL`
|
|
112
|
+
* is the spelling git accepts on that platform.
|
|
113
|
+
*/
|
|
114
|
+
export declare function mergeToolEnv(base: NodeJS.ProcessEnv, scratch: string, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
|
|
106
115
|
export declare function mergeEntityGitFile(base: EntityGitFileVersion | null, left: EntityGitFileVersion | null, right: EntityGitFileVersion | null): EntityGitFileMerge;
|
|
107
116
|
export interface EntityGitSourceBranch {
|
|
108
117
|
files: Array<{
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.unchangedNonRegular = unchangedNonRegular;
|
|
37
37
|
exports.planEntityGitMergedSources = planEntityGitMergedSources;
|
|
38
|
+
exports.mergeToolEnv = mergeToolEnv;
|
|
38
39
|
exports.mergeEntityGitFile = mergeEntityGitFile;
|
|
39
40
|
exports.planEntityGitSources = planEntityGitSources;
|
|
40
41
|
exports.planEntityGitDocuments = planEntityGitDocuments;
|
|
@@ -250,6 +251,24 @@ async function planEntityGitMergedSources(root, revisions, storeLocator) {
|
|
|
250
251
|
return result;
|
|
251
252
|
}
|
|
252
253
|
/** Merge already-normalized bytes. Scratch files are not a materialized Holmes workspace. */
|
|
254
|
+
/**
|
|
255
|
+
* @implements A-SPEC-652.6 — the environment `git merge-file` runs in: the caller's GIT_* stripped,
|
|
256
|
+
* the system and global configs blocked, the ceiling at the scratch parent. The "empty global
|
|
257
|
+
* config" is `os.devNull` on POSIX; on Windows `os.devNull` is `\\.\nul`, which git cannot open as
|
|
258
|
+
* a config file (`fatal: unable to access '\\.\nul': Invalid argument`, status 128 — measured
|
|
259
|
+
* 2026-09-15), so every content merge there was reported as a merge-tool-failure conflict. `NUL`
|
|
260
|
+
* is the spelling git accepts on that platform.
|
|
261
|
+
*/
|
|
262
|
+
function mergeToolEnv(base, scratch, platform = process.platform) {
|
|
263
|
+
const env = { ...base };
|
|
264
|
+
for (const key of Object.keys(env))
|
|
265
|
+
if (key.startsWith('GIT_'))
|
|
266
|
+
delete env[key];
|
|
267
|
+
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
268
|
+
env.GIT_CONFIG_GLOBAL = platform === 'win32' ? 'NUL' : os.devNull;
|
|
269
|
+
env.GIT_CEILING_DIRECTORIES = path.dirname(scratch);
|
|
270
|
+
return env;
|
|
271
|
+
}
|
|
253
272
|
function mergeEntityGitFile(base, left, right) {
|
|
254
273
|
const copy = (version) => version && ({ mode: version.mode, bytes: Buffer.from(version.bytes) });
|
|
255
274
|
const inputs = { base: copy(base), left: copy(left), right: copy(right) };
|
|
@@ -287,13 +306,7 @@ function mergeEntityGitFile(base, left, right) {
|
|
|
287
306
|
try {
|
|
288
307
|
for (const [name, version] of Object.entries({ base, left, right }))
|
|
289
308
|
fs.writeFileSync(path.join(scratch, name), version.bytes, { mode: 0o600 });
|
|
290
|
-
const env =
|
|
291
|
-
for (const key of Object.keys(env))
|
|
292
|
-
if (key.startsWith('GIT_'))
|
|
293
|
-
delete env[key];
|
|
294
|
-
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
295
|
-
env.GIT_CONFIG_GLOBAL = os.devNull;
|
|
296
|
-
env.GIT_CEILING_DIRECTORIES = path.dirname(scratch);
|
|
309
|
+
const env = mergeToolEnv(process.env, scratch);
|
|
297
310
|
const merged = (0, node_child_process_1.spawnSync)('git', ['merge-file', '--stdout', '--diff3', '-L', 'left', '-L', 'base', '-L', 'right', '--', 'left', 'base', 'right'], { cwd: scratch, env, maxBuffer: limit });
|
|
298
311
|
if (merged.error || merged.status === null || merged.status > 127)
|
|
299
312
|
return conflict('merge-tool-failure');
|
|
@@ -74,13 +74,15 @@ function entityStoreBinding(storeRoot) {
|
|
|
74
74
|
const resolved = (0, root_1.resolveProjectRoot)(ancestor, { includeGit: false });
|
|
75
75
|
if (resolved.marker !== '.ax')
|
|
76
76
|
throw new entity_transaction_1.EntityStoreError('missing-workspace', 'The configured store must belong to a Holmes workspace.');
|
|
77
|
-
|
|
77
|
+
// @implements A-SPEC-651.1 — the same canonical spelling resolveProjectRoot used (realpath.native):
|
|
78
|
+
// an 8.3 alias on Windows or /var on macOS collapses on both sides of the comparison.
|
|
79
|
+
const root = (0, root_1.canonicalPath)(resolved.root);
|
|
78
80
|
// The workspace may be reached through a platform alias (/var on macOS).
|
|
79
81
|
// Normalize that prefix only; retain components inside it for symlink checks.
|
|
80
82
|
let spelling = ancestor;
|
|
81
|
-
while (
|
|
83
|
+
while (!(0, root_1.sameCanonicalDirectory)(spelling, root) && path.dirname(spelling) !== spelling)
|
|
82
84
|
spelling = path.dirname(spelling);
|
|
83
|
-
if (
|
|
85
|
+
if (!(0, root_1.sameCanonicalDirectory)(spelling, root))
|
|
84
86
|
throw new entity_transaction_1.EntityStoreError('foreign-store', 'The configured store does not descend from the workspace.');
|
|
85
87
|
const relative = path.relative(spelling, store);
|
|
86
88
|
if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative))
|
|
@@ -72,6 +72,22 @@ export declare function settleEntityTransients(root: string, steps: EntityByteCh
|
|
|
72
72
|
* after the move-aside is refused and both files are preserved.
|
|
73
73
|
*/
|
|
74
74
|
export declare function applyEntityByteChange(root: string, change: EntityByteChange): void;
|
|
75
|
+
/**
|
|
76
|
+
* @implements A-SPEC-652.5 — Windows refuses to rename a directory while another process holds a
|
|
77
|
+
* file inside it open, and answers EPERM (EBUSY/EACCES in other shapes). A reader of the lock
|
|
78
|
+
* (entityLockOwner) holds owner.json for microseconds, so the writer's retirement rename raced it
|
|
79
|
+
* and died with `EPERM: operation not permitted, rename …/entity-locks/<hash> ->
|
|
80
|
+
* …/entity-retired-locks/<uuid>` (measured 2026-09-15 beside a looping reader). These codes are
|
|
81
|
+
* TRANSIENT there: retry briefly, on win32 only, for these codes only, within a fixed budget.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isTransientWindowsFsError(e: unknown): boolean;
|
|
84
|
+
export declare function renameWithTransientRetry(from: string, to: string, deps?: {
|
|
85
|
+
rename?: (from: string, to: string) => void;
|
|
86
|
+
platform?: NodeJS.Platform;
|
|
87
|
+
attempts?: number;
|
|
88
|
+
delayMs?: number;
|
|
89
|
+
sleep?: (ms: number) => void;
|
|
90
|
+
}): void;
|
|
75
91
|
/** The shared source-publication coordination namespace, independent of every configured spec store (A-SPEC-634: exported so recovery can release it without importing the renumber module). */
|
|
76
92
|
export declare const SOURCE_PUBLICATION_LOCATOR = ".ax/state/entity-renumber-source-publication";
|
|
77
93
|
/** Age after which a `.staging-*` entry beside the locks is treated as abandoned by a publisher that died before its rename (A-SPEC-633). */
|
|
@@ -49,6 +49,8 @@ exports.isEntityTransientName = isEntityTransientName;
|
|
|
49
49
|
exports.observeEntityTarget = observeEntityTarget;
|
|
50
50
|
exports.settleEntityTransients = settleEntityTransients;
|
|
51
51
|
exports.applyEntityByteChange = applyEntityByteChange;
|
|
52
|
+
exports.isTransientWindowsFsError = isTransientWindowsFsError;
|
|
53
|
+
exports.renameWithTransientRetry = renameWithTransientRetry;
|
|
52
54
|
exports.withEntityStoreLock = withEntityStoreLock;
|
|
53
55
|
exports.withEntityStoreLockAsync = withEntityStoreLockAsync;
|
|
54
56
|
exports.entityLockHolderAlive = entityLockHolderAlive;
|
|
@@ -219,7 +221,11 @@ function entityLockOwner(root, locator) {
|
|
|
219
221
|
bytes = readEntityBytes(path.join(dir, 'owner.json'));
|
|
220
222
|
}
|
|
221
223
|
catch (e) {
|
|
222
|
-
|
|
224
|
+
// A-SPEC-652.5 — on Windows a file inside a directory that is being renamed away answers
|
|
225
|
+
// EPERM/EBUSY for the instant of the rename: the generation is changing under the reader,
|
|
226
|
+
// the same fact ENOENT reports on POSIX.
|
|
227
|
+
vanished = e.code === 'ENOENT' || (e instanceof EntityStoreError && e.code === 'entity-state-changed')
|
|
228
|
+
|| (process.platform === 'win32' && isTransientWindowsFsError(e));
|
|
223
229
|
if (!vanished)
|
|
224
230
|
throw e;
|
|
225
231
|
}
|
|
@@ -456,6 +462,40 @@ function applyEntityByteChange(root, change) {
|
|
|
456
462
|
fs.unlinkSync(temp);
|
|
457
463
|
}
|
|
458
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* @implements A-SPEC-652.5 — Windows refuses to rename a directory while another process holds a
|
|
467
|
+
* file inside it open, and answers EPERM (EBUSY/EACCES in other shapes). A reader of the lock
|
|
468
|
+
* (entityLockOwner) holds owner.json for microseconds, so the writer's retirement rename raced it
|
|
469
|
+
* and died with `EPERM: operation not permitted, rename …/entity-locks/<hash> ->
|
|
470
|
+
* …/entity-retired-locks/<uuid>` (measured 2026-09-15 beside a looping reader). These codes are
|
|
471
|
+
* TRANSIENT there: retry briefly, on win32 only, for these codes only, within a fixed budget.
|
|
472
|
+
*/
|
|
473
|
+
function isTransientWindowsFsError(e) {
|
|
474
|
+
const code = e?.code;
|
|
475
|
+
return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES';
|
|
476
|
+
}
|
|
477
|
+
const sleepSync = (ms) => { try {
|
|
478
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
479
|
+
}
|
|
480
|
+
catch { /* a runtime without Atomics.wait retries without waiting */ } };
|
|
481
|
+
function renameWithTransientRetry(from, to, deps = {}) {
|
|
482
|
+
const rename = deps.rename ?? ((a, b) => fs.renameSync(a, b));
|
|
483
|
+
const platform = deps.platform ?? process.platform;
|
|
484
|
+
const attempts = Math.max(1, deps.attempts ?? 50);
|
|
485
|
+
const delayMs = deps.delayMs ?? 20;
|
|
486
|
+
const sleep = deps.sleep ?? sleepSync;
|
|
487
|
+
for (let attempt = 1;; attempt++) {
|
|
488
|
+
try {
|
|
489
|
+
rename(from, to);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
catch (e) {
|
|
493
|
+
if (platform !== 'win32' || !isTransientWindowsFsError(e) || attempt >= attempts)
|
|
494
|
+
throw e;
|
|
495
|
+
sleep(delayMs);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
459
499
|
function retireOwnedLock(root, dir, expected, owned) {
|
|
460
500
|
const current = entityStat(dir);
|
|
461
501
|
if (current?.dev !== expected.dev || current?.ino !== expected.ino)
|
|
@@ -468,7 +508,7 @@ function retireOwnedLock(root, dir, expected, owned) {
|
|
|
468
508
|
if (entityStat(destination))
|
|
469
509
|
throw new EntityStoreError('retirement-conflict', 'Lock retirement destination is occupied.');
|
|
470
510
|
// Release the active pathname in one step, retaining owner evidence until then.
|
|
471
|
-
|
|
511
|
+
renameWithTransientRetry(dir, destination);
|
|
472
512
|
for (const file of owned)
|
|
473
513
|
fs.unlinkSync(path.join(destination, path.basename(file)));
|
|
474
514
|
fs.rmdirSync(destination);
|
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.20.
|
|
4
|
+
"version": "0.20.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",
|
|
@@ -59,7 +59,10 @@
|
|
|
59
59
|
"testMatch": [
|
|
60
60
|
"**/src/**/*.test.ts"
|
|
61
61
|
],
|
|
62
|
-
"testTimeout": 30000
|
|
62
|
+
"testTimeout": 30000,
|
|
63
|
+
"setupFilesAfterEnv": [
|
|
64
|
+
"<rootDir>/src/holmes/test-support/jest-timeouts-setup.ts"
|
|
65
|
+
]
|
|
63
66
|
},
|
|
64
67
|
"dependencies": {
|
|
65
68
|
"@modelcontextprotocol/sdk": "^1.29.0",
|