@event4u/agent-config 9.22.0 → 9.23.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 +69 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- 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 +17 -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 +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.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 +79 -1
- package/dist/mcp/registry-manifest.json +2 -2
- 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/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,339 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* lint_mandated_lines.ts — the deterministic half of the mandated-line contract.
|
|
4
|
+
*
|
|
5
|
+
* Contract: `src/agent-src/contexts/execution/mandated-lines.md`. Given a run
|
|
6
|
+
* report on stdin (or `--file`), assert the two checkable obligations:
|
|
7
|
+
*
|
|
8
|
+
* 1. A report claiming a **behaviour change** carries an **Intent** line.
|
|
9
|
+
* 2. A report describing an **outward action** carries an **Authorization** line.
|
|
10
|
+
*
|
|
11
|
+
* Documented invocation, mirroring the sibling reply checker:
|
|
12
|
+
*
|
|
13
|
+
* ./scripts-run src/scripts/lint_mandated_lines --stdin < report.md
|
|
14
|
+
*
|
|
15
|
+
* **Not wired into the `ci` corpus sweep, deliberately.** There is no standing
|
|
16
|
+
* tree of run reports to scan, and a gate pointed at a directory that does not
|
|
17
|
+
* accumulate is the dead-scan-root shape this repository has already paid for
|
|
18
|
+
* four times. It is a callable checker, exactly like
|
|
19
|
+
* `check_reply_consistency --stdin`, and its discrimination is proven by a test
|
|
20
|
+
* rather than by a corpus.
|
|
21
|
+
*
|
|
22
|
+
* **What it cannot check, stated rather than discovered later.** It reads a
|
|
23
|
+
* report, so it can see whether a line is present and well-formed. It cannot
|
|
24
|
+
* see whether the line was emitted *before* the action or reconstructed after,
|
|
25
|
+
* and it cannot see whether the three intent slots were filled honestly. Those
|
|
26
|
+
* are model-carried — the contract says so in the same words.
|
|
27
|
+
*
|
|
28
|
+
* **Gaming risk.** The cheap degenerate pass is a report that never uses the
|
|
29
|
+
* trigger vocabulary: describe the edit without the word "fix", the push
|
|
30
|
+
* without the word "push", and both obligations evaporate because nothing was
|
|
31
|
+
* detected to owe them. Mitigation: the trigger sets below are deliberately
|
|
32
|
+
* broad and include the past-tense forms a completion claim actually uses, and
|
|
33
|
+
* a report that trips NO trigger is reported as `no obligations detected`
|
|
34
|
+
* rather than as a pass — the two read differently on purpose. Residual: a
|
|
35
|
+
* sufficiently vague report still owes nothing, and no lexical check can fix
|
|
36
|
+
* that. The line's real enforcement is that the model emits it at the decision
|
|
37
|
+
* point; this is the backstop for the case where it did not.
|
|
38
|
+
*
|
|
39
|
+
* Exit: 0 clean · 1 usage/IO error · 2 findings.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import * as fs from 'node:fs';
|
|
43
|
+
import * as path from 'node:path';
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
45
|
+
|
|
46
|
+
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
47
|
+
|
|
48
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
49
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A report claims a behaviour change. Past tense included on purpose: a
|
|
53
|
+
* completion claim says "fixed", not "will fix".
|
|
54
|
+
*/
|
|
55
|
+
export const BEHAVIOUR_CHANGE_TRIGGERS: readonly RegExp[] = [
|
|
56
|
+
/\b(fixed|fixes|fixing|a fix)\b/i,
|
|
57
|
+
/\b(changed|changes|changing) the behaviou?r\b/i,
|
|
58
|
+
/\b(implemented|implements|implementing)\b/i,
|
|
59
|
+
/\b(patched|patches|patching)\b/i,
|
|
60
|
+
/\b(refactored|refactors|refactoring)\b/i,
|
|
61
|
+
/\b(corrected|corrects|correcting)\b/i,
|
|
62
|
+
/\bbehaviou?r[- ]changing\b/i,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A report describes an irreversible outward action — the
|
|
67
|
+
* `non-destructive-by-default` Hard-Floor set.
|
|
68
|
+
*/
|
|
69
|
+
export const OUTWARD_ACTION_TRIGGERS: readonly RegExp[] = [
|
|
70
|
+
/\b(pushed|pushing|push(?:ed)? to (?:remote|origin|main))\b/i,
|
|
71
|
+
/\b(deployed|deploying|deploy(?:ed)? to prod)\b/i,
|
|
72
|
+
/\b(published|publishing)\b/i,
|
|
73
|
+
/\b(merged|merging) (?:into |to )?(?:main|master|prod)/i,
|
|
74
|
+
/\b(opened|created) (?:the |a )?(?:pull request|PR)\b/i,
|
|
75
|
+
/\b(sent|sending) (?:the |an )?(?:email|message)\b/i,
|
|
76
|
+
/\b(posted|posting) (?:the |a )?comment\b/i,
|
|
77
|
+
/\b(purchased|submitted|ordered)\b/i,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** `Intent: … · … · …` — three slots separated by the middle dot. Global: EVERY line is validated. */
|
|
81
|
+
export const INTENT_RE = /^[ \t]*(?:>[ \t]*)?Intent(?:[ \t]*\([^)]*\))?:[ \t]*(.+)$/gim;
|
|
82
|
+
|
|
83
|
+
/** `Authorization: "…"` — carrying the user's own words. */
|
|
84
|
+
export const AUTHORIZATION_RE = /^[ \t]*(?:>[ \t]*)?Authorization:[ \t]*(.+)$/gim;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A PAIRED quotation, not a stray quote character.
|
|
88
|
+
*
|
|
89
|
+
* The first version tested for any character in `["“”'']` and got both
|
|
90
|
+
* directions wrong: `the user's roadmap step says to push` passed on the
|
|
91
|
+
* possessive apostrophe — a paraphrase, and literally the
|
|
92
|
+
* documentation-is-not-authorization case the contract denies — while
|
|
93
|
+
* `‘push it and open the PR’` failed, because the typographic single quotes
|
|
94
|
+
* were not in the class at all. What the line must carry is a span somebody
|
|
95
|
+
* actually said, so the test is for an opening mark with a closing partner.
|
|
96
|
+
*/
|
|
97
|
+
const QUOTED_SPAN_RE = /"[^"]+"|“[^”]*”|‘[^’]*’|'[^']{2,}'|«[^»]+»|„[^“”]+[“”]/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Drop fenced blocks before matching.
|
|
101
|
+
*
|
|
102
|
+
* Without this, a report that QUOTES the contract's own merged-block example —
|
|
103
|
+
* which contains a well-formed Intent and Commit line — satisfies both
|
|
104
|
+
* obligations while having emitted neither. The contract's § Brevity
|
|
105
|
+
* illustration was, verbatim, a working bypass.
|
|
106
|
+
*/
|
|
107
|
+
export function stripFences(text: string): string {
|
|
108
|
+
const out: string[] = [];
|
|
109
|
+
let fence: string | null = null;
|
|
110
|
+
for (const line of text.split('\n')) {
|
|
111
|
+
const m = /^[ \t]*(`{3,}|~{3,})/.exec(line);
|
|
112
|
+
if (m !== null) {
|
|
113
|
+
const marker = (m[1] as string)[0] as string;
|
|
114
|
+
if (fence === null) fence = marker;
|
|
115
|
+
else if (fence === marker) fence = null;
|
|
116
|
+
out.push('');
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
out.push(fence === null ? line : '');
|
|
120
|
+
}
|
|
121
|
+
return out.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Join a wrapped continuation onto its label line.
|
|
126
|
+
*
|
|
127
|
+
* The contract's own canonical Intent example wraps across two blockquote
|
|
128
|
+
* lines, and a one-physical-line capture read it as two slots — the document's
|
|
129
|
+
* model-correct artifact failed its own checker. A following line that does not
|
|
130
|
+
* start a new label and is not blank belongs to the line above it.
|
|
131
|
+
*/
|
|
132
|
+
export function unwrapLines(text: string): string {
|
|
133
|
+
const lines = text.split('\n');
|
|
134
|
+
const out: string[] = [];
|
|
135
|
+
for (const raw of lines) {
|
|
136
|
+
const isLabel = /^[ \t]*(?:>[ \t]*)?(?:Intent|Authorization|Commit|Pending|Sibling search)\b/i.test(raw);
|
|
137
|
+
const isBlank = raw.trim() === '';
|
|
138
|
+
const prev = out[out.length - 1];
|
|
139
|
+
if (!isLabel && !isBlank && prev !== undefined && prev.trim() !== '' &&
|
|
140
|
+
/^[ \t]*(?:>[ \t]*)?(?:Intent|Authorization|Commit|Pending|Sibling search)\b/i.test(prev)) {
|
|
141
|
+
out[out.length - 1] = `${prev.trimEnd()} ${raw.trim()}`;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
out.push(raw);
|
|
145
|
+
}
|
|
146
|
+
return out.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Sentences that DENY the action rather than claiming it.
|
|
151
|
+
*
|
|
152
|
+
* Narrow on purpose. A trigger word inside "no code changed" or "nothing was
|
|
153
|
+
* implemented" is a report saying the opposite of what the trigger detects, and
|
|
154
|
+
* charging it an obligation trains the reader to ignore the checker. This
|
|
155
|
+
* cannot be complete — negation is not a lexical property — and the residual is
|
|
156
|
+
* stated in the header rather than hidden.
|
|
157
|
+
*/
|
|
158
|
+
const DENIAL_RE = /\b(no|nothing|not|never|without)\b[^.]{0,40}$/i;
|
|
159
|
+
|
|
160
|
+
/** The slot separator the contract specifies. */
|
|
161
|
+
const SLOT_SEPARATOR = '·';
|
|
162
|
+
|
|
163
|
+
export interface Finding {
|
|
164
|
+
code: 'missing-intent' | 'missing-authorization' | 'intent-slots' | 'authorization-quote';
|
|
165
|
+
message: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface Verdict {
|
|
169
|
+
findings: Finding[];
|
|
170
|
+
/** True when no trigger fired at all — reported distinctly from a pass. */
|
|
171
|
+
noObligations: boolean;
|
|
172
|
+
owed: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Does any trigger fire on a sentence that is not denying it?
|
|
177
|
+
*
|
|
178
|
+
* Sentence-scoped so a denial in one sentence does not excuse a claim in the
|
|
179
|
+
* next: "No code changed. Pushed the doc fix." still owes the authorization
|
|
180
|
+
* line.
|
|
181
|
+
*/
|
|
182
|
+
function _fires(text: string, triggers: readonly RegExp[]): boolean {
|
|
183
|
+
for (const sentence of text.split(/(?<=[.!?\n])/)) {
|
|
184
|
+
if (!triggers.some((re) => re.test(sentence))) continue;
|
|
185
|
+
const before = sentence.slice(0, sentence.search(triggers.find((re) => re.test(sentence)) as RegExp));
|
|
186
|
+
if (DENIAL_RE.test(before)) continue;
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check one report.
|
|
194
|
+
*
|
|
195
|
+
* Separated from the CLI so the discrimination cases are unit-testable without
|
|
196
|
+
* spawning a process — the boundary cases here are textual, and a fixture file
|
|
197
|
+
* per case would make them harder to read, not easier.
|
|
198
|
+
*/
|
|
199
|
+
export function checkReport(text: string): Verdict {
|
|
200
|
+
const findings: Finding[] = [];
|
|
201
|
+
const owed: string[] = [];
|
|
202
|
+
|
|
203
|
+
// Fenced blocks are illustrations, not claims — and unwrapping puts a
|
|
204
|
+
// soft-wrapped continuation back on its label line before anything counts
|
|
205
|
+
// slots.
|
|
206
|
+
const prepared = unwrapLines(stripFences(text));
|
|
207
|
+
|
|
208
|
+
const claimsChange = _fires(prepared, BEHAVIOUR_CHANGE_TRIGGERS);
|
|
209
|
+
const claimsOutward = _fires(prepared, OUTWARD_ACTION_TRIGGERS);
|
|
210
|
+
|
|
211
|
+
if (claimsChange) {
|
|
212
|
+
owed.push('intent');
|
|
213
|
+
INTENT_RE.lastIndex = 0;
|
|
214
|
+
const matches = [...prepared.matchAll(INTENT_RE)];
|
|
215
|
+
if (matches.length === 0) {
|
|
216
|
+
findings.push({
|
|
217
|
+
code: 'missing-intent',
|
|
218
|
+
message:
|
|
219
|
+
'the report claims a behaviour change and carries no Intent line. ' +
|
|
220
|
+
'Three slots: what the code does · what the failing check expects · what the spec says.',
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
// EVERY intent line is validated. Checking only the first made the
|
|
224
|
+
// verdict depend on line order, so one well-formed line satisfied a
|
|
225
|
+
// report claiming any number of behaviour changes.
|
|
226
|
+
for (const [i, m] of matches.entries()) {
|
|
227
|
+
const slots = (m[1] ?? '').split(SLOT_SEPARATOR).map((s) => s.trim()).filter((s) => s !== '');
|
|
228
|
+
if (slots.length < 3) {
|
|
229
|
+
findings.push({
|
|
230
|
+
code: 'intent-slots',
|
|
231
|
+
message:
|
|
232
|
+
`Intent line ${String(i + 1)} of ${String(matches.length)} has ${String(slots.length)} slot(s), not 3. ` +
|
|
233
|
+
'The three slots are the mechanism: when they disagree, the disagreement is the finding ' +
|
|
234
|
+
`and the edit does not proceed. Separate them with '${SLOT_SEPARATOR}'.`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (claimsOutward) {
|
|
242
|
+
owed.push('authorization');
|
|
243
|
+
AUTHORIZATION_RE.lastIndex = 0;
|
|
244
|
+
const m = AUTHORIZATION_RE.exec(prepared);
|
|
245
|
+
if (m === null) {
|
|
246
|
+
findings.push({
|
|
247
|
+
code: 'missing-authorization',
|
|
248
|
+
message:
|
|
249
|
+
'the report describes an irreversible outward action and carries no Authorization line. ' +
|
|
250
|
+
'Documentation is not authorization; completing the task is not authorization.',
|
|
251
|
+
});
|
|
252
|
+
} else if (!QUOTED_SPAN_RE.test(m[1] ?? '')) {
|
|
253
|
+
findings.push({
|
|
254
|
+
code: 'authorization-quote',
|
|
255
|
+
message:
|
|
256
|
+
'the Authorization line quotes nothing. It carries the USER\'S OWN WORDS — a paraphrase ' +
|
|
257
|
+
'is the agent restating its own conclusion, which is what the line exists to prevent.',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { findings, noObligations: !claimsChange && !claimsOutward, owed };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _readInput(argv: readonly string[]): { text: string } | { error: string } {
|
|
266
|
+
const fileIdx = argv.indexOf('--file');
|
|
267
|
+
if (fileIdx !== -1) {
|
|
268
|
+
const p = argv[fileIdx + 1];
|
|
269
|
+
if (p === undefined) {
|
|
270
|
+
return { error: '--file needs a path' };
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
return { text: fs.readFileSync(path.resolve(p), 'utf-8') };
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return { error: `cannot read ${p}: ${String(e)}` };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (argv.includes('--stdin')) {
|
|
279
|
+
try {
|
|
280
|
+
return { text: fs.readFileSync(0, 'utf-8') };
|
|
281
|
+
} catch (e) {
|
|
282
|
+
return { error: `cannot read stdin: ${String(e)}` };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { error: 'usage: lint_mandated_lines --stdin | --file <report.md> [--quiet]' };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
289
|
+
const quiet = argv.includes('--quiet');
|
|
290
|
+
const input = _readInput(argv);
|
|
291
|
+
if ('error' in input) {
|
|
292
|
+
process.stderr.write(`${input.error}\n`);
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Scope assertion. An EMPTY report used to print "no obligations detected"
|
|
297
|
+
// and exit 0 — a piped-nothing reading as compliance, which is the
|
|
298
|
+
// false-green shape this repository has paid for four times. The unit is
|
|
299
|
+
// content lines, and zero of them is a dead scope rather than a clean run.
|
|
300
|
+
const contentLines = input.text.split('\n').filter((l) => l.trim() !== '').length;
|
|
301
|
+
try {
|
|
302
|
+
assertScanned({ gate: 'lint_mandated_lines', scanned: contentLines, units: 'report line(s)', roots: ['<stdin>'] });
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (e instanceof DeadScopeError) {
|
|
305
|
+
process.stderr.write(`❌ ${e.message}\n`);
|
|
306
|
+
return 1;
|
|
307
|
+
}
|
|
308
|
+
throw e;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const verdict = checkReport(input.text);
|
|
312
|
+
for (const f of verdict.findings) {
|
|
313
|
+
process.stderr.write(`❌ ${f.code} ${f.message}\n`);
|
|
314
|
+
}
|
|
315
|
+
if (verdict.findings.length > 0) {
|
|
316
|
+
process.stderr.write(`\n❌ lint_mandated_lines: ${String(verdict.findings.length)} finding(s)\n`);
|
|
317
|
+
return 2;
|
|
318
|
+
}
|
|
319
|
+
if (!quiet) {
|
|
320
|
+
if (verdict.noObligations) {
|
|
321
|
+
// Deliberately not "clean". A report that triggered nothing has not
|
|
322
|
+
// been shown to comply; it has been shown to owe nothing, and the
|
|
323
|
+
// two are different facts.
|
|
324
|
+
process.stdout.write('✅ lint_mandated_lines: no obligations detected in this report\n');
|
|
325
|
+
} else {
|
|
326
|
+
process.stdout.write(
|
|
327
|
+
`✅ lint_mandated_lines: ${String(verdict.owed.length)} obligation(s) owed and present — ` +
|
|
328
|
+
`${verdict.owed.join(', ')}\n`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
336
|
+
process.exit(main());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export { REPO_ROOT };
|
|
@@ -16,16 +16,33 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import * as fs from 'node:fs';
|
|
19
|
+
import * as os from 'node:os';
|
|
19
20
|
import * as path from 'node:path';
|
|
20
21
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
22
|
|
|
22
23
|
import { project_settings_path } from './_lib/agent_settings.js';
|
|
24
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
25
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
23
26
|
import { granularity_problems } from './_lib/roadmap_granularity.js';
|
|
24
27
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
25
28
|
|
|
26
29
|
/** Mirror `QUIET = "--quiet" in sys.argv` (computed at import). */
|
|
27
30
|
const QUIET = process.argv.slice(2).includes('--quiet');
|
|
28
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Optional roadmap-tree root, additive to the pinned CLI contract.
|
|
34
|
+
*
|
|
35
|
+
* Needed because this gate resolves its root from its own file location, not
|
|
36
|
+
* from the working directory — so nothing could exercise the real binary
|
|
37
|
+
* against a fixture tree, and the two rejection paths (untagged roadmap, dead
|
|
38
|
+
* scope) had no end-to-end proof at all. Absent, behaviour is unchanged.
|
|
39
|
+
*/
|
|
40
|
+
function _rootOverride(argv: readonly string[]): string | null {
|
|
41
|
+
const i = argv.indexOf('--root');
|
|
42
|
+
const value = i === -1 ? undefined : argv[i + 1];
|
|
43
|
+
return value === undefined ? null : path.resolve(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
30
47
|
// REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
31
48
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -388,11 +405,61 @@ function _check_execution_mode(fm: string, problems: string[]): void {
|
|
|
388
405
|
}
|
|
389
406
|
}
|
|
390
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Prove, against the real CLI, that this gate still rejects what it must.
|
|
410
|
+
*
|
|
411
|
+
* The empty-tree case is the one that would otherwise rot silently: this gate
|
|
412
|
+
* legitimately reaches zero ACTIVE roadmaps, so only the whole-tree assertion
|
|
413
|
+
* separates "everything is archived" from "the root moved".
|
|
414
|
+
*/
|
|
415
|
+
export function selfTest(): number {
|
|
416
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rmc-selftest-'));
|
|
417
|
+
const seed = (name: string, body: string | null): string => {
|
|
418
|
+
const root = path.join(tmp, name);
|
|
419
|
+
fs.mkdirSync(path.join(root, 'agents', 'roadmaps'), { recursive: true });
|
|
420
|
+
if (body !== null) {
|
|
421
|
+
fs.writeFileSync(path.join(root, 'agents', 'roadmaps', 'demo.md'), body, 'utf-8');
|
|
422
|
+
}
|
|
423
|
+
return root;
|
|
424
|
+
};
|
|
425
|
+
const run = (cwd: string): number =>
|
|
426
|
+
runGateCli(REPO_ROOT, 'src/scripts/lint_roadmap_complexity.ts', ['--quiet', '--root', cwd], REPO_ROOT);
|
|
427
|
+
|
|
428
|
+
const clean = '---\ncomplexity: lightweight\n---\n\n# Demo\n\n## Phase 1\n\n- [ ] **Step 1:** do the thing.\n';
|
|
429
|
+
try {
|
|
430
|
+
return runSelfTest({
|
|
431
|
+
gate: 'lint_roadmap_complexity',
|
|
432
|
+
minCases: 3,
|
|
433
|
+
minRejectCases: 2,
|
|
434
|
+
cases: [
|
|
435
|
+
{
|
|
436
|
+
name: 'a roadmap without a `complexity:` tag is rejected',
|
|
437
|
+
expect: 'reject',
|
|
438
|
+
run: () => run(seed('untagged', '# Demo\n\n## Phase 1\n\n- [ ] **Step 1:** do the thing.\n')),
|
|
439
|
+
},
|
|
440
|
+
{ name: 'a tagged roadmap passes', expect: 'accept', run: () => run(seed('clean', clean)) },
|
|
441
|
+
{
|
|
442
|
+
name: 'an EMPTY roadmap tree is rejected — dead scope, not "all archived"',
|
|
443
|
+
expect: 'reject',
|
|
444
|
+
run: () => run(seed('empty', null)),
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
} finally {
|
|
449
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
391
453
|
function main(): number {
|
|
454
|
+
if (process.argv.slice(2).includes('--self-test')) {
|
|
455
|
+
return selfTest();
|
|
456
|
+
}
|
|
457
|
+
const root = _rootOverride(process.argv.slice(2)) ?? REPO_ROOT;
|
|
458
|
+
const tree = _listRoadmapTree(path.join(root, 'agents', 'roadmaps'));
|
|
392
459
|
try {
|
|
393
460
|
assertScanned({
|
|
394
461
|
gate: 'lint_roadmap_complexity',
|
|
395
|
-
scanned:
|
|
462
|
+
scanned: tree.length,
|
|
396
463
|
units: 'roadmap file(s)',
|
|
397
464
|
roots: ['agents/roadmaps'],
|
|
398
465
|
});
|
|
@@ -404,18 +471,32 @@ function main(): number {
|
|
|
404
471
|
}
|
|
405
472
|
throw e;
|
|
406
473
|
}
|
|
407
|
-
const roadmaps = _globRoadmaps();
|
|
474
|
+
const roadmaps = _globRoadmaps(root);
|
|
408
475
|
const horizon_weeks = _read_horizon_weeks();
|
|
476
|
+
// The ledger plans the WHOLE tree, not just the active glob. The gap
|
|
477
|
+
// between the asserted population and the judged one used to be invisible:
|
|
478
|
+
// the assertion counted `archive/` + `later/` + `skipped/`, the loop judged
|
|
479
|
+
// only the top level, and nothing said so. Now every disposed roadmap is an
|
|
480
|
+
// explicit `out_of_scope`, so the denominator on the green line is honest.
|
|
481
|
+
const ledger = new GateLedger('lint_roadmap_complexity');
|
|
482
|
+
ledger.plan(tree.map((p) => _relPosix(p, root)));
|
|
483
|
+
const active = new Set(roadmaps.map((p) => _relPosix(p, root)));
|
|
484
|
+
for (const rel of tree.map((p) => _relPosix(p, root))) {
|
|
485
|
+
if (!active.has(rel)) {
|
|
486
|
+
ledger.outOfScope(rel, 'excluded_directory');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
409
489
|
if (roadmaps.length === 0) {
|
|
410
490
|
if (!QUIET) {
|
|
411
491
|
process.stdout.write(`✅ no active roadmaps under ${ROADMAP_GLOB} — nothing to lint\n`);
|
|
412
492
|
}
|
|
493
|
+
ledger.report();
|
|
413
494
|
return 0;
|
|
414
495
|
}
|
|
415
496
|
let failed = 0;
|
|
416
497
|
const summary: Array<[string, string]> = [];
|
|
417
498
|
for (const roadmap of roadmaps) {
|
|
418
|
-
const rel = _relPosix(roadmap,
|
|
499
|
+
const rel = _relPosix(roadmap, root);
|
|
419
500
|
const warnings: string[] = [];
|
|
420
501
|
const problems = lint_roadmap(roadmap, horizon_weeks, warnings);
|
|
421
502
|
for (const w of warnings) {
|
|
@@ -426,11 +507,13 @@ function main(): number {
|
|
|
426
507
|
summary.push([rel, complexity]);
|
|
427
508
|
if (problems.length > 0) {
|
|
428
509
|
failed++;
|
|
510
|
+
ledger.fail(rel, problems[0] ?? 'complexity lint failed');
|
|
429
511
|
process.stderr.write(`❌ ${rel} [${complexity}]\n`);
|
|
430
512
|
for (const pr of problems) {
|
|
431
513
|
process.stderr.write(` - ${pr}\n`);
|
|
432
514
|
}
|
|
433
515
|
} else {
|
|
516
|
+
ledger.complete(rel);
|
|
434
517
|
if (!QUIET) {
|
|
435
518
|
process.stdout.write(`✅ ${rel} [${complexity}]\n`);
|
|
436
519
|
}
|
|
@@ -444,6 +527,9 @@ function main(): number {
|
|
|
444
527
|
`summary: ${light} lightweight · ${structural} structural · ` +
|
|
445
528
|
`${untagged} untagged · ${summary.length} total\n`,
|
|
446
529
|
);
|
|
530
|
+
// Unconditional: the completeness line is printed on the red path too, so a
|
|
531
|
+
// failing run still says how much of the corpus it actually judged.
|
|
532
|
+
ledger.report();
|
|
447
533
|
if (failed) {
|
|
448
534
|
process.stderr.write(`\n❌ ${failed} roadmap(s) failed complexity lint\n`);
|
|
449
535
|
return 1;
|
|
@@ -477,8 +563,8 @@ function _countChar(text: string, ch: string, start = 0, end?: number): number {
|
|
|
477
563
|
}
|
|
478
564
|
|
|
479
565
|
/** Sorted `agents/roadmaps/*.md` (non-recursive — mirrors glob, not rglob). */
|
|
480
|
-
function _globRoadmaps(): string[] {
|
|
481
|
-
const dir = path.join(
|
|
566
|
+
function _globRoadmaps(root: string = REPO_ROOT): string[] {
|
|
567
|
+
const dir = path.join(root, 'agents', 'roadmaps');
|
|
482
568
|
let entries: fs.Dirent[];
|
|
483
569
|
try {
|
|
484
570
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -505,23 +591,28 @@ function _relPosix(target: string, root: string): string {
|
|
|
505
591
|
* The active glob is the subset this gate judges, and it reaches zero
|
|
506
592
|
* legitimately once everything is archived, so it cannot double as the scope
|
|
507
593
|
* assertion. The whole tree reaching zero has only one cause: the root moved.
|
|
594
|
+
*
|
|
595
|
+
* Returns the paths rather than a bare count so the ledger can account for the
|
|
596
|
+
* difference between the two populations explicitly: a file under `archive/`
|
|
597
|
+
* is not unchecked-by-accident, it is out of scope for a named reason, and the
|
|
598
|
+
* ledger says which.
|
|
508
599
|
*/
|
|
509
|
-
function
|
|
600
|
+
function _listRoadmapTree(dir: string): string[] {
|
|
510
601
|
let entries: fs.Dirent[];
|
|
511
602
|
try {
|
|
512
603
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
513
604
|
} catch {
|
|
514
|
-
return
|
|
605
|
+
return [];
|
|
515
606
|
}
|
|
516
|
-
|
|
607
|
+
const out: string[] = [];
|
|
517
608
|
for (const entry of entries) {
|
|
518
609
|
if (entry.isDirectory()) {
|
|
519
|
-
|
|
610
|
+
out.push(..._listRoadmapTree(path.join(dir, entry.name)));
|
|
520
611
|
} else if (entry.name.endsWith('.md')) {
|
|
521
|
-
|
|
612
|
+
out.push(path.join(dir, entry.name));
|
|
522
613
|
}
|
|
523
614
|
}
|
|
524
|
-
return
|
|
615
|
+
return out.sort();
|
|
525
616
|
}
|
|
526
617
|
|
|
527
618
|
function _isCliEntry(): boolean {
|