@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/bin/agy-review-await-guard.test.mjs +176 -0
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +61 -14
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +606 -467
  7. package/bridges/antigravity-cli-bridge/references/review-prompt.md +42 -4
  8. package/bridges/codex-cli-bridge/SKILL.md +18 -5
  9. package/bridges/codex-cli-bridge/bin/codex-await-guard.test.mjs +161 -0
  10. package/bridges/codex-cli-bridge/bin/codex-exec.sh +22 -17
  11. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +356 -363
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +275 -286
  14. package/bridges/codex-cli-bridge/capability.json +1 -1
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +4 -2
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  17. package/bridges/codex-cli-bridge/setup/README.md +3 -1
  18. package/capability.json +1 -1
  19. package/package.json +1 -1
  20. package/references/hooks/gate-approve.mjs +1 -1
  21. package/references/modes/mcp.md +37 -0
  22. package/references/modes/recommendations.md +1 -0
  23. package/references/modes/uninstall.md +2 -1
  24. package/references/templates/agent_rules.md +1 -0
  25. package/tools/commands.mjs +7 -0
  26. package/tools/direct-run.mjs +3 -0
  27. package/tools/doc-parity.mjs +18 -2
  28. package/tools/fold-scope-cli.mjs +93 -0
  29. package/tools/fold-scope.mjs +307 -0
  30. package/tools/mcp-registration.mjs +283 -0
  31. package/tools/mcp-server.mjs +314 -0
  32. package/tools/mcp-stdio.mjs +229 -0
  33. package/tools/mcp.mjs +299 -0
  34. package/tools/procedures.mjs +29 -4
  35. package/tools/recommendations.mjs +90 -1
  36. package/tools/uninstall.mjs +356 -45
package/tools/mcp.mjs ADDED
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ // mcp.mjs — the guarded writer behind `/agent-workflow-kit mcp`: registers the kit's stdio MCP
3
+ // server in ONE project, so the typed readers (`path_inventory`, `repo_search`) reach a deployed
4
+ // project instead of only the repo that built them. It writes exactly two files:
5
+ // • `.mcp.json` at the project root — the `agent-workflow` stdio entry (command `node`, args = the
6
+ // RUNNING kit's tools/mcp-server.mjs, absolute);
7
+ // • `.claude/settings.json` — `enabledMcpjsonServers: ["agent-workflow"]` plus the two allow rules
8
+ // derived from the server's own SERVER_NAME + TOOLS.
9
+ //
10
+ // Same family writer discipline as gate-hook.mjs, and the same reasons:
11
+ // • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
12
+ // • the ENTRY is printed BEFORE consent — a registration is a command the client will RUN, so the
13
+ // exact structured value is on screen at the moment the decision is made, never described in
14
+ // prose. It is re-serialized for the preview, so it is that VALUE and not those literal bytes;
15
+ // • `.mcp.json` FIRST, then settings — settings enabling a server whose entry is not yet there
16
+ // would be a client error on every startup;
17
+ // • merge-don't-clobber — foreign servers, foreign settings keys and existing allow rules are
18
+ // preserved; re-apply adds nothing twice; the file's EOL is kept;
19
+ // • a same-name entry that STRUCTURALLY DIFFERS is REFUSED unwritten (key order is ignored, so a
20
+ // re-serialized identical entry is the SAME registration) — silently changing what an MCP server
21
+ // launches is exactly what consent must not slide past; the recovery is named;
22
+ // • the preflight is READ-ONLY: an absent `.claude/` is a NAMED state, and the dir is created on
23
+ // `--apply` only (assertCreatableDirSafe mkdirs, so it may not run in a preview);
24
+ // • a MASKED target (an OS sandbox injects a character device where `.mcp.json` would be — this
25
+ // repo is exactly that case) is not a failure: the kit hands over both paste-ready fragments,
26
+ // writes nothing, and exits 0. Where it cannot write, it says precisely what it would have.
27
+ // • never `settings.local.json`; never commits.
28
+ //
29
+ // The read half lives in mcp-registration.mjs, which the advisor and `uninstall` use — so what is
30
+ // there and what would be written are computed by one module, never by two that can disagree.
31
+ //
32
+ // Exit codes: 0 done / dry-run (incl. the hand-apply masked state); 1 precondition STOP; 2 usage.
33
+ // Dependency-free beyond the kit's own exports, Node >= 22. No side effects on import.
34
+
35
+ import { lstatSync } from 'node:fs';
36
+ import { join, resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { assertCreatableDirSafe, writeContainedFileAtomic } from './atomic-write.mjs';
39
+ import { isDirectRun } from './direct-run.mjs';
40
+ import {
41
+ CLAUDE_DIR_REL,
42
+ ENABLED_KEY,
43
+ MCP_JSON_REL,
44
+ SERVERS_KEY,
45
+ SERVER_NAME,
46
+ SETTINGS_REL,
47
+ STATE,
48
+ formatJson,
49
+ mergeMcpJson,
50
+ mergeSettings,
51
+ readRegistration,
52
+ renderFragments,
53
+ } from './mcp-registration.mjs';
54
+ import { shellQuoteArg } from './review-state.mjs';
55
+
56
+ const q = shellQuoteArg;
57
+
58
+ export const MCP_SYMLINK = 'MCP_SYMLINK';
59
+ export const MCP_MALFORMED = 'MCP_MALFORMED';
60
+ export const MCP_DIFFERS = 'MCP_DIFFERS';
61
+
62
+ const EXIT_OK = 0;
63
+ const EXIT_PRECONDITION = 1;
64
+ const EXIT_USAGE = 2;
65
+ const ERROR_PREFIX = '[agent-workflow-kit]';
66
+ const LF = '\n';
67
+ const JSON_INDENT = 2;
68
+
69
+ export const MCP_TOOL = fileURLToPath(import.meta.url);
70
+ export const applyMcpCommand = (root) => `node ${q(MCP_TOOL)} --apply --cwd ${q(root)}`;
71
+
72
+ const USAGE = `usage: mcp [--dry-run | --apply] [--cwd <dir>] [--help]
73
+
74
+ Registers this kit's stdio MCP server in ONE project: the "${SERVER_NAME}" entry in
75
+ ${MCP_JSON_REL}, and "${ENABLED_KEY}" + the two tool allow rules in ${SETTINGS_REL}.
76
+ Default is --dry-run (a preview that prints the exact entry and writes nothing).
77
+ --apply writes: ${MCP_JSON_REL} first, then ${SETTINGS_REL}; merge-don't-clobber, EOL kept.
78
+
79
+ An existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
80
+ order is ignored). Where ${MCP_JSON_REL} is a device node, FIFO or socket — an OS sandbox
81
+ mask is the usual cause — the entry to merge is printed and nothing is written.
82
+ Never writes settings.local.json; never commits.`;
83
+
84
+ export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
85
+
86
+ export const makeMcpError = (code, message) =>
87
+ Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'McpError', code, exitCode: EXIT_PRECONDITION });
88
+
89
+ const lstatNoFollow = (path, lstat = lstatSync) => {
90
+ try {
91
+ return (lstat ?? lstatSync)(path);
92
+ } catch (err) {
93
+ if (err && err.code === 'ENOENT') return null;
94
+ throw err;
95
+ }
96
+ };
97
+
98
+ // ── preflight (READ-ONLY — it creates nothing, on either lane) ─────────────────────────
99
+
100
+ // A target we could not read or parse is never overwritten: a merge over a file whose current
101
+ // content is unknown is a clobber wearing a merge's name.
102
+ //
103
+ // `maskedAllowed` is TRUE for `.mcp.json` alone. That file has a sanctioned handoff — the kit hands
104
+ // over the entry for a human to merge from outside the sandbox — and `settings.json` has none, so a
105
+ // mask there is an ordinary refusal rather than a second, unplanned success path.
106
+ const assertTargetUsable = (target, { maskedAllowed = false } = {}) => {
107
+ if (target.state === STATE.MASKED) {
108
+ if (maskedAllowed) return;
109
+ throw makeMcpError(
110
+ MCP_SYMLINK,
111
+ `${target.rel} is a ${target.className} (an OS sandbox device mask is the usual cause) — this mode can neither write it nor merge into what it cannot read`,
112
+ );
113
+ }
114
+ if (target.state === STATE.FOREIGN) {
115
+ throw makeMcpError(MCP_SYMLINK, `${target.rel} is a ${target.className} — refusing to write through it`);
116
+ }
117
+ if (target.state === STATE.MALFORMED) {
118
+ throw makeMcpError(MCP_MALFORMED, `${target.rel} is ${target.reason} — refusing to overwrite it; fix or remove it, then re-run`);
119
+ }
120
+ if (target.state === STATE.UNREADABLE) {
121
+ throw makeMcpError(MCP_MALFORMED, `${target.rel} cannot be read (${target.reason}) — refusing to overwrite what was never read`);
122
+ }
123
+ };
124
+
125
+ export const preflightMcp = ({ cwd }, deps = {}) => {
126
+ const root = resolve(cwd ?? deps.cwd ?? process.cwd());
127
+ // The ROOT is judged BEFORE the registration is read, not merely before the first write: reading
128
+ // first would follow the very link this refuses, which is exactly what the shipped claim denies.
129
+ const rootStat = lstatNoFollow(root, deps.lstat);
130
+ // A target that does not exist read as "a project with two absent files", so the preview offered an
131
+ // apply that could only ENOENT. Both lanes refuse the same way, at the same point.
132
+ if (rootStat === null) {
133
+ throw makeMcpError(MCP_SYMLINK, `${root} does not exist — name an existing project directory`);
134
+ }
135
+ if (rootStat.isSymbolicLink()) {
136
+ throw makeMcpError(MCP_SYMLINK, `${root} is a symlink — refusing to register into a symlinked project root`);
137
+ }
138
+ if (!rootStat.isDirectory()) {
139
+ throw makeMcpError(MCP_SYMLINK, `${root} is not a directory — name an existing project directory`);
140
+ }
141
+ const registration = readRegistration(root, deps);
142
+ // ORDER IS THE CONTRACT. Every OBSERVABLE surface is judged first — the container, both targets'
143
+ // classes, then the entry itself — and only a run that survives all of them may reach the one
144
+ // handoff this mode has. Taking the handoff early made a mask on ANY surface swallow the refusals
145
+ // behind it: a differing entry and a malformed settings file both came back as a cheerful exit 0.
146
+ const dir = registration.claudeDir;
147
+ if (dir.state === STATE.FOREIGN) {
148
+ throw makeMcpError(MCP_SYMLINK, `${dir.rel} is a ${dir.className} — refusing to write through it`);
149
+ }
150
+ if (dir.state === STATE.UNREADABLE) {
151
+ throw makeMcpError(MCP_MALFORMED, `${dir.rel} cannot be inspected (${dir.reason}) — refusing to write into it`);
152
+ }
153
+ assertTargetUsable(registration.mcpJson, { maskedAllowed: true });
154
+ assertTargetUsable(registration.settings);
155
+ if (registration.mcpJson.differs) {
156
+ throw makeMcpError(
157
+ MCP_DIFFERS,
158
+ `${MCP_JSON_REL} already carries an "${SERVER_NAME}" server entry that STRUCTURALLY DIFFERS from this kit copy's registration — refusing to change what it launches; review that entry and remove or rename it, then re-run`,
159
+ );
160
+ }
161
+ const masked = registration.mcpJson.state === STATE.MASKED;
162
+ return { root, registration, masked, plan: planMcp(registration) };
163
+ };
164
+
165
+ // The plan is pure over the registration: what is missing, and the exact body each file would get.
166
+ // Both bodies are built even when nothing is written — they ARE the preview and the hand-apply text.
167
+ export const planMcp = (registration) => ({
168
+ writeMcpJson: !registration.mcpJson.matches,
169
+ writeSettings: !registration.settings.complete,
170
+ mcpBody: formatJson(mergeMcpJson(registration), registration.mcpJson.eol),
171
+ settingsBody: formatJson(mergeSettings(registration), registration.settings.eol),
172
+ });
173
+
174
+ // ── the writer ─────────────────────────────────────────────────────────────────────────
175
+
176
+ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
177
+ const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
178
+ const base = { ...preflight, dryRun, wrote: false };
179
+ if (preflight.masked) return { ...base, fragments: renderFragments(preflight.registration) };
180
+ if (dryRun) return base;
181
+
182
+ const { root, registration, plan } = preflight;
183
+ const stop = (message) => makeMcpError(MCP_SYMLINK, message);
184
+ if (plan.writeMcpJson) {
185
+ writeContainedFileAtomic(root, registration.mcpJson.abs, plan.mcpBody, deps, { stop, label: MCP_JSON_REL });
186
+ }
187
+ if (plan.writeSettings) {
188
+ // The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so it
189
+ // belongs on the apply lane only — a preview that made a directory would not be a preview.
190
+ assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
191
+ writeContainedFileAtomic(root, registration.settings.abs, plan.settingsBody, deps, { stop, label: SETTINGS_REL });
192
+ }
193
+ return { ...base, wrote: plan.writeMcpJson || plan.writeSettings };
194
+ };
195
+
196
+ // ── the report ─────────────────────────────────────────────────────────────────────────
197
+
198
+ const POSTURE_LINE =
199
+ 'trust posture: the registered server is a READ-ONLY child of your MCP client (path/type/size/line facts and literal search over this project root) — it runs OUTSIDE the Bash sandbox, as the client itself does, and exposes no write or exec API. The two allow rules make its tool calls promptless; nothing else in this project changes.';
200
+
201
+ const indented = (text) => text.trimEnd().split(LF).map((line) => ` ${line}`).join(LF);
202
+
203
+ const mcpJsonLine = (result) => {
204
+ if (!result.plan.writeMcpJson) return ` - ${MCP_JSON_REL}: already current`;
205
+ const verb = result.dryRun ? 'would add' : 'added';
206
+ return ` - ${MCP_JSON_REL}: ${verb} the "${SERVER_NAME}" stdio entry`;
207
+ };
208
+
209
+ const settingsLine = (result) => {
210
+ const { registration, plan, dryRun } = result;
211
+ if (!plan.writeSettings) return ` - ${SETTINGS_REL}: already current`;
212
+ const parts = [];
213
+ if (!registration.settings.enabled) parts.push(`"${ENABLED_KEY}" += "${SERVER_NAME}"`);
214
+ if (registration.settings.allowMissing.length > 0) parts.push(`allow += ${registration.settings.allowMissing.join(', ')}`);
215
+ return ` - ${SETTINGS_REL}: ${dryRun ? 'would set' : 'set'} ${parts.join(' · ')}`;
216
+ };
217
+
218
+ // The hand-apply text. The two halves are worded differently because the kit KNOWS different things
219
+ // about them: the settings body is a real merge over content it read, while the `.mcp.json` half is
220
+ // the entry ALONE — behind the mask this mode cannot see what that file already declares, and a
221
+ // whole-file body pasted as instructed would delete every server it could not see.
222
+ const maskedReport = (result) => {
223
+ const target = result.registration.mcpJson;
224
+ return [
225
+ // The CLASS is what was observed; the sandbox mask is the usual CAUSE but is not established here.
226
+ `agent-workflow MCP registration — HAND-APPLY: ${MCP_JSON_REL} is a ${target.className} (an OS sandbox device mask is the usual cause), so nothing was written.`,
227
+ ` merge this entry into ${MCP_JSON_REL} under "${SERVERS_KEY}", and keep every other server it already declares (this mode cannot read them through the mask):`,
228
+ indented(`"${SERVER_NAME}": ${result.fragments.mcpEntry.trimEnd()}`),
229
+ ` merge into ${SETTINGS_REL} (that file was observable — and read where present — so this body already carries what is in it):`,
230
+ indented(result.fragments.settings),
231
+ POSTURE_LINE,
232
+ ].join(LF);
233
+ };
234
+
235
+ export const formatResult = (result) => {
236
+ if (result.masked) return maskedReport(result);
237
+ const nothingToDo = !result.plan.writeMcpJson && !result.plan.writeSettings;
238
+ if (nothingToDo) {
239
+ return [`agent-workflow MCP registration — already registered ("${SERVER_NAME}"); nothing to do.`, POSTURE_LINE].join(LF);
240
+ }
241
+ const lines = [
242
+ result.dryRun
243
+ ? 'agent-workflow MCP registration — DRY RUN (no changes; re-run with --apply)'
244
+ : 'agent-workflow MCP registration — APPLY',
245
+ mcpJsonLine(result),
246
+ settingsLine(result),
247
+ ' the entry this registration declares (re-serialized here; the same structured value goes into the file):',
248
+ indented(JSON.stringify(result.registration.entry, null, JSON_INDENT)),
249
+ POSTURE_LINE,
250
+ ];
251
+ if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root)}`);
252
+ return lines.join(LF);
253
+ };
254
+
255
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────
256
+
257
+ export const parseArgs = (argv) => {
258
+ const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
259
+ for (let i = 0; i < argv.length; i += 1) {
260
+ const arg = argv[i];
261
+ if (arg === '--help' || arg === '-h') opts.help = true;
262
+ else if (arg === '--dry-run') opts.dryRunFlag = true;
263
+ else if (arg === '--apply') opts.apply = true;
264
+ else if (arg === '--cwd') {
265
+ i += 1;
266
+ // An EMPTY (or whitespace) value passes both guards above, and `resolve('')` silently means the
267
+ // process cwd — so an explicit target of "" would write the registration wherever the tool
268
+ // happened to run. An explicit argument that names nothing is a usage error, never a default.
269
+ if (argv[i] === undefined || argv[i].startsWith('-') || argv[i].trim() === '') {
270
+ throw fail(EXIT_USAGE, '--cwd needs a directory argument');
271
+ }
272
+ opts.cwd = argv[i];
273
+ } else {
274
+ throw fail(EXIT_USAGE, `unknown argument: ${arg}`);
275
+ }
276
+ }
277
+ if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
278
+ return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
279
+ };
280
+
281
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
282
+ const log = deps.log ?? console.log;
283
+ const errlog = deps.errlog ?? console.error;
284
+ try {
285
+ const args = parseArgs(argv);
286
+ if (args.help) {
287
+ log(USAGE);
288
+ return EXIT_OK;
289
+ }
290
+ log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps)));
291
+ return EXIT_OK;
292
+ } catch (err) {
293
+ errlog(err?.message ?? String(err));
294
+ if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
295
+ return err?.exitCode ?? EXIT_PRECONDITION;
296
+ }
297
+ };
298
+
299
+ if (isDirectRun(import.meta.url)) process.exit(main(process.argv.slice(2)));
@@ -31,6 +31,9 @@ import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from '.
31
31
  // The plan-in-flight detector (AD-038) — imported from the plan-files.mjs LEAF (read-only fs by
32
32
  // construction); the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported.
33
33
  import { plansInFlight, PLANS_REL } from './plan-files.mjs';
34
+ // The family's ONE shell quoter for a RENDERED command operand (bare when the value is already safe,
35
+ // single-quoted otherwise) — the same leaf eight other command renderers here read through.
36
+ import { shellQuoteArg } from './repo-lex.mjs';
34
37
  // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
35
38
  // is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
36
39
  // (orchestration-write.mjs) DIRECTLY — the import-split test pins the direct-import rule.
@@ -298,6 +301,24 @@ const autonomyAdvice = (activity, facts) => {
298
301
  ];
299
302
  };
300
303
 
304
+ // The finding-scope block (procedures.md plan-execution step 5) — plan-execution ONLY and
305
+ // UNCONDITIONAL: the rule routes EVERY finding, review-backed or not, so gating it on REVIEW_RECIPES
306
+ // (which gates only the loop economics above) would hide it from every Solo project. The canon
307
+ // section is printed VERBATIM above, so this block never re-states the rule — it carries only what
308
+ // the canon cannot: the POPULATED checker command, and which of the two registers `--queue` names.
309
+ export const FOLD_SCOPE_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'fold-scope-cli.mjs');
310
+ const foldScopeAdvice = (activity, config, plans) => {
311
+ if (activity !== 'plan-execution') return [];
312
+ const declared = config?.flow?.debtQueue ?? null;
313
+ const queue = declared ?? `${PLANS_REL}/queue.md`;
314
+ const plan = plans.length === 1 ? `${PLANS_REL}/${plans[0]}` : '<plan-file>';
315
+ return [
316
+ 'Finding scope (procedures.md plan-execution step 5) — the rule is the section above; this is the checker it names:',
317
+ ` • node ${shellQuoteArg(FOLD_SCOPE_TOOL)} --class '<in-scope|new-invariant|blocking>' --claim '<the invariant>' --plan ${shellQuoteArg(plan)} --queue ${shellQuoteArg(queue)}`,
318
+ ` • --queue is ${declared ? `${declared}, the declared flow.debtQueue` : `${queue}, the planning lifecycle queue (no flow.debtQueue is declared)`}. Advisory: nothing records that it ran, so a skipped or late call is indistinguishable from a pre-edit declaration.`,
319
+ ];
320
+ };
321
+
301
322
  // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
302
323
  // at the point of use like reviewLoopAdvice paraphrases procedures.md Fold + loop / orchestration §4). Rendered UNCONDITIONALLY for
303
324
  // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
@@ -445,7 +466,7 @@ const contractLines = ({ cmd, contract, settings }) => {
445
466
  return lines;
446
467
  };
447
468
 
448
- const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice }) => {
469
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => {
449
470
  const lines = [
450
471
  section,
451
472
  '',
@@ -464,6 +485,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
464
485
  if (grounding.length) lines.push('', ...grounding);
465
486
  const advice = reviewLoopAdvice(slots, activity);
466
487
  if (advice.length) lines.push('', ...advice);
488
+ if (foldScope.length) lines.push('', ...foldScope);
467
489
  lines.push('', ...costLanesAdvice());
468
490
  if (declaredPractice.length) lines.push('', ...declaredPractice);
469
491
  if (warnings.length) {
@@ -473,7 +495,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
473
495
  return lines.join('\n');
474
496
  };
475
497
 
476
- const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }) => ({
498
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => ({
477
499
  activity,
478
500
  section,
479
501
  slots: Object.fromEntries(
@@ -486,6 +508,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
486
508
  groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
487
509
  // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
488
510
  costLanes: costLanesAdvice(),
511
+ // ADDITIVE (the fold channel): the finding-scope block, structured (empty outside plan-execution).
512
+ foldScope,
489
513
  // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
490
514
  autonomy: autonomyAdvice(activity, autonomy),
491
515
  // ADDITIVE (D-17 U1): the SAME composed lines the human render prints — one array, two renders, so
@@ -574,9 +598,10 @@ export const main = (argv, ctx = {}) => {
574
598
  const flowProbe = ctx.flowProbe ?? defaultFlowProbe;
575
599
  const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
576
600
  const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
601
+ const foldScope = foldScopeAdvice(activity, config, plans);
577
602
  const stdout = json
578
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }), null, 2)
579
- : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice });
603
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }), null, 2)
604
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope });
580
605
  if (autonomy?.error) {
581
606
  return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
582
607
  }
@@ -59,6 +59,13 @@ import { shellQuoteArg } from './review-state.mjs';
59
59
  import { isFinalCapableDeclaration } from './run-gates.mjs';
60
60
  import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, GATES_REL, LCOV_PRODUCER_KEY } from './gates-declaration.mjs';
61
61
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
62
+ // The typed channel's READ-ONLY leaf only — never tools/mcp.mjs, which reaches the write core.
63
+ import {
64
+ MCP_JSON_REL,
65
+ SERVER_NAME as MCP_SERVER_NAME,
66
+ STATE as MCP_STATE,
67
+ readRegistration,
68
+ } from './mcp-registration.mjs';
62
69
  import { matchesCoverageProducer, isCoverageProducerGate } from './coverage-producer.mjs';
63
70
  // How much of the TRACKED tree the changed-line coverage domain can assess at all — the fact that
64
71
  // turns "the checker certifies" into "the checker certifies the assessable minority".
@@ -143,6 +150,13 @@ export const SEVERITIES = Object.freeze({
143
150
  'read-lane.stale': SEVERITY_ATTENTION,
144
151
  'read-lane.missing': SEVERITY_ATTENTION,
145
152
  'state-block': SEVERITY_OPTIONAL,
153
+ // The typed channel. The base arm is an ordinary offer; `.masked` stays an offer too (nothing is
154
+ // broken — the kit simply cannot write through a device node, so the remedy is handed over);
155
+ // `.differing` reports a CONFIGURED declaration that would launch something else, which is the
156
+ // one state here a maintainer must actually look at.
157
+ 'mcp-channel': SEVERITY_OPTIONAL,
158
+ 'mcp-channel.masked': SEVERITY_OPTIONAL,
159
+ 'mcp-channel.differing': SEVERITY_ATTENTION,
146
160
  agents: SEVERITY_OPTIONAL,
147
161
  'family-freshness': SEVERITY_ATTENTION,
148
162
  'adr-store-migration': SEVERITY_ATTENTION,
@@ -210,6 +224,9 @@ export const WHATS = Object.freeze({
210
224
  'read-lane.stale': 'the read-lane is ON but the placed gate hook is stale — an old hook never reads lanes.json, so the lane is silently dark; reseed it',
211
225
  'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
212
226
  'state-block': 'nothing checks the closing state block — a turn that ends on «nothing needed from you», or on a promise it never started, passes unseen',
227
+ 'mcp-channel': "the kit's read-only MCP server is not registered here — path questions and literal searches stay shell strings",
228
+ 'mcp-channel.masked': '{rel} is a {className} here (a sandbox device mask is the usual cause), so the entry to merge is printed instead',
229
+ 'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
213
230
  agents: '{n} read-only subagent(s) not placed (Claude Code) — no shell-free vehicle for that work; the apply PREVIEWS first',
214
231
  'family-freshness': '{parts}',
215
232
  'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
@@ -266,6 +283,7 @@ export const BENEFITS = Object.freeze({
266
283
  'commit-guard': 'integrity — commits require the ONE green --final receipt at the exact staged fingerprint (consented pre-commit arm)',
267
284
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
268
285
  'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
286
+ 'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
269
287
  agents: 'cost and quiet — mechanical work runs on a cheap model, and no vehicle has a shell, so a read-only fan-out cannot flood you with prompts',
270
288
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
271
289
  'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
@@ -311,6 +329,7 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
311
329
  { id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
312
330
  { id: 'state-block', mode: 'state-block-guard', advisorKey: 'state-block' },
313
331
  { id: 'sandbox-masks', mode: 'sandbox-masks', advisorKey: 'sandbox-masks' },
332
+ { id: 'mcp-channel', mode: 'mcp', advisorKey: 'mcp-channel' },
314
333
  { id: 'worktrees-dir', mode: 'worktrees', advisorKey: 'worktrees-dir' },
315
334
  { id: 'family-freshness', mode: 'upgrade', advisorKey: 'family-freshness' },
316
335
  { id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
@@ -1189,7 +1208,7 @@ const readReadLaneToggle = (root, deps) => {
1189
1208
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1190
1209
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1191
1210
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
1192
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook']);
1211
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel']);
1193
1212
 
1194
1213
  const probeSandboxLane = ({ root, deps, add, skip }) => {
1195
1214
  try {
@@ -1384,6 +1403,75 @@ export const probeAdrStore = ({ root, deps, add, skip }) => {
1384
1403
  }
1385
1404
  };
1386
1405
 
1406
+ // The typed-channel item. It asks the READ-ONLY registration leaf and nothing else — importing the
1407
+ // writer would pull the atomic-write core into the advisor's graph (read-graph-purity.test.mjs).
1408
+ // The rendered apply is the mode's FLAGLESS preview on purpose: registering an MCP server means the
1409
+ // client will RUN that command, so the entry is read before it is declared and the `--apply` stays
1410
+ // the maintainer's separate step.
1411
+ const probeMcpChannel = ({ root, deps, add, skip }) => {
1412
+ try {
1413
+ const registration = readRegistration(root, deps);
1414
+ // A target we could not read is never turned into a verdict about what it contains.
1415
+ const assertReadable = (target) => {
1416
+ if (target.state === MCP_STATE.FOREIGN) throw new Error(`${target.rel} is a ${target.className} — refusing to read through it`);
1417
+ if (target.state === MCP_STATE.MALFORMED) throw new Error(`${target.rel} is ${target.reason}`);
1418
+ if (target.state === MCP_STATE.UNREADABLE) throw new Error(`${target.rel} cannot be read (${target.reason})`);
1419
+ };
1420
+ const preview = `node ${q(toolPath('mcp.mjs'))} --cwd ${q(root)}`;
1421
+ // ORDER: the `.mcp.json` half is judged and REPORTED on its own before anything about the
1422
+ // settings half can end the probe. A differing entry is fully observable, and an unreadable
1423
+ // settings file says nothing about it — returning on the settings mask first hid it.
1424
+ assertReadable(registration.mcpJson);
1425
+ const settingsUsable = registration.settings.state !== MCP_STATE.MASKED
1426
+ && registration.settings.state !== MCP_STATE.FOREIGN
1427
+ && registration.settings.state !== MCP_STATE.UNREADABLE
1428
+ && registration.settings.state !== MCP_STATE.MALFORMED;
1429
+ if (registration.mcpJson.differs) {
1430
+ // The remedy is the maintainer's edit either way; the "then run" tail is dropped where that
1431
+ // command could not succeed, so the item never hands over a line that exits 1.
1432
+ const tail = settingsUsable ? `, then run ${preview}` : '';
1433
+ add(
1434
+ 'mcp-channel',
1435
+ fillTemplate(WHATS['mcp-channel.differing'], { server: MCP_SERVER_NAME }),
1436
+ `HAND-APPLY: edit ${q(join(root, MCP_JSON_REL))} → remove or rename the "${MCP_SERVER_NAME}" entry${tail}`,
1437
+ 'mcp-channel.differing',
1438
+ );
1439
+ return;
1440
+ }
1441
+ assertReadable(registration.settings);
1442
+ // The HAND-APPLY arm renders the MODE's own preview, so it may fire only where that command can
1443
+ // actually run — and the writer refuses a masked settings.json outright (it can neither write it
1444
+ // nor merge into what it cannot read). A masked settings half is therefore a stated SKIP: the
1445
+ // completeness this item decides on is unknowable, and offering a command that exits 1 is worse
1446
+ // than saying nothing.
1447
+ if (registration.settings.state === MCP_STATE.MASKED) {
1448
+ skip('mcp-channel', new Error(`${registration.settings.rel} is a ${registration.settings.className} here (a sandbox device mask is the usual cause) — the registration cannot be judged or written from in here; verify it outside the sandbox`));
1449
+ return;
1450
+ }
1451
+ if (registration.mcpJson.state === MCP_STATE.MASKED) {
1452
+ // The kit cannot write through the mask either, so the remedy is HAND-APPLY. But when the
1453
+ // settings half is already complete, the registration was almost certainly made from outside
1454
+ // the sandbox: what cannot be observed becomes a stated SKIP (optimality withheld), never the
1455
+ // same offer again on every single upgrade.
1456
+ if (registration.settings.complete) {
1457
+ skip('mcp-channel', new Error(`${registration.mcpJson.rel} is a ${registration.mcpJson.className} here (a sandbox device mask is the usual cause) and the settings half is already complete — verify the entry outside the sandbox`));
1458
+ return;
1459
+ }
1460
+ add(
1461
+ 'mcp-channel',
1462
+ fillTemplate(WHATS['mcp-channel.masked'], { rel: registration.mcpJson.rel, className: registration.mcpJson.className }),
1463
+ `HAND-APPLY: ${preview}`,
1464
+ 'mcp-channel.masked',
1465
+ );
1466
+ return;
1467
+ }
1468
+ if (registration.registered) return;
1469
+ add('mcp-channel', fillTemplate(WHATS['mcp-channel'], {}), preview);
1470
+ } catch (err) {
1471
+ skip('mcp-channel', err);
1472
+ }
1473
+ };
1474
+
1387
1475
  // ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
1388
1476
  const PROBES = Object.freeze([
1389
1477
  probeVelocityItems,
@@ -1402,6 +1490,7 @@ const PROBES = Object.freeze([
1402
1490
  probeMasksItem,
1403
1491
  probeSandboxLane,
1404
1492
  probeWorktreesDir,
1493
+ probeMcpChannel,
1405
1494
  ]);
1406
1495
 
1407
1496
  export const buildRecommendations = ({ cwd, deps = {} } = {}) => {