@event4u/agent-config 8.6.0 → 8.8.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 (118) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +81 -0
  3. package/CONTRIBUTING.md +25 -0
  4. package/README.md +7 -4
  5. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +1 -1
  6. package/dist/agent-src/contexts/execution/orchestration-benchmark-gate.md +28 -16
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +15 -0
  8. package/dist/agent-src/rules/active-remediation.md +92 -0
  9. package/dist/agent-src/rules/broken-access-control.md +106 -0
  10. package/dist/agent-src/rules/communication-through-line.md +42 -0
  11. package/dist/agent-src/rules/decision-revisit-gate.md +8 -0
  12. package/dist/agent-src/rules/minimal-safe-diff.md +18 -1
  13. package/dist/agent-src/rules/prefer-enums-over-literals.md +65 -0
  14. package/dist/agent-src/rules/senior-engineering-discipline.md +82 -0
  15. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +105 -0
  16. package/dist/agent-src/skills/ai-code-blindspots/evals/triggers.json +52 -0
  17. package/dist/agent-src/skills/authz-review/SKILL.md +21 -4
  18. package/dist/agent-src/skills/dependency-upgrade/SKILL.md +20 -0
  19. package/dist/agent-src/skills/frontend-render-security/SKILL.md +104 -0
  20. package/dist/agent-src/skills/frontend-render-security/evals/triggers.json +52 -0
  21. package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -0
  22. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  23. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +100 -0
  24. package/dist/agent-src/skills/supply-chain-intake/evals/triggers.json +52 -0
  25. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +43 -2
  26. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  27. package/dist/agent-src/templates/roadmaps.md +24 -0
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +196 -16
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +3 -3
  32. package/dist/discovery/orphan-report.md +1 -1
  33. package/dist/discovery/packs.json +13 -5
  34. package/dist/discovery/trust-report.md +11 -11
  35. package/dist/discovery/workspaces.json +12 -4
  36. package/dist/install/emit_host_rules_cli.js +185 -0
  37. package/dist/install/emit_host_rules_cli.js.map +1 -0
  38. package/dist/install/install.mjs +11 -2
  39. package/dist/install/plan.js +4 -1
  40. package/dist/install/plan.js.map +1 -1
  41. package/dist/install/rule_scope.js +109 -0
  42. package/dist/install/rule_scope.js.map +1 -0
  43. package/dist/install/rule_scope_cli.js +77 -0
  44. package/dist/install/rule_scope_cli.js.map +1 -0
  45. package/dist/install/wizard-plan.js +14 -1
  46. package/dist/install/wizard-plan.js.map +1 -1
  47. package/dist/mcp/awesome-mcp-servers.row.md +1 -1
  48. package/dist/mcp/mcp-cloudflare-catalogue.json +1 -1
  49. package/dist/mcp/registry-manifest.json +4 -4
  50. package/dist/router.json +1 -1
  51. package/dist/scripts/_lib/agent_settings.js +963 -0
  52. package/dist/scripts/_lib/agent_settings.js.map +1 -0
  53. package/dist/scripts/_lib/agent_src.js +607 -0
  54. package/dist/scripts/_lib/agent_src.js.map +1 -0
  55. package/dist/scripts/_lib/claude_settings_hooks.js +209 -0
  56. package/dist/scripts/_lib/claude_settings_hooks.js.map +1 -0
  57. package/dist/scripts/_lib/model_tier.js +64 -0
  58. package/dist/scripts/_lib/model_tier.js.map +1 -0
  59. package/dist/scripts/_lib/script_output.js +140 -0
  60. package/dist/scripts/_lib/script_output.js.map +1 -0
  61. package/dist/scripts/_lib/user_global_paths.js +297 -0
  62. package/dist/scripts/_lib/user_global_paths.js.map +1 -0
  63. package/dist/scripts/condense.js +2181 -0
  64. package/dist/scripts/condense.js.map +1 -0
  65. package/dist/server/routes/install.js +18 -2
  66. package/dist/server/routes/install.js.map +1 -1
  67. package/dist/server/schemas/settings.js +4 -1
  68. package/dist/server/schemas/settings.js.map +1 -1
  69. package/docs/CLAIMS.md +30 -2
  70. package/docs/architecture.md +2 -2
  71. package/docs/catalog.md +14 -0
  72. package/docs/contracts/mcp-tool-inventory.md +31 -30
  73. package/docs/decisions/ADR-105-automatic-subagent-orchestration.md +6 -0
  74. package/docs/decisions/ADR-116-memory-tripwire-activation-path.md +2 -2
  75. package/docs/decisions/ADR-117-subagents-auto-default-on.md +88 -0
  76. package/docs/decisions/INDEX.md +1 -0
  77. package/docs/experiences/content_creator.md +2 -0
  78. package/docs/experiences/finance.md +2 -0
  79. package/docs/experiences/founder.md +2 -0
  80. package/docs/experiences/ops.md +2 -0
  81. package/docs/featured-skills.md +2 -2
  82. package/docs/getting-started-by-role.md +1 -1
  83. package/docs/getting-started.md +1 -1
  84. package/docs/governance-advantage.md +1 -1
  85. package/docs/guidelines/agent-infra/memory-access.md +12 -0
  86. package/docs/proof.md +45 -3
  87. package/docs/release-runbook.md +138 -0
  88. package/docs/second-brain-scope.md +73 -0
  89. package/docs/succession.md +80 -0
  90. package/package.json +2 -2
  91. package/src/config/agent-settings.template.yml +29 -8
  92. package/src/install/emit_host_rules_cli.ts +190 -0
  93. package/src/install/plan.ts +11 -1
  94. package/src/install/rule_scope.ts +120 -0
  95. package/src/install/rule_scope_cli.ts +79 -0
  96. package/src/install/wizard-plan.ts +22 -1
  97. package/src/scripts/_lib/orchestration_gate.ts +12 -8
  98. package/src/scripts/_lib/orchestration_savings.ts +127 -0
  99. package/src/scripts/build_proof.ts +38 -0
  100. package/src/scripts/chat_history.ts +79 -7
  101. package/src/scripts/check_trigger_eval_presence.ts +171 -0
  102. package/src/scripts/domain_soundness_status.ts +236 -0
  103. package/src/scripts/generate_index.ts +14 -0
  104. package/src/scripts/hot_context_hook.ts +47 -2
  105. package/src/scripts/install.sh +118 -4
  106. package/src/scripts/lint_behavioural_eval_freshness.ts +140 -0
  107. package/src/scripts/mcp_server/consumer_tool_catalog.json +22 -3
  108. package/src/scripts/mcp_server/tools.ts +159 -1
  109. package/src/scripts/memory_lookup.ts +184 -6
  110. package/src/scripts/memory_replay.ts +285 -0
  111. package/src/scripts/orchestration_savings_report.ts +100 -0
  112. package/src/scripts/schemas/domain-truth.schema.json +94 -0
  113. package/src/scripts/schemas/evals.schema.json +113 -0
  114. package/src/scripts/second_brain_score.ts +186 -0
  115. package/src/scripts/session_memory_index.ts +94 -0
  116. package/src/scripts/skill_eval_coverage.ts +318 -0
  117. package/src/scripts/trigger_eval_grandfather.json +226 -0
  118. package/src/server/schemas/settings.ts +11 -2
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Orchestration savings aggregator (pure, no-I/O).
3
+ *
4
+ * Aggregates realized orchestration telemetry into a token-savings report.
5
+ * Consumes the `orchestration` sub-object of audit-log-v1 JSONL lines
6
+ * (contract: `src/agent-src/contexts/execution/orchestration-telemetry.md`).
7
+ *
8
+ * HONEST LIMIT — the telemetry records `token_delta` (net dispatch cost vs the
9
+ * in-session baseline; negative = saved) but NOT the absolute baseline. A clean
10
+ * "% of session saved" is therefore NOT derivable from this data alone — only
11
+ * ABSOLUTE net tokens saved, plus the measured-vs-estimated provenance split.
12
+ * A percentage would need an absolute-baseline field on the telemetry (see the
13
+ * report `notes` and the PR's follow-up section).
14
+ */
15
+
16
+ export type TokenDeltaProvenance = 'measured' | 'estimated';
17
+
18
+ /** The `orchestration` sub-object shape we read (subset — additive fields only). */
19
+ export interface OrchestrationRecord {
20
+ spawn_count?: number;
21
+ token_delta?: number;
22
+ token_delta_provenance?: TokenDeltaProvenance;
23
+ tier_chosen?: string | null;
24
+ task_class?: string | null;
25
+ outcome?: string;
26
+ }
27
+
28
+ /** A parsed audit-log-v1 line (only the fields this aggregator touches). */
29
+ export interface AuditLine {
30
+ input_kind?: string;
31
+ orchestration?: OrchestrationRecord;
32
+ }
33
+
34
+ export interface ProvenanceStat {
35
+ dispatches: number;
36
+ /** Σ token_delta for this provenance (negative = net saved). */
37
+ net_token_delta: number;
38
+ }
39
+
40
+ export interface SavingsReport {
41
+ /** Lines carrying an orchestration object with spawn_count > 0. */
42
+ dispatches: number;
43
+ total_spawns: number;
44
+ /** Σ token_delta across all dispatches (negative = net saved). */
45
+ net_token_delta: number;
46
+ /** Magnitude of tokens saved (sum of negative deltas, as a positive number). */
47
+ tokens_saved: number;
48
+ /** Sum of positive deltas (orchestration that cost MORE than baseline). */
49
+ tokens_added: number;
50
+ by_provenance: { measured: ProvenanceStat; estimated: ProvenanceStat };
51
+ /** tier_chosen → Σ token_delta (negative = net saved on that tier). */
52
+ by_tier: Record<string, number>;
53
+ /** task_class → Σ token_delta. */
54
+ by_task_class: Record<string, number>;
55
+ /** measured dispatches / dispatches, 0..1 (0 when no dispatches). */
56
+ measured_share: number;
57
+ /** Honest caveats about what the number does and does not mean. */
58
+ notes: string[];
59
+ }
60
+
61
+ function emptyProvenance(): ProvenanceStat {
62
+ return { dispatches: 0, net_token_delta: 0 };
63
+ }
64
+
65
+ /**
66
+ * Aggregate orchestration telemetry lines into a savings report.
67
+ *
68
+ * A line counts as a dispatch only when it carries an `orchestration` object
69
+ * with `spawn_count > 0` (spawn_count 0 / absent = handled in-session, not a
70
+ * dispatch). Unknown/absent fields default safely (token_delta → 0, provenance
71
+ * → estimated) so a pre-extension line never throws.
72
+ */
73
+ export function aggregateOrchestrationSavings(lines: AuditLine[]): SavingsReport {
74
+ const report: SavingsReport = {
75
+ dispatches: 0,
76
+ total_spawns: 0,
77
+ net_token_delta: 0,
78
+ tokens_saved: 0,
79
+ tokens_added: 0,
80
+ by_provenance: { measured: emptyProvenance(), estimated: emptyProvenance() },
81
+ by_tier: {},
82
+ by_task_class: {},
83
+ measured_share: 0,
84
+ notes: [],
85
+ };
86
+
87
+ for (const line of lines) {
88
+ const o = line.orchestration;
89
+ if (!o) continue;
90
+ const spawns = o.spawn_count ?? 0;
91
+ if (!(spawns > 0)) continue; // in-session, not a dispatch
92
+
93
+ report.dispatches += 1;
94
+ report.total_spawns += spawns;
95
+
96
+ const delta = o.token_delta ?? 0;
97
+ report.net_token_delta += delta;
98
+ if (delta < 0) report.tokens_saved += -delta;
99
+ else report.tokens_added += delta;
100
+
101
+ // Provenance defaults to `estimated` when unstated (matches the
102
+ // telemetry contract: estimated is the lossy fallback).
103
+ const prov: TokenDeltaProvenance = o.token_delta_provenance === 'measured' ? 'measured' : 'estimated';
104
+ report.by_provenance[prov].dispatches += 1;
105
+ report.by_provenance[prov].net_token_delta += delta;
106
+
107
+ const tier = o.tier_chosen ?? 'unknown';
108
+ report.by_tier[tier] = (report.by_tier[tier] ?? 0) + delta;
109
+
110
+ const cls = o.task_class ?? 'unclassified';
111
+ report.by_task_class[cls] = (report.by_task_class[cls] ?? 0) + delta;
112
+ }
113
+
114
+ report.measured_share = report.dispatches === 0 ? 0 : report.by_provenance.measured.dispatches / report.dispatches;
115
+
116
+ // Honest caveats — always attached so a consumer never over-reads the number.
117
+ if (report.dispatches === 0) {
118
+ report.notes.push('No orchestration telemetry yet (0 dispatches). The number accrues as `subagents.auto: on` runs delegate real slices.');
119
+ } else {
120
+ report.notes.push('Reports ABSOLUTE net token_delta (negative = saved). A "% of session saved" is NOT derivable — the telemetry records net delta, not the absolute baseline. A percentage needs an absolute-baseline field on the telemetry.');
121
+ if (report.by_provenance.estimated.dispatches > 0) {
122
+ report.notes.push(`${report.by_provenance.estimated.dispatches}/${report.dispatches} dispatch(es) use ESTIMATED token_delta (chars/4, lossy). Prefer measured (host usage metadata).`);
123
+ }
124
+ }
125
+
126
+ return report;
127
+ }
@@ -107,6 +107,42 @@ function render(): string {
107
107
  L.push('Benchmark results are published under `docs/` including measured nulls.');
108
108
  }
109
109
  L.push('');
110
+ L.push('**Behavioural-eval coverage — the honest baseline.** Skill *quality* is only');
111
+ L.push('as good as its measurement. Today **2 of 264** skills carry a behavioural');
112
+ L.push('`evals.json`, and the highest-traffic / highest-cost tiers (default-surface +');
113
+ L.push('`rich` + routers, **0 of 35**) are not yet covered. We publish that gap rather');
114
+ L.push('than imply "264 evaluated skills": coverage is measured per tier');
115
+ L.push('(`./scripts-run src/scripts/skill_eval_coverage`) and **CI-ratcheted so it can');
116
+ L.push('only rise** — a merged change can never lower it. Authoring the priority-tier');
117
+ L.push('evals is gated on per-case human ratification (a generated assertion that');
118
+ L.push('checks the wrong property is worse than none), so the number grows');
119
+ L.push('deliberately, not overnight.');
120
+ L.push('');
121
+ L.push('**Non-coding domain soundness — scoped, not proven.** The `finance` /');
122
+ L.push('`founder` / `ops` / `content` profiles sell concrete domain value (DCF,');
123
+ L.push('runway, RICE, incident command, messaging), but the skills are forged on');
124
+ L.push('TS/PHP — "promising, not proven" off those stacks. A disclaimer floor');
125
+ L.push('bounds *liability*, not *correctness*: a skill can be format-correct,');
126
+ L.push('disclaimered, and still embed a wrong domain assumption. Today **0 of 20**');
127
+ L.push('default-surface domain skills carry a sourced `domain-truth` fixture');
128
+ L.push('(`./scripts-run src/scripts/domain_soundness_status`); all are labeled');
129
+ L.push('`unvalidated` and the validated count is CI-ratcheted. Authoring the');
130
+ L.push('answer keys needs domain competence (a cited method, not the skill\'s own');
131
+ L.push('output), so validation lands deliberately — the gap is published, never');
132
+ L.push('implied away.');
133
+ L.push('');
134
+ L.push('**Second-brain substrate — continuity convenience, task-lift unmeasured.**');
135
+ L.push('The memory substrate is built (working-memory continuity across');
136
+ L.push('compaction, promotable knowledge cards, contradiction surfacing), but');
137
+ L.push('whether it beats a no-memory baseline on a reproducible task is **not yet');
138
+ L.push('measured**. The measurement rig exists — a deterministic multi-session');
139
+ L.push('recall corpus + scorer (`./scripts-run src/scripts/second_brain_score');
140
+ L.push('--dry-run`, no model-in-the-loop grading) — but the paired');
141
+ L.push('`memory-on`/`off`/`placebo` run is spend-bearing and pending. So no');
142
+ L.push('"second brain" capability claim is made anywhere; the substrate ships as');
143
+ L.push('continuity convenience until a paired run backs a lift. Boundary vs a');
144
+ L.push('human PKM is stated in [`docs/second-brain-scope.md`](second-brain-scope.md).');
145
+ L.push('');
110
146
  L.push('## 3. Known limits (published, witness-tested)');
111
147
  L.push('');
112
148
  const skillGaps = collectSkillGaps();
@@ -164,6 +200,8 @@ function render(): string {
164
200
  L.push('task check-refs # no broken internal references');
165
201
  L.push('task check-skill-gaps # every logged known-limit cites a real witness test');
166
202
  L.push('task check-comparison # every comparison-table "our evidence" pointer resolves');
203
+ L.push('./scripts-run src/scripts/skill_eval_coverage # behavioural-eval coverage, per tier');
204
+ L.push('./scripts-run src/scripts/skill_eval_coverage --check # the ratchet: coverage may not drop');
167
205
  L.push('task build-proof-check # this page is in sync with its sources');
168
206
  L.push('```');
169
207
  L.push('');
@@ -43,6 +43,8 @@ import * as fs from 'node:fs';
43
43
  import * as path from 'node:path';
44
44
  import { fileURLToPath, pathToFileURL } from 'node:url';
45
45
 
46
+ import { _entry_tokens_estimate } from './memory_lookup.js';
47
+
46
48
  import { load_agent_settings } from './_lib/agent_settings.js';
47
49
 
48
50
  export const DEFAULT_FILE = 'agents/runtime/.agent-chat-history';
@@ -708,6 +710,26 @@ export function read_entries(
708
710
  agent?: string | null | undefined;
709
711
  } = {},
710
712
  ): Entry[] {
713
+ return read_entries_with_refs(options).map((p) => p.entry);
714
+ }
715
+
716
+ /**
717
+ * Like {@link read_entries} but each entry carries its `ref` — the 0-based
718
+ * ordinal in the file's FULL chronological entry list (header excluded,
719
+ * counted BEFORE any session/agent/last filter), so a ref from an index row
720
+ * stays valid regardless of the filters a later call applies. Refs are
721
+ * stable for the append-only log; file rotation invalidates them (documented
722
+ * — a ref is a within-file locator, not a durable id).
723
+ * (road-to-memory-retrieval-economy Phase 3.)
724
+ */
725
+ export function read_entries_with_refs(
726
+ options: {
727
+ last?: number | null | undefined;
728
+ path?: string | null | undefined;
729
+ session?: string | null | undefined;
730
+ agent?: string | null | undefined;
731
+ } = {},
732
+ ): Array<{ entry: Entry; ref: number }> {
711
733
  const target = options.path ?? file_path();
712
734
  const last = options.last ?? null;
713
735
  const session = options.session ?? null;
@@ -715,8 +737,9 @@ export function read_entries(
715
737
  if (!_isFile(target)) {
716
738
  return [];
717
739
  }
718
- let entries: Entry[] = [];
740
+ let pairs: Array<{ entry: Entry; ref: number }> = [];
719
741
  const lines = _readLines(target);
742
+ let ref = 0;
720
743
  for (let i = 0; i < lines.length; i++) {
721
744
  const line = _strip(lines[i] ?? '');
722
745
  if (!line) {
@@ -732,23 +755,72 @@ export function read_entries(
732
755
  continue;
733
756
  }
734
757
  if (_isPlainObject(obj)) {
735
- entries.push(obj);
758
+ pairs.push({ entry: obj, ref });
759
+ ref += 1;
736
760
  }
737
761
  }
738
762
  if (session !== null) {
739
- entries = entries.filter((e) => e.s === session);
763
+ pairs = pairs.filter((p) => p.entry.s === session);
740
764
  }
741
765
  if (agent !== null) {
742
766
  if (agent === AGENT_UNKNOWN) {
743
- entries = entries.filter((e) => !e.agent);
767
+ pairs = pairs.filter((p) => !p.entry.agent);
744
768
  } else {
745
- entries = entries.filter((e) => e.agent === agent);
769
+ pairs = pairs.filter((p) => p.entry.agent === agent);
746
770
  }
747
771
  }
748
772
  if (last !== null && last >= 0) {
749
- entries = entries.slice(entries.length - last);
773
+ pairs = pairs.slice(pairs.length - last);
774
+ }
775
+ return pairs;
776
+ }
777
+
778
+ /**
779
+ * Timeline anchor: the entries around `ref` in the FULL chronological list —
780
+ * `before`/`after` neighbours plus the anchor itself. JSONL is chronological,
781
+ * so this is slicing, not indexing (road-to-memory-retrieval-economy P3).
782
+ * An out-of-range ref returns [].
783
+ */
784
+ export function slice_around(
785
+ path_: string | null,
786
+ ref: number,
787
+ before = 3,
788
+ after = 3,
789
+ ): Array<{ entry: Entry; ref: number }> {
790
+ const all = read_entries_with_refs({ path: path_ });
791
+ if (ref < 0 || ref >= all.length) {
792
+ return [];
793
+ }
794
+ const start = Math.max(0, ref - Math.max(0, before));
795
+ const end = Math.min(all.length, ref + Math.max(0, after) + 1);
796
+ return all.slice(start, end);
797
+ }
798
+
799
+ /**
800
+ * Compact index row for a history entry: ref + type tag + timestamp-ish
801
+ * fields when present + a ~100-char preview + the real-tokenizer cost of
802
+ * fetching the full entry (same lazy-tokenizer contract as memory_lookup's
803
+ * index rows).
804
+ */
805
+ export function history_index_row(pair: { entry: Entry; ref: number }): Record<string, unknown> {
806
+ const e = pair.entry;
807
+ const text = typeof e.text === 'string' ? e.text : '';
808
+ const row: Record<string, unknown> = {
809
+ ref: pair.ref,
810
+ t: e.t ?? '',
811
+ preview: text.slice(0, 100),
812
+ tokens_estimate: _entry_tokens_estimate(e as Record<string, unknown>),
813
+ };
814
+ if (typeof e.s === 'string') {
815
+ row.s = e.s;
816
+ }
817
+ for (const tsField of ['ts', 'time', 'started']) {
818
+ if (typeof (e as Record<string, unknown>)[tsField] === 'string') {
819
+ row[tsField] = (e as Record<string, unknown>)[tsField];
820
+ break;
821
+ }
750
822
  }
751
- return entries;
823
+ return row;
752
824
  }
753
825
 
754
826
  export function read_entries_for_current(
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Trigger-eval PRESENCE ratchet (road-to-composition-ratchet Phase 1).
4
+ *
5
+ * `check_trigger_evals.ts` validates freshness + structure of triggers.json
6
+ * files that EXIST; it cannot flag a new skill that ships without one, even
7
+ * though the drafting protocol mandates the stub. This gate closes that gap
8
+ * with a shrink-only grandfather allowlist
9
+ * (`src/scripts/trigger_eval_grandfather.json`, frozen 2026-07-08 at 221
10
+ * entries):
11
+ *
12
+ * 1. Every skill under `src/skills/` must either carry
13
+ * `evals/triggers.json` or appear in the allowlist.
14
+ * 2. An allowlist entry whose skill directory no longer exists is stale
15
+ * and must be removed.
16
+ * 3. An allowlist entry whose skill HAS a triggers.json must be removed —
17
+ * the list may only shrink (ratchet), never regrow.
18
+ *
19
+ * Structure + freshness of existing files stay owned by
20
+ * `check_trigger_evals.ts`; this gate checks presence only.
21
+ *
22
+ * Exit codes: 0 ok · 1 violation(s) · 2 usage / unreadable allowlist.
23
+ * `--quiet` suppresses the ✅ summary (errors always print to stderr).
24
+ */
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { fileURLToPath, pathToFileURL } from 'node:url';
28
+
29
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
30
+ const SKILLS_DIR = path.join(REPO_ROOT, 'src', 'skills');
31
+ const ALLOWLIST_PATH = path.join(REPO_ROOT, 'src', 'scripts', 'trigger_eval_grandfather.json');
32
+
33
+ interface Allowlist {
34
+ skills: string[];
35
+ }
36
+
37
+ function load_allowlist(p: string): Allowlist | null {
38
+ try {
39
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as unknown;
40
+ if (
41
+ typeof raw !== 'object' ||
42
+ raw === null ||
43
+ !Array.isArray((raw as { skills?: unknown }).skills) ||
44
+ !(raw as { skills: unknown[] }).skills.every((s) => typeof s === 'string')
45
+ ) {
46
+ return null;
47
+ }
48
+ return { skills: (raw as { skills: string[] }).skills };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function list_skill_dirs(root: string): string[] {
55
+ return fs
56
+ .readdirSync(root, { withFileTypes: true })
57
+ .filter((e) => e.isDirectory())
58
+ .map((e) => e.name)
59
+ .sort();
60
+ }
61
+
62
+ function has_triggers(skill: string): boolean {
63
+ return fs.existsSync(path.join(SKILLS_DIR, skill, 'evals', 'triggers.json'));
64
+ }
65
+
66
+ interface Findings {
67
+ errors: string[];
68
+ covered: number;
69
+ total: number;
70
+ }
71
+
72
+ function check(skills: string[], allowlisted: string[]): Findings {
73
+ const allow = new Set(allowlisted);
74
+ const skillSet = new Set(skills);
75
+ const errors: string[] = [];
76
+ let covered = 0;
77
+
78
+ for (const skill of skills) {
79
+ if (has_triggers(skill)) {
80
+ covered += 1;
81
+ if (allow.has(skill)) {
82
+ errors.push(
83
+ `${skill}: has evals/triggers.json but is still in the grandfather allowlist — remove the entry (shrink-only ratchet)`,
84
+ );
85
+ }
86
+ } else if (!allow.has(skill)) {
87
+ errors.push(
88
+ `${skill}: missing evals/triggers.json and not grandfathered — new/changed skills must ship a trigger-eval set (see artifact-drafting-protocol Phase C)`,
89
+ );
90
+ }
91
+ }
92
+
93
+ for (const entry of allowlisted) {
94
+ if (!skillSet.has(entry)) {
95
+ errors.push(
96
+ `allowlist entry "${entry}" has no matching skill directory — remove the stale entry`,
97
+ );
98
+ }
99
+ }
100
+
101
+ return { errors, covered, total: skills.length };
102
+ }
103
+
104
+ function parse_args(argv: string[]): { quiet: boolean } | null {
105
+ let quiet = false;
106
+ for (const a of argv) {
107
+ if (a === '--quiet' || a === '-q') {
108
+ quiet = true;
109
+ } else {
110
+ return null;
111
+ }
112
+ }
113
+ return { quiet };
114
+ }
115
+
116
+ function main(): number {
117
+ const args = parse_args(process.argv.slice(2));
118
+ if (args === null) {
119
+ process.stderr.write('usage: check_trigger_eval_presence [--quiet]\n');
120
+ return 2;
121
+ }
122
+
123
+ const allowlist = load_allowlist(ALLOWLIST_PATH);
124
+ if (allowlist === null) {
125
+ process.stderr.write(
126
+ `❌ check-trigger-eval-presence: unreadable or malformed allowlist at ${path.relative(REPO_ROOT, ALLOWLIST_PATH)}\n`,
127
+ );
128
+ return 2;
129
+ }
130
+
131
+ const { errors, covered, total } = check(list_skill_dirs(SKILLS_DIR), allowlist.skills);
132
+
133
+ if (errors.length) {
134
+ process.stderr.write('❌ check-trigger-eval-presence: presence-ratchet violation(s):\n');
135
+ for (const e of errors) {
136
+ process.stderr.write(` - ${e}\n`);
137
+ }
138
+ return 1;
139
+ }
140
+
141
+ if (!args.quiet) {
142
+ process.stdout.write(
143
+ `✅ check-trigger-eval-presence: ${covered}/${total} skills carry evals/triggers.json (${allowlist.skills.length} grandfathered, shrink-only)\n`,
144
+ );
145
+ }
146
+ return 0;
147
+ }
148
+
149
+ const _HERE = fileURLToPath(import.meta.url);
150
+ function _isCliEntry(): boolean {
151
+ if (process.argv[1] === undefined) {
152
+ return false;
153
+ }
154
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
155
+ if (import.meta.url === argvUrl) {
156
+ return true;
157
+ }
158
+ try {
159
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
160
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
161
+ return here === argv;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ if (_isCliEntry() || process.argv[1] === _HERE) {
168
+ process.exit(main());
169
+ }
170
+
171
+ export { ALLOWLIST_PATH, check, load_allowlist, parse_args, main };