@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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse a push that leaves this branch's OWN work uncommitted.
|
|
3
|
+
*
|
|
4
|
+
* The defect, measured on 2026-09-07 in `fix/release-obligation-answerable`:
|
|
5
|
+
* a merge-reconciliation baseline was computed, written into
|
|
6
|
+
* `gate-violation-baselines.json` AFTER `git add`, and then committed with
|
|
7
|
+
* `git commit --no-edit` — which captured the STAGED side. The reconciled
|
|
8
|
+
* number stayed in the working tree, the push went out without it, and the
|
|
9
|
+
* pushed branch asserted a baseline the tree contradicted. The operator found
|
|
10
|
+
* it, not the pipeline.
|
|
11
|
+
*
|
|
12
|
+
* Nothing in CI can catch this class: CI checks out the pushed commit, where
|
|
13
|
+
* the working tree that holds the lost edit does not exist. The only place the
|
|
14
|
+
* evidence is available is the machine doing the push, which is why this gate
|
|
15
|
+
* is pre-push and local-only — the same reach `check_branch_freshness` has.
|
|
16
|
+
*
|
|
17
|
+
* The discriminator, whose work is it:
|
|
18
|
+
*
|
|
19
|
+
* A dirty tree at push time is not automatically a defect. Gate scripts write
|
|
20
|
+
* reports, a parallel session may touch a shared file, a test run leaves
|
|
21
|
+
* artifacts. Blocking on all of it would train the operator to set the skip
|
|
22
|
+
* variable, which is worse than no gate.
|
|
23
|
+
*
|
|
24
|
+
* So the split is by AUTHORSHIP, and it is decidable from git alone:
|
|
25
|
+
*
|
|
26
|
+
* 1. INDEX/WORKTREE DIVERGENCE (`MM`, `AM`, `RM`, …) — the file is staged
|
|
27
|
+
* AND changed again since. That is "edited after `git add`" as a fact, not
|
|
28
|
+
* an inference, and it is exactly the shape that produced the defect.
|
|
29
|
+
* 2. STAGED AT ALL — `git add` is a recorded intent to ship the file, and a
|
|
30
|
+
* push carries commits rather than the index, so a staged-but-uncommitted
|
|
31
|
+
* path is this branch's work being left behind by definition. No
|
|
32
|
+
* authorship inference is needed for this class.
|
|
33
|
+
* 3. A DIRTY FILE THIS BRANCH'S OWN COMMITS TOUCHED — the branch has been
|
|
34
|
+
* editing it deliberately, so an open change to it is this branch's
|
|
35
|
+
* unfinished work rather than someone else's noise.
|
|
36
|
+
* 4. EVERYTHING ELSE — a dirty file the branch never committed, or an
|
|
37
|
+
* untracked file. Reported by name, never blocking: this is where a
|
|
38
|
+
* parallel session's edit and a gate script's own report output land, and
|
|
39
|
+
* the branch has no claim on either.
|
|
40
|
+
*
|
|
41
|
+
* Class 4 is advisory, and the residual limit is stated rather than hidden: a
|
|
42
|
+
* file this branch created and has neither staged nor committed is
|
|
43
|
+
* indistinguishable from a foreign untracked file. Class 2 closes it at the
|
|
44
|
+
* first `git add` — which was itself a finding of testing this gate against the
|
|
45
|
+
* live tree, where its own source file sat in class 4 until the rule was
|
|
46
|
+
* widened.
|
|
47
|
+
*/
|
|
48
|
+
import * as path from 'node:path';
|
|
49
|
+
import process from 'node:process';
|
|
50
|
+
import { spawnSync } from 'node:child_process';
|
|
51
|
+
import { fileURLToPath } from 'node:url';
|
|
52
|
+
|
|
53
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
54
|
+
|
|
55
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
56
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
57
|
+
|
|
58
|
+
export interface WorkVerdict {
|
|
59
|
+
/** False when the push must be refused. */
|
|
60
|
+
ok: boolean;
|
|
61
|
+
/** Files that are this branch's own unfinished work, with the reason. */
|
|
62
|
+
blocking: { path: string; why: string }[];
|
|
63
|
+
/** Files that are dirty but not this branch's to answer for. */
|
|
64
|
+
advisory: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One `git status --porcelain` row. */
|
|
68
|
+
interface StatusRow {
|
|
69
|
+
index: string;
|
|
70
|
+
worktree: string;
|
|
71
|
+
path: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parsePorcelain(porcelain: string): StatusRow[] {
|
|
75
|
+
const out: StatusRow[] = [];
|
|
76
|
+
for (const line of porcelain.split('\n')) {
|
|
77
|
+
if (line.length < 4) continue;
|
|
78
|
+
const index = line[0] as string;
|
|
79
|
+
const worktree = line[1] as string;
|
|
80
|
+
let p = line.slice(3);
|
|
81
|
+
// Renames read `R old -> new`; the new path is the one that matters.
|
|
82
|
+
const arrow = p.indexOf(' -> ');
|
|
83
|
+
if (arrow !== -1) p = p.slice(arrow + 4);
|
|
84
|
+
out.push({ index, worktree, path: p.replace(/^"|"$/gu, '') });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The verdict, as a pure function of the two git readings — so the rules are
|
|
91
|
+
* unit-testable without a repository, the shape `confirmGate` and
|
|
92
|
+
* `preflightPosition` already use in this pipeline.
|
|
93
|
+
*/
|
|
94
|
+
export function classifyBranchWork(opts: {
|
|
95
|
+
porcelain: string;
|
|
96
|
+
/** Files this branch's own commits touched (`git diff --name-only base..HEAD`). */
|
|
97
|
+
branchFiles: readonly string[];
|
|
98
|
+
}): WorkVerdict {
|
|
99
|
+
const touched = new Set(opts.branchFiles);
|
|
100
|
+
const blocking: { path: string; why: string }[] = [];
|
|
101
|
+
const advisory: string[] = [];
|
|
102
|
+
|
|
103
|
+
for (const row of parsePorcelain(opts.porcelain)) {
|
|
104
|
+
const untracked = row.index === '?' && row.worktree === '?';
|
|
105
|
+
const staged = row.index !== ' ' && !untracked;
|
|
106
|
+
const dirty = row.worktree !== ' ' && !untracked;
|
|
107
|
+
|
|
108
|
+
if (staged && dirty) {
|
|
109
|
+
blocking.push({
|
|
110
|
+
path: row.path,
|
|
111
|
+
why: 'staged AND changed again since — edited after `git add`, so the commit would capture the stale side',
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (staged) {
|
|
116
|
+
blocking.push({
|
|
117
|
+
path: row.path,
|
|
118
|
+
why: 'staged but never committed — `git add` records the intent to ship it, and a push carries commits, not the index',
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (dirty && touched.has(row.path)) {
|
|
123
|
+
blocking.push({
|
|
124
|
+
path: row.path,
|
|
125
|
+
why: "this branch's own commits touch this file, so an open change to it is unfinished work from this branch",
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
advisory.push(row.path);
|
|
130
|
+
}
|
|
131
|
+
return { ok: blocking.length === 0, blocking, advisory };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function git(args: readonly string[]): string {
|
|
135
|
+
const r = spawnSync('git', args, { cwd: REPO_ROOT, encoding: 'utf-8' });
|
|
136
|
+
return r.status === 0 ? (r.stdout ?? '') : '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Files this branch's commits touched, relative to where it left the base. */
|
|
140
|
+
export function branchOwnFiles(baseRef: string): string[] {
|
|
141
|
+
const base = git(['merge-base', baseRef, 'HEAD']).trim();
|
|
142
|
+
if (base === '') return [];
|
|
143
|
+
return git(['diff', '--name-only', `${base}..HEAD`])
|
|
144
|
+
.split('\n')
|
|
145
|
+
.filter((l) => l.trim() !== '');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function main(argv: readonly string[]): number {
|
|
149
|
+
const quiet = argv.includes('--quiet');
|
|
150
|
+
let baseRef = 'origin/main';
|
|
151
|
+
const bi = argv.indexOf('--base');
|
|
152
|
+
if (bi !== -1 && argv[bi + 1]) baseRef = argv[bi + 1] as string;
|
|
153
|
+
|
|
154
|
+
const porcelain = git(['status', '--porcelain']);
|
|
155
|
+
const verdict = classifyBranchWork({ porcelain, branchFiles: branchOwnFiles(baseRef) });
|
|
156
|
+
|
|
157
|
+
// Through the shared reporter, not a hand-written line: `check_gate_coverage`
|
|
158
|
+
// reads the CALL, and a printed `scanned:` string satisfies the eye while
|
|
159
|
+
// leaving the gate unhardened (measured — this gate reded that ratchet on
|
|
160
|
+
// its first CI run for exactly that reason).
|
|
161
|
+
//
|
|
162
|
+
// Zero rows is the GOOD state here, which inverts the usual dead-scope
|
|
163
|
+
// reasoning: for every other gate an empty corpus may mean the root moved,
|
|
164
|
+
// while for this one it means the operator has nothing outstanding. The
|
|
165
|
+
// scope cannot go dead either — `git status` is the corpus, and a reading it
|
|
166
|
+
// could not produce comes back as an empty string from a non-zero exit,
|
|
167
|
+
// which is indistinguishable from clean and is why the reason below is
|
|
168
|
+
// stated rather than left implicit.
|
|
169
|
+
reportScanned({
|
|
170
|
+
gate: 'check_branch_work_committed',
|
|
171
|
+
scanned: parsePorcelain(porcelain).length,
|
|
172
|
+
units: 'dirty path(s)',
|
|
173
|
+
roots: ['git status --porcelain'],
|
|
174
|
+
allowEmpty:
|
|
175
|
+
'EMPTY_VALID: a clean working tree is this gate\'s PASSING state, not a dead scope. ' +
|
|
176
|
+
'The corpus is git\'s own status output over the whole repository, so there is no ' +
|
|
177
|
+
'root to move; zero rows means the operator has committed everything, which is the ' +
|
|
178
|
+
'outcome the gate exists to produce.',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (verdict.ok) {
|
|
182
|
+
if (!quiet) {
|
|
183
|
+
process.stdout.write(
|
|
184
|
+
`✅ check_branch_work_committed: nothing of this branch's own work is uncommitted` +
|
|
185
|
+
(verdict.advisory.length > 0
|
|
186
|
+
? ` (${String(verdict.advisory.length)} unrelated dirty path(s) left alone)\n`
|
|
187
|
+
: '\n'),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (verdict.advisory.length > 0 && !quiet) {
|
|
191
|
+
for (const p of verdict.advisory) {
|
|
192
|
+
process.stdout.write(` · ${p} — not touched by this branch's commits\n`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
process.stderr.write(
|
|
199
|
+
`❌ check_branch_work_committed: ${String(verdict.blocking.length)} file(s) carry this ` +
|
|
200
|
+
"branch's own uncommitted work:\n",
|
|
201
|
+
);
|
|
202
|
+
for (const b of verdict.blocking) {
|
|
203
|
+
process.stderr.write(` - ${b.path}\n ${b.why}\n`);
|
|
204
|
+
}
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
' A push that leaves them behind ships a branch whose tree contradicts its own\n' +
|
|
207
|
+
' commits, and NO CI gate can see it — CI checks out the commit, never the tree\n' +
|
|
208
|
+
' the edit is sitting in.\n' +
|
|
209
|
+
' Commit them (or `git restore` what was not meant to land), then push again.\n' +
|
|
210
|
+
' Genuinely foreign changes are reported without blocking; if one of these is\n' +
|
|
211
|
+
" truly a parallel session's, commit around it with an explicit pathspec.\n" +
|
|
212
|
+
' WIP push: AGENT_CONFIG_SKIP_PREPUSH_WORKTREE=1.\n',
|
|
213
|
+
);
|
|
214
|
+
if (verdict.advisory.length > 0) {
|
|
215
|
+
process.stderr.write(
|
|
216
|
+
` Left alone (${String(verdict.advisory.length)}): ${verdict.advisory.join(', ')}\n`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const _isMain = (() => {
|
|
223
|
+
const entry = process.argv[1];
|
|
224
|
+
if (!entry) return false;
|
|
225
|
+
try {
|
|
226
|
+
return path.resolve(entry) === path.resolve(_HERE);
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
|
|
232
|
+
if (_isMain) {
|
|
233
|
+
process.exit(main(process.argv.slice(2)));
|
|
234
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* The documented MCP client snippets must match the entry the installer writes.
|
|
4
|
+
*
|
|
5
|
+
* `docs/mcp-server.md` documented an absolute `tsx` path in four snippets while
|
|
6
|
+
* the installer wrote an `npx` invocation, so a consumer who followed the docs
|
|
7
|
+
* and a consumer who ran the installer landed on two different servers — for
|
|
8
|
+
* long enough that nobody noticed either was wrong.
|
|
9
|
+
*
|
|
10
|
+
* Scope is two literals in this tree. It asserts NOTHING about host behaviour:
|
|
11
|
+
* that a host reads the file at all is a separate question this gate does not
|
|
12
|
+
* touch and must not be cited for.
|
|
13
|
+
*
|
|
14
|
+
* Exit codes: 0 in sync · 1 drift · 2 a source is missing or unreadable.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
22
|
+
import { MCP_PACKAGE_NAME, MCP_SERVER_KEY, mcpBridgeEntry } from './_lib/mcp_bridge.js';
|
|
23
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
24
|
+
|
|
25
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
27
|
+
const DOC_REL = 'docs/mcp-server.md';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A snippet is any fenced block naming our server key.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately not a JSON/YAML parse: the four documented clients use three
|
|
33
|
+
* different shapes (`command` as a string, `command` as an object with `path`,
|
|
34
|
+
* and a YAML list), and a parser per shape would drift from the docs as surely
|
|
35
|
+
* as the docs drifted from the installer. The two facts that matter — the
|
|
36
|
+
* runner and the package spec — are the same literals in all three.
|
|
37
|
+
*/
|
|
38
|
+
export function snippetBlocks(text: string): string[] {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
const lines = text.split('\n');
|
|
41
|
+
let open: string[] | null = null;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (line.startsWith('```')) {
|
|
44
|
+
if (open === null) open = [];
|
|
45
|
+
else {
|
|
46
|
+
const body = open.join('\n');
|
|
47
|
+
if (body.includes(MCP_SERVER_KEY) || body.includes('mcpServers')) out.push(body);
|
|
48
|
+
open = null;
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (open !== null) open.push(line);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DocFinding {
|
|
58
|
+
snippet: number;
|
|
59
|
+
reason: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkSnippets(text: string, packageRoot: string): DocFinding[] {
|
|
63
|
+
const server = (mcpBridgeEntry(packageRoot)['mcpServers'] as Record<string, unknown>)[
|
|
64
|
+
MCP_SERVER_KEY
|
|
65
|
+
] as { command: string; args: string[] };
|
|
66
|
+
const findings: DocFinding[] = [];
|
|
67
|
+
|
|
68
|
+
snippetBlocks(text).forEach((body, i) => {
|
|
69
|
+
// Only blocks that actually configure the server — a block that merely
|
|
70
|
+
// mentions `mcpServers` in prose is not a client configuration.
|
|
71
|
+
if (!body.includes('command')) return;
|
|
72
|
+
if (!body.includes(server.command)) {
|
|
73
|
+
findings.push({
|
|
74
|
+
snippet: i + 1,
|
|
75
|
+
reason: `does not use the installer's runner \`${server.command}\``,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (!body.includes(MCP_PACKAGE_NAME)) {
|
|
79
|
+
findings.push({
|
|
80
|
+
snippet: i + 1,
|
|
81
|
+
reason: `does not name the package \`${MCP_PACKAGE_NAME}\``,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// QUOTED, not a substring: `mcp-server` contains `serve`, so a plain
|
|
85
|
+
// `includes` reported a snippet as current after the entry had been
|
|
86
|
+
// changed to `serve` and the doc had not — measured, on this gate's own
|
|
87
|
+
// sensitivity probe. Every documented shape quotes the token.
|
|
88
|
+
const subcommand = server.args[server.args.length - 1] as string;
|
|
89
|
+
if (!body.includes(`"${subcommand}"`)) {
|
|
90
|
+
findings.push({
|
|
91
|
+
snippet: i + 1,
|
|
92
|
+
reason: `does not invoke \`${subcommand}\``,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (body.includes('node_modules/.bin/tsx')) {
|
|
96
|
+
findings.push({
|
|
97
|
+
snippet: i + 1,
|
|
98
|
+
reason: 'documents an absolute tsx path the installer never writes',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function main(): number {
|
|
106
|
+
if (process.argv.slice(2).includes('--self-test')) return selfTest();
|
|
107
|
+
const rootIdx = process.argv.indexOf('--root');
|
|
108
|
+
const root = rootIdx === -1 ? ROOT : (process.argv[rootIdx + 1] ?? ROOT);
|
|
109
|
+
const doc = path.join(root, DOC_REL);
|
|
110
|
+
if (!fs.existsSync(doc)) {
|
|
111
|
+
process.stderr.write(`❌ ${DOC_REL} not found under ${root}\n`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const text = fs.readFileSync(doc, 'utf-8');
|
|
115
|
+
const blocks = snippetBlocks(text).filter((b) => b.includes('command'));
|
|
116
|
+
const findings = checkSnippets(text, root);
|
|
117
|
+
|
|
118
|
+
reportScanned({
|
|
119
|
+
gate: 'check_mcp_doc_drift',
|
|
120
|
+
scanned: blocks.length,
|
|
121
|
+
units: 'documented client snippet(s)',
|
|
122
|
+
roots: [DOC_REL],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (findings.length > 0) {
|
|
126
|
+
for (const f of findings) {
|
|
127
|
+
process.stdout.write(`❌ ${DOC_REL} snippet ${String(f.snippet)}: ${f.reason}\n`);
|
|
128
|
+
}
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
`\n${String(findings.length)} finding(s). The documented entry and the entry ` +
|
|
131
|
+
'`mcpBridgeEntry()` writes have drifted apart.\n',
|
|
132
|
+
);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(
|
|
136
|
+
`✅ check_mcp_doc_drift: ${String(blocks.length)} documented snippet(s) match the ` +
|
|
137
|
+
'installer entry.\n',
|
|
138
|
+
);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One rejecting case per way the two literals can drift. */
|
|
143
|
+
function selfTest(): number {
|
|
144
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-doc-drift-'));
|
|
145
|
+
const write = (name: string, doc: string): string => {
|
|
146
|
+
const dir = path.join(tmp, name);
|
|
147
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
148
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '1.0.0' }));
|
|
149
|
+
fs.writeFileSync(path.join(dir, DOC_REL), doc, 'utf-8');
|
|
150
|
+
return dir;
|
|
151
|
+
};
|
|
152
|
+
const run = (root: string): number =>
|
|
153
|
+
runGateCli(ROOT, 'src/scripts/check_mcp_doc_drift.ts', ['--root', root], root);
|
|
154
|
+
|
|
155
|
+
const good = [
|
|
156
|
+
'```json',
|
|
157
|
+
'{ "mcpServers": { "agent-config": {',
|
|
158
|
+
' "command": "npx",',
|
|
159
|
+
' "args": ["-y", "@event4u/agent-config@<version>", "mcp-server"] } } }',
|
|
160
|
+
'```',
|
|
161
|
+
'',
|
|
162
|
+
].join('\n');
|
|
163
|
+
const tsxPath = good.replace('"npx"', '"/abs/agent-config/node_modules/.bin/tsx"');
|
|
164
|
+
const noPackage = good.replace('@event4u/agent-config@<version>', 'some-other-package');
|
|
165
|
+
const noSubcommand = good.replace('"mcp-server"', '"serve"');
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
return runSelfTest({
|
|
169
|
+
gate: 'check_mcp_doc_drift',
|
|
170
|
+
minCases: 4,
|
|
171
|
+
minRejectCases: 3,
|
|
172
|
+
cases: [
|
|
173
|
+
{
|
|
174
|
+
name: 'an absolute tsx path is rejected',
|
|
175
|
+
expect: 'reject',
|
|
176
|
+
run: () => run(write('tsx', tsxPath)),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'a snippet naming another package is rejected',
|
|
180
|
+
expect: 'reject',
|
|
181
|
+
run: () => run(write('pkg', noPackage)),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'a snippet invoking another subcommand is rejected',
|
|
185
|
+
expect: 'reject',
|
|
186
|
+
run: () => run(write('sub', noSubcommand)),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'the installer-shaped snippet passes',
|
|
190
|
+
expect: 'accept',
|
|
191
|
+
run: () => run(write('good', good)),
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _isCliEntry(): boolean {
|
|
201
|
+
if (process.argv[1] === undefined) return false;
|
|
202
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
|
|
203
|
+
try {
|
|
204
|
+
return (
|
|
205
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
206
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
207
|
+
);
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
214
|
+
process.exit(main());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { DOC_REL, main };
|