@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
@@ -2,10 +2,11 @@
2
2
  * WASM tree-sitter loader for the code-graph engine.
3
3
  *
4
4
  * Pinned pair (ABI-verified 2026-07-23): `web-tree-sitter@0.24.7` +
5
- * `tree-sitter-wasms@0.1.13` → grammar ABI 14. The ABI smoke test
6
- * (`tests/scripts/code_graph_abi.test.ts`) asserts this pairing loads and
7
- * parses before merge; a bump to either dependency re-runs it. No network,
8
- * no node-gyp — grammars load from `node_modules` via the resolved path.
5
+ * `tree-sitter-wasms@0.1.13` → grammar ABI 14. The ABI smoke test lives in
6
+ * `tests/scripts/code_graph.test.ts`; it runs in this repo because the pair is
7
+ * a devDependency. Consumers install neither (see `INSTALL_HINT` below). No
8
+ * network, no node-gyp — grammars load from `node_modules` via the resolved
9
+ * path.
9
10
  *
10
11
  * web-tree-sitter 0.24.x is CJS with a single default export (the Parser
11
12
  * class) and the classic `Parser.Language.load()` API; we reach it through
@@ -51,10 +52,32 @@ interface ParserStatic {
51
52
  Language: { load(wasmPath: string): Promise<TsLanguage> };
52
53
  }
53
54
 
55
+ /**
56
+ * The parser pair is a devDependency, not a runtime one: this repo's tests still
57
+ * exercise the loader, while a consumer install resolves neither. The engine is
58
+ * permanently `enabled: false` (measured recall 0.365 vs grep 0.797), so
59
+ * shipping ~51 MB of WASM to every consumer for a path none of them can reach
60
+ * was pure install cost. A consumer re-enabling the engine installs the
61
+ * ABI-locked pair themselves — this message carries the pin, because
62
+ * `check_dependency_floors` only scans the `dependencies` block.
63
+ */
64
+ const INSTALL_HINT =
65
+ 'the code-graph engine needs its parser pair, which this package no longer ships to consumers ' +
66
+ '(the engine is disabled by a recorded null result). Install the ABI-locked pair:\n' +
67
+ ' npm i web-tree-sitter@0.24.7 tree-sitter-wasms@0.1.13\n' +
68
+ 'Both versions are exact on purpose — the pair is ABI-coupled (grammar ABI 14) ' +
69
+ 'and must move together.';
70
+
54
71
  let _wasmDir: string | null = null;
55
72
  function wasmDir(): string {
56
73
  if (_wasmDir === null) {
57
- _wasmDir = path.join(path.dirname(require.resolve('tree-sitter-wasms/package.json')), 'out');
74
+ let pkgJson: string;
75
+ try {
76
+ pkgJson = require.resolve('tree-sitter-wasms/package.json');
77
+ } catch {
78
+ throw new Error(`tree-sitter-wasms is not installed — ${INSTALL_HINT}`);
79
+ }
80
+ _wasmDir = path.join(path.dirname(pkgJson), 'out');
58
81
  }
59
82
  return _wasmDir;
60
83
  }
@@ -72,7 +95,13 @@ function _Parser(): ParserStatic {
72
95
  // Require exactly once and cache the class reference — repeated
73
96
  // createRequire calls under tsx can hand back a module view whose static
74
97
  // `Language` is not yet populated, which breaks the second grammar load.
75
- if (_mod === null) _mod = require('web-tree-sitter') as ParserStatic;
98
+ if (_mod === null) {
99
+ try {
100
+ _mod = require('web-tree-sitter') as ParserStatic;
101
+ } catch {
102
+ throw new Error(`web-tree-sitter is not installed — ${INSTALL_HINT}`);
103
+ }
104
+ }
76
105
  return _mod;
77
106
  }
78
107
 
@@ -73,14 +73,9 @@ const LEGACY_TIER_MAP: Record<string, string> = {
73
73
  };
74
74
 
75
75
  const ALLOWED_TIERS = new Set(['kernel', 'tier-1', 'tier-2']);
76
- const ALLOWED_TRIGGER_KEYS = new Set([
77
- 'keyword',
78
- 'phrase',
79
- 'intent',
80
- 'file_pattern',
81
- 'path_prefix',
82
- 'command',
83
- ]);
76
+ // `intent` was removed: it never auto-matched at runtime, so declaring one gave
77
+ // the author a false activation path. Compiling one is now a hard error.
78
+ const ALLOWED_TRIGGER_KEYS = new Set(['keyword', 'phrase', 'file_pattern', 'path_prefix', 'command']);
84
79
 
85
80
  function _parse_frontmatter(text: string): JsonObject {
86
81
  if (!text.startsWith('---\n')) {
@@ -1176,7 +1176,7 @@ function _yaml_scalar(value: string): string {
1176
1176
  * Derive host-native activation globs from a rule's `triggers:` frontmatter
1177
1177
  * (road-to-request-scoped-rule-load Phase 2). `file_pattern` triggers map
1178
1178
  * verbatim; `path_prefix` triggers map to `<prefix>**`. Keyword / phrase /
1179
- * intent / command triggers produce no glob — those rules stay
1179
+ * command triggers produce no glob — those rules stay
1180
1180
  * description-activated (Cursor Agent-Requested / Windsurf model_decision).
1181
1181
  */
1182
1182
  export function derive_trigger_globs(meta: Record<string, unknown>): string[] {
@@ -1224,7 +1224,7 @@ export function _emit_windsurf_rule(source: string, target: string): void {
1224
1224
  const description = _strip(String(meta['description'] ?? '').replace(/\n/g, ' '));
1225
1225
  const always_apply = Boolean(meta['alwaysApply'] || meta['type'] === 'always');
1226
1226
  // Path-shaped triggers activate host-natively via Windsurf's `glob`
1227
- // trigger; keyword/intent-only rules keep `model_decision`.
1227
+ // trigger; keyword/phrase-only rules keep `model_decision`.
1228
1228
  const globs = always_apply ? [] : derive_trigger_globs(meta);
1229
1229
  const trigger = always_apply ? 'always_on' : globs.length > 0 ? 'glob' : 'model_decision';
1230
1230
  const lines = [
@@ -130,7 +130,6 @@ function parseSince(since: string | null): number | null {
130
130
  export interface TriggerSpec {
131
131
  keyword?: string;
132
132
  phrase?: string;
133
- intent?: string;
134
133
  file_pattern?: string;
135
134
  path_prefix?: string;
136
135
  command?: string;
@@ -17,7 +17,7 @@
17
17
  * regenerated on every `task sync` and must never be hand-edited.
18
18
  *
19
19
  * For each pack home we emit:
20
- * - `<home>/pack.yaml` — id, label, owner, requires, version, artefact_count, …
20
+ * - `<home>/pack.yaml` — id, label, owner, requires, artefact_count, …
21
21
  * - `<home>/README.md` — pack description + per-artefact description table
22
22
  *
23
23
  * CLI:
@@ -40,7 +40,6 @@ const PACKAGES = path.join(ROOT, 'packages');
40
40
  const SRC_DOMAINS = path.join(ROOT, 'src', 'domains');
41
41
  const SRC_PACKS = path.join(ROOT, 'src', 'packs');
42
42
  const PACKS_VOCAB = path.join(ROOT, 'src', 'config', 'discovery', 'packs.yml');
43
- const PACKAGE_JSON = path.join(ROOT, 'package.json');
44
43
  const GENERATED_HEADER =
45
44
  '# Generated by scripts/generate_pack_manifests.ts — DO NOT EDIT BY HAND\n';
46
45
 
@@ -138,14 +137,6 @@ function _vocab_lookup(packsVocab: Dict[]): Record<string, Dict> {
138
137
  return out;
139
138
  }
140
139
 
141
- function _package_version(): string {
142
- if (!_exists(PACKAGE_JSON)) {
143
- return '0.0.0';
144
- }
145
- const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf-8')) as Dict;
146
- return _pyTruthy(pkg.version) ? String(pkg.version) : '0.0.0';
147
- }
148
-
149
140
  function _artefact_record(p: string, logicalRel: string, fm: Dict): Artefact {
150
141
  const name = _pyTruthy(fm.name)
151
142
  ? String(fm.name)
@@ -256,12 +247,7 @@ function _category_from_logical(logicalRel: string): string {
256
247
  * Build the pack.yaml meta dict. Mirrors `_build_pack_yaml`. Insertion
257
248
  * order here is irrelevant — `_py_safe_dump` sorts keys (sort_keys=True).
258
249
  */
259
- function _build_pack_yaml(
260
- pid: string,
261
- vocab: Record<string, Dict>,
262
- artefacts: Artefact[],
263
- version: string,
264
- ): Dict {
250
+ function _build_pack_yaml(pid: string, vocab: Record<string, Dict>, artefacts: Artefact[]): Dict {
265
251
  const meta = vocab[pid] ?? {};
266
252
  const label = _pyTruthy(meta.label)
267
253
  ? String(meta.label)
@@ -289,7 +275,6 @@ function _build_pack_yaml(
289
275
  trust_level_default: _pyTruthy(meta.trust_level_default)
290
276
  ? (meta.trust_level_default as unknown)
291
277
  : 'core',
292
- version,
293
278
  artefact_count: artefacts.length,
294
279
  };
295
280
  if (_isPlainObject(meta.onboarding)) {
@@ -330,7 +315,6 @@ function _render_readme(packMeta: Dict, artefacts: Artefact[]): string {
330
315
  const requires = (packMeta.requires as unknown[]) ?? [];
331
316
  lines.push(
332
317
  `- **id**: \`${String(packMeta.id)}\``,
333
- `- **version**: \`${String(packMeta.version)}\``,
334
318
  `- **owner**: ${owner.map(String).join(', ') || '—'}`,
335
319
  `- **requires**: ${requires.map(String).join(', ') || '—'}`,
336
320
  `- **artefacts**: ${String(packMeta.artefact_count)}`,
@@ -369,7 +353,6 @@ function _write_if_changed(p: string, content: string, check: boolean): boolean
369
353
  export function main(argv: string[] = process.argv.slice(2)): number {
370
354
  const check = argv.includes('--check');
371
355
  const vocab = _vocab_lookup((_load_yaml(PACKS_VOCAB) as Dict[]) ?? []);
372
- const version = _package_version();
373
356
  const homes = _pack_homes();
374
357
  if (homes.length === 0) {
375
358
  process.stderr.write(
@@ -380,7 +363,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
380
363
  let drift = 0;
381
364
  for (const [pid, homeDir, mode] of homes) {
382
365
  const artefacts = _collect_artefacts(pid, homeDir, mode);
383
- const meta = _build_pack_yaml(pid, vocab, artefacts, version);
366
+ const meta = _build_pack_yaml(pid, vocab, artefacts);
384
367
  const yamlBody = GENERATED_HEADER + _py_safe_dump(meta);
385
368
  const readmeBody = _render_readme(meta, artefacts);
386
369
  const label = _relativeToPosix(homeDir, ROOT);
@@ -40,6 +40,7 @@ import * as path from 'node:path';
40
40
 
41
41
  import { is_kernel_rule } from '../_lib/kernel_rules.js';
42
42
  import { EDIT_TOOLS } from '../minimal_safe_diff_hook.js';
43
+ import { COMMAND_TOOLS } from '../verify_before_complete_hook.js';
43
44
  import { readHookStdin } from './hook_stdin.js';
44
45
 
45
46
  const _HERE = fileURLToPath(import.meta.url);
@@ -122,25 +123,159 @@ export function targets_kernel_rule(filePath: string): string | null {
122
123
  return basename.replace(/\.md$/, '');
123
124
  }
124
125
 
125
- /** Return (blocked, reason) for one PreToolUse envelope. Pure — no I/O beyond the arg. */
126
+ /** In-place mutators that name their target as a plain argument. */
127
+ const _MUTATOR_VERBS: ReadonlySet<string> = new Set([
128
+ 'sed',
129
+ 'tee',
130
+ 'truncate',
131
+ 'rm',
132
+ 'shred',
133
+ ]);
134
+ /** Verbs whose LAST positional argument is the destination. */
135
+ const _DEST_LAST_VERBS: ReadonlySet<string> = new Set(['mv', 'cp', 'install', 'rsync']);
136
+
137
+ /**
138
+ * Shell tokens that, in a command naming a kernel rule path, mean the path is
139
+ * being WRITTEN rather than read. Reads (`cat`, `grep`, `head`, `diff`,
140
+ * `git show`) carry none of these and stay allowed — a kernel rule is
141
+ * immutable, not secret.
142
+ */
143
+ function _bash_targets_kernel_rule(command: string): string | null {
144
+ // Cheap reject first: no kernel-rule path in the string at all.
145
+ const tokens = command.split(/\s+/).filter((t) => t.length > 0);
146
+ const stripped = tokens.map((t) => t.replace(/^['"]|['"]$/g, ''));
147
+ const ruleOf = (t: string): string | null => targets_kernel_rule(t);
148
+ if (!stripped.some((t) => ruleOf(t) !== null)) {
149
+ return null;
150
+ }
151
+
152
+ // 1. Redirection into the path: `> p`, `>> p`, `>p`, `>>p`.
153
+ for (let i = 0; i < stripped.length; i += 1) {
154
+ const tok = stripped[i] as string;
155
+ const inline = /^>{1,2}(.+)$/.exec(tok);
156
+ if (inline) {
157
+ const r = ruleOf(inline[1] as string);
158
+ if (r !== null) {
159
+ return r;
160
+ }
161
+ }
162
+ if (tok === '>' || tok === '>>') {
163
+ const next = stripped[i + 1];
164
+ if (typeof next === 'string') {
165
+ const r = ruleOf(next);
166
+ if (r !== null) {
167
+ return r;
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ // 2. A mutator verb anywhere in the pipeline, with the path as an argument.
174
+ // `sed` only counts in its in-place form — `sed 's/x/y/' file` prints.
175
+ const verbs = new Set<string>();
176
+ let prevWasSeparator = true;
177
+ for (const tok of stripped) {
178
+ if (['&&', '||', ';', '|'].includes(tok)) {
179
+ prevWasSeparator = true;
180
+ continue;
181
+ }
182
+ if (prevWasSeparator && !tok.startsWith('-')) {
183
+ verbs.add(path.basename(tok));
184
+ prevWasSeparator = false;
185
+ }
186
+ }
187
+ const sedInPlace = verbs.has('sed') && stripped.some((t) => /^-i/.test(t));
188
+ for (const verb of verbs) {
189
+ if (verb === 'sed' && !sedInPlace) {
190
+ continue;
191
+ }
192
+ if (_MUTATOR_VERBS.has(verb)) {
193
+ const hit = stripped.map(ruleOf).find((r) => r !== null);
194
+ if (hit !== undefined && hit !== null) {
195
+ return hit;
196
+ }
197
+ }
198
+ if (_DEST_LAST_VERBS.has(verb)) {
199
+ const last = stripped[stripped.length - 1] as string;
200
+ const r = ruleOf(last);
201
+ if (r !== null) {
202
+ return r;
203
+ }
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Return (blocked, reason) for one PreToolUse envelope. Pure — no I/O beyond
211
+ * the arg.
212
+ *
213
+ * Two surfaces, one effect. The Write/Edit branch is the original Layer-1
214
+ * guard. The Bash branch was added by road-to-governance-invariants Phase 1
215
+ * after the S0.2 spike measured the gap: this gate keyed on the TOOL NAME, so
216
+ * `Bash sed -i … src/rules/commit-policy.md` reached the immutable-rule
217
+ * outcome the Write branch refuses, and a two-step
218
+ * `Write docs/staging/<kernel>.md` → `Bash mv … src/rules/<kernel>.md`
219
+ * sequence did the same with every step individually allowed.
220
+ *
221
+ * The Bash branch is deliberately narrow (2026-08-02 council cut, option ii):
222
+ * only redirection into the path, an in-place `sed`, a `tee`/`truncate`/`rm`
223
+ * naming it, or a `mv`/`cp` whose DESTINATION it is. Reads stay allowed — a
224
+ * kernel rule is immutable, not secret — and no attempt is made to understand
225
+ * arbitrary shell. Recognising every conceivable write verb would make this a
226
+ * shell sandbox, which is the failure mode the council named.
227
+ */
126
228
  export function check_envelope(envelope: JsonObject): [boolean, string] {
127
229
  const { tool, paths } = _extract(envelope);
128
- if (!tool || !EDIT_TOOLS.has(tool)) {
230
+ if (!tool) {
129
231
  return [false, ''];
130
232
  }
131
- for (const p of paths) {
132
- const ruleName = targets_kernel_rule(p);
133
- if (ruleName !== null) {
134
- return [
135
- true,
136
- `kernel rule ${ruleName} is immutable — tighten-only via the override ` +
137
- 'exception registry; see docs/contracts/kernel-membership.md',
138
- ];
233
+ const deny = (ruleName: string): [boolean, string] => [
234
+ true,
235
+ `kernel rule ${ruleName} is immutable — tighten-only via the override ` +
236
+ 'exception registry; see docs/contracts/kernel-membership.md',
237
+ ];
238
+ if (EDIT_TOOLS.has(tool)) {
239
+ for (const p of paths) {
240
+ const ruleName = targets_kernel_rule(p);
241
+ if (ruleName !== null) {
242
+ return deny(ruleName);
243
+ }
244
+ }
245
+ return [false, ''];
246
+ }
247
+ if (COMMAND_TOOLS.has(tool)) {
248
+ const cmd = _extract_command(envelope);
249
+ if (cmd !== null) {
250
+ const ruleName = _bash_targets_kernel_rule(cmd);
251
+ if (ruleName !== null) {
252
+ return deny(ruleName);
253
+ }
139
254
  }
140
255
  }
141
256
  return [false, ''];
142
257
  }
143
258
 
259
+ /** Best-effort read of a shell command off a PreToolUse envelope. */
260
+ function _extract_command(envelope: JsonObject): string | null {
261
+ const payload = _isObject(envelope['payload']) ? envelope['payload'] : envelope;
262
+ const ti = _isObject(payload['tool_input'])
263
+ ? payload['tool_input']
264
+ : _isObject(envelope['tool_input'])
265
+ ? envelope['tool_input']
266
+ : null;
267
+ for (const src of [ti, payload]) {
268
+ if (src === null) {
269
+ continue;
270
+ }
271
+ const v = src['command'];
272
+ if (typeof v === 'string' && v) {
273
+ return v;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
144
279
  function _asObject(v: JsonValue | undefined): JsonObject | null {
145
280
  return _isObject(v) ? v : null;
146
281
  }
@@ -33,6 +33,34 @@ const _SHELL_SEPARATORS: ReadonlySet<string> = new Set(['&&', '||', ';', '|']);
33
33
  const _NO_VERIFY_FLAGS: ReadonlySet<string> = new Set(['--no-verify']);
34
34
  const _NO_VERIFY_SHORT: ReadonlySet<string> = new Set(['-n']);
35
35
  const _HOOKS_PATH_RE = /^core\.hooksPath\s*=/i;
36
+ /** Bare key form, as `git config core.hooksPath <value>` spells it. */
37
+ const _HOOKS_PATH_KEY_RE = /^core\.hooksPath$/i;
38
+ /**
39
+ * `git config` flags that make the invocation a READ or a RESTORE rather than
40
+ * a write. `--unset` removes the override — the opposite of disabling hooks —
41
+ * so it stays allowed, or the gate would block its own remediation.
42
+ */
43
+ /** Global `git` options that consume the following token as their value. */
44
+ const _GIT_GLOBAL_OPTS_WITH_VALUE: ReadonlySet<string> = new Set([
45
+ '-C',
46
+ '-c',
47
+ '--config',
48
+ '--git-dir',
49
+ '--work-tree',
50
+ '--namespace',
51
+ '--exec-path',
52
+ '--super-prefix',
53
+ ]);
54
+ const _CONFIG_READ_OR_RESTORE: ReadonlySet<string> = new Set([
55
+ '--get',
56
+ '--get-all',
57
+ '--get-regexp',
58
+ '--get-urlmatch',
59
+ '--list',
60
+ '-l',
61
+ '--unset',
62
+ '--unset-all',
63
+ ]);
36
64
 
37
65
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
38
66
  type JsonObject = { [k: string]: JsonValue };
@@ -178,7 +206,80 @@ function _git_base(tokens: string[]): string[] | null {
178
206
  return null;
179
207
  }
180
208
 
209
+ /**
210
+ * `git config … core.hooksPath <value>` — the subcommand form of the hooksPath
211
+ * override, distinct from the `-c` / `--config` global-option form handled in
212
+ * `_is_blocked`.
213
+ *
214
+ * Closed by road-to-governance-invariants Phase 1 after the S0.2 spike measured
215
+ * it: `git-history-discipline` already claimed `core.hooksPath` overrides were
216
+ * "deterministically blocked", and only the inline `-c` form was. That made
217
+ * `git config core.hooksPath /dev/null` → `git commit` a two-step sequence
218
+ * whose every step this gate allowed and whose composition is exactly the
219
+ * outcome the gate exists to prevent.
220
+ *
221
+ * Deliberately narrow, per the 2026-08-02 council cut (option ii — widen only
222
+ * the exact shapes with no plausible legitimate use):
223
+ * - a READ (`--get`, `--list`, or the key with no value) is allowed;
224
+ * - `--unset` is allowed — it restores hooks, it does not disable them;
225
+ * - non-git ways of disabling a hook (`mv`/`chmod`/`rm` on `.git/hooks/*`)
226
+ * are NOT matched here. Recognising them would turn this guard into a
227
+ * shell sandbox and those verbs have ordinary legitimate uses. That gap
228
+ * is published rather than papered over — see the S0.2 spike.
229
+ */
230
+ function _is_config_hookspath_write(git_tokens: string[]): [boolean, string] {
231
+ const rest = git_tokens.slice(1);
232
+ // The subcommand is the first token that is neither a global option nor
233
+ // the VALUE of one — `git -C /repo config …` must still resolve to
234
+ // `config`, not to `/repo`.
235
+ let subIdx = -1;
236
+ for (let k = 0; k < rest.length; k += 1) {
237
+ const tok = rest[k] as string;
238
+ if (!tok.startsWith('-')) {
239
+ subIdx = k;
240
+ break;
241
+ }
242
+ if (_GIT_GLOBAL_OPTS_WITH_VALUE.has(tok)) {
243
+ k += 1; // consume its value
244
+ }
245
+ }
246
+ if (subIdx === -1 || rest[subIdx] !== 'config') {
247
+ return [false, ''];
248
+ }
249
+ const args = rest.slice(subIdx + 1);
250
+ if (args.some((t) => _CONFIG_READ_OR_RESTORE.has(t))) {
251
+ return [false, ''];
252
+ }
253
+ for (let k = 0; k < args.length; k += 1) {
254
+ const tok = args[k] as string;
255
+ // `core.hooksPath=<value>` — one token, always a write.
256
+ if (_HOOKS_PATH_RE.test(tok)) {
257
+ return [
258
+ true,
259
+ `'git config ${tok}' disables git hooks via hooksPath (git-history-discipline)`,
260
+ ];
261
+ }
262
+ if (!_HOOKS_PATH_KEY_RE.test(tok)) {
263
+ continue;
264
+ }
265
+ // Bare key: a write only when a value follows.
266
+ const next = args.slice(k + 1).find((t) => !t.startsWith('-'));
267
+ if (next !== undefined) {
268
+ return [
269
+ true,
270
+ `'git config ${tok} ${next}' disables git hooks via hooksPath (git-history-discipline)`,
271
+ ];
272
+ }
273
+ return [false, ''];
274
+ }
275
+ return [false, ''];
276
+ }
277
+
181
278
  function _is_blocked(git_tokens: string[]): [boolean, string] {
279
+ const [cfgBlocked, cfgReason] = _is_config_hookspath_write(git_tokens);
280
+ if (cfgBlocked) {
281
+ return [cfgBlocked, cfgReason];
282
+ }
182
283
  let i = 1; // skip 'git'
183
284
  while (i < git_tokens.length) {
184
285
  const tok = git_tokens[i] as string;
@@ -23,10 +23,10 @@
23
23
  * Usage:
24
24
  * ./scripts-run src/scripts/lint_agent_security [--sarif artifacts/agent-security.sarif]
25
25
  */
26
- import { spawnSync } from "node:child_process";
27
26
  import fs from "node:fs";
28
27
  import path from "node:path";
29
28
  import { fileURLToPath, pathToFileURL } from "node:url";
29
+ import { runCountedProbe } from "./_lib/counted_probe.js";
30
30
 
31
31
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
32
32
 
@@ -54,9 +54,10 @@ function _run(script: string): [number, Finding[]] {
54
54
  // (capture_output=True, text=True equivalent). Mirrors the retired Python implementation's
55
55
  // `subprocess.run([sys.executable, HERE/<child>.py, "--json"])`, now that the
56
56
  // children are TypeScript and the `.py` originals are deleted.
57
- const proc = spawnSync(tsxBin, [path.join(_HERE, script), "--json"], {
58
- encoding: "utf-8",
59
- });
57
+ // Bounded read: a truncated `--json` payload would fail to parse and be
58
+ // swallowed as zero findings — a security aggregate reporting clean because
59
+ // it lost the answer. `runCountedProbe` throws on overflow instead.
60
+ const proc = runCountedProbe(tsxBin, [path.join(_HERE, script), "--json"]);
60
61
  let findings: Finding[];
61
62
  try {
62
63
  const parsed: unknown = JSON.parse((proc.stdout ?? "") || "[]");
@@ -93,8 +93,8 @@
93
93
  {
94
94
  "file": "src/skills/supply-chain-intake/SKILL.md",
95
95
  "lines": [
96
- 70,
97
- 72
96
+ 80,
97
+ 82
98
98
  ],
99
99
  "reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
100
100
  },
@@ -64,6 +64,65 @@ const STATUS_ORDER: Record<string, number> = { pass: 0, pass_with_warnings: 1, f
64
64
  * baseline worktree-add failure (→ exit 2 in main). */
65
65
  class CalledProcessError extends Error {}
66
66
 
67
+ /** The baseline ran but produced nothing usable (→ exit 2 in main).
68
+ *
69
+ * Distinct from the two DOCUMENTED empty-baseline degradations (no linter file
70
+ * in the ref; a pre-migration `.py` baseline with no python3). Those are
71
+ * deliberate. This one is a broken run, and returning an empty baseline for it
72
+ * silently reclassifies every existing finding as a "new file" — the gate then
73
+ * fires on an unmodified tree and can never report a real regression, because a
74
+ * regression needs the file present in both maps. */
75
+ class BaselineCollectionError extends Error {}
76
+
77
+ /** Give the detached baseline worktree a resolvable module context.
78
+ *
79
+ * `git worktree add` checks out tracked files only, so the temp tree has no
80
+ * `node_modules` and the linter dies on its first bare import. Symlinking the
81
+ * repo's own install is enough — the baseline runs the ref's linter source
82
+ * against the ref's artefacts; only the dependency resolution is borrowed. */
83
+ function _link_node_modules(tmpdir: string, repo_root: string): void {
84
+ const target = path.join(repo_root, 'node_modules');
85
+ const link = path.join(tmpdir, 'node_modules');
86
+ if (fs.existsSync(link) || !fs.existsSync(target)) {
87
+ return;
88
+ }
89
+ try {
90
+ fs.symlinkSync(target, link, 'dir');
91
+ } catch {
92
+ // Non-fatal here: a failed link surfaces as a failed baseline run
93
+ // below, which raises with the real cause rather than degrading.
94
+ }
95
+ }
96
+
97
+ /** Re-root the baseline's file keys onto the repo-relative paths the working-tree
98
+ * run emits.
99
+ *
100
+ * The baseline runs with `--repo-root <tmpdir>` and reports absolute paths under
101
+ * that temp worktree; the working-tree run reports paths relative to the repo.
102
+ * Left as-is the two maps share no key, so every comparison is vacuous — the
103
+ * disjoint guard would reject the run outright once the baseline is non-empty. */
104
+ function _relativise(data: LinterJson, tmpdir: string): LinterJson {
105
+ const roots = new Set<string>([tmpdir]);
106
+ try {
107
+ roots.add(fs.realpathSync(tmpdir));
108
+ } catch {
109
+ /* the temp dir is about to be removed; the literal prefix still applies */
110
+ }
111
+ const strip = (f: string): string => {
112
+ for (const r of roots) {
113
+ const prefix = r.endsWith(path.sep) ? r : r + path.sep;
114
+ if (f.startsWith(prefix)) {
115
+ return f.slice(prefix.length);
116
+ }
117
+ }
118
+ return f;
119
+ };
120
+ return {
121
+ ...data,
122
+ results: (data.results ?? []).map((e) => ({ ...e, file: strip(e.file) })),
123
+ };
124
+ }
125
+
67
126
  /** Run the linter and return parsed JSON. If ref is null, run on working tree.
68
127
  *
69
128
  * Mirrors `run_linter_json`: for a ref, add a detached temp worktree, run the
@@ -91,6 +150,7 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
91
150
  );
92
151
  }
93
152
  try {
153
+ _link_node_modules(tmpdir, repo_root);
94
154
  // The baseline ref may be pre-migration (`.py`, run via python3) or
95
155
  // post-migration (`.ts`, run via tsx). Probe both extensions in both
96
156
  // locations and spawn by extension. A historical `.py` ref genuinely
@@ -139,7 +199,17 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
139
199
  encoding: 'utf8',
140
200
  });
141
201
  const out = result.stdout ?? '';
142
- return out.trim() ? (JSON.parse(out) as LinterJson) : { results: [], summary: {} };
202
+ if (!out.trim()) {
203
+ const why =
204
+ result.status === 0
205
+ ? 'it exited 0 but printed nothing'
206
+ : `it exited ${result.status ?? 'null'}`;
207
+ throw new BaselineCollectionError(
208
+ `the baseline linter for '${ref}' produced no output — ${why}.\n` +
209
+ `stderr: ${(result.stderr ?? '').trim().split('\n').slice(-6).join('\n')}`,
210
+ );
211
+ }
212
+ return _relativise(JSON.parse(out) as LinterJson, tmpdir);
143
213
  } finally {
144
214
  spawnSync('git', ['-C', repo_root, 'worktree', 'remove', '--force', tmpdir], {
145
215
  encoding: 'utf8',
@@ -433,6 +503,10 @@ function main(): number {
433
503
  );
434
504
  return 2;
435
505
  }
506
+ if (e instanceof BaselineCollectionError) {
507
+ process.stderr.write(`Error: ${e.message}\n`);
508
+ return 2;
509
+ }
436
510
  throw e;
437
511
  }
438
512
 
@@ -454,6 +528,22 @@ function main(): number {
454
528
  return 2;
455
529
  }
456
530
 
531
+ // The same guard's blind spot: an EMPTY baseline against a populated
532
+ // current tree is not comparable either, and it is the worse failure —
533
+ // every existing finding is then reported as a "new file", so the gate
534
+ // fires on an unmodified tree and no real regression can ever surface.
535
+ // The documented empty-baseline degradations (no linter in the ref; a
536
+ // pre-migration `.py` baseline with no python3) print their own warning
537
+ // above and land here on purpose.
538
+ if (!baseKeys.length && currKeys.length) {
539
+ process.stderr.write(
540
+ `Error: the baseline '${args.baseline}' produced no lint results while the ` +
541
+ `current tree produced ${currKeys.length} — runs are not comparable; ` +
542
+ 'refusing to report. Every finding would be misreported as new.\n',
543
+ );
544
+ return 2;
545
+ }
546
+
457
547
  const delta = compare(baseline_map, current_map);
458
548
 
459
549
  if (args.format === 'json') {
@@ -528,4 +618,5 @@ export {
528
618
  format_text,
529
619
  format_markdown,
530
620
  main,
621
+ _relativise,
531
622
  };