@event4u/agent-config 9.22.0 → 9.24.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 +84 -238
- package/README.md +2 -2
- package/dist/agent-src/commands/agent-handoff.md +10 -3
- package/dist/agent-src/commands/analyze/inbox.md +190 -0
- package/dist/agent-src/commands/analyze.md +4 -3
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- package/dist/agent-src/commands/pr/create.md +6 -6
- package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
- package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
- package/dist/agent-src/rules/context-hygiene.md +20 -0
- package/dist/agent-src/rules/downstream-changes.md +26 -0
- package/dist/agent-src/rules/think-before-action.md +25 -0
- package/dist/agent-src/rules/token-budget-discipline.md +46 -3
- package/dist/agent-src/rules/token-efficiency.md +13 -0
- package/dist/agent-src/rules/ui-audit-gate.md +33 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
- package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
- package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
- package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
- package/dist/cli-delegate/cmd_export.js +2 -1
- package/dist/cli-delegate/cmd_prune.js +2 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
- package/dist/cli-delegate/cmd_settings_set.js +3370 -0
- package/dist/cli-delegate/cmd_sync.js +2 -1
- package/dist/cli-delegate/cmd_uninstall.js +2 -1
- package/dist/cli-delegate/cmd_validate.js +2 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +54 -27
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +79 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/scripts/condense.js +47 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/settings.js +82 -0
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +191 -0
- package/dist/shared/settingsClasses.js.map +1 -0
- package/docs/contracts/settings-classes.md +342 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
- package/docs/guidelines/agent-infra/false-green.md +187 -0
- package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
- package/package.json +3 -1
- package/src/config/ci-local-parity.yml +12 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +54 -12
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/pack-size-budget.json +4 -3
- package/src/scripts/_cli/cmd_settings_set.ts +509 -0
- package/src/scripts/_dispatch.bash +16 -0
- package/src/scripts/_lib/gate_ledger.ts +295 -0
- package/src/scripts/_lib/gate_result.ts +101 -0
- package/src/scripts/_lib/gate_self_test.ts +129 -0
- package/src/scripts/_lib/measured_render.ts +97 -0
- package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
- package/src/scripts/check_gate_completeness.ts +244 -0
- package/src/scripts/check_gate_coverage.ts +35 -6
- package/src/scripts/check_references.ts +125 -15
- package/src/scripts/check_review_dispositions.ts +14 -1
- package/src/scripts/check_suppression_hygiene.ts +503 -0
- package/src/scripts/condense.ts +47 -2
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +65 -1
- package/src/scripts/hooks/host_semantics.ts +140 -0
- package/src/scripts/lint_framework_leakage.ts +147 -15
- package/src/scripts/lint_mandated_lines.ts +339 -0
- package/src/scripts/lint_roadmap_complexity.ts +102 -11
- package/src/scripts/lint_roadmap_family_cap.ts +191 -0
- package/src/scripts/lint_settings_classes.ts +397 -0
- package/src/scripts/lint_token_budget_discipline.ts +109 -1
- package/src/scripts/measure_rule_absoluta.ts +231 -0
- package/src/scripts/report_imperative_density.ts +229 -0
- package/src/scripts/verify_physical_move.ts +10 -1
- package/src/server/io/yamlIO.ts +173 -0
- package/src/shared/settingsClasses.ts +235 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Suppression hygiene across every human-authored allowlist and baseline.
|
|
4
|
+
*
|
|
5
|
+
* Three properties, all of which this estate previously carried by convention:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Shrink-only, mechanically.** Every list is compared entry-SET against
|
|
8
|
+
* the base ref, not entry-COUNT against a checked-in number. A count
|
|
9
|
+
* comparison passes swap-one-out-add-one-in; the set comparison does not.
|
|
10
|
+
* (`_lib/ratchet_base_ref.ts` carries the mechanism and the rename handling.)
|
|
11
|
+
* 2. **Every human-authored entry carries a `reason`.** An unexplained
|
|
12
|
+
* suppression cannot be audited by the next reader, and "the diff explains
|
|
13
|
+
* it" stops being true the moment the diff is six months old.
|
|
14
|
+
* 3. **Every NEW entry additionally carries a `falsifier`** — a re-runnable
|
|
15
|
+
* command that decides the entry. An entry with a falsifier is a ratchet;
|
|
16
|
+
* one without is a hole. Existing entries are grandfathered by count rather
|
|
17
|
+
* than retro-filled, because a retro-fill of 38 entries would be 38 guesses
|
|
18
|
+
* at what a past author meant, and a plausible-looking guess is worse than
|
|
19
|
+
* an admitted gap.
|
|
20
|
+
*
|
|
21
|
+
* **Why estate-level rather than one call per gate.** The roadmap step that
|
|
22
|
+
* charters this names the framework-leakage allowlist as the first adopter,
|
|
23
|
+
* and it is first in the inventory below. The enforcement itself lives here,
|
|
24
|
+
* once, for two reasons the per-gate wiring cannot match: a NEW allowlist added
|
|
25
|
+
* next month is caught by the inventory check even if its author never wires
|
|
26
|
+
* anything, and a hot lint that runs on every save does not acquire a
|
|
27
|
+
* dependency on git plumbing (`git show`, rename detection, base-ref
|
|
28
|
+
* resolution) which is exactly where CI-vs-local divergence gets introduced.
|
|
29
|
+
*
|
|
30
|
+
* **Gaming risk.** The cheapest degenerate pass is a `falsifier` field holding
|
|
31
|
+
* a command that always exits 0 (`true`, `echo ok`). This gate rejects the
|
|
32
|
+
* literal degenerate forms and enforces a minimum length; it cannot execute the
|
|
33
|
+
* command to check that it discriminates, and does not pretend to. The residual
|
|
34
|
+
* is a human read at review time, and it is named here so that it is a known
|
|
35
|
+
* gap rather than an assumed guarantee.
|
|
36
|
+
*
|
|
37
|
+
* **Lifecycle.** This gate lands ENFORCED, not advisory, because it was run
|
|
38
|
+
* against the real corpus before wiring and the live estate already satisfies
|
|
39
|
+
* it: all 38 object-tier entries across 10 surfaces carry a `reason`, and the
|
|
40
|
+
* `falsifier` requirement applies to NEW entries only. Advisory-until-empty is
|
|
41
|
+
* the rule for a gate whose corpus has findings on day one; this one had none,
|
|
42
|
+
* and shipping it advisory would have been a hedge rather than a stage.
|
|
43
|
+
*
|
|
44
|
+
* CLI contract: exit 0 = clean, 1 = growth, missing reason, missing falsifier
|
|
45
|
+
* on a new entry, an un-inventoried suppression file, or a dead scope.
|
|
46
|
+
* `--quiet` suppresses the per-file green lines.
|
|
47
|
+
*/
|
|
48
|
+
import * as fs from 'node:fs';
|
|
49
|
+
import * as path from 'node:path';
|
|
50
|
+
import process from 'node:process';
|
|
51
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
52
|
+
|
|
53
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
54
|
+
import {
|
|
55
|
+
BaseRefUnavailableError,
|
|
56
|
+
compareToBaseRef,
|
|
57
|
+
resolveBaseRef,
|
|
58
|
+
type RatchetComparison,
|
|
59
|
+
} from './_lib/ratchet_base_ref.js';
|
|
60
|
+
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
61
|
+
|
|
62
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
63
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
64
|
+
const QUIET = process.argv.slice(2).includes('--quiet');
|
|
65
|
+
|
|
66
|
+
/** Minimum characters in a `reason` / `falsifier` before it can be audited. */
|
|
67
|
+
export const MIN_REASON_CHARS = 20;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Falsifier values that satisfy the schema while deciding nothing.
|
|
71
|
+
*
|
|
72
|
+
* Named explicitly rather than described, so the check is testable.
|
|
73
|
+
*/
|
|
74
|
+
const DEGENERATE_FALSIFIERS = new Set(['true', ':', 'echo ok', 'exit 0', 'n/a', 'none', 'tbd']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Entry tiers.
|
|
78
|
+
*
|
|
79
|
+
* - `object` — human-authored records; carry `reason`, and `falsifier` when new.
|
|
80
|
+
* - `string_list` — bare-string carve-outs whose entry IS the whole record, so
|
|
81
|
+
* there is nowhere to put a reason. Subject to no-growth only; a file-level
|
|
82
|
+
* rationale lives in the JSON's own `_comment` / `_doc` key.
|
|
83
|
+
*/
|
|
84
|
+
export type SuppressionTier = 'object' | 'string_list';
|
|
85
|
+
|
|
86
|
+
export interface SuppressionSpec {
|
|
87
|
+
/** Repo-relative path. */
|
|
88
|
+
file: string;
|
|
89
|
+
/** Key holding the entry list (or `null` when the root IS the list). */
|
|
90
|
+
listKey: string | null;
|
|
91
|
+
tier: SuppressionTier;
|
|
92
|
+
/** Field names, in order, that identify an entry for set comparison. */
|
|
93
|
+
keyFields?: readonly string[];
|
|
94
|
+
/** Field carrying the human rationale (`reason` unless noted). */
|
|
95
|
+
reasonField?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Whether the entry SET may gain members.
|
|
98
|
+
*
|
|
99
|
+
* `forbidden` (default) is right for a suppression list: a new entry is a
|
|
100
|
+
* new hole. `requires_falsifier` is right for a registry OF ratchets —
|
|
101
|
+
* `gate-violation-baselines.json` gains an entry every time a new gate
|
|
102
|
+
* records its debt, and refusing that would mean no gate may ever be
|
|
103
|
+
* baselined, which forbids the practice this whole roadmap is asking for.
|
|
104
|
+
* Its shrink-only property is the per-entry `count`, enforced by
|
|
105
|
+
* `_lib/gate_baseline.ts`, not set membership.
|
|
106
|
+
*/
|
|
107
|
+
growth?: 'forbidden' | 'requires_falsifier';
|
|
108
|
+
/**
|
|
109
|
+
* Fields that decide whether an entry is position-keyed or content-keyed.
|
|
110
|
+
*
|
|
111
|
+
* The two-tier split this roadmap asks for is, in this tree, entirely about
|
|
112
|
+
* ONE surface: human entries keyed by line number. Nothing here mixes human
|
|
113
|
+
* and machine-generated records — that half of the split is already the
|
|
114
|
+
* shape. What does exist is the recorded drift failure, where inserting a
|
|
115
|
+
* paragraph above an allowlisted line re-fires the ratchet on an entry
|
|
116
|
+
* nobody touched. Reporting the position-keyed count per run makes the
|
|
117
|
+
* migration to content anchors visible instead of aspirational.
|
|
118
|
+
*/
|
|
119
|
+
driftKey?: { positionField: string; anchorField: string };
|
|
120
|
+
/** Why this list exists at all — printed with the count, so absence is visible. */
|
|
121
|
+
what: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The declared inventory of human-authored suppression surfaces.
|
|
126
|
+
*
|
|
127
|
+
* `check_suppression_inventory` (below) fails when a `*allowlist*.json` or
|
|
128
|
+
* `*baseline*.json` exists under the scanned roots and is absent here — an
|
|
129
|
+
* allowlist nobody listed is an allowlist nobody ratchets.
|
|
130
|
+
*/
|
|
131
|
+
export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
|
|
132
|
+
{
|
|
133
|
+
file: 'src/scripts/lint_framework_leakage_allowlist.json',
|
|
134
|
+
listKey: 'entries',
|
|
135
|
+
tier: 'object',
|
|
136
|
+
keyFields: ['file'],
|
|
137
|
+
driftKey: { positionField: 'lines', anchorField: 'anchor' },
|
|
138
|
+
what: 'per-line framework-token exemptions in generic artefacts',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
file: 'src/scripts/pack_dependency_allowlist.json',
|
|
142
|
+
listKey: 'entries',
|
|
143
|
+
tier: 'object',
|
|
144
|
+
keyFields: ['pack', 'slug'],
|
|
145
|
+
what: 'cross-pack dependency edges accepted as-is',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
file: 'src/scripts/lint_skill_originality_allowlist.json',
|
|
149
|
+
listKey: 'pairs',
|
|
150
|
+
tier: 'object',
|
|
151
|
+
keyFields: ['skill_a', 'skill_b'],
|
|
152
|
+
what: 'skill pairs allowed to overlap',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
file: 'src/scripts/lint_workflow_security_allowlist.json',
|
|
156
|
+
listKey: 'findings',
|
|
157
|
+
tier: 'object',
|
|
158
|
+
keyFields: ['workflow', 'rule'],
|
|
159
|
+
what: 'accepted workflow-security findings',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
file: 'src/scripts/lint_skill_scripts_readonly_allowlist.json',
|
|
163
|
+
listKey: 'entries',
|
|
164
|
+
tier: 'object',
|
|
165
|
+
keyFields: ['path'],
|
|
166
|
+
what: 'skill scripts permitted to write',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
file: 'src/scripts/audit_skill_overlap_allowlist.json',
|
|
170
|
+
listKey: 'entries',
|
|
171
|
+
tier: 'object',
|
|
172
|
+
keyFields: ['skill_a', 'skill_b', 'path'],
|
|
173
|
+
what: 'accepted skill-overlap findings',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
file: 'src/scripts/lint_skill_descriptions_allowlist.json',
|
|
177
|
+
listKey: 'entries',
|
|
178
|
+
tier: 'object',
|
|
179
|
+
keyFields: ['skill', 'path'],
|
|
180
|
+
what: 'accepted skill-description findings',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
file: 'src/scripts/check_no_conflict_markers_allowlist.json',
|
|
184
|
+
listKey: 'files',
|
|
185
|
+
tier: 'string_list',
|
|
186
|
+
what: 'files permitted to contain conflict-marker-shaped text',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
file: 'src/scripts/external_sources_denylist.json',
|
|
190
|
+
listKey: 'skip_paths',
|
|
191
|
+
tier: 'string_list',
|
|
192
|
+
what: 'paths exempt from the external-source scan',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
file: 'src/config/gate-violation-baselines.json',
|
|
196
|
+
listKey: 'gates',
|
|
197
|
+
tier: 'object',
|
|
198
|
+
reasonField: 'note',
|
|
199
|
+
growth: 'requires_falsifier',
|
|
200
|
+
what: 'per-gate known-debt counts',
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
export interface SuppressionFinding {
|
|
205
|
+
file: string;
|
|
206
|
+
entry: string;
|
|
207
|
+
kind: 'growth' | 'missing_reason' | 'missing_falsifier' | 'degenerate_falsifier' | 'uninventoried';
|
|
208
|
+
detail: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface LoadedEntry {
|
|
212
|
+
key: string;
|
|
213
|
+
reason: string;
|
|
214
|
+
falsifier: string;
|
|
215
|
+
/** True when the entry is keyed by position and carries no content anchor. */
|
|
216
|
+
positionKeyed: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function entryKey(spec: SuppressionSpec, value: unknown, index: number): string {
|
|
220
|
+
if (typeof value === 'string') {
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
if (value !== null && typeof value === 'object') {
|
|
224
|
+
const rec = value as Record<string, unknown>;
|
|
225
|
+
const parts = (spec.keyFields ?? []).map((f) => (typeof rec[f] === 'string' ? String(rec[f]) : ''));
|
|
226
|
+
const joined = parts.filter((p) => p !== '').join('|');
|
|
227
|
+
if (joined !== '') {
|
|
228
|
+
return joined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return `#${String(index)}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Read one suppression file into comparable entries. */
|
|
235
|
+
export function loadEntries(spec: SuppressionSpec, repoRoot = REPO_ROOT): LoadedEntry[] {
|
|
236
|
+
const raw = fs.readFileSync(path.join(repoRoot, spec.file), 'utf-8');
|
|
237
|
+
const parsed: unknown = JSON.parse(raw);
|
|
238
|
+
const container = spec.listKey === null ? parsed : (parsed as Record<string, unknown>)[spec.listKey];
|
|
239
|
+
const reasonField = spec.reasonField ?? 'reason';
|
|
240
|
+
|
|
241
|
+
const rows: LoadedEntry[] = [];
|
|
242
|
+
const push = (value: unknown, key: string): void => {
|
|
243
|
+
const rec = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
|
244
|
+
const drift = spec.driftKey;
|
|
245
|
+
const anchored =
|
|
246
|
+
drift !== undefined &&
|
|
247
|
+
typeof rec[drift.anchorField] === 'string' &&
|
|
248
|
+
String(rec[drift.anchorField]).trim() !== '';
|
|
249
|
+
const positional =
|
|
250
|
+
drift !== undefined && Array.isArray(rec[drift.positionField]) && !anchored;
|
|
251
|
+
rows.push({
|
|
252
|
+
key,
|
|
253
|
+
reason: typeof rec[reasonField] === 'string' ? String(rec[reasonField]).trim() : '',
|
|
254
|
+
falsifier: typeof rec['falsifier'] === 'string' ? String(rec['falsifier']).trim() : '',
|
|
255
|
+
positionKeyed: positional,
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (Array.isArray(container)) {
|
|
260
|
+
container.forEach((item, i) => push(item, entryKey(spec, item, i)));
|
|
261
|
+
} else if (container !== null && typeof container === 'object') {
|
|
262
|
+
for (const [k, v] of Object.entries(container as Record<string, unknown>)) {
|
|
263
|
+
push(v, k);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return rows;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Every `*allowlist*.json` / `*baseline*.json` under the scanned roots. */
|
|
270
|
+
export function discoverSuppressionFiles(repoRoot = REPO_ROOT): string[] {
|
|
271
|
+
const roots = ['src/scripts', 'src/config'];
|
|
272
|
+
const out: string[] = [];
|
|
273
|
+
for (const root of roots) {
|
|
274
|
+
const dir = path.join(repoRoot, root);
|
|
275
|
+
let names: string[];
|
|
276
|
+
try {
|
|
277
|
+
names = fs.readdirSync(dir);
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
for (const name of names) {
|
|
282
|
+
if (!name.endsWith('.json')) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (/allowlist|denylist|baselines?/i.test(name)) {
|
|
286
|
+
out.push(`${root}/${name}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out.sort();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function main(): number {
|
|
294
|
+
const findings: SuppressionFinding[] = [];
|
|
295
|
+
const ledger = new GateLedger('check_suppression_hygiene');
|
|
296
|
+
|
|
297
|
+
// An un-inventoried suppression file is the growth path this gate would
|
|
298
|
+
// otherwise miss entirely: adding a brand-new allowlist is unbounded growth
|
|
299
|
+
// that no per-file ratchet can see.
|
|
300
|
+
const declared = new Set(SUPPRESSION_INVENTORY.map((s) => s.file));
|
|
301
|
+
const discovered = discoverSuppressionFiles();
|
|
302
|
+
for (const file of discovered) {
|
|
303
|
+
if (!declared.has(file)) {
|
|
304
|
+
findings.push({
|
|
305
|
+
file,
|
|
306
|
+
entry: '(whole file)',
|
|
307
|
+
kind: 'uninventoried',
|
|
308
|
+
detail:
|
|
309
|
+
'a suppression-shaped file that SUPPRESSION_INVENTORY does not declare — add it ' +
|
|
310
|
+
'there (with its entry shape) so it is ratcheted, or rename it if it is not a ' +
|
|
311
|
+
'suppression surface',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
assertScanned({
|
|
318
|
+
gate: 'check_suppression_hygiene',
|
|
319
|
+
scanned: SUPPRESSION_INVENTORY.length,
|
|
320
|
+
units: 'suppression file(s)',
|
|
321
|
+
roots: ['src/scripts', 'src/config'],
|
|
322
|
+
});
|
|
323
|
+
} catch (exc) {
|
|
324
|
+
if (exc instanceof DeadScopeError) {
|
|
325
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
326
|
+
return 1;
|
|
327
|
+
}
|
|
328
|
+
throw exc;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const baseRef = resolveBaseRef(REPO_ROOT);
|
|
332
|
+
if (baseRef === null) {
|
|
333
|
+
process.stderr.write(
|
|
334
|
+
'❌ check_suppression_hygiene: no base ref resolves (tried RATCHET_BASE_REF, ' +
|
|
335
|
+
'GITHUB_BASE_REF, the PR merge parent, origin/main, main). A shrink-only check ' +
|
|
336
|
+
'that cannot see its "before" has verified nothing, so this is a failure rather ' +
|
|
337
|
+
'than a skip.\n',
|
|
338
|
+
);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
ledger.plan(SUPPRESSION_INVENTORY.map((s) => s.file));
|
|
343
|
+
const lines: string[] = [];
|
|
344
|
+
|
|
345
|
+
for (const spec of SUPPRESSION_INVENTORY) {
|
|
346
|
+
const absolute = path.join(REPO_ROOT, spec.file);
|
|
347
|
+
if (!fs.existsSync(absolute)) {
|
|
348
|
+
// A declared file that vanished is a dead entry in the inventory,
|
|
349
|
+
// not an absence of findings.
|
|
350
|
+
ledger.fail(spec.file, 'declared in SUPPRESSION_INVENTORY but absent on disk');
|
|
351
|
+
findings.push({
|
|
352
|
+
file: spec.file,
|
|
353
|
+
entry: '(whole file)',
|
|
354
|
+
kind: 'uninventoried',
|
|
355
|
+
detail: 'declared in SUPPRESSION_INVENTORY but absent on disk',
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const entries = loadEntries(spec);
|
|
361
|
+
const reasonField = spec.reasonField ?? 'reason';
|
|
362
|
+
|
|
363
|
+
let comparison: RatchetComparison;
|
|
364
|
+
try {
|
|
365
|
+
comparison = compareToBaseRef({
|
|
366
|
+
baselinePath: spec.file,
|
|
367
|
+
baseRef,
|
|
368
|
+
repoRoot: REPO_ROOT,
|
|
369
|
+
entriesOf: (parsed) => entriesOfSpec(spec, parsed),
|
|
370
|
+
});
|
|
371
|
+
} catch (exc) {
|
|
372
|
+
if (exc instanceof BaseRefUnavailableError) {
|
|
373
|
+
ledger.fail(spec.file, exc.message);
|
|
374
|
+
findings.push({ file: spec.file, entry: '(whole file)', kind: 'growth', detail: exc.message });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
throw exc;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const added = new Set(comparison.added);
|
|
381
|
+
let fileFindings = 0;
|
|
382
|
+
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
if (spec.tier === 'object' && entry.reason.length < MIN_REASON_CHARS) {
|
|
385
|
+
findings.push({
|
|
386
|
+
file: spec.file,
|
|
387
|
+
entry: entry.key,
|
|
388
|
+
kind: 'missing_reason',
|
|
389
|
+
detail: `\`${reasonField}\` is missing or shorter than ${String(MIN_REASON_CHARS)} characters`,
|
|
390
|
+
});
|
|
391
|
+
fileFindings += 1;
|
|
392
|
+
}
|
|
393
|
+
if (!added.has(entry.key)) {
|
|
394
|
+
continue; // grandfathered: falsifier is required of NEW entries only
|
|
395
|
+
}
|
|
396
|
+
if (comparison.verdict === 'growth' && (spec.growth ?? 'forbidden') === 'forbidden') {
|
|
397
|
+
findings.push({
|
|
398
|
+
file: spec.file,
|
|
399
|
+
entry: entry.key,
|
|
400
|
+
kind: 'growth',
|
|
401
|
+
detail: `absent at ${baseRef} — this list is shrink-only`,
|
|
402
|
+
});
|
|
403
|
+
fileFindings += 1;
|
|
404
|
+
}
|
|
405
|
+
if (spec.tier !== 'object') {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (entry.falsifier.length < MIN_REASON_CHARS) {
|
|
409
|
+
findings.push({
|
|
410
|
+
file: spec.file,
|
|
411
|
+
entry: entry.key,
|
|
412
|
+
kind: 'missing_falsifier',
|
|
413
|
+
detail:
|
|
414
|
+
'a NEW entry must carry `falsifier`: a re-runnable command that decides it. ' +
|
|
415
|
+
'An entry with a falsifier is a ratchet; one without is a hole',
|
|
416
|
+
});
|
|
417
|
+
fileFindings += 1;
|
|
418
|
+
} else if (DEGENERATE_FALSIFIERS.has(entry.falsifier.toLowerCase())) {
|
|
419
|
+
findings.push({
|
|
420
|
+
file: spec.file,
|
|
421
|
+
entry: entry.key,
|
|
422
|
+
kind: 'degenerate_falsifier',
|
|
423
|
+
detail: `\`${entry.falsifier}\` always succeeds — it decides nothing`,
|
|
424
|
+
});
|
|
425
|
+
fileFindings += 1;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (fileFindings > 0) {
|
|
430
|
+
ledger.fail(spec.file, `${String(fileFindings)} finding(s)`);
|
|
431
|
+
} else {
|
|
432
|
+
ledger.complete(spec.file);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const reset = comparison.verdict === 'reset' ? ` — RE-BASELINED: ${comparison.resetReason ?? ''}` : '';
|
|
436
|
+
const positional = entries.filter((e) => e.positionKeyed).length;
|
|
437
|
+
const drift =
|
|
438
|
+
positional > 0 ? `, ${String(positional)} position-keyed (drift-fragile, migrate to anchors)` : '';
|
|
439
|
+
lines.push(
|
|
440
|
+
` ${spec.file}: ${String(entries.length)} entry(ies) (${String(comparison.baseCount)} at base, ` +
|
|
441
|
+
`${String(comparison.removed.length)} removed)${drift} — ${spec.what}${reset}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
process.stdout.write(
|
|
446
|
+
`suppression hygiene: ${String(SUPPRESSION_INVENTORY.length)} declared surface(s), ` +
|
|
447
|
+
`${String(discovered.length)} discovered, base ${baseRef}\n`,
|
|
448
|
+
);
|
|
449
|
+
if (!QUIET) {
|
|
450
|
+
for (const line of lines) {
|
|
451
|
+
process.stdout.write(`${line}\n`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
ledger.report();
|
|
455
|
+
process.stdout.write(`scanned: ${String(SUPPRESSION_INVENTORY.length)}\n`);
|
|
456
|
+
|
|
457
|
+
if (findings.length > 0) {
|
|
458
|
+
process.stderr.write(`\n❌ ${String(findings.length)} suppression-hygiene finding(s):\n`);
|
|
459
|
+
for (const f of findings) {
|
|
460
|
+
process.stderr.write(` ${f.file} → ${f.entry} [${f.kind}]\n ${f.detail}\n`);
|
|
461
|
+
}
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Entry keys for one spec, applied to a parsed baseline from either side. */
|
|
468
|
+
export function entriesOfSpec(spec: SuppressionSpec, parsed: unknown): string[] {
|
|
469
|
+
const container = spec.listKey === null ? parsed : (parsed as Record<string, unknown>)[spec.listKey];
|
|
470
|
+
if (Array.isArray(container)) {
|
|
471
|
+
return container.map((item, i) => entryKey(spec, item, i));
|
|
472
|
+
}
|
|
473
|
+
if (container !== null && typeof container === 'object') {
|
|
474
|
+
return Object.keys(container as Record<string, unknown>);
|
|
475
|
+
}
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function _isCliEntry(): boolean {
|
|
480
|
+
if (process.argv[1] === undefined) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
484
|
+
if (import.meta.url === argvUrl) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
// A symlinked invocation (installed projection, or macOS /var → /private/var
|
|
488
|
+
// temp dirs) makes the raw URLs differ: compare realpaths so the entry guard
|
|
489
|
+
// still fires instead of silently no-opping.
|
|
490
|
+
try {
|
|
491
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
492
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
493
|
+
return here === argv;
|
|
494
|
+
} catch {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
500
|
+
process.exitCode = main();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export { QUIET };
|
package/src/scripts/condense.ts
CHANGED
|
@@ -1053,12 +1053,51 @@ function _read_rule_packs(): string[] | null {
|
|
|
1053
1053
|
* compiles and behaves unchanged.
|
|
1054
1054
|
*/
|
|
1055
1055
|
/** List rule basenames under RULES_SOURCE, workspace/pack scope applied. */
|
|
1056
|
+
/**
|
|
1057
|
+
* Is this rule `type: manual` (ADR-004 reference-only)?
|
|
1058
|
+
*
|
|
1059
|
+
* The schema is explicit: `manual` = "no auto-injection (zero workspace-budget
|
|
1060
|
+
* cost); file remains as a reference document linkable from skills/contexts."
|
|
1061
|
+
* `compile_router` already honours the first half by omitting manual rules from
|
|
1062
|
+
* `dist/router.json`. The per-tool projection did NOT: it symlinked them into
|
|
1063
|
+
* `.claude/rules/` (and siblings), so under the default `eager-all` projection
|
|
1064
|
+
* their full bodies were injected every turn — the exact opposite of "zero
|
|
1065
|
+
* workspace-budget cost". `brand-consistency` is the clearest case: its own body
|
|
1066
|
+
* says "reference-only, no router emission", and it shipped in context anyway.
|
|
1067
|
+
*
|
|
1068
|
+
* Excluded from the per-tool trees ONLY. The file keeps its place in
|
|
1069
|
+
* `dist/agent-src/rules/`, which is what "remains as a reference document
|
|
1070
|
+
* linkable" requires — inbound cross-references must still resolve.
|
|
1071
|
+
*/
|
|
1072
|
+
function _is_manual_rule(rule_path: string): boolean {
|
|
1073
|
+
let content: string;
|
|
1074
|
+
try {
|
|
1075
|
+
content = fs.readFileSync(rule_path, 'utf-8');
|
|
1076
|
+
} catch {
|
|
1077
|
+
return false; // unreadable → fail open, same posture as the scope filters
|
|
1078
|
+
}
|
|
1079
|
+
const [fm_lines] = _split_frontmatter(content);
|
|
1080
|
+
if (fm_lines === null) {
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
for (const line of fm_lines) {
|
|
1084
|
+
const m = /^type:\s*["']?([a-z]+)["']?\s*$/.exec(line);
|
|
1085
|
+
if (m) {
|
|
1086
|
+
return m[1] === 'manual';
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1056
1092
|
function _scoped_rule_basenames(): string[] {
|
|
1057
1093
|
const scope = _read_rule_workspaces();
|
|
1058
1094
|
const pack_scope = _read_rule_packs();
|
|
1059
1095
|
return _iterdirSorted(MODULE_STATE.RULES_SOURCE)
|
|
1060
1096
|
.filter((p) => p.endsWith('.md') && _isFile(p))
|
|
1061
1097
|
.filter((p) => rule_in_scope(p, scope, pack_scope))
|
|
1098
|
+
// ADR-004: a manual rule costs zero workspace budget, so it never gets a
|
|
1099
|
+
// per-tool symlink regardless of projection mode. See _is_manual_rule.
|
|
1100
|
+
.filter((p) => !_is_manual_rule(p))
|
|
1062
1101
|
// A compile-disabled rule has no dist/agent-src/ counterpart, so a per-tool
|
|
1063
1102
|
// symlink to it would dangle. The source file still exists, which is why the
|
|
1064
1103
|
// scope filters alone let it through — the toggle is a separate axis and this
|
|
@@ -1311,7 +1350,10 @@ export function generate_cursor_mdc_rules(): number {
|
|
|
1311
1350
|
const pack_scope = _read_rule_packs();
|
|
1312
1351
|
const rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
|
|
1313
1352
|
.filter((p) => _isFile(p))
|
|
1314
|
-
.filter((p) => rule_in_scope(p, scope, pack_scope))
|
|
1353
|
+
.filter((p) => rule_in_scope(p, scope, pack_scope))
|
|
1354
|
+
// ADR-004 manual rules are reference-only — never auto-injected into a
|
|
1355
|
+
// per-tool tree. Same predicate as generate_rule_symlinks.
|
|
1356
|
+
.filter((p) => !_is_manual_rule(p));
|
|
1315
1357
|
const stems = rules.map((r) => path.basename(r, '.md'));
|
|
1316
1358
|
const valid = new Set([
|
|
1317
1359
|
...stems.map((s) => `${s}.mdc`),
|
|
@@ -1330,7 +1372,10 @@ export function generate_windsurf_modern_rules(): number {
|
|
|
1330
1372
|
const pack_scope = _read_rule_packs();
|
|
1331
1373
|
const rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
|
|
1332
1374
|
.filter((p) => _isFile(p))
|
|
1333
|
-
.filter((p) => rule_in_scope(p, scope, pack_scope))
|
|
1375
|
+
.filter((p) => rule_in_scope(p, scope, pack_scope))
|
|
1376
|
+
// ADR-004 manual rules are reference-only — never auto-injected into a
|
|
1377
|
+
// per-tool tree. Same predicate as generate_rule_symlinks.
|
|
1378
|
+
.filter((p) => !_is_manual_rule(p));
|
|
1334
1379
|
const valid = new Set(rules.map((r) => path.basename(r)));
|
|
1335
1380
|
_clean_modern_dir(_WINDSURF_RULES_DIR(), valid);
|
|
1336
1381
|
for (const rule of rules) {
|