@sabaiway/agent-workflow-kit 6.0.0 → 7.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 +102 -0
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-await-guard.test.mjs +176 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +61 -14
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +606 -467
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +42 -4
- package/bridges/codex-cli-bridge/SKILL.md +18 -5
- package/bridges/codex-cli-bridge/bin/codex-await-guard.test.mjs +161 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +22 -17
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +356 -363
- package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +275 -286
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/references/driving-codex.md +4 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
- package/bridges/codex-cli-bridge/setup/README.md +3 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/mcp.md +37 -0
- package/references/modes/recommendations.md +1 -0
- package/references/modes/uninstall.md +2 -1
- package/references/templates/agent_rules.md +1 -0
- package/tools/commands.mjs +7 -0
- package/tools/direct-run.mjs +3 -0
- package/tools/doc-parity.mjs +18 -2
- package/tools/fold-scope-cli.mjs +93 -0
- package/tools/fold-scope.mjs +307 -0
- package/tools/mcp-registration.mjs +283 -0
- package/tools/mcp-server.mjs +314 -0
- package/tools/mcp-stdio.mjs +229 -0
- package/tools/mcp.mjs +299 -0
- package/tools/procedures.mjs +29 -4
- package/tools/recommendations.mjs +90 -1
- package/tools/uninstall.mjs +356 -45
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// The finding-scope rule as a checker — the half prose cannot do (the engine canon: procedures.md,
|
|
2
|
+
// plan-execution step 5). A finding NAMES the invariant its fix enforces BEFORE the edit; WHERE that
|
|
3
|
+
// invariant already lives decides the disposition, and this module REFUSES a claim whose reference
|
|
4
|
+
// does not resolve:
|
|
5
|
+
//
|
|
6
|
+
// in-scope the claim matches WITHIN ONE acceptance bullet of the plan -> fold here.
|
|
7
|
+
// new-invariant the claim matches NO acceptance bullet AND one OPEN queue row carries the claim in
|
|
8
|
+
// its invariant field and all five fields (invariant, origin as file:line, narrow
|
|
9
|
+
// fix, proof, residual exposure) with that exposure declared NOT live -> the narrow
|
|
10
|
+
// fix ships now, only the generalization defers.
|
|
11
|
+
// blocking no correct narrow fix exists -> the phase does not close; there is no queue arm.
|
|
12
|
+
//
|
|
13
|
+
// Markdown is read through the family's ONE block model (references/scripts/markdown-blocks.mjs, the
|
|
14
|
+
// module the archivers already read through): fenced regions, ATX headings at 0-3 columns of indent,
|
|
15
|
+
// CRLF and the backtick-info-string rule are ITS problem, not a second hand-rolled grammar here. A
|
|
16
|
+
// document it refuses is a loud refusal, never a silent empty read.
|
|
17
|
+
//
|
|
18
|
+
// ADVISORY in this slice: nothing records that it ran, so a skipped or late call is
|
|
19
|
+
// indistinguishable from a pre-edit declaration. A fingerprint-bound receipt a gate reads is next.
|
|
20
|
+
//
|
|
21
|
+
// Pure string functions - every input is text; the CLI half (fold-scope-cli.mjs) owns the fs.
|
|
22
|
+
// Dependency-free, Node >= 22.
|
|
23
|
+
|
|
24
|
+
import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
|
|
25
|
+
|
|
26
|
+
export const CLASSES = ['in-scope', 'new-invariant', 'blocking'];
|
|
27
|
+
export const ROW_FIELDS = ['invariant', 'origin', 'narrow fix', 'proof', 'residual exposure'];
|
|
28
|
+
export const ACCEPTANCE_HEADING = '## Verification';
|
|
29
|
+
// The canon says a deferral row carries "the origin `file:line`". Anchored at the start of the value
|
|
30
|
+
// and a POSITIVE line number, so "file.mjs:12junk" and "file.mjs:0" are not one; trailing context
|
|
31
|
+
// after the token is fine, because the canon asks the row to CARRY a file:line, not to carry nothing
|
|
32
|
+
// else.
|
|
33
|
+
const ORIGIN_SHAPE = /^\S+:[1-9]\d*(\s|$)/;
|
|
34
|
+
// A closed row is not a live deferral. Narrow on purpose: the row TITLE carrying one of these
|
|
35
|
+
// literals refuses fail-closed; the general per-project status grammar is queued, not guessed here.
|
|
36
|
+
const CLOSED_MARKERS = ['DONE', 'CLOSED'];
|
|
37
|
+
const ORIGIN_MISSING = 'origin (the canon requires a file:line)';
|
|
38
|
+
const BULLET = /^-\s+\S/;
|
|
39
|
+
|
|
40
|
+
const normalize = (s) => String(s ?? '').replace(/\r/g, '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
const contains = (haystack, needle) => normalize(haystack).toLowerCase().includes(needle);
|
|
42
|
+
|
|
43
|
+
// The ONE bullet scan both readers use, over the block model's lines. A fenced region is a quotation
|
|
44
|
+
// AND a boundary: it closes the block it interrupts, so text past a fence can never join the bullet
|
|
45
|
+
// before it (which would let a far-side literal satisfy a near-side claim). A `-` plus any whitespace
|
|
46
|
+
// run opens a block; a blank or indented line continues it; any other unindented line closes it.
|
|
47
|
+
// Blocks are returned RAW (their own lines) — the queue reader needs the field lines inside them.
|
|
48
|
+
const bulletBlocks = (lines, fencedLines, from, to) => {
|
|
49
|
+
const blocks = [];
|
|
50
|
+
let current = null;
|
|
51
|
+
const close = () => {
|
|
52
|
+
if (current) blocks.push(current);
|
|
53
|
+
current = null;
|
|
54
|
+
};
|
|
55
|
+
for (let index = from; index < to; index += 1) {
|
|
56
|
+
if (fencedLines.has(index)) {
|
|
57
|
+
close();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const line = lines[index];
|
|
61
|
+
if (BULLET.test(line)) {
|
|
62
|
+
close();
|
|
63
|
+
current = [line];
|
|
64
|
+
} else if (current && (line.trim() === '' || /^\s+\S/.test(line))) {
|
|
65
|
+
current.push(line);
|
|
66
|
+
} else {
|
|
67
|
+
close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
close();
|
|
71
|
+
return blocks;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// extractAcceptance(planText) -> the top-level bullets under `## Verification`, each collapsed to one
|
|
75
|
+
// line. Per the planning canon those bullets ARE the acceptance criteria and they are the WHOLE list.
|
|
76
|
+
// ONE heading recognizer decides both ends: the section opens on the block model's `## Verification`
|
|
77
|
+
// heading and closes at the next heading of level 1 or 2, so a level-3 subheading stays INSIDE (which
|
|
78
|
+
// is what "in this section" means) and a 4-space line, being an indented code block rather than a
|
|
79
|
+
// heading, neither opens nor closes it. A Verification with no bullets declares NO criteria.
|
|
80
|
+
export const extractAcceptance = (planText) => {
|
|
81
|
+
const { lines, headings, fencedLines } = tokenizeMarkdown(String(planText ?? ''), 'the plan');
|
|
82
|
+
const open = headings.find((heading) => heading.text.trim() === ACCEPTANCE_HEADING);
|
|
83
|
+
if (!open) return [];
|
|
84
|
+
const next = headings.find((heading) => heading.index > open.index && heading.level <= 2);
|
|
85
|
+
return bulletBlocks(lines, fencedLines, open.index + 1, next ? next.index : lines.length)
|
|
86
|
+
.map((block) => normalize(block.join('\n').replace(/^-\s+/, '')))
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ONE question per pattern: this one asks whether the line CARRIES a label, never whether the value
|
|
91
|
+
// behind it is any good. Requiring a non-empty value here made an empty repeat invisible, so a row
|
|
92
|
+
// could repeat a label and still be admitted; the value is judged downstream, where "" is missing.
|
|
93
|
+
const FIELD_LINE = new RegExp(`^\\s*(?:[-*]\\s+)?(${ROW_FIELDS.map((f) => f.replace(' ', '\\s+')).join('|')})\\s*:\\s*(.*)$`, 'i');
|
|
94
|
+
|
|
95
|
+
// The labelled fields of one row, folding CONTINUATION lines into the value they belong to (a wrapped
|
|
96
|
+
// invariant read to its first line only would refuse a legitimate deferral). A REPEATED label is
|
|
97
|
+
// recorded and REFUSED, never resolved by keeping the first value: a row saying "not live" and then
|
|
98
|
+
// "live" would otherwise be accepted as not-live, which is the contradiction this fails closed on.
|
|
99
|
+
const parseFields = (block) => {
|
|
100
|
+
const values = {};
|
|
101
|
+
let open = null;
|
|
102
|
+
for (const line of block.split('\n')) {
|
|
103
|
+
const match = line.match(FIELD_LINE);
|
|
104
|
+
if (match) {
|
|
105
|
+
open = match[1].toLowerCase().replace(/\s+/g, ' ');
|
|
106
|
+
values[open] = [...(values[open] ?? []), match[2].trim()];
|
|
107
|
+
} else if (open && /^\s+\S/.test(line) && !BULLET.test(line.trim())) {
|
|
108
|
+
values[open][values[open].length - 1] += ` ${line.trim()}`;
|
|
109
|
+
} else {
|
|
110
|
+
open = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const fields = {};
|
|
114
|
+
for (const label of ROW_FIELDS) fields[label] = values[label]?.[0] ?? null;
|
|
115
|
+
return { fields, values, duplicates: ROW_FIELDS.filter((label) => (values[label] ?? []).length > 1) };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// The disposition a residual exposure declares. Exactly TWO forms declare the negative: "not live"
|
|
119
|
+
// and "not-live", each a standalone token on both sides — so "not--live", "not - live",
|
|
120
|
+
// "not-live-ish" and "maybe-not-live" declare nothing. EVERY not-live span is removed before the
|
|
121
|
+
// positive is looked for, malformed ones included: the `live` inside a botched negative is not a
|
|
122
|
+
// declaration of the positive, and reading it as one would route the author to blocking over a row
|
|
123
|
+
// that only needs re-wording. A standalone bare `live` then decides, and a row carrying both a
|
|
124
|
+
// declared negative and a real positive is a contradiction that fails closed as live.
|
|
125
|
+
const NEGATIVE_SPAN = /(?<![-\w])not[\s-]*live(?![-\w])/g;
|
|
126
|
+
const DECLARED_NEGATIVE = /(?<![-\w])not(?:\s+|-)live(?![-\w])/;
|
|
127
|
+
const LIVE_TOKEN = /(?<![-\w])live(?![-\w])/;
|
|
128
|
+
const exposureOf = (value) => {
|
|
129
|
+
const text = String(value ?? '').toLowerCase();
|
|
130
|
+
const declaredNegative = DECLARED_NEGATIVE.test(text);
|
|
131
|
+
if (LIVE_TOKEN.test(text.replace(NEGATIVE_SPAN, ' '))) return 'live';
|
|
132
|
+
return declaredNegative ? 'not-live' : null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const topLevelRows = (queueText) => {
|
|
136
|
+
const { lines, fencedLines } = tokenizeMarkdown(String(queueText ?? ''), 'the queue');
|
|
137
|
+
return bulletBlocks(lines, fencedLines, 0, lines.length).map((block) => block.join('\n'));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const EMPTY_ROW = () => ({ found: false, matches: 0, fields: {}, missing: [...ROW_FIELDS], duplicates: [], exposure: null, closed: null, claimInInvariant: false });
|
|
141
|
+
|
|
142
|
+
// findDebtRow(queueText, claim) -> the ONE queue row FOR this invariant, its fields and its
|
|
143
|
+
// disposition. Discovery prefers the row whose INVARIANT field carries the claim; the whole-block
|
|
144
|
+
// match is only the fallback, and it exists so a row that carries the claim but no invariant field is
|
|
145
|
+
// still FOUND and its missing field can be named. Zero and several matches are both "not found" with
|
|
146
|
+
// the count: a deferral names ONE row, and guessing which is the unresolved reference this refuses.
|
|
147
|
+
export const findDebtRow = (queueText, claim) => {
|
|
148
|
+
const needle = normalize(claim).toLowerCase();
|
|
149
|
+
if (!needle) return EMPTY_ROW();
|
|
150
|
+
const rows = topLevelRows(queueText).map((block) => ({ block, ...parseFields(block) }));
|
|
151
|
+
// EVERY recorded value of `invariant` is a candidate key, not just the first: a claim sitting in a
|
|
152
|
+
// repeated label would otherwise be invisible here and resolve to some OTHER row.
|
|
153
|
+
const owns = (r) => (r.values.invariant ?? []).some((v) => contains(v, needle));
|
|
154
|
+
const byInvariant = rows.filter(owns);
|
|
155
|
+
const hits = byInvariant.length ? byInvariant : rows.filter((r) => contains(r.block, needle));
|
|
156
|
+
// A candidate that repeats a label cannot be reasoned about at all, so the refusal comes BEFORE the
|
|
157
|
+
// row is resolved — ahead of the ambiguity count and ahead of every field judgement.
|
|
158
|
+
const broken = [...new Set(hits.flatMap((r) => r.duplicates))];
|
|
159
|
+
if (broken.length) return { ...EMPTY_ROW(), matches: hits.length, duplicates: broken };
|
|
160
|
+
if (hits.length !== 1) return { ...EMPTY_ROW(), matches: hits.length };
|
|
161
|
+
const { block, fields, duplicates } = hits[0];
|
|
162
|
+
const missing = ROW_FIELDS.filter((label) => !fields[label]);
|
|
163
|
+
if (fields.origin && !ORIGIN_SHAPE.test(fields.origin)) missing.push(ORIGIN_MISSING);
|
|
164
|
+
return {
|
|
165
|
+
found: true,
|
|
166
|
+
matches: 1,
|
|
167
|
+
block,
|
|
168
|
+
fields,
|
|
169
|
+
missing,
|
|
170
|
+
duplicates,
|
|
171
|
+
exposure: exposureOf(fields['residual exposure']),
|
|
172
|
+
closed: CLOSED_MARKERS.find((marker) => block.split('\n')[0].includes(marker)) ?? null,
|
|
173
|
+
claimInInvariant: owns(hits[0]),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const verdict = (kind, code, exit, claim, lines) => ({
|
|
178
|
+
verdict: kind,
|
|
179
|
+
code,
|
|
180
|
+
exit,
|
|
181
|
+
lines: [`fold-scope: ${kind} ${code}${claim ? ` — "${claim}"` : ''}`, ...lines],
|
|
182
|
+
});
|
|
183
|
+
const accept = (code, claim, lines) => verdict('ACCEPT', code, 0, claim, lines);
|
|
184
|
+
const refuse = (code, claim, lines, exit = 1) => verdict('REFUSE', code, exit, claim, lines);
|
|
185
|
+
|
|
186
|
+
const FIVE_FIELDS = ROW_FIELDS.join(', ');
|
|
187
|
+
|
|
188
|
+
// A document read is the ONE place a throw is expected: the block model REFUSES an unclosed fence and
|
|
189
|
+
// an ambiguous leading `---` rather than guessing, and its message names the file and line.
|
|
190
|
+
const read = (fn) => {
|
|
191
|
+
try {
|
|
192
|
+
return { value: fn() };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return { error: (err && err.message) || String(err) };
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const unreadable = (claim, message) => refuse('document-unreadable', claim, [
|
|
198
|
+
` ${message}`,
|
|
199
|
+
' a document that cannot be read as markdown is never guessed around — fix it, then re-run.',
|
|
200
|
+
], 2);
|
|
201
|
+
|
|
202
|
+
// decideFoldScope({ cls, claim, planText, queueText }) -> { verdict, code, exit, lines }.
|
|
203
|
+
// Exit 0 for every ACCEPT, 1 for every matrix REFUSE, 2 for a refusal about the ARGUMENTS themselves
|
|
204
|
+
// (an unknown or absent class, an absent claim, an unreadable document) - the CLI prints the lines
|
|
205
|
+
// and returns the code.
|
|
206
|
+
export const decideFoldScope = ({ cls, claim, planText, queueText } = {}) => {
|
|
207
|
+
const claimText = normalize(claim);
|
|
208
|
+
if (!CLASSES.includes(cls)) {
|
|
209
|
+
return refuse('class-unknown', typeof cls === 'string' ? cls : '', [
|
|
210
|
+
` --class must be one of: ${CLASSES.join(', ')} — there is no default arm.`,
|
|
211
|
+
' name the invariant the fix would enforce, then say where it already lives.',
|
|
212
|
+
], 2);
|
|
213
|
+
}
|
|
214
|
+
if (!claimText) {
|
|
215
|
+
return refuse('claim-absent', '', [
|
|
216
|
+
' --claim carries the invariant the fix would enforce, as a literal.',
|
|
217
|
+
' a finding with no named invariant has no scope to decide.',
|
|
218
|
+
], 2);
|
|
219
|
+
}
|
|
220
|
+
if (cls === 'blocking') {
|
|
221
|
+
return accept('blocking', claimText, [
|
|
222
|
+
' no correct narrow fix exists, so the phase does not close.',
|
|
223
|
+
' there is no deferral arm here: fix it in this phase, or the phase stays open.',
|
|
224
|
+
]);
|
|
225
|
+
}
|
|
226
|
+
const criteria = read(() => extractAcceptance(planText));
|
|
227
|
+
if (criteria.error) return unreadable(claimText, criteria.error);
|
|
228
|
+
const acceptance = criteria.value;
|
|
229
|
+
const needle = claimText.toLowerCase();
|
|
230
|
+
const matched = acceptance.find((bullet) => contains(bullet, needle)) ?? null;
|
|
231
|
+
if (cls === 'in-scope') {
|
|
232
|
+
return matched
|
|
233
|
+
? accept('in-scope', claimText, [
|
|
234
|
+
` matched acceptance bullet: ${matched}`,
|
|
235
|
+
' fold here: the invariant is already required by this plan. Fold the finding as a red->green test and re-review.',
|
|
236
|
+
])
|
|
237
|
+
: refuse('in-scope-unmatched', claimText, [
|
|
238
|
+
` no acceptance bullet of the plan carries this literal (${acceptance.length} bullet(s) read).`,
|
|
239
|
+
' the lane: ship the NARROW fix for the found site (red first, then green); the generalization defers only as',
|
|
240
|
+
` --class new-invariant, once a queue row carries ${FIVE_FIELDS}. With no correct narrow fix, --class blocking.`,
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
if (matched) {
|
|
244
|
+
return refuse('new-invariant-already-accepted', claimText, [
|
|
245
|
+
` the invariant IS an acceptance bullet: ${matched}`,
|
|
246
|
+
' route to the fold arm: --class in-scope. Already-required work is never a deferral.',
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
const found = read(() => findDebtRow(queueText, claimText));
|
|
250
|
+
if (found.error) return unreadable(claimText, found.error);
|
|
251
|
+
const row = found.value;
|
|
252
|
+
if (row.duplicates.length) {
|
|
253
|
+
return refuse('new-invariant-row-duplicate-field', claimText, [
|
|
254
|
+
` a candidate queue row declares more than once: ${row.duplicates.join(', ')}.`,
|
|
255
|
+
' a repeated label is not resolved by keeping the first value — a row that says both "not live"',
|
|
256
|
+
' and "live" declares a contradiction, and a claim hidden in a repeat would resolve to another row.',
|
|
257
|
+
' Delete the duplicate so the row states ONE value per field.',
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
if (row.matches > 1) {
|
|
261
|
+
return refuse('new-invariant-row-ambiguous', claimText, [
|
|
262
|
+
` ${row.matches} queue rows carry this literal — a deferral names ONE row.`,
|
|
263
|
+
' narrow the claim to the invariant statement of the row you mean.',
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
if (!row.found) {
|
|
267
|
+
return refuse('new-invariant-row-absent', claimText, [
|
|
268
|
+
' no queue row carries this literal.',
|
|
269
|
+
` a deferral owes a row carrying all five fields: ${FIVE_FIELDS}, that exposure declared NOT live.`,
|
|
270
|
+
' if the exposure IS live, this is not a deferral at all — it is --class blocking.',
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
if (row.closed) {
|
|
274
|
+
return refuse('new-invariant-row-closed', claimText, [
|
|
275
|
+
` the queue row title carries "${row.closed}" — a closed row is not a live deferral.`,
|
|
276
|
+
' re-open that row, or write a new one for the invariant this finding names.',
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
279
|
+
if (row.missing.length) {
|
|
280
|
+
return refuse('new-invariant-row-incomplete', claimText, [
|
|
281
|
+
` the queue row is missing: ${row.missing.join(', ')}.`,
|
|
282
|
+
' a row short of a field is a note, not a deferral — write the field, then re-run.',
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
if (!row.claimInInvariant) {
|
|
286
|
+
return refuse('new-invariant-claim-not-invariant', claimText, [
|
|
287
|
+
` the row carries this literal, but NOT in its invariant field: ${row.fields.invariant}`,
|
|
288
|
+
' a deferral is keyed on the invariant the row states — quote that, or write the row this finding needs.',
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
291
|
+
if (row.exposure === 'live') {
|
|
292
|
+
return refuse('new-invariant-exposure-live', claimText, [
|
|
293
|
+
` the residual exposure is declared LIVE: ${row.fields['residual exposure']}`,
|
|
294
|
+
' a live defect in shipped behaviour is never deferred — route to --class blocking.',
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
if (row.exposure === null) {
|
|
298
|
+
return refuse('new-invariant-exposure-undeclared', claimText, [
|
|
299
|
+
` the residual exposure declares neither "live" nor "not live": ${row.fields['residual exposure']}`,
|
|
300
|
+
' silence is not a declaration — state the disposition in the row.',
|
|
301
|
+
]);
|
|
302
|
+
}
|
|
303
|
+
return accept('new-invariant', claimText, [
|
|
304
|
+
` the queue row carries all five fields and declares its residual exposure NOT live: ${row.fields.origin}`,
|
|
305
|
+
' the narrow fix ships in this phase; ONLY the generalization defers.',
|
|
306
|
+
]);
|
|
307
|
+
};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// mcp-registration.mjs — the READ-ONLY half of the `mcp` mode: is the kit's stdio MCP server
|
|
2
|
+
// registered in THIS project? It answers for two consumers that must never reach a writer — the
|
|
3
|
+
// `mcp-channel` advisor item and `uninstall`'s report — and the writer (mcp.mjs) composes its bodies
|
|
4
|
+
// from the same merges, so "what is there" and "what would be written" can never drift apart.
|
|
5
|
+
//
|
|
6
|
+
// Split out for the reason bridge-settings-read.mjs was (bridges 2.3.0, D6): a read-only consumer
|
|
7
|
+
// that imports the writer pulls in the atomic-write core, which read-graph-purity.test.mjs forbids.
|
|
8
|
+
// That is also why the two settings-path literals are NOT imported from velocity-profile.mjs (a
|
|
9
|
+
// write module) — the equality is pinned by a test instead.
|
|
10
|
+
//
|
|
11
|
+
// It reads through fs-read-nofollow.mjs rather than velocity's readSettingsFile because that reader
|
|
12
|
+
// follows a symlink: a symlinked `.mcp.json` whose TARGET is a perfect registration would report
|
|
13
|
+
// registered over a file the writer must refuse to touch. Every never-committable dirent class
|
|
14
|
+
// (the sandbox's own device masks, a FIFO, a socket) is a NAMED state, classified by lstat BEFORE
|
|
15
|
+
// any open. Nothing here throws: every failure is a state with its own reason.
|
|
16
|
+
//
|
|
17
|
+
// RESIDUAL, stated as a BOUNDARY. What this module closes is the STATIC case: a symlink, device,
|
|
18
|
+
// FIFO or socket ALREADY at a path is classified, named, and never read — a target decided FOREIGN
|
|
19
|
+
// is never opened. (A target decided REGULAR is of course opened; that is the read.)
|
|
20
|
+
//
|
|
21
|
+
// Against a path that CHANGES under it, this module promises nothing, and the two races are NOT the
|
|
22
|
+
// same shape:
|
|
23
|
+
// • the LEAF is protected by the shared reader as far as a path-based reader can go — it opens
|
|
24
|
+
// `O_NOFOLLOW`, fstats the DESCRIPTOR and reads through it, so a swapped SYMLINK cannot be
|
|
25
|
+
// followed. What it cannot catch is substitution by another REGULAR FILE, which needs the open
|
|
26
|
+
// bound to an earlier inode observation — inside fs-read-nofollow.mjs, which four consumers
|
|
27
|
+
// share, so that is the leaf's decision rather than this one's.
|
|
28
|
+
// • the CONTAINER cannot be closed that way at all: no property of the leaf's descriptor says
|
|
29
|
+
// anything about the directory the path was resolved through. That needs directory-relative
|
|
30
|
+
// opening (`openat`), which Node does not expose — so it is a platform limit, not a missing check.
|
|
31
|
+
// Classifying `.claude` before reading inside it closes the STATIC symlinked container — the real and
|
|
32
|
+
// reachable case, where a settings file outside the work tree became a verdict; a container swapped
|
|
33
|
+
// mid-flight is not closed, for the reason above. Earlier drafts of this comment enumerated windows
|
|
34
|
+
// and were corrected three rounds running, each time for being one window too generous. The bar this
|
|
35
|
+
// module meets: no STATIC foreign path is followed or read, and no target decided FOREIGN is opened.
|
|
36
|
+
//
|
|
37
|
+
// Dependency-free, Node >= 22. No writes, no CLI, no side effects on import.
|
|
38
|
+
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { describeNonRegular, lstatNoFollowRead, readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
42
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
43
|
+
import { SERVER_NAME, TOOLS } from './mcp-server.mjs';
|
|
44
|
+
|
|
45
|
+
export { SERVER_NAME };
|
|
46
|
+
|
|
47
|
+
export const MCP_JSON_REL = '.mcp.json';
|
|
48
|
+
export const CLAUDE_DIR_REL = '.claude';
|
|
49
|
+
export const SETTINGS_REL = '.claude/settings.json';
|
|
50
|
+
export const SERVERS_KEY = 'mcpServers';
|
|
51
|
+
export const ENABLED_KEY = 'enabledMcpjsonServers';
|
|
52
|
+
// OUT OF SCOPE, deliberately and by name: `disabledMcpjsonServers`. A server listed there is rejected
|
|
53
|
+
// by the client in every mode, so a project can hold our entry, the enable and both rules and still
|
|
54
|
+
// have a dark channel — this mode does NOT detect that, and `registered` therefore means "the three
|
|
55
|
+
// things this mode writes are in place", never "the client will load it".
|
|
56
|
+
//
|
|
57
|
+
// It was implemented during review and then SUBTRACTED. The reason is worth keeping: honouring a veto
|
|
58
|
+
// means reading it from every scope the client merges, and each scope has its own masked, symlinked,
|
|
59
|
+
// malformed and unreadable states — in each of which a hidden deny still yields a confident answer.
|
|
60
|
+
// Three review rounds each closed one such hole and opened the next. A check that is wrong in states
|
|
61
|
+
// it cannot enumerate is worse than a stated limit, so this is the stated limit.
|
|
62
|
+
// The RUNNING kit copy's own server — the args entry a registration must carry to reach THIS kit.
|
|
63
|
+
export const DEFAULT_SERVER_PATH = fileURLToPath(new URL('./mcp-server.mjs', import.meta.url));
|
|
64
|
+
|
|
65
|
+
export const STATE = Object.freeze({
|
|
66
|
+
ABSENT: 'absent',
|
|
67
|
+
MASKED: 'masked',
|
|
68
|
+
FOREIGN: 'foreign',
|
|
69
|
+
UNREADABLE: 'unreadable',
|
|
70
|
+
MALFORMED: 'malformed',
|
|
71
|
+
PRESENT: 'present',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const LF = '\n';
|
|
75
|
+
const CRLF = '\r\n';
|
|
76
|
+
const JSON_INDENT = 2;
|
|
77
|
+
|
|
78
|
+
// The two allow rules a client needs, derived from the server's own name + tool list — never a
|
|
79
|
+
// re-typed pair (a renamed tool would otherwise ship a rule nothing grants).
|
|
80
|
+
export const allowRulesFor = (tools = TOOLS) => tools.map((tool) => `mcp__${SERVER_NAME}__${tool.name}`);
|
|
81
|
+
|
|
82
|
+
export const buildServerEntry = (serverPath) => ({ type: 'stdio', command: 'node', args: [serverPath] });
|
|
83
|
+
|
|
84
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
85
|
+
|
|
86
|
+
// Key-order-independent structural identity: an entry that differs only in key order is the SAME
|
|
87
|
+
// registration, while an extra key, a different command or a different arg is a real difference.
|
|
88
|
+
const stable = (value) => {
|
|
89
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'undefined';
|
|
90
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
91
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// The never-committable stat classes (core-evidence's own review-domain rule, re-decided here rather
|
|
95
|
+
// than imported: core-evidence reaches the atomic-write core and this module may not).
|
|
96
|
+
const isMaskStat = (st) => st.isCharacterDevice() || st.isBlockDevice() || st.isFIFO() || st.isSocket();
|
|
97
|
+
|
|
98
|
+
// lstat FIRST, then read: the classification decides whether an open may happen at all, so a FIFO is
|
|
99
|
+
// never opened and a symlink is never followed.
|
|
100
|
+
const readTarget = (abs, io) => {
|
|
101
|
+
let st;
|
|
102
|
+
try {
|
|
103
|
+
st = lstatNoFollowRead(abs, io.lstat);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { state: STATE.UNREADABLE, reason: (err && (err.code || err.message)) || 'lstat failed' };
|
|
106
|
+
}
|
|
107
|
+
if (st === null) return { state: STATE.ABSENT };
|
|
108
|
+
if (isMaskStat(st)) return { state: STATE.MASKED, className: describeNonRegular(st) };
|
|
109
|
+
if (!st.isFile()) return { state: STATE.FOREIGN, className: describeNonRegular(st) };
|
|
110
|
+
const r = readRegularFileNoFollow(abs, io);
|
|
111
|
+
if (r.outcome === 'absent') return { state: STATE.ABSENT };
|
|
112
|
+
if (r.outcome === 'foreign') return { state: STATE.FOREIGN, className: r.className };
|
|
113
|
+
if (r.outcome !== 'ok') return { state: STATE.UNREADABLE, reason: r.code };
|
|
114
|
+
return { state: STATE.PRESENT, text: r.content };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const eolOf = (text) => (text.includes(CRLF) ? CRLF : LF);
|
|
118
|
+
|
|
119
|
+
// A file we cannot parse is MALFORMED with its own reason — never an empty object, which would let a
|
|
120
|
+
// writer clobber a file it never understood.
|
|
121
|
+
const parseJsonText = (text) => {
|
|
122
|
+
let data;
|
|
123
|
+
try {
|
|
124
|
+
data = JSON.parse(text);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return { state: STATE.MALFORMED, reason: `not valid JSON (${(err && err.message) || 'parse failed'})` };
|
|
127
|
+
}
|
|
128
|
+
if (!isPlainObject(data)) return { state: STATE.MALFORMED, reason: 'the root is not a JSON object' };
|
|
129
|
+
return { state: STATE.PRESENT, data };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// A MANAGED key of the wrong type is MALFORMED, not "empty". Reading it as an empty container and
|
|
133
|
+
// merging over it DESTROYS whatever it held — the merge-through-clobber `gate-hook.mjs`
|
|
134
|
+
// (assertHooksShape) already refuses for its own `hooks` key. Only the four keys this mode writes are
|
|
135
|
+
// judged; a foreign key of any shape is data, carried over untouched and never inspected.
|
|
136
|
+
const wrongType = (key, expected) => `carrying a "${key}" key that is not ${expected}`;
|
|
137
|
+
|
|
138
|
+
const NEUTRAL_ENTRY = { hasEntry: false, existing: null, matches: false, differs: false };
|
|
139
|
+
|
|
140
|
+
// The two decisions over TEXT. Exported because a consumer that already holds the bytes — the
|
|
141
|
+
// uninstaller, which reads every surface through its own injected fs — must answer "is this OUR
|
|
142
|
+
// registration?" by the SAME rule the reader uses, not by a second copy of it.
|
|
143
|
+
export const decideMcpJsonText = (text, entry) => {
|
|
144
|
+
const parsed = parseJsonText(text);
|
|
145
|
+
const eol = eolOf(text);
|
|
146
|
+
if (parsed.state !== STATE.PRESENT) return { ...parsed, eol, ...NEUTRAL_ENTRY };
|
|
147
|
+
const servers = parsed.data[SERVERS_KEY];
|
|
148
|
+
if (servers !== undefined && !isPlainObject(servers)) {
|
|
149
|
+
return { state: STATE.MALFORMED, eol, reason: wrongType(SERVERS_KEY, 'a JSON object'), ...NEUTRAL_ENTRY };
|
|
150
|
+
}
|
|
151
|
+
// PRESENCE is hasOwnProperty, never `value !== null`: a key that is THERE is a declaration this
|
|
152
|
+
// mode may refuse but must never replace — a literal `null` included.
|
|
153
|
+
const hasEntry = isPlainObject(servers) && Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
|
|
154
|
+
const existing = hasEntry ? servers[SERVER_NAME] : null;
|
|
155
|
+
const matches = hasEntry && stable(existing) === stable(entry);
|
|
156
|
+
return { ...parsed, eol, hasEntry, existing, matches, differs: hasEntry && !matches };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const decideSettingsText = (text, allowRules) => {
|
|
160
|
+
const parsed = parseJsonText(text);
|
|
161
|
+
const eol = eolOf(text);
|
|
162
|
+
const neutral = { enabled: false, allowPresent: [], allowMissing: allowRules, complete: false };
|
|
163
|
+
if (parsed.state !== STATE.PRESENT) return { ...parsed, eol, ...neutral };
|
|
164
|
+
const malformed = (reason) => ({ state: STATE.MALFORMED, eol, reason, ...neutral });
|
|
165
|
+
const enabledList = parsed.data[ENABLED_KEY];
|
|
166
|
+
if (enabledList !== undefined && !Array.isArray(enabledList)) return malformed(wrongType(ENABLED_KEY, 'a JSON array'));
|
|
167
|
+
const permissions = parsed.data.permissions;
|
|
168
|
+
if (permissions !== undefined && !isPlainObject(permissions)) return malformed(wrongType('permissions', 'a JSON object'));
|
|
169
|
+
const allow = isPlainObject(permissions) ? permissions.allow : undefined;
|
|
170
|
+
if (allow !== undefined && !Array.isArray(allow)) return malformed(wrongType('permissions.allow', 'a JSON array'));
|
|
171
|
+
const rules = Array.isArray(allow) ? allow : [];
|
|
172
|
+
const enabled = Array.isArray(enabledList) && enabledList.includes(SERVER_NAME);
|
|
173
|
+
const allowMissing = allowRules.filter((rule) => !rules.includes(rule));
|
|
174
|
+
return {
|
|
175
|
+
...parsed,
|
|
176
|
+
eol,
|
|
177
|
+
enabled,
|
|
178
|
+
allowPresent: allowRules.filter((rule) => rules.includes(rule)),
|
|
179
|
+
allowMissing,
|
|
180
|
+
// "Everything this mode writes is in place" — see the scope note in the header for what that
|
|
181
|
+
// deliberately does NOT mean.
|
|
182
|
+
complete: enabled && allowMissing.length === 0,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// readRegistration(root, io?) → the full registration picture of ONE project. `io.serverPath`
|
|
187
|
+
// overrides the running kit's server path (tests); every fs primitive in `io` is the fs-read-nofollow
|
|
188
|
+
// injection contract. NEVER throws.
|
|
189
|
+
export const readRegistration = (root, io = {}) => {
|
|
190
|
+
const serverPath = io.serverPath ?? DEFAULT_SERVER_PATH;
|
|
191
|
+
const entry = buildServerEntry(serverPath);
|
|
192
|
+
const allowRules = allowRulesFor();
|
|
193
|
+
|
|
194
|
+
const mcpAbs = join(root, MCP_JSON_REL);
|
|
195
|
+
const mcpRead = readTarget(mcpAbs, io);
|
|
196
|
+
const mcpJson = { rel: MCP_JSON_REL, abs: mcpAbs, eol: LF, ...NEUTRAL_ENTRY, ...mcpRead,
|
|
197
|
+
...(mcpRead.state === STATE.PRESENT ? decideMcpJsonText(mcpRead.text, entry) : {}) };
|
|
198
|
+
|
|
199
|
+
// The CONTAINER is classified BEFORE the file inside it is read, and the order is load-bearing:
|
|
200
|
+
// path resolution follows an INTERMEDIATE symlink (O_NOFOLLOW guards the FINAL component only), so
|
|
201
|
+
// reading first would pull a settings file from outside the work tree into memory — and into a
|
|
202
|
+
// verdict, and into a rendered merge body — before anything got the chance to refuse it.
|
|
203
|
+
const claudeDirAbs = join(root, CLAUDE_DIR_REL);
|
|
204
|
+
const claudeDir = { rel: CLAUDE_DIR_REL, abs: claudeDirAbs, ...classifyDir(claudeDirAbs, io) };
|
|
205
|
+
const containerUsable = claudeDir.state === STATE.PRESENT || claudeDir.state === STATE.ABSENT;
|
|
206
|
+
|
|
207
|
+
// settings.local.json is NOT read at all: this mode never writes it, so counting a rule that lives
|
|
208
|
+
// only there would report a registration the writer cannot maintain. (Reading it for the deny veto
|
|
209
|
+
// alone was tried and subtracted — see the scope note in the header.)
|
|
210
|
+
const settingsAbs = join(root, SETTINGS_REL);
|
|
211
|
+
const settingsRead = containerUsable
|
|
212
|
+
? readTarget(settingsAbs, io)
|
|
213
|
+
: { state: STATE.UNREADABLE, reason: `${CLAUDE_DIR_REL} is a ${claudeDir.className ?? claudeDir.reason} — refusing to read through it` };
|
|
214
|
+
const neutralAllow = { enabled: false, allowPresent: [], allowMissing: allowRules, complete: false };
|
|
215
|
+
const settings = { rel: SETTINGS_REL, abs: settingsAbs, eol: LF, ...neutralAllow, ...settingsRead,
|
|
216
|
+
...(settingsRead.state === STATE.PRESENT ? decideSettingsText(settingsRead.text, allowRules) : {}) };
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
root,
|
|
220
|
+
serverPath,
|
|
221
|
+
entry,
|
|
222
|
+
allowRules,
|
|
223
|
+
mcpJson,
|
|
224
|
+
settings,
|
|
225
|
+
claudeDir,
|
|
226
|
+
registered: mcpJson.matches && settings.complete,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// The `.claude/` container: an ABSENT dir is a named state the preflight reports and only `--apply`
|
|
231
|
+
// resolves (creating it here would make a read-only preflight write).
|
|
232
|
+
const classifyDir = (abs, io) => {
|
|
233
|
+
let st;
|
|
234
|
+
try {
|
|
235
|
+
st = lstatNoFollowRead(abs, io.lstat);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return { state: STATE.UNREADABLE, reason: (err && (err.code || err.message)) || 'lstat failed' };
|
|
238
|
+
}
|
|
239
|
+
if (st === null) return { state: STATE.ABSENT };
|
|
240
|
+
if (st.isDirectory()) return { state: STATE.PRESENT };
|
|
241
|
+
return { state: STATE.FOREIGN, className: describeNonRegular(st) };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// ── the merges (ONE definition, shared by the writer's bodies and the paste-ready fragments) ──
|
|
245
|
+
// Merge-don't-clobber in both: every foreign server, key and allow rule is carried over untouched,
|
|
246
|
+
// and our own entry/rules are added idempotently.
|
|
247
|
+
|
|
248
|
+
export const mergeMcpJson = (registration) => {
|
|
249
|
+
const base = isPlainObject(registration.mcpJson.data) ? registration.mcpJson.data : {};
|
|
250
|
+
const servers = isPlainObject(base[SERVERS_KEY]) ? base[SERVERS_KEY] : {};
|
|
251
|
+
return { ...base, [SERVERS_KEY]: { ...servers, [SERVER_NAME]: registration.entry } };
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export const mergeSettings = (registration) => {
|
|
255
|
+
const base = isPlainObject(registration.settings.data) ? registration.settings.data : {};
|
|
256
|
+
const enabled = Array.isArray(base[ENABLED_KEY]) ? base[ENABLED_KEY] : [];
|
|
257
|
+
const permissions = isPlainObject(base.permissions) ? base.permissions : {};
|
|
258
|
+
const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
|
|
259
|
+
return {
|
|
260
|
+
...base,
|
|
261
|
+
[ENABLED_KEY]: enabled.includes(SERVER_NAME) ? enabled : [...enabled, SERVER_NAME],
|
|
262
|
+
permissions: { ...permissions, allow: [...allow, ...registration.allowRules.filter((rule) => !allow.includes(rule))] },
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const formatJson = (data, eol) => `${JSON.stringify(data, null, JSON_INDENT).replaceAll(LF, eol)}${eol}`;
|
|
267
|
+
|
|
268
|
+
// The two hand-apply bodies — the whole output of the MASKED arm.
|
|
269
|
+
//
|
|
270
|
+
// They are deliberately DIFFERENT shapes, because what the kit knows about each file differs. The
|
|
271
|
+
// settings file was OBSERVABLE — and read where present — so its body is a real merge: it already
|
|
272
|
+
// carries every foreign key it had. The masked `.mcp.json` was not observable at all, so a
|
|
273
|
+
// whole-file body would name only our server and, pasted as
|
|
274
|
+
// instructed, would delete every foreign server the mask hid. `mcpEntry` is therefore the ENTRY
|
|
275
|
+
// ALONE, to be merged under `mcpServers` by a human who can see what is actually in that file.
|
|
276
|
+
export const renderFragments = (registration) => ({
|
|
277
|
+
mcpEntry: formatJson(registration.entry, registration.mcpJson.eol),
|
|
278
|
+
settings: formatJson(mergeSettings(registration), registration.settings.eol),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// A LIBRARY module the mode doc names by path — so someone can try to run it. A no-op on import;
|
|
282
|
+
// on a direct run it points at the command that acts on what this module only reports.
|
|
283
|
+
refuseDirectRun(import.meta.url);
|