@event4u/agent-config 9.35.0 → 10.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +95 -142
  3. package/agents/templates/.ai-council.yml.example +31 -51
  4. package/dist/agent-src/commands/analyze/conformance.md +15 -4
  5. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  6. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  7. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  8. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  9. package/dist/agent-src/rules/session-canary.md +28 -0
  10. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  11. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  12. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  13. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  14. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  15. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  16. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  18. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  19. package/dist/agent-src/templates/AGENTS.md +1 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/{chunk-2TGMGLBL.js → chunk-2H5SE7DU.js} +35 -5
  22. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-3JUTT26I.js} +8 -3
  23. package/dist/cli-delegate/{chunk-ZAXH3ZWU.js → chunk-HJSKTMIF.js} +14 -13
  24. package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
  25. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  26. package/dist/cli-delegate/{chunk-H6F2CQFI.js → chunk-NX5AXSLD.js} +3 -3
  27. package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
  28. package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
  29. package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
  30. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  31. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  32. package/dist/cli-delegate/cmd_conformance.js +1038 -197
  33. package/dist/cli-delegate/cmd_doctor.js +4 -4
  34. package/dist/cli-delegate/cmd_explain.js +2 -2
  35. package/dist/cli-delegate/cmd_export.js +4 -4
  36. package/dist/cli-delegate/cmd_handoff.js +3 -3
  37. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  38. package/dist/cli-delegate/cmd_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  40. package/dist/cli-delegate/cmd_prune.js +4 -4
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  43. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  44. package/dist/cli-delegate/cmd_session_recycle.js +9 -2
  45. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  46. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  47. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  48. package/dist/cli-delegate/cmd_sync.js +4 -4
  49. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  50. package/dist/cli-delegate/cmd_update.js +1 -1
  51. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  52. package/dist/cli-delegate/cmd_validate.js +4 -4
  53. package/dist/cli-delegate/cmd_versions.js +1 -1
  54. package/dist/discovery/deprecation-report.md +1 -1
  55. package/dist/discovery/discovery-manifest.json +14 -14
  56. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  57. package/dist/discovery/discovery-manifest.summary.md +1 -1
  58. package/dist/discovery/dormancy-report.md +1 -1
  59. package/dist/discovery/orphan-report.md +1 -1
  60. package/dist/discovery/packs.json +2 -2
  61. package/dist/discovery/trust-report.md +1 -1
  62. package/dist/discovery/workspaces.json +2 -2
  63. package/dist/hooks/dispatch.js +654 -411
  64. package/dist/install/install.mjs +39 -29
  65. package/dist/mcp/registry-manifest.json +1 -1
  66. package/dist/mcp/server.json +2 -2
  67. package/dist/mcp/server.mjs +1580 -617
  68. package/dist/router.json +1 -1
  69. package/dist/scripts/_lib/agent_settings.js +39 -25
  70. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  71. package/dist/scripts/condense.js +15 -2
  72. package/dist/scripts/condense.js.map +1 -1
  73. package/dist/server/routes/wizard.js +23 -3
  74. package/dist/server/routes/wizard.js.map +1 -1
  75. package/dist/server/schemas/settings.js +11 -6
  76. package/dist/server/schemas/settings.js.map +1 -1
  77. package/docs/contracts/settings-classes.md +16 -9
  78. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  79. package/package.json +2 -2
  80. package/src/agent-src/templates/AGENTS.md +1 -1
  81. package/src/config/agent-settings.template.yml +38 -36
  82. package/src/config/ci-local-parity.yml +44 -0
  83. package/src/config/gate-coverage.yml +12 -0
  84. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  85. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  86. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  87. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  88. package/src/scripts/_lib/agent_settings.ts +39 -25
  89. package/src/scripts/_lib/environment_detector.ts +52 -1
  90. package/src/scripts/_lib/hook_settings.ts +79 -0
  91. package/src/scripts/_lib/ui_surface.ts +97 -0
  92. package/src/scripts/ai_council/config.ts +62 -24
  93. package/src/scripts/before_complete_hook.ts +39 -0
  94. package/src/scripts/capture_skill_catalogue.ts +483 -0
  95. package/src/scripts/check_bridge_derivation.ts +89 -3
  96. package/src/scripts/check_ci_local_parity.ts +74 -0
  97. package/src/scripts/check_references.ts +15 -1
  98. package/src/scripts/condense.ts +16 -2
  99. package/src/scripts/conformance_scan.ts +200 -9
  100. package/src/scripts/council_cli.ts +95 -3
  101. package/src/scripts/hook_manifest.yaml +30 -9
  102. package/src/scripts/hooks/block_no_verify.ts +82 -9
  103. package/src/scripts/hooks/concern_registry.ts +2 -0
  104. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
  106. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  107. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  108. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  109. package/src/scripts/probe_promissory_closing.ts +180 -0
  110. package/src/scripts/probe_session_canary.ts +180 -0
  111. package/src/scripts/report_consultation_rate.ts +358 -0
  112. package/src/scripts/report_skill_activation.ts +5 -1
  113. package/src/server/schemas/settings.ts +14 -13
  114. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  115. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  116. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  117. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -38,6 +38,27 @@ const HOOK = path.join(REPO, 'src', 'scripts', 'install-hooks.sh');
38
38
  /** Roots of the local closure. `ci` is the local mirror; the hook is what a push runs. */
39
39
  const LOCAL_ROOTS = ['ci', 'consistency'] as const;
40
40
 
41
+ /**
42
+ * Round 7 § Phase 3 — the third dimension, and why two were not enough.
43
+ *
44
+ * `task ci` INCLUDES `preflight` (`Taskfile.yml:87`), so the two roots above
45
+ * answer "does the local MIRROR match CI". The pass an agent is actually told to
46
+ * run before pushing is `task preflight`, a deliberate subset of `ci` — its own
47
+ * docstring excludes `check_enforcement_coverage` at a measured 30.7 s because
48
+ * "a pre-push gate that doubles the hook teaches people to skip it".
49
+ *
50
+ * That makes `CI ∩ ci \ preflight` invisible to this gate by construction, and it
51
+ * is exactly where round 7's measured "green locally, red remotely" cycles came
52
+ * from: the Thin-Root char cap, the ratchet living in the Node-Tests shard, and
53
+ * the derived-page freshness check.
54
+ *
55
+ * Reported, not gated. The subset is intentional, so failing on it would demand
56
+ * that `preflight` grow into `ci` — the opposite of what its author decided. What
57
+ * this dimension removes is the IMPLICATION that a green preflight means CI will
58
+ * be green.
59
+ */
60
+ const PREFLIGHT_ROOTS = ['preflight'] as const;
61
+
41
62
  export interface Declared {
42
63
  id: string;
43
64
  class: string;
@@ -197,6 +218,19 @@ export interface ParityReport {
197
218
  undeclared_ci_only: string[];
198
219
  undeclared_local_only: string[];
199
220
  stale_declarations: string[];
221
+ /** Round 7 § Phase 3 — `task preflight`'s own closure. */
222
+ preflight_gates: string[];
223
+ /**
224
+ * `CI ∩ local \ (preflight ∪ pre-push hook)` — gates a push's remote run
225
+ * enforces, the local mirror covers, and NOTHING a push runs locally reaches.
226
+ * Report-only: the measured size of "green locally, red remotely", not a
227
+ * failure.
228
+ *
229
+ * The hook's own closure is in the subtrahend per R2 finding 4: subtracting
230
+ * preflight alone counted gates the pre-push hook already runs, which cannot
231
+ * fail after a push. That read 221; the honest figure is 209.
232
+ */
233
+ ci_not_in_preflight: string[];
200
234
  }
201
235
 
202
236
  export function analyse(repo = REPO, manifest?: Manifest): ParityReport {
@@ -231,12 +265,38 @@ export function analyse(repo = REPO, manifest?: Manifest): ParityReport {
231
265
  ...[...declaredLocal].filter((g) => localGates.has(g) && ciGates.has(g)),
232
266
  ].sort();
233
267
 
268
+ // Round 7 § 3.1 — the third dimension. Derived the same way as the other two,
269
+ // so it cannot drift from them by construction.
270
+ //
271
+ // R2 finding 4 (medium): the first version subtracted only the `preflight`
272
+ // closure from `localGates`, and `localGates` includes every gate reachable
273
+ // from `install-hooks.sh` — which the PRE-PUSH hook runs. A gate that runs
274
+ // before the push lands cannot produce a "green locally, red remotely" cycle,
275
+ // so counting it inflated the number and mislabelled it, in a step whose own
276
+ // text calls the number "the deliverable, not an adjective". The subtrahend is
277
+ // therefore everything a push already runs: preflight PLUS the hook's own
278
+ // closure.
279
+ const preflightGates = local_closure(PREFLIGHT_ROOTS, tasks);
280
+ const prePush = new Set(preflightGates);
281
+ if (fs.existsSync(HOOK)) {
282
+ const hook = fs.readFileSync(HOOK, 'utf-8');
283
+ for (const g of extract_gates(hook)) prePush.add(g);
284
+ for (const t of extract_tasks(hook)) {
285
+ for (const g of local_closure([t], tasks)) prePush.add(g);
286
+ }
287
+ }
288
+ const ci_not_in_preflight = [...ciGates]
289
+ .filter((g) => localGates.has(g) && !prePush.has(g))
290
+ .sort();
291
+
234
292
  return {
235
293
  ci_gates: [...ciGates].sort(),
236
294
  local_gates: [...localGates].sort(),
237
295
  undeclared_ci_only,
238
296
  undeclared_local_only,
239
297
  stale_declarations,
298
+ preflight_gates: [...preflightGates].sort(),
299
+ ci_not_in_preflight,
240
300
  };
241
301
  }
242
302
 
@@ -292,6 +352,20 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
292
352
  for (const g of r.stale_declarations) process.stdout.write(` - ${g}\n`);
293
353
  }
294
354
 
355
+ // Round 7 § 3.2 — printed on EVERY path, pass or fail, because the number is
356
+ // the deliverable. A green parity verdict above says the local MIRROR matches
357
+ // CI; it says nothing about the pass a contributor is told to run, and reading
358
+ // the green line as if it did is the implication this block removes.
359
+ process.stdout.write(
360
+ `ℹ️ preflight: ${String(r.preflight_gates.length)} gate(s) of the ${String(r.local_gates.length)} local ones; ` +
361
+ `${String(r.ci_not_in_preflight.length)} CI-enforced gate(s) run NEITHER in \`task preflight\` NOR\n` +
362
+ ' anywhere else the pre-push hook reaches — i.e. the set that can only fail after\n' +
363
+ ' a push. `task ci` INCLUDES preflight, so the parity verdict above cannot see it.\n' +
364
+ ' Report-only: the subset is deliberate (see the preflight docstring). A green\n' +
365
+ ' preflight is therefore not a prediction that CI will be green.\n',
366
+ );
367
+ for (const g of r.ci_not_in_preflight) process.stdout.write(` · ${g}\n`);
368
+
295
369
  if (!failed) {
296
370
  process.stdout.write(
297
371
  `✅ CI ↔ local parity: ${String(r.ci_gates.length)} CI gate(s), ${String(r.local_gates.length)} local, ` +
@@ -57,7 +57,21 @@ function makeBrokenRef(
57
57
 
58
58
  const SCAN_DIRS = ['dist/agent-src', 'agents'];
59
59
  const SKIP_DIRS = [
60
- 'agents/roadmaps/archive', // archived roadmaps have historical refs
60
+ // Archived roadmaps have historical refs — a link to a file that existed when
61
+ // the plan was written is not drift, so the exclusion is right on the merits.
62
+ //
63
+ // ROUND 7, DOWNGRADE (2026-08-12): it also means this checker is the ONLY thing
64
+ // that would see a class of real rot, and cannot. Measured: **530 dead relative
65
+ // links across 147 of 466 archived roadmaps**. The cause is not history — it is
66
+ // that `archive_completed_roadmaps` moves a file one directory deeper and never
67
+ // re-depths its outgoing relative links, so a link that resolved before the move
68
+ // is broken BY the move. Two tools, one silence: the writer does not rewrite, and
69
+ // the only checker that would notice is scoped away from where it lands.
70
+ //
71
+ // Not repaired here: the fix is a bulk edit across 147 files plus a re-depth pass
72
+ // in the sweep, which is its own change. Recorded so the exclusion is read as a
73
+ // known gap with a number rather than as coverage.
74
+ 'agents/roadmaps/archive',
61
75
  'agents/runtime/council/sessions', // per-user audit trail (gitignored), captured provider output
62
76
  'agents/runtime/council/responses', // paired council output (gitignored), captured provider output
63
77
  'agents/runtime/council/questions', // design Q&A trail — forward-refs to planned artifacts
@@ -359,8 +359,18 @@ const _ALL_TOOLS: ReadonlySet<string> = new Set([
359
359
  ]);
360
360
  void _ALL_TOOLS;
361
361
 
362
- function _active_tools(): ReadonlySet<string> | null {
363
- const tools_file = path.join(MODULE_STATE.PROJECT_ROOT, 'agents', '.agent-tools.yml');
362
+ /**
363
+ * Tool ids selected by `agents/.agent-tools.yml` under `root`, or `null` when
364
+ * the file is absent / malformed / carries no `tools:` list — `null` means
365
+ * "all tools active", never "none".
366
+ *
367
+ * Root-parameterised and exported so a consumer auditing a checkout other than
368
+ * `MODULE_STATE.PROJECT_ROOT` (a gate run with `--root`) reads the selection
369
+ * this generator actually honours, instead of re-implementing these four
370
+ * fallbacks and drifting from them.
371
+ */
372
+ export function active_tools_at(root: string): ReadonlySet<string> | null {
373
+ const tools_file = path.join(root, 'agents', '.agent-tools.yml');
364
374
  if (!_exists(tools_file)) {
365
375
  return null;
366
376
  }
@@ -380,6 +390,10 @@ function _active_tools(): ReadonlySet<string> | null {
380
390
  return new Set(tools.filter((t): t is string => typeof t === 'string'));
381
391
  }
382
392
 
393
+ function _active_tools(): ReadonlySet<string> | null {
394
+ return active_tools_at(MODULE_STATE.PROJECT_ROOT);
395
+ }
396
+
383
397
  function _tool_active(tool_id: string): boolean {
384
398
  const active = _active_tools();
385
399
  return active === null ? true : active.has(tool_id);
@@ -41,6 +41,9 @@ import { isSyntheticPrompt } from "./_lib/prompt_shape.js";
41
41
  import { classifyAuthorization, type GitOp } from "./git_authorization_hook.js";
42
42
  import { BLOCK_OPS, commandOp } from "./hooks/block_unauthorized_git.js";
43
43
  import { isVacuousOutput, isCiPoll, pendingCount } from "./before_complete_hook.js";
44
+ // Round 7 § 1.5 — the SAME predicate `turn-end-gate` refuses on, imported rather
45
+ // than reimplemented, so the measured rate and the gate cannot disagree.
46
+ import { detectCompletionClaim } from "./hooks/turn_end_gate_hook.js";
44
47
  import { isEvaluationPrompt, isSelfScoped, preloadedVerdict } from "./hooks/evidence_independence.js";
45
48
 
46
49
  /**
@@ -57,7 +60,12 @@ import { isEvaluationPrompt, isSelfScoped, preloadedVerdict } from "./hooks/evid
57
60
  const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
58
61
 
59
62
  export interface Violation {
60
- check: "language-pin" | "git-authorization" | "vacuous-evidence" | "evidence-steering";
63
+ check:
64
+ | "language-pin"
65
+ | "git-authorization"
66
+ | "vacuous-evidence"
67
+ | "evidence-steering"
68
+ | "completion-claim";
61
69
  session: string;
62
70
  at: string;
63
71
  detail: string;
@@ -78,6 +86,17 @@ export interface SessionReport {
78
86
  user_turns: number;
79
87
  assistant_turns: number;
80
88
  violations: Violation[];
89
+ /**
90
+ * Round 7 § 6.2 — assistant turns that stood under a GERMAN pin, i.e. the only
91
+ * turns on which `language-pin` can fire at all. The rate was published over
92
+ * ALL assistant turns, which understates it by whatever fraction of a corpus is
93
+ * English-pinned: measured on round 7's own corpus, 1 655 of 2 354 (70 %), and
94
+ * the same 152 violations read 6.5 % on the old denominator and 9.2 % on this
95
+ * one — enough to move the band verdict from OUTSIDE to INSIDE.
96
+ */
97
+ de_pin_turns: number;
98
+ /** First entry timestamp, so a reader can era-split without a second pass. */
99
+ first_at: string;
81
100
  }
82
101
 
83
102
  /**
@@ -134,7 +153,7 @@ export const OBSERVED_BAND = { low: 9.1, high: 39.2 } as const;
134
153
  */
135
154
  export const BAND_MIN_TURNS = 1978;
136
155
 
137
- export type BandVerdict = "inside" | "outside" | "corpus-too-small";
156
+ export type BandVerdict = "inside" | "outside" | "corpus-too-small" | "era-spanning";
138
157
 
139
158
  /** Round-6 Phase 4.5 — the forward-capture record for one scan run. */
140
159
  export interface RateRecord {
@@ -144,7 +163,29 @@ export interface RateRecord {
144
163
  assistant_turns: number;
145
164
  language_pin: number;
146
165
  rate_pct: number;
147
- /** `outside` is the falsifier firing; `corpus-too-small` is not a reading. */
166
+ /**
167
+ * Round 7 § 6.2 / 6.1 — OPTIONAL, and not for convenience.
168
+ *
169
+ * `--record` appends one line per run to a series whose whole purpose is
170
+ * comparability over time, and every line written before round 7 carries none
171
+ * of these four. Declaring them required would make the type say something
172
+ * false about the records on disk, and the renderer already reads them
173
+ * defensively for exactly that reason. `analyse()` always populates them, so a
174
+ * fresh run is never partial — the optionality describes the archive, not the
175
+ * producer.
176
+ */
177
+ de_pin_turns?: number;
178
+ /** The same numerator over `de_pin_turns`. Reported beside `rate_pct`, never instead. */
179
+ rate_pct_de_pin?: number;
180
+ /** The corpus straddles a carrier landing, so no band verdict. */
181
+ era_spanning?: boolean;
182
+ /** Which carrier, when `era_spanning` — empty otherwise. */
183
+ era_reason?: string;
184
+ /**
185
+ * `outside` is the falsifier firing; `corpus-too-small` and `era-spanning` are
186
+ * not readings — the first is about corpus size, the second about the corpus
187
+ * containing two different systems.
188
+ */
148
189
  band: BandVerdict;
149
190
  /**
150
191
  * `null` when the scanned store is NOT this repo's own — `measureDelivered` is
@@ -157,7 +198,49 @@ export interface RateRecord {
157
198
  delivered_global_tokens: number | null;
158
199
  }
159
200
 
160
- export function bandVerdict(rate_pct: number, assistant_turns: number): BandVerdict {
201
+ /**
202
+ * Round 7 § 6.1 — dates on which a carrier for a mechanised check LANDED.
203
+ *
204
+ * A rate pooled across one of these is not a reading about behaviour; it is a
205
+ * weighted average of two different systems. The band's falsifier is "a fourth
206
+ * PROJECT outside the band", and on round 7's first run it fired on the project
207
+ * that helped DEFINE the band — because the corpus straddled the language-pin
208
+ * carrier. 23.1 % before it, 0.0 % after, 6.5 % pooled, and the pooled figure was
209
+ * announced as the falsifier.
210
+ *
211
+ * Declared as data with provenance rather than inferred: nothing in a transcript
212
+ * records which carriers were bound while it was written.
213
+ */
214
+ export const CARRIER_CHANGES: readonly { at: string; what: string }[] = [
215
+ { at: "2026-08-07T00:00:00Z", what: "language-pin bound on user_prompt_submit (round 5)" },
216
+ ];
217
+
218
+ /** True when the corpus spans a carrier change, i.e. two systems in one average. */
219
+ export function spansCarrierChange(firstAts: readonly string[]): { spans: boolean; what: string } {
220
+ const stamps = firstAts.filter((s) => s !== "").sort();
221
+ if (stamps.length === 0) {
222
+ return { spans: false, what: "" };
223
+ }
224
+ const lo = stamps[0] as string;
225
+ const hi = stamps[stamps.length - 1] as string;
226
+ for (const c of CARRIER_CHANGES) {
227
+ if (lo < c.at && hi >= c.at) {
228
+ return { spans: true, what: c.what };
229
+ }
230
+ }
231
+ return { spans: false, what: "" };
232
+ }
233
+
234
+ export function bandVerdict(
235
+ rate_pct: number,
236
+ assistant_turns: number,
237
+ opts: { spansCarrier?: boolean } = {},
238
+ ): BandVerdict {
239
+ // The era guard is checked FIRST and for the same reason the turn floor is: a
240
+ // verdict about corpus shape must not be reported as a verdict about behaviour.
241
+ if (opts.spansCarrier === true) {
242
+ return "era-spanning";
243
+ }
161
244
  if (assistant_turns < BAND_MIN_TURNS) {
162
245
  return "corpus-too-small";
163
246
  }
@@ -181,6 +264,7 @@ export const CHECK_IDS: readonly CheckId[] = [
181
264
  "git-authorization",
182
265
  "vacuous-evidence",
183
266
  "evidence-steering",
267
+ "completion-claim",
184
268
  ];
185
269
 
186
270
  /**
@@ -207,6 +291,12 @@ export const CHECK_MEANINGS: Record<CheckId, string> = {
207
291
  "evidence-steering":
208
292
  "A self-commissioned evaluation whose prompt pre-loaded its own verdict, " +
209
293
  "or a second self-scoped evaluation of the same subject within one turn.",
294
+ "completion-claim":
295
+ "An assistant turn claiming the work is complete while the last CI read in " +
296
+ "that session was NOT settled. Round 7 measured 14 instances by reading, " +
297
+ "each followed by the user handing the work back; this is the same " +
298
+ "predicate `turn-end-gate`'s completion detector refuses on, so the rate " +
299
+ "and the gate cannot disagree. A session that never polled CI can never hit.",
210
300
  };
211
301
 
212
302
  /**
@@ -317,7 +407,11 @@ export function isInjectedBody(text: string): boolean {
317
407
  * `API Error: 529 Overloaded` retry banners. Counting the harness as the model
318
408
  * inflates the very number this scan exists to report honestly.
319
409
  */
320
- const HARNESS_TEXT =
410
+ // Exported since round 7 § 6.3: `probe_session_canary` needs the SAME predicate,
411
+ // and its first version had its own copy that omitted `You've hit your` — which
412
+ // turned a spend-limit banner into a missed greeting and reported 24/28 for a
413
+ // corpus that is 25/28.
414
+ export const HARNESS_TEXT =
321
415
  /^(API Error:|Request (timed out|was aborted)|\[Request interrupted|Error: |Credit balance is too low|You've hit your|Prompt is too long)/i;
322
416
 
323
417
  /** Rough English-opener test for the language check (first prose line only). */
@@ -330,6 +424,8 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
330
424
  user_turns: 0,
331
425
  assistant_turns: 0,
332
426
  violations: [],
427
+ de_pin_turns: 0,
428
+ first_at: "",
333
429
  };
334
430
 
335
431
  let pinned: "de" | "en" | null = null;
@@ -343,6 +439,10 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
343
439
  // that split reproducible against a future corpus instead of re-argued.
344
440
  let turnsSincePrompt = 0;
345
441
  let compactionSincePrompt = false;
442
+ // Round 7 § 1.5 — session-scoped, deliberately NOT reset per prompt: the
443
+ // measured failure is a completion claim in a LATER turn than the poll.
444
+ let ciSeen = false;
445
+ let ciSettled = false;
346
446
 
347
447
  for (const line of lines) {
348
448
  if (!line.trim()) {
@@ -354,6 +454,12 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
354
454
  } catch {
355
455
  continue;
356
456
  }
457
+ // Round 7 § 6.1 — captured for the era guard. The FIRST timestamp in the
458
+ // file, not the file mtime: mtime moves when a session is resumed, which
459
+ // would put a July session in the August era.
460
+ if (report.first_at === "" && typeof entry["timestamp"] === "string") {
461
+ report.first_at = entry["timestamp"];
462
+ }
357
463
 
358
464
  // Tool results arrive as `user` entries carrying `toolUseResult`.
359
465
  if (entry["type"] === "user" && entry["toolUseResult"] !== undefined && pendingPoll !== null) {
@@ -370,6 +476,14 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
370
476
  detail: `CI poll read as settled without an in-flight observation: ${out.slice(0, 90)}`,
371
477
  });
372
478
  }
479
+ // Round 7 § 1.5 — the session-scoped CI-settle state the completion-claim
480
+ // check reads. R2 finding 3: this copied `pending === 0` AND the wrong
481
+ // comment claiming it matched the producer's discrimination. It did not —
482
+ // a stale all-pass table read as a settle and silenced the check. The
483
+ // in-flight witness is required here too, so the measurement and the gate
484
+ // agree on what a settle is.
485
+ ciSeen = true;
486
+ ciSettled = pending === 0 && sawPending && !isVacuousOutput(out);
373
487
  pendingPoll = null;
374
488
  continue;
375
489
  }
@@ -424,6 +538,26 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
424
538
  if (prose !== null) {
425
539
  report.assistant_turns += 1;
426
540
  turnsSincePrompt += 1;
541
+ // Round 7 § 6.2 — the denominator the language check can actually fire on.
542
+ // Counted here, beside the numerator, so the two cannot be derived from
543
+ // different populations later.
544
+ if (pinned === "de") {
545
+ report.de_pin_turns += 1;
546
+ }
547
+ // Round 7 § 1.5 — placed BEFORE the language block on purpose: that block
548
+ // `continue`s on a harness banner, and a check placed after it would be
549
+ // skipped for every such turn without saying so.
550
+ if (!HARNESS_TEXT.test((prose.split("\n").find((l) => l.trim()) ?? "").trim())) {
551
+ const cc = detectCompletionClaim(prose, { seen: ciSeen, settled: ciSettled });
552
+ if (cc !== null) {
553
+ report.violations.push({
554
+ check: "completion-claim",
555
+ session: sessionId,
556
+ at,
557
+ detail: `completion claimed over an unsettled CI read: "${cc.evidence.slice(0, 110)}"`,
558
+ });
559
+ }
560
+ }
427
561
  if (pinned === "de") {
428
562
  const first = prose.split("\n").find((l) => l.trim()) ?? "";
429
563
  if (HARNESS_TEXT.test(first.trim())) {
@@ -585,6 +719,7 @@ export function scanStore(store: string, limit: number): ScanReport {
585
719
  "git-authorization": 0,
586
720
  "vacuous-evidence": 0,
587
721
  "evidence-steering": 0,
722
+ "completion-claim": 0,
588
723
  };
589
724
  for (const s of per_session) {
590
725
  for (const v of s.violations) {
@@ -600,6 +735,14 @@ export function scanStore(store: string, limit: number): ScanReport {
600
735
  // band" — a rounding artefact announcing the declared falsifier.
601
736
  const rate_pct =
602
737
  assistant_turns === 0 ? 0 : Number(((100 * language_pin) / assistant_turns).toFixed(1));
738
+ // Round 7 § 6.2 — the second denominator, reported beside the first rather than
739
+ // instead of it: replacing the figure silently would break comparability with
740
+ // every recorded run, and the band's own three reference values were computed
741
+ // on the all-turns denominator.
742
+ const de_pin_turns = per_session.reduce((n, s) => n + s.de_pin_turns, 0);
743
+ const rate_pct_de_pin =
744
+ de_pin_turns === 0 ? 0 : Number(((100 * language_pin) / de_pin_turns).toFixed(1));
745
+ const era = spansCarrierChange(per_session.map((s) => s.first_at));
603
746
  const delivered = measureDelivered(
604
747
  path.join(REPO_ROOT, ".claude", "rules"),
605
748
  path.join(process.env["HOME"] ?? os.homedir(), ".claude", "rules"),
@@ -619,7 +762,11 @@ export function scanStore(store: string, limit: number): ScanReport {
619
762
  assistant_turns,
620
763
  language_pin,
621
764
  rate_pct,
622
- band: bandVerdict(rate_pct, assistant_turns),
765
+ de_pin_turns,
766
+ rate_pct_de_pin,
767
+ era_spanning: era.spans,
768
+ era_reason: era.what,
769
+ band: bandVerdict(rate_pct, assistant_turns, { spansCarrier: era.spans }),
623
770
  // Only attach the carrier figures when the rate and the carriers describe
624
771
  // the same project.
625
772
  delivered_project_tokens: ownStore ? delivered.project.tokens : null,
@@ -680,7 +827,36 @@ export function render(report: ScanReport): string {
680
827
  ` language-pin rate ${report.rate.rate_pct.toFixed(1)}% ` +
681
828
  `(${report.rate.language_pin} of ${report.rate.assistant_turns} assistant turns)`,
682
829
  );
683
- if (report.rate.band === "corpus-too-small") {
830
+ // Round 7 § 6.2 — both denominators, always. The band's three reference values
831
+ // were computed on the all-turns one, so that stays the headline; the second is
832
+ // the only one the check can actually fire on, and on round 7's own corpus the
833
+ // two differ by enough to move the verdict (6.5 % vs 9.2 %, band low 9.1 %).
834
+ // Optional-read, not `report.rate.x.toFixed(...)`: a `rate` block RECORDED
835
+ // before round 7 carries neither field, and `--record` exists to be read back
836
+ // over time. A renderer that throws on last month's line makes the series
837
+ // unreadable to prove a point about denominators.
838
+ const dePinTurns = report.rate.de_pin_turns;
839
+ const dePinRate = report.rate.rate_pct_de_pin;
840
+ if (typeof dePinTurns === 'number' && typeof dePinRate === 'number') {
841
+ lines.push(
842
+ ` ${dePinRate.toFixed(1)}% ` +
843
+ `(${report.rate.language_pin} of ${dePinTurns} turns under a GERMAN pin — the only` +
844
+ ' turns this check can fire on)',
845
+ );
846
+ lines.push(
847
+ ' The band (M5, n=3) was computed on the ALL-turns denominator, so the first figure',
848
+ );
849
+ lines.push(' is the comparable one and the second is the honest one. Both are printed.');
850
+ }
851
+ if (report.rate.band === "era-spanning") {
852
+ lines.push(
853
+ ` Not compared: the corpus SPANS a carrier landing — ${report.rate.era_reason || 'see CARRIER_CHANGES'}.`,
854
+ );
855
+ lines.push(' A rate pooled across it is a weighted average of two different systems, not');
856
+ lines.push(' a reading about behaviour. Round 7 measured 23.1% before and 0.0% after that');
857
+ lines.push(' exact carrier; the pooled 6.5% was announced as the falsifier firing.');
858
+ lines.push(' Split the window at the carrier date and read each era on its own.');
859
+ } else if (report.rate.band === "corpus-too-small") {
684
860
  lines.push(
685
861
  ` Not compared: ${report.rate.assistant_turns} turns is below the ${BAND_MIN_TURNS} of the`,
686
862
  );
@@ -702,8 +878,23 @@ export function render(report: ScanReport): string {
702
878
  lines.push(" as new. Store novelty is not project novelty.");
703
879
  }
704
880
  lines.push("");
705
- lines.push(" Scanned checks are exactly the mechanised ones. Ask-shape, session-canary,");
706
- lines.push(" promissory closings and checkbox batching are left as prose and NOT measured.");
881
+ // Round 7 § 6.4 — this footer used to lump four classes together as "NOT
882
+ // measured", and two of the four were wrong. Stated per class instead, because
883
+ // "not measured here" and "not measured anywhere" are different facts and only
884
+ // one of them is a gap.
885
+ lines.push(" Scanned checks are exactly the mechanised ones. What is NOT in the counts above:");
886
+ lines.push(" ask-shape (trailing free-text offer) — measured NOWHERE. ~11 findings in round 7's");
887
+ lines.push(" reading half; the discriminator needs judgement, so it stays advisory on purpose.");
888
+ lines.push(" session-canary (opening greeting) — not here, but PROBEABLE:");
889
+ lines.push(" `./scripts-run src/scripts/probe_session_canary`. Round 7: 25/28 sessions,");
890
+ lines.push(" 24/25 post-carrier. The per-TASK instance is undecidable — no task boundary");
891
+ lines.push(" is recorded in a transcript.");
892
+ lines.push(" promissory closings — not here, but ALREADY GATED: `turn-end-gate`");
893
+ lines.push(" ships a blocking `detectPromissory`. Round 7 probe: 1 of 120 hand-back turns");
894
+ lines.push(" (the 163 this line first printed was the retracted denominator — it counted");
895
+ lines.push(" synthetic user turns as hand-back closers).");
896
+ lines.push(" `./scripts-run src/scripts/probe_promissory_closing`.");
897
+ lines.push(" checkbox batching — measured NOWHERE. ~5 low-severity findings.");
707
898
  return lines.join("\n");
708
899
  }
709
900
 
@@ -749,11 +749,18 @@ function _postRunQuorum(
749
749
  for (let i = 0; i < members.length; i++) {
750
750
  const m = members[i] as ExternalAIClient;
751
751
  const r = responses[i];
752
- if (r !== undefined && !r.error) {
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() !== '') {
753
760
  present += 1;
754
761
  continue;
755
762
  }
756
- const raw = r?.error ?? 'no response';
763
+ const raw = r?.error ?? (r !== undefined && r.text.trim() === '' ? 'empty response body' : 'no response');
757
764
  const failure = classifyCliFailure(raw);
758
765
  absent.push({
759
766
  member: m.name,
@@ -772,7 +779,14 @@ function _postRunQuorum(
772
779
  */
773
780
  function _format_quorum_line(q: QuorumResult): string {
774
781
  const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
775
- return `council:quorum · ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.`;
782
+ // Round 7 § 5.3 — a degraded pass says so. `1/2 present, needed 1 —
783
+ // concluded` is literally true and reads as agreement; with a threshold of 1
784
+ // a solo answer concludes, and nothing in the line distinguishes "both
785
+ // members agreed" from "one member answered". The counts were always there;
786
+ // what was missing is the word that stops a reader inferring convergence.
787
+ const degraded =
788
+ q.present < q.total ? ` ⚠️ DEGRADED — ${String(q.total - q.present)} member(s) did not answer; this is not convergence.` : '';
789
+ return `council:quorum · ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${degraded}`;
776
790
  }
777
791
 
778
792
  function build_members(settings: Dict, opts: BuildMembersOptions = {}): ExternalAIClient[] {
@@ -2328,6 +2342,31 @@ function _emit_shadow_slo_banner(): void {
2328
2342
  * leaves nothing to infer. Exit is always 0 — this reports a state, it does not
2329
2343
  * gate — and the verdict line is machine-greppable in both directions.
2330
2344
  */
2345
+ /**
2346
+ * Resolve the concrete transport a member would use on THIS machine right now.
2347
+ *
2348
+ * `cmd_status` exists to answer "is the council reachable, and will it bill me".
2349
+ * Since the transport-mode setting was removed, the second half is not a value
2350
+ * anyone can look up in the config — it is a per-machine resolution. Printing a
2351
+ * configured mode would be the same class of stale answer the removed setting
2352
+ * produced. `mode` is pinned to `auto` because the loader no longer emits
2353
+ * anything else.
2354
+ */
2355
+ function _resolvedTransportFor(
2356
+ name: string,
2357
+ member: { readonly binary: string | null; readonly api_key_ref: string | null },
2358
+ report?: EnvironmentReport,
2359
+ ): ReturnType<typeof resolveMemberTransport> {
2360
+ return resolveMemberTransport({
2361
+ provider: name,
2362
+ report: report ?? detectEnvironment(),
2363
+ invocationMode: null,
2364
+ memberSettings: null,
2365
+ globalMode: 'auto',
2366
+ binaryOverride: member.binary,
2367
+ });
2368
+ }
2369
+
2331
2370
  export function cmd_status(args: Args, opts: { env?: Record<string, string | undefined> } = {}): number {
2332
2371
  const env = opts.env ?? process.env;
2333
2372
  const override = env[COUNCIL_CONFIG_ENV];
@@ -2363,6 +2402,21 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
2363
2402
  members_total: cfg === null ? null : cfg.members.size,
2364
2403
  members_enabled: cfg === null ? null : enabledMembers.length,
2365
2404
  member_names: enabledMembers.map(([n]) => n),
2405
+ transports: Object.fromEntries(
2406
+ enabledMembers.map(([n, m]) => {
2407
+ const t = _resolvedTransportFor(n, m);
2408
+ return [
2409
+ n,
2410
+ {
2411
+ available: t.available,
2412
+ transport: t.available ? t.transport : null,
2413
+ billing: t.available ? t.billing : null,
2414
+ reason: t.reason,
2415
+ },
2416
+ ];
2417
+ }),
2418
+ ),
2419
+ ignored_transport_keys: cfg?.ignored_transport_keys ?? [],
2366
2420
  parse_error,
2367
2421
  project_tree_consulted: false,
2368
2422
  })}\n`,
@@ -2383,6 +2437,25 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
2383
2437
  if (enabledMembers.length > 0) {
2384
2438
  _stdout(` ${enabledMembers.map(([n]) => n).join(', ')}\n`);
2385
2439
  }
2440
+ // Transport is resolved per machine, so the only honest way to answer
2441
+ // "will this cost money" is to resolve it here rather than print a
2442
+ // configured value. The billing class comes from the detected auth
2443
+ // source, never from the transport name — a community CLI wrapper
2444
+ // shells out to the same paid API and stays metered.
2445
+ for (const [name, member] of enabledMembers) {
2446
+ const t = _resolvedTransportFor(name, member);
2447
+ const billing = t.available ? ` · ${t.billing}` : '';
2448
+ const detail = t.available ? t.transport : `unavailable — ${t.reason ?? 'no usable transport'}`;
2449
+ _stdout(` transport ${name}: ${detail}${billing}\n`);
2450
+ }
2451
+ if (cfg.ignored_transport_keys.length > 0) {
2452
+ _stdout('\n');
2453
+ _stdout(' Ignored transport keys — transport is resolved, not configured:\n');
2454
+ for (const key of cfg.ignored_transport_keys) {
2455
+ _stdout(` ${key}\n`);
2456
+ }
2457
+ _stdout(' Safe to delete from the config file; they change nothing.\n');
2458
+ }
2386
2459
  }
2387
2460
  _stdout('\n');
2388
2461
  _stdout(' The project tree is NEVER consulted (ADR-104). No project file —\n');
@@ -2593,6 +2666,25 @@ function cmd_run(
2593
2666
  return 0;
2594
2667
  }
2595
2668
 
2669
+ // Round 7 § 5.1 — VALIDATE THE OUTPUT PATH BEFORE THE FIRST BILLABLE CALL.
2670
+ //
2671
+ // The only validation used to sit immediately before the WRITE, i.e. after
2672
+ // every member had been paid, so a wrong directory discarded a completed run.
2673
+ // Measured across round 7's corpus: ~$1.30 in three sessions (`291f827b`
2674
+ // ~$0.42, `9502795e` $0.44, plus a discarded run each in `d6154522` and
2675
+ // `3d50d0df`). The trap sat in the operator's notes for weeks and was still
2676
+ // paid twice, because the defect is the ORDERING, not the caller.
2677
+ //
2678
+ // Placed HERE, after the `--confirm` gate, and not at function entry: the
2679
+ // estimate-only path neither writes nor bills, and the first attempt at this
2680
+ // fix validated at entry and broke it — caught by
2681
+ // `council_cli.test.ts`'s no-confirm case. Nothing above this line spends.
2682
+ //
2683
+ // The validator is pure, so the pre-write call stays: it returns the string
2684
+ // the write uses, and removing it to avoid "duplicate work" would put the
2685
+ // guarantee back in a single place that runs late.
2686
+ _validate_council_output_path(args.output as string, { kind: 'responses', subcommand: 'run' });
2687
+
2596
2688
  const cost_cfg = (ai_cfg['cost_budget'] as Dict) || {};
2597
2689
  const budget = new CostBudget({
2598
2690
  max_input_tokens: _pyInt(cost_cfg['max_input_tokens'] ?? 50_000, 50_000),