@sabaiway/agent-workflow-kit 14.33.0 → 14.35.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/tools/mcp.mjs CHANGED
@@ -22,21 +22,21 @@
22
22
  // • the preflight is READ-ONLY: an absent `.claude/` is a NAMED state, and the dir is created on
23
23
  // `--apply` only (assertCreatableDirSafe mkdirs, so it may not run in a preview);
24
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.
25
+ // repo is exactly that case) is not a failure: the kit writes nothing, exits 0 and names the
26
+ // advisor's mcp-channel item, whose check line re-judges outside the sandbox;
27
27
  // • never `settings.local.json`; never commits.
28
28
  //
29
29
  // The read half lives in mcp-registration.mjs, which the advisor and `uninstall` use — so what is
30
30
  // there and what would be written are computed by one module, never by two that can disagree.
31
31
  //
32
- // Exit codes: 0 done / dry-run (incl. the hand-apply masked state); 1 precondition STOP; 2 usage.
32
+ // Exit codes: 0 done / dry-run (incl. the masked state); 1 precondition STOP; 2 usage.
33
33
  // Dependency-free beyond the kit's own exports, Node >= 22. No side effects on import.
34
34
 
35
35
  import {
36
36
  lstatSync, openSync, closeSync, fstatSync, writeFileSync, ftruncateSync, fsyncSync,
37
37
  renameSync, constants,
38
38
  } from 'node:fs';
39
- import { join, resolve } from 'node:path';
39
+ import { dirname, join, resolve } from 'node:path';
40
40
  import { fileURLToPath } from 'node:url';
41
41
  import { assertCreatableDirSafe, writeContainedFileAtomic } from './atomic-write.mjs';
42
42
  import { isDirectRun } from './direct-run.mjs';
@@ -46,7 +46,6 @@ import {
46
46
  CLAUDE_DIR_REL,
47
47
  ENABLED_KEY,
48
48
  MCP_JSON_REL,
49
- SERVERS_KEY,
50
49
  SERVER_NAME,
51
50
  SETTINGS_REL,
52
51
  STATE,
@@ -54,9 +53,9 @@ import {
54
53
  mergeMcpJson,
55
54
  mergeSettings,
56
55
  readRegistration,
57
- renderFragments,
58
56
  } from './mcp-registration.mjs';
59
57
  import { shellQuoteArg } from './review-state.mjs';
58
+ import { applySlot } from './write-lanes.mjs';
60
59
 
61
60
  const q = shellQuoteArg;
62
61
 
@@ -83,7 +82,8 @@ Default is --dry-run (a preview that prints the exact entry and writes nothing).
83
82
 
84
83
  Without --replace, an existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
85
84
  order is ignored). Where ${MCP_JSON_REL} is a device node, FIFO or socket — an OS sandbox
86
- mask is the usual cause — the entry to merge is printed and nothing is written.
85
+ mask is the usual cause — nothing is written, and the report names the advisor's mcp-channel
86
+ item, which applies the registration by the consent route.
87
87
  Never writes settings.local.json; never commits.`;
88
88
 
89
89
  export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
@@ -105,9 +105,9 @@ const lstatNoFollow = (path, lstat = lstatSync) => {
105
105
  // A target we could not read or parse is never overwritten: a merge over a file whose current
106
106
  // content is unknown is a clobber wearing a merge's name.
107
107
  //
108
- // `maskedAllowed` is TRUE for `.mcp.json` alone. That file has a sanctioned handoff — the kit hands
109
- // over the entry for a human to merge from outside the sandbox — and `settings.json` has none, so a
110
- // mask there is an ordinary refusal rather than a second, unplanned success path.
108
+ // `maskedAllowed` is TRUE for `.mcp.json` alone. That file has a sanctioned route — the advisor's
109
+ // mcp-channel item, whose check line re-judges outside the sandbox — and `settings.json` has none, so
110
+ // a mask there is an ordinary refusal rather than a second, unplanned success path.
111
111
  const assertTargetUsable = (target, { maskedAllowed = false } = {}) => {
112
112
  if (target.state === STATE.MASKED) {
113
113
  if (maskedAllowed) return;
@@ -168,7 +168,7 @@ export const preflightMcp = ({ cwd, replace = false }, deps = {}) => {
168
168
  };
169
169
 
170
170
  // The plan is pure over the registration: what is missing, and the exact body each file would get.
171
- // Both bodies are built even when nothing is written — they ARE the preview and the hand-apply text.
171
+ // Both bodies are built even when nothing is written — they ARE the preview.
172
172
  export const planMcp = (registration) => ({
173
173
  writeMcpJson: !registration.mcpJson.matches,
174
174
  writeSettings: !registration.settings.complete,
@@ -225,9 +225,8 @@ const writeTarget = (root, target, body, deps, stop) => {
225
225
 
226
226
  export const writeMcp = ({ cwd, dryRun = true, replace = false } = {}, deps = {}) => {
227
227
  const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd(), replace }, deps);
228
- const base = { ...preflight, dryRun, wrote: false };
229
- if (preflight.masked) return { ...base, fragments: renderFragments(preflight.registration) };
230
- if (dryRun) return base;
228
+ const base = { ...preflight, dryRun, replace, wrote: false };
229
+ if (preflight.masked || dryRun) return base;
231
230
 
232
231
  const { root, registration, plan } = preflight;
233
232
  const stop = (message) => makeMcpError(MCP_SYMLINK, message);
@@ -286,19 +285,19 @@ const settingsLine = (result) => {
286
285
  return ` - ${SETTINGS_REL}: ${dryRun ? 'would set' : 'set'} ${parts.join(' · ')}`;
287
286
  };
288
287
 
289
- // The hand-apply text. The two halves are worded differently because the kit KNOWS different things
290
- // about them: the settings body is a real merge over content it read, while the `.mcp.json` half is
291
- // the entry ALONE — behind the mask this mode cannot see what that file already declares, and a
292
- // whole-file body pasted as instructed would delete every server it could not see.
288
+ // The masked report names the item that applies the registration by the consent route: its check
289
+ // line, run once more outside the sandbox, reads the real file this mode cannot see through the mask.
290
+ // A run under --replace routes through the differing arm, whose check and apply carry the replace.
293
291
  const maskedReport = (result) => {
294
292
  const target = result.registration.mcpJson;
293
+ const variant = result.replace ? 'mcp-channel.differing' : 'mcp-channel';
294
+ const { check, apply } = applySlot(variant, { toolsDir: dirname(MCP_TOOL), root: result.root });
295
295
  return [
296
296
  // The CLASS is what was observed; the sandbox mask is the usual CAUSE but is not established here.
297
- `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.`,
298
- ` 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):`,
299
- indented(`"${SERVER_NAME}": ${result.fragments.mcpEntry.trimEnd()}`),
300
- ` merge into ${SETTINGS_REL} (that file was observable — and read where present — so this body already carries what is in it):`,
301
- indented(result.fragments.settings),
297
+ `agent-workflow MCP registration — nothing was written: ${MCP_JSON_REL} is a ${target.className} (an OS sandbox device mask is the usual cause).`,
298
+ " the advisor's mcp-channel item applies the registration by the consent route — its check line runs once more outside the sandbox and the apply line runs on the yes given on that result:",
299
+ ` check: ${check}`,
300
+ ` apply: ${apply}`,
302
301
  POSTURE_LINE,
303
302
  HIDDEN_MODE_LINE,
304
303
  ].join(LF);
@@ -55,7 +55,7 @@ import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-re
55
55
  import { probeSandboxMasks, needsMasksApply, patternToRel } from './sandbox-masks.mjs';
56
56
  import { shellQuoteArg } from './review-state.mjs';
57
57
  import { isFinalCapableDeclaration } from './run-gates.mjs';
58
- import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, GATES_REL, LCOV_PRODUCER_KEY } from './gates-declaration.mjs';
58
+ import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, LCOV_PRODUCER_KEY } from './gates-declaration.mjs';
59
59
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
60
60
  // The typed channel's READ-ONLY leaf only — never tools/mcp.mjs, which reaches the write core.
61
61
  import {
@@ -75,16 +75,19 @@ import { CHECKER_CLAIM, INITIAL_ADOPTION_REASON, SOURCE_SIZE_GATE_ID, classifySo
75
75
  // The declared-path resolution + segment containment this item's convergence lane shares with the
76
76
  // autonomy render's allowWrite degrade — ONE leaf, so the two answers cannot drift.
77
77
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
78
- import { resolveGitHooksPath } from './commit-guard.mjs';
78
+ import { readPreCommitState } from './git-hooks.mjs';
79
+ export { resolveGitHooksPath, readPreCommitState, canonicalGuardLineRe, INSTALLER_MARKER_RE } from './git-hooks.mjs';
79
80
  import { loadConfig } from './orchestration-config.mjs';
80
81
  import { homeRelative } from './bridge-state-dirs.mjs';
81
82
  import { sandboxEnabled, HOST_HONORS_QUALIFIER } from './bridge-wiring.mjs';
82
83
  import { assertContainedRealPath } from './fs-safe.mjs';
83
84
  import { nearestExistingDir, worktreesGrantDir, worktreesGrantHoldsHome } from './worktrees.mjs';
84
- import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
85
+ import { preflightCheapAgents, writeCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
86
+ import { ATTRIBUTION_KEY, LEGACY_ATTRIBUTION_KEY, attributionHidesText, effectiveAttribution, readAttributionChoice, wrongTypedKey } from './attribution.mjs';
87
+ import { planGlobalRemoval } from './hide-footprint.mjs';
85
88
  import { NODE_EVIDENCE, NODE_EVIDENCE_SCRIPTS, probeNodeEvidence } from './node-evidence.mjs';
86
89
  // The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
87
- import { surveyExecutorVehicle, readExecutorPosture, executorTemplate, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
90
+ import { surveyExecutorVehicle, readExecutorPosture, executorBodyDiffers, executorTemplate, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
88
91
  // The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
89
92
  // (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
90
93
  import {
@@ -116,6 +119,8 @@ export { ACKS_FILE, ACKS_LANE_KEY, ACKS_WORKTREES_DIR_KEY, ACKS_COVERAGE_DOMAIN_
116
119
  const HERE = dirname(fileURLToPath(import.meta.url));
117
120
  const toolPath = (rel) => join(HERE, rel);
118
121
  const q = shellQuoteArg;
122
+ // A user variant's own step, as the lane leaf words it: the rendered apply of every user item.
123
+ const userStepOf = (variant, root) => applySlot(variant, { toolsDir: HERE, root }).apply;
119
124
  const ABSENT_CODE = 'ENOENT';
120
125
  const ENFORCEMENT_KIT_ROOT = resolve(HERE, '..');
121
126
  const ENFORCEMENT_BUNDLE_ROOT = join(ENFORCEMENT_KIT_ROOT, 'references', 'scripts');
@@ -160,6 +165,7 @@ export const SEVERITIES = Object.freeze({
160
165
  'review-recipe': SEVERITY_ATTENTION,
161
166
  'gates-declaration': SEVERITY_OPTIONAL,
162
167
  'gates-inert': SEVERITY_ATTENTION,
168
+ 'gates-inert.unfillable': SEVERITY_ATTENTION,
163
169
  'gates-inert.no-verification': SEVERITY_ATTENTION,
164
170
  // Both third outcomes are attention, and both therefore block the flow-optimal line MECHANICALLY:
165
171
  // an added item makes the verdict non-null. One reports a pair that is dead on a tree the closed
@@ -194,6 +200,12 @@ export const SEVERITIES = Object.freeze({
194
200
  // names the subagent carrier over a vehicle that cannot carry it — a configured declaration that
195
201
  // is broken, which is what attention means.
196
202
  'executor-vehicle': SEVERITY_ATTENTION,
203
+ // A hand edit of the placed executor is not a broken declaration — the vehicle still carries — so
204
+ // re-deriving it is an offer.
205
+ 'executor-vehicle.differing': SEVERITY_OPTIONAL,
206
+ // The AGENTS.md block is a configured choice the harness does not honour.
207
+ attribution: SEVERITY_ATTENTION,
208
+ 'footprint-global': SEVERITY_OPTIONAL,
197
209
  'family-freshness': SEVERITY_ATTENTION,
198
210
  'adr-store-migration': SEVERITY_ATTENTION,
199
211
  'sandbox-masks': SEVERITY_OPTIONAL,
@@ -246,6 +258,7 @@ export const composeVerdict = ({ attention, optional, skipped }) => {
246
258
  // banned tokens), never a fixture-coverage gamble. A pure-placeholder template marks a WHAT whose
247
259
  // content is fully dynamic (capped at composition by truncation-with-count) — except `profile-gap`, whose
248
260
  // WHAT is never truncated: the leaf's suite pins every composed WHAT under the cap.
261
+ const GATES_INERT_WHAT = 'the declared coverage checker ({id}) has no producer before it — it certifies nothing this run, or reads a stale lcov';
249
262
  export const WHATS = Object.freeze({
250
263
  'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
251
264
  'velocity-core.rule': 'auto mode refuses a harness apply, so work cannot stay in auto mode — the apply ask rule is not seeded',
@@ -258,7 +271,8 @@ export const WHATS = Object.freeze({
258
271
  'sandbox-provision.installable': 'the OS sandbox is unavailable: {reason} — installable via the doctor (consent tuple {tuple})',
259
272
  'review-recipe': '{degraded}',
260
273
  'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
261
- 'gates-inert': 'the declared coverage checker ({id}) has no producer before it — it certifies nothing this run, or reads a stale lcov',
274
+ 'gates-inert': GATES_INERT_WHAT,
275
+ 'gates-inert.unfillable': GATES_INERT_WHAT,
262
276
  'gates-inert.no-verification': "all {n} declared gate(s) are the kit's own checkers — the matrix runs no project-verification command",
263
277
  'gates-inert.producer-unrecognized': 'the coverage checker ({id}) has NO producer declared and none offerable, on a tree the changed-line domain barely reaches',
264
278
  'gates-inert.coverage-domain-narrow': 'certification covers the assessable minority only — {ext} dominate(s) the tracked tree, and the changed-line domain excludes them',
@@ -278,6 +292,9 @@ export const WHATS = Object.freeze({
278
292
  'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
279
293
  agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
280
294
  'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
295
+ 'executor-vehicle.differing': 'the placed executor vehicle differs from the body derived from docs/ai/vehicles.json — a hand edit stands until it is re-derived',
296
+ attribution: 'AGENTS.md says attribution off but .claude/settings.json does not hide the harness trailer and PR text',
297
+ 'footprint-global': 'legacy kit ignore line(s) sit in {file} — they ignore kit paths in every repository here',
281
298
  'family-freshness': '{parts}',
282
299
  'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
283
300
  'sandbox-masks': '{n} sandbox mask(s) clutter git status — the managed exclude block is absent or stale',
@@ -343,6 +360,8 @@ export const BENEFITS = Object.freeze({
343
360
  'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
344
361
  agents: 'cost and quiet — cheap-model mechanical work, no shell on a read-only vehicle (no prompt flood); the executor carries slices you verify',
345
362
  'executor-vehicle': 'carrier readiness — a slot you configured subagent dispatches the subagent it names instead of silently running solo',
363
+ attribution: 'attribution — the harness adds no Co-Authored-By trailer, PR text or session link, as your AGENTS.md block requires',
364
+ 'footprint-global': 'hygiene — the legacy machine-global excludes stop ignoring kit paths in every other repository on this host',
346
365
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
347
366
  'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
348
367
  'sandbox-masks': 'zero clutter — a mask git ignores through the block leaves git status; a mount-only one leaves git add -A and the review fingerprint too',
@@ -352,6 +371,10 @@ export const BENEFITS = Object.freeze({
352
371
  'jev-connect': 'decisions — a typed choice with a confidence in under a second for your code and the agent\'s scripts, once for every project here',
353
372
  'jev-skill': 'the TypeSafe skill for Claude Code, Codex and Antigravity CLI, one pinned verified copy per agent root, from one command you run',
354
373
  });
374
+ // The one arm whose gain differs from its base item's (BENEFITS stays keyed by item key).
375
+ export const VARIANT_BENEFITS = Object.freeze({
376
+ 'executor-vehicle.differing': 'carrier fidelity — the placed executor runs the model and effort docs/ai/vehicles.json names, not a hand edit',
377
+ });
355
378
 
356
379
  // ── the CLOSED opt-in capability registry (OPT-IN-SHIPS-INVISIBLE) ──────────────────────────────
357
380
  // 3.14.0 shipped a capability with every surface an AGENT reads and NO advisor entry, so `upgrade`
@@ -388,6 +411,8 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
388
411
  { id: 'read-lane', mode: 'hook', advisorKey: 'read-lane' },
389
412
  { id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
390
413
  { id: 'enforcement', mode: 'bootstrap', advisorKey: 'enforcement' },
414
+ { id: 'attribution', mode: 'bootstrap', advisorKey: 'attribution' },
415
+ { id: 'footprint-global', mode: 'bootstrap', advisorKey: 'footprint-global' },
391
416
  { id: 'state-block', mode: 'state-block-guard', advisorKey: 'state-block' },
392
417
  { id: 'sandbox-masks', mode: 'sandbox-masks', advisorKey: 'sandbox-masks' },
393
418
  { id: 'mcp-channel', mode: 'mcp', advisorKey: 'mcp-channel' },
@@ -568,7 +593,8 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
568
593
  }
569
594
  }
570
595
  if (degraded.length > 0) {
571
- add('review-recipe', fillTemplate(WHATS['review-recipe'], { degraded: capList(degraded, templateBudget(WHATS['review-recipe'])) }), '/agent-workflow-kit backends');
596
+ add('review-recipe', fillTemplate(WHATS['review-recipe'], { degraded: capList(degraded, templateBudget(WHATS['review-recipe'])) }),
597
+ userStepOf('review-recipe', root));
572
598
  }
573
599
  } catch (err) {
574
600
  skip('review-recipe', err);
@@ -631,8 +657,8 @@ const probeGates = ({ root, deps, add, skip, shared }) => {
631
657
  // gate is not a kit checker. Cause A's apply follows what the FILL can actually do, which the
632
658
  // D-8 placement rule changed: when the checker is the declaration's LAST gate and the project's own
633
659
  // scripts yield an offerable producer, the fill now PLACES that producer before the checker, so the
634
- // remedy is the ordinary consent-gated preview. HAND-APPLY remains exactly where no offerable
635
- // producer exists, or where the checker is not last — there the edit really is the maintainer's,
660
+ // remedy is the ordinary consent-gated preview. Where no offerable producer exists, or the checker
661
+ // is not last, the arm is the user item `gates-inert.unfillable` — the edit is the project's own,
636
662
  // because the fill never reorders entries it did not write.
637
663
  //
638
664
  // An ABSENT or EMPTY declaration belongs to the gates-declaration item; a malformed one throws out
@@ -745,8 +771,9 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
745
771
  add(
746
772
  'gates-inert',
747
773
  fillTemplate(WHATS['gates-inert.producer-unrecognized'], { id }),
748
- `HAND-APPLY: mark the gate that actually writes the lcov with "lcovProducer": true in ${GATES_REL} (references/modes/gates.md names the key), or drop ${id} — and note the fill offers only terminating-class script NAMES, so a recognized body under another name (a "ci" script running node --test) is declared by hand, not marked`,
774
+ userStepOf('gates-inert.producer-unrecognized', root),
749
775
  'gates-inert.producer-unrecognized',
776
+ `${id} has no producer declared and none offerable — the fill offers only terminating-class script names, so a recognized body under another name (a "ci" script running node --test) is declared as its own gate entry, never marked`,
750
777
  );
751
778
  return;
752
779
  }
@@ -768,13 +795,12 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
768
795
  : colliding !== undefined
769
796
  ? `a producer IS offerable here, but its id "${colliding.id}" is already declared, so the fill refuses it as a collision — rename that gate, or repoint it at the producer form`
770
797
  : `no offerable producer exists here, and the fill never reorders entries it did not write`;
771
- add(
772
- 'gates-inert',
773
- fillTemplate(WHATS['gates-inert'], { id }),
774
- checkerIsLast && producer
775
- ? fillPreviewFor(root, [producer.id])
776
- : `HAND-APPLY: declare or MOVE a suite gate carrying the coverage reporters BEFORE ${id} in ${GATES_REL} (references/modes/gates.md names the exact form), or drop ${id} — ${blocked}`,
777
- );
798
+ if (checkerIsLast && producer) {
799
+ add('gates-inert', fillTemplate(WHATS['gates-inert'], { id }), fillPreviewFor(root, [producer.id]));
800
+ return;
801
+ }
802
+ add('gates-inert', fillTemplate(WHATS['gates-inert.unfillable'], { id }), userStepOf('gates-inert.unfillable', root),
803
+ 'gates-inert.unfillable', blocked);
778
804
  return;
779
805
  }
780
806
  // The source-size checker is one of the kit's OWN checkers, and it is deliberately NOT
@@ -867,15 +893,11 @@ const probeSourceSize = ({ root, deps, add, skip }) => {
867
893
  }
868
894
  // The id SQUATTER — the id says the practice is declared, the cmd is not this checker under any
869
895
  // reading. `--adopt` here mints the record and is then refused by the fill on the id collision:
870
- // correct against a squatter, and useless as a RENDERED remedy, so this arm names the two hand
871
- // edits that actually resolve it instead.
896
+ // correct against a squatter, and useless as a RENDERED remedy, so this arm is a user item whose
897
+ // step names the edits that actually resolve it.
872
898
  if (gates.some((gate) => gate.id === SOURCE_SIZE_GATE_ID)) {
873
- add(
874
- 'source-size',
875
- fillTemplate(WHATS['source-size.id-squatter'], {}),
876
- `HAND-APPLY: in ${GATES_REL}, either rename the "${SOURCE_SIZE_GATE_ID}" gate to an id of its own, or repoint its cmd at node ${q(tool)} --check — then re-run recommendations`,
877
- 'source-size.id-squatter',
878
- );
899
+ add('source-size', fillTemplate(WHATS['source-size.id-squatter'], {}), userStepOf('source-size.id-squatter', root),
900
+ 'source-size.id-squatter');
879
901
  return;
880
902
  }
881
903
  add('source-size', fillTemplate(WHATS['source-size'], {}), adoptLine);
@@ -890,27 +912,11 @@ const probeSourceSize = ({ root, deps, add, skip }) => {
890
912
  // alike; an UNMANAGED hook is a loud skip naming the user's own step (never an overwrite offer); a
891
913
  // non-final-capable declaration gets NO offer (an armed guard over a matrix that cannot mint a
892
914
  // receipt would block every commit).
893
- const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
894
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
895
- // Convergence needs the EXACT canonical armed line the installer writes — full-line anchored on
896
- // the kit's OWN resolved guard path. A commented-out line or a lookalike path (fake-commit-guard)
897
- // must never read as armed (that would suppress the offer and leave commits unguarded).
898
- const canonicalGuardLineRe = () => new RegExp(`^node "${escapeRe(toolPath('commit-guard.mjs'))}" --check$`, 'm');
899
- // The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
900
- export const readPreCommitState = (root, deps = {}) => {
901
- const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
902
- if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
903
- const path = join(hooksPath, 'pre-commit');
904
- const hook = (() => {
905
- try {
906
- if (readsAbsent(deps, path)) return null;
907
- return String((deps.readFile ?? readFileSync)(path, 'utf8'));
908
- } catch {
909
- return null;
910
- }
911
- })();
912
- if (hook === null) return { state: 'absent', armed: false };
913
- return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe().test(hook) };
915
+ // The advisor asks git under its own process env; only the commit-guard apply passes the installer's.
916
+ const readHookState = (root, deps) => {
917
+ const hook = readPreCommitState(root, { ...deps, env: undefined });
918
+ if (hook.state === 'unreadable') throw new Error(`${hook.path} is unreadable (${hook.code}) — the pre-commit hook cannot be judged`);
919
+ return hook;
914
920
  };
915
921
  const formsApply = (variant, root) => consoleArgv(variant, { toolsDir: HERE, root }).apply.map(q).join(' ');
916
922
  const probeCommitGuard = ({ root, deps, add, skip }) => {
@@ -931,7 +937,7 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
931
937
  skip('commit-guard', new Error(enforcementAdmits(root, deps) ? COMMIT_GUARD_INSTALLER_VIA_ITEM : COMMIT_GUARD_INSTALLER_OWN_STEP));
932
938
  return;
933
939
  }
934
- const { state, armed } = readPreCommitState(root, deps);
940
+ const { state, armed } = readHookState(root, deps);
935
941
  if (armed || state === 'no-hooks-dir') return; // armed (managed or not), or not a git tree — converged
936
942
  if (state === 'unmanaged') {
937
943
  skip('commit-guard', new Error(COMMIT_GUARD_UNMANAGED_HOOK));
@@ -977,7 +983,7 @@ const enforcementOffer = (root, deps) => {
977
983
  if (readEnforcementNode(destination, lstat) === null) continue;
978
984
  throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
979
985
  }
980
- if (readPreCommitState(root, deps).state === 'unmanaged') throw new Error(ENFORCEMENT_UNMANAGED_HOOK);
986
+ if (readHookState(root, deps).state === 'unmanaged') throw new Error(ENFORCEMENT_UNMANAGED_HOOK);
981
987
  return true;
982
988
  };
983
989
 
@@ -1125,16 +1131,31 @@ const writerBlock = (root, deps) => {
1125
1131
  }
1126
1132
  };
1127
1133
 
1134
+ const HIDDEN_AGENTS_FOLLOW_UP = (root) => `hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`;
1135
+
1128
1136
  const probeExecutorVehicle = ({ root, deps, add, skip }) => {
1137
+ if (renderBrokenExecutor({ root, deps, add, skip })) return;
1138
+ try {
1139
+ if (!executorBodyDiffers(root, deps)) return;
1140
+ const plan = writeCheapAgents({ cwd: root, dryRun: true }, deps);
1141
+ if (!plan.stampOk) throw new Error(`run /agent-workflow-kit upgrade first (deployment stamp ${plan.stamp ?? 'none'}, expected ${EXPECTED_WORKFLOW_VERSION})`);
1142
+ add('executor-vehicle', WHATS['executor-vehicle.differing'], formsApply('executor-vehicle.differing', root),
1143
+ 'executor-vehicle.differing', HIDDEN_AGENTS_FOLLOW_UP(root));
1144
+ } catch (err) {
1145
+ skip('executor-vehicle', err);
1146
+ }
1147
+ };
1148
+
1149
+ const renderBrokenExecutor = ({ root, deps, add, skip }) => {
1129
1150
  try {
1130
1151
  // The VALIDATED reader, as probeReviewRecipe uses it: a schema-invalid config is a stated skip,
1131
1152
  // never an item computed over a shape nothing accepted.
1132
1153
  const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
1133
1154
  const configured = Object.entries(ACTIVITIES).flatMap(([activity, def]) =>
1134
1155
  Object.keys(def.slots).filter((slot) => config?.[activity]?.[slot] === 'subagent'));
1135
- if (configured.length === 0) return;
1156
+ if (configured.length === 0) return false;
1136
1157
  const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(root, deps);
1137
- if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return;
1158
+ if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return false;
1138
1159
  const reason = safeLine(survey.reason ?? '');
1139
1160
  const { posture, reason: settingsReason } = readExecutorPosture(root, deps);
1140
1161
  const stamp = readStamp(join(root, WORKFLOW_STAMP), readFsDeps(deps));
@@ -1158,10 +1179,12 @@ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
1158
1179
  // frontmatter or read-only tools included), so those states need no precondition; every precondition above is a refusal the apply would meet.
1159
1180
  `node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
1160
1181
  'executor-vehicle',
1161
- `${preconditions.length ? `precondition: ${preconditions.join('; ')}; ` : ''}hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
1182
+ `${preconditions.length ? `precondition: ${preconditions.join('; ')}; ` : ''}${HIDDEN_AGENTS_FOLLOW_UP(root)}`,
1162
1183
  );
1184
+ return true;
1163
1185
  } catch (err) {
1164
1186
  skip('executor-vehicle', err);
1187
+ return true;
1165
1188
  }
1166
1189
  };
1167
1190
 
@@ -1314,7 +1337,7 @@ const readReadLaneToggle = (root, deps) => {
1314
1337
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1315
1338
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1316
1339
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
1317
- export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill']);
1340
+ export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill', 'attribution', 'footprint-global']);
1318
1341
 
1319
1342
  // The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
1320
1343
  // zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
@@ -1322,6 +1345,8 @@ export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'read-lane', 'workt
1322
1345
  // spec-check's own census; a recorded decline (the `spec-adoption` ack lane) is the fact that
1323
1346
  // silences the item, and an unreadable store is a stated skip so the flow-optimal line never renders
1324
1347
  // over it. The not-adopted apply is the spec-layer ensure; the decline preview rides the recipe line.
1348
+ // The one opening of every offer's decline alternative: a chat write the agent runs on the user's decline.
1349
+ export const DECLINE_ALTERNATIVE = 'decline alternative (asked apart, before the confirmation; on your decline the agent records it instead of the apply, never after it): ';
1325
1350
  export const probeSpecAdoption = ({ root, deps, add, skip }) => {
1326
1351
  try {
1327
1352
  const survey = surveySpecAdoption(root, deps);
@@ -1337,7 +1362,7 @@ export const probeSpecAdoption = ({ root, deps, add, skip }) => {
1337
1362
  fillTemplate(WHATS['spec-adoption'], {}),
1338
1363
  `node ${q(toolPath('ensure-configs.mjs'))} --reconcile --only ${SPEC_LAYER_ENSURE} --cwd ${q(root)}`,
1339
1364
  'spec-adoption',
1340
- `HAND-APPLY alternative (instead of the apply, never after it): decline the layer by recording it — ${decline}`,
1365
+ `${DECLINE_ALTERNATIVE}${decline}`,
1341
1366
  );
1342
1367
  return;
1343
1368
  }
@@ -1457,13 +1482,12 @@ export const probeAdrStore = ({ root, deps, add, skip }) => {
1457
1482
  try {
1458
1483
  const shape = ADR_LAYOUT_SHAPES[surveyAdrLayoutStrict(root, deps)];
1459
1484
  if (!shape) return; // migrated, or no ADR substrate at all — nothing to offer
1460
- // HAND-APPLY, not the standard lane: the consent flow executes the apply slot against the
1461
- // confirmation given BEFORE the preview, and this crossing requires informed consent AFTER its
1462
- // dry-run. A runnable one-liner here would auto-run a tree-mutating migration on stale consent.
1485
+ // The apply is the bare dry run: consent takes two steps, as profile-gap's does — the first yes
1486
+ // runs it and the plan is relayed, the same line with --apply runs only on a second yes over it.
1463
1487
  add(
1464
1488
  'adr-store-migration',
1465
1489
  fillTemplate(WHATS['adr-store-migration'], { shape }),
1466
- `HAND-APPLY: node ${q(toolPath('migrate-adr-store.mjs'))} --dry-run --cwd ${q(root)} — then re-run with --apply ONLY after showing the plan and getting fresh consent`,
1490
+ `node ${q(toolPath('migrate-adr-store.mjs'))} --dry-run --cwd ${q(root)}`,
1467
1491
  );
1468
1492
  } catch (err) {
1469
1493
  skip('adr-store-migration', err);
@@ -1514,7 +1538,7 @@ export const probeJevConnect = ({ root, deps, add, skip }) => {
1514
1538
  const place = placeOf({ env, platform: host.platform, hostname: host.hostname, container: host.container });
1515
1539
  const decline = `node ${q(toolPath('ack-write.mjs'))} --lane jev-connect --fingerprint ${JEV_CONNECT_DECLINE} --cwd ${q(root)}`;
1516
1540
  add('jev-connect', WHATS['jev-connect'], applySlot('jev-connect', { toolsDir: HERE }).apply, 'jev-connect',
1517
- `${place ? `run the apply in ${place}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${decline}`);
1541
+ `${place ? `run the apply in ${place}; ` : ''}${DECLINE_ALTERNATIVE}${decline}`);
1518
1542
  } catch (err) {
1519
1543
  skip('jev-connect', err);
1520
1544
  }
@@ -1548,7 +1572,7 @@ export const probeJevSkill = ({ root, deps, add, skip }) => {
1548
1572
  .map(({ path, reason }) => `left untouched: ${printable(path)} — ${printable(reason)}; `).join('');
1549
1573
  const record = `node ${q(toolPath('ack-write.mjs'))} --lane jev-skill --fingerprint ${decline} --cwd ${q(root)}`;
1550
1574
  add('jev-skill', what, line, behind.length ? 'jev-skill.earlier' : 'jev-skill',
1551
- `${place ? `run the apply in ${place}; ` : ''}${untouched}${note ? `${note}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${record}`);
1575
+ `${place ? `run the apply in ${place}; ` : ''}${untouched}${note ? `${note}; ` : ''}${DECLINE_ALTERNATIVE}${record}`);
1552
1576
  } catch (err) {
1553
1577
  skip('jev-skill', err);
1554
1578
  }
@@ -1564,9 +1588,48 @@ export const probeProfileGaps = ({ root, deps, add, skip }) => {
1564
1588
  }
1565
1589
  };
1566
1590
 
1591
+ // The attribution item by its table (spec init-project, part in-session-writes): a choice of off the
1592
+ // effective settings do not honour; the writer's preflight runs before it renders, so no rendered check always exits 3.
1593
+ const probeAttribution = ({ root, deps, add, skip }) => {
1594
+ try {
1595
+ if (readAttributionChoice(root, deps) !== 'off') return;
1596
+ const project = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root }).data;
1597
+ const local = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root }).data;
1598
+ const effective = effectiveAttribution(project, local);
1599
+ if (attributionHidesText(effective)) return;
1600
+ preflightVelocityProfile({ cwd: root }, deps);
1601
+ for (const [file, data] of [[SETTINGS_FILE, project], [SETTINGS_LOCAL_FILE, local]]) {
1602
+ const wrong = wrongTypedKey(data);
1603
+ if (wrong) throw new Error(`${file}: ${wrong} — the attribution item cannot judge it`);
1604
+ }
1605
+ const setsText = (value) => value !== null && typeof value === 'object' && ['commit', 'pr'].some((key) => Object.hasOwn(value, key));
1606
+ const localKey = Object.hasOwn(local ?? {}, ATTRIBUTION_KEY) && !attributionHidesText({ [ATTRIBUTION_KEY]: local[ATTRIBUTION_KEY] }) ? ATTRIBUTION_KEY
1607
+ : local?.[LEGACY_ATTRIBUTION_KEY] === true && !setsText(effective[ATTRIBUTION_KEY]) ? LEGACY_ATTRIBUTION_KEY : null;
1608
+ if (localKey) throw new Error(`${SETTINGS_LOCAL_FILE} sets ${localKey} so the harness adds its text — a local-scope override is your own edit`);
1609
+ add('attribution', WHATS.attribution, formsApply('attribution', root));
1610
+ } catch (err) {
1611
+ skip('attribution', err);
1612
+ }
1613
+ };
1614
+
1615
+ // The leftover machine-global block: the one plan the hide reports and the --remove-global mode removes,
1616
+ // asked of git under the home the advisor was given.
1617
+ const probeFootprintGlobal = ({ root, deps, add, skip }) => {
1618
+ try {
1619
+ const home = deps.home ?? deps.jevHost?.home ?? homedir();
1620
+ const plan = (deps.planGlobalRemoval ?? planGlobalRemoval)({ env: { ...process.env, HOME: home }, home }, root, { home });
1621
+ if (plan === null) return;
1622
+ const file = truncatedTo(plan.source, templateBudget(WHATS['footprint-global']));
1623
+ add('footprint-global', fillTemplate(WHATS['footprint-global'], { file }), formsApply('footprint-global', root));
1624
+ } catch (err) {
1625
+ skip('footprint-global', err);
1626
+ }
1627
+ };
1628
+
1567
1629
  // ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
1568
1630
  const PROBES = Object.freeze([
1569
1631
  probeVelocityItems,
1632
+ probeAttribution,
1570
1633
  probeAutonomyItems,
1571
1634
  probeSandboxProvision,
1572
1635
  probeReviewRecipe,
@@ -1582,6 +1645,7 @@ const PROBES = Object.freeze([
1582
1645
  probeFamilyFreshness,
1583
1646
  probeAdrStore,
1584
1647
  probeMasksItem,
1648
+ probeFootprintGlobal,
1585
1649
  probeWorktreesDir,
1586
1650
  probeMcpChannel,
1587
1651
  probeSpecAdoption,
@@ -1604,10 +1668,9 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1604
1668
  // which item fired, and only this field says which ARM of it did — so a consumer (the pre-publish
1605
1669
  // smoke asserting a specific false-green never returns) can assert the exact outcome instead of
1606
1670
  // pattern-matching prose that is free to be reworded.
1607
- // `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
1608
- // the capped WHAT and does NOT belong in the pure-command apply (the bridge-tier missing-entries
1609
- // line; the worktrees-dir ack fallback; the
1610
- // agents hidden-mode reconcile follow-up). Single-line like apply; absent for every other item.
1671
+ // `detail` (optional) is an extra rendered `recipe:` line — an item's own facts or its alternative,
1672
+ // TOO LONG for the capped WHAT and outside the pure-command apply (such as the bridge-tier
1673
+ // missing-entries line or the worktrees-dir ack fallback). Single-line like apply; absent otherwise.
1611
1674
  // Returns whether the item really RENDERED. One probe's disposition depends on another's having
1612
1675
  // spoken (the marker-stale ⟷ read-lane.stale precedence), and "the conditions still look right"
1613
1676
  // is not the same fact as "an item exists" — the shape backstop can refuse, and a condition read
@@ -1629,7 +1692,7 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1629
1692
  home: deps.jevHost?.home ?? deps.home ?? '',
1630
1693
  platform: deps.jevHost?.platform ?? deps.platform,
1631
1694
  });
1632
- items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key],
1695
+ items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: VARIANT_BENEFITS[variant] ?? BENEFITS[key],
1633
1696
  check: slot?.check ?? null, apply: slot?.apply ?? apply, detail });
1634
1697
  return true;
1635
1698
  };
@@ -1697,9 +1760,9 @@ Usage:
1697
1760
 
1698
1761
  Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
1699
1762
  one composed verdict line opens every non-optimal render, then per item {severity · what is
1700
- sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (the bridge-tier
1701
- missing-entries line, the worktrees-dir ack fallback, or the agents hidden-mode reconcile
1702
- follow-up) · the harness check line · the exact consent-gated apply one-liner}. --cwd is
1763
+ sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (an item's own
1764
+ facts or its alternative, such as the bridge-tier missing-entries line or
1765
+ the worktrees-dir ack fallback) · the harness check line · the exact consent-gated apply one-liner}. --cwd is
1703
1766
  REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
1704
1767
  section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
1705
1768
  stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
@@ -1710,8 +1773,8 @@ allow rules, <wrapper> * exclusions (codex-exec * where an execute slot delegate
1710
1773
  sandbox.network.allowedDomains and state dirs in sandbox.filesystem.allowWrite. Where the host
1711
1774
  honors the settings sandbox keys, an excluded wrapper runs outside the sandbox, every sandboxed
1712
1775
  command of the project can reach each host, and every sandboxed command can write each state dir;
1713
- plan/diff review modes and delegated execution keep their prompt. Items marked HAND-APPLY stay
1714
- the user's own step.
1776
+ plan/diff review modes and delegated execution keep their prompt.
1777
+ A user item stays the user's own step.
1715
1778
 
1716
1779
  Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
1717
1780
  rendered (items or empty); 1 error; 2 usage.`;
@@ -40,7 +40,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
40
40
  'note-refreshed',
41
41
  'refreshed',
42
42
  'regenerated',
43
- 're-derived',
43
+ 'pending',
44
44
  'already-current',
45
45
  'customized-preserved',
46
46
  'malformed-preserved',