@event4u/agent-config 10.2.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 -276
- 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/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/design-fidelity.md +49 -3
- 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 +8 -8
- package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/persona.md +26 -0
- package/dist/agent-src/templates/roadmaps.md +27 -7
- 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 +1 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +13 -13
- 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 +116 -24
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1 -1
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -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/config/gate-coverage.yml +42 -8
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/review-prompt-binding-baseline.json +19 -0
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_dispatch.bash +1 -0
- package/src/scripts/ai_council/clients.ts +15 -1
- package/src/scripts/check_ci_local_parity.ts +60 -6
- package/src/scripts/check_review_prompt_binding.ts +553 -0
- package/src/scripts/check_suppression_hygiene.ts +19 -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/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_skill_router_head.ts +254 -0
- package/src/scripts/report_consultation_rate.ts +126 -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
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Harvest-ledger linter — the knowledge-side sibling of lint_provenance.ts.
|
|
4
|
+
*
|
|
5
|
+
* `provenance/borrows.jsonl` records borrowed CODE. `provenance/harvests.jsonl`
|
|
6
|
+
* records borrowed IDEAS: an externally-sourced heuristic, number, or mechanism
|
|
7
|
+
* that some skill, rule, or roadmap in this tree now asserts as doctrine. The
|
|
8
|
+
* `code-provenance` rule binds the obligation — cite a `harvest_id` or label the
|
|
9
|
+
* statement as own analysis — and this gate audits the ledger that obligation
|
|
10
|
+
* points at.
|
|
11
|
+
*
|
|
12
|
+
* Like its sibling it is strict from day one, because it checks OUR OWN RECORDS
|
|
13
|
+
* rather than running a similarity detector: a malformed row is a defect in the
|
|
14
|
+
* record, not a probabilistic call. And like its sibling it CANNOT catch an
|
|
15
|
+
* unrecorded harvest — no gate can see a citation nobody wrote. Saying so here
|
|
16
|
+
* is deliberate; claiming otherwise would inflate the coverage this repo
|
|
17
|
+
* reports.
|
|
18
|
+
*
|
|
19
|
+
* Naming, stated because the collision is live: `check_claims.ts` and
|
|
20
|
+
* `docs/CLAIMS.md` also use the word "claim", for the opposite direction of
|
|
21
|
+
* travel — public claims this package makes ABOUT ITSELF. This gate is named
|
|
22
|
+
* after `lint_provenance`, its actual sibling, so a reader who greps either
|
|
23
|
+
* name lands in the right family.
|
|
24
|
+
*
|
|
25
|
+
* Checks:
|
|
26
|
+
* 1. Schema — the closed 6-field shape (harvest_id, stated_in, source_ref,
|
|
27
|
+
* evidence_locator, harvested_at, verdict), every field format-checked.
|
|
28
|
+
* 2. Uniqueness — a duplicate harvest_id makes a citation ambiguous.
|
|
29
|
+
* 3. Dead rows — every `stated_in` path exists and stays inside the repo.
|
|
30
|
+
* 4. Pinning — `source_ref` carries a revision or is explicitly opaque; a
|
|
31
|
+
* bare URL is rejected because it cannot be re-verified later.
|
|
32
|
+
* 5. Orphan citations — every `<!-- harvest:<id> -->` marker under the
|
|
33
|
+
* scanned roots resolves to a ledger row.
|
|
34
|
+
*
|
|
35
|
+
* An EMPTY ledger with zero markers is a legitimate pass and is reported as
|
|
36
|
+
* such on stdout. A gate that scans nothing and exits green silently is
|
|
37
|
+
* indistinguishable from a broken one, so the green path always prints what it
|
|
38
|
+
* actually examined.
|
|
39
|
+
*
|
|
40
|
+
* Usage:
|
|
41
|
+
* ./scripts-run src/scripts/lint_harvest_provenance
|
|
42
|
+
* ./scripts-run src/scripts/lint_harvest_provenance --quiet
|
|
43
|
+
*
|
|
44
|
+
* Exit codes: 0 = clean · 2 = any finding OR usage error (mirrors
|
|
45
|
+
* lint_provenance — both mean "this run produced nothing trustworthy").
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import * as fs from 'node:fs';
|
|
49
|
+
import * as path from 'node:path';
|
|
50
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
51
|
+
|
|
52
|
+
import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
|
|
53
|
+
|
|
54
|
+
const _FILE = fileURLToPath(import.meta.url);
|
|
55
|
+
const _HERE = path.dirname(_FILE);
|
|
56
|
+
// src/scripts/lint_harvest_provenance.ts → two levels up is the repo root.
|
|
57
|
+
const REPO = path.resolve(_HERE, '..', '..');
|
|
58
|
+
const LEDGER_REL = 'provenance/harvests.jsonl';
|
|
59
|
+
|
|
60
|
+
/** Roots scanned for citation markers. Markdown-bearing authored surfaces. */
|
|
61
|
+
export const CITATION_SCAN_ROOTS: readonly string[] = [
|
|
62
|
+
'src/skills',
|
|
63
|
+
'src/rules',
|
|
64
|
+
'src/agent-src',
|
|
65
|
+
'src/domains',
|
|
66
|
+
'agents/roadmaps',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// ─── field formats ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const REQUIRED_FIELDS = [
|
|
72
|
+
'harvest_id', 'stated_in', 'source_ref', 'evidence_locator',
|
|
73
|
+
'harvested_at', 'verdict',
|
|
74
|
+
] as const;
|
|
75
|
+
const VERDICT_VALUES: ReadonlySet<string> = new Set(['adopt', 'adapt']);
|
|
76
|
+
const HARVEST_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
77
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
78
|
+
const EVIDENCE_MIN_LENGTH = 3;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A pinned public source: any http(s) or git URL carrying an `@<revision>`
|
|
82
|
+
* suffix. The revision is 7-64 hex chars — the same shape `borrows.jsonl`
|
|
83
|
+
* accepts for `source_sha`, kept identical so the two ledgers do not disagree
|
|
84
|
+
* about what "pinned" means.
|
|
85
|
+
*/
|
|
86
|
+
const PINNED_URL_RE = /^(https?:\/\/\S+|git@\S+)@[0-9a-fA-F]{7,64}$/;
|
|
87
|
+
/** An opaque reference, for a source `source-confidentiality` keeps untracked. */
|
|
88
|
+
const OPAQUE_REF_RE = /^(opaque:[\w.-]+|ENC1:\S+)$/;
|
|
89
|
+
|
|
90
|
+
/** Citation marker as it appears in an authored artefact. */
|
|
91
|
+
export const CITATION_MARKER_RE = /<!--\s*harvest:([a-z0-9-]+)\s*-->/g;
|
|
92
|
+
|
|
93
|
+
// ─── types ───────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface HarvestRecord {
|
|
96
|
+
readonly harvest_id: string;
|
|
97
|
+
readonly stated_in: string;
|
|
98
|
+
readonly source_ref: string;
|
|
99
|
+
readonly evidence_locator: string;
|
|
100
|
+
readonly harvested_at: string;
|
|
101
|
+
readonly verdict: 'adopt' | 'adapt';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface Finding {
|
|
105
|
+
/** Ledger line number, or 0 for findings that belong to no single row. */
|
|
106
|
+
readonly line: number;
|
|
107
|
+
readonly rule: 'schema' | 'uniqueness' | 'dead-row' | 'pinning' | 'orphan-citation';
|
|
108
|
+
readonly message: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isValidCalendarDate(s: string): boolean {
|
|
112
|
+
if (!ISO_DATE_RE.test(s)) return false;
|
|
113
|
+
const [y, mo, d] = s.split('-').map(Number) as [number, number, number];
|
|
114
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
115
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Strip an optional `:<line>` suffix from a `stated_in` value. */
|
|
119
|
+
export function stripLineSuffix(statedIn: string): string {
|
|
120
|
+
const m = /^(.*):(\d+)$/.exec(statedIn);
|
|
121
|
+
return m ? (m[1] as string) : statedIn;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── record validation ───────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** Validate one parsed ledger line against the closed schema. */
|
|
127
|
+
export function validateRecord(raw: unknown, line: number, repoRoot: string): Finding[] {
|
|
128
|
+
const findings: Finding[] = [];
|
|
129
|
+
|
|
130
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
131
|
+
return [{ line, rule: 'schema', message: 'record is not a JSON object' }];
|
|
132
|
+
}
|
|
133
|
+
const rec = raw as Record<string, unknown>;
|
|
134
|
+
|
|
135
|
+
for (const k of Object.keys(rec)) {
|
|
136
|
+
if (!(REQUIRED_FIELDS as readonly string[]).includes(k)) {
|
|
137
|
+
findings.push({
|
|
138
|
+
line,
|
|
139
|
+
rule: 'schema',
|
|
140
|
+
message: `unexpected field '${k}' — the ledger schema is closed to exactly [${REQUIRED_FIELDS.join(', ')}]`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const k of REQUIRED_FIELDS) {
|
|
145
|
+
if (!(k in rec)) {
|
|
146
|
+
findings.push({ line, rule: 'schema', message: `missing required field '${k}'` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if ('harvest_id' in rec && (typeof rec.harvest_id !== 'string' || !HARVEST_ID_RE.test(rec.harvest_id))) {
|
|
151
|
+
findings.push({
|
|
152
|
+
line,
|
|
153
|
+
rule: 'schema',
|
|
154
|
+
message: `harvest_id must be a kebab-case slug, got ${JSON.stringify(rec.harvest_id)}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if ('stated_in' in rec) {
|
|
159
|
+
if (typeof rec.stated_in !== 'string' || rec.stated_in.trim().length === 0) {
|
|
160
|
+
findings.push({ line, rule: 'schema', message: 'stated_in must be a non-empty repo-relative path' });
|
|
161
|
+
} else {
|
|
162
|
+
const rel = stripLineSuffix(rec.stated_in);
|
|
163
|
+
const resolved = path.resolve(repoRoot, rel);
|
|
164
|
+
const withinRepo = resolved === repoRoot || resolved.startsWith(repoRoot + path.sep);
|
|
165
|
+
if (path.isAbsolute(rel) || !withinRepo) {
|
|
166
|
+
findings.push({
|
|
167
|
+
line,
|
|
168
|
+
rule: 'schema',
|
|
169
|
+
message: `stated_in '${rel}' escapes the repo root — must be a repo-relative path`,
|
|
170
|
+
});
|
|
171
|
+
} else if (!fs.existsSync(resolved)) {
|
|
172
|
+
findings.push({
|
|
173
|
+
line,
|
|
174
|
+
rule: 'dead-row',
|
|
175
|
+
message: `stated_in '${rel}' does not exist — the artefact asserting this harvest is gone, so the row cites nothing`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if ('source_ref' in rec) {
|
|
182
|
+
const ref = rec.source_ref;
|
|
183
|
+
if (typeof ref !== 'string' || ref.trim().length === 0) {
|
|
184
|
+
findings.push({ line, rule: 'schema', message: 'source_ref must be a non-empty string' });
|
|
185
|
+
} else if (!PINNED_URL_RE.test(ref) && !OPAQUE_REF_RE.test(ref)) {
|
|
186
|
+
findings.push({
|
|
187
|
+
line,
|
|
188
|
+
rule: 'pinning',
|
|
189
|
+
message:
|
|
190
|
+
`source_ref ${JSON.stringify(ref)} is neither a revision-pinned URL ('<url>@<sha>') ` +
|
|
191
|
+
"nor an opaque token ('opaque:<id>' / 'ENC1:<payload>'). An unpinned reference cannot be " +
|
|
192
|
+
're-verified later, which is the whole point of recording it',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if ('evidence_locator' in rec) {
|
|
198
|
+
const loc = rec.evidence_locator;
|
|
199
|
+
if (typeof loc !== 'string' || loc.trim().length < EVIDENCE_MIN_LENGTH) {
|
|
200
|
+
findings.push({
|
|
201
|
+
line,
|
|
202
|
+
rule: 'schema',
|
|
203
|
+
message: `evidence_locator must be at least ${EVIDENCE_MIN_LENGTH} characters — a claim whose evidence cannot be located again is not a citation`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if ('harvested_at' in rec && !(typeof rec.harvested_at === 'string' && isValidCalendarDate(rec.harvested_at))) {
|
|
209
|
+
findings.push({
|
|
210
|
+
line,
|
|
211
|
+
rule: 'schema',
|
|
212
|
+
message: `harvested_at must be an ISO-8601 date (YYYY-MM-DD), got ${JSON.stringify(rec.harvested_at)}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if ('verdict' in rec && (typeof rec.verdict !== 'string' || !VERDICT_VALUES.has(rec.verdict))) {
|
|
217
|
+
findings.push({
|
|
218
|
+
line,
|
|
219
|
+
rule: 'schema',
|
|
220
|
+
message:
|
|
221
|
+
`verdict must be 'adopt' or 'adapt', got ${JSON.stringify(rec.verdict)} — a reject/already/unclear ` +
|
|
222
|
+
'finding has no artefact citing it and belongs in the analysis document, not the ledger',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return findings;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── ledger parsing ──────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
export interface ParsedLine {
|
|
232
|
+
readonly line: number;
|
|
233
|
+
readonly value: unknown;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Split jsonl text into non-blank lines, parsing each as JSON. */
|
|
237
|
+
export function parseLedgerText(text: string): { readonly parsed: ParsedLine[]; readonly findings: Finding[] } {
|
|
238
|
+
const parsed: ParsedLine[] = [];
|
|
239
|
+
const findings: Finding[] = [];
|
|
240
|
+
const lines = text.split('\n');
|
|
241
|
+
for (let i = 0; i < lines.length; i++) {
|
|
242
|
+
const raw = (lines[i] ?? '').trim();
|
|
243
|
+
if (raw.length === 0) continue;
|
|
244
|
+
try {
|
|
245
|
+
parsed.push({ line: i + 1, value: JSON.parse(raw) });
|
|
246
|
+
} catch (err) {
|
|
247
|
+
findings.push({ line: i + 1, rule: 'schema', message: `invalid JSON: ${(err as Error).message}` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { parsed, findings };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Parse + validate every line, then check cross-row uniqueness. */
|
|
254
|
+
export function lintLedgerText(
|
|
255
|
+
text: string,
|
|
256
|
+
repoRoot: string,
|
|
257
|
+
): { readonly records: HarvestRecord[]; readonly findings: Finding[] } {
|
|
258
|
+
const { parsed, findings } = parseLedgerText(text);
|
|
259
|
+
const records: HarvestRecord[] = [];
|
|
260
|
+
const seen = new Map<string, number>();
|
|
261
|
+
|
|
262
|
+
for (const { line, value } of parsed) {
|
|
263
|
+
const recordFindings = validateRecord(value, line, repoRoot);
|
|
264
|
+
findings.push(...recordFindings);
|
|
265
|
+
if (recordFindings.length > 0) continue;
|
|
266
|
+
|
|
267
|
+
const rec = value as HarvestRecord;
|
|
268
|
+
const prior = seen.get(rec.harvest_id);
|
|
269
|
+
if (prior !== undefined) {
|
|
270
|
+
findings.push({
|
|
271
|
+
line,
|
|
272
|
+
rule: 'uniqueness',
|
|
273
|
+
message: `duplicate harvest_id '${rec.harvest_id}' (first seen on line ${prior}) — a citation must resolve to exactly one row`,
|
|
274
|
+
});
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
seen.set(rec.harvest_id, line);
|
|
278
|
+
records.push(rec);
|
|
279
|
+
}
|
|
280
|
+
return { records, findings };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── citation scan ───────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
export interface Citation {
|
|
286
|
+
readonly id: string;
|
|
287
|
+
readonly file: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Recursively collect markdown files under a root. */
|
|
291
|
+
function collectMarkdown(dir: string, out: string[]): void {
|
|
292
|
+
let entries: fs.Dirent[];
|
|
293
|
+
try {
|
|
294
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
for (const e of entries) {
|
|
299
|
+
const full = path.join(dir, e.name);
|
|
300
|
+
if (e.isDirectory()) {
|
|
301
|
+
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
302
|
+
collectMarkdown(full, out);
|
|
303
|
+
} else if (e.isFile() && e.name.endsWith('.md')) {
|
|
304
|
+
out.push(full);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Find every `<!-- harvest:<id> -->` marker under the scan roots. */
|
|
310
|
+
export function collectCitations(repoRoot: string, roots: readonly string[] = CITATION_SCAN_ROOTS): Citation[] {
|
|
311
|
+
const files: string[] = [];
|
|
312
|
+
for (const r of roots) collectMarkdown(path.join(repoRoot, r), files);
|
|
313
|
+
|
|
314
|
+
const citations: Citation[] = [];
|
|
315
|
+
for (const f of files) {
|
|
316
|
+
let text: string;
|
|
317
|
+
try {
|
|
318
|
+
text = fs.readFileSync(f, 'utf-8');
|
|
319
|
+
} catch {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// Fresh lastIndex per file — the regex is module-level and /g is stateful.
|
|
323
|
+
const re = new RegExp(CITATION_MARKER_RE.source, 'g');
|
|
324
|
+
let m: RegExpExecArray | null;
|
|
325
|
+
while ((m = re.exec(text)) !== null) {
|
|
326
|
+
citations.push({ id: m[1] as string, file: path.relative(repoRoot, f) });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return citations;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A persona's optional `sources:` frontmatter list is a citation in exactly the
|
|
334
|
+
* sense this gate already checks: an id that must resolve to a ledger row. It
|
|
335
|
+
* is collected here rather than in a second gate so that "a harvest id
|
|
336
|
+
* resolves" has one owner — a persona-side check living elsewhere would be free
|
|
337
|
+
* to disagree with this one.
|
|
338
|
+
*
|
|
339
|
+
* Three states, and only the third produces citations: absent = unscoped,
|
|
340
|
+
* `[]` = explicitly asserts nothing harvested, non-empty = scoped to those ids.
|
|
341
|
+
*/
|
|
342
|
+
export function collectPersonaSources(repoRoot: string, personaRoot = 'src/agent-src/personas'): Citation[] {
|
|
343
|
+
const files: string[] = [];
|
|
344
|
+
collectMarkdown(path.join(repoRoot, personaRoot), files);
|
|
345
|
+
|
|
346
|
+
const citations: Citation[] = [];
|
|
347
|
+
for (const f of files) {
|
|
348
|
+
let text: string;
|
|
349
|
+
try {
|
|
350
|
+
text = fs.readFileSync(f, 'utf-8');
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
355
|
+
if (!fm) continue;
|
|
356
|
+
// Deliberately a narrow line-scanner rather than a YAML parse: this gate
|
|
357
|
+
// must not fail on a persona whose frontmatter is malformed elsewhere —
|
|
358
|
+
// that is validate_frontmatter's finding to report, and duplicating it
|
|
359
|
+
// here would produce two different error messages for one defect.
|
|
360
|
+
const block = /(^|\n)sources:\s*(\[[^\]]*\]|\n(?:\s*-\s*\S+\n?)*)/.exec(fm[1] as string);
|
|
361
|
+
if (!block) continue;
|
|
362
|
+
const raw = block[2] as string;
|
|
363
|
+
const ids = raw.trim().startsWith('[')
|
|
364
|
+
? raw.replace(/[[\]]/g, '').split(',')
|
|
365
|
+
: raw.split('\n').map((l) => l.replace(/^\s*-\s*/, ''));
|
|
366
|
+
for (const id of ids.map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)) {
|
|
367
|
+
citations.push({ id, file: path.relative(repoRoot, f) });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return citations;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Every citation must resolve to a ledger row. */
|
|
374
|
+
export function findOrphanCitations(
|
|
375
|
+
citations: readonly Citation[],
|
|
376
|
+
records: readonly HarvestRecord[],
|
|
377
|
+
): Finding[] {
|
|
378
|
+
const known = new Set(records.map((r) => r.harvest_id));
|
|
379
|
+
return citations
|
|
380
|
+
.filter((c) => !known.has(c.id))
|
|
381
|
+
.map((c) => ({
|
|
382
|
+
line: 0,
|
|
383
|
+
rule: 'orphan-citation' as const,
|
|
384
|
+
message: `${c.file} cites harvest '${c.id}', which has no row in ${LEDGER_REL}`,
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
class ExitCode extends Error {
|
|
391
|
+
readonly code: number;
|
|
392
|
+
constructor(code: number) {
|
|
393
|
+
super(`exit ${code}`);
|
|
394
|
+
this.code = code;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function parseArgs(argv: string[]): { readonly quiet: boolean } {
|
|
399
|
+
let quiet = false;
|
|
400
|
+
for (const a of argv) {
|
|
401
|
+
if (a === '--quiet') {
|
|
402
|
+
quiet = true;
|
|
403
|
+
} else if (a === '-h' || a === '--help') {
|
|
404
|
+
process.stdout.write('usage: lint_harvest_provenance [--quiet]\n');
|
|
405
|
+
throw new ExitCode(0);
|
|
406
|
+
} else {
|
|
407
|
+
process.stderr.write(`❌ lint_harvest_provenance: unrecognized argument: ${a}\n`);
|
|
408
|
+
throw new ExitCode(2);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { quiet };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
415
|
+
const args = parseArgs(argv);
|
|
416
|
+
const ledgerPath = path.join(REPO, LEDGER_REL);
|
|
417
|
+
|
|
418
|
+
// An EMPTY ledger is a legitimate state — it is empty today. An ABSENT one
|
|
419
|
+
// is not: without this guard a moved ledger would read as zero harvests and
|
|
420
|
+
// every citation would surface as an orphan, or (with no citations either)
|
|
421
|
+
// the run would report a clean green over a file that no longer exists.
|
|
422
|
+
try {
|
|
423
|
+
assertWatchlistResolves({ gate: 'lint_harvest_provenance', candidates: [LEDGER_REL], repoRoot: REPO });
|
|
424
|
+
} catch (exc) {
|
|
425
|
+
if (exc instanceof DeadScopeError) {
|
|
426
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
427
|
+
return 2;
|
|
428
|
+
}
|
|
429
|
+
throw exc;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const text = fs.readFileSync(ledgerPath, 'utf-8');
|
|
433
|
+
const { records, findings } = lintLedgerText(text, REPO);
|
|
434
|
+
const citations = [...collectCitations(REPO), ...collectPersonaSources(REPO)];
|
|
435
|
+
const allFindings = [...findings, ...findOrphanCitations(citations, records)];
|
|
436
|
+
|
|
437
|
+
if (allFindings.length > 0) {
|
|
438
|
+
for (const f of allFindings) {
|
|
439
|
+
const at = f.line > 0 ? `${LEDGER_REL}:${f.line}` : LEDGER_REL;
|
|
440
|
+
process.stderr.write(`❌ ${at}: [${f.rule}] ${f.message}\n`);
|
|
441
|
+
}
|
|
442
|
+
process.stderr.write(
|
|
443
|
+
`❌ lint_harvest_provenance: ${allFindings.length} finding(s) — checks our own records, strict from day one\n`,
|
|
444
|
+
);
|
|
445
|
+
return 2;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!args.quiet) {
|
|
449
|
+
// Always name what was examined, including the zero case: a green line
|
|
450
|
+
// that does not distinguish "nothing to check" from "everything checked"
|
|
451
|
+
// is the failure mode this repo has already paid for once.
|
|
452
|
+
process.stdout.write(
|
|
453
|
+
`✅ lint_harvest_provenance: ${records.length} ledger row(s) OK · ` +
|
|
454
|
+
`${citations.length} citation(s) resolved across ${CITATION_SCAN_ROOTS.length} scanned root(s)` +
|
|
455
|
+
`${records.length === 0 && citations.length === 0 ? ' — ledger is empty and nothing cites it, which is a legitimate pass' : ''}\n`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Robust "am I the entry script?" — realpath-compares argv[1] to this file. */
|
|
462
|
+
function _isCliEntry(): boolean {
|
|
463
|
+
const a = process.argv[1];
|
|
464
|
+
if (!a) return false;
|
|
465
|
+
if (a === _FILE || pathToFileURL(path.resolve(a)).href === import.meta.url) return true;
|
|
466
|
+
try {
|
|
467
|
+
return fs.realpathSync(a) === fs.realpathSync(_FILE);
|
|
468
|
+
} catch {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (_isCliEntry()) {
|
|
473
|
+
try {
|
|
474
|
+
process.exit(main());
|
|
475
|
+
} catch (exc) {
|
|
476
|
+
if (exc instanceof ExitCode) {
|
|
477
|
+
process.exit(exc.code);
|
|
478
|
+
}
|
|
479
|
+
throw exc;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export { REPO, LEDGER_REL, ExitCode };
|