@event4u/agent-config 9.12.0 → 9.13.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.
Files changed (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +80 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/rules/active-remediation.md +0 -2
  5. package/dist/agent-src/rules/architecture.md +0 -1
  6. package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -2
  8. package/dist/agent-src/rules/broken-access-control.md +0 -2
  9. package/dist/agent-src/rules/code-comment-discipline.md +0 -3
  10. package/dist/agent-src/rules/code-provenance.md +0 -3
  11. package/dist/agent-src/rules/communication-through-line.md +2 -3
  12. package/dist/agent-src/rules/content-quoting-floor.md +0 -2
  13. package/dist/agent-src/rules/context-hygiene.md +1 -3
  14. package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
  15. package/dist/agent-src/rules/design-fidelity.md +29 -0
  16. package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
  17. package/dist/agent-src/rules/downstream-changes.md +10 -1
  18. package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
  19. package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
  20. package/dist/agent-src/rules/git-history-discipline.md +0 -7
  21. package/dist/agent-src/rules/guidelines.md +0 -2
  22. package/dist/agent-src/rules/improve-before-implement.md +4 -2
  23. package/dist/agent-src/rules/invite-challenge.md +0 -4
  24. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
  25. package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
  26. package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
  27. package/dist/agent-src/rules/missing-tool-handling.md +0 -1
  28. package/dist/agent-src/rules/no-attribution-footers.md +0 -3
  29. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
  30. package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
  31. package/dist/agent-src/rules/no-roadmap-references.md +0 -2
  32. package/dist/agent-src/rules/output-discipline.md +0 -1
  33. package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
  34. package/dist/agent-src/rules/preservation-guard.md +0 -3
  35. package/dist/agent-src/rules/role-mode-adherence.md +0 -1
  36. package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
  37. package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
  38. package/dist/agent-src/rules/session-canary.md +0 -2
  39. package/dist/agent-src/rules/size-enforcement.md +0 -5
  40. package/dist/agent-src/rules/source-confidentiality.md +0 -3
  41. package/dist/agent-src/rules/source-discovery-gate.md +0 -2
  42. package/dist/agent-src/rules/think-before-action.md +3 -3
  43. package/dist/agent-src/rules/token-budget-discipline.md +0 -1
  44. package/dist/agent-src/rules/token-efficiency.md +1 -2
  45. package/dist/agent-src/rules/tool-safety.md +0 -1
  46. package/dist/agent-src/rules/user-interaction.md +0 -3
  47. package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
  48. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
  49. package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
  50. package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
  51. package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
  52. package/dist/agent-src/skills/design-review/SKILL.md +11 -0
  53. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
  54. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
  55. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  56. package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
  57. package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
  58. package/dist/agent-src/skills/performance/SKILL.md +14 -0
  59. package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
  60. package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
  61. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
  62. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
  63. package/dist/agent-src/skills/video-director/SKILL.md +9 -6
  64. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  65. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  66. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  67. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  68. package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
  69. package/dist/discovery/deprecation-report.md +1 -1
  70. package/dist/discovery/discovery-manifest.json +89 -67
  71. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  72. package/dist/discovery/discovery-manifest.summary.md +3 -3
  73. package/dist/discovery/orphan-report.md +1 -1
  74. package/dist/discovery/packs.json +6 -5
  75. package/dist/discovery/trust-report.md +2 -2
  76. package/dist/discovery/workspaces.json +5 -4
  77. package/dist/hooks/dispatch.js +174 -13
  78. package/dist/mcp/registry-manifest.json +3 -3
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +750 -507
  81. package/dist/router.json +1 -1
  82. package/dist/scripts/condense.js +2 -2
  83. package/docs/MIGRATION.md +1 -1
  84. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
  85. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
  86. package/docs/guidelines/code-clarity.md +9 -2
  87. package/docs/guidelines/design-fidelity-mechanics.md +44 -0
  88. package/llms.txt +63 -27
  89. package/package.json +4 -4
  90. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  91. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  92. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  93. package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
  94. package/src/config/agent-settings.template.yml +8 -0
  95. package/src/scripts/_lib/counted_probe.ts +142 -0
  96. package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
  97. package/src/scripts/_lib/release_scope.ts +207 -0
  98. package/src/scripts/ai_council/stance_tally.ts +37 -2
  99. package/src/scripts/audit_auto_rules.ts +9 -9
  100. package/src/scripts/audit_likelihood.ts +3 -3
  101. package/src/scripts/audit_overlap.ts +2 -2
  102. package/src/scripts/audit_skill_overlap.ts +233 -95
  103. package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
  104. package/src/scripts/build_proof.ts +15 -1
  105. package/src/scripts/check_dependency_floors.ts +16 -6
  106. package/src/scripts/check_gate_coverage.ts +6 -6
  107. package/src/scripts/check_kernel_rule_bundle.ts +5 -4
  108. package/src/scripts/check_token_quality_golden.ts +0 -18
  109. package/src/scripts/code_graph/loader.ts +35 -6
  110. package/src/scripts/compile_router.ts +3 -8
  111. package/src/scripts/condense.ts +2 -2
  112. package/src/scripts/explain_run.ts +0 -1
  113. package/src/scripts/generate_pack_manifests.ts +3 -20
  114. package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
  115. package/src/scripts/hooks/block_no_verify.ts +101 -0
  116. package/src/scripts/lint_agent_security.ts +5 -4
  117. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  118. package/src/scripts/lint_regression.ts +92 -1
  119. package/src/scripts/lint_skill_descriptions.ts +93 -5
  120. package/src/scripts/project_thin_rules.ts +2 -2
  121. package/src/scripts/release.ts +107 -3
  122. package/src/scripts/resolve_lint_scope.ts +53 -0
  123. package/src/scripts/router_telemetry.ts +0 -5
  124. package/src/scripts/runtime_dispatcher.ts +40 -0
  125. package/src/scripts/schemas/pack.schema.json +1 -5
  126. package/src/scripts/schemas/rule.schema.json +1 -2
  127. package/src/scripts/self_review_gate.ts +15 -73
  128. package/src/scripts/skill_linter.ts +93 -11
  129. package/src/scripts/sweep_dead_scan_roots.ts +550 -0
  130. package/src/scripts/trigger_coverage.ts +11 -37
@@ -0,0 +1,550 @@
1
+ #!/usr/bin/env -S node --import tsx
2
+ /**
3
+ * sweep_dead_scan_roots — which gate scripts walk a root that does not exist?
4
+ *
5
+ * Population expansion for `road-to-gates-that-can-fail` Phase 1. The committed
6
+ * census (`agents/evidence/reports/gate-scope-census.md`) covers the 14 gates a
7
+ * manual audit confirmed dead; extending it to the full population is that
8
+ * step's stated remaining work. This is the deterministic instrument for it.
9
+ *
10
+ * WHY IT EXISTS: two more dead gates were found OUTSIDE the censused 14 while
11
+ * doing thematically unrelated work — `audit_skill_overlap` (rooted at a
12
+ * deleted container, 0 of 287 skills read) and `lint_media_policy_linkage`
13
+ * (`agents/policies/media` absent, exits 0 with "nothing to lint"). Two
14
+ * incidental finds outside an audited set is a base-rate question, not an
15
+ * anecdote, and a hand-written census cannot answer it repeatably.
16
+ *
17
+ * ── CRITERION ──────────────────────────────────────────────────────────────
18
+ *
19
+ * A finding is a repo path assembled from string literals over a ROOT-like
20
+ * base that does not exist on disk. It is CONFIRMED only with POSITIVE READ
21
+ * EVIDENCE — the path must demonstrably be read, not merely mentioned:
22
+ *
23
+ * direct-read fs read called with the identifier
24
+ * helper-read:<fn> passed to a same-file function whose body reads
25
+ * inline-read a read wraps the join expression itself
26
+ * derived-read-><id>:<ev> a derivation chain that TERMINATES in a read
27
+ * array-iterated-*:<arr> member of a root array whose loop variable reads
28
+ * spec-iterated-*:<arr> property of an iterated spec table, prop reads
29
+ *
30
+ * "Not written to" is deliberately NOT evidence, and neither is derivation on
31
+ * its own. Both were tried and both produced false positives — see the
32
+ * evidence log below. Everything else missing-but-unproven is reported as
33
+ * UNPROVEN, never dropped: static single-file analysis cannot see reads that
34
+ * cross a module boundary, so the confirmed set is a floor, not a census.
35
+ *
36
+ * ── EVIDENCE LOG (every rule below was added by a measured failure) ────────
37
+ *
38
+ * 1. Reads inside function bodies and lowercase locals count. A first cut
39
+ * read only top-level uppercase constants and MISSED `audit_skill_overlap`
40
+ * — a sweep that misses a known-dead gate is itself a dead gate.
41
+ * 2. Positive read evidence, not absence of writes. "Not write-only" flagged
42
+ * `check_gate_paths`, whose `packages` entry is a membership allowlist for
43
+ * an `_is_under_source_tree` predicate. Path-as-predicate is not
44
+ * path-as-root.
45
+ * 3. Array membership, with the loop variable required to read. Multi-root
46
+ * gates collect roots in an array literal, leaving no per-root identifier
47
+ * — that is why `lint_media_policy_linkage` reported 1 of its 4 dead roots.
48
+ * Treating iteration alone as evidence re-flagged `check_gate_paths`
49
+ * (it iterates its allowlist into `startsWith`), so the loop variable must
50
+ * itself flow into a read.
51
+ * 4. Derivation must terminate in a read. `derived-join` used to assert only
52
+ * "this identifier is the base of a further join" — derivation, not
53
+ * lecture. It carried four class-A findings.
54
+ * 5. Property joins in iterated spec tables. Rule 4 demoted `lint_namespace`,
55
+ * which the census independently lists as dead: its roots live as object
56
+ * properties in a `TARGETS` table whose destructured loop variable feeds a
57
+ * reading helper. Neither the identifier trail nor the array tracer could
58
+ * attach.
59
+ *
60
+ * Not built, on purpose: `forEach`/`map` spec variants and property joins
61
+ * outside array literals. Neither occurs in this corpus. A rule without a
62
+ * measured occurrence is the speculative extension this log exists to prevent.
63
+ *
64
+ * ── DISPOSITIONS LEDGER ────────────────────────────────────────────────────
65
+ *
66
+ * Findings cleared by manual review live in `agents/evidence/sweep-dispositions.json`
67
+ * ({script, rel, category, reason, date}). They are PRINTED as
68
+ * `known-benign (<category>)`, never silently suppressed — the sweep has
69
+ * memory so the same three cases are not re-litigated every run. A ledger
70
+ * entry with no matching finding is STALE (the code moved; re-review), and the
71
+ * ledger is capped so growth forces re-litigation instead of quiet accretion.
72
+ *
73
+ * ── EXIT CONTRACT (one meaning per code, so red stays informative) ─────────
74
+ *
75
+ * 0 clean
76
+ * 1 confirmed class-A finding(s) — real dead roots. Nothing else returns 1.
77
+ * 2 self-test failure — the extractor is blind; output is not trustworthy.
78
+ * 3 ledger hygiene — stale entry or over cap. The sweep ran fine; the LIST
79
+ * needs work.
80
+ *
81
+ * Real findings outrank hygiene: a stale entry alongside genuine class-A hits
82
+ * still exits 1, with the stale warning on stderr. Returning 3 there would let
83
+ * a bookkeeping problem mask dead gates — the precise failure this gate exists
84
+ * to catch, reproduced in the gate itself.
85
+ *
86
+ * Usage:
87
+ * ./scripts-run src/scripts/sweep_dead_scan_roots [--quiet] [--json <path>]
88
+ * [--root <dir>] [--ledger <path>]
89
+ */
90
+
91
+ import * as fs from 'node:fs';
92
+ import * as path from 'node:path';
93
+ import process from 'node:process';
94
+ import { fileURLToPath } from 'node:url';
95
+
96
+ const _HERE = fileURLToPath(import.meta.url);
97
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
98
+ const DEFAULT_SCRIPTS = path.join(REPO, 'src', 'scripts');
99
+ const DEFAULT_LEDGER = path.join(REPO, 'agents', 'evidence', 'sweep-dispositions.json');
100
+ const LEDGER_CAP = 15;
101
+
102
+ /** Population: the gate-name prefix convention the census uses. */
103
+ const GATE_RE = /^(lint|audit|check|verify)_.*\.ts$/;
104
+
105
+ /** Identifiers accepted as a repo-root base for a literal join. */
106
+ const BASE_IDS: ReadonlySet<string> = new Set([
107
+ 'ROOT', 'REPO_ROOT', 'REPO', 'PROJECT_ROOT', 'REPO_DIR',
108
+ ]);
109
+
110
+ const READ_FNS = 'readdirSync|readFileSync|statSync|opendirSync|existsSync|globSync';
111
+ const READ_RE = new RegExp(`(${READ_FNS})`);
112
+
113
+ /**
114
+ * Triage classes. Assignment is mechanical; disposition is a human call.
115
+ * A — pre-ADR-051 containers: the exact class the census repaired 14 of.
116
+ * B — build artifacts consumed as inputs: missing in a fresh clone is only a
117
+ * defect if CI runs the gate before its producer.
118
+ * C — everything else: optional surface (→ `allowEmpty` with a reason) or a
119
+ * retired feature (→ retire the gate).
120
+ */
121
+ const CLASS_A_RETIRED: readonly string[] = ['.agent-src.uncondensed', 'packages'];
122
+ const CLASS_B_ARTIFACT: readonly string[] = ['dist/', 'internal/bench/reports/', '.github/', 'site/dist'];
123
+
124
+ export type TriageClass = 'A' | 'B' | 'C';
125
+
126
+ export function classify(rel: string): TriageClass {
127
+ if (CLASS_A_RETIRED.some((p) => rel === p || rel.startsWith(`${p}/`))) return 'A';
128
+ if (CLASS_B_ARTIFACT.some((p) => rel.startsWith(p))) return 'B';
129
+ return 'C';
130
+ }
131
+
132
+ export interface Finding {
133
+ rel: string;
134
+ names: string;
135
+ evidence: string;
136
+ }
137
+
138
+ export interface Analysis {
139
+ confirmed: Finding[];
140
+ unproven: Finding[];
141
+ }
142
+
143
+ export interface Disposition {
144
+ script: string;
145
+ rel: string;
146
+ category: string;
147
+ reason: string;
148
+ date: string;
149
+ }
150
+
151
+ /** Literal segments of a `path.join(base, 'a', 'b')` tail, joined POSIX-style. */
152
+ function literals(tail: string): string {
153
+ return [...tail.matchAll(/'([^']*)'/g)].map((x) => x[1]).join('/');
154
+ }
155
+
156
+ /** Same-file functions whose body contains a read (crude slice, deliberately). */
157
+ function localReaders(src: string): Set<string> {
158
+ const out = new Set<string>();
159
+ const re =
160
+ /(?:function\s+([A-Za-z_$][\w$]*)|const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
161
+ let m: RegExpExecArray | null;
162
+ while ((m = re.exec(src)) !== null) {
163
+ const name = (m[1] ?? m[2]) as string;
164
+ if (READ_RE.test(src.slice(m.index, m.index + 3000))) out.add(name);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** Does `v` flow into a read within `scope`? Returns the evidence kind. */
170
+ function readsWithin(src: string, scope: string, v: string, readers: Set<string>, kind: string): string | null {
171
+ if (new RegExp(`(${READ_FNS})\\([^)]*\\b${v}\\b`).test(scope)) return `${kind}-read`;
172
+ for (const r of readers) {
173
+ if (new RegExp(`\\b${r}\\(\\s*${v}\\b`).test(scope)) return `${kind}-helper:${r}`;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** `const NAME = [ path.join(<BASE>, 'lit'…), … ]` — roots collected in an array. */
179
+ function arrayRootDecls(src: string): Array<{ name: string; rels: string[] }> {
180
+ const out: Array<{ name: string; rels: string[] }> = [];
181
+ const re = /const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*\[([\s\S]*?)\]/g;
182
+ let m: RegExpExecArray | null;
183
+ while ((m = re.exec(src)) !== null) {
184
+ const rels: string[] = [];
185
+ const jre = /path\.join\(\s*([A-Za-z_$][\w$]*)\s*((?:,\s*'[^']*')+)\s*\)/g;
186
+ let j: RegExpExecArray | null;
187
+ while ((j = jre.exec(m[2] as string)) !== null) {
188
+ if (BASE_IDS.has(j[1] as string)) rels.push(literals(j[2] as string));
189
+ }
190
+ if (rels.length) out.push({ name: m[1] as string, rels });
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /** Read-shaped use of a whole root array — inherited by every member. */
196
+ function arrayEvidence(src: string, name: string, readers: Set<string>): string | null {
197
+ const forRe = new RegExp(`for\\s*\\(\\s*const\\s+([\\w$]+)\\s+of\\s+${name}\\b`, 'g');
198
+ let m: RegExpExecArray | null;
199
+ while ((m = forRe.exec(src)) !== null) {
200
+ const ev = readsWithin(src, src.slice(m.index, m.index + 400), m[1] as string, readers, 'array-iterated');
201
+ if (ev) return ev;
202
+ }
203
+ const cbRe = new RegExp(`${name}\\.(?:forEach|map|flatMap|filter)\\(\\s*\\(?([\\w$]+)\\)?\\s*=>`, 'g');
204
+ while ((m = cbRe.exec(src)) !== null) {
205
+ const ev = readsWithin(src, src.slice(m.index, m.index + 400), m[1] as string, readers, 'array-traversed');
206
+ if (ev) return ev;
207
+ }
208
+ for (const r of readers) {
209
+ if (new RegExp(`\\b${r}\\(\\s*(?:\\.\\.\\.)?${name}\\b`).test(src)) return `array-passed-helper:${r}`;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /** Identifier → repo-relative path, two hops, so property joins over a derived parent resolve. */
215
+ function resolveMap(src: string): Map<string, string> {
216
+ const out = new Map<string, string>();
217
+ const re =
218
+ /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*path\.join\(\s*([A-Za-z_$][\w$]*)\s*((?:,\s*'[^']*')*)\s*\)/g;
219
+ for (let hop = 0; hop < 2; hop += 1) {
220
+ re.lastIndex = 0;
221
+ let m: RegExpExecArray | null;
222
+ while ((m = re.exec(src)) !== null) {
223
+ const [, name, base, tail] = m;
224
+ const lit = literals(tail as string);
225
+ if (BASE_IDS.has(base as string)) {
226
+ if (lit) out.set(name as string, lit);
227
+ } else if (out.has(base as string)) {
228
+ const parent = out.get(base as string) as string;
229
+ out.set(name as string, lit ? `${parent}/${lit}` : parent);
230
+ }
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+
236
+ export function analyze(src: string, repoRoot: string): Analysis {
237
+ const readers = localReaders(src);
238
+ const resolved = resolveMap(src);
239
+
240
+ // Property joins inside array-literal spec tables; evidence comes from the
241
+ // DESTRUCTURED property flowing into a read.
242
+ const specHits = new Map<string, string>();
243
+ const specUnproven = new Set<string>();
244
+ {
245
+ const are = /const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*\[([\s\S]*?)\];/g;
246
+ let am: RegExpExecArray | null;
247
+ while ((am = are.exec(src)) !== null) {
248
+ const [, arrName, body] = am;
249
+ const pre =
250
+ /([A-Za-z_$][\w$]*)\s*:\s*path\.join\(\s*([A-Za-z_$][\w$]*)\s*((?:,\s*'[^']*')*)\s*\)/g;
251
+ const propRels: Array<{ prop: string; rel: string }> = [];
252
+ let pm: RegExpExecArray | null;
253
+ while ((pm = pre.exec(body as string)) !== null) {
254
+ const [, prop, base, tail] = pm;
255
+ const lit = literals(tail as string);
256
+ let rel: string | null = null;
257
+ if (BASE_IDS.has(base as string)) rel = lit || null;
258
+ else if (resolved.has(base as string)) {
259
+ const parent = resolved.get(base as string) as string;
260
+ rel = lit ? `${parent}/${lit}` : parent;
261
+ }
262
+ if (rel) propRels.push({ prop: prop as string, rel });
263
+ }
264
+ if (!propRels.length) continue;
265
+
266
+ const evByProp = new Map<string, string>();
267
+ const fre = new RegExp(`for\\s*\\(\\s*const\\s*\\{([^}]*)\\}\\s+of\\s+${arrName}\\b`, 'g');
268
+ let fm: RegExpExecArray | null;
269
+ while ((fm = fre.exec(src)) !== null) {
270
+ const scope = src.slice(fm.index, fm.index + 400);
271
+ for (const raw of (fm[1] as string).split(',')) {
272
+ const v = raw.trim().split(':').pop()?.trim() ?? '';
273
+ if (!v || evByProp.has(v)) continue;
274
+ const ev = readsWithin(src, scope, v, readers, 'spec-iterated');
275
+ if (ev) evByProp.set(v, ev);
276
+ }
277
+ }
278
+ for (const { prop, rel } of propRels) {
279
+ const ev = evByProp.get(prop);
280
+ if (ev) specHits.set(rel, `${ev}:${arrName}`);
281
+ else specUnproven.add(rel);
282
+ }
283
+ }
284
+ }
285
+
286
+ const arrayHits = new Map<string, string>();
287
+ const arrayUnproven = new Set<string>();
288
+ for (const arr of arrayRootDecls(src)) {
289
+ const ev = arrayEvidence(src, arr.name, readers);
290
+ for (const rel of arr.rels) {
291
+ if (ev) arrayHits.set(rel, `${ev}:${arr.name}`);
292
+ else arrayUnproven.add(rel);
293
+ }
294
+ }
295
+
296
+ // Direct literal joins over a base, with their declaring identifier.
297
+ const found = new Map<string, { names: Set<string>; inlineRead: boolean }>();
298
+ {
299
+ const re =
300
+ /(?:(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*)?path\.join\(\s*([A-Za-z_$][\w$]*)\s*((?:,\s*'[^']*')+)\s*\)/g;
301
+ let m: RegExpExecArray | null;
302
+ while ((m = re.exec(src)) !== null) {
303
+ const [, name, base, tail] = m;
304
+ if (!BASE_IDS.has(base as string)) continue;
305
+ const rel = literals(tail as string);
306
+ if (!rel) continue;
307
+ const e = found.get(rel) ?? { names: new Set<string>(), inlineRead: false };
308
+ if (name) e.names.add(name);
309
+ // Anchored on purpose: the read must WRAP the join. An unanchored
310
+ // lookback bled across newlines and handed a preceding statement's
311
+ // read to an unrelated array member.
312
+ else if (new RegExp(`(${READ_FNS})\\s*\\($`).test(src.slice(Math.max(0, m.index - 40), m.index))) {
313
+ e.inlineRead = true;
314
+ }
315
+ found.set(rel, e);
316
+ }
317
+ }
318
+
319
+ // Derivation graph: parent identifier → children declared from it.
320
+ const derived = new Map<string, string[]>();
321
+ {
322
+ const re =
323
+ /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*path\.join\(\s*([A-Za-z_$][\w$]*)\s*(?:,\s*'[^']*')*\s*\)/g;
324
+ let m: RegExpExecArray | null;
325
+ while ((m = re.exec(src)) !== null) {
326
+ const [, child, parent] = m;
327
+ if (BASE_IDS.has(parent as string)) continue;
328
+ derived.set(parent as string, [...(derived.get(parent as string) ?? []), child as string]);
329
+ }
330
+ }
331
+
332
+ const identifierEvidence = (n: string, seen: Set<string>, depth: number): string | null => {
333
+ if (depth > 3 || seen.has(n)) return null;
334
+ seen.add(n);
335
+ if (new RegExp(`(${READ_FNS})\\([^)]*\\b${n}\\b`).test(src)) return 'direct-read';
336
+ for (const r of readers) {
337
+ if (new RegExp(`\\b${r}\\(\\s*${n}\\b`).test(src)) return `helper-read:${r}`;
338
+ }
339
+ for (const child of derived.get(n) ?? []) {
340
+ const ev = identifierEvidence(child, seen, depth + 1);
341
+ if (ev) return `derived-read->${child}:${ev}`;
342
+ }
343
+ return null; // derivation without a terminating read is NOT evidence
344
+ };
345
+
346
+ const confirmed: Finding[] = [];
347
+ const unproven: Finding[] = [];
348
+ const missing = (rel: string): boolean => !fs.existsSync(path.join(repoRoot, rel));
349
+
350
+ for (const [rel, e] of found) {
351
+ if (!missing(rel)) continue;
352
+ let evidence: string | null =
353
+ e.inlineRead ? 'inline-read' : (arrayHits.get(rel) ?? specHits.get(rel) ?? null);
354
+ let writeOnly = false;
355
+ for (const n of e.names) {
356
+ if (evidence) break;
357
+ evidence = identifierEvidence(n, new Set<string>(), 0);
358
+ if (!evidence) {
359
+ writeOnly = new RegExp(
360
+ `(mkdirSync|writeFileSync|appendFileSync|copyFileSync)\\([^)]*\\b${n}\\b`,
361
+ ).test(src);
362
+ }
363
+ }
364
+ const hit: Finding = { rel, names: [...e.names].join(',') || '(inline)', evidence: evidence ?? '—' };
365
+ if (evidence) confirmed.push(hit);
366
+ else if (!writeOnly) unproven.push(hit);
367
+ }
368
+
369
+ const push = (target: Finding[], other: Finding[], rel: string, names: string, evidence: string): void => {
370
+ if (!missing(rel)) return;
371
+ if (confirmed.some((h) => h.rel === rel) || other.some((h) => h.rel === rel)) return;
372
+ target.push({ rel, names, evidence });
373
+ };
374
+ for (const [rel, ev] of specHits) push(confirmed, [], rel, '(spec-table)', ev);
375
+ for (const rel of specUnproven) push(unproven, unproven, rel, '(spec-table)', '—');
376
+ for (const [rel, ev] of arrayHits) push(confirmed, [], rel, '(array)', ev);
377
+ for (const rel of arrayUnproven) push(unproven, unproven, rel, '(array)', '—');
378
+
379
+ return { confirmed, unproven };
380
+ }
381
+
382
+ /**
383
+ * In-process guard: the dual fixture — the one case that pins BOTH directions
384
+ * of the precision/recall trade. A spec-table root (the `lint_namespace` shape)
385
+ * must be confirmed; a predicate-only iteration (the `check_gate_paths` shape)
386
+ * must not. Every other fixture lives in the test file; this one runs in
387
+ * production because a blind extractor must refuse to report at all.
388
+ */
389
+ export function selfTest(repoRoot: string): boolean {
390
+ const fixture = [
391
+ "const SRC_J = path.join(ROOT, 'agents', 'sweep-selftest-absent-j');",
392
+ "const TARGETS_J = [{ kind: 'skill', root: path.join(SRC_J, 'skills'), glob: '*.md' }];",
393
+ 'function _globJ(p, g) { return fs.readdirSync(p).filter((x) => x.endsWith(g)); }',
394
+ 'for (const { kind, root, glob } of TARGETS_J) { _globJ(root, glob); }',
395
+ "const ALLOW_K = [{ label: 'x', root: path.join(ROOT, 'agents', 'sweep-selftest-absent-k') }];",
396
+ 'for (const { label, root } of ALLOW_K) { if (target.startsWith(root)) { ok = true; } }',
397
+ ].join('\n');
398
+ const { confirmed, unproven } = analyze(fixture, repoRoot);
399
+ const c = new Set(confirmed.map((h) => h.rel));
400
+ const u = new Set(unproven.map((h) => h.rel));
401
+ return (
402
+ c.has('agents/sweep-selftest-absent-j/skills') &&
403
+ !c.has('agents/sweep-selftest-absent-k') &&
404
+ u.has('agents/sweep-selftest-absent-k')
405
+ );
406
+ }
407
+
408
+ export function loadLedger(file: string): Disposition[] {
409
+ if (!fs.existsSync(file)) return [];
410
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Disposition[];
411
+ return Array.isArray(data) ? data : [];
412
+ }
413
+
414
+ interface Args {
415
+ quiet: boolean;
416
+ json: string | null;
417
+ scripts: string;
418
+ ledger: string;
419
+ }
420
+
421
+ export function parseArgs(argv: string[]): Args {
422
+ const a: Args = { quiet: false, json: null, scripts: DEFAULT_SCRIPTS, ledger: DEFAULT_LEDGER };
423
+ for (let i = 0; i < argv.length; i += 1) {
424
+ const arg = argv[i];
425
+ if (arg === '--quiet') a.quiet = true;
426
+ else if (arg === '--json') a.json = argv[(i += 1)] ?? '';
427
+ else if (arg === '--root') a.scripts = path.resolve(REPO, argv[(i += 1)] ?? DEFAULT_SCRIPTS);
428
+ else if (arg === '--ledger') a.ledger = path.resolve(REPO, argv[(i += 1)] ?? DEFAULT_LEDGER);
429
+ else {
430
+ process.stderr.write(`sweep_dead_scan_roots: unrecognized argument: ${arg}\n`);
431
+ process.exit(2);
432
+ }
433
+ }
434
+ return a;
435
+ }
436
+
437
+ export function main(argv: string[]): number {
438
+ const args = parseArgs(argv);
439
+ const repoRoot = path.resolve(args.scripts, '..', '..');
440
+
441
+ if (!selfTest(repoRoot)) {
442
+ process.stderr.write(
443
+ 'sweep_dead_scan_roots: SELF-TEST FAILED — the extractor is blind on a pinned mode, refusing to report.\n',
444
+ );
445
+ return 2;
446
+ }
447
+
448
+ let ledger: Disposition[];
449
+ try {
450
+ ledger = loadLedger(args.ledger);
451
+ } catch (exc) {
452
+ process.stderr.write(`sweep_dead_scan_roots: unreadable ledger — ${String(exc)}\n`);
453
+ return 3;
454
+ }
455
+ if (ledger.length > LEDGER_CAP) {
456
+ process.stderr.write(
457
+ `sweep_dead_scan_roots: ledger has ${ledger.length} entries (cap ${LEDGER_CAP}) — ` +
458
+ 're-litigate the dispositions before adding more.\n',
459
+ );
460
+ return 3;
461
+ }
462
+
463
+ const pop = fs.existsSync(args.scripts)
464
+ ? fs.readdirSync(args.scripts).filter((f) => GATE_RE.test(f)).sort()
465
+ : [];
466
+ const results = new Map<string, Analysis>();
467
+ for (const f of pop) {
468
+ const r = analyze(fs.readFileSync(path.join(args.scripts, f), 'utf-8'), repoRoot);
469
+ if (r.confirmed.length || r.unproven.length) results.set(f, r);
470
+ }
471
+
472
+ const used = new Set<Disposition>();
473
+ const disposed = (script: string, rel: string): Disposition | undefined => {
474
+ const hit = ledger.find((l) => l.script === script && l.rel === rel);
475
+ if (hit) used.add(hit);
476
+ return hit;
477
+ };
478
+
479
+ let classA = 0;
480
+ const lines: string[] = [];
481
+ for (const [script, r] of results) {
482
+ for (const h of r.confirmed) {
483
+ const d = disposed(script, h.rel);
484
+ if (d) {
485
+ lines.push(` CONFIRMED ${script}: ${h.rel} — known-benign (${d.category}): ${d.reason}`);
486
+ continue;
487
+ }
488
+ const cls = classify(h.rel);
489
+ if (cls === 'A') classA += 1;
490
+ lines.push(` CONFIRMED [${cls}] ${script}: ${h.rel} [${h.evidence}] (${h.names})`);
491
+ }
492
+ for (const h of r.unproven) {
493
+ const d = disposed(script, h.rel);
494
+ lines.push(
495
+ d
496
+ ? ` unproven ${script}: ${h.rel} — known-benign (${d.category}): ${d.reason}`
497
+ : ` unproven [${classify(h.rel)}] ${script}: ${h.rel} (${h.names})`,
498
+ );
499
+ }
500
+ }
501
+
502
+ const stale = ledger.filter((l) => !used.has(l));
503
+ const confirmedCount = [...results.values()].reduce((n, r) => n + r.confirmed.length, 0);
504
+ const unprovenCount = [...results.values()].reduce((n, r) => n + r.unproven.length, 0);
505
+
506
+ if (args.json) {
507
+ fs.mkdirSync(path.dirname(args.json), { recursive: true });
508
+ fs.writeFileSync(
509
+ args.json,
510
+ `${JSON.stringify(
511
+ {
512
+ population: pop.length,
513
+ confirmed: confirmedCount,
514
+ unproven: unprovenCount,
515
+ class_a: classA,
516
+ stale: stale.map((s) => `${s.script}:${s.rel}`),
517
+ findings: [...results].map(([script, r]) => ({ script, ...r })),
518
+ },
519
+ null,
520
+ 2,
521
+ )}\n`,
522
+ );
523
+ }
524
+
525
+ if (!args.quiet) {
526
+ process.stdout.write(`sweep_dead_scan_roots: ${pop.length} gate script(s) scanned\n`);
527
+ for (const l of lines) process.stdout.write(`${l}\n`);
528
+ }
529
+
530
+ if (stale.length) {
531
+ process.stderr.write(
532
+ `⚠️ STALE ledger entries (no matching finding — the code moved, re-review): ` +
533
+ `${stale.map((l) => `${l.script}:${l.rel}`).join(', ')}\n`,
534
+ );
535
+ }
536
+
537
+ // Real findings outrank ledger hygiene — never let bookkeeping mask a dead gate.
538
+ const exit = classA > 0 ? 1 : stale.length > 0 ? 3 : 0;
539
+ if (!args.quiet || exit !== 0) {
540
+ process.stdout.write(
541
+ `sweep_dead_scan_roots: ${confirmedCount} confirmed, ${unprovenCount} unproven, ` +
542
+ `${classA} class-A, ${stale.length} stale → exit ${exit}\n`,
543
+ );
544
+ }
545
+ return exit;
546
+ }
547
+
548
+ if (path.resolve(process.argv[1] ?? '') === path.resolve(_HERE)) {
549
+ process.exit(main(process.argv.slice(2)));
550
+ }
@@ -19,8 +19,14 @@
19
19
  * - kernel rules always fire (always-on layer).
20
20
  * - a tier rule fires iff any of its triggers matches the prompt:
21
21
  * - `keyword` → case-insensitive substring.
22
- * - `intent` → every alpha word (len>2) of the intent phrase appears as a
23
- * token in the prompt.
22
+ * - `phrase` → case-insensitive substring (multi-word).
23
+ *
24
+ * `intent` used to be the second matcher here, on word-set-inclusion
25
+ * semantics, while `router_telemetry` documented the same key as
26
+ * "informational only — never auto-matches". That divergence is gone: the
27
+ * trigger type was removed, and `phrase` — which every other tool already
28
+ * treats as a real matcher — took its place. A rule now proves it can fire on
29
+ * a substring a prompt actually contains, not on a bag of words.
24
30
  *
25
31
  * A case fails when an expected rule is NOT in the fired set. Exit 1 on any
26
32
  * miss → the merge that would have shrunk the rule is blocked (2.2).
@@ -40,9 +46,6 @@ export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
40
46
  export const ROUTER = path.join(REPO_ROOT, 'dist', 'router.json');
41
47
  export const CORPUS = path.join(REPO_ROOT, 'tests', 'eval', 'trigger-coverage.yaml');
42
48
 
43
- // _WORD = re.compile(r"[a-z][a-z0-9_]+") — matched against text.lower().
44
- const _WORD = /[a-z][a-z0-9_]+/g;
45
-
46
49
  type Router = {
47
50
  kernel?: string[];
48
51
  tier_1?: Array<{ id: string; triggers?: Array<Record<string, string>> }>;
@@ -63,48 +66,19 @@ interface CaseResult {
63
66
  expect: string[];
64
67
  }
65
68
 
66
- function _tokens(text: string): Set<string> {
67
- const out = new Set<string>();
68
- const low = text.toLowerCase();
69
- _WORD.lastIndex = 0;
70
- let m: RegExpExecArray | null;
71
- while ((m = _WORD.exec(low)) !== null) {
72
- const w = m[0];
73
- if (w.length > 2) {
74
- out.add(w);
75
- }
76
- }
77
- return out;
78
- }
79
-
80
- function _isSubset(small: Set<string>, big: Set<string>): boolean {
81
- for (const v of small) {
82
- if (!big.has(v)) {
83
- return false;
84
- }
85
- }
86
- return true;
87
- }
88
-
89
69
  export function load_router(): Router {
90
70
  return JSON.parse(fs.readFileSync(ROUTER, 'utf-8')) as Router;
91
71
  }
92
72
 
93
73
  export function fired_rules(prompt: string, router: Router): Set<string> {
94
74
  const low = prompt.toLowerCase();
95
- const toks = _tokens(prompt);
96
75
  const fired = new Set<string>(router.kernel ?? []);
97
76
  for (const tier of ['tier_1', 'tier_2'] as const) {
98
77
  for (const entry of router[tier] ?? []) {
99
78
  for (const trig of entry.triggers ?? []) {
100
- if ('keyword' in trig) {
101
- if (low.includes((trig['keyword'] as string).toLowerCase())) {
102
- fired.add(entry.id);
103
- break;
104
- }
105
- } else if ('intent' in trig) {
106
- const words = _tokens(trig['intent'] as string);
107
- if (words.size > 0 && _isSubset(words, toks)) {
79
+ if ('keyword' in trig || 'phrase' in trig) {
80
+ const needle = String(trig['keyword'] ?? trig['phrase']).toLowerCase();
81
+ if (needle !== '' && low.includes(needle)) {
108
82
  fired.add(entry.id);
109
83
  break;
110
84
  }