@event4u/agent-config 13.0.0 → 14.0.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 +160 -333
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +224 -104
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2018 -1320
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1652 -892
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +2 -2
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gh_transient.ts +80 -0
  75. package/src/scripts/_lib/release_material.ts +16 -5
  76. package/src/scripts/_lib/review_independence.ts +111 -0
  77. package/src/scripts/_lib/session_register.ts +14 -0
  78. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  79. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  80. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  81. package/src/scripts/ai_council/_py_json.ts +96 -0
  82. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  83. package/src/scripts/ai_council/clients.ts +80 -143
  84. package/src/scripts/ai_council/config.ts +25 -11
  85. package/src/scripts/ai_council/probe_store.ts +196 -0
  86. package/src/scripts/ai_council/qualification.ts +425 -0
  87. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  88. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  89. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  90. package/src/scripts/capture_skill_catalogue.ts +264 -4
  91. package/src/scripts/check_evaluator_schema.ts +249 -0
  92. package/src/scripts/check_pr_ci_current.ts +409 -0
  93. package/src/scripts/check_review_schema.ts +195 -0
  94. package/src/scripts/check_rule_activation_census.ts +451 -0
  95. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  96. package/src/scripts/condense.ts +1 -1
  97. package/src/scripts/council_cli.ts +141 -191
  98. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  99. package/src/scripts/hook_manifest.yaml +72 -5
  100. package/src/scripts/hooks/concern_registry.ts +4 -0
  101. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  102. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  103. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  105. package/src/scripts/hooks_doctor.ts +112 -0
  106. package/src/scripts/interruption_report.ts +443 -0
  107. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  108. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  109. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  110. package/src/scripts/release.ts +38 -53
  111. package/src/scripts/rule_activation_census.ts +292 -0
  112. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  113. package/src/scripts/schemas/review-findings.schema.json +31 -0
  114. package/src/scripts/self_review_gate.ts +6 -0
  115. package/src/scripts/session_register_hook.ts +30 -1
  116. package/src/scripts/sync_pr_branch.ts +271 -0
  117. package/src/scripts/test_council_qualification.ts +213 -0
  118. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -1297,7 +1297,7 @@ function _CLAUDE_COMMANDS_DIR(): string {
1297
1297
  return path.join(MODULE_STATE.PROJECT_ROOT, '.claude', 'commands');
1298
1298
  }
1299
1299
 
1300
- function _parse_frontmatter(content: string): [Record<string, unknown>, string] {
1300
+ export function _parse_frontmatter(content: string): [Record<string, unknown>, string] {
1301
1301
  if (!content.startsWith('---')) {
1302
1302
  return [{}, content];
1303
1303
  }
@@ -48,7 +48,9 @@ import {
48
48
  PerplexityCliClient,
49
49
  XAIClient,
50
50
  XAICliClient,
51
+ CLI_CONSUMER_COUNCIL,
51
52
  load_anthropic_key,
53
+ load_cli_call_attribution,
52
54
  load_cli_call_counts,
53
55
  load_openai_key,
54
56
  quota_summary_line,
@@ -64,27 +66,38 @@ import { format_install_hints } from './ai_council/cli_hints.js';
64
66
  import {
65
67
  type AdvisorConfig,
66
68
  type CouncilConfig,
67
- type QuorumSetting,
68
69
  COUNCIL_CONFIG_ENV,
69
70
  COUNCIL_CONFIG_USER_GLOBAL_REL,
70
71
  CouncilConfigError,
71
72
  load_council_config,
72
73
  resolve_api_key,
74
+ resolve_cli_call_caps,
73
75
  resolve_config_path,
74
76
  } from './ai_council/config.js';
75
77
  import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
76
78
  import { InvalidModeError, resolve_global_mode } from './ai_council/modes.js';
77
79
  import { resolveMemberTransport } from './ai_council/transport_resolver.js';
78
- import { absentReasonFromCliFailure, classifyCliFailure, type AbsentReason } from './ai_council/transport_resolver.js';
79
- import { evaluateQuorum, SOLO_FLOOR_MIN_PRESENT, type QuorumResult } from './ai_council/quorum.js';
80
+ import { classifyCliFailure, type AbsentReason } from './ai_council/transport_resolver.js';
81
+ import { evaluateQuorum, type QuorumResult } from './ai_council/quorum.js';
80
82
  import {
81
- appendEvent,
82
- appendQuorumEvent,
83
- type QuorumAbsence,
84
- type QuorumCommand,
85
- type QuorumDispatch,
86
- type QuorumEventPhase,
87
- } from './ai_council/events_log.js';
83
+ _emitQuorumEvent,
84
+ _format_quorum_line,
85
+ _postRunQuorum,
86
+ _quorum_min_present_from,
87
+ _quorum_setting_from,
88
+ } from './ai_council/quorum_wiring.js';
89
+ import { formatQualificationLine, type MemberQualification } from './ai_council/qualification.js';
90
+ import {
91
+ absenceReasonFor,
92
+ attendanceGate,
93
+ countableSeats,
94
+ qualificationJson,
95
+ qualificationStatusLines,
96
+ qualifySeat,
97
+ recordRoundObservations,
98
+ } from './ai_council/qualification_wiring.js';
99
+ import { PROBE_STORE_RELPATH, readProbeStore, type ProbeStore } from './ai_council/probe_store.js';
100
+ import { appendEvent, type QuorumCommand, type QuorumDispatch } from './ai_council/events_log.js';
88
101
  import {
89
102
  type ClassificationResult,
90
103
  type SizeFitVerdict,
@@ -609,6 +622,18 @@ interface BuildMembersOptions {
609
622
  * state that differs per machine and per CI run.
610
623
  */
611
624
  environment_report?: EnvironmentReport | null;
625
+ /**
626
+ * Recorded provider observations. Supplied ⇒ presence is gated on the
627
+ * qualification ladder; omitted ⇒ not evaluated at all, and presence keeps
628
+ * its constructibility-only meaning. Why omission is the safe default:
629
+ * `qualification_wiring.ts` § The injection contract.
630
+ */
631
+ probe_store?: ProbeStore | null;
632
+ /**
633
+ * Out-param: the verdict per enabled member, in roster order. Empty from a
634
+ * caller that passed no store means "not evaluated" — NOT "all qualified".
635
+ */
636
+ qualification_out?: MemberQualification[] | null;
612
637
  }
613
638
 
614
639
  /**
@@ -632,172 +657,6 @@ function _member_api_key_present(cfg: Dict): boolean | undefined {
632
657
  }
633
658
  }
634
659
 
635
- /**
636
- * `ai['quorum']` as forwarded by `_synthesize_ai_council_block` (already
637
- * validated by `config.ts::_build_quorum` on that path) — or, for a caller
638
- * that hands `build_members` a hand-built dict bypassing the loader
639
- * entirely, a defensive re-check that fails soft to `'majority'` rather
640
- * than throwing. `build_members` is not the config-validation layer;
641
- * `evaluateQuorum` degrades an unrecognisable setting toward
642
- * `inconclusive`, never toward a false `concluded`, so failing soft here
643
- * costs nothing on the safety side.
644
- */
645
- function _quorum_setting_from(ai: Dict): QuorumSetting {
646
- const raw = ai['quorum'];
647
- if (raw === 'majority') {
648
- return 'majority';
649
- }
650
- if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
651
- return raw;
652
- }
653
- return 'majority';
654
- }
655
-
656
- /**
657
- * `ai['quorum_min_present']` with the same fail-soft posture as
658
- * `_quorum_setting_from`: the loader has already validated it on the normal
659
- * path, and a hand-built dict that bypasses the loader falls back to the
660
- * ADR-224 default rather than throwing.
661
- *
662
- * Failing soft costs nothing on the safety side here, and less than it does
663
- * for `quorum`: this value feeds a counterfactual boolean that no gate reads,
664
- * so the worst outcome of a wrong fallback is one mis-recorded telemetry line,
665
- * never a pass that concluded when it should not have.
666
- */
667
- function _quorum_min_present_from(ai: Dict): number {
668
- const raw = ai['quorum_min_present'];
669
- if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
670
- return raw;
671
- }
672
- return SOLO_FLOOR_MIN_PRESENT;
673
- }
674
-
675
- /**
676
- * Emit one `quorum_result` line for a resolved quorum.
677
- *
678
- * Both `evaluateQuorum` call sites route through here, tagged by `phase`, so
679
- * a solo-concluded pass is distinguishable from a full-attendance one in the
680
- * event log — the gap `road-to-always-on-orchestration`'s Risk 6 asserted was
681
- * already closed and was not.
682
- *
683
- * The emit sits at the call sites rather than inside `_postRunQuorum` on
684
- * purpose: that function is exported and unit-tested as a pure derivation,
685
- * and a write buried in it would make every test of it a log writer.
686
- *
687
- * `absent` entries arrive as the CLI's own `{member, reason, detail}` dicts;
688
- * `detail` is dropped by `appendQuorumEvent`'s input type, which cannot carry
689
- * free-form text at all.
690
- */
691
- function _emitQuorumEvent(
692
- phase: QuorumEventPhase,
693
- quorum: QuorumResult,
694
- absent: readonly Dict[],
695
- ctx: {
696
- command: QuorumCommand;
697
- dispatch?: QuorumDispatch;
698
- /** Enabled members before `--single` filtering; defaults to the roster that ran. */
699
- configuredTotal?: number;
700
- lens?: string;
701
- invocation?: string;
702
- /**
703
- * The ADR-224 shadow floor for this pass. `gateClass` is deliberately
704
- * NOT in this context object: no CLI path declares itself gate-class,
705
- * so a parameter here would be one no caller ever sets, and its
706
- * absence is what the `false` on every line honestly records.
707
- */
708
- minPresent?: number;
709
- },
710
- ): void {
711
- appendQuorumEvent({
712
- lens: ctx.lens ?? '',
713
- invocation: ctx.invocation ?? '',
714
- phase,
715
- command: ctx.command,
716
- dispatch: ctx.dispatch ?? 'full',
717
- configuredTotal: ctx.configuredTotal ?? quorum.total,
718
- result: quorum,
719
- ...(ctx.minPresent !== undefined ? { minPresent: ctx.minPresent } : {}),
720
- absent: absent.map(
721
- (a): QuorumAbsence => ({
722
- member: String(a['member'] ?? ''),
723
- reason: (a['reason'] as QuorumAbsence['reason']) ?? 'unavailable',
724
- }),
725
- ),
726
- });
727
- }
728
-
729
- /**
730
- * Re-derive quorum AFTER the provider calls, over what was actually USABLE —
731
- * never what merely CONSTRUCTED (M3, independent-review finding). `members`
732
- * and `responses` must be index-aligned (`consult()`'s own contract: one
733
- * `CouncilResponse` per member, from the final round); `members.length` is
734
- * the `n` — deliberately the roster that actually ran (post `--single` /
735
- * `--siblings` filtering), never the full config roster a filtered run
736
- * never attempted. A response carrying `.error` (or a missing response
737
- * entry altogether) counts as absent, classified through the same
738
- * `AbsentReason` vocabulary a static (pre-call) resolution failure uses, so
739
- * one artefact buckets every absence — construction-time and mid-flight —
740
- * under one taxonomy.
741
- */
742
- function _postRunQuorum(
743
- members: ExternalAIClient[],
744
- responses: CouncilResponse[],
745
- ai_cfg: Dict,
746
- ): { quorum: QuorumResult; absent: Dict[] } {
747
- const absent: Dict[] = [];
748
- let present = 0;
749
- for (let i = 0; i < members.length; i++) {
750
- const m = members[i] as ExternalAIClient;
751
- const r = responses[i];
752
- // Round 7 § 5.2 — attendance is a NON-EMPTY answer, not the absence of an
753
- // error. `!r.error` alone counted a member that returned zero bytes, and
754
- // that is not hypothetical: a 290 s curl timeout returned an empty body
755
- // with no error set in two sessions (`9fc9ba3e`, `4ac2f7ac`) and the
756
- // banner printed `2/2 present` — a single-voice verdict presented as
757
- // convergence, on a paid run. An empty answer contributes nothing to a
758
- // quorum by definition, whatever the transport thought of it.
759
- if (r !== undefined && !r.error && r.text.trim() !== '') {
760
- present += 1;
761
- continue;
762
- }
763
- const raw = r?.error ?? (r !== undefined && r.text.trim() === '' ? 'empty response body' : 'no response');
764
- const failure = classifyCliFailure(raw);
765
- absent.push({
766
- member: m.name,
767
- reason: absentReasonFromCliFailure(failure) ?? 'unavailable',
768
- detail: raw,
769
- });
770
- }
771
- return { quorum: evaluateQuorum(members.length, present, _quorum_setting_from(ai_cfg)), absent };
772
- }
773
-
774
- /**
775
- * One-line k-of-n banner, mirrored from `orchestrator.ts::_render_quorum_line`
776
- * but for the CLI's own stdout stream rather than the rendered report —
777
- * "attendance is telemetry, never a silent drop" applies to the estimate
778
- * preview too, not only to a completed pass.
779
- */
780
- function _format_quorum_line(q: QuorumResult, phase?: QuorumEventPhase): string {
781
- // The phase tag is not decoration. A degraded run prints attendance TWICE —
782
- // the pre-run reading before the estimate table, the post-run reading after
783
- // the consult — and the two disagree by construction: `2/2 present …
784
- // concluded` then `0/2 present … INCONCLUSIVE`. Same prefix, opposite
785
- // content, and until 2026-08-12 neither line said which was which, so an
786
- // operator skimming (or anything grepping `council:quorum ·`) could take the
787
- // stale one. The event log had solved this from the start with an explicit
788
- // `phase` field; stdout had not. Optional, because the estimate path prints
789
- // exactly one line and a tag there would claim a distinction it does not make.
790
- const tag = phase === undefined ? '' : `${phase === 'pre_run' ? 'before the run' : 'after the run'} · `;
791
- const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
792
- // Round 7 § 5.3 — a degraded pass says so. `1/2 present, needed 1 —
793
- // concluded` is literally true and reads as agreement; with a threshold of 1
794
- // a solo answer concludes, and nothing in the line distinguishes "both
795
- // members agreed" from "one member answered". The counts were always there;
796
- // what was missing is the word that stops a reader inferring convergence.
797
- const degraded =
798
- q.present < q.total ? ` ⚠️ DEGRADED — ${String(q.total - q.present)} member(s) did not answer; this is not convergence.` : '';
799
- return `council:quorum · ${tag}${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${degraded}`;
800
- }
801
660
 
802
661
  function build_members(settings: Dict, opts: BuildMembersOptions = {}): ExternalAIClient[] {
803
662
  const invocation_mode = opts.invocation_mode ?? null;
@@ -805,6 +664,9 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
805
664
  const siblings_overrides = opts.siblings_overrides ?? null;
806
665
  const skipped = opts.skipped ?? null;
807
666
  const quorum_out = opts.quorum_out ?? null;
667
+ const probe_store = opts.probe_store ?? null;
668
+ const qualification_out = opts.qualification_out ?? null;
669
+ const qualifications: MemberQualification[] = [];
808
670
  // Read-only, per-process-memoised (`detectEnvironment` caches the
809
671
  // no-argument call) — cheap to call once per `build_members` invocation.
810
672
  // A caller may inject a fixed report (tests only); production leaves
@@ -837,10 +699,16 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
837
699
  // dropped the configured default for every caller that hands this
838
700
  // exported function a raw `.ai-council.yml` dict.
839
701
  const global_mode = resolve_global_mode(ai);
702
+ // ENFORCED cap source. This function accepts BOTH config shapes (see the
703
+ // `resolve_global_mode` note above), and for a RAW `.ai-council.yml` dict a
704
+ // commented-out `cli_call_budget:` leaves the map empty — the lookup below
705
+ // used to fall back to `null`, which `ask()` reads as uncapped while still
706
+ // booking. Resolution runs through `resolve_cli_call_caps`, the same
707
+ // authority `cmd_quota` uses, so uncapped is unreachable by omission.
840
708
  const cli_budget_cfg = _isDict(ai) ? ((ai['cli_call_budget'] as Dict) || {}) : {};
841
- const cli_caps = _isDict(cli_budget_cfg)
842
- ? ((cli_budget_cfg['max_calls_per_day'] as Dict) || {})
843
- : {};
709
+ const cli_caps = resolve_cli_call_caps(
710
+ _isDict(cli_budget_cfg) ? cli_budget_cfg['max_calls_per_day'] : undefined,
711
+ );
844
712
  const cli_warn_at = _isDict(cli_budget_cfg)
845
713
  ? _pyFloat(cli_budget_cfg['warn_at'] ?? 0.8, 0.8)
846
714
  : 0.8;
@@ -928,6 +796,20 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
928
796
  // same pattern.
929
797
  ...(api_key_present !== undefined ? { apiKeyPresent: api_key_present } : {}),
930
798
  });
799
+ // Qualification runs on the resolved transport, before any branch
800
+ // decides whether this seat constructs — ONE call site, because the
801
+ // branches differ in how a member is built, not in what would qualify
802
+ // it. `||` matches the construction path below (R2 finding 11).
803
+ if (probe_store !== null) {
804
+ qualifications.push(
805
+ qualifySeat(
806
+ name,
807
+ resolved,
808
+ (overrides[name] as string | undefined) || (cfg['model'] as string | undefined) || null,
809
+ probe_store,
810
+ ),
811
+ );
812
+ }
931
813
  if (name in siblings) {
932
814
  if (resolved.transport !== 'api') {
933
815
  throw new CouncilDisabledError(
@@ -1015,7 +897,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
1015
897
  members.push(
1016
898
  _construct_cli_member(name, model, {
1017
899
  binary: (cfg['binary'] as string | null) ?? null,
1018
- max_calls_per_day: (cli_caps[name] as number | undefined) ?? null,
900
+ max_calls_per_day: cli_caps[name] as number,
1019
901
  warn_at: cli_warn_at,
1020
902
  }),
1021
903
  );
@@ -1055,6 +937,26 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
1055
937
  );
1056
938
  }
1057
939
  }
940
+ // Phase 3 step 4 — presence is gated on qualification, and the roster is
941
+ // NOT. Dropping an unqualified seat from `total` would lower the threshold
942
+ // (`ceil(n/2)`) and make a short pass EASIER to conclude, which inverts the
943
+ // intent; withholding it from `present` is what makes the pass report being
944
+ // short. A seat already recorded absent is excluded here so it is not
945
+ // subtracted twice.
946
+ //
947
+ // Routed through `record_absent`, never subtracted separately — that keeps
948
+ // the event's `total - present == absent.length` invariant. The seat is
949
+ // still dispatched: see `qualification_wiring.ts::attendanceGate`.
950
+ const gate = attendanceGate(qualifications, new Set(absent_entries.map((e) => String(e['member']))));
951
+ for (const q of gate.toRecordAbsent) {
952
+ record_absent({ member: q.name, reason: absenceReasonFor(q), detail: formatQualificationLine(q) });
953
+ }
954
+ if (gate.noticeLine !== null) {
955
+ _stderr(`${gate.noticeLine}\n`);
956
+ }
957
+ if (qualification_out !== null) {
958
+ qualification_out.push(...qualifications);
959
+ }
1058
960
  if (quorum_out !== null) {
1059
961
  const present = total_enabled - absent_count;
1060
962
  quorum_out.result = evaluateQuorum(total_enabled, present, _quorum_setting_from(ai));
@@ -1239,7 +1141,13 @@ function _construct_cli_member(
1239
1141
  const warn_at = opts.warn_at ?? 0.8;
1240
1142
  if (name in _CLI_FACTORY) {
1241
1143
  const [cls, default_model] = _CLI_FACTORY[name] as [
1242
- new (opts: { model: string; binary?: string | null; max_calls_per_day?: number | null; warn_at?: number }) => CliClient,
1144
+ new (opts: {
1145
+ model: string;
1146
+ binary?: string | null;
1147
+ max_calls_per_day?: number | null;
1148
+ warn_at?: number;
1149
+ consumer?: string;
1150
+ }) => CliClient,
1243
1151
  string,
1244
1152
  string,
1245
1153
  ];
@@ -1248,6 +1156,9 @@ function _construct_cli_member(
1248
1156
  binary,
1249
1157
  max_calls_per_day,
1250
1158
  warn_at,
1159
+ // Declared at the construction site — the client cannot know its
1160
+ // caller. The council half of the two enumerated consumers.
1161
+ consumer: CLI_CONSUMER_COUNCIL,
1251
1162
  });
1252
1163
  }
1253
1164
  throw new CouncilDisabledError(
@@ -1836,6 +1747,7 @@ function cmd_estimate(
1836
1747
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
1837
1748
  skipped,
1838
1749
  quorum_out,
1750
+ probe_store: readProbeStore(REPO_ROOT),
1839
1751
  // A cost preview spends nothing and runs no pass. The line is
1840
1752
  // still written — tagged, so a consumer excludes it deliberately
1841
1753
  // instead of never learning it was there.
@@ -2401,6 +2313,14 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
2401
2313
  // because only one of the two is fixed by writing a config.
2402
2314
  const configured = cfg !== null && cfg.enabled && enabledMembers.length > 0;
2403
2315
 
2316
+ // `configured` above is a boolean off the config file — exactly what let a
2317
+ // dead seat report as healthy. It stays (it answers a real question) but is
2318
+ // no longer the only thing this command says.
2319
+ const probeStore = readProbeStore(REPO_ROOT);
2320
+ const qualifications = enabledMembers.map(([name, m]) =>
2321
+ qualifySeat(name, _resolvedTransportFor(name, m), m.model, probeStore),
2322
+ );
2323
+
2404
2324
  if (_getattr(args, 'json', false)) {
2405
2325
  _stdout(
2406
2326
  `${JSON.stringify({
@@ -2427,8 +2347,14 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
2427
2347
  }),
2428
2348
  ),
2429
2349
  ignored_transport_keys: cfg?.ignored_transport_keys ?? [],
2350
+ qualification: qualificationJson(qualifications),
2351
+ qualified_members: countableSeats(qualifications),
2430
2352
  parse_error,
2353
+ // Scoped to CONFIG (ADR-104); the probe read is named beside
2354
+ // it — `qualification_wiring.ts` § What the status surface consulted.
2355
+ project_tree_consulted_for_config: false,
2431
2356
  project_tree_consulted: false,
2357
+ probe_store_path: PROBE_STORE_RELPATH,
2432
2358
  })}\n`,
2433
2359
  );
2434
2360
  return 0;
@@ -2458,6 +2384,11 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
2458
2384
  const detail = t.available ? t.transport : `unavailable — ${t.reason ?? 'no usable transport'}`;
2459
2385
  _stdout(` transport ${name}: ${detail}${billing}\n`);
2460
2386
  }
2387
+ // The four-value verdict per seat, then the advice — TWO warnings, so
2388
+ // an `unavailable` seat is not told to re-run (R2 finding 12).
2389
+ for (const line of qualificationStatusLines(qualifications)) {
2390
+ _stdout(`${line}\n`);
2391
+ }
2461
2392
  if (cfg.ignored_transport_keys.length > 0) {
2462
2393
  _stdout('\n');
2463
2394
  _stdout(' Ignored transport keys — transport is resolved, not configured:\n');
@@ -2468,7 +2399,7 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
2468
2399
  }
2469
2400
  }
2470
2401
  _stdout('\n');
2471
- _stdout(' The project tree is NEVER consulted (ADR-104). No project file —\n');
2402
+ _stdout(' The project tree is NEVER consulted FOR CONFIG (ADR-104). No project file —\n');
2472
2403
  _stdout(' `.agent-settings.yml` included — indicates council availability in\n');
2473
2404
  _stdout(' either direction. Do not infer it from one; run this instead.\n');
2474
2405
  if (!configured) {
@@ -2559,6 +2490,7 @@ function cmd_run(
2559
2490
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
2560
2491
  skipped,
2561
2492
  quorum_out,
2493
+ probe_store: readProbeStore(REPO_ROOT),
2562
2494
  });
2563
2495
  }
2564
2496
  // Measured, never assumed: `_apply_solo_dispatch` escalates back to the
@@ -2773,6 +2705,7 @@ function cmd_run(
2773
2705
  const post_run = _postRunQuorum(members, responses, ai_cfg);
2774
2706
  quorum_out.result = post_run.quorum;
2775
2707
  skipped.push(...post_run.absent);
2708
+ recordRoundObservations(REPO_ROOT, members, responses, classifyCliFailure);
2776
2709
  // Attendance telemetry for the reading that actually decided the pass.
2777
2710
  // `post_run.absent` is the mid-flight set only; the construction-time
2778
2711
  // skips already went out under `phase: 'pre_run'` from `build_members`,
@@ -3092,6 +3025,7 @@ function cmd_debate(
3092
3025
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
3093
3026
  skipped,
3094
3027
  quorum_out,
3028
+ probe_store: readProbeStore(REPO_ROOT),
3095
3029
  command: 'debate',
3096
3030
  });
3097
3031
  }
@@ -3305,6 +3239,12 @@ function cmd_debate(
3305
3239
  throw exc;
3306
3240
  }
3307
3241
 
3242
+ // Debate consumes qualification, so it produces observations too —
3243
+ // otherwise a debate-only operator's seats stay `unknown` forever
3244
+ // (R2 finding 14). Not a post-run quorum: recording and re-deriving are
3245
+ // different jobs, and the debate path deliberately has no quorum.
3246
+ recordRoundObservations(REPO_ROOT, members, all_rounds[all_rounds.length - 1] ?? [], classifyCliFailure);
3247
+
3308
3248
  let actual_total = 0.0;
3309
3249
  for (const rnd of [...all_rounds, restate_responses]) {
3310
3250
  for (const r of rnd) {
@@ -3450,7 +3390,13 @@ function cmd_quota(args: Args, opts: { settings?: Dict | null } = {}): number {
3450
3390
  const s = opts.settings !== undefined && opts.settings !== null ? opts.settings : load_settings();
3451
3391
  const ai_cfg = _isDict(s) ? ((s['ai_council'] as Dict) || {}) : {};
3452
3392
  const cli_budget_cfg = _isDict(ai_cfg) ? ((ai_cfg['cli_call_budget'] as Dict) || {}) : {};
3453
- const caps = _isDict(cli_budget_cfg) ? ((cli_budget_cfg['max_calls_per_day'] as Dict) || {}) : {};
3393
+ // REPORTED cap source — the same authority the gate uses. It used to read
3394
+ // the settings mapping directly and print "no providers have a configured
3395
+ // cap", which was misleading: the gate seeds a default for every provider,
3396
+ // so an operator reading "no cap" was capped and already booked past it.
3397
+ const caps = resolve_cli_call_caps(
3398
+ _isDict(cli_budget_cfg) ? cli_budget_cfg['max_calls_per_day'] : undefined,
3399
+ );
3454
3400
  const warn_at = _isDict(cli_budget_cfg) ? _pyFloat(cli_budget_cfg['warn_at'] ?? 0.8, 0.8) : 0.8;
3455
3401
 
3456
3402
  if (_getattr<string | null>(args, 'reset', null)) {
@@ -3465,13 +3411,9 @@ function cmd_quota(args: Args, opts: { settings?: Dict | null } = {}): number {
3465
3411
  }
3466
3412
 
3467
3413
  const counts = load_cli_call_counts();
3468
- if (Object.keys(caps).length === 0) {
3469
- _stdout(
3470
- 'council:quota · no providers have a configured ' +
3471
- 'cli_call_budget.max_calls_per_day cap.\n',
3472
- );
3473
- return 0;
3474
- }
3414
+ const attribution = load_cli_call_attribution();
3415
+ // No empty-caps branch: the resolver seeds every provider, so there is no
3416
+ // configuration in which this command has nothing to report.
3475
3417
  for (const provider of _pySortedStr(Object.keys(caps))) {
3476
3418
  const limit = _pyInt(caps[provider]);
3477
3419
  const used = _pyInt(counts[provider] ?? 0, 0);
@@ -3482,7 +3424,15 @@ function cmd_quota(args: Args, opts: { settings?: Dict | null } = {}): number {
3482
3424
  } else if (ratio >= warn_at) {
3483
3425
  status = 'warn';
3484
3426
  }
3485
- _stdout(`council:quota · ${provider} · ${used}/${limit} · ${status}\n`);
3427
+ // Named when the sidecar knows them. An empty suffix is the honest
3428
+ // reading for a bucket booked before attribution existed — not a claim
3429
+ // that nobody spent it.
3430
+ const perConsumer = attribution[provider] ?? {};
3431
+ const consumers = _pySortedStr(Object.keys(perConsumer));
3432
+ const by = consumers.length > 0
3433
+ ? ` · by ${consumers.map((c) => `${c} ${_pyInt(perConsumer[c] ?? 0, 0)}`).join(' + ')}`
3434
+ : '';
3435
+ _stdout(`council:quota · ${provider} · ${used}/${limit} · ${status}${by}\n`);
3486
3436
  }
3487
3437
  return 0;
3488
3438
  }
@@ -685,12 +685,19 @@ function findingsSkeleton(args: {
685
685
  return [
686
686
  `# Findings: ${args.slug}`,
687
687
  `<!-- completion-review: v1 | reviewed: ${args.reviewedDate} | scope: ${args.scopeHash} | diff: ${args.headSha} | reviewer: r2-fresh-subagent-${args.slug} | prompt_hash: ${args.promptHash} -->`,
688
+ // Set at CREATION, never inferred later: an inferred type reads filename
689
+ // and location, which are exactly the signals that already fail to
690
+ // distinguish an input from a binding. This artifact binds a scope from
691
+ // its first byte, so `current-binding` is what it IS — an empty table is
692
+ // a RESULT, and a reviewer returning zero findings flips the type to
693
+ // `honest-null` in the same edit that replaces the table.
694
+ `<!-- evidence-type: v1 | type: current-binding | declared: ${args.reviewedDate} -->`,
688
695
  '',
689
696
  args.manifest,
690
697
  '',
691
698
  '| # | Severity | File:Line | Finding | Status | Reason/Ref |',
692
699
  '|---|----------|-----------|---------|--------|------------|',
693
- '<!-- reviewer fills the table; 0 findings => replace the table with the exact honest-null line per docs/contracts/plan-review-gates.md §2.3 -->',
700
+ '<!-- reviewer fills the table; 0 findings => replace the table with the exact honest-null line per docs/contracts/plan-review-gates.md §2.3 AND change the evidence-type to `honest-null` per docs/contracts/evidence-artifact-types.md §4 -->',
694
701
  '',
695
702
  ].join('\n');
696
703
  }
@@ -74,6 +74,21 @@ concerns:
74
74
  # Phase 5 — Tier-1 hook for the minimal-safe-diff rule. Counts unique
75
75
  # files touched per turn into agents/runtime/state/minimal-safe-diff.json and
76
76
  # flips warning=true past hooks.minimal_safe_diff.threshold.
77
+ # road-to-metric-loop-and-review-integrity Phase 4 — shipping-intent gate on
78
+ # diff VOLUME, complementary to `minimal-safe-diff` rather than a duplicate:
79
+ # that concern counts unique files touched PER TURN, this one counts changed
80
+ # lines on the branch AT A SHIP VERB. Threshold 1695 is the corrected p90 of
81
+ # this repository's own last 40 merged PRs, and the exclusion set (review-input
82
+ # snapshots, generated indexes, projection trees) is part of the metric's
83
+ # definition, not a tuning knob — the replay measured the naive metric
84
+ # double-counting the diff it was measuring. Warn-level first; escalation waits
85
+ # for one release of recorded firings. Evidence:
86
+ # agents/evidence/eval-findings/metric-loop-s04.md
87
+ ship-diff-volume:
88
+ script: src/scripts/hooks/ship_diff_volume_hook.ts
89
+ args: []
90
+ fail_closed: false
91
+ severity: advisory
77
92
  minimal-safe-diff:
78
93
  script: src/scripts/minimal_safe_diff_hook.ts
79
94
  args: []
@@ -474,6 +489,16 @@ concerns:
474
489
  args: []
475
490
  fail_closed: false
476
491
  severity: advisory
492
+ # road-to-stop-gate-honesty step 3.1 — skipped on a refusal RETRY.
493
+ # ARGUMENT, per concern as that step requires: the F2 fire gate is a
494
+ # once-per-session marker, and this concern's own header records that a
495
+ # second run "reads the marker and returns silently, without re-running the
496
+ # transcript scan". The retry therefore cannot change its artefact — the
497
+ # telemetry line was already written, or already suppressed, on the first
498
+ # Stop. Idempotence is the concern's own construction, not our assumption
499
+ # about it. The saving is the module load rather than a large read, which is
500
+ # the smaller of the two flags below it and is stated as such.
501
+ skip_on_refusal_retry: true
477
502
  # road-to-conformance-round5 Phase 3 — the FIRST concern that can refuse a
478
503
  # turn-END. Round 5 measured the reason: both blocking carriers reached zero
479
504
  # violations, neither advisory carrier did, and 19 language violations
@@ -557,6 +582,43 @@ concerns:
557
582
  args: []
558
583
  fail_closed: false
559
584
  severity: advisory
585
+ # road-to-user-out-of-the-loop Phase 0 Step 1 — the interruption ledger.
586
+ # Every Stop, classifies whether the turn that just ended put a synchronous
587
+ # contact to the user and appends one `{run_id, turn, kind, class, roadmap}`
588
+ # line to gitignored runtime state (agents/runtime/state/interruptions.jsonl,
589
+ # file shape after gate-metrics.jsonl). Three classes, not two: a hand-back
590
+ # ("das entscheidest Du", "your call") ends the turn and waits for the user
591
+ # exactly as a question does, so counting only `?` would report this
592
+ # package's own preferred shape as zero contacts.
593
+ #
594
+ # CAPTURE ONLY, and that is the point rather than caution: Phase 0's two
595
+ # pre-registered claims are baselines, and a baseline can only be read off a
596
+ # window no mechanism reacted to. It emits nothing, never warns, and always
597
+ # returns 0 — so it carries no src/config/hook-token-budget.json row, the
598
+ # same as subagent-ledger and orchestration-record.
599
+ #
600
+ # Reply-shape detection is IMPORTED from turn-end-gate (readTranscriptTail,
601
+ # finalParagraph, HANDBACK) rather than copied — a second copy would drift
602
+ # from the measured corpus's detector and the drift would land silently in
603
+ # the baseline. Dedupe on the turn ordinal, the same identity that hook
604
+ # derives, because a host may fire Stop twice for one turn. fail_closed: false.
605
+ interruption-ledger:
606
+ script: src/scripts/hooks/interruption_ledger_hook.ts
607
+ args: []
608
+ fail_closed: false
609
+ severity: advisory
610
+ # road-to-stop-gate-honesty step 3.1 — skipped on a refusal RETRY, and this
611
+ # is the flag with the real saving.
612
+ # ARGUMENT, per concern: `alreadyRecorded(ledgerPath, run_id, turn)` dedupes
613
+ # on the TURN ORDINAL, and a refusal retry is the same turn by construction
614
+ # — the gate's layer-2 marker is keyed on that same ordinal. So the ledger
615
+ # already writes nothing on the retry and the artefact is provably identical.
616
+ # What it does NOT already avoid is the cost: `main()` calls
617
+ # `readTranscriptTail` (up to 8 MB, walking the whole file) BEFORE reaching
618
+ # `alreadyRecorded`, so today every refused Stop pays a full transcript walk
619
+ # and then discards the result. That is the "pure duplicate cost" § D-3
620
+ # names, measured in the source rather than assumed.
621
+ skip_on_refusal_retry: true
560
622
  # road-to-subagent-lifecycle-integrity Phase 1 (Steps 2+3) — the subagent
561
623
  # lifecycle ledger. Binds ONLY the two new events; on every other slot it is
562
624
  # not listed at all. Appends `subagent_start` / `subagent_stop` lines to
@@ -665,22 +727,27 @@ roles:
665
727
  # is on it: a worker was handed a bounded slice by an orchestrator that
666
728
  # already chose the approach, so a routing pointer there advises nobody who
667
729
  # can act on it and costs the dispatch anyway.
668
- drop: [delegation-nudge, skill-route, end-review-nudge, council-availability, team-review-gate, self-repair, session-eol]
730
+ # `interruption-ledger` joins it on the first clause verbatim — "a worker
731
+ # never talks to the user". Its whole measurement is synchronous user
732
+ # contacts, so a worker turn that ends in a question to its orchestrator
733
+ # would be recorded as a contact that never reached a human, inflating the
734
+ # exact baseline Phase 0 pre-registers.
735
+ drop: [delegation-nudge, skill-route, end-review-nudge, council-availability, team-review-gate, self-repair, session-eol, interruption-ledger]
669
736
 
670
737
  platforms:
671
738
  augment:
672
739
  session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
673
740
  session_end: [chat-history, memory-learn, session-register]
674
741
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
675
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
742
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, ship-diff-volume, reread-guard]
676
743
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
677
744
 
678
745
  claude:
679
746
  session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
680
747
  session_end: [chat-history, memory-learn, session-register]
681
- stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, turn-end-gate, self-repair, session-register, session-eol]
748
+ stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, turn-end-gate, self-repair, session-register, session-eol, interruption-ledger]
682
749
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, delegation-nudge, skill-route, git-authorization, session-canary, self-repair, session-register]
683
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard, spawn-guard-shadow]
750
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, ship-diff-volume, reread-guard, spawn-guard-shadow]
684
751
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape, language-mirror]
685
752
  # `language-mirror` — round-5 § 6.1. `pre_compact` sets a pin-lost marker;
686
753
  # the `post_tool_use` entry above re-emits the pin exactly once while that
@@ -734,7 +801,7 @@ platforms:
734
801
  # still costs the dispatch, and its entire output is thrown away.
735
802
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
736
803
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
737
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard, spawn-guard-shadow]
804
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, ship-diff-volume, reread-guard, spawn-guard-shadow]
738
805
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
739
806
  # subagent-ledger IS bound here, unlike the two nudges above, and the
740
807
  # difference is the delivery channel rather than the platform: the nudges