@holmes-lab/holmes-kit 0.1.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ARTICLES = void 0;
|
|
4
|
+
exports.verifyConstitution = verifyConstitution;
|
|
5
|
+
const basis_1 = require("../mcp/basis");
|
|
6
|
+
const spec_types_1 = require("../spec/spec-types");
|
|
7
|
+
const rtm_check_1 = require("../rtm/rtm-check");
|
|
8
|
+
const validator_1 = require("../spec/validator");
|
|
9
|
+
exports.ARTICLES = {
|
|
10
|
+
'ART-7': '열린 치명 발견은 미완이다 — severity critical 이 open 인 한 완료가 없다 (important/minor 는 review_status 보고에만 남는다: 기록을 피하게 만드는 차단은 원장을 죽인다)',
|
|
11
|
+
'ART-6': '판단은 현재 빌드에서 내려야 한다 — 설치된 코드와 갈라진 서버에서 봉인된 리뷰 결과는 이미 사라진 세계를 기술한다',
|
|
12
|
+
'ART-1': 'No Spec, No Code — source may only change under an approved A-SPEC (+ approved T-SPEC, test-first)',
|
|
13
|
+
'ART-2': 'RTM integrity — the governed spec graph must stay free of orphans, cycles, dangling refs, duplicates',
|
|
14
|
+
'ART-3': 'Spec validity — every governed spec satisfies its type rules, including honest 4-quadrant GWT coverage',
|
|
15
|
+
'ART-4': 'Coverage honesty — declared coverage must be backed by real anchored test cases, not prose',
|
|
16
|
+
'ART-5': 'Approval is out-of-band — a spec cannot self-approve; governance config cannot be self-written',
|
|
17
|
+
};
|
|
18
|
+
function verifyConstitution(ctx) {
|
|
19
|
+
const governed = (0, spec_types_1.filterGoverned)(ctx.specs);
|
|
20
|
+
// @implements A-SPEC-191 (§4b) — the early return may NOT swallow recorded findings: a project
|
|
21
|
+
// that reviews before authoring specs (measured: adopt flow, reverse_scan first) held an open
|
|
22
|
+
// critical while Stop passed, because ART-7 was accidentally coupled to spec-graph existence.
|
|
23
|
+
if (governed.length === 0 && (ctx.findings ?? []).length === 0)
|
|
24
|
+
return []; // rows 가 아니라 원본 기준 — 오염 행만 있는 원장도 감식 대상이다
|
|
25
|
+
const resolve = (id) => ctx.specs.find((s) => s.id === id) ?? null;
|
|
26
|
+
const v = [];
|
|
27
|
+
// ART-2: RTM integrity
|
|
28
|
+
for (const issue of (0, rtm_check_1.rtmCheck)(governed)) {
|
|
29
|
+
v.push({ article: 'ART-2', detail: `RTM ${issue.kind} [${issue.id}]: ${issue.detail}` });
|
|
30
|
+
}
|
|
31
|
+
// ART-3: spec validity (type rules + 4-quadrant GWT via validateSpec)
|
|
32
|
+
for (const s of governed) {
|
|
33
|
+
const r = (0, validator_1.validateSpec)(s, resolve);
|
|
34
|
+
if (!r.ok) {
|
|
35
|
+
for (const f of r.findings) {
|
|
36
|
+
if (f.level === 'error')
|
|
37
|
+
v.push({ article: 'ART-3', detail: `spec ${s.id}: ${f.code} — ${f.message}` });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ART-4: coverage honesty (only when evidence was supplied)
|
|
42
|
+
if (ctx.testCasesByAspec || ctx.executedByAspec) {
|
|
43
|
+
// Evidence aggregates over the SPEC FAMILY: a T-SPEC on A-SPEC-100 is satisfied by tests anchored
|
|
44
|
+
// to A-SPEC-100 OR any slice sub-id A-SPEC-100.N (exact matching falsely blocked legitimate
|
|
45
|
+
// sub-slice anchors — finding MED-6).
|
|
46
|
+
const familySum = (m, aspec) => {
|
|
47
|
+
if (!m)
|
|
48
|
+
return undefined;
|
|
49
|
+
const hits = Object.entries(m).filter(([id]) => id === aspec || id.startsWith(`${aspec}.`));
|
|
50
|
+
return hits.length > 0 ? hits.reduce((s, [, n]) => s + n, 0) : undefined;
|
|
51
|
+
};
|
|
52
|
+
for (const t of governed) {
|
|
53
|
+
if (t.type !== 'T-SPEC' || t.status !== 'approved')
|
|
54
|
+
continue;
|
|
55
|
+
const aspec = t.dependsOn[0];
|
|
56
|
+
if (!aspec)
|
|
57
|
+
continue;
|
|
58
|
+
// BASELINE (always enforced): the syntactic lower bound. Evidence can never waive it.
|
|
59
|
+
if (ctx.testCasesByAspec && (familySum(ctx.testCasesByAspec, aspec) ?? 0) === 0) {
|
|
60
|
+
v.push({ article: 'ART-4', detail: `${t.id}: declared coverage but NO anchored test file with real cases for ${aspec} (anchor the covering tests with the @implements marker for ${aspec})` });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// ADDITIVE: a recorded run that covered this A-SPEC and executed nothing is an extra violation.
|
|
64
|
+
const executed = familySum(ctx.executedByAspec, aspec);
|
|
65
|
+
if (executed === 0) {
|
|
66
|
+
v.push({ article: 'ART-4', detail: `${t.id}: a recorded test run covered ${aspec} but EXECUTED no cases` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// @implements A-SPEC-160
|
|
71
|
+
// ART-6: a judgement sealed while the answering server ran code that no longer matched what was
|
|
72
|
+
// installed. Measured 2026-08-08: such a server reported `impactedSpecs: []` for a commit touching
|
|
73
|
+
// 39 specs, so anything recorded against it describes a world that was already gone.
|
|
74
|
+
//
|
|
75
|
+
// STRICTLY ADDITIVE. Only a PRESENT marker offends: a finding with no basis, or one whose basis
|
|
76
|
+
// shows agreement, is untouched. Treating absence as a violation would block every project holding
|
|
77
|
+
// a ledger written before REQ-157 — the bricking REQ-146 measured. Resolved findings are past
|
|
78
|
+
// judgements already dealt with.
|
|
79
|
+
// Defence in depth: the ledger is JSON lines anything may have written, and the supplier may
|
|
80
|
+
// not be FindingsLedger.list(). A row that is not an object with a string id cannot be judged —
|
|
81
|
+
// and must not be able to crash the gate into fail-open (round-3: a literal `null` line did).
|
|
82
|
+
const rows = (ctx.findings ?? []).filter((f) => !!f && typeof f === 'object' && typeof f.id === 'string');
|
|
83
|
+
for (const f of rows) {
|
|
84
|
+
if (f.status !== 'open')
|
|
85
|
+
continue;
|
|
86
|
+
if (!(0, basis_1.basisDiverged)(f.basis))
|
|
87
|
+
continue;
|
|
88
|
+
v.push({ article: 'ART-6', detail: `finding ${f.id}: 기록 시점에 서버가 설치된 빌드와 갈라져 있었습니다 (basis ${f.basis}) — 현재 빌드로 다시 리뷰하고 기록하세요` });
|
|
89
|
+
}
|
|
90
|
+
// @implements A-SPEC-191
|
|
91
|
+
// ART-7: an open critical finding means the work is NOT done. The severity was always recorded
|
|
92
|
+
// and review_status even computed `blocked` from it — but nothing enforced it, so a reviewer's
|
|
93
|
+
// "critical — must not ship" changed nothing about the next turn. Same additive shape as ART-6:
|
|
94
|
+
// only the PRESENT marker (severity string 'critical') offends; absent/unknown severities and
|
|
95
|
+
// resolved findings stay silent, so pre-REQ-157 ledgers cannot brick (the A-SPEC-146 trap).
|
|
96
|
+
//
|
|
97
|
+
// Judged on the LAST record per id: the ledger is append-only, so resolution IS a later line with
|
|
98
|
+
// the same id. Judging every line would make one recorded critical a violation forever — a trap,
|
|
99
|
+
// not a tooth. (ART-6 stays line-based above: a seal made in a diverged world is a past fact no
|
|
100
|
+
// later line can un-happen.)
|
|
101
|
+
const latest = new Map();
|
|
102
|
+
const lastOpenCritical = new Map();
|
|
103
|
+
for (const f of rows) {
|
|
104
|
+
latest.set(f.id, f); // supply order = record order
|
|
105
|
+
// LATEST open severity, not "ever critical" (r6): a canonically-closed critical must not be
|
|
106
|
+
// resurrected by a later MINOR re-finding's diverged resolve.
|
|
107
|
+
if (f.status === 'open')
|
|
108
|
+
lastOpenCritical.set(f.id, f.severity === 'critical');
|
|
109
|
+
}
|
|
110
|
+
for (const f of latest.values()) {
|
|
111
|
+
// typeof guard (round-2): the ledger is JSON lines anything may have written — a non-string
|
|
112
|
+
// summary crashed this very function, and a crash in verifyConstitution is a fail-open Stop
|
|
113
|
+
// (the CLI catches and exits 0). The gate must never be crashable by its own evidence.
|
|
114
|
+
const gist = typeof f.summary === 'string' && f.summary ? ` (${f.summary.slice(0, 80)})` : '';
|
|
115
|
+
if (f.status === 'open' && f.severity === 'critical') {
|
|
116
|
+
v.push({ article: 'ART-7', detail: `finding ${f.id}${gist}: 열린 치명 발견 — 결함을 고치고 재리뷰로 resolved 를 기록하기 전까지 이 작업은 완료될 수 없습니다` });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// @implements A-SPEC-191 (§4c) — the judgement class ART-6 exists to reject ("sealed in an
|
|
120
|
+
// already-gone world") was fully TRUSTED when it said "fixed": a resolved line stamped on a
|
|
121
|
+
// diverged server lifted the gate unconditionally (measured). A diverged resolution of a
|
|
122
|
+
// critical does not lift; re-verify on the current build and record again. Absent basis stays
|
|
123
|
+
// trusted (pre-REQ-157 ledgers — the A-SPEC-146 trap).
|
|
124
|
+
if (f.status === 'resolved' && lastOpenCritical.get(f.id) === true && (0, basis_1.basisDiverged)(f.basis)) {
|
|
125
|
+
v.push({ article: 'ART-7', detail: `finding ${f.id}${gist}: 해소가 갈라진 빌드에서 봉인되었습니다 (basis ${f.basis}) — 현재 빌드로 재검증해 다시 기록하십시오` });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return v;
|
|
129
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EnvIdentityProvider = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Reads the claim from the process environment — the same out-of-band discipline `HOLMES_APPROVAL`
|
|
6
|
+
* established, and for the same reason: a session cannot set its own environment, so it cannot claim
|
|
7
|
+
* its own role. A role carried in the tool payload would be self-assertion, which is precisely what
|
|
8
|
+
* ART-5 forbids on the spec axis.
|
|
9
|
+
*/
|
|
10
|
+
class EnvIdentityProvider {
|
|
11
|
+
env;
|
|
12
|
+
constructor(env = process.env) {
|
|
13
|
+
this.env = env;
|
|
14
|
+
}
|
|
15
|
+
current() {
|
|
16
|
+
const raw = this.env.HOLMES_ROLE;
|
|
17
|
+
// A blank value is absence, not a role named "". Treating whitespace as a role would create a
|
|
18
|
+
// principal nobody registered and that no policy can grant anything to.
|
|
19
|
+
const role = typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : undefined;
|
|
20
|
+
let actor;
|
|
21
|
+
try {
|
|
22
|
+
const approval = this.env.HOLMES_APPROVAL ? JSON.parse(this.env.HOLMES_APPROVAL) : undefined;
|
|
23
|
+
if (approval && typeof approval.actor === 'string')
|
|
24
|
+
actor = approval.actor;
|
|
25
|
+
}
|
|
26
|
+
catch { /* a malformed approval is no actor, never a throw */ }
|
|
27
|
+
return { actor, role, proven: false };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.EnvIdentityProvider = EnvIdentityProvider;
|
|
@@ -0,0 +1,165 @@
|
|
|
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.ACQUIRE_MS = exports.STALE_MS = exports.LedgerLockError = void 0;
|
|
37
|
+
exports.lockDirFor = lockDirFor;
|
|
38
|
+
exports.withLedgerLock = withLedgerLock;
|
|
39
|
+
// @implements A-SPEC-141
|
|
40
|
+
const fs = __importStar(require("node:fs"));
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
42
|
+
/**
|
|
43
|
+
* Cross-process mutual exclusion for one ledger file.
|
|
44
|
+
*
|
|
45
|
+
* WHY this exists, measured rather than assumed. `ProvenanceChain.append()` was read-then-append and
|
|
46
|
+
* the single-use approval gate was check-then-act, so two agents running at once could both observe
|
|
47
|
+
* "nonce not consumed" and both proceed. Two real processes synchronized on a file barrier, 20
|
|
48
|
+
* trials: the single-use approval was spent TWICE 20/20 times, and the tamper-evident chain reported
|
|
49
|
+
* `ok:false` 17/20 times — reporting tampering that never happened. Sequential behaviour was, and
|
|
50
|
+
* remains, correct; the failure only appears when the critical sections overlap, which is precisely
|
|
51
|
+
* the case a harness with "agentic" in its name promises to support.
|
|
52
|
+
*
|
|
53
|
+
* `fs.mkdirSync` is the primitive: atomic on every platform this ships to, `EEXIST` when held, and
|
|
54
|
+
* with none of the file-vs-`O_EXCL` subtleties. A DIRECTORY rather than a lock FILE because a
|
|
55
|
+
* zero-byte file is what a cleanup script or `git clean` deletes mid-hold, while a directory holds
|
|
56
|
+
* its own metadata and cannot be removed non-empty by accident.
|
|
57
|
+
*
|
|
58
|
+
* LIMIT, stated rather than implied: single host, single filesystem with atomic `mkdir`. Networked
|
|
59
|
+
* filesystems (NFS, shared volumes) are out of scope and this does not pretend to cover them.
|
|
60
|
+
*/
|
|
61
|
+
/** Thrown when exclusion cannot be obtained within the acquire budget. Distinguishable on purpose. */
|
|
62
|
+
class LedgerLockError extends Error {
|
|
63
|
+
// @implements A-SPEC-189 §7 (round 10) — a lock we could not take is a REFUSAL with a next step
|
|
64
|
+
// ('another process holds it; retry'), not a crash. The MCP boundary shapes refusals by this
|
|
65
|
+
// marker, so this one arrives as {ok:false, reason} instead of a raw -32603.
|
|
66
|
+
holmesRefusal = true;
|
|
67
|
+
}
|
|
68
|
+
exports.LedgerLockError = LedgerLockError;
|
|
69
|
+
/**
|
|
70
|
+
* A hold older than this is treated as abandoned and broken.
|
|
71
|
+
*
|
|
72
|
+
* 30 seconds against a critical section that reads a few hundred bytes and appends one line —
|
|
73
|
+
* microseconds of real work, so this is roughly five orders of magnitude of headroom and cannot fire
|
|
74
|
+
* on a live holder that is merely slow. The number exists because a `kill -9`'d agent must not wedge
|
|
75
|
+
* every other agent forever.
|
|
76
|
+
*/
|
|
77
|
+
exports.STALE_MS = 30_000;
|
|
78
|
+
/** Default budget for waiting on someone else's hold before failing. */
|
|
79
|
+
exports.ACQUIRE_MS = 5_000;
|
|
80
|
+
function lockDirFor(ledgerFile) {
|
|
81
|
+
return `${ledgerFile}.lock`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Sleep without burning a core. Every caller of the ledger is synchronous (hooks and CLI entry
|
|
85
|
+
* points are short-lived processes), so a synchronous wait is the honest shape; `Atomics.wait` on a
|
|
86
|
+
* throwaway buffer parks the thread instead of spinning on `Date.now()`.
|
|
87
|
+
*/
|
|
88
|
+
function sleepSync(ms) {
|
|
89
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
90
|
+
}
|
|
91
|
+
function readHolder(dir) {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'holder.json'), 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return {}; // an unreadable holder file is not evidence of anything; age still decides staleness
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Age of the hold, from the lock directory's mtime. `null` when it cannot be read (it may be gone). */
|
|
100
|
+
function holdAgeMs(dir) {
|
|
101
|
+
try {
|
|
102
|
+
return Date.now() - fs.statSync(dir).mtimeMs;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function release(dir) {
|
|
109
|
+
// Remove recursively: a body that wrote a stray file inside the hold must not turn release into a
|
|
110
|
+
// crash. `force` also makes a double-release (or a hold already broken by someone else) a no-op.
|
|
111
|
+
try {
|
|
112
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
catch { /* release is best-effort by design */ }
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Run `fn` while holding exclusion for `ledgerFile`.
|
|
118
|
+
*
|
|
119
|
+
* The hold is released in `finally`, including when `fn` throws, and a release problem never masks
|
|
120
|
+
* `fn`'s own error — the body's failure is the one the caller needs to see.
|
|
121
|
+
*/
|
|
122
|
+
function withLedgerLock(ledgerFile, fn, opts) {
|
|
123
|
+
const dir = lockDirFor(ledgerFile);
|
|
124
|
+
const acquireMs = opts?.acquireMs ?? exports.ACQUIRE_MS;
|
|
125
|
+
const staleMs = opts?.staleMs ?? exports.STALE_MS;
|
|
126
|
+
const deadline = Date.now() + acquireMs;
|
|
127
|
+
for (;;) {
|
|
128
|
+
try {
|
|
129
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
130
|
+
fs.mkdirSync(dir); // ATOMIC: this is the whole mutual-exclusion primitive
|
|
131
|
+
try {
|
|
132
|
+
fs.writeFileSync(path.join(dir, 'holder.json'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), 'utf8');
|
|
133
|
+
}
|
|
134
|
+
catch { /* diagnostics only; the hold is the directory, not the file */ }
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err.code !== 'EEXIST')
|
|
139
|
+
throw err;
|
|
140
|
+
const age = holdAgeMs(dir);
|
|
141
|
+
if (age !== null && age > staleMs) {
|
|
142
|
+
// Break it — but never silently. Stealing a lock without a record is exactly the kind of
|
|
143
|
+
// invisible action this project refuses everywhere else.
|
|
144
|
+
const holder = readHolder(dir);
|
|
145
|
+
release(dir);
|
|
146
|
+
try {
|
|
147
|
+
opts?.onStaleBreak?.({ pid: holder.pid, ageMs: age, ledgerFile });
|
|
148
|
+
}
|
|
149
|
+
catch { /* recording must not block recovery */ }
|
|
150
|
+
continue; // re-race for it; another breaker may win and that is fine
|
|
151
|
+
}
|
|
152
|
+
if (Date.now() >= deadline) {
|
|
153
|
+
const holder = readHolder(dir);
|
|
154
|
+
throw new LedgerLockError(`could not obtain exclusive ledger access within ${acquireMs}ms — held by pid ${holder.pid ?? 'unknown'} since ${holder.startedAt ?? 'unknown'}`);
|
|
155
|
+
}
|
|
156
|
+
sleepSync(15);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return fn();
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
release(dir);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ledgerStoreConformance = ledgerStoreConformance;
|
|
4
|
+
/**
|
|
5
|
+
* The ONE conformance suite every `LedgerStore` adapter must pass.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a single exported function rather than a copy per adapter: two copies drift, and
|
|
8
|
+
* "both adapters pass" quietly stops meaning anything. Everything here is expressed against the
|
|
9
|
+
* CONTRACT — no paths, no files, no knowledge of how an adapter stores anything. If a property
|
|
10
|
+
* cannot be written that way, that is itself the finding: the contract has leaked its
|
|
11
|
+
* implementation, and finding that now is the whole reason a second adapter exists.
|
|
12
|
+
*/
|
|
13
|
+
function ledgerStoreConformance(name, make) {
|
|
14
|
+
const evt = (actor, n, over = {}) => ({
|
|
15
|
+
ts: `2026-08-07T00:00:0${n}.000Z`, actor, kind: 'spec-approved',
|
|
16
|
+
summary: `${actor} ${n}`, inputs: [], rationale: 'r', ...over,
|
|
17
|
+
});
|
|
18
|
+
describe(`LedgerStore conformance — ${name} (A-SPEC-150)`, () => {
|
|
19
|
+
it('normal: an appended event is readable', () => {
|
|
20
|
+
const s = make();
|
|
21
|
+
s.append(evt('a', 0));
|
|
22
|
+
expect(s.loadAll().map((e) => e.summary)).toContain('a 0');
|
|
23
|
+
});
|
|
24
|
+
it('normal: the store reports its own replica identity', () => {
|
|
25
|
+
expect(make().replicaId()).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
it('negative: reading leaves NO trace — a reader that writes is a defect regardless of what it breaks', () => {
|
|
28
|
+
// Promoted to the CONTRACT after the file adapter reintroduced it: resolving the replica id in
|
|
29
|
+
// the constructor made a mere read create `.ax/state/`. REQ-148 had already fixed exactly this
|
|
30
|
+
// in ProvenanceLedger, and it came back in the next implementation *because the contract did
|
|
31
|
+
// not require it*. Fixing it in one place is not fixing it.
|
|
32
|
+
const s = make();
|
|
33
|
+
const snapshot = () => JSON.stringify(s.loadAll());
|
|
34
|
+
const before = snapshot();
|
|
35
|
+
s.chains();
|
|
36
|
+
s.verifyAll();
|
|
37
|
+
s.loadAll();
|
|
38
|
+
s.isNonceConsumed('N-x');
|
|
39
|
+
s.replicaId();
|
|
40
|
+
expect(snapshot()).toBe(before);
|
|
41
|
+
expect(s.loadAll()).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
it('normal: an empty store verifies and reads empty', () => {
|
|
44
|
+
const s = make();
|
|
45
|
+
expect(s.loadAll()).toEqual([]);
|
|
46
|
+
expect(s.verifyAll().ok).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('normal: verifyAll passes over what it wrote', () => {
|
|
49
|
+
const s = make();
|
|
50
|
+
s.append(evt('a', 0));
|
|
51
|
+
s.append(evt('a', 1));
|
|
52
|
+
expect(s.verifyAll().ok).toBe(true);
|
|
53
|
+
expect(s.verifyAll().broken).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
it('boundary: loadAll is deterministic — repeated reads agree exactly', () => {
|
|
56
|
+
const s = make();
|
|
57
|
+
s.append(evt('a', 0));
|
|
58
|
+
s.append(evt('a', 1));
|
|
59
|
+
expect(JSON.stringify(s.loadAll())).toBe(JSON.stringify(s.loadAll()));
|
|
60
|
+
});
|
|
61
|
+
it('normal: chains() reports the replica that wrote, by identity not location', () => {
|
|
62
|
+
const s = make();
|
|
63
|
+
s.append(evt('a', 0));
|
|
64
|
+
expect(s.chains().map((c) => c.replicaId)).toContain(s.replicaId());
|
|
65
|
+
});
|
|
66
|
+
it('negative: append is append-only — an earlier event is never altered', () => {
|
|
67
|
+
// The property the whole audit trail rests on. An adapter that rewrote history would still
|
|
68
|
+
// satisfy "the new event is readable", so this has to be asserted separately.
|
|
69
|
+
const s = make();
|
|
70
|
+
s.append(evt('a', 0));
|
|
71
|
+
const before = JSON.stringify(s.loadAll());
|
|
72
|
+
s.append(evt('a', 1));
|
|
73
|
+
expect(JSON.stringify(s.loadAll().slice(0, 1))).toBe(before);
|
|
74
|
+
});
|
|
75
|
+
it('corner: a nonce spent in the store is seen as spent', () => {
|
|
76
|
+
// REQ-148's rule promoted to the CONTRACT: judging from a single chain lets one single-use
|
|
77
|
+
// approval be spent once per replica. Any adapter that stores chains separately can get this
|
|
78
|
+
// wrong, so the contract — not one implementation — has to require it.
|
|
79
|
+
const s = make();
|
|
80
|
+
s.append(evt('a', 0, { kind: 'nonce-consumed', inputs: ['N-1'] }));
|
|
81
|
+
expect(s.isNonceConsumed('N-1')).toBe(true);
|
|
82
|
+
expect(s.isNonceConsumed('N-2')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it('negative: an ordinary event does not count as a consumption', () => {
|
|
85
|
+
const s = make();
|
|
86
|
+
s.append(evt('a', 0));
|
|
87
|
+
expect(s.isNonceConsumed('N-1')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryLedgerStore = exports.FileLedgerStore = void 0;
|
|
4
|
+
// @implements A-SPEC-150
|
|
5
|
+
const provenance_chain_1 = require("./provenance-chain");
|
|
6
|
+
const provenance_ledger_1 = require("./provenance-ledger");
|
|
7
|
+
/**
|
|
8
|
+
* Local adapter — DELEGATES to `ProvenanceLedger` rather than re-implementing it.
|
|
9
|
+
*
|
|
10
|
+
* That is load-bearing, not stylistic. REQ-141's per-file lock, REQ-147's read compatibility with the
|
|
11
|
+
* pre-split ledger, and REQ-148's replica split all live inside `ProvenanceLedger`. An adapter that
|
|
12
|
+
* wrote files itself would satisfy every conformance test while silently dropping all three, and
|
|
13
|
+
* nothing in this suite would notice — which is why a structural test pins the delegation too.
|
|
14
|
+
*/
|
|
15
|
+
class FileLedgerStore {
|
|
16
|
+
dir;
|
|
17
|
+
givenId;
|
|
18
|
+
inner;
|
|
19
|
+
cachedId;
|
|
20
|
+
/**
|
|
21
|
+
* The replica id is resolved LAZILY and the root derivation is delegated.
|
|
22
|
+
*
|
|
23
|
+
* Both were regressions I reintroduced here: resolving in the constructor made a mere read create
|
|
24
|
+
* `.ax/state/` (REQ-148 had fixed precisely that in ProvenanceLedger), and deriving the project
|
|
25
|
+
* root with a local regex duplicated a rule that already exists — two copies of one rule drift.
|
|
26
|
+
* The contract now demands the no-footprint property so the NEXT adapter cannot lose it either.
|
|
27
|
+
*/
|
|
28
|
+
constructor(dir, givenId) {
|
|
29
|
+
this.dir = dir;
|
|
30
|
+
this.givenId = givenId;
|
|
31
|
+
this.inner = provenance_ledger_1.ProvenanceLedger.at(dir, givenId);
|
|
32
|
+
}
|
|
33
|
+
replicaId() {
|
|
34
|
+
if (this.cachedId === undefined)
|
|
35
|
+
this.cachedId = this.givenId ?? this.inner.replicaIdOf();
|
|
36
|
+
return this.cachedId;
|
|
37
|
+
}
|
|
38
|
+
chains() {
|
|
39
|
+
// `id` is the chain's NAME, not its path — the contract must stay satisfiable without a filesystem.
|
|
40
|
+
return this.inner.chains().map((c) => ({ replicaId: c.replicaId, id: c.replicaId ?? 'legacy' }));
|
|
41
|
+
}
|
|
42
|
+
loadAll() { return this.inner.loadAll(); }
|
|
43
|
+
verifyAll() {
|
|
44
|
+
const r = this.inner.verifyAll();
|
|
45
|
+
return { ok: r.ok, broken: r.broken.map((b) => ({ replicaId: b.replicaId, detail: b.detail })) };
|
|
46
|
+
}
|
|
47
|
+
append(body) {
|
|
48
|
+
return this.inner.append(body);
|
|
49
|
+
}
|
|
50
|
+
isNonceConsumed(nonce) { return this.inner.isNonceConsumed(nonce); }
|
|
51
|
+
}
|
|
52
|
+
exports.FileLedgerStore = FileLedgerStore;
|
|
53
|
+
/**
|
|
54
|
+
* In-memory adapter — the instrument that makes the contract testable.
|
|
55
|
+
*
|
|
56
|
+
* Uses NO filesystem, deliberately. An in-memory store backed by a temp directory would be the file
|
|
57
|
+
* adapter in disguise and would prove nothing about coupling. It reuses the pure chain functions
|
|
58
|
+
* (`chainNext`, `verifyChain`) because those are the format, not the storage.
|
|
59
|
+
*/
|
|
60
|
+
class MemoryLedgerStore {
|
|
61
|
+
id;
|
|
62
|
+
byReplica = new Map();
|
|
63
|
+
constructor(id = 'memory') {
|
|
64
|
+
this.id = id;
|
|
65
|
+
}
|
|
66
|
+
replicaId() { return this.id; }
|
|
67
|
+
chains() {
|
|
68
|
+
return [...this.byReplica.keys()].sort((a, b) => String(a).localeCompare(String(b)))
|
|
69
|
+
.map((r) => ({ replicaId: r, id: r ?? 'legacy' }));
|
|
70
|
+
}
|
|
71
|
+
loadAll() {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const [replicaId, events] of this.byReplica)
|
|
74
|
+
for (const e of events)
|
|
75
|
+
out.push({ ...e, replicaId });
|
|
76
|
+
const key = (e) => `${e.ts} ${e.replicaId ?? ''} ${String(e.seq).padStart(12, '0')}`;
|
|
77
|
+
return out.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
78
|
+
}
|
|
79
|
+
verifyAll() {
|
|
80
|
+
const broken = [];
|
|
81
|
+
for (const [replicaId, events] of this.byReplica) {
|
|
82
|
+
const r = (0, provenance_chain_1.verifyChain)(events, process.env.HOLMES_LEDGER_KEY || undefined);
|
|
83
|
+
if (!r.ok)
|
|
84
|
+
broken.push({ replicaId, detail: r.detail });
|
|
85
|
+
}
|
|
86
|
+
return { ok: broken.length === 0, broken };
|
|
87
|
+
}
|
|
88
|
+
append(body) {
|
|
89
|
+
const chain = this.byReplica.get(this.id) ?? [];
|
|
90
|
+
const evt = (0, provenance_chain_1.chainNext)(chain, body, process.env.HOLMES_LEDGER_KEY || undefined);
|
|
91
|
+
// Push onto a copy-then-set so an earlier event object is never mutated in place — append-only
|
|
92
|
+
// has to hold for the objects a caller already read, not just for the array.
|
|
93
|
+
this.byReplica.set(this.id, [...chain, evt]);
|
|
94
|
+
return evt;
|
|
95
|
+
}
|
|
96
|
+
isNonceConsumed(nonce) {
|
|
97
|
+
// @implements A-SPEC-191 §13 — the fingerprint form is what writers record now (§11c). An
|
|
98
|
+
// adapter that only knew the raw value would report 'not consumed' for every real consumption,
|
|
99
|
+
// and the conformance suite would still pass because it wrote raw values itself.
|
|
100
|
+
const { nonceFingerprint } = require('./provenance-chain');
|
|
101
|
+
const fp = nonceFingerprint(nonce);
|
|
102
|
+
return this.loadAll().some((e) => e.kind === 'nonce-consumed'
|
|
103
|
+
&& ((e.inputs ?? []).includes(nonce) || (e.inputs ?? []).includes(fp)));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
exports.MemoryLedgerStore = MemoryLedgerStore;
|
|
@@ -0,0 +1,83 @@
|
|
|
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.formatProgressEntry = formatProgressEntry;
|
|
37
|
+
exports.parseProgressLog = parseProgressLog;
|
|
38
|
+
exports.appendProgress = appendProgress;
|
|
39
|
+
exports.readProgress = readProgress;
|
|
40
|
+
// @implements A-SPEC-125.2
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
43
|
+
function formatProgressEntry(e) {
|
|
44
|
+
if (!e.ts || !e.kind || !e.summary)
|
|
45
|
+
throw new Error('progress entry requires ts, kind, summary');
|
|
46
|
+
return JSON.stringify({ ts: e.ts, kind: e.kind, summary: e.summary, ...(e.refs?.length ? { refs: e.refs } : {}), ...(e.suite != null ? { suite: e.suite } : {}), ...(e.head ? { head: e.head } : {}) });
|
|
47
|
+
}
|
|
48
|
+
function parseProgressLog(text) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const line of text.split('\n')) {
|
|
51
|
+
const t = line.trim();
|
|
52
|
+
if (!t)
|
|
53
|
+
continue;
|
|
54
|
+
try {
|
|
55
|
+
const e = JSON.parse(t);
|
|
56
|
+
if (e && typeof e.ts === 'string' && typeof e.kind === 'string' && typeof e.summary === 'string')
|
|
57
|
+
out.push(e);
|
|
58
|
+
}
|
|
59
|
+
catch { /* skip corrupt line — append-only logs tolerate torn writes */ }
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
const LEDGER = path.join('.ax', 'ledger', 'progress.jsonl');
|
|
64
|
+
function appendProgress(root, entry) {
|
|
65
|
+
try {
|
|
66
|
+
const file = path.join(root, LEDGER);
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
fs.appendFileSync(file, formatProgressEntry(entry) + '\n');
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
} // fail-open: a ledger write must never break the workflow
|
|
74
|
+
}
|
|
75
|
+
function readProgress(root, lastN) {
|
|
76
|
+
try {
|
|
77
|
+
const all = parseProgressLog(fs.readFileSync(path.join(root, LEDGER), 'utf8'));
|
|
78
|
+
return lastN ? all.slice(-lastN) : all;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|