@event4u/agent-config 14.17.0 → 14.19.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 (184) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +207 -77
  5. package/README.md +72 -150
  6. package/dist/agent-src/README.md +1 -1
  7. package/dist/agent-src/commands/agent-handoff.md +2 -2
  8. package/dist/agent-src/commands/pr/merge.md +35 -44
  9. package/dist/agent-src/commands/review/changes.md +19 -19
  10. package/dist/agent-src/commands/review.md +2 -2
  11. package/dist/agent-src/contexts/execution/host-capability-manifest.md +30 -3
  12. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -2
  14. package/dist/agent-src/rules/guidelines.md +1 -1
  15. package/dist/agent-src/rules/session-canary.md +2 -2
  16. package/dist/agent-src/rules/token-efficiency.md +1 -1
  17. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  18. package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
  19. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  20. package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
  21. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  22. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  23. package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
  24. package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
  25. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
  26. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
  27. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
  28. package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
  29. package/dist/agent-src/skills/design-review/SKILL.md +3 -1
  30. package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
  31. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  32. package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
  33. package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
  34. package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
  35. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  36. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  37. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  38. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  39. package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
  40. package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
  41. package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
  42. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  43. package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
  44. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  45. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  46. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
  47. package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
  48. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +29 -2
  49. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  50. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  51. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  52. package/dist/cli/commands/recordTriggerEval.js +2 -2
  53. package/dist/cli/main.js +1 -1
  54. package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-GZTMRYCN.js} +405 -341
  55. package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-IDHUQIXP.js} +283 -132
  56. package/dist/cli-delegate/cmd_adr_effective.js +318 -81
  57. package/dist/cli-delegate/cmd_conformance.js +749 -669
  58. package/dist/cli-delegate/cmd_doctor.js +1 -1
  59. package/dist/cli-delegate/cmd_export.js +1 -1
  60. package/dist/cli-delegate/cmd_handoff.js +1 -1
  61. package/dist/cli-delegate/cmd_prune.js +1 -1
  62. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  63. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  64. package/dist/cli-delegate/cmd_sync.js +1 -1
  65. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  66. package/dist/cli-delegate/cmd_validate.js +1 -1
  67. package/dist/discovery/deprecation-report.md +1 -1
  68. package/dist/discovery/discovery-manifest.json +39 -38
  69. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  70. package/dist/discovery/discovery-manifest.summary.md +1 -1
  71. package/dist/discovery/dormancy-report.md +1 -1
  72. package/dist/discovery/orphan-report.md +1 -1
  73. package/dist/discovery/packs.json +2 -2
  74. package/dist/discovery/trust-report.md +1 -1
  75. package/dist/discovery/workspaces.json +2 -2
  76. package/dist/hooks/dispatch.js +2764 -2439
  77. package/dist/install/install.mjs +588 -527
  78. package/dist/mcp/registry-manifest.json +2 -2
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +1381 -1138
  81. package/dist/scripts/_lib/model_tier.js +1 -1
  82. package/dist/shared/settingsConsent.js +1 -1
  83. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
  84. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
  85. package/docs/guidelines/design-antipatterns.md +87 -64
  86. package/package.json +1 -1
  87. package/src/agent-src/README.md +1 -1
  88. package/src/config/ci-local-parity.yml +6 -0
  89. package/src/config/conformance-claim-baseline.json +9 -0
  90. package/src/config/gate-coverage.yml +170 -0
  91. package/src/config/gate-violation-baselines.json +4 -4
  92. package/src/config/release-gate-locality.yml +141 -0
  93. package/src/scripts/_cli/cmd_doctor.ts +2 -2
  94. package/src/scripts/_cli/cmd_handoff.ts +1 -1
  95. package/src/scripts/_cli/cmd_update.ts +1 -1
  96. package/src/scripts/_cli/cmd_versions.ts +1 -1
  97. package/src/scripts/_lib/adr_frontmatter.ts +44 -0
  98. package/src/scripts/_lib/consequence_objects.ts +152 -0
  99. package/src/scripts/_lib/exec_evidence.ts +7 -0
  100. package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
  101. package/src/scripts/_lib/host_permission_checks.ts +251 -0
  102. package/src/scripts/_lib/model_tier.ts +1 -1
  103. package/src/scripts/_lib/obligation_frequency.ts +2 -2
  104. package/src/scripts/_lib/release_highlights.ts +265 -1
  105. package/src/scripts/_lib/release_material.ts +129 -0
  106. package/src/scripts/_lib/repeated_run.ts +77 -0
  107. package/src/scripts/_lib/runtime_journal.ts +26 -3
  108. package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
  109. package/src/scripts/_lib/session_register.ts +11 -6
  110. package/src/scripts/_lib/sqlite_schema.ts +22 -0
  111. package/src/scripts/_lib/surface.ts +86 -0
  112. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  113. package/src/scripts/_lib/ui_authority.ts +116 -0
  114. package/src/scripts/_lib/unified_diff.ts +121 -0
  115. package/src/scripts/adr/regenerate_index.ts +54 -1
  116. package/src/scripts/adr_cite_check.ts +71 -15
  117. package/src/scripts/ai_council/clients.ts +1 -1
  118. package/src/scripts/autonomy_friction_corpus.ts +229 -0
  119. package/src/scripts/build_proof.ts +15 -2
  120. package/src/scripts/check_beta_review_markers.ts +141 -17
  121. package/src/scripts/check_enforcement_coverage.ts +1 -1
  122. package/src/scripts/check_enforcement_denominator.ts +121 -11
  123. package/src/scripts/check_finding_dispositions.ts +239 -6
  124. package/src/scripts/check_gate_coverage.ts +96 -0
  125. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  126. package/src/scripts/check_release_highlights.ts +99 -14
  127. package/src/scripts/check_skill_activation_claim.ts +179 -0
  128. package/src/scripts/check_suppression_hygiene.ts +11 -0
  129. package/src/scripts/check_trigger_evals.ts +1 -1
  130. package/src/scripts/code_graph/build.ts +230 -33
  131. package/src/scripts/code_graph/extract.ts +168 -33
  132. package/src/scripts/code_graph/types.ts +37 -2
  133. package/src/scripts/code_graph/validate.ts +12 -1
  134. package/src/scripts/conformance_scan.ts +45 -4
  135. package/src/scripts/detect_silent_catch.ts +403 -0
  136. package/src/scripts/detect_verification_tampering.ts +538 -0
  137. package/src/scripts/explain_run.ts +1 -1
  138. package/src/scripts/generate_capability_matrix.ts +1 -1
  139. package/src/scripts/git_authorization_hook.ts +228 -18
  140. package/src/scripts/hook_manifest.json +1 -1
  141. package/src/scripts/hook_manifest.yaml +47 -30
  142. package/src/scripts/hooks/augment-dispatcher.sh +1 -1
  143. package/src/scripts/hooks/block_no_verify.ts +3 -3
  144. package/src/scripts/hooks/category_a.ts +291 -0
  145. package/src/scripts/hooks/cline-dispatcher.sh +1 -1
  146. package/src/scripts/hooks/concern_registry.ts +2 -2
  147. package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
  148. package/src/scripts/hooks/dispatch_hook.ts +17 -22
  149. package/src/scripts/hooks/evidence_independence.ts +21 -2
  150. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +175 -364
  151. package/src/scripts/hooks/host_lowering.ts +215 -0
  152. package/src/scripts/hooks/host_lowering.yaml +185 -0
  153. package/src/scripts/hooks/host_semantics.ts +105 -37
  154. package/src/scripts/hooks/journal_record_hook.ts +10 -0
  155. package/src/scripts/hooks/permission_gate.ts +117 -0
  156. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  157. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  158. package/src/scripts/hooks_status.ts +12 -10
  159. package/src/scripts/install-hooks.sh +134 -1
  160. package/src/scripts/install.ts +53 -82
  161. package/src/scripts/install_anthropic_key.sh +2 -2
  162. package/src/scripts/language_mirror_hook.ts +27 -5
  163. package/src/scripts/lint_design_antipattern_parity.ts +80 -0
  164. package/src/scripts/lint_evidence_artifacts.ts +45 -1
  165. package/src/scripts/lint_hook_manifest.ts +87 -3
  166. package/src/scripts/lint_motion_authority_drift.ts +731 -0
  167. package/src/scripts/lint_readme_size.ts +158 -12
  168. package/src/scripts/lint_reasoning_fixtures.ts +216 -0
  169. package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
  170. package/src/scripts/measure_sibling_naming.ts +150 -0
  171. package/src/scripts/redact_hook_capture.ts +1 -1
  172. package/src/scripts/release.ts +31 -143
  173. package/src/scripts/release_drill.ts +71 -1
  174. package/src/scripts/release_publication.ts +243 -3
  175. package/src/scripts/release_verify.ts +220 -0
  176. package/src/scripts/report_skill_activation.ts +319 -7
  177. package/src/scripts/schemas/review-findings.schema.json +6 -1
  178. package/src/scripts/schemas/skill.schema.json +12 -2
  179. package/src/scripts/self_review_gate.ts +10 -1
  180. package/src/scripts/session_canary_hook.ts +1 -1
  181. package/src/scripts/session_register_hook.ts +4 -3
  182. package/src/scripts/trigger_eval_grandfather.json +5 -1
  183. package/src/scripts/validate_agent_settings.ts +1 -1
  184. package/src/shared/settingsConsent.ts +1 -1
@@ -1,28 +1,62 @@
1
1
  #!/usr/bin/env tsx
2
2
  /**
3
- * CI guard for README.md line budget.
3
+ * CI guard for README.md size and time-to-first-command.
4
4
  *
5
5
  * Ported from the retired Python `src/scripts/lint_readme_size.py` (ADR-200, Phase 4 /
6
6
  * Wave 4b). The CLI contract is pinned — `--quiet` flag, exit
7
- * codes (0 within budget, 1 over budget / missing), stdout/stderr split,
8
- * byte-identical finding messages.
7
+ * codes (0 within budget, 1 over budget / missing), stdout/stderr split.
9
8
  *
10
9
  * The role-first-onboarding roadmap (Phase 2 Step 6) freezes README at
11
10
  * its current length: replace, do not grow. Every line added above the
12
- * fold must displace an existing line. Budget: 750 lines max.
11
+ * fold must displace an existing line.
12
+ *
13
+ * Three budgets, because a line budget alone missed a full regression of this
14
+ * file once already — see WORD_LIMIT below for the measurement and the reason
15
+ * the two newer numbers are not derived from the current file.
13
16
  */
14
17
 
15
18
  import * as fs from 'node:fs';
19
+ import * as os from 'node:os';
16
20
  import * as path from 'node:path';
17
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
22
 
19
- import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
23
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
24
+ import { assertWatchlistResolves, DeadScopeError, reportScanned } from './_lib/scan_scope.js';
20
25
 
21
26
  const _HERE = fileURLToPath(import.meta.url);
22
27
 
23
28
  const README = 'README.md';
24
29
  const LIMIT = 750;
25
30
 
31
+ /**
32
+ * Word and first-command budgets, derived from the README as it stood when
33
+ * `readme-and-docs-improvement.md` archived — NOT from the current file.
34
+ *
35
+ * That roadmap reached this same goal and archived complete on 2026-05-18. The
36
+ * file then drifted back, and the line budget above could not see it: lines
37
+ * went 747 -> 638, which is *better*, while words went 4849 -> 5550 and the
38
+ * first executable command moved from line 75 to line 160. A guard measuring
39
+ * only lines stayed green through every step of that, with 112 lines to spare.
40
+ *
41
+ * **This is the third recorded arrival of the same complaint, and nothing in
42
+ * the repository caught any of them.** Two independent external readers raised
43
+ * it again on 2026-09-06, three months after the fix archived as complete. A
44
+ * budget nobody watches is how a fix stops holding, which is why these two
45
+ * numbers are registered in `src/config/gate-coverage.yml` rather than living
46
+ * only here: removing them is then a gate failure rather than a quiet edit.
47
+ *
48
+ * Deriving these two numbers from the current file would make the gate green
49
+ * on day one and ratchet the regression in as the new floor. They come from
50
+ * the archive commit instead, and this command re-derives both:
51
+ *
52
+ * git show 032a244a3:README.md > /tmp/r.md && \
53
+ * echo "words=$(wc -w < /tmp/r.md)" \
54
+ * "first_fence=$(grep -n '^```' /tmp/r.md | head -1 | cut -d: -f1)"
55
+ * # words=4849 first_fence=75
56
+ */
57
+ const WORD_LIMIT = 4849;
58
+ const FIRST_COMMAND_LIMIT = 75;
59
+
26
60
  /**
27
61
  * Count splitlines() entries the way Python does — terminal `\n` does NOT
28
62
  * yield a trailing empty entry. Universal-newline aware (\r\n / \r / \n).
@@ -46,7 +80,10 @@ function _splitlinesCount(text: string): number {
46
80
  }
47
81
 
48
82
  function main(): number {
83
+ if (process.argv.slice(2).includes('--self-test')) return selfTest();
49
84
  const quiet = process.argv.slice(2).includes('--quiet');
85
+ const rootArgIdx = process.argv.indexOf('--root');
86
+ const root = rootArgIdx === -1 ? process.cwd() : (process.argv[rootArgIdx + 1] ?? process.cwd());
50
87
  // README is this gate's whole scope and resolves against cwd, so a wrong
51
88
  // cwd is indistinguishable from a deleted file. Replaces the bare
52
89
  // existsSync check: same exit code, but the failure now names the root it
@@ -56,7 +93,7 @@ function main(): number {
56
93
  assertWatchlistResolves({
57
94
  gate: 'lint_readme_size',
58
95
  candidates: [README],
59
- repoRoot: process.cwd(),
96
+ repoRoot: root,
60
97
  });
61
98
  } catch (exc) {
62
99
  if (exc instanceof DeadScopeError) {
@@ -67,20 +104,129 @@ function main(): number {
67
104
  }
68
105
  throw exc;
69
106
  }
70
- const text = fs.readFileSync(README, 'utf-8');
107
+ reportScanned({
108
+ gate: 'lint_readme_size',
109
+ scanned: 1,
110
+ units: 'file',
111
+ roots: [README],
112
+ });
113
+ const text = fs.readFileSync(path.join(root, README), 'utf-8');
71
114
  const n = _splitlinesCount(text);
72
- if (n > LIMIT) {
73
- process.stdout.write(
74
- `FAIL ${README}: ${n} lines (limit ${LIMIT}). Trim before merge.\n`,
115
+ const words = countWords(text);
116
+ const firstCommand = firstCommandLine(text);
117
+
118
+ // All three figures print on both paths. The regression this gate missed
119
+ // was visible in two numbers nobody was shown, so reporting only the one
120
+ // that failed would rebuild the blind spot one measurement further along.
121
+ const report =
122
+ `${README}: ${String(n)} lines (limit ${String(LIMIT)}) · ` +
123
+ `${String(words)} words (limit ${String(WORD_LIMIT)}) · ` +
124
+ `first command at line ${firstCommand === null ? 'none' : String(firstCommand)} ` +
125
+ `(limit ${String(FIRST_COMMAND_LIMIT)})`;
126
+
127
+ const failures: string[] = [];
128
+ if (n > LIMIT) failures.push(`${String(n)} lines exceeds ${String(LIMIT)}`);
129
+ if (words > WORD_LIMIT) {
130
+ failures.push(`${String(words)} words exceeds ${String(WORD_LIMIT)}`);
131
+ }
132
+ if (firstCommand === null) {
133
+ failures.push('no fenced command block at all');
134
+ } else if (firstCommand > FIRST_COMMAND_LIMIT) {
135
+ failures.push(
136
+ `first command at line ${String(firstCommand)} exceeds ${String(FIRST_COMMAND_LIMIT)}`,
75
137
  );
138
+ }
139
+
140
+ if (failures.length > 0) {
141
+ process.stdout.write(`FAIL ${report}\n`);
142
+ for (const f of failures) {
143
+ process.stdout.write(` - ${f}\n`);
144
+ }
145
+ process.stdout.write(' Trim before merge.\n');
76
146
  return 1;
77
147
  }
78
148
  if (!quiet) {
79
- process.stdout.write(`OK ${README}: ${n} lines (limit ${LIMIT}).\n`);
149
+ process.stdout.write(`OK ${report}\n`);
80
150
  }
81
151
  return 0;
82
152
  }
83
153
 
154
+ /** Whitespace-separated tokens, matching `wc -w` (which the budget derives from). */
155
+ function countWords(text: string): number {
156
+ const trimmed = text.trim();
157
+ return trimmed === '' ? 0 : trimmed.split(/\s+/).length;
158
+ }
159
+
160
+ /**
161
+ * 1-based line of the first fenced block, or `null` when there is none.
162
+ *
163
+ * The opening fence is what a reader's eye reaches, so the fence line itself
164
+ * is the measurement — not the first line of code inside it.
165
+ */
166
+ function firstCommandLine(text: string): number | null {
167
+ const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
168
+ for (let i = 0; i < lines.length; i += 1) {
169
+ if (lines[i]!.startsWith('```')) return i + 1;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * The gate proving it still DISCRIMINATES, not merely that it ran.
176
+ *
177
+ * One rejecting case per budget, because the failure this gate exists to end
178
+ * was a guard that measured one of the three and stayed green while the other
179
+ * two regressed. A suite that only exercises the line budget would reproduce
180
+ * that blind spot exactly.
181
+ */
182
+ function selfTest(): number {
183
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-selftest-'));
184
+ const write = (name: string, body: string): string => {
185
+ const dir = path.join(tmp, name);
186
+ fs.mkdirSync(dir, { recursive: true });
187
+ fs.writeFileSync(path.join(dir, README), body, 'utf-8');
188
+ return dir;
189
+ };
190
+ const run = (root: string): number =>
191
+ runGateCli(process.cwd(), 'src/scripts/lint_readme_size.ts', ['--root', root], root);
192
+
193
+ const fence = '```bash\nnpx thing\n```\n';
194
+ const filler = (n: number): string => `${'word '.repeat(n).trim()}\n`;
195
+
196
+ try {
197
+ return runSelfTest({
198
+ gate: 'lint_readme_size',
199
+ minCases: 4,
200
+ minRejectCases: 3,
201
+ cases: [
202
+ {
203
+ name: 'a README whose first command sits below the budget is rejected',
204
+ expect: 'reject',
205
+ run: () =>
206
+ run(write('late-command', `${'filler\n'.repeat(FIRST_COMMAND_LIMIT + 5)}${fence}`)),
207
+ },
208
+ {
209
+ name: 'a README over the word budget is rejected',
210
+ expect: 'reject',
211
+ run: () => run(write('wordy', `${fence}${filler(WORD_LIMIT + 50)}`)),
212
+ },
213
+ {
214
+ name: 'a README over the line budget is rejected',
215
+ expect: 'reject',
216
+ run: () => run(write('long', `${fence}${'x\n'.repeat(LIMIT + 5)}`)),
217
+ },
218
+ {
219
+ name: 'a short README with an early command passes',
220
+ expect: 'accept',
221
+ run: () => run(write('good', `# Title\n\n${fence}`)),
222
+ },
223
+ ],
224
+ });
225
+ } finally {
226
+ fs.rmSync(tmp, { recursive: true, force: true });
227
+ }
228
+ }
229
+
84
230
  function _isCliEntry(): boolean {
85
231
  if (process.argv[1] === undefined) {
86
232
  return false;
@@ -107,4 +253,4 @@ if (_isCliEntry() || process.argv[1] === _HERE) {
107
253
  process.exit(main());
108
254
  }
109
255
 
110
- export { README, LIMIT, main };
256
+ export { README, LIMIT, WORD_LIMIT, FIRST_COMMAND_LIMIT, countWords, firstCommandLine, main };
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Structural validator for the RDP trigger-fixture corpus.
4
+ *
5
+ * `tests/reasoning-layer-eval/` shipped 21 fixtures and, after the Python
6
+ * retirement, zero validators: `find … -name '*.ts' -o -name '*.py'` returned
7
+ * nothing while the corpus's own README and `trigger-fixtures.json`'s
8
+ * `description` still told a reader to run two deleted files. A corpus nothing
9
+ * can check is a corpus nothing does check.
10
+ *
11
+ * Scope is deliberately the SHAPE and nothing else. Live scoring against a model
12
+ * is `skill_trigger_eval.ts`, which is billable and already exists; nothing about
13
+ * validating the corpus needs a model call. What the deleted `validate_fixtures.py`
14
+ * ALSO did and this does not is check the cost-gating invariants — that half was
15
+ * not ported and is not claimed here.
16
+ *
17
+ * Exit codes: 0 corpus well-formed · 1 a row is malformed · 2 the corpus is
18
+ * missing or unparseable.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+
25
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
26
+ import { reportScanned } from './_lib/scan_scope.js';
27
+
28
+ const _HERE = fileURLToPath(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
30
+ const CORPUS_REL = 'tests/reasoning-layer-eval/trigger-fixtures.json';
31
+
32
+ /**
33
+ * The five keys a row carries, and no others.
34
+ *
35
+ * Exact-set rather than a minimum: an unknown key is how a schema drifts in a
36
+ * corpus with no reader, and the two additive fields this suite already has
37
+ * over the per-skill `evals/triggers.json` convention (`discipline`, `host`)
38
+ * are exactly what a permissive check would have let multiply.
39
+ */
40
+ const REQUIRED_KEYS = ['q', 'trigger', 'discipline', 'host', 'note'] as const;
41
+ const HOSTS = ['standard', 'strong'] as const;
42
+
43
+ export interface Finding {
44
+ where: string;
45
+ reason: string;
46
+ }
47
+
48
+ export function validateCorpus(text: string): { findings: Finding[]; rows: number } {
49
+ const findings: Finding[] = [];
50
+ let doc: unknown;
51
+ try {
52
+ doc = JSON.parse(text);
53
+ } catch (exc) {
54
+ return {
55
+ findings: [{ where: CORPUS_REL, reason: `not valid JSON: ${String(exc)}` }],
56
+ rows: 0,
57
+ };
58
+ }
59
+ const top = doc as Record<string, unknown>;
60
+ for (const k of ['suite', 'description', 'queries']) {
61
+ if (top[k] === undefined) {
62
+ findings.push({ where: CORPUS_REL, reason: `top level is missing '${k}'` });
63
+ }
64
+ }
65
+ const queries = Array.isArray(top['queries']) ? (top['queries'] as unknown[]) : [];
66
+ if (queries.length === 0) {
67
+ findings.push({ where: CORPUS_REL, reason: "'queries' is empty or not an array" });
68
+ return { findings, rows: 0 };
69
+ }
70
+
71
+ queries.forEach((raw, i) => {
72
+ const where = `${CORPUS_REL}[${String(i)}]`;
73
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
74
+ findings.push({ where, reason: 'row is not an object' });
75
+ return;
76
+ }
77
+ const row = raw as Record<string, unknown>;
78
+ const keys = Object.keys(row).sort();
79
+ const expected: string[] = [...REQUIRED_KEYS].sort();
80
+ for (const k of expected) {
81
+ if (!keys.includes(k)) findings.push({ where, reason: `missing key '${k}'` });
82
+ }
83
+ for (const k of keys) {
84
+ if (!expected.includes(k)) findings.push({ where, reason: `unknown key '${k}'` });
85
+ }
86
+ if (row['q'] !== undefined && (typeof row['q'] !== 'string' || row['q'].trim() === '')) {
87
+ findings.push({ where, reason: "'q' must be a non-empty string" });
88
+ }
89
+ if (row['note'] !== undefined && (typeof row['note'] !== 'string' || row['note'].trim() === '')) {
90
+ findings.push({ where, reason: "'note' must be a non-empty string" });
91
+ }
92
+ if (row['discipline'] !== undefined && (typeof row['discipline'] !== 'string' || row['discipline'].trim() === '')) {
93
+ findings.push({ where, reason: "'discipline' must be a non-empty string" });
94
+ }
95
+ if (row['trigger'] !== undefined && typeof row['trigger'] !== 'boolean') {
96
+ findings.push({ where, reason: "'trigger' must be a boolean, not a string" });
97
+ }
98
+ if (row['host'] !== undefined && !(HOSTS as readonly unknown[]).includes(row['host'])) {
99
+ findings.push({
100
+ where,
101
+ reason: `'host' must be one of ${HOSTS.join('|')}, got ${JSON.stringify(row['host'])}`,
102
+ });
103
+ }
104
+ });
105
+ return { findings, rows: queries.length };
106
+ }
107
+
108
+ function main(): number {
109
+ if (process.argv.slice(2).includes('--self-test')) return selfTest();
110
+ const rootIdx = process.argv.indexOf('--root');
111
+ const root = rootIdx === -1 ? ROOT : (process.argv[rootIdx + 1] ?? ROOT);
112
+ const file = path.join(root, CORPUS_REL);
113
+ if (!fs.existsSync(file)) {
114
+ process.stderr.write(`❌ corpus not found: ${CORPUS_REL} (under ${root})\n`);
115
+ return 2;
116
+ }
117
+ const { findings, rows } = validateCorpus(fs.readFileSync(file, 'utf-8'));
118
+ // Emitted on the red path too: a gate that reports what it inspected only
119
+ // when it passes leaves the coverage census blind exactly when it matters.
120
+ reportScanned({
121
+ gate: 'lint_reasoning_fixtures',
122
+ scanned: rows,
123
+ units: 'fixture row(s)',
124
+ roots: [CORPUS_REL],
125
+ });
126
+ if (findings.length > 0) {
127
+ for (const f of findings) {
128
+ process.stdout.write(`❌ ${f.where}: ${f.reason}\n`);
129
+ }
130
+ process.stdout.write(`\n${String(findings.length)} malformed fixture finding(s).\n`);
131
+ return 1;
132
+ }
133
+ process.stdout.write(
134
+ `✅ lint_reasoning_fixtures: ${String(rows)} fixture row(s) well-formed ` +
135
+ `(${REQUIRED_KEYS.join(', ')}; host ∈ ${HOSTS.join('|')}).\n`,
136
+ );
137
+ return 0;
138
+ }
139
+
140
+ /** The gate proving it still DISCRIMINATES — one rejecting case per rule the roadmap names. */
141
+ function selfTest(): number {
142
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rdp-fixtures-selftest-'));
143
+ const good = {
144
+ q: 'a question',
145
+ trigger: true,
146
+ discipline: 'grounding',
147
+ host: 'standard',
148
+ note: 'why',
149
+ };
150
+ const write = (name: string, queries: unknown[]): string => {
151
+ const dir = path.join(tmp, name, 'tests', 'reasoning-layer-eval');
152
+ fs.mkdirSync(dir, { recursive: true });
153
+ fs.writeFileSync(
154
+ path.join(dir, 'trigger-fixtures.json'),
155
+ JSON.stringify({ suite: 's', description: 'd', queries }),
156
+ 'utf-8',
157
+ );
158
+ return path.join(tmp, name);
159
+ };
160
+ const run = (root: string): number =>
161
+ runGateCli(ROOT, 'src/scripts/lint_reasoning_fixtures.ts', ['--root', root], root);
162
+
163
+ try {
164
+ return runSelfTest({
165
+ gate: 'lint_reasoning_fixtures',
166
+ minCases: 4,
167
+ minRejectCases: 3,
168
+ cases: [
169
+ {
170
+ name: 'a row with a sixth key is rejected',
171
+ expect: 'reject',
172
+ run: () => run(write('sixth-key', [{ ...good, extra: 1 }])),
173
+ },
174
+ {
175
+ name: 'a row missing discipline is rejected',
176
+ expect: 'reject',
177
+ run: () => {
178
+ const { discipline: _drop, ...rest } = good;
179
+ return run(write('no-discipline', [rest]));
180
+ },
181
+ },
182
+ {
183
+ name: 'host: "medium" is rejected',
184
+ expect: 'reject',
185
+ run: () => run(write('bad-host', [{ ...good, host: 'medium' }])),
186
+ },
187
+ {
188
+ name: 'a well-formed row passes',
189
+ expect: 'accept',
190
+ run: () => run(write('good', [good])),
191
+ },
192
+ ],
193
+ });
194
+ } finally {
195
+ fs.rmSync(tmp, { recursive: true, force: true });
196
+ }
197
+ }
198
+
199
+ function _isCliEntry(): boolean {
200
+ if (process.argv[1] === undefined) return false;
201
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
202
+ try {
203
+ return (
204
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
205
+ fs.realpathSync(path.resolve(process.argv[1]))
206
+ );
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ if (_isCliEntry() || process.argv[1] === _HERE) {
213
+ process.exit(main());
214
+ }
215
+
216
+ export { CORPUS_REL, REQUIRED_KEYS, HOSTS, main };
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * A skill may not say "explicit request only" and publish auto-trigger keywords.
4
+ *
5
+ * The description is consumed — it is the frontmatter a catalogue and a host
6
+ * surface read. The keyword section is not: measured across the tree, nothing
7
+ * in `src/` parses `## Auto-trigger keywords`, so a skill carrying both halves
8
+ * is not mis-ROUTED, it is mis-DESCRIBED. A reader deciding whether they may
9
+ * invoke the skill meets two opposite answers in one file, and the half that
10
+ * looks operational is the half nothing reads.
11
+ *
12
+ * Three skills shipped in that state and a human had to notice it. The pair is
13
+ * mechanically decidable from the file, so noticing it again should not be
14
+ * anybody's job.
15
+ *
16
+ * Deliberately not a judgement about WHICH half is right. Both are legitimate
17
+ * designs; only the contradiction is a defect, and which way to resolve it is
18
+ * the author's call.
19
+ *
20
+ * Exit codes: 0 no contradiction · 1 at least one file carries both.
21
+ */
22
+ import * as fs from 'node:fs';
23
+ import * as os from 'node:os';
24
+ import * as path from 'node:path';
25
+ import { fileURLToPath, pathToFileURL } from 'node:url';
26
+
27
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
28
+ import { reportScanned } from './_lib/scan_scope.js';
29
+
30
+ const _HERE = fileURLToPath(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
32
+ const SKILLS_REL = 'src/skills';
33
+
34
+ const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/;
35
+ /** Matched in the description only — the phrase is meaningful there, prose elsewhere. */
36
+ const EXPLICIT_ONLY_RE = /explicit[- ]request[- ]only|explicit request only/i;
37
+ const AUTO_TRIGGER_RE = /^##\s+Auto-trigger keywords\s*$/m;
38
+
39
+ export function contradicts(text: string): boolean {
40
+ const fm = FRONTMATTER_RE.exec(text);
41
+ if (fm === null) return false;
42
+ // `description:` may be a folded or quoted scalar spanning lines, so the
43
+ // slice runs to the next top-level key rather than to the next newline.
44
+ const desc = /^description:([\s\S]*?)(?=\n[a-z_]+:|$)/im.exec(fm[1] as string);
45
+ if (desc === null) return false;
46
+ if (!EXPLICIT_ONLY_RE.test(desc[1] as string)) return false;
47
+ return AUTO_TRIGGER_RE.test(text);
48
+ }
49
+
50
+ function main(): number {
51
+ if (process.argv.slice(2).includes('--self-test')) return selfTest();
52
+ const rootIdx = process.argv.indexOf('--root');
53
+ const root = rootIdx === -1 ? ROOT : (process.argv[rootIdx + 1] ?? ROOT);
54
+ const dir = path.join(root, SKILLS_REL);
55
+ let names: string[] = [];
56
+ try {
57
+ names = fs.readdirSync(dir).sort();
58
+ } catch {
59
+ process.stderr.write(`❌ no skills directory under ${root}/${SKILLS_REL}\n`);
60
+ return 1;
61
+ }
62
+ const offenders: string[] = [];
63
+ let scanned = 0;
64
+ for (const n of names) {
65
+ const f = path.join(dir, n, 'SKILL.md');
66
+ if (!fs.existsSync(f)) continue;
67
+ scanned += 1;
68
+ if (contradicts(fs.readFileSync(f, 'utf-8'))) {
69
+ offenders.push(`${SKILLS_REL}/${n}/SKILL.md`);
70
+ }
71
+ }
72
+ reportScanned({
73
+ gate: 'lint_skill_trigger_contradiction',
74
+ scanned,
75
+ units: 'SKILL.md file(s)',
76
+ roots: [SKILLS_REL],
77
+ });
78
+ if (offenders.length > 0) {
79
+ for (const o of offenders) {
80
+ process.stdout.write(
81
+ `❌ ${o}: description says explicit-request-only AND the body publishes ` +
82
+ '`## Auto-trigger keywords` — the two halves say opposite things ' +
83
+ 'and nothing in src/ parses the keyword half, so the operational-looking ' +
84
+ 'one is the inert one.\n',
85
+ );
86
+ }
87
+ process.stdout.write(
88
+ `\n${String(offenders.length)} contradicting skill(s). Resolve by removing whichever ` +
89
+ 'half is not true; the check does not decide which.\n',
90
+ );
91
+ return 1;
92
+ }
93
+ process.stdout.write(
94
+ `✅ lint_skill_trigger_contradiction: ${String(scanned)} skill(s) inspected, ` +
95
+ 'none claims explicit-request-only while publishing auto-trigger keywords.\n',
96
+ );
97
+ return 0;
98
+ }
99
+
100
+ function selfTest(): number {
101
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trigger-contradiction-selftest-'));
102
+ const write = (name: string, body: string): string => {
103
+ const dir = path.join(tmp, name, SKILLS_REL, 'sample');
104
+ fs.mkdirSync(dir, { recursive: true });
105
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), body, 'utf-8');
106
+ return path.join(tmp, name);
107
+ };
108
+ const run = (root: string): number =>
109
+ runGateCli(ROOT, 'src/scripts/lint_skill_trigger_contradiction.ts', ['--root', root], root);
110
+
111
+ const both =
112
+ '---\nname: sample\ndescription: Does a thing; explicit request only.\n---\n\n## Auto-trigger keywords\n\nfoo\n';
113
+ const descOnly = '---\nname: sample\ndescription: Does a thing; explicit request only.\n---\n\nbody\n';
114
+ const autoOnly = '---\nname: sample\ndescription: Does a thing.\n---\n\n## Auto-trigger keywords\n\nfoo\n';
115
+
116
+ try {
117
+ return runSelfTest({
118
+ gate: 'lint_skill_trigger_contradiction',
119
+ minCases: 3,
120
+ minRejectCases: 1,
121
+ cases: [
122
+ {
123
+ name: 'a skill carrying BOTH halves is rejected',
124
+ expect: 'reject',
125
+ run: () => run(write('both', both)),
126
+ },
127
+ {
128
+ name: 'explicit-request-only with no keyword section passes',
129
+ expect: 'accept',
130
+ run: () => run(write('desc-only', descOnly)),
131
+ },
132
+ {
133
+ name: 'auto-trigger keywords with no explicit-only claim passes',
134
+ expect: 'accept',
135
+ run: () => run(write('auto-only', autoOnly)),
136
+ },
137
+ ],
138
+ });
139
+ } finally {
140
+ fs.rmSync(tmp, { recursive: true, force: true });
141
+ }
142
+ }
143
+
144
+ function _isCliEntry(): boolean {
145
+ if (process.argv[1] === undefined) return false;
146
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
147
+ try {
148
+ return (
149
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
150
+ fs.realpathSync(path.resolve(process.argv[1]))
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ if (_isCliEntry() || process.argv[1] === _HERE) {
158
+ process.exit(main());
159
+ }
160
+
161
+ export { SKILLS_REL, main };