@event4u/agent-config 14.19.0 → 14.21.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 (161) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +351 -156
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/agent-handoff.md +42 -41
  8. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  9. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  10. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  11. package/dist/agent-src/commands/analyze.md +12 -7
  12. package/dist/agent-src/commands/feature/plan.md +55 -16
  13. package/dist/agent-src/commands/feature/refactor.md +8 -3
  14. package/dist/agent-src/commands/humanize.md +48 -4
  15. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
  17. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  18. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  19. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  20. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  21. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  22. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  23. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  24. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  25. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  26. package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
  27. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  28. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  29. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  30. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  31. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  32. package/dist/agent-src/templates/features.md +5 -0
  33. package/dist/agent-src/templates/roadmaps.md +29 -1
  34. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  35. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  36. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  37. package/dist/cli/mcp/dispatch.js +10 -0
  38. package/dist/cli/mcp/stdio.js +33 -2
  39. package/dist/cli/mcp/telemetry.js +227 -0
  40. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  41. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
  42. package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
  43. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  44. package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
  45. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  46. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  47. package/dist/cli-delegate/cmd_conformance.js +88 -110
  48. package/dist/cli-delegate/cmd_doctor.js +3 -3
  49. package/dist/cli-delegate/cmd_export.js +2 -2
  50. package/dist/cli-delegate/cmd_handoff.js +42 -12
  51. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  52. package/dist/cli-delegate/cmd_prune.js +2 -2
  53. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  54. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  55. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  56. package/dist/cli-delegate/cmd_sync.js +2 -2
  57. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  58. package/dist/cli-delegate/cmd_validate.js +2 -2
  59. package/dist/discovery/deprecation-report.md +1 -1
  60. package/dist/discovery/discovery-manifest.json +62 -31
  61. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  62. package/dist/discovery/discovery-manifest.summary.md +3 -3
  63. package/dist/discovery/dormancy-report.md +1 -1
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +7 -6
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +6 -5
  68. package/dist/hooks/dispatch.js +3801 -2803
  69. package/dist/install/install.mjs +396 -168
  70. package/dist/mcp/registry-manifest.json +3 -3
  71. package/dist/mcp/server.json +2 -2
  72. package/dist/mcp/server.mjs +809 -180
  73. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  74. package/dist/scripts/ai_council/seat_policy.js +106 -0
  75. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  76. package/dist/scripts/mcp_server/telemetry.js +148 -0
  77. package/docs/guidelines/agent-infra/naming.md +1 -1
  78. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  79. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  80. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  81. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  82. package/llms.txt +14 -14
  83. package/package.json +1 -1
  84. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  85. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  86. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  87. package/src/config/assurance-capability-registry.json +9 -0
  88. package/src/config/gate-coverage.yml +55 -0
  89. package/src/config/gate-violation-baselines.json +18 -6
  90. package/src/config/release-gate-locality.yml +4 -3
  91. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  92. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  93. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  94. package/src/scripts/_lib/host_capability.ts +76 -1
  95. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  96. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  97. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  98. package/src/scripts/_lib/orchestration_record.ts +23 -1
  99. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  100. package/src/scripts/_lib/release_highlights.ts +19 -211
  101. package/src/scripts/_lib/release_material.ts +27 -77
  102. package/src/scripts/_lib/release_position.ts +96 -0
  103. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  104. package/src/scripts/_lib/security_lint.ts +179 -7
  105. package/src/scripts/_lib/self_repair.ts +141 -6
  106. package/src/scripts/_lib/self_repair_store.ts +273 -7
  107. package/src/scripts/_lib/structured_ask.ts +117 -0
  108. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  109. package/src/scripts/_lib/tty_prompt.ts +76 -0
  110. package/src/scripts/ai_council/api_transport.ts +163 -0
  111. package/src/scripts/ai_council/clients.ts +58 -63
  112. package/src/scripts/ai_council/config.ts +13 -0
  113. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  114. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  115. package/src/scripts/ai_council/seat_policy.ts +137 -0
  116. package/src/scripts/ai_council/status_surface.ts +79 -0
  117. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  118. package/src/scripts/ai_tells_rules.ts +446 -5
  119. package/src/scripts/ask_block_census.ts +499 -0
  120. package/src/scripts/bench_humanizer_eval.ts +264 -40
  121. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  122. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  123. package/src/scripts/check_branch_work_committed.ts +234 -0
  124. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  125. package/src/scripts/check_pack_size.ts +321 -3
  126. package/src/scripts/check_release_highlights.ts +0 -157
  127. package/src/scripts/council_cli.ts +18 -27
  128. package/src/scripts/detect_ai_tells.ts +142 -20
  129. package/src/scripts/handoff_context_hook.ts +54 -7
  130. package/src/scripts/harvest_reference_tokens.ts +698 -0
  131. package/src/scripts/hook_manifest.json +1 -1
  132. package/src/scripts/hook_manifest.yaml +31 -1
  133. package/src/scripts/hooks/concern_registry.ts +2 -0
  134. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  135. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  136. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  137. package/src/scripts/install-hooks.sh +52 -0
  138. package/src/scripts/install.ts +4 -4
  139. package/src/scripts/lint_agent_security.ts +316 -25
  140. package/src/scripts/lint_confusables.ts +42 -6
  141. package/src/scripts/lint_handoffs.ts +0 -132
  142. package/src/scripts/lint_hidden_unicode.ts +49 -7
  143. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  144. package/src/scripts/lint_mcp_config_security.ts +42 -6
  145. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  146. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  147. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  148. package/src/scripts/mcp_server/resources.ts +41 -0
  149. package/src/scripts/mcp_server/telemetry.ts +19 -4
  150. package/src/scripts/migrate_command_suggestions.ts +1 -1
  151. package/src/scripts/orchestration_record.ts +12 -1
  152. package/src/scripts/probe_unblocked_ask.ts +135 -2
  153. package/src/scripts/release.ts +50 -95
  154. package/src/scripts/release_drill.ts +6 -10
  155. package/src/scripts/release_env.ts +1 -0
  156. package/src/scripts/release_publication.ts +40 -31
  157. package/src/scripts/self_repair_cli.ts +31 -1
  158. package/src/scripts/self_review_gate.ts +409 -15
  159. package/src/scripts/skill_scout.ts +69 -0
  160. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  161. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Ask before park — the inline `blocked-by:` marker, and the decision that must
3
+ * precede writing one.
4
+ *
5
+ * road-to-asked-not-parked Phase 3. `terminal-states.md` defines `blocked` to
6
+ * include "a decision only the user can make", and `roadmap-process-loop.md`
7
+ * writes and reads the inline marker with NO ask in between. The result is a
8
+ * decision the user never sees: it is filed, correctly labelled, in a file
9
+ * nobody is watching.
10
+ *
11
+ * This module is the executable half of the fix. It answers two questions and
12
+ * nothing else:
13
+ *
14
+ * 1. Given a context — is the host interactive, was the question put, was it
15
+ * answered, did it time out — what does the run DO? ({@link decideAsk})
16
+ * 2. What does the marker then look like? ({@link renderBlockedByMarker})
17
+ *
18
+ * The obligations it encodes:
19
+ *
20
+ * - On an interactive host a user-decision block is PUT TO THE USER FIRST. Only
21
+ * a decline, a timeout, or a non-interactive context writes a marker.
22
+ * - A TIMEOUT IS NOT CONSENT, and neither is a non-interactive context. Where
23
+ * the ask cannot be completed the run ends `approval-required` — a state
24
+ * `terminal-states.md` already defines — carrying the unanswered question
25
+ * verbatim and its conservative default UNADOPTED. Nothing here ever returns
26
+ * the default as an answer.
27
+ * - The marker records WHETHER the question was put, and when it was not, WHY.
28
+ * `asked: no` with no reason is not a legal marker: the whole point is that a
29
+ * later reader can tell a decision that was declined from one that was never
30
+ * offered.
31
+ */
32
+ import type { RunTerminalState } from './outcome_vocabularies.js';
33
+
34
+ /** A parsed inline `<!-- blocked-by: … -->` marker. */
35
+ export interface BlockedByMarker {
36
+ /** The blocker id the marker points at. */
37
+ readonly id: string;
38
+ /** Whether the question was put to the user. `null` when the marker predates the field. */
39
+ readonly asked: boolean | null;
40
+ /** Why it was not put. Required whenever `asked` is `false`. */
41
+ readonly reason: string | null;
42
+ }
43
+
44
+ /**
45
+ * The marker grammar, extended with an optional `| asked: …` tail.
46
+ *
47
+ * The tail is OPTIONAL in the grammar and required by the linter for the
48
+ * user-decision class only. Parsing and policy are separated on purpose: a
49
+ * parser that rejects the old shape would make every archived roadmap
50
+ * unreadable, which is a migration, not a fix.
51
+ */
52
+ export const BLOCKED_BY_MARKER_RE =
53
+ /<!--[ \t]*blocked-by:[ \t]*([a-z0-9-]+)[ \t]*(?:\|[ \t]*asked:[ \t]*(yes|no)[ \t]*(?:[—-][ \t]*([^>]*?))?[ \t]*)?-->/i;
54
+
55
+ /** Parse one marker, or `null` when the text carries none. */
56
+ export function parseBlockedByMarker(text: string): BlockedByMarker | null {
57
+ const m = BLOCKED_BY_MARKER_RE.exec(text);
58
+ if (m === null) {
59
+ return null;
60
+ }
61
+ const asked = m[2] === undefined ? null : m[2].toLowerCase() === 'yes';
62
+ const reason = m[3] === undefined || m[3].trim() === '' ? null : m[3].trim();
63
+ return { id: (m[1] as string).toLowerCase(), asked, reason };
64
+ }
65
+
66
+ /** Render a marker. Throws on `asked: no` with no reason — the state the field exists to prevent. */
67
+ export function renderBlockedByMarker(marker: BlockedByMarker): string {
68
+ if (marker.asked === null) {
69
+ return `<!-- blocked-by: ${marker.id} -->`;
70
+ }
71
+ if (marker.asked) {
72
+ return `<!-- blocked-by: ${marker.id} | asked: yes -->`;
73
+ }
74
+ const reason = (marker.reason ?? '').trim();
75
+ if (reason === '') {
76
+ throw new Error(
77
+ `blocked-by marker for '${marker.id}': asked: no needs a reason — ` +
78
+ 'a marker that does not say why the question was not put is the parking ' +
79
+ 'lot this field exists to end',
80
+ );
81
+ }
82
+ return `<!-- blocked-by: ${marker.id} | asked: no — ${reason} -->`;
83
+ }
84
+
85
+ /** What the run knows when it reaches a step blocked on a user decision. */
86
+ export interface AskContext {
87
+ /** The blocker id. */
88
+ readonly id: string;
89
+ /** The question, in the words it would be put in. */
90
+ readonly question: string;
91
+ /** The conservative option, named so a reader can see what was NOT adopted. */
92
+ readonly conservativeDefault: string;
93
+ /** Can this run reach the user at all? */
94
+ readonly interactive: boolean;
95
+ /** Why not, when it cannot. Required whenever `interactive` is `false`. */
96
+ readonly nonInteractiveReason?: string | undefined;
97
+ /** The user's answer, when one arrived. */
98
+ readonly answer?: string | undefined;
99
+ /** The ask was put and no answer came back. */
100
+ readonly timedOut?: boolean | undefined;
101
+ }
102
+
103
+ /** What the run does next. */
104
+ export interface AskDecision {
105
+ /**
106
+ * `ask` — put the question and come back with an answer or a timeout.
107
+ * `answered` — an answer exists; nothing is parked.
108
+ * `park` — write the marker; the run may continue with other steps.
109
+ * `approval-required` — the run stops and hands the unanswered question back.
110
+ */
111
+ readonly action: 'ask' | 'answered' | 'park' | 'approval-required';
112
+ /** The terminal state to report, when this decision ends the run. */
113
+ readonly terminal: RunTerminalState | null;
114
+ /** The marker to write, when one is written. */
115
+ readonly marker: string | null;
116
+ /** The text the run puts in front of the user. Never a silent default. */
117
+ readonly surfaced: string | null;
118
+ /** Whether the conservative default was adopted. ALWAYS false — see the module header. */
119
+ readonly defaultAdopted: false;
120
+ }
121
+
122
+ /**
123
+ * Decide, for one user-decision block, whether to ask, park, or stop.
124
+ *
125
+ * Order matters: an ANSWER wins over everything, then a timeout, then a
126
+ * non-interactive context, then the interactive path. A timeout is checked
127
+ * before interactivity so a run that asked and got nothing is never quietly
128
+ * re-classified as "could not ask".
129
+ */
130
+ export function decideAsk(ctx: AskContext): AskDecision {
131
+ const unanswered =
132
+ `${ctx.question}\n\nConservative option (NOT adopted): ${ctx.conservativeDefault}`;
133
+
134
+ if (ctx.answer !== undefined && ctx.answer.trim() !== '') {
135
+ return {
136
+ action: 'answered',
137
+ terminal: null,
138
+ marker: null,
139
+ surfaced: null,
140
+ defaultAdopted: false,
141
+ };
142
+ }
143
+
144
+ if (ctx.timedOut === true) {
145
+ return {
146
+ action: 'approval-required',
147
+ terminal: 'approval-required',
148
+ marker: renderBlockedByMarker({
149
+ id: ctx.id,
150
+ asked: true,
151
+ reason: null,
152
+ }),
153
+ surfaced: unanswered,
154
+ defaultAdopted: false,
155
+ };
156
+ }
157
+
158
+ if (!ctx.interactive) {
159
+ const why = (ctx.nonInteractiveReason ?? '').trim();
160
+ return {
161
+ action: 'park',
162
+ terminal: null,
163
+ marker: renderBlockedByMarker({
164
+ id: ctx.id,
165
+ asked: false,
166
+ reason: why === '' ? 'non-interactive context, reason not recorded' : why,
167
+ }),
168
+ surfaced: unanswered,
169
+ defaultAdopted: false,
170
+ };
171
+ }
172
+
173
+ return {
174
+ action: 'ask',
175
+ terminal: null,
176
+ marker: null,
177
+ surfaced: ctx.question,
178
+ defaultAdopted: false,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * The user declined to decide. That IS an answer about the ask — the question
184
+ * was put — so the marker records `asked: yes` and the decline is the reason
185
+ * the step stays blocked.
186
+ */
187
+ export function parkAfterDecline(id: string): string {
188
+ return renderBlockedByMarker({ id, asked: true, reason: null });
189
+ }
@@ -67,6 +67,50 @@ export interface HostCapabilityManifest {
67
67
  * stop-loss behaviour — loudly, never silently.
68
68
  */
69
69
  worker_respawn: boolean;
70
+ /**
71
+ * The host exposes its own structured-ask tool — a native question picker
72
+ * the agent can call instead of rendering a numbered-options block as prose.
73
+ *
74
+ * A STRICT BOOLEAN, and the per-host detail deliberately lives elsewhere.
75
+ * The source draft for road-to-asked-not-parked proposed a nested object
76
+ * carrying the tool name and the question/option ceilings; `asBool` below
77
+ * coerces every non-`true` value to `false`, so such an object would
78
+ * normalize silently to nothing. The shape lives in
79
+ * `_lib/structured_ask.ts` (`STRUCTURED_ASK_SHAPES`), which a caller reads
80
+ * explicitly; this field answers only whether there is anything to read.
81
+ *
82
+ * `false` is the safe default on purpose: an unknown host degrades to a
83
+ * text ask, and the agent never fires a tool call into a host that has no
84
+ * such tool.
85
+ */
86
+ structured_ask: boolean;
87
+ /**
88
+ * The host reads a PROJECT-scope MCP config file (`.mcp.json` or its
89
+ * per-host equivalent) and starts the servers it names.
90
+ *
91
+ * Added because nothing in this tree could answer it: the installer writes
92
+ * `.mcp.json` for `claude-code` alone while writing other hosts' config
93
+ * files for hooks, and no code could say whether that omission was a
94
+ * measured absence or an unasked question.
95
+ *
96
+ * Same discipline as `worker_respawn`: `true` only once OBSERVED on a host,
97
+ * never inferred from the fact that a host supports MCP at all — a host may
98
+ * read a user-scope config and ignore a project one, which is precisely the
99
+ * distinction a consumer needs and the one an inference would erase.
100
+ */
101
+ reads_project_mcp_config: boolean;
102
+ /**
103
+ * The host requires a human action after installation before the MCP server
104
+ * is live — an approval dialog, a restart, a manual enable.
105
+ *
106
+ * `true` is the SAFE default here, and it is the opposite of every other
107
+ * field in this manifest. The others answer "can the host do X", where an
108
+ * unknown must not read as a capability. This one answers "is there a step
109
+ * left for the user", where an unknown must not read as "nothing to do":
110
+ * an install that silently omits a required step leaves a server nobody
111
+ * started and no message saying so.
112
+ */
113
+ mcp_needs_manual_activation: boolean;
70
114
  }
71
115
 
72
116
  /** Safe default — unknown host assumes no subagent primitive. */
@@ -78,6 +122,11 @@ const SAFE_DEFAULT: HostCapabilityManifest = {
78
122
  separate_quota_pool: false,
79
123
  agent_teams: false,
80
124
  worker_respawn: false,
125
+ structured_ask: false,
126
+ reads_project_mcp_config: false,
127
+ // `true` because the safe answer here is "there is still a step", not
128
+ // "there is nothing to do" — see the field's own note.
129
+ mcp_needs_manual_activation: true,
81
130
  };
82
131
 
83
132
  /** Coerce one field: only a strict boolean `true` survives; everything else is `false`. */
@@ -106,6 +155,12 @@ export function normalizeHostManifest(input: unknown): HostCapabilityManifest {
106
155
  separate_quota_pool: asBool(src.separate_quota_pool),
107
156
  agent_teams: asBool(src.agent_teams),
108
157
  worker_respawn: asBool(src.worker_respawn),
158
+ structured_ask: asBool(src.structured_ask),
159
+ reads_project_mcp_config: asBool(src.reads_project_mcp_config),
160
+ // Inverted coercion, matching the inverted default: only an explicit
161
+ // `false` clears the residual, so an absent or malformed value keeps
162
+ // the manual step visible instead of silently dropping it.
163
+ mcp_needs_manual_activation: src.mcp_needs_manual_activation !== false,
109
164
  };
110
165
  }
111
166
 
@@ -179,9 +234,26 @@ export function normalizeHostManifest(input: unknown): HostCapabilityManifest {
179
234
  *
180
235
  * `agent_teams` — out of scope for a row by construction; see its own field
181
236
  * doc comment and the protocol section.
237
+ *
238
+ * `structured_ask: false` — OBSERVED ABSENT, not never-looked, and the
239
+ * difference is why it is written rather than omitted. claude (Claude Code
240
+ * 2.1.263, Opus 5 1M session, 2026-09-07). The host delivers the session's
241
+ * tool surface at session start; that surface carried no structured-ask tool
242
+ * under any name `_lib/structured_ask.ts` matches, and no per-host shape is
243
+ * recorded for it in `STRUCTURED_ASK_SHAPES`. Artefact:
244
+ * `agents/evidence/analysis/structured-ask-host-observation-2026-09.md`. code-comment-allow provenance-comment -- the observation protocol declares a row inadmissible without its artefact citation, so this pointer is part of the contract the row satisfies, not evidence duplicated from a roadmap
245
+ * The observation is about the delivered surface of one session on one
246
+ * version; it is not a claim that the vendor ships no such tool anywhere,
247
+ * and a later session observing one writes the `true` row over it.
248
+ *
249
+ * NO ROW ANYWHERE SETS THE STRUCTURED-ASK FIELD TRUE, and the observation
250
+ * protocol above is the only path to the first one that does. A vendor's
251
+ * documentation is not that path — and the phrasing here avoids the literal
252
+ * key-value pair on purpose, because the roadmap step that ships this field
253
+ * verifies the absence with a grep for exactly that string.
182
254
  */
183
255
  const HOST_CAPABILITY_REGISTRY: Readonly<Record<string, Partial<HostCapabilityManifest>>> = {
184
- claude: { subagent_spawn: true, parallel_spawn: true },
256
+ claude: { subagent_spawn: true, parallel_spawn: true, structured_ask: false },
185
257
  };
186
258
 
187
259
  /**
@@ -287,6 +359,9 @@ const CAPABILITY_FIELDS: readonly CapabilityField[] = [
287
359
  'separate_quota_pool',
288
360
  'agent_teams',
289
361
  'worker_respawn',
362
+ 'structured_ask',
363
+ 'reads_project_mcp_config',
364
+ 'mcp_needs_manual_activation',
290
365
  ];
291
366
 
292
367
  /**
@@ -34,6 +34,7 @@ import * as fs from 'node:fs';
34
34
  import * as path from 'node:path';
35
35
 
36
36
  import type { WiringCheck } from './runtime_wiring_checks.js';
37
+ import { TOOL_TO_HOST, residualReport } from './mcp_consent_residual.js';
37
38
 
38
39
  /**
39
40
  * Root agent-instruction files, as a multi-vendor ecosystem convention rather
@@ -477,11 +478,41 @@ export function checkOverrideSet(projectRoot: string): WiringCheck {
477
478
  };
478
479
  }
479
480
 
481
+ /**
482
+ * Step 3.2 — what remains non-automatic per host, and where that came from.
483
+ *
484
+ * Belongs to reach rather than to a doctor-local check because that is what it
485
+ * measures: the gap between "installed" and "actually running". A residual is
486
+ * exactly the distance the install did not cover.
487
+ *
488
+ * Reports the residual, never a checklist of what worked — a list of successes
489
+ * is noise a reader has to filter, while a named residual is the one thing they
490
+ * can act on. An UNRECORDED host warns too, because "nobody checked" must not
491
+ * read as "nothing to do".
492
+ */
493
+ export function checkMcpConsentResidual(): WiringCheck {
494
+ const id = 'mcp-consent-residual';
495
+ const lines = residualReport(Object.keys(TOOL_TO_HOST));
496
+ if (lines.length === 0) {
497
+ return { id, status: 'ok', message: 'no MCP consent residual recorded for any known host', remedy: '' };
498
+ }
499
+ const actionable = lines.filter((l) => l.state === 'residual');
500
+ return {
501
+ id,
502
+ status: 'warn',
503
+ message: lines.map((l) => l.text).join(' · '),
504
+ remedy:
505
+ actionable.length > 0
506
+ ? 'complete the step named above on each host you use'
507
+ : 'record an observation for the unchecked hosts, or ignore if you do not use them',
508
+ };
509
+ }
510
+
480
511
  // ---------------------------------------------------------------------------
481
512
  // Wiring — the shape `cmd_doctor` consumes
482
513
  // ---------------------------------------------------------------------------
483
514
 
484
- export const REACH_CHECK_IDS = ['instruction-path-reach', 'version-axis', 'override-set'] as const;
515
+ export const REACH_CHECK_IDS = ['instruction-path-reach', 'version-axis', 'override-set', 'mcp-consent-residual'] as const;
485
516
 
486
517
  export function reachRunners(opts: {
487
518
  projectRoot: string;
@@ -491,5 +522,6 @@ export function reachRunners(opts: {
491
522
  'instruction-path-reach': () => checkInstructionPaths(opts.projectRoot),
492
523
  'version-axis': () => checkVersionAxis(opts.projectRoot, opts.resolvableVersion),
493
524
  'override-set': () => checkOverrideSet(opts.projectRoot),
525
+ 'mcp-consent-residual': () => checkMcpConsentResidual(),
494
526
  };
495
527
  }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
+ import { probeHostCapabilities } from './host_capability.js';
18
19
 
19
20
  /** `install.ts`'s deploy tuple: [written, skipped, status, paths]. */
20
21
  export type DeployTuple = [number, number, string, string[]];
@@ -35,14 +36,43 @@ export type MergeJsonFile = (
35
36
  * installer end up with the same entry rather than two that drift.
36
37
  */
37
38
  export const MCP_SERVER_KEY = 'agent-config';
38
- export const MCP_BRIDGE_ENTRY = {
39
- mcpServers: {
40
- [MCP_SERVER_KEY]: {
41
- command: 'npx',
42
- args: ['-y', '@event4u/agent-config', 'mcp-server'],
39
+ export const MCP_PACKAGE_NAME = '@event4u/agent-config';
40
+
41
+ /**
42
+ * The entry, pinned to the version the installer is running from.
43
+ *
44
+ * Unpinned, `npx -y @event4u/agent-config` resolves the `latest` dist-tag on
45
+ * every server start, so the server a consumer runs is whatever the registry
46
+ * served most recently — not the one their installer approved, and not the one
47
+ * their lockfile records. The pin makes those the same artefact.
48
+ *
49
+ * A version that cannot be read is left unpinned rather than guessed: an
50
+ * invented specifier would fail to resolve at server start, which is a worse
51
+ * failure than the drift it was meant to close.
52
+ */
53
+ export function mcpBridgeEntry(packageRoot: string): Record<string, unknown> {
54
+ const version = readPackageVersion(packageRoot);
55
+ const spec = version === null ? MCP_PACKAGE_NAME : `${MCP_PACKAGE_NAME}@${version}`;
56
+ return {
57
+ mcpServers: {
58
+ [MCP_SERVER_KEY]: {
59
+ command: 'npx',
60
+ args: ['-y', spec, 'mcp-server'],
61
+ },
43
62
  },
44
- },
45
- };
63
+ };
64
+ }
65
+
66
+ /** The manifest `version`, or `null` when it is absent or unreadable. */
67
+ export function readPackageVersion(packageRoot: string): string | null {
68
+ try {
69
+ const raw = fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8');
70
+ const v = (JSON.parse(raw) as { version?: unknown }).version;
71
+ return typeof v === 'string' && v.trim() !== '' ? v : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
46
76
 
47
77
  /**
48
78
  * `.mcp.json` — the project-scope MCP config Claude Code reads.
@@ -66,9 +96,191 @@ export const MCP_BRIDGE_ENTRY = {
66
96
  */
67
97
  export function makeEnsureMcpBridge(
68
98
  mergeJsonFile: MergeJsonFile,
69
- ): (projectRoot: string, force: boolean) => Record<string, unknown>[] {
70
- return (projectRoot, force) =>
71
- mergeJsonFile(path.join(projectRoot, '.mcp.json'), MCP_BRIDGE_ENTRY, force, '.mcp.json');
99
+ ): (projectRoot: string, force: boolean, packageRoot?: string) => Record<string, unknown>[] {
100
+ return (projectRoot, force, packageRoot = projectRoot) =>
101
+ mergeJsonFile(
102
+ path.join(projectRoot, '.mcp.json'),
103
+ mcpBridgeEntry(packageRoot),
104
+ force,
105
+ '.mcp.json',
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Where each host reads a PROJECT-scope MCP config, and which capability-manifest
111
+ * row gates writing to it (roadmap step 3.3).
112
+ *
113
+ * Keyed by the installer's `--tools` id; `hostId` is the SEPARATE
114
+ * platform identifier the capability manifest is keyed by (`claude`, not
115
+ * `claude-code`). Conflating the two namespaces is the mistake
116
+ * `host_capability.ts` warns about in its own registry doc, so the mapping is
117
+ * written out rather than derived.
118
+ *
119
+ * Presence in this table is NOT permission to write. It records where the file
120
+ * would go IF the axis said the host reads one; the axis decides, and it says
121
+ * `true` only once someone observed it.
122
+ */
123
+ export const MCP_PROJECT_CONFIG: Readonly<Record<string, { hostId: string; relPath: string }>> = {
124
+ 'claude-code': { hostId: 'claude', relPath: '.mcp.json' },
125
+ cursor: { hostId: 'cursor', relPath: '.cursor/mcp.json' },
126
+ 'gemini-cli': { hostId: 'gemini', relPath: '.gemini/settings.json' },
127
+ };
128
+
129
+ /** One host to register, resolved from the `--tools` set and the capability axis. */
130
+ export interface McpRegistrationTarget {
131
+ toolId: string;
132
+ hostId: string;
133
+ relPath: string;
134
+ }
135
+
136
+ /** Injectable capability reader — the installer passes the real probe. */
137
+ export type ReadsProjectMcpConfig = (hostId: string) => boolean;
138
+
139
+ /**
140
+ * The hosts an install may register the server on, beyond `claude-code`.
141
+ *
142
+ * TWO gates, both required. `--tools` is the consumer's selection; the
143
+ * capability axis is the evidence. A host is registered only where BOTH say so,
144
+ * so a config file this package already writes for hooks does not silently
145
+ * acquire an MCP entry on the strength of the file existing.
146
+ *
147
+ * `claude-code` is excluded here on purpose: its `.mcp.json` registration
148
+ * predates the axis and is written unconditionally by `makeEnsureMcpBridge`.
149
+ * Routing it through the axis would DELETE a working registration, because the
150
+ * axis reports `false` for every host today — "nobody answered", not "checked
151
+ * and absent". This function adds reach; it never removes any.
152
+ *
153
+ * MEASURED STATE, 2026-09-07: the capability registry carries one row
154
+ * (`claude`) and it does not set `reads_project_mcp_config`, so this returns an
155
+ * EMPTY list for every `--tools` set. That is the honest reading of the axis,
156
+ * not a defect in this function — and it is a recorded absence, never a finding
157
+ * that these hosts do not read such a file.
158
+ */
159
+ export function mcpRegistrationTargets(
160
+ tools: ReadonlySet<string>,
161
+ readsConfig: ReadsProjectMcpConfig,
162
+ ): McpRegistrationTarget[] {
163
+ const out: McpRegistrationTarget[] = [];
164
+ for (const [toolId, spec] of Object.entries(MCP_PROJECT_CONFIG)) {
165
+ if (toolId === 'claude-code') continue;
166
+ if (!tools.has(toolId)) continue;
167
+ if (!readsConfig(spec.hostId)) continue;
168
+ out.push({ toolId, hostId: spec.hostId, relPath: spec.relPath });
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * Merge the server entry into every target's config file.
175
+ *
176
+ * Returns the merged keys per tool id, in the shape the installer's
177
+ * `merged_keys_by_tool` lockfile record expects. An empty map is the normal
178
+ * result today and is not an error.
179
+ */
180
+ export function makeEnsureMcpRegistrations(
181
+ mergeJsonFile: MergeJsonFile,
182
+ ): (
183
+ projectRoot: string,
184
+ force: boolean,
185
+ packageRoot: string,
186
+ targets: readonly McpRegistrationTarget[],
187
+ ) => Record<string, Record<string, unknown>[]> {
188
+ return (projectRoot, force, packageRoot, targets) => {
189
+ const merged: Record<string, Record<string, unknown>[]> = {};
190
+ for (const t of targets) {
191
+ merged[t.toolId] = mergeJsonFile(
192
+ path.join(projectRoot, ...t.relPath.split('/')),
193
+ mcpBridgeEntry(packageRoot),
194
+ force,
195
+ t.relPath,
196
+ );
197
+ }
198
+ return merged;
199
+ };
200
+ }
201
+
202
+ /** The axis reader the installer uses. Injected in tests, never overridden there. */
203
+ function readsProjectMcpConfig(hostId: string): boolean {
204
+ return probeHostCapabilities(hostId).reads_project_mcp_config;
205
+ }
206
+
207
+ /**
208
+ * Write every MCP registration one install owes, and record the merged keys.
209
+ *
210
+ * ONE call site rather than three, because `install.ts` is 3,900 lines over the
211
+ * source-size ratchet's ceiling and every line added there is a line the ratchet
212
+ * charges for. The composition is the same either way; where it lives is the
213
+ * only thing that differs, and it belongs beside the entry it writes.
214
+ *
215
+ * `claude-code` is registered whenever it is selected — that write predates the
216
+ * axis and routing it through would DELETE it, the axis being `false` for every
217
+ * host today. Every OTHER host needs both gates: the `--tools` selection and an
218
+ * OBSERVED `reads_project_mcp_config`.
219
+ */
220
+ export function registerMcpHosts(
221
+ mergeJsonFile: MergeJsonFile,
222
+ projectRoot: string,
223
+ force: boolean,
224
+ packageRoot: string,
225
+ tools: ReadonlySet<string>,
226
+ into: Record<string, Record<string, unknown>[]>,
227
+ readsConfig: ReadsProjectMcpConfig = readsProjectMcpConfig,
228
+ ): void {
229
+ const ensure = makeEnsureMcpRegistrations(mergeJsonFile);
230
+ const claude = MCP_PROJECT_CONFIG['claude-code'];
231
+ if (tools.has('claude-code') && claude !== undefined) {
232
+ const merged = ensure(projectRoot, force, packageRoot, [
233
+ { toolId: 'claude-code', hostId: claude.hostId, relPath: claude.relPath },
234
+ ]);
235
+ (into['claude-code'] ??= []).push(...(merged['claude-code'] ?? []));
236
+ }
237
+ for (const [toolId, keys] of Object.entries(
238
+ ensure(projectRoot, force, packageRoot, mcpRegistrationTargets(tools, readsConfig)),
239
+ )) {
240
+ (into[toolId] ??= []).push(...keys);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Rewrite a stale `.mcp.json` entry in place, touching only the key we own.
246
+ *
247
+ * Install writes the entry once; nothing re-read it afterwards, so a pin
248
+ * written by one install stayed frozen at that version for the life of the
249
+ * file — which is exactly the drift the pin was added to close, one release
250
+ * later. This is the update path.
251
+ *
252
+ * Scope discipline: the ONLY key rewritten is `mcpServers[MCP_SERVER_KEY]`.
253
+ * A server a consumer added by hand sits beside it and is not read, compared,
254
+ * reordered or rewritten — the same contract `merge_json_file` honours on the
255
+ * install path, and the reason uninstall can subtract exactly one key.
256
+ *
257
+ * Returns `'absent'` (no file, or no entry of ours to repair — install owns
258
+ * that case, not this one), `'current'`, or `'rewritten'`.
259
+ */
260
+ export function migrateMcpBridge(
261
+ projectRoot: string,
262
+ packageRoot: string,
263
+ ): 'absent' | 'current' | 'rewritten' {
264
+ const file = path.join(projectRoot, '.mcp.json');
265
+ let doc: Record<string, unknown>;
266
+ try {
267
+ doc = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>;
268
+ } catch {
269
+ return 'absent';
270
+ }
271
+ const servers = doc['mcpServers'];
272
+ if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) return 'absent';
273
+ const table = servers as Record<string, unknown>;
274
+ if (table[MCP_SERVER_KEY] === undefined) return 'absent';
275
+
276
+ const wanted = (mcpBridgeEntry(packageRoot)['mcpServers'] as Record<string, unknown>)[
277
+ MCP_SERVER_KEY
278
+ ];
279
+ if (JSON.stringify(table[MCP_SERVER_KEY]) === JSON.stringify(wanted)) return 'current';
280
+
281
+ table[MCP_SERVER_KEY] = wanted;
282
+ fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8');
283
+ return 'rewritten';
72
284
  }
73
285
 
74
286
  /**