@event4u/agent-config 10.1.0 → 10.3.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +110 -167
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
- package/dist/agent-src/commands/roadmap/create.md +4 -1
- package/dist/agent-src/commands/roadmap/next.md +38 -0
- package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +22 -1
- package/dist/agent-src/contexts/contracts/frugality-charter.md +1 -1
- package/dist/agent-src/personas/advisors/first-principles.md +6 -0
- package/dist/agent-src/rules/active-remediation.md +53 -2
- package/dist/agent-src/rules/code-provenance.md +66 -4
- package/dist/agent-src/rules/content-quoting-floor.md +6 -0
- package/dist/agent-src/rules/context-hygiene.md +39 -0
- package/dist/agent-src/rules/design-fidelity.md +49 -3
- package/dist/agent-src/rules/token-budget-discipline.md +3 -2
- package/dist/agent-src/rules/token-efficiency.md +22 -0
- package/dist/agent-src/skills/ai-council/SKILL.md +28 -6
- package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
- package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +9 -9
- package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
- package/dist/agent-src/skills/token-optimizer/SKILL.md +5 -1
- package/dist/agent-src/templates/agent-settings.md +12 -28
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +2 -2
- package/dist/agent-src/templates/persona.md +26 -0
- package/dist/agent-src/templates/roadmaps.md +27 -7
- package/dist/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
- package/dist/agent-src/templates/skill.md +35 -1
- package/dist/cli/commands/commands.candidates.test.js +257 -0
- package/dist/cli/commands/commands.candidates.test.js.map +1 -0
- package/dist/cli/commands/commands.js +199 -0
- package/dist/cli/commands/commands.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-3JUTT26I.js → chunk-6ENSXSBW.js} +1 -1
- package/dist/cli-delegate/{chunk-TVGXH7VA.js → chunk-6LOPHJLD.js} +1 -1
- package/dist/cli-delegate/{chunk-XT2YTJCZ.js → chunk-AYKQGIBG.js} +4 -0
- package/dist/cli-delegate/{chunk-HJSKTMIF.js → chunk-BV3WR67D.js} +3 -19
- package/dist/cli-delegate/{chunk-HTIU3W56.js → chunk-BWMXLD4B.js} +6 -2
- package/dist/cli-delegate/{chunk-2H5SE7DU.js → chunk-FV34EGEX.js} +166 -39
- package/dist/cli-delegate/{chunk-XTC5ARB6.js → chunk-IR3IVRVC.js} +7 -2
- package/dist/cli-delegate/{chunk-LRXJK6GN.js → chunk-JMQZ7MM5.js} +1 -1
- package/dist/cli-delegate/{chunk-NX5AXSLD.js → chunk-X3VOARZD.js} +3 -3
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +881 -204
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +6 -5
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +44 -9
- package/dist/cli-delegate/cmd_settings_get.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +21 -21
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +204 -44
- package/dist/install/install.mjs +9 -21
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1337 -490
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +25 -2
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/_lib/compile_time_toggles.js +4 -3
- package/dist/scripts/_lib/compile_time_toggles.js.map +1 -1
- package/dist/server/io/yamlIO.js +0 -2
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/schemas/settings.js +3 -9
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +64 -2
- package/dist/shared/settingsClasses.js.map +1 -1
- package/dist/ui/assets/{index-B5qGYOJo.js → index-5fXOHoVh.js} +2 -2
- package/dist/ui/assets/index-5fXOHoVh.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +307 -145
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
- package/docs/guidelines/agent-infra/layered-settings.md +1 -2
- package/docs/guidelines/agent-infra/symptom-driven-harvest-loop.md +104 -0
- package/docs/guidelines/design-antipatterns.md +79 -10
- package/docs/guidelines/design-fidelity-mechanics.md +120 -2
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
- package/src/config/agent-settings.template.yml +12 -30
- package/src/config/gate-coverage.yml +42 -8
- package/src/config/gate-violation-baselines.json +15 -3
- package/src/config/profiles/balanced.ini +0 -2
- package/src/config/profiles/full.ini +1 -3
- package/src/config/profiles/minimal.ini +0 -2
- package/src/config/review-prompt-binding-baseline.json +19 -0
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +66 -9
- package/src/scripts/_cli/handoff_sessions.ts +3 -2
- package/src/scripts/_dispatch.bash +1 -0
- package/src/scripts/_lib/agent_settings.ts +25 -2
- package/src/scripts/_lib/bench_telegraph.ts +1 -1
- package/src/scripts/_lib/cc_transcript.ts +25 -0
- package/src/scripts/_lib/compile_time_toggles.ts +4 -3
- package/src/scripts/ai_council/clients.ts +243 -34
- package/src/scripts/cache_realization_report.ts +3 -2
- package/src/scripts/check_ci_local_parity.ts +60 -6
- package/src/scripts/check_review_prompt_binding.ts +553 -0
- package/src/scripts/check_rule_projection_integrity.ts +3 -1
- package/src/scripts/check_suppression_hygiene.ts +19 -3
- package/src/scripts/conformance_scan.ts +282 -9
- package/src/scripts/cost/track.mjs +10 -4
- package/src/scripts/council_cli.ts +33 -3
- package/src/scripts/design_slop_rules.test.ts +37 -6
- package/src/scripts/design_slop_rules.ts +150 -22
- package/src/scripts/dispatch_r2_reviewer.ts +15 -1
- package/src/scripts/external_sources_denylist.json +3 -1
- package/src/scripts/hook_manifest.yaml +17 -6
- package/src/scripts/hooks/block_unauthorized_git.ts +54 -8
- package/src/scripts/hooks/delegation_nudge_hook.ts +33 -8
- package/src/scripts/hooks/evidence_independence.ts +61 -9
- package/src/scripts/hooks/session_eol_hook.ts +123 -3
- package/src/scripts/hooks/turn_end_gate_hook.ts +31 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
- package/src/scripts/lint_design_antipattern_parity.ts +201 -0
- package/src/scripts/lint_harvest_provenance.ts +483 -0
- package/src/scripts/lint_settings_classes.ts +273 -11
- package/src/scripts/lint_skill_router_head.ts +254 -0
- package/src/scripts/lint_symptom_intake.ts +241 -0
- package/src/scripts/report_consultation_rate.ts +128 -2
- package/src/scripts/report_skill_activation.ts +6 -4
- package/src/scripts/report_skill_obligation_violations.ts +2 -1
- package/src/scripts/run_skill_evals.ts +10 -5
- package/src/scripts/schemas/persona.schema.json +8 -0
- package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
- package/src/scripts/sweep_dead_scan_roots.ts +51 -0
- package/src/server/io/yamlIO.ts +0 -2
- package/src/server/schemas/settings.ts +3 -19
- package/src/shared/settingsClasses.ts +93 -2
- package/dist/ui/assets/index-B5qGYOJo.js.map +0 -1
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* The prompt→verdict binding, checked.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `docs/contracts/plan-review-gates.md` § 5 recorded the prompt
|
|
6
|
+
* channel as an accepted risk on the premise that *"no in-repo check can read the
|
|
7
|
+
* prompt, so there is no detection floor"*. That premise was true about the HASH
|
|
8
|
+
* and false about the TEXT sitting beside it: `dispatch_r2_reviewer` writes the
|
|
9
|
+
* prompt it built verbatim to `<slug>.review-input/prompt.md`, and those files are
|
|
10
|
+
* tracked. An in-repo check could read the prompt all along; nothing did.
|
|
11
|
+
*
|
|
12
|
+
* `prompt_hash` was written into every findings marker, parsed by
|
|
13
|
+
* `check_completion_review`, and **compared to nothing** — `--verify` /
|
|
14
|
+
* `--verify-current` re-derive `scope_hash`, `roadmap_hash` and `ac_hash` only.
|
|
15
|
+
* A retrospective scan of the committed corpus found the binding already broken
|
|
16
|
+
* in two records, one of them dated after the only known cause was fixed. The
|
|
17
|
+
* only link between a verdict and the prompt that produced it was broken,
|
|
18
|
+
* currently, and nobody noticed — because nothing looked.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT DOES NOT DO, stated so the gate is not oversold. Three limits, and the
|
|
21
|
+
* first is the cheapest bypass rather than the cleverest:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Omission beats substitution.** Simply not committing
|
|
24
|
+
* `<slug>.review-input/prompt.md` drops the round out of the checkable set
|
|
25
|
+
* with no finding and no signal, because an artefact without a package is
|
|
26
|
+
* deliberately out of scope (see {@link collectPackages}). Nothing in the tree
|
|
27
|
+
* requires the package to exist. Measured on the corpus this gate shipped
|
|
28
|
+
* against: **11 of 19** artefacts already carry a `prompt_hash` with no
|
|
29
|
+
* package, so the bypassed state is the historical norm and is
|
|
30
|
+
* indistinguishable from it. Closing this needs a requirement that packages be
|
|
31
|
+
* committed — a migration decision, not a check.
|
|
32
|
+
* 2. **Substitution stays undetectable.** The host that authors a steered prompt
|
|
33
|
+
* also writes the file, so swapping a clean prompt for the steered one passes.
|
|
34
|
+
* What changes is that steering then requires an act of substitution leaving
|
|
35
|
+
* its own artefact in the commit, instead of being invisible by default.
|
|
36
|
+
* 3. **The steering predicate is one-of-four.** Against the four clauses the
|
|
37
|
+
* case-zero incident records verbatim, `preloadedVerdict` matches exactly one.
|
|
38
|
+
* That is the detection floor this buys, not a solved problem.
|
|
39
|
+
*
|
|
40
|
+
* Corpus-wide on purpose. `check_completion_review` deliberately reports grammar
|
|
41
|
+
* violations only for the branch's own or scope-relevant artefacts, because a
|
|
42
|
+
* stale foreign artefact must not poison an unrelated PR. That reasoning does not
|
|
43
|
+
* transfer here: a committed `prompt.md` and its recorded hash are both immutable,
|
|
44
|
+
* so the binding is branch-independent and a break in any record is a permanent
|
|
45
|
+
* defect. With the two known breaks baselined, an unrelated PR stays green.
|
|
46
|
+
*
|
|
47
|
+
* Exit codes:
|
|
48
|
+
* 0 every checkable binding re-derives, no steered prompt, baseline accurate
|
|
49
|
+
* 1 a binding broke, a prompt carries a pre-loaded verdict, or the baseline
|
|
50
|
+
* no longer describes the corpus
|
|
51
|
+
* 2 internal error (unreadable corpus, malformed baseline)
|
|
52
|
+
*/
|
|
53
|
+
import * as crypto from 'node:crypto';
|
|
54
|
+
import * as fs from 'node:fs';
|
|
55
|
+
import * as os from 'node:os';
|
|
56
|
+
import * as path from 'node:path';
|
|
57
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
58
|
+
|
|
59
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
60
|
+
import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
|
|
61
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
62
|
+
import { parseArtifact } from './check_completion_review.js';
|
|
63
|
+
import { preloadedVerdict } from './hooks/evidence_independence.js';
|
|
64
|
+
|
|
65
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
66
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
67
|
+
const DEFAULT_REVIEWS_DIR = 'agents/evidence/reviews';
|
|
68
|
+
const DEFAULT_BASELINE = 'src/config/review-prompt-binding-baseline.json';
|
|
69
|
+
|
|
70
|
+
/** A committed prompt whose recorded hash is known not to re-derive. */
|
|
71
|
+
export interface BaselineEntry {
|
|
72
|
+
slug: string;
|
|
73
|
+
/** The hash the findings marker records. */
|
|
74
|
+
declared: string;
|
|
75
|
+
/** The hash the committed `prompt.md` actually produces. */
|
|
76
|
+
actual: string;
|
|
77
|
+
reason: string;
|
|
78
|
+
/**
|
|
79
|
+
* An acknowledged steering match, pinned to the exact phrase.
|
|
80
|
+
*
|
|
81
|
+
* WHY THIS EXISTS, and why it is separate from the hash exemption. The
|
|
82
|
+
* steering predicate is `preloadedVerdict`, a live regex list owned by
|
|
83
|
+
* `evidence_independence` and expected to grow — its own history includes a
|
|
84
|
+
* phrase dropped for false positives. A committed prompt cannot be edited
|
|
85
|
+
* into compliance without rewriting a historical record. So with no
|
|
86
|
+
* acknowledgement path at all, ONE phrase added to that shared list would
|
|
87
|
+
* convert already-committed prompts into permanent blocks on every unrelated
|
|
88
|
+
* PR, and a gate whose only output is an unfixable block is the gate that
|
|
89
|
+
* gets switched off.
|
|
90
|
+
*
|
|
91
|
+
* The phrase is pinned rather than the slug: acknowledging "this record
|
|
92
|
+
* matches THIS clause, and here is the human read of it" cannot silently
|
|
93
|
+
* cover a DIFFERENT clause matching the same file later. The corpus carries
|
|
94
|
+
* zero of these today, which is the state to keep.
|
|
95
|
+
*/
|
|
96
|
+
steeredAck?: { phrase: string; reason: string };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface Finding {
|
|
100
|
+
kind: 'binding-broken' | 'steered-prompt' | 'stale-baseline';
|
|
101
|
+
slug: string;
|
|
102
|
+
detail: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Tally {
|
|
106
|
+
/** Artefacts carrying both a `prompt_hash` and a committed prompt package. */
|
|
107
|
+
packages: number;
|
|
108
|
+
/** Packages whose hash re-derives. */
|
|
109
|
+
binding: number;
|
|
110
|
+
/** Packages whose hash does not re-derive (baselined or not). */
|
|
111
|
+
broken: number;
|
|
112
|
+
/** Of {@link broken}, how many an accurate baseline entry covers. */
|
|
113
|
+
baselined: number;
|
|
114
|
+
/** Packages whose prompt carries a pre-loaded verdict. */
|
|
115
|
+
steered: number;
|
|
116
|
+
/** Of {@link steered}, how many carry a phrase-pinned `steeredAck`. */
|
|
117
|
+
steeringAcknowledged: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function sha256(buf: Buffer | string): string {
|
|
121
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read the baseline, keyed by slug.
|
|
126
|
+
*
|
|
127
|
+
* A malformed baseline is an internal error, never an empty map: silently
|
|
128
|
+
* treating an unparseable suppression file as "no exemptions" would red the gate
|
|
129
|
+
* for the wrong reason, and treating it as "everything exempt" would disarm it.
|
|
130
|
+
*/
|
|
131
|
+
export function loadBaseline(file: string): Map<string, BaselineEntry> {
|
|
132
|
+
const out = new Map<string, BaselineEntry>();
|
|
133
|
+
let raw: string;
|
|
134
|
+
try {
|
|
135
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
136
|
+
} catch {
|
|
137
|
+
return out; // absent baseline = no exemptions, which is the strict state
|
|
138
|
+
}
|
|
139
|
+
const parsed = JSON.parse(raw) as { entries?: unknown };
|
|
140
|
+
const entries = parsed.entries;
|
|
141
|
+
if (!Array.isArray(entries)) {
|
|
142
|
+
throw new Error(`${file}: expected an \`entries\` array`);
|
|
143
|
+
}
|
|
144
|
+
for (const e of entries) {
|
|
145
|
+
const entry = e as Partial<BaselineEntry>;
|
|
146
|
+
if (
|
|
147
|
+
typeof entry.slug !== 'string' ||
|
|
148
|
+
typeof entry.declared !== 'string' ||
|
|
149
|
+
typeof entry.actual !== 'string' ||
|
|
150
|
+
typeof entry.reason !== 'string'
|
|
151
|
+
) {
|
|
152
|
+
throw new Error(`${file}: entry missing slug / declared / actual / reason`);
|
|
153
|
+
}
|
|
154
|
+
// A duplicate slug would silently overwrite the earlier entry, dropping
|
|
155
|
+
// its reason and its falsifier — the precise hole the suppression
|
|
156
|
+
// ratchet exists to prevent, and invisible to it because the file still
|
|
157
|
+
// parses.
|
|
158
|
+
if (out.has(entry.slug)) {
|
|
159
|
+
throw new Error(`${file}: duplicate entry for slug '${entry.slug}'`);
|
|
160
|
+
}
|
|
161
|
+
const ack = entry.steeredAck;
|
|
162
|
+
if (ack !== undefined && (typeof ack.phrase !== 'string' || typeof ack.reason !== 'string')) {
|
|
163
|
+
throw new Error(`${file}: ${entry.slug}: steeredAck needs a phrase and a reason`);
|
|
164
|
+
}
|
|
165
|
+
out.set(entry.slug, {
|
|
166
|
+
slug: entry.slug,
|
|
167
|
+
declared: entry.declared.toLowerCase(),
|
|
168
|
+
actual: entry.actual.toLowerCase(),
|
|
169
|
+
reason: entry.reason,
|
|
170
|
+
...(ack === undefined ? {} : { steeredAck: ack }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface Package {
|
|
177
|
+
slug: string;
|
|
178
|
+
/** Repo-relative, for messages. */
|
|
179
|
+
artefact: string;
|
|
180
|
+
declared: string;
|
|
181
|
+
promptPath: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The checkable set: an artefact with a `prompt_hash` AND a committed prompt.
|
|
186
|
+
*
|
|
187
|
+
* Either half absent means there is nothing to compare — exactly as before this
|
|
188
|
+
* gate existed. That is a deliberate non-finding: `prompt_hash` is an additive
|
|
189
|
+
* v1 field, so most historical artefacts predate it, and requiring a package
|
|
190
|
+
* retroactively would turn a real check into a migration event.
|
|
191
|
+
*/
|
|
192
|
+
export function collectPackages(reviewsDirAbs: string, repo: string, ledger: GateLedger): Package[] {
|
|
193
|
+
const names = fs
|
|
194
|
+
.readdirSync(reviewsDirAbs)
|
|
195
|
+
.filter((n) => n.endsWith('.findings.md'))
|
|
196
|
+
.sort();
|
|
197
|
+
ledger.plan(names);
|
|
198
|
+
|
|
199
|
+
const out: Package[] = [];
|
|
200
|
+
for (const name of names) {
|
|
201
|
+
const slug = name.replace(/\.findings\.md$/, '');
|
|
202
|
+
const abs = path.join(reviewsDirAbs, name);
|
|
203
|
+
const declared = parseArtifact(fs.readFileSync(abs, 'utf-8')).marker?.promptHash;
|
|
204
|
+
if (declared === undefined) {
|
|
205
|
+
ledger.outOfScope(name, 'not_applicable_kind');
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const promptPath = path.join(reviewsDirAbs, `${slug}.review-input`, 'prompt.md');
|
|
209
|
+
if (!fs.existsSync(promptPath)) {
|
|
210
|
+
ledger.skip(name, 'no_applicable_files');
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
out.push({
|
|
214
|
+
slug,
|
|
215
|
+
artefact: path.relative(repo, abs).split(path.sep).join('/'),
|
|
216
|
+
declared: declared.toLowerCase(),
|
|
217
|
+
promptPath,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function evaluate(
|
|
224
|
+
packages: readonly Package[],
|
|
225
|
+
baseline: Map<string, BaselineEntry>,
|
|
226
|
+
ledger: GateLedger,
|
|
227
|
+
): { findings: Finding[]; tally: Tally } {
|
|
228
|
+
const findings: Finding[] = [];
|
|
229
|
+
const tally: Tally = {
|
|
230
|
+
packages: packages.length,
|
|
231
|
+
binding: 0,
|
|
232
|
+
broken: 0,
|
|
233
|
+
baselined: 0,
|
|
234
|
+
steered: 0,
|
|
235
|
+
steeringAcknowledged: 0,
|
|
236
|
+
};
|
|
237
|
+
const matched = new Set<string>();
|
|
238
|
+
|
|
239
|
+
for (const pkg of packages) {
|
|
240
|
+
const name = `${pkg.slug}.findings.md`;
|
|
241
|
+
// ONE read: the hash must cover exactly the bytes the steering predicate
|
|
242
|
+
// screened. Two reads leave a window in which they could differ, which
|
|
243
|
+
// would publish a hash that does not correspond to the text that was
|
|
244
|
+
// checked — undetectable downstream.
|
|
245
|
+
const buf = fs.readFileSync(pkg.promptPath);
|
|
246
|
+
const text = buf.toString('utf-8');
|
|
247
|
+
const actual = sha256(buf);
|
|
248
|
+
let failed: string | null = null;
|
|
249
|
+
const entry = baseline.get(pkg.slug);
|
|
250
|
+
|
|
251
|
+
// Steering is checked on EVERY package, including one whose hash is
|
|
252
|
+
// baselined: the hash exemption covers a hash, never the content. It can
|
|
253
|
+
// only be acknowledged by its own `steeredAck`, pinned to the matched
|
|
254
|
+
// phrase — see the field's docstring for why an acknowledgement path has
|
|
255
|
+
// to exist at all.
|
|
256
|
+
const steer = preloadedVerdict(text);
|
|
257
|
+
if (steer !== null) {
|
|
258
|
+
tally.steered += 1;
|
|
259
|
+
const ack = entry?.steeredAck;
|
|
260
|
+
if (ack !== undefined && ack.phrase === steer) {
|
|
261
|
+
tally.steeringAcknowledged += 1;
|
|
262
|
+
matched.add(pkg.slug);
|
|
263
|
+
} else {
|
|
264
|
+
failed = `pre-loaded verdict in the committed prompt: ${JSON.stringify(steer)}`;
|
|
265
|
+
findings.push({
|
|
266
|
+
kind: 'steered-prompt',
|
|
267
|
+
slug: pkg.slug,
|
|
268
|
+
detail:
|
|
269
|
+
`${pkg.artefact}: the reviewer prompt carries ${JSON.stringify(steer)} — ` +
|
|
270
|
+
'a verdict authored into the prompt, which is the case-zero failure ' +
|
|
271
|
+
'(evaluator-independence). It is not covered by a hash exemption; ' +
|
|
272
|
+
'acknowledging it needs a `steeredAck` entry pinning this exact phrase.',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (actual === pkg.declared) {
|
|
278
|
+
tally.binding += 1;
|
|
279
|
+
} else {
|
|
280
|
+
tally.broken += 1;
|
|
281
|
+
if (entry !== undefined && entry.declared === pkg.declared && entry.actual === actual) {
|
|
282
|
+
tally.baselined += 1;
|
|
283
|
+
matched.add(pkg.slug);
|
|
284
|
+
} else if (entry !== undefined) {
|
|
285
|
+
matched.add(pkg.slug);
|
|
286
|
+
failed = 'baselined break moved';
|
|
287
|
+
findings.push({
|
|
288
|
+
kind: 'stale-baseline',
|
|
289
|
+
slug: pkg.slug,
|
|
290
|
+
detail:
|
|
291
|
+
`${pkg.artefact}: baselined as declared=${entry.declared} actual=${entry.actual}, ` +
|
|
292
|
+
`but the corpus now reads declared=${pkg.declared} actual=${actual}. ` +
|
|
293
|
+
'The record moved — repair it or re-record the entry with the reason it changed.',
|
|
294
|
+
});
|
|
295
|
+
} else {
|
|
296
|
+
failed = 'binding broken';
|
|
297
|
+
findings.push({
|
|
298
|
+
kind: 'binding-broken',
|
|
299
|
+
slug: pkg.slug,
|
|
300
|
+
detail:
|
|
301
|
+
`${pkg.artefact}: prompt_hash ${pkg.declared} does not re-derive from ` +
|
|
302
|
+
`${path.basename(path.dirname(pkg.promptPath))}/prompt.md (sha256 ${actual}). ` +
|
|
303
|
+
'The verdict is no longer attributable to the prompt that produced it.',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (failed === null) {
|
|
309
|
+
ledger.complete(name);
|
|
310
|
+
} else {
|
|
311
|
+
ledger.fail(name, failed);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A baseline entry naming a slug the corpus no longer carries is a hole the
|
|
316
|
+
// ratchet cannot see: it would keep exempting a record that is gone, and the
|
|
317
|
+
// day a package returns under that slug it would be exempt for free.
|
|
318
|
+
for (const [slug, entry] of baseline) {
|
|
319
|
+
if (!matched.has(slug)) {
|
|
320
|
+
findings.push({
|
|
321
|
+
kind: 'stale-baseline',
|
|
322
|
+
slug,
|
|
323
|
+
detail:
|
|
324
|
+
`baseline entry '${slug}' matches no broken binding in the corpus ` +
|
|
325
|
+
`(reason recorded: ${entry.reason.slice(0, 80)}…) — the record was repaired or ` +
|
|
326
|
+
'removed, so the entry must go with it.',
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { findings, tally };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* `--self-test`: prove the CLI still rejects, not just that the functions do.
|
|
336
|
+
*
|
|
337
|
+
* The fixtures are the two failures the roadmap named — a one-byte prompt edit
|
|
338
|
+
* and a pre-loaded verdict — plus an intact corpus, so a suite that only ever
|
|
339
|
+
* rejects would be visible as a suite that discriminates nothing.
|
|
340
|
+
*/
|
|
341
|
+
function selfTest(): number {
|
|
342
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-binding-selftest-'));
|
|
343
|
+
const marker = (h: string): string =>
|
|
344
|
+
`<!-- completion-review: v1 | reviewed: 2026-08-13 | scope: ${'a'.repeat(64)} | ` +
|
|
345
|
+
`diff: abc1234 | reviewer: r2-fresh-subagent-x | prompt_hash: ${h} -->`;
|
|
346
|
+
|
|
347
|
+
const build = (slug: string, prompt: string, declared: string): string => {
|
|
348
|
+
const dir = fs.mkdtempSync(path.join(root, 'corpus-'));
|
|
349
|
+
const reviews = path.join(dir, 'reviews');
|
|
350
|
+
fs.mkdirSync(path.join(reviews, `${slug}.review-input`), { recursive: true });
|
|
351
|
+
fs.writeFileSync(
|
|
352
|
+
path.join(reviews, `${slug}.findings.md`),
|
|
353
|
+
`# Findings: ${slug}\n${marker(declared)}\n`,
|
|
354
|
+
'utf-8',
|
|
355
|
+
);
|
|
356
|
+
fs.writeFileSync(path.join(reviews, `${slug}.review-input`, 'prompt.md'), prompt, 'utf-8');
|
|
357
|
+
return dir;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const invoke = (corpus: string): number =>
|
|
361
|
+
runGateCli(
|
|
362
|
+
REPO_ROOT,
|
|
363
|
+
'src/scripts/check_review_prompt_binding.ts',
|
|
364
|
+
// No baseline: a self-test that could be silenced by the shipped
|
|
365
|
+
// exemptions would prove the exemptions, not the detection.
|
|
366
|
+
['--repo', corpus, '--reviews-dir', 'reviews', '--baseline', 'absent-baseline.json'],
|
|
367
|
+
corpus,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const intact = 'Review the diff against the roadmap and report findings.\n';
|
|
371
|
+
const cases: SelfTestCase[] = [
|
|
372
|
+
{
|
|
373
|
+
name: 'intact binding is accepted',
|
|
374
|
+
expect: 'accept',
|
|
375
|
+
run: () => invoke(build('intact', intact, sha256(intact))),
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: 'a one-byte prompt edit breaks the binding',
|
|
379
|
+
expect: 'reject',
|
|
380
|
+
run: () => invoke(build('edited', `${intact}.`, sha256(intact))),
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: 'a pre-loaded verdict in the prompt is caught even when the hash re-derives',
|
|
384
|
+
expect: 'reject',
|
|
385
|
+
run: () => {
|
|
386
|
+
const steered = `${intact}NO-FINDINGS is expected and welcome.\n`;
|
|
387
|
+
return invoke(build('steered', steered, sha256(steered)));
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
return runSelfTest({ gate: 'check_review_prompt_binding', cases, minCases: 3, minRejectCases: 2 });
|
|
394
|
+
} finally {
|
|
395
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface Args {
|
|
400
|
+
repo: string;
|
|
401
|
+
reviewsDir: string;
|
|
402
|
+
baseline: string;
|
|
403
|
+
json: boolean;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseArgs(argv: readonly string[]): Args {
|
|
407
|
+
const args: Args = {
|
|
408
|
+
repo: REPO_ROOT,
|
|
409
|
+
reviewsDir: DEFAULT_REVIEWS_DIR,
|
|
410
|
+
baseline: DEFAULT_BASELINE,
|
|
411
|
+
json: false,
|
|
412
|
+
};
|
|
413
|
+
for (let i = 0; i < argv.length; i++) {
|
|
414
|
+
const a = argv[i] as string;
|
|
415
|
+
const take = (): string => {
|
|
416
|
+
const v = argv[++i];
|
|
417
|
+
if (v === undefined) {
|
|
418
|
+
process.stderr.write(`check_review_prompt_binding: ${a} expects one argument\n`);
|
|
419
|
+
// § 6: the count is emitted on EVERY exit path, exit 2 included —
|
|
420
|
+
// the coverage guard must never be left without a number.
|
|
421
|
+
process.stdout.write('scanned: 0\n');
|
|
422
|
+
process.exit(2);
|
|
423
|
+
}
|
|
424
|
+
return v;
|
|
425
|
+
};
|
|
426
|
+
if (a === '--repo') args.repo = path.resolve(take());
|
|
427
|
+
else if (a === '--reviews-dir') args.reviewsDir = take();
|
|
428
|
+
else if (a === '--baseline') args.baseline = take();
|
|
429
|
+
else if (a === '--json') args.json = true;
|
|
430
|
+
else if (a === '-h' || a === '--help') {
|
|
431
|
+
process.stdout.write(
|
|
432
|
+
'usage: check_review_prompt_binding [--repo DIR] [--reviews-dir REL] ' +
|
|
433
|
+
'[--baseline REL] [--json]\n',
|
|
434
|
+
);
|
|
435
|
+
process.exit(0);
|
|
436
|
+
} else {
|
|
437
|
+
process.stderr.write(`check_review_prompt_binding: unrecognized argument: ${a}\n`);
|
|
438
|
+
process.stdout.write('scanned: 0\n');
|
|
439
|
+
process.exit(2);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return args;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function main(argv?: readonly string[]): number {
|
|
446
|
+
const raw = argv ?? process.argv.slice(2);
|
|
447
|
+
if (raw.includes('--self-test')) {
|
|
448
|
+
return selfTest();
|
|
449
|
+
}
|
|
450
|
+
const args = parseArgs(raw);
|
|
451
|
+
const reviewsDirAbs = path.isAbsolute(args.reviewsDir)
|
|
452
|
+
? args.reviewsDir
|
|
453
|
+
: path.join(args.repo, args.reviewsDir);
|
|
454
|
+
const baselineAbs = path.isAbsolute(args.baseline)
|
|
455
|
+
? args.baseline
|
|
456
|
+
: path.join(args.repo, args.baseline);
|
|
457
|
+
|
|
458
|
+
const ledger = new GateLedger('check_review_prompt_binding');
|
|
459
|
+
let packages: Package[];
|
|
460
|
+
let baseline: Map<string, BaselineEntry>;
|
|
461
|
+
try {
|
|
462
|
+
packages = collectPackages(reviewsDirAbs, args.repo, ledger);
|
|
463
|
+
baseline = loadBaseline(baselineAbs);
|
|
464
|
+
} catch (exc) {
|
|
465
|
+
process.stderr.write(
|
|
466
|
+
`❌ check_review_prompt_binding: ${exc instanceof Error ? exc.message : String(exc)}\n`,
|
|
467
|
+
);
|
|
468
|
+
process.stdout.write('scanned: 0\n');
|
|
469
|
+
return 2;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// In `--json` mode stdout carries the payload and NOTHING else, because both
|
|
473
|
+
// baseline entries name `--json` as their falsifier and a falsifier that has
|
|
474
|
+
// to be un-interleaved before it parses is not re-runnable. The human lines,
|
|
475
|
+
// the ledger and the `scanned:` count all move to stderr there.
|
|
476
|
+
const out = args.json
|
|
477
|
+
? process.stderr.write.bind(process.stderr)
|
|
478
|
+
: process.stdout.write.bind(process.stdout);
|
|
479
|
+
|
|
480
|
+
let findings: Finding[];
|
|
481
|
+
let tally: Tally;
|
|
482
|
+
try {
|
|
483
|
+
// The count is the CHECKABLE set, not the directory listing: an artefact
|
|
484
|
+
// with no prompt package was not inspected, and counting it would be the
|
|
485
|
+
// false-coverage claim `scanned:` exists to prevent. `reportScanned`
|
|
486
|
+
// asserts and publishes the SAME number — splitting the two is how a
|
|
487
|
+
// published count drifts from the validated one.
|
|
488
|
+
reportScanned(
|
|
489
|
+
{
|
|
490
|
+
gate: 'check_review_prompt_binding',
|
|
491
|
+
scanned: packages.length,
|
|
492
|
+
units: 'reviewer prompt package(s)',
|
|
493
|
+
roots: [args.reviewsDir],
|
|
494
|
+
},
|
|
495
|
+
out,
|
|
496
|
+
);
|
|
497
|
+
({ findings, tally } = evaluate(packages, baseline, ledger));
|
|
498
|
+
} catch (exc) {
|
|
499
|
+
if (exc instanceof DeadScopeError) {
|
|
500
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
501
|
+
return 1;
|
|
502
|
+
}
|
|
503
|
+
// Anything else here is an internal error, and § 6 maps those to 2 so a
|
|
504
|
+
// broken gate cannot block the change that repairs it. Letting it throw
|
|
505
|
+
// would exit 1 — the "policy violation, block" code — inverting the
|
|
506
|
+
// contract for exactly the failures the docstring promises are exit 2.
|
|
507
|
+
process.stderr.write(
|
|
508
|
+
`❌ check_review_prompt_binding: ${exc instanceof Error ? exc.message : String(exc)}\n`,
|
|
509
|
+
);
|
|
510
|
+
return 2;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
ledger.report(out);
|
|
514
|
+
out(
|
|
515
|
+
`prompt bindings: ${String(tally.packages)} package(s) · ${String(tally.binding)} binding · ` +
|
|
516
|
+
`${String(tally.steered)} steered (${String(tally.steeringAcknowledged)} acknowledged) · ` +
|
|
517
|
+
`${String(tally.broken)} broken (${String(tally.baselined)} baselined)\n`,
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
if (args.json) {
|
|
521
|
+
process.stdout.write(`${JSON.stringify({ tally, findings }, null, 2)}\n`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (findings.length === 0) {
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
process.stderr.write(`\n❌ ${String(findings.length)} prompt-binding finding(s):\n`);
|
|
528
|
+
for (const f of findings) {
|
|
529
|
+
process.stderr.write(` [${f.kind}] ${f.detail}\n`);
|
|
530
|
+
}
|
|
531
|
+
return 1;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function _isCliEntry(): boolean {
|
|
535
|
+
if (process.argv[1] === undefined) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
return (
|
|
543
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
544
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
545
|
+
);
|
|
546
|
+
} catch {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (_isCliEntry()) {
|
|
552
|
+
process.exit(main());
|
|
553
|
+
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* WHY THIS GATE EXISTS (road-to-conformance-round5 Phase 1, measured 2026-08-07)
|
|
7
7
|
* ---------------------------------------------------------------------------
|
|
8
|
-
* `docs/contracts/rule-router.md
|
|
8
|
+
* `docs/contracts/rule-router.md` (§ "The router earns its place as a
|
|
9
|
+
* **compile-time** artifact", measured: zero consumers under
|
|
10
|
+
* `src/scripts/hooks/`) states that nothing loads
|
|
9
11
|
* `dist/router.json` at runtime, so **projection is the only reach mechanism
|
|
10
12
|
* there is**: a non-kernel rule activates by the model's judgment over text
|
|
11
13
|
* already in context, and the per-tool rule tree is what puts it there. That
|
|
@@ -227,9 +227,20 @@ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
|
|
|
227
227
|
file: 'src/config/rule-enforcement-baseline.json',
|
|
228
228
|
listKey: null,
|
|
229
229
|
tier: 'string_list',
|
|
230
|
-
newInThisChange: true,
|
|
231
230
|
what: 'rules predating the enforced_by-declaration ratchet',
|
|
232
231
|
},
|
|
232
|
+
{
|
|
233
|
+
// Two committed prompt→verdict bindings that do not re-derive. Both
|
|
234
|
+
// sit inside round records § 2.7 declares immutable, so neither may be
|
|
235
|
+
// repaired by editing the record — the entry pins BOTH hashes, so a
|
|
236
|
+
// later repair or a further corruption reds instead of passing.
|
|
237
|
+
file: 'src/config/review-prompt-binding-baseline.json',
|
|
238
|
+
listKey: 'entries',
|
|
239
|
+
tier: 'object',
|
|
240
|
+
keyFields: ['slug'],
|
|
241
|
+
newInThisChange: true,
|
|
242
|
+
what: 'review prompts whose recorded prompt_hash does not re-derive',
|
|
243
|
+
},
|
|
233
244
|
];
|
|
234
245
|
|
|
235
246
|
export interface SuppressionFinding {
|
|
@@ -391,6 +402,12 @@ export function main(): number {
|
|
|
391
402
|
const entries = loadEntries(spec);
|
|
392
403
|
const reasonField = spec.reasonField ?? 'reason';
|
|
393
404
|
|
|
405
|
+
// Declared BEFORE the stale-flag branch below, which is a file-level
|
|
406
|
+
// finding: it must feed the single terminal resolution at the end of
|
|
407
|
+
// this iteration rather than resolve the target itself. Resolving twice
|
|
408
|
+
// is a `LedgerUsageError` that aborts the whole gate.
|
|
409
|
+
let fileFindings = 0;
|
|
410
|
+
|
|
394
411
|
let comparison: RatchetComparison;
|
|
395
412
|
try {
|
|
396
413
|
comparison = compareToBaseRef({
|
|
@@ -413,8 +430,8 @@ export function main(): number {
|
|
|
413
430
|
`\`newInThisChange\` is still set, but the baseline now resolves at ${baseRef} — ` +
|
|
414
431
|
'the flag has served its purpose and must be removed from SUPPRESSION_INVENTORY. ' +
|
|
415
432
|
'Leaving it set makes a future mistyped path pass as a new baseline forever.';
|
|
416
|
-
ledger.fail(spec.file, detail);
|
|
417
433
|
findings.push({ file: spec.file, entry: '(whole file)', kind: 'uninventoried', detail });
|
|
434
|
+
fileFindings += 1;
|
|
418
435
|
}
|
|
419
436
|
} catch (exc) {
|
|
420
437
|
if (exc instanceof BaseRefUnavailableError) {
|
|
@@ -426,7 +443,6 @@ export function main(): number {
|
|
|
426
443
|
}
|
|
427
444
|
|
|
428
445
|
const added = new Set(comparison.added);
|
|
429
|
-
let fileFindings = 0;
|
|
430
446
|
|
|
431
447
|
for (const entry of entries) {
|
|
432
448
|
if (spec.tier === 'object' && entry.reason.length < MIN_REASON_CHARS) {
|