@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,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeTrustScore = computeTrustScore;
|
|
4
|
+
function computeTrustScore(m) {
|
|
5
|
+
// Malformed metrics fail SAFE to observe-only — trust must never be granted on unreadable data.
|
|
6
|
+
const nums = [m.slicesCompleted, m.suiteGreenRate, m.guardrailDenials, m.faults, m.regressions, m.criticalFindings];
|
|
7
|
+
if (nums.some((n) => typeof n !== 'number' || Number.isNaN(n) || n < 0) || m.suiteGreenRate > 1) {
|
|
8
|
+
return { score: 0, level: 'L0', rationale: 'malformed metrics — trust cannot be assessed (fail-safe to observe-only)' };
|
|
9
|
+
}
|
|
10
|
+
// Base: earned by volume of green work, saturating (12 slices ≈ full credit), scaled by green rate.
|
|
11
|
+
const earned = Math.min(1, m.slicesCompleted / 12) * m.suiteGreenRate;
|
|
12
|
+
// Penalties: multiplicative decay — each incident class independently shrinks trust.
|
|
13
|
+
const penalty = Math.pow(0.5, m.faults) *
|
|
14
|
+
Math.pow(0.6, m.regressions) *
|
|
15
|
+
Math.pow(0.7, m.criticalFindings) *
|
|
16
|
+
Math.pow(0.9, m.guardrailDenials);
|
|
17
|
+
const score = Math.round(earned * penalty * 1000) / 1000;
|
|
18
|
+
// Level from score, with HARD CAPS: fresh faults/regressions cap at L1; criticals cap at L2.
|
|
19
|
+
let level = score >= 0.85 ? 'L4' : score >= 0.65 ? 'L3' : score >= 0.35 ? 'L2' : score >= 0.15 ? 'L1' : 'L0';
|
|
20
|
+
if (m.faults > 0 || m.regressions > 0)
|
|
21
|
+
level = capLevel(level, 'L1');
|
|
22
|
+
else if (m.criticalFindings > 0)
|
|
23
|
+
level = capLevel(level, 'L2');
|
|
24
|
+
if (m.slicesCompleted === 0)
|
|
25
|
+
level = capLevel(level, 'L2'); // no track record -> never above default-gated
|
|
26
|
+
return { score, level, rationale: rationaleFor(m, score, level) };
|
|
27
|
+
}
|
|
28
|
+
const ORDER = ['L0', 'L1', 'L2', 'L3', 'L4'];
|
|
29
|
+
function capLevel(l, cap) {
|
|
30
|
+
return ORDER.indexOf(l) > ORDER.indexOf(cap) ? cap : l;
|
|
31
|
+
}
|
|
32
|
+
function rationaleFor(m, score, level) {
|
|
33
|
+
const parts = [`score=${score}`, `slices=${m.slicesCompleted}`, `green=${m.suiteGreenRate}`];
|
|
34
|
+
if (m.faults)
|
|
35
|
+
parts.push(`faults=${m.faults} (caps L1)`);
|
|
36
|
+
if (m.regressions)
|
|
37
|
+
parts.push(`regressions=${m.regressions} (caps L1)`);
|
|
38
|
+
if (m.criticalFindings)
|
|
39
|
+
parts.push(`criticals=${m.criticalFindings} (caps L2)`);
|
|
40
|
+
if (m.guardrailDenials)
|
|
41
|
+
parts.push(`denials=${m.guardrailDenials}`);
|
|
42
|
+
return `${level}: ${parts.join(', ')} — hard-hitl always gates regardless of level`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.claimedAnchors = claimedAnchors;
|
|
4
|
+
exports.newlyClaimed = newlyClaimed;
|
|
5
|
+
// @implements A-SPEC-166
|
|
6
|
+
/**
|
|
7
|
+
* Which specs a piece of content CLAIMS to implement.
|
|
8
|
+
*
|
|
9
|
+
* Measured 2026-08-08: writing `@implements A-SPEC-163` — an unapproved spec — into an EXISTING file
|
|
10
|
+
* was allowed, while the identical content in a new file was denied. The gate asked whether the FILE
|
|
11
|
+
* carried an approved anchor, never what the CHANGE was claiming, so No-Spec-No-Code held per file
|
|
12
|
+
* and not per change. Most real work edits existing files.
|
|
13
|
+
*
|
|
14
|
+
* Whether a change falls within its spec's scope is a natural-language judgement, and this harness
|
|
15
|
+
* keeps LLM judgement out of governance. What a change CLAIMS, though, is right there in the text —
|
|
16
|
+
* that is the decidable subset, and this is it.
|
|
17
|
+
*/
|
|
18
|
+
const ANCHOR_RE = /@implements\s+([A-Za-z]+-SPEC-[\w.]+)/g;
|
|
19
|
+
function claimedAnchors(content) {
|
|
20
|
+
return [...new Set([...content.matchAll(ANCHOR_RE)].map((m) => m[1]))];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The anchors this write ADDS, relative to what the file already had.
|
|
24
|
+
*
|
|
25
|
+
* Only the additions are judged. Re-judging anchors already in the file would block every edit to a
|
|
26
|
+
* file whose spec later moved back to draft — including the edits needed to fix exactly that.
|
|
27
|
+
*/
|
|
28
|
+
function newlyClaimed(content, previous) {
|
|
29
|
+
const had = new Set(previous ? claimedAnchors(previous) : []);
|
|
30
|
+
return claimedAnchors(content).filter((a) => !had.has(a));
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GATE_BLIND_SPOTS = void 0;
|
|
4
|
+
exports.blindSpotSummary = blindSpotSummary;
|
|
5
|
+
exports.GATE_BLIND_SPOTS = [
|
|
6
|
+
{ example: 'node ./cleanup.js', why: '스크립트 파일의 내용은 명령 문자열에 없습니다' },
|
|
7
|
+
{ example: 'bash ./deploy.sh', why: '같은 이유 — 실행할 내용이 파일 안에 있습니다' },
|
|
8
|
+
{ example: 'npm run clean', why: 'package.json의 스크립트 정의가 명령 문자열 밖에 있습니다' },
|
|
9
|
+
{ example: 'make distclean', why: 'Makefile의 규칙이 명령 문자열 밖에 있습니다' },
|
|
10
|
+
{ example: 'CMD="something"; $CMD', why: '실행될 명령이 실행 시점에 조립됩니다' },
|
|
11
|
+
{ example: 'xargs rm < targets.txt', why: '대상 목록이 표준 입력으로 들어옵니다' },
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* One line for `doctor`.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately neither "the gate stops everything" nor "the gate stops nothing". The first gets
|
|
17
|
+
* someone hurt; the second makes a reader distrust the parts that do work — and after REQ-167 those
|
|
18
|
+
* parts include creating the script in the first place.
|
|
19
|
+
*/
|
|
20
|
+
function blindSpotSummary() {
|
|
21
|
+
return '가드레일은 명령 문자열을 검사합니다 — 이미 저장소에 있는 스크립트를 실행하는 명령'
|
|
22
|
+
+ `(${exports.GATE_BLIND_SPOTS.slice(0, 3).map((b) => b.example).join(', ')} 등)의 내용은 보지 못합니다.`
|
|
23
|
+
+ ' 스크립트를 새로 작성하는 것은 승인이 필요합니다(A-SPEC-167).'
|
|
24
|
+
// @implements A-SPEC-170
|
|
25
|
+
// Stated, not implied. Four sweep rounds each found runner-manifest names the previous round's
|
|
26
|
+
// list had missed, so calling the list complete would be false — and this is the one place the
|
|
27
|
+
// harness tells a user what it cannot see. It belongs in the prose, not in GATE_BLIND_SPOTS,
|
|
28
|
+
// whose entries are commands a test executes against the gate.
|
|
29
|
+
+ ' 루트 러너 매니페스트(Rakefile·Gemfile 등)의 이름 목록은 완전하지 않습니다 — 생태계마다 새로 생깁니다.'
|
|
30
|
+
// @implements A-SPEC-175
|
|
31
|
+
// The residual after REQ-175. Losing `.ax/specs` is now detected, because the ledger records the
|
|
32
|
+
// approvals and `init`'s .gitignore block keeps `provenance*.jsonl` committed. Losing the whole
|
|
33
|
+
// `.ax` takes that evidence with it, and a project then looks like one that never opted in. Every
|
|
34
|
+
// shell route to either state is denied by this gate; what remains is deletion from outside the
|
|
35
|
+
// session, and saying so is the only honest option left.
|
|
36
|
+
+ ' `.ax` 전체가 세션 밖에서 삭제되면(에디터·Finder·세션 밖 git) 원장도 함께 사라져'
|
|
37
|
+
+ ' 거버넌스를 켠 적 없는 프로젝트와 구별되지 않습니다 — `.ax/specs`만 사라진 경우는 탐지됩니다(REQ-175).';
|
|
38
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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.DecisionLedger = void 0;
|
|
37
|
+
// @implements A-SPEC-125.2
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
// Default path used by callers: .ax/ledger/decisions.jsonl
|
|
41
|
+
/**
|
|
42
|
+
* Append-only, provenance audit trail for riskGate decisions. Each call to
|
|
43
|
+
* `record` appends one JSON line per event to a `.jsonl` file -- never
|
|
44
|
+
* truncated, never rewritten -- so the file itself is the durable evidence
|
|
45
|
+
* trail.
|
|
46
|
+
*
|
|
47
|
+
* Timestamps and ids are caller-supplied: this ledger has no clock, so
|
|
48
|
+
* recording is fully deterministic given its inputs. Ledger I/O is
|
|
49
|
+
* deliberately separate from riskGate's decision -- a write failure here
|
|
50
|
+
* must never change a gate verdict that was already computed.
|
|
51
|
+
*/
|
|
52
|
+
class DecisionLedger {
|
|
53
|
+
path;
|
|
54
|
+
constructor(path) {
|
|
55
|
+
this.path = path;
|
|
56
|
+
}
|
|
57
|
+
record(events) {
|
|
58
|
+
if (events.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
const lines = events.map((e) => DecisionLedger.serialize(e)).join('');
|
|
61
|
+
fs.mkdirSync(path.dirname(this.path), { recursive: true });
|
|
62
|
+
fs.appendFileSync(this.path, lines, 'utf8');
|
|
63
|
+
}
|
|
64
|
+
list() {
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = fs.readFileSync(this.path, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
// Missing file: no decisions recorded yet, not an error. Any other
|
|
71
|
+
// failure (EACCES, a directory at this path, etc.) must NOT be
|
|
72
|
+
// swallowed: silently returning [] here would erase the audit trail
|
|
73
|
+
// that the DecisionLedger exists to guarantee -- a silent gate-pass
|
|
74
|
+
// equivalent.
|
|
75
|
+
if (err.code === 'ENOENT')
|
|
76
|
+
return [];
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
const events = [];
|
|
80
|
+
for (const line of raw.split('\n')) {
|
|
81
|
+
if (line.trim().length === 0)
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
events.push(JSON.parse(line));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Corrupt line: skip it rather than throwing away the whole ledger.
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return events;
|
|
92
|
+
}
|
|
93
|
+
/** Ordered-object serialization so identical events always produce identical bytes. */
|
|
94
|
+
static serialize(e) {
|
|
95
|
+
const ordered = {
|
|
96
|
+
ts: e.ts,
|
|
97
|
+
actor: e.actor,
|
|
98
|
+
action: e.action,
|
|
99
|
+
level: e.level,
|
|
100
|
+
decision: e.decision,
|
|
101
|
+
rationale: e.rationale,
|
|
102
|
+
reasons: e.reasons,
|
|
103
|
+
};
|
|
104
|
+
return JSON.stringify(ordered) + '\n';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
exports.DecisionLedger = DecisionLedger;
|
|
@@ -0,0 +1,129 @@
|
|
|
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.isExecutableArtifact = isExecutableArtifact;
|
|
37
|
+
const path = __importStar(require("node:path"));
|
|
38
|
+
// @implements A-SPEC-167
|
|
39
|
+
/**
|
|
40
|
+
* Whether writing this file creates an EXECUTION path.
|
|
41
|
+
*
|
|
42
|
+
* Measured 2026-08-08: `package.json`, `Makefile`, `Dockerfile`, `.github/workflows/*` and `*.sh`
|
|
43
|
+
* all classified as nothing and were therefore allowed unconditionally — while every one of them
|
|
44
|
+
* runs. `package.json` executes on install, a workflow executes holding repository secrets, the
|
|
45
|
+
* Makefile and Dockerfile execute at build time. Combined with the indirection limit (REQ-165),
|
|
46
|
+
* writing `x.sh` and then running `bash ./x.sh` was a complete governance bypass in two allowed
|
|
47
|
+
* steps.
|
|
48
|
+
*
|
|
49
|
+
* The cause is that "code" was an extension list. What executes is code; the compiler's file list
|
|
50
|
+
* is a proxy that misses everything else.
|
|
51
|
+
*/
|
|
52
|
+
/** Names the ecosystem fixed. A short exact list is defensible where an extension list is not. */
|
|
53
|
+
const MANIFESTS = [
|
|
54
|
+
'package.json', 'makefile', 'dockerfile',
|
|
55
|
+
// Axis sweep round 2: the first list was JS and C only, so a Java, Rust or Python project's build
|
|
56
|
+
// file went ungated while the harness claims eight languages. Each executes at build or install
|
|
57
|
+
// time; `.npmrc` reaches the supply chain through `ignore-scripts` and the registry.
|
|
58
|
+
'build.gradle', 'build.gradle.kts', 'pom.xml', 'cargo.toml', 'pyproject.toml',
|
|
59
|
+
'justfile', 'taskfile.yml', 'taskfile.yaml', '.npmrc', 'setup.py',
|
|
60
|
+
// Round 4. Root runner manifests share no property, so this stays an enumeration — and the fact
|
|
61
|
+
// that it is INCOMPLETE is declared rather than pretended away (see blind-spots.ts).
|
|
62
|
+
'rakefile', 'gemfile', 'jenkinsfile', 'vagrantfile',
|
|
63
|
+
'docker-compose.yml', 'docker-compose.yaml', '.gitlab-ci.yml', '.gitlab-ci.yaml',
|
|
64
|
+
// Round 5 parity: GitLab was in and Travis, Azure and Bitbucket were not — one CI service
|
|
65
|
+
// covered and three not is an inconsistency in this list, the same shape `.command` was. Brewfile
|
|
66
|
+
// and Procfile were also found and deliberately LEFT OUT: they describe an environment rather
|
|
67
|
+
// than running on write, and they fall inside the incompleteness this list already declares.
|
|
68
|
+
'.travis.yml', 'azure-pipelines.yml', 'bitbucket-pipelines.yml',
|
|
69
|
+
];
|
|
70
|
+
/** Scripts without a shebang. A supplement, and known to be insufficient on its own. */
|
|
71
|
+
// `.command` is macOS's — double-clickable and executed by Finder. Round 3 found it missing while
|
|
72
|
+
// `.bat` and `.cmd` were present, which was an inconsistency in this list rather than a new idea.
|
|
73
|
+
/** Suffixes that mark a copy, a draft or a note rather than a live manifest. */
|
|
74
|
+
const DOC_SUFFIXES = ['.md', '.txt', '.bak', '.example', '.sample', '.template', '.orig'];
|
|
75
|
+
const SCRIPT_EXTS = ['.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd', '.command'];
|
|
76
|
+
/**
|
|
77
|
+
* @implements A-SPEC-170
|
|
78
|
+
* Directories whose CONTENTS execute, whatever the files are called.
|
|
79
|
+
*
|
|
80
|
+
* Rounds 1-3 each filled a name list and round 4 found eight more — the trend was the finding, not
|
|
81
|
+
* the eight names. One rule covers them and the next CI tool lands in the same place.
|
|
82
|
+
*
|
|
83
|
+
* `.github` is narrowed to `workflows/` on purpose: `ISSUE_TEMPLATE/` and `CODEOWNERS` do not
|
|
84
|
+
* execute, and a blanket rule over the whole directory would repeat REQ-164's measured accident,
|
|
85
|
+
* where a broad directory match swallowed files that never belonged to the category.
|
|
86
|
+
*/
|
|
87
|
+
const EXECUTABLE_DIRS = ['.github/workflows/', '.circleci/', '.husky/', '.git/hooks/'];
|
|
88
|
+
function isExecutableArtifact(relPath, content) {
|
|
89
|
+
// A shebang is the broadest signal and the cheapest — the content is already in the payload, so
|
|
90
|
+
// there is no I/O — and it is the only one that catches an extensionless `bin/deploy`.
|
|
91
|
+
if (typeof content === 'string' && content.startsWith('#!'))
|
|
92
|
+
return true;
|
|
93
|
+
// Lexically normalised before matching. Round 9 found `.github/workflows/../ISSUE_TEMPLATE/x.md`
|
|
94
|
+
// treated as a workflow because the raw string CONTAINS `.github/workflows/` — REQ-163 established
|
|
95
|
+
// that a path is judged by what it resolves to, and this function was still doing substring work.
|
|
96
|
+
// `path.posix.normalize` collapses `..` and `.` without touching the filesystem, so the rule stays
|
|
97
|
+
// pure.
|
|
98
|
+
const norm = path.posix.normalize(relPath.replace(/\\/g, '/')).toLowerCase();
|
|
99
|
+
const base = norm.slice(norm.lastIndexOf('/') + 1);
|
|
100
|
+
// Exact base name, or a manifest with a VARIANT suffix — `Dockerfile.prod`,
|
|
101
|
+
// `docker-compose.override.yml` and `Makefile.inc` are real conventions that build tools read,
|
|
102
|
+
// found in round 10 while their bare forms were covered. Still never a bare prefix: the suffix
|
|
103
|
+
// must not be a document or backup extension, or `Makefile.md` and `pom.xml.bak` would inherit
|
|
104
|
+
// protection they did not earn, and the over-blocking this design spends half its effort avoiding
|
|
105
|
+
// would start.
|
|
106
|
+
if (MANIFESTS.includes(base))
|
|
107
|
+
return true;
|
|
108
|
+
// A VARIANT keeps the manifest's identity, not just its opening letters. Two shapes, because the
|
|
109
|
+
// manifests come in two shapes:
|
|
110
|
+
// - extensionless (`Dockerfile`) → `Dockerfile.prod` : name + a suffix
|
|
111
|
+
// - with an extension (`…-compose.yml`) → `…-compose.override.yml`: stem + a segment + SAME ext
|
|
112
|
+
// A bare prefix rule was tried first and pulled in `pyproject.py`, a Python source file that is
|
|
113
|
+
// not a variant of `pyproject.toml` at all — the existing over-blocking control caught it.
|
|
114
|
+
if (!DOC_SUFFIXES.some((d) => base.endsWith(d))) {
|
|
115
|
+
for (const m of MANIFESTS) {
|
|
116
|
+
const dot = m.startsWith('.') ? m.indexOf('.', 1) : m.indexOf('.');
|
|
117
|
+
if (dot < 0) {
|
|
118
|
+
if (base.startsWith(`${m}.`))
|
|
119
|
+
return true; // Dockerfile.prod
|
|
120
|
+
}
|
|
121
|
+
else if (base.startsWith(`${m.slice(0, dot)}.`) && base.endsWith(m.slice(dot))) {
|
|
122
|
+
return true; // docker-compose.override.yml
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (EXECUTABLE_DIRS.some((d) => norm.includes(d)))
|
|
127
|
+
return true;
|
|
128
|
+
return SCRIPT_EXTS.some((e) => base.endsWith(e));
|
|
129
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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.GOVERNANCE_LOST_HINT = void 0;
|
|
37
|
+
exports.hasGovernanceHistory = hasGovernanceHistory;
|
|
38
|
+
// @implements A-SPEC-175
|
|
39
|
+
/**
|
|
40
|
+
* Did this project ever opt into governance?
|
|
41
|
+
*
|
|
42
|
+
* The ungoverned escape hatch allows every write on a project with no spec tree, which is correct for
|
|
43
|
+
* a repository that never opted in — without it, pointing the Write matcher at a spec-less project
|
|
44
|
+
* denies 100% of source writes. But the condition could not tell that case apart from a governed
|
|
45
|
+
* project whose specs went missing, and measured 2026-08-12 on an installed tarball: moving
|
|
46
|
+
* `.ax/specs` aside turned an unanchored code write from `deny` to `allow`, silently.
|
|
47
|
+
*
|
|
48
|
+
* The answer has to come from evidence that outlives the spec tree. `spec_approve` writes a
|
|
49
|
+
* `spec-approved` event carrying the sealed digest, and the `.gitignore` block `init` installs
|
|
50
|
+
* excludes `provenance*.jsonl` from being ignored — so the record is committed and survives a clone.
|
|
51
|
+
* A marker file would fail twice over: it can be deleted alongside the specs, and no already-installed
|
|
52
|
+
* project has one.
|
|
53
|
+
*/
|
|
54
|
+
const fs = __importStar(require("node:fs"));
|
|
55
|
+
const path = __importStar(require("node:path"));
|
|
56
|
+
const APPROVAL_EVENT = '"kind":"spec-approved"';
|
|
57
|
+
/**
|
|
58
|
+
* The two ways out, and why the project is being judged governed at all.
|
|
59
|
+
*
|
|
60
|
+
* Shared by both hooks so they cannot describe the same situation differently. Naming only the
|
|
61
|
+
* restore path would be no guidance to a user whose actual intent is to stop being governed — they
|
|
62
|
+
* would keep deleting things until it stopped.
|
|
63
|
+
*/
|
|
64
|
+
exports.GOVERNANCE_LOST_HINT = '[Holmes-Kit] .ax/specs is missing, but this project has approved specs in its ledger — governance was'
|
|
65
|
+
+ ' switched on here and its documents are gone, so every code write is refused rather than silently'
|
|
66
|
+
+ ' ungoverned. Restore the spec tree (e.g. `git checkout -- .ax/specs`), or run'
|
|
67
|
+
+ ' `holmes-kit init --target . --remove` if you are done with governance.';
|
|
68
|
+
/**
|
|
69
|
+
* True if any provenance replica records a spec approval.
|
|
70
|
+
*
|
|
71
|
+
* Fails toward `true` on anything unreadable: if "cannot read" meant "never governed", corrupting a
|
|
72
|
+
* ledger file would be a way to switch the gate off — worse than the hole this closes.
|
|
73
|
+
*/
|
|
74
|
+
function hasGovernanceHistory(root) {
|
|
75
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = fs.readdirSync(dir);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// A ledger directory that exists but cannot be listed is evidence withheld, not evidence absent.
|
|
82
|
+
return err.code !== 'ENOENT';
|
|
83
|
+
}
|
|
84
|
+
// Only provenance replicas. `stop-guard.json` and other bookkeeping live here too, and treating
|
|
85
|
+
// any file as the record would let unrelated state decide whether the gate is armed.
|
|
86
|
+
for (const name of entries.filter((n) => n.startsWith('provenance') && n.endsWith('.jsonl'))) {
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
// A substring scan, not a per-line parse: the answer is boolean, a broken line must not hide a
|
|
95
|
+
// real approval on another line, and a large ledger must not be walked object by object inside a
|
|
96
|
+
// hook. The needle is the serialized field, so ordinary prose in a `summary` cannot forge it.
|
|
97
|
+
if (text.includes(APPROVAL_EVENT))
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ACTIONS = void 0;
|
|
4
|
+
exports.classifyAction = classifyAction;
|
|
5
|
+
exports.phaseCheck = phaseCheck;
|
|
6
|
+
const executable_artifact_1 = require("./executable-artifact");
|
|
7
|
+
const test_files_1 = require("../cpg/test-files");
|
|
8
|
+
const approval_blockers_1 = require("../spec/approval-blockers");
|
|
9
|
+
const tspec_state_1 = require("./tspec-state");
|
|
10
|
+
// @implements A-SPEC-100.2
|
|
11
|
+
// @implements A-SPEC-189
|
|
12
|
+
// The RUNTIME list is the single truth; the type derives from it. The phase_check schema's enum
|
|
13
|
+
// advertises this same array — a hand-copied literal there was a second truth, and round-3 measured
|
|
14
|
+
// that deleting two of its members survived the entire suite while the wire falsely refused actions
|
|
15
|
+
// this module accepts.
|
|
16
|
+
/** 문면에 실을 수 있는 id 만 문자 그대로 — 나머지는 이름을 잃되 문장은 잃지 않는다(§5R). */
|
|
17
|
+
const quotableId = (id) => (id === undefined ? null : id.length <= tspec_state_1.ID_MAX ? id : null);
|
|
18
|
+
exports.ACTIONS = [
|
|
19
|
+
'AUTHOR_REQ', 'AUTHOR_HSPEC', 'AUTHOR_ASPEC', 'AUTHOR_CSPEC', 'AUTHOR_TSPEC',
|
|
20
|
+
'WRITE_TEST', 'WRITE_CODE',
|
|
21
|
+
];
|
|
22
|
+
const NO_SPEC_NO_CODE = 'No Spec, No Code — 선행 스펙 승인 전 구현 진입 금지';
|
|
23
|
+
// Executable-source extensions the WRITE_CODE gate must cover. Kept broad on purpose: an extension
|
|
24
|
+
// NOT listed here silently escapes No-Spec-No-Code, so err toward inclusion (adversarial-review F2 —
|
|
25
|
+
// .mjs/.cjs/.mts/.cts and C/C++ header/variant + other-language sources were escaping).
|
|
26
|
+
const CODE_EXT_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|kts|scala|cs|c|cc|cxx|cpp|h|hh|hpp|hxx|m|mm|swift|rb|php)$/i;
|
|
27
|
+
/**
|
|
28
|
+
* Classify a write target. `p` must be PROJECT-RELATIVE (POSIX or Windows separators both fine).
|
|
29
|
+
*
|
|
30
|
+
* Relative matters for the test rules: several of them key on a directory segment, so an absolute
|
|
31
|
+
* path would let a project checked out under `/…/test/` classify its production code as tests —
|
|
32
|
+
* DOWNGRADING the gate, since WRITE_TEST does not require an approved T-SPEC. Callers that hold an
|
|
33
|
+
* absolute path make it relative first (see evaluateHook).
|
|
34
|
+
*/
|
|
35
|
+
function classifyAction(p, content) {
|
|
36
|
+
const norm = p.replace(/\\/g, '/');
|
|
37
|
+
// Spec authoring is a MARKDOWN file under a spec folder. A code file merely PLACED at such a path
|
|
38
|
+
// (e.g. src/x/.ax/specs/01_req/evil.ts) must NOT be downgraded to the permissive author gate —
|
|
39
|
+
// require the `.md` extension so it falls through to the WRITE_CODE gate instead (review F4).
|
|
40
|
+
if (/\.md$/i.test(norm)) {
|
|
41
|
+
if (norm.includes('.ax/specs/01_req/'))
|
|
42
|
+
return 'AUTHOR_REQ';
|
|
43
|
+
if (norm.includes('.ax/specs/02_h-spec/'))
|
|
44
|
+
return 'AUTHOR_HSPEC';
|
|
45
|
+
if (norm.includes('.ax/specs/03_a-spec/'))
|
|
46
|
+
return 'AUTHOR_ASPEC';
|
|
47
|
+
if (norm.includes('.ax/specs/04_cpg/'))
|
|
48
|
+
return 'AUTHOR_CSPEC';
|
|
49
|
+
if (norm.includes('.ax/specs/05_t-spec/'))
|
|
50
|
+
return 'AUTHOR_TSPEC';
|
|
51
|
+
}
|
|
52
|
+
// Test detection uses the SCANNER'S table, not a bespoke `.test.`/`.spec.` regex. The bespoke one
|
|
53
|
+
// knew only the TS/JS infix, so every pytest file (`tests/test_briefing.py`), Go `_test.go`, Rust
|
|
54
|
+
// `tests/*.rs` and JUnit `AppTest.java` fell through to WRITE_CODE — gating a project's own test
|
|
55
|
+
// suite MORE strictly than the code it tests (WRITE_CODE additionally demands an approved T-SPEC),
|
|
56
|
+
// which no Python or Go project can satisfy for its tests. Measured on a real Python target. One
|
|
57
|
+
// table means the gate and the graph cannot disagree about what a test is.
|
|
58
|
+
if ((0, test_files_1.isTestFile)(norm))
|
|
59
|
+
return 'WRITE_TEST';
|
|
60
|
+
if (/\.(test|spec)\.[a-z]+$/i.test(norm))
|
|
61
|
+
return 'WRITE_TEST';
|
|
62
|
+
if (CODE_EXT_RE.test(norm))
|
|
63
|
+
return 'WRITE_CODE';
|
|
64
|
+
// @implements A-SPEC-167
|
|
65
|
+
// What EXECUTES is code. The extension list above is the compiler's view, and it missed
|
|
66
|
+
// package.json, Makefile, Dockerfile, CI workflows and shell scripts — each an execution path,
|
|
67
|
+
// each previously unclassified and therefore ungated. Folding into WRITE_CODE rather than adding
|
|
68
|
+
// an action kind means the role policy, the phase gate and the playbooks all already know it.
|
|
69
|
+
if ((0, executable_artifact_1.isExecutableArtifact)(norm, content))
|
|
70
|
+
return 'WRITE_CODE';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function deny(phase, missing, message, next, discipline = NO_SPEC_NO_CODE) {
|
|
74
|
+
return { decision: 'deny', phase, missing, remediation: { message, next_action: next, discipline } };
|
|
75
|
+
}
|
|
76
|
+
function phaseCheck(action, ctx) {
|
|
77
|
+
const byId = new Map(ctx.specs.map((s) => [s.id, s]));
|
|
78
|
+
const approvedOfType = (t) => ctx.specs.some((s) => s.type === t && s.status === 'approved');
|
|
79
|
+
const existsOfType = (t) => ctx.specs.some((s) => s.type === t);
|
|
80
|
+
switch (action) {
|
|
81
|
+
case 'AUTHOR_REQ': return { decision: 'allow', phase: 'INTAKE' };
|
|
82
|
+
case 'AUTHOR_HSPEC':
|
|
83
|
+
return existsOfType('REQ') ? { decision: 'allow', phase: 'DESIGN' }
|
|
84
|
+
: deny('INTAKE', ['REQ'], '선행 REQ가 없습니다.', "spec_create(type='REQ', ...)");
|
|
85
|
+
case 'AUTHOR_ASPEC':
|
|
86
|
+
return approvedOfType('H-SPEC') ? { decision: 'allow', phase: 'SPECIFY' }
|
|
87
|
+
: deny('DESIGN', ['approved H-SPEC'], 'approved H-SPEC이 없습니다.', "H-SPEC 완성 후 spec_validate → approve");
|
|
88
|
+
case 'AUTHOR_CSPEC':
|
|
89
|
+
case 'AUTHOR_TSPEC':
|
|
90
|
+
return approvedOfType('A-SPEC') ? { decision: 'allow', phase: 'TEST-SPEC' }
|
|
91
|
+
: deny('SPECIFY', ['approved A-SPEC'], 'approved A-SPEC이 없습니다.', "A-SPEC 먼저 작성·승인");
|
|
92
|
+
case 'WRITE_TEST':
|
|
93
|
+
case 'WRITE_CODE': {
|
|
94
|
+
const aspec = ctx.targetAspecId ? byId.get(ctx.targetAspecId) : undefined;
|
|
95
|
+
if (!aspec || aspec.status !== 'approved') {
|
|
96
|
+
// @implements A-SPEC-182
|
|
97
|
+
// Say WHY it cannot be approved, not just that it is not. The reason is already computable
|
|
98
|
+
// here — `spec_approve` derives it from the same specs moments later. Measured over 244
|
|
99
|
+
// governed runs: 425 refusals in 99 wordings, none of which carried the reason, and authors
|
|
100
|
+
// then groped through `.ax/` with the shell (8.5 calls/run, r=0.71 against total tokens).
|
|
101
|
+
// Nothing is appended when the target is absent (nothing to compute) or when nothing blocks
|
|
102
|
+
// (the silence itself says "approve it") — see the boundary cases in T-SPEC-182.
|
|
103
|
+
// @implements A-SPEC-192 §5R (round 9) — the TWIN of the approval-claim refusal, and until
|
|
104
|
+
// now the unbudgeted one: the same fact, computed by the same `blockerSummary`, printed
|
|
105
|
+
// raw. Measured 60,286 characters on one call (a 20k-char id echoed twice plus a
|
|
106
|
+
// pathological section name repeated 700 times), while its sibling one function away was
|
|
107
|
+
// capped at 1,200 — so whether a refusal was bounded depended on whether the anchor already
|
|
108
|
+
// existed on disk, i.e. on input position. Same budget, same omission labels, same rule:
|
|
109
|
+
// sentences are omitted whole and counted, never cut.
|
|
110
|
+
// @implements A-SPEC-192 §7R (round 10) — a sentence that says the name was omitted must not
|
|
111
|
+
// then print it. Measured: with a 120-char id the refusal read "구현 대상 A-SPEC(이름이
|
|
112
|
+
// 문면 예산을 넘어 생략)…" and quoted the id whole two lines later — through the clause
|
|
113
|
+
// LABEL (blockerSummary defaults it to spec.id) and again through blocker BODIES that embed
|
|
114
|
+
// the id in their own prose (breaking_change, id-format). Both carriers are folded here, at
|
|
115
|
+
// the one place that knows the id is unquotable.
|
|
116
|
+
// One carrier, not two: folding the clause LABEL separately was unobservable once the whole
|
|
117
|
+
// summary is scrubbed (a mutation battery proved the label branch equivalent), and two
|
|
118
|
+
// places that must agree about the same fact is how they stop agreeing.
|
|
119
|
+
// @implements A-SPEC-192 §10R (round 10, 4th) — the twin now renders through the SAME
|
|
120
|
+
// budgeted clause the sibling uses (`budgetedClause`), instead of measuring the whole joined
|
|
121
|
+
// summary and dropping it all. Measured before: a 93-char parent blocker — the one action the
|
|
122
|
+
// author could take — disappeared because another blocker was 1,154 chars, and the effective
|
|
123
|
+
// budget slid with the id's length (one character past ID_MAX freed 63 and flipped a 236-char
|
|
124
|
+
// refusal that named nothing into a 1,209-char one that named everything).
|
|
125
|
+
const unquotable = ctx.targetAspecId !== undefined && quotableId(ctx.targetAspecId) === null;
|
|
126
|
+
const fold = (s) => (unquotable && ctx.targetAspecId
|
|
127
|
+
? s.split(ctx.targetAspecId).join('<이름이 문면 예산을 넘어 생략>')
|
|
128
|
+
: s);
|
|
129
|
+
// Never throws — the contract `blockerSummary` carried and this call must keep: this runs in
|
|
130
|
+
// a PreToolUse hook on every tool call, and a refusal that says less is recoverable while a
|
|
131
|
+
// hook that dies is not. (Round-10 lost it for one build by calling approvalBlockers raw.)
|
|
132
|
+
const rawBlockers = (() => {
|
|
133
|
+
try {
|
|
134
|
+
return aspec ? (0, approval_blockers_1.approvalBlockers)(aspec, (id) => byId.get(id) ?? null).map(fold) : [];
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
const why = (0, tspec_state_1.budgetedClause)(unquotable ? '<이름이 문면 예산을 넘어 생략>' : (ctx.targetAspecId ?? aspec?.id ?? ''), `${aspec?.type ?? 'A-SPEC'}`, rawBlockers);
|
|
141
|
+
// 부재는 '미지정'이고, 이름이 문면 예산을 넘는 것은 다른 사실이다 — 두 사정을 한 낱말로
|
|
142
|
+
// 뭉치면 호출자는 자기가 무엇을 잘못했는지 알 수 없다.
|
|
143
|
+
// 빈 문자열도 부재다(round-10: 빈 괄호만 남아 무엇이 문제인지 말하지 않았다).
|
|
144
|
+
const named = ctx.targetAspecId === undefined || ctx.targetAspecId === ''
|
|
145
|
+
? '미지정'
|
|
146
|
+
: (quotableId(ctx.targetAspecId) ?? '이름이 문면 예산을 넘어 생략');
|
|
147
|
+
return deny('DESIGN', ['approved A-SPEC'], `구현 대상 A-SPEC(${named})이 approved가 아닙니다.${why}`, "spec_create/approve로 A-SPEC → T-SPEC 완성");
|
|
148
|
+
}
|
|
149
|
+
// @implements A-SPEC-183
|
|
150
|
+
// A STATE, not a boolean. Measured 2026-08-13: four different T-SPEC situations produced one
|
|
151
|
+
// identical sentence while needing four different actions, and `promote-slice`'s 「침묵의
|
|
152
|
+
// 실패 모드」 section existed to explain what the refusal could not. `decision`, `phase` and
|
|
153
|
+
// `missing` stay exactly as they were — this changes what is SAID, never what is decided.
|
|
154
|
+
if (action === 'WRITE_CODE') {
|
|
155
|
+
const state = (0, tspec_state_1.tspecStateFor)(aspec.id, ctx.specs);
|
|
156
|
+
if (state.kind !== 'approved') {
|
|
157
|
+
const r = (0, tspec_state_1.tspecRemediation)(aspec.id, state, (s) => { try {
|
|
158
|
+
return (0, approval_blockers_1.approvalBlockers)(s, (id) => byId.get(id) ?? null);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
} });
|
|
163
|
+
return deny('TEST-SPEC', ['approved T-SPEC'], r.message, r.next_action);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { decision: 'allow', phase: 'IMPLEMENT' };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|