@event4u/agent-config 12.1.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +164 -186
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/cost/report.md +23 -0
  5. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  6. package/dist/agent-src/commands/fix/ci.md +39 -1
  7. package/dist/agent-src/commands/pr/create.md +65 -4
  8. package/dist/agent-src/commands/roadmap/next.md +28 -5
  9. package/dist/agent-src/commands/work.md +16 -0
  10. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  11. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  12. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  13. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  14. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  15. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  16. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  17. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  18. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  19. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  20. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  21. package/dist/agent-src/rules/design-fidelity.md +1 -1
  22. package/dist/agent-src/rules/design-review-after-ui-write.md +36 -14
  23. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  24. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  25. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  26. package/dist/agent-src/rules/ui-audit-gate.md +34 -12
  27. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  28. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  29. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  30. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  31. package/dist/agent-src/scripts/update_roadmap_progress.ts +70 -4
  32. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  33. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  34. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  35. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  36. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  37. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  38. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  39. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  40. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  41. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  42. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  43. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  44. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  45. package/dist/agent-src/skills/roadmap-writing/SKILL.md +5 -5
  46. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  47. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  48. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  49. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  50. package/dist/agent-src/templates/roadmaps.md +43 -3
  51. package/dist/cli/registry.js +1 -0
  52. package/dist/cli/registry.js.map +1 -1
  53. package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-7PZ4FSZB.js} +368 -191
  54. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  55. package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-F3YUF7SA.js} +1 -1
  56. package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
  57. package/dist/cli-delegate/cmd_conformance.js +224 -104
  58. package/dist/cli-delegate/cmd_doctor.js +2 -2
  59. package/dist/cli-delegate/cmd_export.js +2 -2
  60. package/dist/cli-delegate/cmd_handoff.js +1 -1
  61. package/dist/cli-delegate/cmd_prune.js +2 -2
  62. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  63. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  64. package/dist/cli-delegate/cmd_sync.js +2 -2
  65. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  66. package/dist/cli-delegate/cmd_validate.js +2 -2
  67. package/dist/discovery/deprecation-report.md +1 -1
  68. package/dist/discovery/discovery-manifest.json +73 -30
  69. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  70. package/dist/discovery/discovery-manifest.summary.md +3 -3
  71. package/dist/discovery/dormancy-report.md +1 -1
  72. package/dist/discovery/orphan-report.md +1 -1
  73. package/dist/discovery/packs.json +7 -5
  74. package/dist/discovery/trust-report.md +3 -3
  75. package/dist/discovery/workspaces.json +6 -4
  76. package/dist/hooks/dispatch.js +2653 -1365
  77. package/dist/install/install.mjs +12 -4
  78. package/dist/mcp/registry-manifest.json +3 -3
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +2679 -887
  81. package/dist/router.json +1 -1
  82. package/dist/scripts/condense.js +1 -1
  83. package/dist/scripts/condense.js.map +1 -1
  84. package/dist/server/schemas/settings.js +2 -2
  85. package/dist/server/schemas/settings.js.map +1 -1
  86. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  87. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  88. package/dist/ui/index.html +1 -1
  89. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  90. package/docs/guidelines/design-handover-extraction.md +87 -0
  91. package/docs/guidelines/prompt-templates.md +24 -1
  92. package/package.json +1 -1
  93. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  94. package/src/agent-src/scripts/gate_execute.ts +395 -0
  95. package/src/agent-src/scripts/resume_probe.ts +422 -0
  96. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  97. package/src/agent-src/scripts/update_roadmap_progress.ts +70 -4
  98. package/src/config/ci-local-parity.yml +13 -0
  99. package/src/config/evaluator-budgets.json +9 -8
  100. package/src/config/gate-coverage.yml +140 -12
  101. package/src/config/gate-violation-baselines.json +9 -3
  102. package/src/config/hook-token-budget.json +8 -1
  103. package/src/config/rule-activation-census.json +41 -0
  104. package/src/config/rule-stub-ceilings.json +52 -0
  105. package/src/scripts/_dispatch.bash +4 -0
  106. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  107. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  108. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  109. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  110. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  111. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  112. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  113. package/src/scripts/_lib/gh_transient.ts +80 -0
  114. package/src/scripts/_lib/release_material.ts +16 -5
  115. package/src/scripts/_lib/review_independence.ts +111 -0
  116. package/src/scripts/_lib/session_register.ts +14 -0
  117. package/src/scripts/_lib/skill_catalogue.ts +278 -5
  118. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  119. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  120. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  121. package/src/scripts/ai_council/_py_json.ts +96 -0
  122. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  123. package/src/scripts/ai_council/clients.ts +101 -164
  124. package/src/scripts/ai_council/config.ts +25 -11
  125. package/src/scripts/ai_council/probe_store.ts +196 -0
  126. package/src/scripts/ai_council/qualification.ts +425 -0
  127. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  128. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  129. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  130. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  131. package/src/scripts/bench_ab_v2_run.ts +3 -42
  132. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  133. package/src/scripts/bench_ab_v2_search.ts +343 -0
  134. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  135. package/src/scripts/build_archive_index.ts +383 -0
  136. package/src/scripts/capture_skill_catalogue.ts +369 -4
  137. package/src/scripts/check_evaluator_schema.ts +249 -0
  138. package/src/scripts/check_pr_ci_current.ts +409 -0
  139. package/src/scripts/check_review_schema.ts +195 -0
  140. package/src/scripts/check_rule_activation_census.ts +451 -0
  141. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  142. package/src/scripts/check_source_size_budget.ts +428 -0
  143. package/src/scripts/check_suppression_hygiene.ts +5 -1
  144. package/src/scripts/condense.ts +1 -1
  145. package/src/scripts/cost/backfill_rates.mjs +298 -0
  146. package/src/scripts/cost/budget.mjs +12 -32
  147. package/src/scripts/cost_summary.ts +25 -0
  148. package/src/scripts/council_cli.ts +141 -191
  149. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  150. package/src/scripts/external_sources_denylist.json +1 -0
  151. package/src/scripts/hook_manifest.yaml +99 -6
  152. package/src/scripts/hooks/concern_registry.ts +6 -0
  153. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  154. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  155. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  156. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  157. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  158. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  159. package/src/scripts/hooks_doctor.ts +112 -0
  160. package/src/scripts/interruption_report.ts +443 -0
  161. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  162. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  163. package/src/scripts/lint_harvest_provenance.ts +35 -1
  164. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  165. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  166. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  167. package/src/scripts/lint_skill_router_head.ts +21 -0
  168. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  169. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  170. package/src/scripts/mcp_server/tools.ts +87 -0
  171. package/src/scripts/release.ts +43 -58
  172. package/src/scripts/routing_doctor.ts +50 -13
  173. package/src/scripts/rule_activation_census.ts +292 -0
  174. package/src/scripts/schemas/command.schema.json +1 -1
  175. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  176. package/src/scripts/schemas/review-findings.schema.json +31 -0
  177. package/src/scripts/schemas/skill.schema.json +18 -1
  178. package/src/scripts/self_review_gate.ts +6 -0
  179. package/src/scripts/session_register_hook.ts +30 -1
  180. package/src/scripts/sync_pr_branch.ts +271 -0
  181. package/src/scripts/test_council_qualification.ts +213 -0
  182. package/src/scripts/trigger_coverage.ts +224 -3
  183. package/src/server/schemas/settings.ts +2 -2
  184. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
  185. package/src/config/budget-routing.json +0 -7
@@ -78,6 +78,8 @@ export function _resetModelCeilingMemo(): void {
78
78
  _modelCeilingMemo.clear();
79
79
  }
80
80
  import * as user_global_paths from '../_lib/user_global_paths.js';
81
+ import { jsonDumpsIndent2 as _jsonDumpsIndent2 } from './_py_json.js';
82
+ import * as budget from './cli_call_budget.js';
81
83
  import { appendEvent } from './events_log.js';
82
84
 
83
85
  export const ANTHROPIC_KEY_FILENAME = 'anthropic.key';
@@ -453,6 +455,12 @@ interface CliClientOptions {
453
455
  max_calls_per_day?: number | null | undefined;
454
456
  warn_at?: number | undefined;
455
457
  cli_calls_path?: string | null | undefined;
458
+ /**
459
+ * Who is booking these calls — a `CLI_CONSUMER_*` constant, declared by the
460
+ * construction site because the client cannot know its caller. Omitted →
461
+ * `CLI_CONSUMER_UNKNOWN`, which is a finding rather than a default.
462
+ */
463
+ consumer?: string | undefined;
456
464
  }
457
465
 
458
466
  /**
@@ -987,6 +995,17 @@ export class PerplexityClient extends _OpenAICompatibleClient {
987
995
 
988
996
  export const CLI_CALLS_FILENAME = 'cli-calls.json';
989
997
 
998
+ // Durability, attribution and cap resolution live in `cli_call_budget.ts` — see
999
+ // its header for why the dependency runs one way only.
1000
+ export {
1001
+ CLI_CALLS_ATTRIBUTION_SUFFIX,
1002
+ CLI_CONSUMER_COUNCIL,
1003
+ CLI_CONSUMER_TEAM,
1004
+ CLI_CONSUMER_UNKNOWN,
1005
+ QUOTA_SOURCE_LOCAL_BUDGET,
1006
+ QUOTA_SOURCE_PROVIDER,
1007
+ } from './cli_call_budget.js';
1008
+
990
1009
  // Default subprocess timeout (seconds) for a single CLI call. Long enough for
991
1010
  // the largest frontier models to think; short enough to surface a hung
992
1011
  // subprocess without freezing the council run.
@@ -1056,18 +1075,39 @@ export function load_cli_call_counts(p: string | null = null): Record<string, nu
1056
1075
  return out;
1057
1076
  }
1058
1077
 
1059
- /** Increment today's call count for `provider`. Returns new total. */
1060
- export function record_cli_call(provider: string, p: string | null = null): number {
1078
+ /**
1079
+ * Return today's `provider → consumer → count` attribution. Fail-soft: an
1080
+ * absent, stale, or malformed sidecar reads as `{}` and never gates a call.
1081
+ */
1082
+ export function load_cli_call_attribution(
1083
+ p: string | null = null,
1084
+ ): Record<string, Record<string, number>> {
1061
1085
  const target = p !== null ? p : _cliCallsStatePath();
1062
- const counts = load_cli_call_counts(target);
1063
- counts[provider] = (counts[provider] ?? 0) + 1;
1064
- fs.mkdirSync(path.dirname(target), { recursive: true });
1065
- fs.writeFileSync(
1066
- target,
1067
- _jsonDumpsIndent2({ date: _today_utc_iso(), counts }),
1068
- { encoding: 'utf-8' },
1069
- );
1070
- return counts[provider];
1086
+ return budget.readAttribution(target, _today_utc_iso());
1087
+ }
1088
+
1089
+ /**
1090
+ * Increment today's call count for `provider`. Returns the new total.
1091
+ *
1092
+ * `consumer` names who booked it and lands in the sidecar, never in the counter
1093
+ * the gate reads. It defaults to `CLI_CONSUMER_UNKNOWN` so an undeclared call
1094
+ * path is visible as a finding rather than attributed to whoever happens to be
1095
+ * first in the enumeration.
1096
+ */
1097
+ export function record_cli_call(
1098
+ provider: string,
1099
+ p: string | null = null,
1100
+ consumer: string = budget.CLI_CONSUMER_UNKNOWN,
1101
+ ): number {
1102
+ const target = p !== null ? p : _cliCallsStatePath();
1103
+ const today = _today_utc_iso();
1104
+ return budget.withStateLock(target, () => {
1105
+ const counts = load_cli_call_counts(target);
1106
+ counts[provider] = (counts[provider] ?? 0) + 1;
1107
+ budget.writeStateAtomically(target, _jsonDumpsIndent2({ date: today, counts }));
1108
+ budget.recordAttribution(target, provider, consumer, today, _jsonDumpsIndent2);
1109
+ return counts[provider] as number;
1110
+ });
1071
1111
  }
1072
1112
 
1073
1113
  /**
@@ -1082,57 +1122,29 @@ export function reset_cli_call_counts(
1082
1122
  p: string | null = null,
1083
1123
  ): Record<string, number> {
1084
1124
  const target = p !== null ? p : _cliCallsStatePath();
1085
- let counts = load_cli_call_counts(target);
1086
- if (provider === null) {
1087
- counts = {};
1088
- } else {
1089
- delete counts[provider];
1090
- }
1091
- fs.mkdirSync(path.dirname(target), { recursive: true });
1092
- fs.writeFileSync(
1093
- target,
1094
- _jsonDumpsIndent2({ date: _today_utc_iso(), counts }),
1095
- { encoding: 'utf-8' },
1096
- );
1097
- return counts;
1125
+ const today = _today_utc_iso();
1126
+ return budget.withStateLock(target, () => {
1127
+ let counts = load_cli_call_counts(target);
1128
+ if (provider === null) {
1129
+ counts = {};
1130
+ } else {
1131
+ delete counts[provider];
1132
+ }
1133
+ budget.writeStateAtomically(target, _jsonDumpsIndent2({ date: today, counts }));
1134
+ budget.resetAttribution(target, provider, today, _jsonDumpsIndent2);
1135
+ return counts;
1136
+ });
1098
1137
  }
1099
1138
 
1100
1139
  /**
1101
- * Build the pre-run quota summary line (step-8 P1, D1 + D4).
1102
- *
1103
- * Returns `[summary, warn_providers]` where `summary` is the formatted
1104
- * one-liner (empty string when no CLI member has a configured cap) and
1105
- * `warn_providers` is the subset whose `used / max_calls_per_day` ratio crossed
1106
- * `warn_at`. Uncapped providers (`max_calls_per_day is None`) are omitted from
1107
- * the summary entirely — they cannot exceed a threshold that does not exist.
1140
+ * Build the pre-run quota summary line (step-8 P1, D1 + D4). Formatting lives in
1141
+ * `cli_call_budget.ts`; this reads the counter and delegates.
1108
1142
  */
1109
1143
  export function quota_summary_line(
1110
1144
  clients: CliClient[],
1111
1145
  opts: { cli_calls_path?: string | null } = {},
1112
1146
  ): [string, string[]] {
1113
- const cliCallsPath = opts.cli_calls_path ?? null;
1114
- // Python: [c for c in clients if getattr(c, "max_calls_per_day", None)]
1115
- // → truthy check (None or 0 both falsy).
1116
- const capped = clients.filter((c) => _pyTruthy(_getattr(c, 'max_calls_per_day', null)));
1117
- if (capped.length === 0) {
1118
- return ['', []];
1119
- }
1120
- const counts = load_cli_call_counts(cliCallsPath);
1121
- const parts: string[] = [];
1122
- const warn: string[] = [];
1123
- for (const c of capped) {
1124
- const name = _getattr(c, 'name', '?') as string;
1125
- const used = _pyInt(counts[name] ?? 0);
1126
- const limit = _pyInt((c.max_calls_per_day as number));
1127
- parts.push(`${name} ${used}/${limit}`);
1128
- const ratio = limit > 0 ? used / limit : 0.0;
1129
- const warnAt = Number(_getattr(c, 'warn_at', 0.8));
1130
- if (ratio >= warnAt) {
1131
- warn.push(name);
1132
- }
1133
- }
1134
- const prefix = warn.length > 0 ? '⚠️ ' : '';
1135
- return [`${prefix}council:quota · ${parts.join(' · ')}`, warn];
1147
+ return budget.quotaSummaryLine(clients, load_cli_call_counts(opts.cli_calls_path ?? null));
1136
1148
  }
1137
1149
 
1138
1150
  /** Transport-seam result, shaped like a finished `subprocess.run`. */
@@ -1196,6 +1208,8 @@ export abstract class CliClient extends ExternalAIClient {
1196
1208
  warn_at: number;
1197
1209
  binary!: string;
1198
1210
  protected _cli_calls_path: string | null;
1211
+ /** Attribution label for every booking this client makes. */
1212
+ protected _consumer: string;
1199
1213
 
1200
1214
  static _AUTH_FAILURE_PATTERNS: readonly string[] = [
1201
1215
  'authentication',
@@ -1239,6 +1253,7 @@ export abstract class CliClient extends ExternalAIClient {
1239
1253
  this.max_calls_per_day = opts.max_calls_per_day ?? null;
1240
1254
  this.warn_at = opts.warn_at ?? 0.8;
1241
1255
  this._cli_calls_path = opts.cli_calls_path ?? null;
1256
+ this._consumer = opts.consumer ?? budget.CLI_CONSUMER_UNKNOWN;
1242
1257
  const binary = opts.binary ?? null;
1243
1258
  if (binary !== null) {
1244
1259
  this.binary = binary;
@@ -1287,7 +1302,7 @@ export abstract class CliClient extends ExternalAIClient {
1287
1302
  */
1288
1303
  protected _recordCallQuietly(): void {
1289
1304
  try {
1290
- record_cli_call(this.name, this._cli_calls_path);
1305
+ record_cli_call(this.name, this._cli_calls_path, this._consumer);
1291
1306
  } catch {
1292
1307
  // state-file write failure is non-fatal here.
1293
1308
  }
@@ -1441,6 +1456,9 @@ export abstract class CliClient extends ExternalAIClient {
1441
1456
  cli: true,
1442
1457
  cli_calls_used: used,
1443
1458
  cli_calls_max: this.max_calls_per_day,
1459
+ // Our counter refused before any spawn — nothing sent,
1460
+ // nothing booked for this call.
1461
+ quota_source: budget.QUOTA_SOURCE_LOCAL_BUDGET,
1444
1462
  },
1445
1463
  });
1446
1464
  }
@@ -1549,6 +1567,13 @@ export abstract class CliClient extends ExternalAIClient {
1549
1567
  cli: true,
1550
1568
  returncode: proc.returncode,
1551
1569
  stderr_tail: (proc.stderr || '').slice(-500),
1570
+ // The vendor refused us; the local-budget refusal above
1571
+ // returns before spawning. Opposite remedies, one error
1572
+ // string — so discriminate explicitly rather than leaving it
1573
+ // inferable from which metadata keys happen to be absent.
1574
+ ...(code === 'cli_quota_exhausted'
1575
+ ? { quota_source: budget.QUOTA_SOURCE_PROVIDER }
1576
+ : {}),
1552
1577
  },
1553
1578
  });
1554
1579
  }
@@ -1679,23 +1704,23 @@ export class AnthropicCliClient extends CliClient {
1679
1704
  // documented "disable all tools" value.
1680
1705
  //
1681
1706
  // It also repairs a live failure, which is how the grant was found
1682
- // rather than reasoned about. The spawn inherits the caller's cwd
1683
- // (`_runSubprocess` sets none, deliberately — the worker-role marker
1684
- // below depends on the vendor CLI loading this repo's hook chain),
1685
- // so invoking the council from inside a repository made the child
1686
- // load that project's own instructions AND every tool definition.
1687
- // In this package that overflowed the model's context window
1688
- // outright: `is_error: true`, `"the request is ~214331 tokens
1707
+ // rather than reasoned about. AT THE TIME (2026-08-12) the spawn
1708
+ // inherited the caller's cwd, so invoking the council from inside a
1709
+ // repository made the child load that project's own instructions AND
1710
+ // every tool definition. In this package that overflowed the context
1711
+ // window outright: `is_error: true`, `"the request is ~214331 tokens
1689
1712
  // (limit 200000) … the rest is system prompt, tool definitions"`,
1690
- // exit 1, empty stderr — so the member reported as unavailable with
1691
- // no diagnosable cause. Measured 2026-08-12: identical call from
1692
- // `/tmp` exit 0, from the worktree exit 1, and adding this one flag
1693
- // returns the worktree call to exit 0.
1713
+ // exit 1, empty stderr — the member reported unavailable with no
1714
+ // diagnosable cause. Identical call from `/tmp` exit 0, from the
1715
+ // worktree exit 1, and this one flag returns the worktree to exit 0.
1694
1716
  //
1695
- // Dropping the tools rather than neutralising the cwd is the
1696
- // narrower fix of the two: it leaves the worker-role hook chain
1697
- // intact and removes only the capability the role never had a use
1698
- // for. The context saving is a consequence, not the justification.
1717
+ // TENSE CORRECTED 2026-08-16. This block read "`_runSubprocess` sets
1718
+ // none, deliberately" and "dropping the tools RATHER THAN
1719
+ // neutralising the cwd" — both false since `_runSubprocess` set
1720
+ // `cwd: os.tmpdir()` for a DIFFERENT contamination (a member
1721
+ // answering this repo's `end-review-nudge`). Two repairs, one
1722
+ // cause, neither aware of the other, so a reader here concluded
1723
+ // the cwd is inherited. Both are live; this flag bounds what a member may DO.
1699
1724
  '--tools',
1700
1725
  '',
1701
1726
  '--append-system-prompt',
@@ -1836,10 +1861,10 @@ export class OpenAICliClient extends CliClient {
1836
1861
  `"The '${this.model}' model is not supported when using Codex with a ` +
1837
1862
  'ChatGPT account." The CLI publishes no list of models it DOES accept, ' +
1838
1863
  'so there is no supported set to offer here — the one configuration ' +
1839
- 'measured to work is no pin at all. Fix: remove the `model:` line from ' +
1840
- 'the openai member (or set it to `codex-default`) to use the CLI\'s own ' +
1841
- 'default. A pinned model is only reachable on the API transport ' +
1842
- '(`api_key_ref:` without `mode: cli`).',
1864
+ 'measured to work is no pin at all. Fix: set `model: codex-default` on ' +
1865
+ 'the openai member. Do NOT delete the `model:` line — an enabled member ' +
1866
+ 'with no model fails config load, so that takes EVERY provider to NOT ' +
1867
+ 'CONFIGURED. A pin is reachable only on the API transport.',
1843
1868
  };
1844
1869
  }
1845
1870
 
@@ -1985,8 +2010,8 @@ export class OpenAICliClient extends CliClient {
1985
2010
  ? {
1986
2011
  hint:
1987
2012
  'the codex subscription transport serves no explicitly pinned ' +
1988
- 'model measured so far — remove the `model:` line from the ' +
1989
- 'openai member to use the CLI default',
2013
+ 'model measured so far — set `model: codex-default` on the ' +
2014
+ 'openai member; deleting the line fails config load',
1990
2015
  }
1991
2016
  : {}),
1992
2017
  },
@@ -2719,94 +2744,6 @@ function _excMessage(exc: unknown): string {
2719
2744
  return String(exc);
2720
2745
  }
2721
2746
 
2722
- /** `json.dumps(obj, indent=2)` — default ensure_ascii=True, insertion-order keys. */
2723
- function _jsonDumpsIndent2(value: unknown): string {
2724
- return _jsonDumpsIndented(value, 2, 0);
2725
- }
2726
-
2727
- function _jsonDumpsIndented(value: unknown, indent: number, level: number): string {
2728
- if (value === null || value === undefined) {
2729
- return 'null';
2730
- }
2731
- switch (typeof value) {
2732
- case 'boolean':
2733
- return value ? 'true' : 'false';
2734
- case 'number':
2735
- return _pyJsonNumber(value);
2736
- case 'string':
2737
- return _pyJsonStringAscii(value);
2738
- case 'object':
2739
- break;
2740
- default:
2741
- throw new TypeError(`Object of type ${typeof value} is not JSON serializable`);
2742
- }
2743
- const pad = ' '.repeat(indent * (level + 1));
2744
- const closePad = ' '.repeat(indent * level);
2745
- if (Array.isArray(value)) {
2746
- if (value.length === 0) {
2747
- return '[]';
2748
- }
2749
- const items = value.map((v) => pad + _jsonDumpsIndented(v, indent, level + 1));
2750
- return `[\n${items.join(',\n')}\n${closePad}]`;
2751
- }
2752
- const obj = value as Record<string, unknown>;
2753
- const keys = Object.keys(obj);
2754
- if (keys.length === 0) {
2755
- return '{}';
2756
- }
2757
- const items = keys.map(
2758
- (k) => `${pad}${_pyJsonStringAscii(k)}: ${_jsonDumpsIndented(obj[k], indent, level + 1)}`,
2759
- );
2760
- return `{\n${items.join(',\n')}\n${closePad}}`;
2761
- }
2762
-
2763
- /** Render a number like Python `json.dumps` (int vs float; JS has one type). */
2764
- function _pyJsonNumber(n: number): string {
2765
- if (!Number.isFinite(n)) {
2766
- if (Number.isNaN(n)) {
2767
- return 'NaN';
2768
- }
2769
- return n > 0 ? 'Infinity' : '-Infinity';
2770
- }
2771
- return String(n);
2772
- }
2773
-
2774
- /** Escape a string like Python `json.dumps(..., ensure_ascii=True)` (default). */
2775
- function _pyJsonStringAscii(s: string): string {
2776
- let out = '"';
2777
- for (const ch of s) {
2778
- const code = ch.codePointAt(0) ?? 0;
2779
- if (ch === '"') {
2780
- out += '\\"';
2781
- } else if (ch === '\\') {
2782
- out += '\\\\';
2783
- } else if (ch === '\b') {
2784
- out += '\\b';
2785
- } else if (ch === '\f') {
2786
- out += '\\f';
2787
- } else if (ch === '\n') {
2788
- out += '\\n';
2789
- } else if (ch === '\r') {
2790
- out += '\\r';
2791
- } else if (ch === '\t') {
2792
- out += '\\t';
2793
- } else if (code < 0x20) {
2794
- out += `\\u${code.toString(16).padStart(4, '0')}`;
2795
- } else if (code < 0x7f) {
2796
- out += ch;
2797
- } else if (code <= 0xffff) {
2798
- out += `\\u${code.toString(16).padStart(4, '0')}`;
2799
- } else {
2800
- // Astral plane → surrogate pair, matching Python json.dumps default.
2801
- const c = code - 0x10000;
2802
- const hi = 0xd800 + (c >> 10);
2803
- const lo = 0xdc00 + (c & 0x3ff);
2804
- out += `\\u${hi.toString(16).padStart(4, '0')}\\u${lo.toString(16).padStart(4, '0')}`;
2805
- }
2806
- }
2807
- return out + '"';
2808
- }
2809
-
2810
2747
  /** Python `shutil.which(name)` — resolve an executable on PATH. */
2811
2748
  function _which(name: string): string | null {
2812
2749
  // Absolute / relative path with a separator: check directly (Python's which
@@ -76,6 +76,7 @@ import * as path from 'node:path';
76
76
  import { parse as parseYaml } from 'yaml';
77
77
 
78
78
  import * as user_global_paths from '../_lib/user_global_paths.js';
79
+ import * as budget from './cli_call_budget.js';
79
80
  // LAYERING CONSTRAINT, load-bearing: this is a VALUE import from quorum, while
80
81
  // `quorum.ts` imports `QuorumSetting` back from here. That back-edge is
81
82
  // `import type` and is erased at build, so there is no runtime cycle today.
@@ -1927,13 +1928,23 @@ function _build_member(
1927
1928
  * Generous per-provider guard for `mode: cli` / `mode: auto` members
1928
1929
  * (road-to-always-on-orchestration Phase 3.4). Before this default the map
1929
1930
  * shipped empty and an unlisted provider ran uncapped — a plan-quota guard
1930
- * that only existed if the user remembered to opt in. Every known provider
1931
- * now carries this floor unless a config entry overrides it; sized as a
1932
- * GUARD against silent quota exhaustion on an always-on pass, not a brake on
1933
- * normal use (see the template's own worked-example sizing for the
1934
- * reasoning behind a TIGHTER per-provider number).
1931
+ * that only existed if the user remembered to opt in.
1932
+ *
1933
+ * Re-exported from `cli_call_budget.ts`, which owns it: `build_members` and
1934
+ * `cmd_quota` both need the same number, and re-deriving it at either site is
1935
+ * how the reported cap and the enforced cap drift apart.
1935
1936
  */
1936
- const _DEFAULT_CLI_CALLS_PER_DAY = 50;
1937
+ export const DEFAULT_CLI_CALLS_PER_DAY = budget.DEFAULT_CLI_CALLS_PER_DAY;
1938
+
1939
+ /**
1940
+ * Resolve the per-provider daily cap map — the SINGLE authority, shared with the
1941
+ * gate and the report. Thin wrapper that supplies this module's provider set;
1942
+ * the contract (defaults on omission, lenient where this module is strict) is
1943
+ * documented on `budget.resolveCliCallCaps`.
1944
+ */
1945
+ export function resolve_cli_call_caps(raw: unknown): Record<string, number> {
1946
+ return budget.resolveCliCallCaps(raw, _VALID_PROVIDERS);
1947
+ }
1937
1948
 
1938
1949
  function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
1939
1950
  if (!_isDict(d)) {
@@ -1945,10 +1956,11 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
1945
1956
  '`cli_call_budget.max_calls_per_day` must be a mapping.',
1946
1957
  );
1947
1958
  }
1948
- const caps = new Map<string, number>();
1949
- for (const provider of _VALID_PROVIDERS) {
1950
- caps.set(provider, _DEFAULT_CLI_CALLS_PER_DAY);
1951
- }
1959
+ // Validate FIRST, then resolve. Splitting the two is what lets the strict
1960
+ // builder and the lenient `resolve_cli_call_caps` share one seeding
1961
+ // implementation without either weakening the other: every rejection below
1962
+ // still throws, and what survives is handed to the shared resolver rather
1963
+ // than seeded a second time here.
1952
1964
  for (const [provider, value] of Object.entries(raw_caps)) {
1953
1965
  if (!_VALID_PROVIDERS.has(provider)) {
1954
1966
  throw new CouncilConfigError(
@@ -1962,8 +1974,10 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
1962
1974
  `non-negative integer (got ${_pyRepr(value)}).`,
1963
1975
  );
1964
1976
  }
1965
- caps.set(provider, value);
1966
1977
  }
1978
+ const caps = new Map<string, number>(
1979
+ Object.entries(resolve_cli_call_caps(raw_caps)),
1980
+ );
1967
1981
  const warn_at_raw = _get(d, 'warn_at', 0.8);
1968
1982
  if (_isBool(warn_at_raw) || !_isNumber(warn_at_raw)) {
1969
1983
  throw new CouncilConfigError(
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The observation half of provider qualification (road-to-release-review-p0
3
+ * Phase 3).
4
+ *
5
+ * `qualification.ts` is pure and answers "given what has been observed, what
6
+ * may this seat claim". This module is the only thing that observes. It exists
7
+ * because the qualification ladder's strongest rung — has a real exchange with
8
+ * this provider ever succeeded — cannot be answered from configuration, and
9
+ * answering it with a fresh probe would mean spending money every time anyone
10
+ * asked whether the council was healthy.
11
+ *
12
+ * So nothing here probes. A council run that receives a non-empty answer from a
13
+ * seat has already performed the exchange; this records that it happened. The
14
+ * cost of the observation is zero because the observation is a by-product of
15
+ * work the operator asked for anyway, and a seat therefore decays out of
16
+ * `unknown` through normal use rather than through a maintenance ritual.
17
+ *
18
+ * **The store is deliberately runtime-local and gitignored.** `agents/runtime/`
19
+ * is not tracked, which has a consequence worth stating rather than
20
+ * discovering: a fresh worktree starts with no observations, so every seat
21
+ * reads `unknown` there until a run happens in it. That is the honest reading —
22
+ * a checkout that has never talked to a provider genuinely has no evidence that
23
+ * it can — and it is strictly better than the alternative of committing a
24
+ * machine-specific health claim into a shared tree, where it would be stale for
25
+ * everyone but the machine that wrote it.
26
+ *
27
+ * Corrupt or unreadable state is treated as "no observations", never as an
28
+ * error: the same tolerant-reader doctrine the settings surface follows. A
29
+ * status command that crashes because a JSON file has a stray byte is worse
30
+ * than one that reports `unknown`, which is what the absence of evidence
31
+ * actually means.
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+
37
+ import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
38
+ import type { CliFailureClass } from './transport_resolver.js';
39
+ import type { ProbeRecord } from './qualification.js';
40
+
41
+ /**
42
+ * The same kill-switch the council event log honours.
43
+ *
44
+ * Sharing it rather than minting a second name is deliberate: both write
45
+ * council observational state into the worktree, and the suite that needs one
46
+ * suppressed needs the other suppressed for the identical reason. The
47
+ * read-only witness in `tests/scripts/witness/` fails on ANY worktree path
48
+ * appearing while it runs — including a gitignored one — so a test that
49
+ * exercises `cmd_run` and silently dropped a probe row here would break that
50
+ * witness whenever sharding put the two files together. Measured on this
51
+ * branch: the first version of this module had no switch and wrote
52
+ * `agents/runtime/state/council-probes.json` during the council suite.
53
+ *
54
+ * The coupling has a real cost worth stating: an operator who suppresses the
55
+ * event log also stops accumulating observations, so seats stay `unknown`.
56
+ * That is the correct direction — a run whose telemetry is switched off did
57
+ * not record evidence, and pretending otherwise is the over-claim this whole
58
+ * module exists to remove.
59
+ */
60
+ const _KILL_SWITCH_ENV = 'AGENT_CONFIG_NO_EVENTS_LOG';
61
+
62
+ /** Repo-relative location. Runtime state, gitignored, never committed. */
63
+ export const PROBE_STORE_RELPATH = path.join('agents', 'runtime', 'state', 'council-probes.json');
64
+
65
+ export interface ProbeStore {
66
+ readonly schema: 1;
67
+ readonly members: Readonly<Record<string, ProbeRecord>>;
68
+ }
69
+
70
+ const EMPTY: ProbeStore = { schema: 1, members: {} };
71
+
72
+ /**
73
+ * The closed set a stored `outcome` may hold — `ok` plus every
74
+ * `CliFailureClass`. Duplicated as a runtime value because the type erases,
75
+ * and this is a trust boundary: the file is hand-editable.
76
+ */
77
+ const VALID_OUTCOMES: ReadonlySet<string> = new Set<'ok' | CliFailureClass>([
78
+ 'ok',
79
+ 'binary_missing',
80
+ 'auth_rejected',
81
+ 'cli_unsupported',
82
+ 'model_unservable',
83
+ 'timeout',
84
+ 'server_error',
85
+ 'quota_exhausted',
86
+ 'other',
87
+ ]);
88
+
89
+ export function probeStorePath(root: string): string {
90
+ return path.join(root, PROBE_STORE_RELPATH);
91
+ }
92
+
93
+ /**
94
+ * Read the store. Any failure — missing, unreadable, malformed, wrong schema —
95
+ * yields the empty store.
96
+ *
97
+ * Individual member entries are validated one by one rather than trusting the
98
+ * object wholesale: a single corrupt row must not discard the other seats'
99
+ * evidence, because that would silently downgrade healthy seats to `unknown`
100
+ * and the operator would have no way to tell the two causes apart.
101
+ */
102
+ export function readProbeStore(root: string): ProbeStore {
103
+ let raw: string;
104
+ try {
105
+ raw = fs.readFileSync(probeStorePath(root), 'utf8');
106
+ } catch {
107
+ return EMPTY;
108
+ }
109
+ let parsed: unknown;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ } catch {
113
+ return EMPTY;
114
+ }
115
+ if (typeof parsed !== 'object' || parsed === null) {
116
+ return EMPTY;
117
+ }
118
+ const obj = parsed as Record<string, unknown>;
119
+ if (obj['schema'] !== 1) {
120
+ return EMPTY;
121
+ }
122
+ const membersRaw = obj['members'];
123
+ if (typeof membersRaw !== 'object' || membersRaw === null) {
124
+ return EMPTY;
125
+ }
126
+ const members: Record<string, ProbeRecord> = {};
127
+ for (const [name, value] of Object.entries(membersRaw as Record<string, unknown>)) {
128
+ if (typeof value !== 'object' || value === null) {
129
+ continue;
130
+ }
131
+ const v = value as Record<string, unknown>;
132
+ const at = v['at'];
133
+ const outcome = v['outcome'];
134
+ if (typeof at !== 'string' || at === '' || typeof outcome !== 'string') {
135
+ continue;
136
+ }
137
+ // Validated against the closed set, not cast into it. A bare
138
+ // non-empty-string check let a corrupt or hand-edited value through,
139
+ // where `_liveProbe` read it as `!== 'ok'` and not impaired — i.e. a
140
+ // typo silently downgraded a healthy seat to `unavailable`
141
+ // (R2 finding 9). Dropping the row instead yields `unknown`, which is
142
+ // the honest reading of "this record is unreadable".
143
+ if (!VALID_OUTCOMES.has(outcome)) {
144
+ continue;
145
+ }
146
+ members[name] = { at, outcome: outcome as 'ok' | CliFailureClass };
147
+ }
148
+ return { schema: 1, members };
149
+ }
150
+
151
+ /**
152
+ * Record one member's outcome, merging into whatever is already stored.
153
+ *
154
+ * Best-effort by construction: a write failure is swallowed. Telemetry that can
155
+ * break a council run is worse than telemetry that occasionally misses a row —
156
+ * the miss costs one `unknown`, the throw costs the run.
157
+ */
158
+ export function recordProbe(
159
+ root: string,
160
+ name: string,
161
+ outcome: 'ok' | CliFailureClass,
162
+ at: string,
163
+ ): void {
164
+ recordProbes(root, [{ name, outcome, at }]);
165
+ }
166
+
167
+ /** One observation, as a caller collects them during a pass. */
168
+ export interface ProbeEntry {
169
+ readonly name: string;
170
+ readonly outcome: 'ok' | CliFailureClass;
171
+ readonly at: string;
172
+ }
173
+
174
+ /** Batch form — one read/write for a whole pass rather than one per member. */
175
+ export function recordProbes(root: string, entries: readonly ProbeEntry[]): void {
176
+ if (entries.length === 0 || isEnvKillSwitchActive(_KILL_SWITCH_ENV)) {
177
+ return;
178
+ }
179
+ try {
180
+ const current = readProbeStore(root);
181
+ const members: Record<string, ProbeRecord> = { ...current.members };
182
+ for (const e of entries) {
183
+ members[e.name] = { at: e.at, outcome: e.outcome };
184
+ }
185
+ const target = probeStorePath(root);
186
+ fs.mkdirSync(path.dirname(target), { recursive: true });
187
+ fs.writeFileSync(target, `${JSON.stringify({ schema: 1, members }, null, 2)}\n`, 'utf8');
188
+ } catch {
189
+ // Intentionally silent — see the docstring.
190
+ }
191
+ }
192
+
193
+ /** The record for one member, or null when nothing was ever observed. */
194
+ export function probeFor(store: ProbeStore, name: string): ProbeRecord | null {
195
+ return store.members[name] ?? null;
196
+ }