@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,392 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.listOrCount = exports.ID_MAX = exports.REASON_BUDGET = exports.ID_BUDGET = void 0;
|
|
4
|
+
exports.tspecStateFor = tspecStateFor;
|
|
5
|
+
exports.tspecRemediation = tspecRemediation;
|
|
6
|
+
exports.budgetedClause = budgetedClause;
|
|
7
|
+
const approval_blockers_1 = require("../spec/approval-blockers");
|
|
8
|
+
/** Distinct specs by id — a duplicated id must not be counted or named twice. */
|
|
9
|
+
const distinct = (specs) => {
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
return specs.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Classify the T-SPEC situation for `aspecId`. Array lookups only — the gate calls this from a
|
|
15
|
+
* PreToolUse hook on every tool call, so it never touches the filesystem.
|
|
16
|
+
*/
|
|
17
|
+
function tspecStateFor(aspecId, specs) {
|
|
18
|
+
const tspecs = specs.filter((s) => s.type === 'T-SPEC');
|
|
19
|
+
const linked = tspecs.filter((s) => (s.dependsOn ?? []).includes(aspecId));
|
|
20
|
+
// The approved lookup runs on the RAW list. Review caught the first version deduplicating first:
|
|
21
|
+
// with a duplicated id where one copy was draft and one approved, `distinct` kept whichever came
|
|
22
|
+
// first and the approved one was dropped — flipping allow to deny, nondeterministically by readdir
|
|
23
|
+
// order, against a `breaking_change: none` contract. Deduplication is a MESSAGE concern only.
|
|
24
|
+
const approved = linked.find((s) => s.status === 'approved');
|
|
25
|
+
if (approved)
|
|
26
|
+
return { kind: 'approved', spec: approved };
|
|
27
|
+
// Every non-approved status lands here — `draft`, `review`, `outdated`. Narrowing to
|
|
28
|
+
// `=== 'draft'` would silently let an outdated T-SPEC read as a different situation.
|
|
29
|
+
if (linked.length > 0)
|
|
30
|
+
return { kind: 'linked-not-approved', specs: distinct(linked) };
|
|
31
|
+
// A T-SPEC with NO A-SPEC edge at all is the only candidate the gate can point at without
|
|
32
|
+
// guessing: measured, this repository has 0 of them, so one existing means someone just wrote it
|
|
33
|
+
// and has not attached it yet — which is exactly the playbook's silent failure.
|
|
34
|
+
//
|
|
35
|
+
// The test is the `A-SPEC-` PREFIX, not whether the edge resolves. Review proposed resolvability
|
|
36
|
+
// to also catch a typo'd edge, and measuring it showed why not: whenever the spec set is
|
|
37
|
+
// incomplete — an unreadable file, a partial load — every T-SPEC whose target is missing becomes
|
|
38
|
+
// an "orphan" and gets named. A probe with 73 such T-SPECs printed all of them, which is F1
|
|
39
|
+
// returning. Missing the typo case costs one unnamed candidate; over-naming hands the author a
|
|
40
|
+
// list of unrelated documents, and that was the HIGH.
|
|
41
|
+
const orphans = tspecs.filter((s) => !(s.dependsOn ?? []).some((d) => d.startsWith('A-SPEC-')));
|
|
42
|
+
return { kind: 'unlinked', orphans: distinct(orphans) };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Rendering budget. Four rules, because four review rounds each found the previous rendering helper
|
|
46
|
+
* leaking through a case its sibling already handled:
|
|
47
|
+
*
|
|
48
|
+
* 1. An id is printed WHOLE or not at all. Slicing one produces a spec id that does not exist, and
|
|
49
|
+
* it split a surrogate pair into `permissionDecisionReason`.
|
|
50
|
+
* 2. If no id fits, report the COUNT. An empty list followed by `외 N개` names nothing while
|
|
51
|
+
* implying a list — measured `T-SPEC 외 1개가 …`, self-contradictory.
|
|
52
|
+
* 3. Every field is bounded, not just `message`. `next_action` went to 20,028 characters because
|
|
53
|
+
* the budgets were applied to the sentence only.
|
|
54
|
+
* 4. No unconditional first item, anywhere. Keeping `parts[0]` regardless of size is how a 20,000
|
|
55
|
+
* character id produced a 40,192-character refusal AFTER the size cap was added.
|
|
56
|
+
*/
|
|
57
|
+
const CAP = 3; // most items ever named
|
|
58
|
+
exports.ID_BUDGET = 200; // an id list is ~15 chars an entry
|
|
59
|
+
exports.REASON_BUDGET = 1200; // ONE approval-blocker sentence is ~500 (field, allowed values, ADR)
|
|
60
|
+
/**
|
|
61
|
+
* @implements A-SPEC-192 §11R (round 11)
|
|
62
|
+
* The exact cost of a clause's FRAMING, at the worst-case label (§10R: the id's length must never
|
|
63
|
+
* decide how much reason survives). `blockerClause(id, [''])` IS that frame; the `- 1` this replaces
|
|
64
|
+
* under-charged every clause by one character, which is invisible in one clause and 24 characters
|
|
65
|
+
* over budget across a few. Measured before the fix: 1,294 characters against a 1,200 ceiling.
|
|
66
|
+
*/
|
|
67
|
+
const clauseFrame = () => (0, approval_blockers_1.blockerClause)('x'.repeat(exports.ID_MAX), ['']).length;
|
|
68
|
+
/**
|
|
69
|
+
* The worst-case cost of the `; (외 N건)` tail — text that no budget charged for, so it was spent
|
|
70
|
+
* every time a clause omitted anything. Priced at the whole count, since the omitted number is only
|
|
71
|
+
* known after clamping and can never exceed it.
|
|
72
|
+
*/
|
|
73
|
+
const tailCost = (total) => (total > 1 ? approval_blockers_1.BLOCKER_SEP.length + `(외 ${total}건)`.length : 0);
|
|
74
|
+
exports.ID_MAX = 80; // an id longer than this is never quoted — rule 1
|
|
75
|
+
/** Items that fit, whole, within `budget`. Never slices; never keeps an oversized first item. */
|
|
76
|
+
const fitting = (items, budget, cap = CAP) => {
|
|
77
|
+
const kept = [];
|
|
78
|
+
let used = 0;
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
if (kept.length >= cap)
|
|
81
|
+
break; // cap counts NAMES KEPT, not positions scanned —
|
|
82
|
+
// a positional slice let 3 oversized leading ids empty the whole window (round-3: 'N개' while
|
|
83
|
+
// later ids fit; the round-2 continue-fix had only covered the cap=Infinity path).
|
|
84
|
+
// SKIP, not break (round-2): an item that does not fit folds into the count; later items name.
|
|
85
|
+
if (used + item.length > budget)
|
|
86
|
+
continue;
|
|
87
|
+
kept.push(item);
|
|
88
|
+
used += item.length + 2; // the ', ' that will join them
|
|
89
|
+
}
|
|
90
|
+
return kept;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* `a, b 외 N개` — or, when nothing fits, `N개` (rule 2). The count always agrees with what is shown.
|
|
94
|
+
*/
|
|
95
|
+
// @implements A-SPEC-192 — exported so the approval-claim refusal reuses the SAME budget instead
|
|
96
|
+
// of growing its own (a second truth) or staying unbounded (the measured 40,192-char refusal class).
|
|
97
|
+
const listOrCount = (items, budget, unit, cap) => {
|
|
98
|
+
if (items.length === 0)
|
|
99
|
+
return ''; // nothing to report is not a count of zero
|
|
100
|
+
const kept = fitting(items, budget, cap);
|
|
101
|
+
if (kept.length === 0)
|
|
102
|
+
return `${items.length}${unit}`;
|
|
103
|
+
const rest = items.length - kept.length;
|
|
104
|
+
return rest > 0 ? `${kept.join(', ')} 외 ${rest}${unit}` : kept.join(', ');
|
|
105
|
+
};
|
|
106
|
+
exports.listOrCount = listOrCount;
|
|
107
|
+
/**
|
|
108
|
+
* Whole blockers within `budget`. Empty when none fits — never a dangling label.
|
|
109
|
+
*
|
|
110
|
+
* Takes the blockers as an ARRAY, never a joined string. Review found the string form re-split on
|
|
111
|
+
* `; `, a delimiter that also appears inside author-controlled text, yielding a sliced spec id with
|
|
112
|
+
* an unterminated quote and a remainder that counted fragments.
|
|
113
|
+
*
|
|
114
|
+
* `continue`, not `break`: one oversized blocker must not suppress the rest. Measured on the version
|
|
115
|
+
* this replaces — a 1,230-character blocker erased a 33-character one that fit easily, and the
|
|
116
|
+
* refusal came out byte-identical to the message for a spec with NO blockers. The design makes that
|
|
117
|
+
* silence load-bearing ("approve it"), so the author followed it and `spec_approve` refused with the
|
|
118
|
+
* very reason the gate had and withheld.
|
|
119
|
+
*/
|
|
120
|
+
const clampBlockers = (blockers, budget) => {
|
|
121
|
+
const kept = [];
|
|
122
|
+
let used = 0;
|
|
123
|
+
for (const b of blockers) {
|
|
124
|
+
if (used + b.length > budget)
|
|
125
|
+
continue; // rule 4, and one big item skips only itself
|
|
126
|
+
kept.push(b);
|
|
127
|
+
used += b.length + approval_blockers_1.BLOCKER_SEP.length;
|
|
128
|
+
}
|
|
129
|
+
return kept;
|
|
130
|
+
};
|
|
131
|
+
/** An id short enough to quote, or null — rule 1 applied to a single id. */
|
|
132
|
+
const quotable = (id) => (id.length <= exports.ID_MAX ? id : null);
|
|
133
|
+
/**
|
|
134
|
+
* What the author should read, and what they should do, for a state that denies.
|
|
135
|
+
*
|
|
136
|
+
* `null` for `approved` — there is nothing to say when nothing is wrong.
|
|
137
|
+
*/
|
|
138
|
+
function tspecRemediation(rawAspecId, state, why) {
|
|
139
|
+
if (state.kind === 'approved')
|
|
140
|
+
return null;
|
|
141
|
+
// Rule 3 applies to the TARGET id too. Review measured a 20,000-character A-SPEC id producing a
|
|
142
|
+
// 40,128-character message: every budget bounded the T-SPEC ids and none bounded this one, which
|
|
143
|
+
// is interpolated twice into the sentence and once into `next_action`.
|
|
144
|
+
const aspecId = quotable(rawAspecId) ?? '대상 A-SPEC';
|
|
145
|
+
if (state.kind === 'linked-not-approved') {
|
|
146
|
+
// Why it cannot be approved comes from REQ-182's computation, not a second one here — if the
|
|
147
|
+
// gate derived its own view, the author could satisfy the gate and be refused at approval.
|
|
148
|
+
// @implements A-SPEC-192 §6R (round 10) — a SHARE, not a race. The pre-tool-use twin got this
|
|
149
|
+
// in round 9 and this one — the surface the comment there points at as the source of the
|
|
150
|
+
// discipline — kept first-come-full-draw. Measured through phaseCheck('WRITE_CODE'): a leading
|
|
151
|
+
// reason of 1,144 characters erased both siblings' reasons, and making it ONE character longer
|
|
152
|
+
// flipped the refusal from 1,478 characters (siblings gone) to 353 (siblings in full). An input
|
|
153
|
+
// that gets worse must not make the answer better.
|
|
154
|
+
// The reason/quote window follows the specs whose ids the NAME window kept (round-4: fitting
|
|
155
|
+
// skips oversized leading ids, so a positional slice attributed three unnamed specs' blockers
|
|
156
|
+
// to the three specs the sentence had just named).
|
|
157
|
+
const namedIds = new Set(fitting(state.specs.map((s) => s.id), exports.ID_BUDGET, CAP));
|
|
158
|
+
const window = state.specs.filter((s) => namedIds.has(s.id));
|
|
159
|
+
const shownSpecs = (window.length > 0 ? window : state.specs).slice(0, CAP);
|
|
160
|
+
// Each shown spec is guaranteed an equal share first; the unused remainder is then offered in
|
|
161
|
+
// order, so a long leading reason can spend only what nobody else needs.
|
|
162
|
+
// @implements A-SPEC-192 §8R (round 10, 2nd) — ONE algorithm, and it counts what it prints.
|
|
163
|
+
//
|
|
164
|
+
// The share-plus-remainder shape had two accounting units: `spare` charged the fully wrapped
|
|
165
|
+
// clause (body + ` [id] 승인까지 남은 것: `), while the remainder pool debited the raw body. So
|
|
166
|
+
// the moment a reason crossed the share line it refunded its ~25-character wrapper to the pool,
|
|
167
|
+
// and a sibling whose reason happened to sit in that 25-char window flipped from omitted to
|
|
168
|
+
// printed — one character worse in, 644 characters "better" out (measured). The same mismatch
|
|
169
|
+
// let the reservations exceed the budget they defended (3 × (400 + 25) = 1,275 > 1,200).
|
|
170
|
+
//
|
|
171
|
+
// Now every spec is priced at the clause it would actually print, and the budget is spent
|
|
172
|
+
// SHORTEST-FIRST: the money buys the most sentences, one long reason cannot outbid several short
|
|
173
|
+
// ones, and no separate 'share' branch remains to disagree with this one.
|
|
174
|
+
// @implements A-SPEC-192 §9R (round 10, 3rd) — ONE unit, all the way down: the CLAUSE.
|
|
175
|
+
//
|
|
176
|
+
// §8R priced clauses but still judged individual blockers against the raw budget, and the two
|
|
177
|
+
// met badly. (i) `clampBlockers(all, REASON_BUDGET)` is cumulative, so a pathological reason that
|
|
178
|
+
// merely fit the budget was still charged and dragged its own spec's SHORT reason into silence —
|
|
179
|
+
// the very silence §8R said the exclusion prevented — and at one character more it dropped out
|
|
180
|
+
// and the short reason came back: worse input, better refusal. (ii) The omission label asked
|
|
181
|
+
// `some(b => b.length <= REASON_BUDGET)` on BODIES while the budget also has to cover the framing
|
|
182
|
+
// (` [id] 승인까지 남은 것: `), so a solo spec's 1,176-character reason vanished under the words
|
|
183
|
+
// "문면 예산 소진" while nothing had spent it, and the effective cap slid with the id's length.
|
|
184
|
+
//
|
|
185
|
+
// So each spec's framing is priced first, and every judgement about a blocker is made against
|
|
186
|
+
// what is left for BODIES after that framing.
|
|
187
|
+
// @implements A-SPEC-192 §10R (round 10, 5th) — the framing is priced at its WORST case, so how
|
|
188
|
+
// long your id happens to be never decides how much of your reason survives. Pricing the actual
|
|
189
|
+
// label made the body budget slide with the id, and crossing ID_MAX (where the id folds to a
|
|
190
|
+
// short marker) handed ~63 characters back at once: one character longer, one more reason
|
|
191
|
+
// printed. The clause still fits, because the real frame is never larger than this bound.
|
|
192
|
+
// @implements A-SPEC-192 §11R (round 11) — the frame is the empty-body clause itself (the `- 1`
|
|
193
|
+
// under-charged each clause by one character), and the `(외 N건)` tail is text too. Both are
|
|
194
|
+
// priced, so the ceiling holds instead of being approached.
|
|
195
|
+
// 사유 계산은 호출자의 함수다 — 한 스펙에 대해 한 번만 부른다. (§11R 에서 절마다 다시 부르는
|
|
196
|
+
// 판정을 늘렸더니 블로커가 만 건인 스펙에서 스위트가 5분을 넘겼다: 값이 아니라 계산이 문제였다.)
|
|
197
|
+
const blockersOf = ((cache) => (s) => {
|
|
198
|
+
const hit = cache.get(s.id);
|
|
199
|
+
if (hit !== undefined)
|
|
200
|
+
return hit;
|
|
201
|
+
const v = why(s);
|
|
202
|
+
cache.set(s.id, v);
|
|
203
|
+
return v;
|
|
204
|
+
})(new Map());
|
|
205
|
+
const frameLen = (_s) => clauseFrame();
|
|
206
|
+
const bodyBudget = (s) => Math.max(0, exports.REASON_BUDGET - frameLen(s) - tailCost(blockersOf(s).length));
|
|
207
|
+
// @implements A-SPEC-192 §12R (round 13) — the reservation-plus-auction this replaces granted a
|
|
208
|
+
// spec the WHOLE remaining pool without asking whether it could use a single character of it,
|
|
209
|
+
// and never took the unused part back. Measured: of a 1,200-character budget, 87 were spent and
|
|
210
|
+
// 19 printable reasons were thrown away; and because the auction ordered by "smallest want",
|
|
211
|
+
// growing one blocker by ONE character flipped the order and swung the refusal from 87 to 1,013
|
|
212
|
+
// characters (§8R's cliff, back in the upgrade step). Both die with the unit of allocation: a
|
|
213
|
+
// budget is spent on REASONS, one at a time, and the cheapest next reason anywhere wins.
|
|
214
|
+
//
|
|
215
|
+
// Ties break on the spec ID, never on array position — `LocalMarkdownRepository.list()` hands back the
|
|
216
|
+
// directory-listing order, which no author controls (r13 measured 2 reasons vs 20 from the
|
|
217
|
+
// array order alone).
|
|
218
|
+
/**
|
|
219
|
+
* The clause body a spec prints when it carries reasons but bought none.
|
|
220
|
+
*
|
|
221
|
+
* @implements A-SPEC-192 §12R — priced and rendered by ONE function, because the pricing used a
|
|
222
|
+
* worst-case reconstruction of this sentence and the two could drift apart. `compact` drops the
|
|
223
|
+
* pointer so a named spec can still say something when the pointed label does not fit; silence
|
|
224
|
+
* is byte-identical to "nothing blocks me", which is the r5 finding.
|
|
225
|
+
*/
|
|
226
|
+
const omitLabel = (s, id, compact) => {
|
|
227
|
+
const all = blockersOf(s);
|
|
228
|
+
const hint = compact ? '' : ` — spec_validate${id ? ` ${id}` : ''} 로 확인하십시오`;
|
|
229
|
+
// §9R: "spent" and "over" are different facts. A body is over budget when it cannot fit even
|
|
230
|
+
// with the whole budget minus this spec's own framing.
|
|
231
|
+
return all.some((b) => b.length <= bodyBudget(s))
|
|
232
|
+
? `(문면 예산 소진으로 사유 ${all.length}건 생략${hint})`
|
|
233
|
+
: `(사유 ${all.length}건이 문면 예산을 넘어 생략${hint})`;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Indices of the blockers this spec could print at all — judged one at a time, not cumulatively.
|
|
237
|
+
* INDICES, not values: two blockers can be byte-identical (a truncating generator, a repeated
|
|
238
|
+
* validator message), and a value-keyed set then printed every copy — measured 5,020,022
|
|
239
|
+
* characters against a 1,200 ceiling. What was bought is a position, not a string.
|
|
240
|
+
*/
|
|
241
|
+
const printable = (s) => {
|
|
242
|
+
const cap = bodyBudget(s);
|
|
243
|
+
return blockersOf(s).map((b, i) => (b.length <= cap ? i : -1)).filter((i) => i >= 0);
|
|
244
|
+
};
|
|
245
|
+
/** The exact clause cost for a chosen set: framing + bodies + separators + the omission tail. */
|
|
246
|
+
const clauseCost = (s, bodySum, count) => (count === 0 ? 0
|
|
247
|
+
: frameLen(s) + bodySum + approval_blockers_1.BLOCKER_SEP.length * (count - 1)
|
|
248
|
+
+ (count < blockersOf(s).length ? tailCost(blockersOf(s).length) : 0));
|
|
249
|
+
const omitCost = (s, compact) => frameLen(s) + omitLabel(s, 'x'.repeat(exports.ID_MAX), compact).length;
|
|
250
|
+
const chosen = new Map(); // 산 블로커의 자리 — 인쇄는 원래 순서로 한다
|
|
251
|
+
const printed = new Set();
|
|
252
|
+
const allowanceKind = new Map();
|
|
253
|
+
let pool = exports.REASON_BUDGET;
|
|
254
|
+
const speaking = shownSpecs
|
|
255
|
+
.filter((s) => blockersOf(s).length > 0)
|
|
256
|
+
.map((s) => {
|
|
257
|
+
const bs = blockersOf(s);
|
|
258
|
+
// 값은 길이로 매기고, 동점은 자리로 — 배열 순서가 아니라 결정적인 규칙이어야 한다.
|
|
259
|
+
const rest = printable(s).sort((x, y) => bs[x].length - bs[y].length || x - y);
|
|
260
|
+
return { s, bs, rest, bodySum: 0, count: 0 };
|
|
261
|
+
})
|
|
262
|
+
.sort((a, b) => (a.s.id < b.s.id ? -1 : a.s.id > b.s.id ? 1 : 0));
|
|
263
|
+
// Marginal-cost greedy: at every step, buy the cheapest "one more reason" available anywhere.
|
|
264
|
+
// A spec's first reason is priced from silence, so nothing is reserved and nothing is stranded,
|
|
265
|
+
// and each spec offers its CHEAPEST remaining reason — the old code could only extend a prefix,
|
|
266
|
+
// so a 900-character leading blocker buried the 9-character one behind it.
|
|
267
|
+
for (;;) {
|
|
268
|
+
let best;
|
|
269
|
+
for (const g of speaking) {
|
|
270
|
+
const i = g.rest[0];
|
|
271
|
+
if (i === undefined)
|
|
272
|
+
continue;
|
|
273
|
+
const inc = clauseCost(g.s, g.bodySum + g.bs[i].length, g.count + 1) - clauseCost(g.s, g.bodySum, g.count);
|
|
274
|
+
if (best === undefined || inc < best.inc)
|
|
275
|
+
best = { g, inc, i };
|
|
276
|
+
}
|
|
277
|
+
if (best === undefined || best.inc > pool)
|
|
278
|
+
break;
|
|
279
|
+
best.g.rest.shift();
|
|
280
|
+
best.g.bodySum += best.g.bs[best.i].length;
|
|
281
|
+
best.g.count += 1;
|
|
282
|
+
pool -= best.inc;
|
|
283
|
+
printed.add(best.g.s.id);
|
|
284
|
+
const set = chosen.get(best.g.s.id) ?? new Set();
|
|
285
|
+
set.add(best.i);
|
|
286
|
+
chosen.set(best.g.s.id, set);
|
|
287
|
+
}
|
|
288
|
+
// A named spec that bought no reason still says SOMETHING when anything is left — silence is
|
|
289
|
+
// byte-identical to "nothing blocks me" (r5's finding). The compact label is the fallback when
|
|
290
|
+
// even the pointed one does not fit; reasons come first because a label is not an action.
|
|
291
|
+
for (const g of speaking) {
|
|
292
|
+
if (printed.has(g.s.id))
|
|
293
|
+
continue;
|
|
294
|
+
for (const compact of [false, true]) {
|
|
295
|
+
const cost = omitCost(g.s, compact);
|
|
296
|
+
if (cost > pool)
|
|
297
|
+
continue;
|
|
298
|
+
printed.add(g.s.id);
|
|
299
|
+
pool -= cost;
|
|
300
|
+
allowanceKind.set(g.s.id, compact ? 'compact' : 'pointed');
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const reasons = shownSpecs
|
|
305
|
+
.map((s) => {
|
|
306
|
+
const all = blockersOf(s);
|
|
307
|
+
if (all.length === 0 || !printed.has(s.id))
|
|
308
|
+
return '';
|
|
309
|
+
// 산 블로커를 원래 순서로 인쇄한다 — 값은 길이로 매기되, 저자가 읽는 차례는 바꾸지 않는다
|
|
310
|
+
// (approvalBlockers 는 부모 먼저처럼 의미 있는 차례로 돌려준다).
|
|
311
|
+
const bought = chosen.get(s.id) ?? new Set();
|
|
312
|
+
const kept = all.filter((_b, i) => bought.has(i));
|
|
313
|
+
if (kept.length === 0) {
|
|
314
|
+
// r5(192): dropping oversized/over-budget blockers WITHOUT a label made this refusal
|
|
315
|
+
// byte-identical to the no-blocker one. r6: the label itself must obey rule 1/3 — raw
|
|
316
|
+
// `${s.id}` resurrected the unbounded-id class (20k-char id → 20,132-char deny), and one
|
|
317
|
+
// wording conflated 'sentence too big alone' with 'shared budget spent' (a 5-char reason
|
|
318
|
+
// read as pathological). quotable folds the id; the two causes get their own words.
|
|
319
|
+
const q = quotable(s.id);
|
|
320
|
+
// 값 매김이 산 라벨과 같은 라벨을 인쇄한다 — 지목형이 예산에 들어가면 그것을, 아니면 축약형을.
|
|
321
|
+
const pointed = (0, approval_blockers_1.blockerClause)(q ?? `${s.type}`, [omitLabel(s, q ?? '', false)]);
|
|
322
|
+
const compact = (0, approval_blockers_1.blockerClause)(q ?? `${s.type}`, [omitLabel(s, q ?? '', true)]);
|
|
323
|
+
return (allowanceKind.get(s.id) === 'compact') ? compact : pointed;
|
|
324
|
+
}
|
|
325
|
+
const rest = all.length - kept.length;
|
|
326
|
+
return (0, approval_blockers_1.blockerClause)(quotable(s.id) ?? `${s.type}`, rest > 0 ? [...kept, `(외 ${rest}건)`] : kept);
|
|
327
|
+
})
|
|
328
|
+
.filter((r) => r.length > 0)
|
|
329
|
+
.join('');
|
|
330
|
+
const named = (0, exports.listOrCount)(state.specs.map((s) => s.id), exports.ID_BUDGET, '개');
|
|
331
|
+
const first = quotable((shownSpecs[0] ?? state.specs[0]).id);
|
|
332
|
+
return {
|
|
333
|
+
message: `T-SPEC ${named}가 A-SPEC ${aspecId}를 시험한다고 선언했으나 approved가 아닙니다.${reasons}`,
|
|
334
|
+
next_action: first ? `그 T-SPEC을 승인 (spec_approve ${first})` : '그 T-SPEC을 승인',
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
// BOTH moves, because the gate genuinely cannot tell which applies. Naming only one of them is
|
|
338
|
+
// how the earlier draft turned "write the test" into "attach an unrelated document".
|
|
339
|
+
const orphans = state.orphans.length > 0
|
|
340
|
+
// The deps list inside each item is bounded too, so no single item can blow the budget and get
|
|
341
|
+
// dropped whole — the author would then see "외 N개" with nothing named.
|
|
342
|
+
? ` 간선이 없는 T-SPEC이 있습니다 — ${(0, exports.listOrCount)(state.orphans.map((s) => {
|
|
343
|
+
const deps = s.dependsOn ?? [];
|
|
344
|
+
return `${s.id}: [${(0, exports.listOrCount)(deps, exports.ID_BUDGET, '개')}]`;
|
|
345
|
+
}), exports.ID_BUDGET, '개')}.`
|
|
346
|
+
: '';
|
|
347
|
+
return {
|
|
348
|
+
message: `A-SPEC ${aspecId}를 depends_on에 담은 T-SPEC이 없습니다(테스트 먼저).`
|
|
349
|
+
+ ` 아직 쓰지 않았다면 4분면 T-SPEC을 작성·승인하고, 이미 썼다면 그 T-SPEC의 depends_on에 ${aspecId}를 추가하라.${orphans}`,
|
|
350
|
+
next_action: `T-SPEC 작성·승인, 또는 기존 T-SPEC의 depends_on에 ${aspecId} 추가`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* @implements A-SPEC-192 §10R (round 10, 4th)
|
|
355
|
+
* ONE spec's clause, budgeted — the shared discipline both refusal surfaces now use.
|
|
356
|
+
*
|
|
357
|
+
* The twin in `phase.ts` compared the whole joined summary (framing + id + every blocker) against
|
|
358
|
+
* REASON_BUDGET and dropped it ALL when it did not fit. Two harms followed, both measured. A 93-char
|
|
359
|
+
* parent blocker — the one action the author could take right now — vanished because a sibling
|
|
360
|
+
* blocker happened to be 1,154 characters. And because the id sits inside what was being measured,
|
|
361
|
+
* the effective budget slid with the id's LENGTH: growing an id by one character past ID_MAX made
|
|
362
|
+
* the §7R scrub fold it, freed 63 characters, and turned a 236-char refusal that named nothing into
|
|
363
|
+
* a 1,209-char refusal that named everything. Worse input, better answer, decided by something the
|
|
364
|
+
* author cannot control.
|
|
365
|
+
*
|
|
366
|
+
* Here the framing is priced first and every judgement about a blocker is made against what remains
|
|
367
|
+
* for bodies; blockers are dropped one at a time and counted, and the label distinguishes "no reason
|
|
368
|
+
* fits at all" from "the budget was spent elsewhere" — the same words the sibling uses.
|
|
369
|
+
*/
|
|
370
|
+
function budgetedClause(labelId, fallbackLabel, blockers, spentElsewhere = false) {
|
|
371
|
+
if (blockers.length === 0)
|
|
372
|
+
return '';
|
|
373
|
+
const label = quotable(labelId) ?? fallbackLabel;
|
|
374
|
+
// Worst-case framing (§10R): the budget must not depend on the id's length. See tspec-state's
|
|
375
|
+
// frameLen for the measurement that forced this.
|
|
376
|
+
// §11R: the frame is the empty-body clause ITSELF — the `- 1` under-priced every clause by one
|
|
377
|
+
// character, and the `(외 N건)` tail below was charged to nothing at all. Both are text; both are
|
|
378
|
+
// priced here, so "budget" means a ceiling rather than an estimate.
|
|
379
|
+
const frame = clauseFrame();
|
|
380
|
+
const budget = Math.max(0, exports.REASON_BUDGET - frame - tailCost(blockers.length));
|
|
381
|
+
const kept = clampBlockers(blockers, budget);
|
|
382
|
+
if (kept.length === 0) {
|
|
383
|
+
const q = quotable(labelId);
|
|
384
|
+
const hint = ` — spec_validate${q ? ` ${q}` : ''} 로 확인하십시오`;
|
|
385
|
+
const anyFits = blockers.some((b) => b.length <= budget);
|
|
386
|
+
return (0, approval_blockers_1.blockerClause)(label, [(anyFits || spentElsewhere)
|
|
387
|
+
? `(문면 예산 소진으로 사유 ${blockers.length}건 생략${hint})`
|
|
388
|
+
: `(사유 ${blockers.length}건이 문면 예산을 넘어 생략${hint})`]);
|
|
389
|
+
}
|
|
390
|
+
const rest = blockers.length - kept.length;
|
|
391
|
+
return (0, approval_blockers_1.blockerClause)(label, rest > 0 ? [...kept, `(외 ${rest}건)`] : kept);
|
|
392
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
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.PATH_FIELDS = exports.READ_ONLY_TOOLS = void 0;
|
|
37
|
+
exports.writesFiles = writesFiles;
|
|
38
|
+
exports.resolveTarget = resolveTarget;
|
|
39
|
+
exports.isProtectedTarget = isProtectedTarget;
|
|
40
|
+
exports.specTargetOf = specTargetOf;
|
|
41
|
+
exports.protectedFileKindOf = protectedFileKindOf;
|
|
42
|
+
exports.protectedKindOf = protectedKindOf;
|
|
43
|
+
// @implements A-SPEC-163
|
|
44
|
+
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const path = __importStar(require("node:path"));
|
|
46
|
+
/**
|
|
47
|
+
* Tools known to only READ. Everything else that carries a path is treated as a write.
|
|
48
|
+
*
|
|
49
|
+
* The list is inverted on purpose. The gate used to ask `['Write','Edit'].includes(name)`, and
|
|
50
|
+
* measured 2026-08-08 that meant `MultiEdit`, `NotebookEdit`, and a lower-case `write` reached the
|
|
51
|
+
* same protected files `Write` was denied — No-Spec-No-Code, the role gate, the ledger ban and the
|
|
52
|
+
* approval-config ban all bypassed by a different name. Adding those three names would postpone the
|
|
53
|
+
* problem to the fourth tool; inverting it means an unrecognised tool fails CLOSED.
|
|
54
|
+
*/
|
|
55
|
+
exports.READ_ONLY_TOOLS = [
|
|
56
|
+
'read', 'grep', 'glob', 'ls', 'notebookread',
|
|
57
|
+
'webfetch', 'websearch', 'todowrite', 'task', 'bashoutput', 'killshell',
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* Payload keys that name a file. Shared with normalization so the two cannot drift: a field the
|
|
61
|
+
* gate does not know about is a path the gate cannot see, which is how `notebook_path` slipped
|
|
62
|
+
* through.
|
|
63
|
+
*/
|
|
64
|
+
exports.PATH_FIELDS = ['file_path', 'notebook_path'];
|
|
65
|
+
const pathIn = (input) => {
|
|
66
|
+
if (!input || typeof input !== 'object')
|
|
67
|
+
return undefined;
|
|
68
|
+
const rec = input;
|
|
69
|
+
for (const f of exports.PATH_FIELDS)
|
|
70
|
+
if (typeof rec[f] === 'string' && rec[f] !== '')
|
|
71
|
+
return rec[f];
|
|
72
|
+
return undefined;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Whether this tool call writes a file.
|
|
76
|
+
*
|
|
77
|
+
* A missing or non-string name counts as a write. `normalizeHookInput` turns both into `''`, and the
|
|
78
|
+
* old membership test read that as "not a write tool" — REQ-144 closed exactly this direction for
|
|
79
|
+
* the path field while leaving the name field open.
|
|
80
|
+
*/
|
|
81
|
+
function writesFiles(toolName, input) {
|
|
82
|
+
if (pathIn(input) === undefined)
|
|
83
|
+
return false;
|
|
84
|
+
const name = (typeof toolName === 'string' ? toolName : '').toLowerCase();
|
|
85
|
+
return !exports.READ_ONLY_TOOLS.includes(name);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The file a raw path actually names.
|
|
89
|
+
*
|
|
90
|
+
* `path.resolve` folds `//`, `.` and `..`. Then the longest EXISTING ancestor is realpath'd and the
|
|
91
|
+
* remainder rejoined lexically — a whole-path `realpath` throws on a file that does not exist yet,
|
|
92
|
+
* which is precisely when the gate has to answer.
|
|
93
|
+
*/
|
|
94
|
+
function resolveTarget(root, raw, depth = 0) {
|
|
95
|
+
const abs = path.resolve(root, raw);
|
|
96
|
+
// A DANGLING symlink still names where a write would land, and `realpath` fails on it — so the
|
|
97
|
+
// link is followed explicitly. Without this, `ln -s .ax/ledger/not-yet-there innocent` then
|
|
98
|
+
// writing `innocent` creates a file inside the protected directory while the gate sees a name
|
|
99
|
+
// outside it. The depth cap stops a symlink cycle from spinning.
|
|
100
|
+
if (depth < 8) {
|
|
101
|
+
try {
|
|
102
|
+
if (fs.lstatSync(abs).isSymbolicLink()) {
|
|
103
|
+
return resolveTarget(path.dirname(abs), fs.readlinkSync(abs), depth + 1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch { /* not a symlink, or unreadable — fall through to ancestor resolution */ }
|
|
107
|
+
}
|
|
108
|
+
const parts = [];
|
|
109
|
+
let cur = abs;
|
|
110
|
+
for (;;) {
|
|
111
|
+
try {
|
|
112
|
+
return path.join(fs.realpathSync(cur), ...parts.reverse());
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
const parent = path.dirname(cur);
|
|
116
|
+
if (parent === cur)
|
|
117
|
+
return abs; // reached the filesystem root without an existing ancestor
|
|
118
|
+
parts.push(path.basename(cur));
|
|
119
|
+
cur = parent;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Governance directories, relative to the project root. */
|
|
124
|
+
const PROTECTED_DIRS = [
|
|
125
|
+
path.join('.ax', 'ledger'),
|
|
126
|
+
path.join('.ax', 'roles'),
|
|
127
|
+
path.join('.ax', 'cpg_cache'),
|
|
128
|
+
path.join('.ax', 'state'),
|
|
129
|
+
'.claude',
|
|
130
|
+
];
|
|
131
|
+
/**
|
|
132
|
+
* Whether a write lands inside a governance directory.
|
|
133
|
+
*
|
|
134
|
+
* Comparison folds case because that is what the filesystem does: measured 2026-08-08 on macOS,
|
|
135
|
+
* writing `.AX/roles/policy.yaml` changed the very file the gate protects. On a case-sensitive
|
|
136
|
+
* filesystem folding over-protects, which is the safe direction and only collides for a project
|
|
137
|
+
* that deliberately keeps both `.ax` and `.AX`.
|
|
138
|
+
*
|
|
139
|
+
* The separator suffix matters: without it `.axolotl/` would inherit `.ax/`'s protection by prefix.
|
|
140
|
+
*/
|
|
141
|
+
function isProtectedTarget(root, raw) {
|
|
142
|
+
return protectedKindOf(root, raw) !== null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Which governance directory a write lands in, or `null`.
|
|
146
|
+
*
|
|
147
|
+
* Callers that phrase a refusal need to name the right thing — REQ-155 measured how much a message
|
|
148
|
+
* that points at the wrong file costs — so the matched directory is returned rather than a bare
|
|
149
|
+
* boolean re-derived from a second regex.
|
|
150
|
+
*/
|
|
151
|
+
/**
|
|
152
|
+
* The spec FILE a write lands on, resolved — or `null` when the target is not a spec.
|
|
153
|
+
*
|
|
154
|
+
* @implements A-SPEC-191 §23 (round 12) — §22 moved `.mcp.json`, `.env`, `.ax/roles` and
|
|
155
|
+
* `.ax/ledger` onto identity and left the spec store itself on a case-sensitive regex over the
|
|
156
|
+
* caller's string. Measured on one fixture: `.AX/specs/…`, `.Ax/specs/…`, `.ax/SPECS/…`,
|
|
157
|
+
* `.ax//specs/…`, `.ax/./specs/…`, a link named `alias.md` and a link named `specslink` ALL
|
|
158
|
+
* reached the same files with the pattern looking elsewhere — so a session could self-approve an
|
|
159
|
+
* A-SPEC and a T-SPEC and then write the code they authorise, and could rewrite the body of an
|
|
160
|
+
* already-approved spec. The two rules the spec chain rests on (no self-approval, no seal-breaking
|
|
161
|
+
* edit) were both spelling-deep. The RESOLVED path is returned so callers read the disk at the
|
|
162
|
+
* same file they judged — asking `existsSync` about the caller's spelling was its own bypass.
|
|
163
|
+
*/
|
|
164
|
+
function specTargetOf(root, specsDir, raw) {
|
|
165
|
+
const resolved = resolveTarget(root, raw);
|
|
166
|
+
if (!/\.md$/i.test(resolved))
|
|
167
|
+
return null;
|
|
168
|
+
const t = resolved.toLowerCase();
|
|
169
|
+
// The WIRED store and the default layout both count. A deployment can move its specs, but
|
|
170
|
+
// `<root>/.ax/specs` stays a spec store by the project's own convention — judging only the wired
|
|
171
|
+
// one would hand back exactly the bypass this section closes for anyone who writes to the other.
|
|
172
|
+
for (const dir of [specsDir, path.join('.ax', 'specs')]) {
|
|
173
|
+
const b = resolveTarget(root, dir).toLowerCase();
|
|
174
|
+
if (t.startsWith(b + path.sep))
|
|
175
|
+
return resolved;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Which governance FILE a write lands on, or `null`.
|
|
181
|
+
*
|
|
182
|
+
* @implements A-SPEC-191 §22 (round 11) — `.ax/*` and `.claude` were judged by identity here while
|
|
183
|
+
* `.mcp.json` and `.env` were judged by a regex over the caller's string, one function away. So the
|
|
184
|
+
* same sentence that A-SPEC-163 wrote ("identity, not spelling") was true of half its subject:
|
|
185
|
+
* a link inside the project under any other name reached `.mcp.json` with the pattern looking at a
|
|
186
|
+
* different string, and on a case-insensitive volume `.MCP.json` was the same file too. The
|
|
187
|
+
* resolution is `resolveTarget`'s — it follows links, including dangling ones, because a write
|
|
188
|
+
* through a dangling link creates exactly the file it names.
|
|
189
|
+
*/
|
|
190
|
+
function protectedFileKindOf(root, raw) {
|
|
191
|
+
const resolved = resolveTarget(root, raw);
|
|
192
|
+
const base = resolveTarget(root, '.');
|
|
193
|
+
const foldedTarget = resolved.toLowerCase();
|
|
194
|
+
const foldedBase = base.toLowerCase();
|
|
195
|
+
if (foldedTarget !== foldedBase && !foldedTarget.startsWith(foldedBase + path.sep))
|
|
196
|
+
return null;
|
|
197
|
+
const name = path.basename(resolved).toLowerCase();
|
|
198
|
+
if (name === '.mcp.json')
|
|
199
|
+
return '.mcp.json';
|
|
200
|
+
// @implements A-SPEC-193 §8 (round 13) — 하네스가 늘면 **집행 지점도 는다**. Antigravity 는
|
|
201
|
+
// `.agents/hooks.json` 에서 PreToolUse/Stop 을 읽고 `.agents/mcp_config.json` 에서 도구를
|
|
202
|
+
// 읽는다. 그 둘은 `.claude/settings*` 및 `.mcp.json` 과 정확히 같은 성질의 파일이다 — 세션이
|
|
203
|
+
// 고칠 수 있으면 세션이 제 게이트를 끌 수 있다(실측: 배선 직후 두 파일 모두 allow 였다).
|
|
204
|
+
// `.agents` 전체를 잠그지는 않는다: 규칙과 스킬은 평범한 저작물이고, 잠그면 진짜 작업이 막힌다.
|
|
205
|
+
const parent = path.basename(path.dirname(resolved)).toLowerCase();
|
|
206
|
+
if (parent === '.agents' && (name === 'hooks.json' || name === 'mcp_config.json'))
|
|
207
|
+
return `.agents/${name}`;
|
|
208
|
+
// Example/sample/template/dist copies carry no secret — the same exclusion the hook's regex had.
|
|
209
|
+
if (/^\.env(\.[\w-]+)?$/.test(name) && !/\.(example|sample|template|dist)$/.test(name))
|
|
210
|
+
return '.env';
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
function protectedKindOf(root, raw) {
|
|
214
|
+
const target = resolveTarget(root, raw).toLowerCase();
|
|
215
|
+
const base = resolveTarget(root, '.').toLowerCase();
|
|
216
|
+
if (target !== base && !target.startsWith(base + path.sep))
|
|
217
|
+
return null; // outside the project
|
|
218
|
+
for (const d of PROTECTED_DIRS) {
|
|
219
|
+
const dir = path.join(base, d).toLowerCase();
|
|
220
|
+
if (target === dir || target.startsWith(dir + path.sep))
|
|
221
|
+
return d;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|