@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +351 -156
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +10 -0
- package/dist/agent-src/commands/agent-handoff.md +42 -41
- package/dist/agent-src/commands/analyze/inbox.md +1 -1
- package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
- package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
- package/dist/agent-src/commands/analyze.md +12 -7
- package/dist/agent-src/commands/feature/plan.md +55 -16
- package/dist/agent-src/commands/feature/refactor.md +8 -3
- package/dist/agent-src/commands/humanize.md +48 -4
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
- package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
- package/dist/agent-src/rules/evaluator-independence.md +4 -4
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
- package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
- package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
- package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
- package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
- package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
- package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/features.md +5 -0
- package/dist/agent-src/templates/roadmaps.md +29 -1
- package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/dist/cli/mcp/dispatch.js +10 -0
- package/dist/cli/mcp/stdio.js +33 -2
- package/dist/cli/mcp/telemetry.js +227 -0
- package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
- package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
- package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
- package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
- package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
- package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
- package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
- package/dist/cli-delegate/cmd_conformance.js +88 -110
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +42 -12
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +59 -902
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +62 -31
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +3801 -2803
- package/dist/install/install.mjs +396 -168
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +809 -180
- package/dist/scripts/ai_council/content_ceiling.js +63 -0
- package/dist/scripts/ai_council/seat_policy.js +106 -0
- package/dist/scripts/ai_council/transport_resolver.js +21 -0
- package/dist/scripts/mcp_server/telemetry.js +148 -0
- package/docs/guidelines/agent-infra/naming.md +1 -1
- package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
- package/docs/guidelines/redundancy-taxonomy.md +28 -0
- package/llms.txt +14 -14
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/src/config/assurance-capability-registry.json +9 -0
- package/src/config/gate-coverage.yml +55 -0
- package/src/config/gate-violation-baselines.json +18 -6
- package/src/config/release-gate-locality.yml +4 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
- package/src/scripts/_lib/audit_field_provenance.ts +44 -3
- package/src/scripts/_lib/blocked_by_marker.ts +189 -0
- package/src/scripts/_lib/host_capability.ts +76 -1
- package/src/scripts/_lib/install_reach_checks.ts +33 -1
- package/src/scripts/_lib/mcp_bridge.ts +222 -10
- package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
- package/src/scripts/_lib/orchestration_record.ts +23 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
- package/src/scripts/_lib/release_highlights.ts +19 -211
- package/src/scripts/_lib/release_material.ts +27 -77
- package/src/scripts/_lib/release_position.ts +96 -0
- package/src/scripts/_lib/review_skipped_record.ts +17 -1
- package/src/scripts/_lib/security_lint.ts +179 -7
- package/src/scripts/_lib/self_repair.ts +141 -6
- package/src/scripts/_lib/self_repair_store.ts +273 -7
- package/src/scripts/_lib/structured_ask.ts +117 -0
- package/src/scripts/_lib/subagent_capsule.ts +155 -3
- package/src/scripts/_lib/tty_prompt.ts +76 -0
- package/src/scripts/ai_council/api_transport.ts +163 -0
- package/src/scripts/ai_council/clients.ts +58 -63
- package/src/scripts/ai_council/config.ts +13 -0
- package/src/scripts/ai_council/content_ceiling.ts +79 -0
- package/src/scripts/ai_council/jury_aggregate.ts +178 -0
- package/src/scripts/ai_council/seat_policy.ts +137 -0
- package/src/scripts/ai_council/status_surface.ts +79 -0
- package/src/scripts/ai_council/transport_resolver.ts +59 -1
- package/src/scripts/ai_tells_rules.ts +446 -5
- package/src/scripts/ask_block_census.ts +499 -0
- package/src/scripts/bench_humanizer_eval.ts +264 -40
- package/src/scripts/bench_prose_tells_fp.ts +198 -0
- package/src/scripts/build_rule_trigger_matrix.ts +5 -0
- package/src/scripts/check_branch_work_committed.ts +234 -0
- package/src/scripts/check_mcp_doc_drift.ts +217 -0
- package/src/scripts/check_pack_size.ts +321 -3
- package/src/scripts/check_release_highlights.ts +0 -157
- package/src/scripts/council_cli.ts +18 -27
- package/src/scripts/detect_ai_tells.ts +142 -20
- package/src/scripts/handoff_context_hook.ts +54 -7
- package/src/scripts/harvest_reference_tokens.ts +698 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +31 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
- package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
- package/src/scripts/hooks/session_eol_hook.ts +18 -7
- package/src/scripts/install-hooks.sh +52 -0
- package/src/scripts/install.ts +4 -4
- package/src/scripts/lint_agent_security.ts +316 -25
- package/src/scripts/lint_confusables.ts +42 -6
- package/src/scripts/lint_handoffs.ts +0 -132
- package/src/scripts/lint_hidden_unicode.ts +49 -7
- package/src/scripts/lint_instruction_smuggling.ts +42 -6
- package/src/scripts/lint_mcp_config_security.ts +42 -6
- package/src/scripts/lint_roadmap_blockers.ts +51 -1
- package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
- package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
- package/src/scripts/mcp_server/resources.ts +41 -0
- package/src/scripts/mcp_server/telemetry.ts +19 -4
- package/src/scripts/migrate_command_suggestions.ts +1 -1
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/probe_unblocked_ask.ts +135 -2
- package/src/scripts/release.ts +50 -95
- package/src/scripts/release_drill.ts +6 -10
- package/src/scripts/release_env.ts +1 -0
- package/src/scripts/release_publication.ts +40 -31
- package/src/scripts/self_repair_cli.ts +31 -1
- package/src/scripts/self_review_gate.ts +409 -15
- package/src/scripts/skill_scout.ts +69 -0
- package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
- 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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
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
|
/**
|