@event4u/agent-config 14.9.0 → 14.10.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 (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +49 -0
  3. package/dist/agent-src/skills/async-python-patterns/SKILL.md +1 -1
  4. package/dist/agent-src/skills/code-refactoring/SKILL.md +1 -1
  5. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  6. package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
  7. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  8. package/dist/agent-src/skills/discovery-interview/SKILL.md +1 -1
  9. package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
  10. package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -1
  11. package/dist/agent-src/skills/editorial-calendar/SKILL.md +1 -1
  12. package/dist/agent-src/skills/github-ci/SKILL.md +1 -1
  13. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +1 -1
  14. package/dist/agent-src/skills/judge-injection-defense/SKILL.md +1 -1
  15. package/dist/agent-src/skills/laravel-validation/SKILL.md +1 -1
  16. package/dist/agent-src/skills/legal-intake-triage/SKILL.md +1 -1
  17. package/dist/agent-src/skills/legal-practice-profile/SKILL.md +1 -1
  18. package/dist/agent-src/skills/messaging-architecture/SKILL.md +1 -1
  19. package/dist/agent-src/skills/migration-architect/SKILL.md +1 -1
  20. package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +1 -1
  21. package/dist/agent-src/skills/module-management/SKILL.md +1 -1
  22. package/dist/agent-src/skills/motion-choreographer/SKILL.md +1 -1
  23. package/dist/agent-src/skills/onboarding-program/SKILL.md +1 -1
  24. package/dist/agent-src/skills/perf-feedback-craft/SKILL.md +1 -1
  25. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +1 -1
  26. package/dist/agent-src/skills/receiving-code-review/SKILL.md +1 -1
  27. package/dist/agent-src/skills/scene-expander/SKILL.md +1 -1
  28. package/dist/agent-src/skills/secrets-management/SKILL.md +1 -1
  29. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  30. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  31. package/dist/discovery/deprecation-report.md +1 -1
  32. package/dist/discovery/discovery-manifest.json +29 -29
  33. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  34. package/dist/discovery/discovery-manifest.summary.md +1 -1
  35. package/dist/discovery/dormancy-report.md +1 -1
  36. package/dist/discovery/orphan-report.md +1 -1
  37. package/dist/discovery/packs.json +2 -2
  38. package/dist/discovery/trust-report.md +1 -1
  39. package/dist/discovery/workspaces.json +2 -2
  40. package/dist/mcp/registry-manifest.json +1 -1
  41. package/dist/mcp/server.json +2 -2
  42. package/package.json +1 -1
  43. package/src/config/gate-coverage.yml +20 -0
  44. package/src/config/preamble-payload-budget.json +71 -67
  45. package/src/scripts/ai_council/seating.ts +211 -0
  46. package/src/scripts/check_pack_passport_reconciliation.ts +274 -0
  47. package/src/scripts/generate_pack_manifests.ts +78 -0
  48. package/src/scripts/schemas/pack.schema.json +62 -4
  49. package/src/scripts/schemas/skill.schema.json +2 -2
  50. package/src/scripts/tighten_skill_descriptions.ts +264 -0
@@ -1,6 +1,6 @@
1
1
  # Discovery — Dormancy Report
2
2
 
3
- - Generated: `2026-08-23T01:36:08Z`
3
+ - Generated: `2026-08-23T02:30:18Z`
4
4
  - Window: **6 months** without a commit
5
5
 
6
6
  > Dormancy triggers **review, never auto-deprecation**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Orphan Report
2
2
 
3
- - Generated: `2026-08-23T01:36:08Z`
3
+ - Generated: `2026-08-23T02:30:18Z`
4
4
  - Orphan artefacts: **0**
5
5
 
6
6
  > An orphan is an artefact whose declared pack has no other members.
@@ -1,6 +1,6 @@
1
1
  {
2
- "checksum": "sha256:9317e131726d366f7508a28af162952845a4a5bbfdd0fee947504b3be5538ba4",
3
- "generated_at": "2026-08-23T01:36:08Z",
2
+ "checksum": "sha256:b5481a95fd4f5f0901fb80d9993f809fa9f890c7ff18e6fc9b863356b5037a73",
3
+ "generated_at": "2026-08-23T02:30:18Z",
4
4
  "packs": [
5
5
  {
6
6
  "always_on": false,
@@ -1,6 +1,6 @@
1
1
  # Discovery — Trust Report
2
2
 
3
- - Generated: `2026-08-23T01:36:08Z`
3
+ - Generated: `2026-08-23T02:30:18Z`
4
4
  - Workspaces tracked: **10**
5
5
  - Human-review-required artefacts: **3**
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "checksum": "sha256:9317e131726d366f7508a28af162952845a4a5bbfdd0fee947504b3be5538ba4",
3
- "generated_at": "2026-08-23T01:36:08Z",
2
+ "checksum": "sha256:b5481a95fd4f5f0901fb80d9993f809fa9f890c7ff18e6fc9b863356b5037a73",
3
+ "generated_at": "2026-08-23T02:30:18Z",
4
4
  "scanner_version": "de0a22b4ac4c",
5
5
  "workspaces": [
6
6
  {
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "14.9.0"
12
+ "version": "14.10.0"
13
13
  },
14
14
  "registries": [
15
15
  {
@@ -9,12 +9,12 @@
9
9
  "transport": {
10
10
  "type": "stdio"
11
11
  },
12
- "version": "14.9.0"
12
+ "version": "14.10.0"
13
13
  }
14
14
  ],
15
15
  "repository": {
16
16
  "source": "github",
17
17
  "url": "https://github.com/event4u-app/agent-config"
18
18
  },
19
- "version": "14.9.0"
19
+ "version": "14.10.0"
20
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "14.9.0",
4
+ "version": "14.10.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -297,6 +297,26 @@ gates:
297
297
  no_canary_reason: >-
298
298
  the violation is a MODIFICATION, not a creation: this gate reds when package.json `files[]` or `.npmignore` changes and the committed artefact does not, and the canary contract is create-only. A planted FILE cannot move either surface — the closest it gets is changing the payload-derived shadowed set, which is informational and deliberately excluded from the drift verdict, so a plant would leave the gate green and be reported as dead. Discrimination is proven by the gate's own `--self-test` (8 cases, 5 rejecting) over the pattern matcher, and end to end by hand: editing `files[]` alone exits 1, regenerating exits 0, editing `.npmignore` alone exits 1, restoring exits 0.
299
299
 
300
+ - id: check_pack_passport_reconciliation
301
+ argv: []
302
+ min_scanned: 15
303
+ corpus: "the generated token_passport block in every src/packs/*/pack.yaml (17 at registration)"
304
+ status: enforced
305
+ no_canary_reason: >-
306
+ this is a LISTING, not an enforcement: `main()` returns 0 on every path by design, so no planted file can make it red and the harness's "caught the planted defect" observation is unreachable by construction. It is registered for its `scanned:` floor, which is the one thing about it that CAN break — a passport block that stops being generated, or a packs root that moves. Discrimination is proven by the gate's own `--self-test` (6 cases, 3 rejecting) over the two pure units behind the number: the passport parser and the reconciliation ratio.
307
+ note: >
308
+ Registered because it emits the `scanned:` contract line, and a test in
309
+ this tree requires every emitter to be registered — it caught this entry
310
+ being absent, which is the coverage register working as intended.
311
+
312
+ It exits 0 on a large gap ON PURPOSE. The ±10 % band it was specified with
313
+ assumed packs roughly PARTITION the tree; measured, `dependencies` claims
314
+ 4 skills and 0 rules against 291 and 118, so gaps of 84 % and above are a
315
+ property of the tree rather than a defect. A gate red by construction gets
316
+ disabled rather than fixed, so this reports coverage instead. The condition
317
+ that would make the exit code follow the band — pack coverage approaching
318
+ the tree — is recorded in docs/contracts/pack-token-passport.md.
319
+
300
320
  - id: check_gate_completeness
301
321
  argv: []
302
322
  min_scanned: 180
@@ -1,72 +1,76 @@
1
1
  {
2
- "_comment": "Per-spawn preamble payload ceiling (road-to-cache-economy Phase 3), unblocked by C-3 measuring 38.0% against a 15% bar. This is a RATCHET, not the roadmap's literal candidate numbers: those (median 40k / p95 50k) were anchored to an upstream ~37k baseline, while this package's own deterministic in-repo payload is ~102.5k tokens — a hard 40k gate would be red on the day it lands and would teach the reader to ignore it. The ratchet fails on GROWTH instead, so the number can only walk down, and the 40k/50k target stays recorded as the destination. Only the deterministic, in-repo buckets are gated: user-scope rules depend on what the developer happens to have installed globally and are therefore not CI-checkable.",
3
- "schema_version": 1,
4
- "registered_at": "2026-07-30",
5
- "owner": "maintainer",
6
- "review_by": "2027-07-30",
7
- "metric": {
8
- "basis": "chars/4 token estimate over the projected tree, produced by src/scripts/preamble_byte_census.ts",
9
- "gated_buckets": [
10
- "project-scope rules (dist/agent-src/rules/*.md)",
11
- "preloaded skills catalog (name + description, dist/agent-src/skills)",
12
- "CLAUDE.md hierarchy (project + project-local)"
13
- ],
14
- "excluded_buckets": [
15
- "user-scope rules (~/.claude/rules/*.md) — machine-dependent, not CI-checkable",
16
- "tool definitions + dispatch prompt — no local source carries the request payload; the census reports it as an explicit residual"
17
- ]
18
- },
19
- "baseline_tokens": 102520,
20
- "baseline_history": [
21
- {
22
- "tokens": 102599,
23
- "at": "2026-07-30",
24
- "why": "registered with the gate"
25
- },
26
- {
27
- "tokens": 102520,
28
- "at": "2026-07-31",
29
- "why": "free tightening: the measured tree came in 79 tok under the registered baseline, so the ratchet walks down to meet it. Not a claimed reduction — nothing was optimised; the point of a ratchet is that a lower measurement becomes the new ceiling instead of becoming unused headroom."
30
- }
2
+ "_comment": "Per-spawn preamble payload ceiling (road-to-cache-economy Phase 3), unblocked by C-3 measuring 38.0% against a 15% bar. This is a RATCHET, not the roadmap's literal candidate numbers: those (median 40k / p95 50k) were anchored to an upstream ~37k baseline, while this package's own deterministic in-repo payload is ~102.5k tokens \u2014 a hard 40k gate would be red on the day it lands and would teach the reader to ignore it. The ratchet fails on GROWTH instead, so the number can only walk down, and the 40k/50k target stays recorded as the destination. Only the deterministic, in-repo buckets are gated: user-scope rules depend on what the developer happens to have installed globally and are therefore not CI-checkable.",
3
+ "schema_version": 1,
4
+ "registered_at": "2026-07-30",
5
+ "owner": "maintainer",
6
+ "review_by": "2027-07-30",
7
+ "metric": {
8
+ "basis": "chars/4 token estimate over the projected tree, produced by src/scripts/preamble_byte_census.ts",
9
+ "gated_buckets": [
10
+ "project-scope rules (dist/agent-src/rules/*.md)",
11
+ "preloaded skills catalog (name + description, dist/agent-src/skills)",
12
+ "CLAUDE.md hierarchy (project + project-local)"
31
13
  ],
32
- "headroom_pct": 5,
33
- "target_tokens": {
34
- "median": 40000,
35
- "p95": 50000,
36
- "_comment": "The roadmap's candidate numbers, kept as the destination. Reaching them needs reductions this package has not built yet; scope-dedup (C-3) removes a redundant COPY at the consumer, it does not shrink the suite itself."
37
- },
38
- "target_schedule": {
39
- "_comment": "road-to-inbox-harvest-2026-08-d-context-ledger Step 5.3. The target above was a destination with no route: the ratchet fails on GROWTH only, so the corpus can drift away from 40k indefinitely without any gate objecting. These milestones give the destination a schedule. They are DATES WITH NUMBERS, not a derivation - no measurement in this tree says the estate can reach 85k by a given day, and pretending otherwise would be the invented-threshold failure this file's own baseline_history is careful to avoid. What they buy is that a miss becomes a published fact with an owner instead of a silent drift.",
40
- "owner": "maintainer",
41
- "measured_at_registration": {
42
- "tokens": 125593,
43
- "at": "2026-08-15",
44
- "note": "MEASURED total at registration time, from check_preamble_payload_budget. It is ~23k ABOVE both baseline_tokens (102520) and the derived ceiling, i.e. the gate is red on the tree as it stands. The baseline is deliberately NOT raised to meet it - raising a ratchet baseline to clear a failing check is the config-weakening move this repo blocks by construction. It is recorded here so the milestones below are planned against the real starting point rather than a stale one. Per-rule ranking: agents/evidence/analysis/rule-paths-coverage-census.md"
45
- },
46
- "milestones": [
47
- {
48
- "by": "2026-11-10",
49
- "tokens": 102520,
50
- "why": "Return to the registered baseline. This is a RECOVERY milestone, not progress toward 40k - it undoes drift rather than shrinking the estate. The date is the review_by both budget files already carry, so it needs no new calendar."
51
- },
52
- {
53
- "by": "2027-02-10",
54
- "tokens": 85000,
55
- "why": "First real reduction. The rule-paths census shows 92.1% of rule weight sits in the auto tier and the top 20 files carry 35.5% of it, so a scoped tranche is the plausible mechanism - GATED on the paths-scoping consumer decision, which is a human call and may resolve against scoping entirely."
56
- },
57
- {
58
- "by": "2027-05-10",
59
- "tokens": 60000,
60
- "why": "Second reduction. No mechanism is committed for this one; it exists so the destination keeps a date after the first tranche lands, and it is the milestone most likely to be revised on evidence."
61
- }
62
- ],
63
- "on_miss": "A missed milestone is PUBLISHED - recorded here with the measured number and the reason - and never silently re-dated. Two consecutive misses mean the schedule was wrong or the destination is unreachable with the mechanisms available; either outcome is a decision to surface, not a date to move again.",
64
- "revisit_if": "The paths-scoping decision resolves against scoping (removing the only named mechanism for the 85k milestone), or a measured reduction lands ahead of schedule and the remaining milestones are re-derived from it."
14
+ "excluded_buckets": [
15
+ "user-scope rules (~/.claude/rules/*.md) \u2014 machine-dependent, not CI-checkable",
16
+ "tool definitions + dispatch prompt \u2014 no local source carries the request payload; the census reports it as an explicit residual",
17
+ "personas (src/agent-src/personas, 36 files, ~37,181 tok) \u2014 measured ZERO standing cost 2026-08-22: .claude/personas/ is written by this repo's generators (5 in-repo references) and read by no host contract; the host's real subagent surface is .claude/agents/, which this package writes 1 file into. Reversal condition: a host publishing a personas contract, or a generator folding persona prose into an instruction file that IS read. Evidence: agents/evidence/analysis/uncounted-prose-corpora-load-semantics.md",
18
+ "contexts (src/agent-src/contexts, 58 files, ~114,042 tok) \u2014 measured ON-DEMAND 2026-08-22: 0 rules declare load_context_eager: (17 declare the lazy load_context:), and rule.schema.json:49 documents eager as the variant that counts against the per-rule budget. This records that no eager declaration exists, NOT that eager loading would be free \u2014 nobody has declared one, so nobody has measured it. Evidence: agents/evidence/analysis/uncounted-prose-corpora-load-semantics.md",
19
+ "skill bodies (src/skills/*/SKILL.md, 290 files, ~595,843 tok) \u2014 measured ON-DEMAND 2026-08-22: censusSkillsCatalog (preamble_byte_census.ts:216-234) builds the gated catalog bucket from `- name: description` per skill and reads no body byte, which is why 290 skills totalling ~595,843 tok report as ~14.4k standing. Corollary: the description cap is the WHOLE skill-side lever, not a fragment of it. Evidence: agents/evidence/analysis/uncounted-prose-corpora-load-semantics.md"
20
+ ],
21
+ "_bucket_classification_note": "All three additions above are EXCLUSIONS, not gated buckets, and that is the finding rather than a shortcut. A census bucket over a corpus measured at zero is a permanently green gate certifying nothing. The exclusions carry their measurement so the next pass that proposes one of these corpora as a token lever meets the number instead of the silence."
22
+ },
23
+ "baseline_tokens": 102520,
24
+ "baseline_history": [
25
+ {
26
+ "tokens": 102599,
27
+ "at": "2026-07-30",
28
+ "why": "registered with the gate"
65
29
  },
66
- "reference_measurements": {
67
- "measured_cold_start_median_tokens": 230930,
68
- "c3_scope_dedup_reduction_pct": 38,
69
- "c3_condition": "FIXTURE-ONLY as of 2026-07-31: the 38% was measured on a byte-identical two-scope fixture, and that condition is currently unreachable for every consumer because install.ts:2723/2725 stamp package:/source_path: into every installed rule while the in-repo projection stamps nothing. Aligning versions yields 0/110 twins, not 110/110. The figure is the mechanism's ceiling, NOT a realised saving, and this baseline therefore assumes no dedup. Reachability is the subject of road-to-dedup-reachability; run measure_scope_dedup for the live split.",
70
- "_comment": "The measured median comes from Claude Code transcripts (deduped by message.id + requestId) and is context for the ratchet, never the gated number — it moves with the developer's own session history."
30
+ {
31
+ "tokens": 102520,
32
+ "at": "2026-07-31",
33
+ "why": "free tightening: the measured tree came in 79 tok under the registered baseline, so the ratchet walks down to meet it. Not a claimed reduction \u2014 nothing was optimised; the point of a ratchet is that a lower measurement becomes the new ceiling instead of becoming unused headroom."
71
34
  }
35
+ ],
36
+ "headroom_pct": 5,
37
+ "target_tokens": {
38
+ "median": 40000,
39
+ "p95": 50000,
40
+ "_comment": "The roadmap's candidate numbers, kept as the destination. Reaching them needs reductions this package has not built yet; scope-dedup (C-3) removes a redundant COPY at the consumer, it does not shrink the suite itself."
41
+ },
42
+ "target_schedule": {
43
+ "_comment": "road-to-inbox-harvest-2026-08-d-context-ledger Step 5.3. The target above was a destination with no route: the ratchet fails on GROWTH only, so the corpus can drift away from 40k indefinitely without any gate objecting. These milestones give the destination a schedule. They are DATES WITH NUMBERS, not a derivation - no measurement in this tree says the estate can reach 85k by a given day, and pretending otherwise would be the invented-threshold failure this file's own baseline_history is careful to avoid. What they buy is that a miss becomes a published fact with an owner instead of a silent drift.",
44
+ "owner": "maintainer",
45
+ "measured_at_registration": {
46
+ "tokens": 125593,
47
+ "at": "2026-08-15",
48
+ "note": "MEASURED total at registration time, from check_preamble_payload_budget. It is ~23k ABOVE both baseline_tokens (102520) and the derived ceiling, i.e. the gate is red on the tree as it stands. The baseline is deliberately NOT raised to meet it - raising a ratchet baseline to clear a failing check is the config-weakening move this repo blocks by construction. It is recorded here so the milestones below are planned against the real starting point rather than a stale one. Per-rule ranking: agents/evidence/analysis/rule-paths-coverage-census.md"
49
+ },
50
+ "milestones": [
51
+ {
52
+ "by": "2026-11-10",
53
+ "tokens": 102520,
54
+ "why": "Return to the registered baseline. This is a RECOVERY milestone, not progress toward 40k - it undoes drift rather than shrinking the estate. The date is the review_by both budget files already carry, so it needs no new calendar."
55
+ },
56
+ {
57
+ "by": "2027-02-10",
58
+ "tokens": 85000,
59
+ "why": "First real reduction. The rule-paths census shows 92.1% of rule weight sits in the auto tier and the top 20 files carry 35.5% of it, so a scoped tranche is the plausible mechanism - GATED on the paths-scoping consumer decision, which is a human call and may resolve against scoping entirely."
60
+ },
61
+ {
62
+ "by": "2027-05-10",
63
+ "tokens": 60000,
64
+ "why": "Second reduction. No mechanism is committed for this one; it exists so the destination keeps a date after the first tranche lands, and it is the milestone most likely to be revised on evidence."
65
+ }
66
+ ],
67
+ "on_miss": "A missed milestone is PUBLISHED - recorded here with the measured number and the reason - and never silently re-dated. Two consecutive misses mean the schedule was wrong or the destination is unreachable with the mechanisms available; either outcome is a decision to surface, not a date to move again.",
68
+ "revisit_if": "The paths-scoping decision resolves against scoping (removing the only named mechanism for the 85k milestone), or a measured reduction lands ahead of schedule and the remaining milestones are re-derived from it."
69
+ },
70
+ "reference_measurements": {
71
+ "measured_cold_start_median_tokens": 230930,
72
+ "c3_scope_dedup_reduction_pct": 38,
73
+ "c3_condition": "FIXTURE-ONLY as of 2026-07-31: the 38% was measured on a byte-identical two-scope fixture, and that condition is currently unreachable for every consumer because install.ts:2723/2725 stamp package:/source_path: into every installed rule while the in-repo projection stamps nothing. Aligning versions yields 0/110 twins, not 110/110. The figure is the mechanism's ceiling, NOT a realised saving, and this baseline therefore assumes no dedup. Reachability is the subject of road-to-dedup-reachability; run measure_scope_dedup for the live split.",
74
+ "_comment": "The measured median comes from Claude Code transcripts (deduped by message.id + requestId) and is context for the ratchet, never the gated number \u2014 it moves with the developer's own session history."
75
+ }
72
76
  }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Who is seated for a run, decided once and frozen.
3
+ *
4
+ * Selection today is config-static ask-all: every enabled member, every
5
+ * question, no per-question seating. This adds an OPTIONAL declaration of what
6
+ * a run wants from its seats, and computes the answer once.
7
+ *
8
+ * FAMILY DIVERSITY ONLY — no band ordering. `blocker: b-ladder-order-benchmark-spend`
9
+ * was resolved (b) on 2026-08-22: nothing in this tree establishes that a higher
10
+ * capability band reviews better, the archived benchmark substrate would have to
11
+ * be re-run to support it, and the council that would have decided otherwise had
12
+ * no quota. So `tier` keeps its single documented meaning — a capability rank
13
+ * read only as a chairman-selection input — and nothing here reads it. A seat
14
+ * declaration carries MISSION, never RANK.
15
+ *
16
+ * "Family" is the PROVIDER, not a capability band. For a council, diversity means
17
+ * independent providers: two Claude bands share a vendor, a training pipeline and
18
+ * a failure mode, so seating `opus` beside `sonnet` buys none of what a second
19
+ * opinion is for. (`MODEL_FAMILIES` in the orchestration-record hook enumerates
20
+ * Claude bands for a different purpose — token attribution — and is deliberately
21
+ * not reused here.)
22
+ *
23
+ * Absent declaration ⇒ today's behaviour, byte-identical. That is asserted by a
24
+ * test rather than intended.
25
+ */
26
+ import type { MemberConfig } from './config.js';
27
+
28
+ /** A member's provider family. The map key IS the provider in this config shape. */
29
+ export function familyOf(member: MemberConfig): string {
30
+ return member.name;
31
+ }
32
+
33
+ /**
34
+ * Read the optional `seat_constraints:` block out of a raw council config.
35
+ *
36
+ * DELIBERATELY NOT A FIELD ON `CouncilConfig`, and the reason is a ratchet
37
+ * rather than a design preference. `config.ts` is 2,205 lines — 705 over the
38
+ * 1,500-line source-size line — and `check_source_size_budget` counts only the
39
+ * excess above that line, so the 39 lines this feature first added there were a
40
+ * straight +39 against a shrink-only baseline. The gate's own message is
41
+ * explicit that raising the baseline is "a defect, not a fix". Extraction is the
42
+ * sanctioned repair, and this file is 169 lines, so the same code costs nothing
43
+ * here.
44
+ *
45
+ * THE TRADE-OFF, stated rather than buried: validation moves from LOAD time to
46
+ * READ time. A malformed `min_families` no longer fails the moment the config
47
+ * is parsed; it fails when a caller asks for the constraints. Today that
48
+ * difference is unobservable — Phase 2 ships the DECLARATION and no caller
49
+ * reads it yet — but it is a real change in when a bad config is caught, and
50
+ * whoever wires the first caller should call this at run start so the failure
51
+ * lands before any seat is used.
52
+ *
53
+ * Fails CLOSED on a malformed value rather than falling back to absent: a run
54
+ * that declared a diversity floor and silently got none is the exact silent
55
+ * fallback the degradation line exists to prevent.
56
+ */
57
+ export function readSeatConstraints(raw: unknown, label = 'seat_constraints'): SeatConstraints {
58
+ if (raw === null || raw === undefined) return {};
59
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
60
+ throw new TypeError(`${label} must be a mapping (got ${typeof raw}).`);
61
+ }
62
+ const block = (raw as Record<string, unknown>)['seat_constraints'] ?? raw;
63
+ if (block === null || block === undefined) return {};
64
+ if (typeof block !== 'object' || Array.isArray(block)) {
65
+ throw new TypeError(`${label} must be a mapping (got ${typeof block}).`);
66
+ }
67
+ const mf = (block as Record<string, unknown>)['min_families'];
68
+ if (mf === null || mf === undefined) return {};
69
+ if (typeof mf !== 'number' || !Number.isInteger(mf) || mf < 1) {
70
+ throw new TypeError(`${label}.min_families must be an integer >= 1 when set (got ${String(mf)}).`);
71
+ }
72
+ return { min_families: mf };
73
+ }
74
+
75
+ export interface SeatConstraints {
76
+ /**
77
+ * Minimum distinct provider families the run wants seated.
78
+ *
79
+ * Absent (or 1) ⇒ no constraint and no degradation line: ask-all, unchanged.
80
+ */
81
+ readonly min_families?: number;
82
+ }
83
+
84
+ export interface Seating {
85
+ /** The seated members, in deterministic config order. */
86
+ readonly seats: readonly MemberConfig[];
87
+ /** Distinct families actually seated. */
88
+ readonly families: readonly string[];
89
+ /**
90
+ * Exactly ONE line when the constraint could not be met, `null` otherwise.
91
+ *
92
+ * One line, not a wall: a degradation nobody reads is the same as a silent
93
+ * fallback, and N warnings is how a reader learns to skip the header.
94
+ */
95
+ readonly degraded: string | null;
96
+ }
97
+
98
+ /**
99
+ * Compute the seating once.
100
+ *
101
+ * Deterministic and side-effect-free: the same members and constraints always
102
+ * produce the same seats, which is what makes freezing it meaningful. Seating
103
+ * that changed between round 1 and round 3 would silently invalidate the
104
+ * anonymised-peer-reply contract the council skill depends on — a "reviewer B"
105
+ * in round 3 that is not the "reviewer B" of round 1 makes every cross-reference
106
+ * in the synthesis wrong while looking fine.
107
+ */
108
+ export function resolveSeating(
109
+ members: readonly MemberConfig[],
110
+ constraints: SeatConstraints = {},
111
+ ): Seating {
112
+ const enabled = members.filter((m) => m.enabled);
113
+ const families = [...new Set(enabled.map(familyOf))];
114
+ const want = constraints.min_families ?? 1;
115
+
116
+ if (want <= 1 || families.length >= want) {
117
+ return { seats: enabled, families, degraded: null };
118
+ }
119
+ return {
120
+ seats: enabled,
121
+ families,
122
+ degraded:
123
+ `tier-degraded: asked for ${String(want)} model families, seated ${String(families.length)} ` +
124
+ `(${families.join(', ')}). The run proceeds with what is enabled — a council that refused to ` +
125
+ `convene on a diversity shortfall would fail closed on the common single-provider install, ` +
126
+ `which is worse than a stated shortfall.`,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Freeze a seating so a later config mutation cannot change it.
132
+ *
133
+ * Returns a structurally frozen snapshot. This is the mechanism behind "resolve
134
+ * once per run": the caller resolves at run start, freezes, and passes the
135
+ * snapshot down — so there is no path by which round 3 re-reads the config.
136
+ */
137
+ export function freezeSeating(s: Seating): Seating {
138
+ return Object.freeze({
139
+ seats: Object.freeze([...s.seats]),
140
+ families: Object.freeze([...s.families]),
141
+ degraded: s.degraded,
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Vendor sentinels and "latest in band" aliases — an id that cannot go stale.
147
+ *
148
+ * Read from the providers' own CLI surfaces on 2026-08-22, never from recall:
149
+ * `claude --help` documents 'fable', 'opus' and 'sonnet' as aliases for the
150
+ * latest model in their band, and `codex-default` is the openai transport's
151
+ * documented "let the CLI choose".
152
+ */
153
+ export const ADMISSIBLE_SENTINELS: ReadonlySet<string> = new Set([
154
+ 'codex-default',
155
+ 'fable',
156
+ 'opus',
157
+ 'sonnet',
158
+ 'haiku',
159
+ ]);
160
+
161
+ export interface AdmissibilityResult {
162
+ readonly admissible: boolean;
163
+ readonly reason: string | null;
164
+ }
165
+
166
+ /**
167
+ * Is this member's `model:` id from an admissible SOURCE?
168
+ *
169
+ * THE CRITERION AS WRITTEN COULD NOT BE IMPLEMENTED, and the deviation is
170
+ * recorded here rather than papered over. The roadmap step says a declared seat
171
+ * "resolves through the shipped `model_tier` bands; a model id that no tier maps
172
+ * to is rejected at config load". But `TIER_TO_CLAUDE_MODEL`
173
+ * (`_lib/model_tier.ts:36-41`) maps tiers onto CLAUDE aliases only — there is no
174
+ * entry any `gemini-*`, `grok-*` or `sonar-*` id could resolve through. Taken
175
+ * literally, that rule rejects every non-anthropic member in the shipped starter
176
+ * config, i.e. it fails closed on a valid config.
177
+ *
178
+ * What the step is actually protecting is stated in its own title: "including
179
+ * the host's own recall". The failure mode is a plausible-sounding model id
180
+ * TYPED FROM MEMORY, which is indistinguishable from a real one at review time
181
+ * and would silently undo the pin refresh. So admissibility is checked against
182
+ * the SOURCE of the id, over the two things this tree can actually verify:
183
+ *
184
+ * 1. a vendor sentinel or documented "latest in band" alias — cannot go stale,
185
+ * and read from the provider's own surface; or
186
+ * 2. a dated pin carrying a `verified_at` stamp — which records that a human
187
+ * looked at the provider's surface on a named date, and which
188
+ * `check_council_pin_staleness` ages out.
189
+ *
190
+ * An id that is neither is exactly the recall case: a bare dated id nobody
191
+ * recorded checking.
192
+ */
193
+ export function checkModelAdmissibility(member: MemberConfig): AdmissibilityResult {
194
+ const id = member.model.trim().toLowerCase();
195
+ if (ADMISSIBLE_SENTINELS.has(id)) {
196
+ return { admissible: true, reason: null };
197
+ }
198
+ if (member.verified_at !== null && member.verified_at !== undefined) {
199
+ return { admissible: true, reason: null };
200
+ }
201
+ return {
202
+ admissible: false,
203
+ reason:
204
+ `members.${member.name}.model is '${member.model}' — a dated id with no verified_at stamp. ` +
205
+ `An id must come from a source this tree can check: a vendor sentinel or documented ` +
206
+ `"latest in band" alias (${[...ADMISSIBLE_SENTINELS].join(', ')} — see src/scripts/_lib/model_tier.ts ` +
207
+ `for the tier vocabulary these bands come from), or a pin carrying verified_at: "YYYY-MM-DD" ` +
208
+ `recorded from the provider's own surface. A model id remembered rather than read is not an ` +
209
+ `admissible source, and is indistinguishable from a real one at review time.`,
210
+ };
211
+ }