@event4u/agent-config 9.36.0 → 10.1.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 (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +117 -2
  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/rules/session-canary.md +28 -0
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
  7. package/dist/agent-src/skills/adr-create/SKILL.md +5 -0
  8. package/dist/agent-src/skills/check-refs/SKILL.md +5 -0
  9. package/dist/agent-src/skills/lint-skills/SKILL.md +5 -0
  10. package/dist/agent-src/skills/md-language-check/SKILL.md +5 -0
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/cli-delegate/{chunk-SFGARMAB.js → chunk-2H5SE7DU.js} +24 -4
  13. package/dist/cli-delegate/{chunk-U7CMAIOC.js → chunk-3JUTT26I.js} +1 -1
  14. package/dist/cli-delegate/{chunk-JP53LNVT.js → chunk-HJSKTMIF.js} +6 -14
  15. package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
  16. package/dist/cli-delegate/{chunk-NR7HSN3P.js → chunk-NX5AXSLD.js} +3 -3
  17. package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
  18. package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
  19. package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
  20. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  21. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  22. package/dist/cli-delegate/cmd_conformance.js +1037 -196
  23. package/dist/cli-delegate/cmd_doctor.js +3 -3
  24. package/dist/cli-delegate/cmd_explain.js +2 -2
  25. package/dist/cli-delegate/cmd_export.js +4 -4
  26. package/dist/cli-delegate/cmd_handoff.js +3 -3
  27. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  28. package/dist/cli-delegate/cmd_migrate.js +2 -2
  29. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  30. package/dist/cli-delegate/cmd_prune.js +4 -4
  31. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  32. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  33. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  34. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  35. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  36. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  37. package/dist/cli-delegate/cmd_sync.js +4 -4
  38. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  39. package/dist/cli-delegate/cmd_update.js +1 -1
  40. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  41. package/dist/cli-delegate/cmd_validate.js +4 -4
  42. package/dist/cli-delegate/cmd_versions.js +1 -1
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +8 -8
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +1 -1
  47. package/dist/discovery/dormancy-report.md +1 -1
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +2 -2
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +2 -2
  52. package/dist/hooks/dispatch.js +125 -89
  53. package/dist/install/install.mjs +22 -26
  54. package/dist/mcp/registry-manifest.json +1 -1
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +1579 -616
  57. package/dist/scripts/_lib/agent_settings.js +39 -25
  58. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  59. package/dist/scripts/condense.js +15 -2
  60. package/dist/scripts/condense.js.map +1 -1
  61. package/dist/server/routes/wizard.js +23 -3
  62. package/dist/server/routes/wizard.js.map +1 -1
  63. package/dist/server/schemas/settings.js +6 -6
  64. package/dist/server/schemas/settings.js.map +1 -1
  65. package/docs/contracts/settings-classes.md +14 -9
  66. package/package.json +1 -1
  67. package/src/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
  68. package/src/config/agent-settings.template.yml +6 -36
  69. package/src/config/ci-local-parity.yml +44 -0
  70. package/src/scripts/_lib/agent_settings.ts +39 -25
  71. package/src/scripts/_lib/environment_detector.ts +52 -1
  72. package/src/scripts/_lib/untrusted_content.test.ts +175 -0
  73. package/src/scripts/_lib/untrusted_content.ts +184 -0
  74. package/src/scripts/ai_council/config.ts +62 -24
  75. package/src/scripts/before_complete_hook.ts +39 -0
  76. package/src/scripts/check_bridge_derivation.ts +89 -3
  77. package/src/scripts/check_ci_local_parity.ts +74 -0
  78. package/src/scripts/check_references.ts +15 -1
  79. package/src/scripts/condense.ts +16 -2
  80. package/src/scripts/conformance_scan.ts +200 -9
  81. package/src/scripts/council_cli.ts +95 -3
  82. package/src/scripts/hook_manifest.yaml +12 -6
  83. package/src/scripts/hooks/block_no_verify.ts +82 -9
  84. package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
  85. package/src/scripts/probe_promissory_closing.ts +180 -0
  86. package/src/scripts/probe_session_canary.ts +180 -0
  87. package/src/scripts/schemas/skill.schema.json +42 -0
  88. package/src/scripts/skill_linter.ts +57 -2
  89. package/src/server/schemas/settings.ts +6 -14
@@ -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),
@@ -60,8 +60,12 @@ concerns:
60
60
  severity: advisory
61
61
  # Phase 5 — Tier-1 hook for the verify-before-complete rule. Records
62
62
  # observable evidence of verification commands (tests, quality tools,
63
- # builds) into agents/runtime/state/verify-before-complete.json. The rule body
64
- # cites that file as the source of truth for "verified this turn?".
63
+ # builds) into agents/state/verify-before-complete.json — the path the code
64
+ # actually writes (`before_complete_hook.STATE_FILE`); this comment said
65
+ # `agents/runtime/state/` until round 7, which is a directory it never used.
66
+ # The rule body cites that file as the source of truth for "verified this
67
+ # turn?". Since round 7 it also carries `ci_last`, the session-scoped CI-settle
68
+ # negative that `turn-end-gate` reads to refuse a premature completion claim.
65
69
  verify-before-complete:
66
70
  script: src/scripts/before_complete_hook.ts
67
71
  args: []
@@ -447,10 +451,12 @@ concerns:
447
451
  # mismatch (19 measured) — because building the unsafe part twice is how a
448
452
  # second detector becomes a second outage.
449
453
  #
450
- # DEFAULT OFF (`hooks.turn_end_gate.enabled`), per the council
451
- # recorded in round 6: the mechanism exists and soaks before it binds. Both
452
- # detectors are default ON *within* the master switch, which is round 5
453
- # § 3.6's ask read so that both hold at once.
454
+ # ALWAYS ARMED — no settings surface (2026-08-12). It shipped default-OFF on
455
+ # the round-6 council's "soak before it binds", and the maintainer removed the
456
+ # switch on the ground that a concern which is off cannot soak: the flag made
457
+ # the soak it protected unreachable. Whether the gate fires is decided by each
458
+ # detector's own trigger conditions, which is the same shape `delegation-nudge`
459
+ # uses — no signal, no output. `hooks.turn_end_gate.*` is in REMOVED_KEYS.
454
460
  #
455
461
  # fail_closed: FALSE, deliberately and unlike every other blocking concern
456
462
  # here: a crash in a turn-end gate must let the turn END. Failing closed on
@@ -409,31 +409,104 @@ export function _looks_like_git_invocation(cmd: string, depth = 0): boolean {
409
409
  * Heredoc bodies are removed before extraction: a backtick or `$(` inside a
410
410
  * commit message being written to a file is data, and feeding it back through
411
411
  * this function is exactly the false positive the round-5 repair closed.
412
+ *
413
+ * ROUND 7 — THREAT MODEL for extending that removal to the shlex path (§ 2.1).
414
+ * Until round 7 the stripped string fed only `substitutionPayloads`; `shlexSplit`
415
+ * still received the RAW command. Both halves of that were measured, on this
416
+ * branch, before anything changed:
417
+ *
418
+ * `git commit -F - <<'EOF' … maintainer's … EOF` → BLOCKED (false positive)
419
+ * `bash <<EOF … git commit --no-verify … EOF` → ALLOWED (real bypass)
420
+ *
421
+ * So this is not the round-6 trade of one false positive for two false negatives.
422
+ * The bypass PRE-EXISTS: with the body inline, `shlexSplit` succeeds, the tokens
423
+ * are `bash <<EOF git commit --no-verify EOF`, command position is `bash`, and
424
+ * `_git_base` never sees git. Stripping before shlex fixes the false positive;
425
+ * scanning the bodies fixes the bypass. Both directions verified by probe.
426
+ *
427
+ * What an attacker can hide in a heredoc body, and how each is handled:
428
+ *
429
+ * - a body consumed by a SHELL (`sh`/`bash`/`zsh`/`ksh`, with or without a path
430
+ * prefix or an `env` prefix) IS a command list → scanned recursively.
431
+ * - a body consumed by anything else (`git commit -F -`, `cat > f`, `tee`) is
432
+ * DATA. Scanning it would re-open the exact false positive above, since a
433
+ * commit message may legitimately contain the words `--no-verify`.
434
+ * - an UNTERMINATED heredoc never matches the delimiter, so nothing is stripped
435
+ * and the raw string still reaches shlex — the fail-closed branch keeps it.
436
+ */
437
+
438
+ /** One heredoc: its body, and whether a shell is the thing consuming it. */
439
+ interface Heredoc {
440
+ body: string;
441
+ shellConsumed: boolean;
442
+ }
443
+
444
+ /**
445
+ * Command position on the line that opens the heredoc is a shell interpreter, so
446
+ * the body is a command list rather than data. Anchored to the segment start or a
447
+ * shell separator, exactly like `_looks_like_git_invocation` does one level up —
448
+ * a bare mention of `bash` in an argument must not promote a message to code.
412
449
  */
450
+ const _SHELL_CONSUMER_RE =
451
+ /(?:^|[|&;]|\$\(|`)\s*(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*)?(?:\S*\/)?(?:ba|z|k)?sh\b/;
452
+
453
+ export function _heredocs(cmd: string): { stripped: string; docs: Heredoc[] } {
454
+ const re = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1([\s\S]*?)^\s*\2\s*$/gm;
455
+ const docs: Heredoc[] = [];
456
+ let stripped = '';
457
+ let last = 0;
458
+ let m: RegExpExecArray | null;
459
+ while ((m = re.exec(cmd)) !== null) {
460
+ const lineStart = cmd.lastIndexOf('\n', m.index) + 1;
461
+ const prefix = cmd.slice(lineStart, m.index);
462
+ docs.push({ body: m[3] ?? '', shellConsumed: _SHELL_CONSUMER_RE.test(prefix) });
463
+ stripped += cmd.slice(last, m.index) + '<<HEREDOC';
464
+ last = m.index + m[0].length;
465
+ }
466
+ stripped += cmd.slice(last);
467
+ return { stripped, docs };
468
+ }
469
+
413
470
  function _check_command(cmd: string, depth = 0): [boolean, string] {
471
+ // Round 7 § 2.2 — the stripped string now feeds shlex as well, not only the
472
+ // substitution scan. `<<HEREDOC` is a plain token, so an apostrophe in a
473
+ // commit message can no longer abort the parse of the command carrying it.
474
+ const { stripped, docs } = depth <= 3 ? _heredocs(cmd) : { stripped: cmd, docs: [] };
414
475
  if (depth <= 3) {
415
- const withoutHeredocs = cmd.replace(
416
- /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm,
417
- '<<HEREDOC',
418
- );
419
- for (const payload of substitutionPayloads(withoutHeredocs)) {
476
+ for (const payload of substitutionPayloads(stripped)) {
420
477
  const [blocked, reason] = _check_command(payload, depth + 1);
421
478
  if (blocked) {
422
479
  return [true, reason];
423
480
  }
424
481
  }
482
+ // A shell-consumed body is a command list, so it is checked like one.
483
+ for (const doc of docs) {
484
+ if (!doc.shellConsumed) {
485
+ continue;
486
+ }
487
+ const [blocked, reason] = _check_command(doc.body, depth + 1);
488
+ if (blocked) {
489
+ return [true, reason];
490
+ }
491
+ }
425
492
  }
426
493
  let tokens: string[];
427
494
  try {
428
- tokens = shlexSplit(cmd);
495
+ tokens = shlexSplit(stripped);
429
496
  } catch (e) {
430
497
  if (e instanceof ShlexError) {
431
- if (_looks_like_git_invocation(cmd)) {
498
+ // Tested against the string that actually failed to parse. Round 7
499
+ // § 2.4: the message no longer prescribes the message-in-a-file
500
+ // workaround, because a TERMINATED heredoc is now stripped before
501
+ // this point and can no longer be the cause. What reaches here is
502
+ // genuinely unbalanced quoting — an unterminated heredoc, or an odd
503
+ // quote outside one — which bash itself refuses to run.
504
+ if (_looks_like_git_invocation(stripped)) {
432
505
  return [
433
506
  true,
434
507
  'command parse failed (shlex) on a git-containing command — fail-closed (git-history-discipline). \n'
435
- + 'Most common cause: an apostrophe or backtick inside a heredoc commit message in the SAME compound command as `git`. \n'
436
- + 'Fix: write the message to a file and use `git commit -F <file>` — do not retry the heredoc.',
508
+ + 'The quoting is unbalanced OUTSIDE any terminated heredoc — an unterminated heredoc, or an odd quote. \n'
509
+ + 'bash refuses this command too; fix the quoting rather than working around the guard.',
437
510
  ];
438
511
  }
439
512
  return [false, ''];