@event4u/agent-config 9.33.0 → 9.34.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 (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +85 -167
  3. package/dist/agent-src/commands/agent-handoff.md +15 -3
  4. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  5. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  6. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  7. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  8. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  9. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  10. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  11. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  14. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-REMEZSVP.js} +9 -2
  15. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  16. package/dist/cli-delegate/cmd_conformance.js +1 -1
  17. package/dist/cli-delegate/cmd_doctor.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +11 -11
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +2 -2
  26. package/dist/discovery/dormancy-report.md +17 -0
  27. package/dist/discovery/orphan-report.md +1 -1
  28. package/dist/discovery/packs.json +3 -3
  29. package/dist/discovery/trust-report.md +1 -1
  30. package/dist/discovery/workspaces.json +3 -3
  31. package/dist/hooks/dispatch.js +96 -7
  32. package/dist/mcp/registry-manifest.json +2 -2
  33. package/dist/mcp/server.json +2 -2
  34. package/dist/mcp/server.mjs +118 -10
  35. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  36. package/package.json +2 -2
  37. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  38. package/src/config/quorum-attendance-budget.json +1 -1
  39. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  40. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  41. package/src/scripts/_lib/security_lint.ts +18 -4
  42. package/src/scripts/_lib/self_repair.ts +60 -0
  43. package/src/scripts/_lib/self_repair_store.ts +65 -5
  44. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  45. package/src/scripts/ai_council/events_log.ts +2 -2
  46. package/src/scripts/ai_council/orchestrator.ts +38 -0
  47. package/src/scripts/ai_council/prompts.ts +165 -3
  48. package/src/scripts/ai_council/quorum.ts +10 -0
  49. package/src/scripts/ai_team/review_gate.ts +2 -2
  50. package/src/scripts/build_discovery_manifest.ts +165 -0
  51. package/src/scripts/check_claims.ts +51 -2
  52. package/src/scripts/ci_time_ratio.ts +3 -1
  53. package/src/scripts/discovery_graph.ts +201 -27
  54. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  55. package/src/scripts/lint_handoffs.ts +92 -2
  56. package/src/scripts/lint_hedge_words.ts +317 -0
  57. package/src/scripts/lint_mcp_config_security.ts +1 -1
  58. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  59. package/src/scripts/self_repair_cli.ts +26 -1
  60. package/src/scripts/skill_linter.ts +58 -2
@@ -0,0 +1,37 @@
1
+ /**
2
+ * One definition of "is this env kill-switch on?".
3
+ *
4
+ * WHY THIS EXISTS: the predicate was written out twice, byte-for-byte —
5
+ * `ai_council/events_log.ts` and `ai_team/review_gate.ts` each carried a
6
+ * private `_kill_switch_active()` with the identical body AND the identical
7
+ * env var (`AGENT_CONFIG_NO_EVENTS_LOG`). `diff` over the two blocks was empty
8
+ * and no shared helper existed. Two copies of a boolean coercion is two places
9
+ * for the next accepted spelling to land in only one.
10
+ *
11
+ * The subtle half is why `'false'` is listed at all: `process.env` values are
12
+ * strings, so the obvious `Boolean(process.env.X)` reads the *string* `'false'`
13
+ * as true and silently arms a switch the operator explicitly disarmed. The
14
+ * off-set is therefore explicit — unset, `''`, `'0'`, `'false'`, `'False'` —
15
+ * and everything else is on, so a typo fails toward the safer direction (the
16
+ * switch suppresses a log; a spurious suppression is visible, a spurious write
17
+ * is not).
18
+ *
19
+ * SCOPE, stated because the source over-claimed it. `grep -rn "=== 'false'"
20
+ * src/scripts/` returns 10 hits; only the two migrated here share one
21
+ * predicate. The others are deliberately different and stay put:
22
+ * `orchestration_record.ts` is a CLI-flag coercion whose invalid-string
23
+ * fall-through is documented, `turn_end_gate_hook.ts` additionally accepts
24
+ * `yes`/`on`, and `validate_frontmatter.ts` is a YAML scalar parser. Folding
25
+ * those in would change three behaviours to remove three lines.
26
+ */
27
+
28
+ /** The env values that mean "off". Everything else — including a typo — is on. */
29
+ const _OFF_VALUES: ReadonlySet<string> = new Set(['', '0', 'false', 'False']);
30
+
31
+ /**
32
+ * True when the named environment variable is set to anything outside the
33
+ * off-set. Unset reads as off.
34
+ */
35
+ export function isEnvKillSwitchActive(name: string): boolean {
36
+ return !_OFF_VALUES.has(process.env[name] ?? '');
37
+ }
@@ -408,10 +408,13 @@ export function* iter_corpus(
408
408
  * Exit 1 iff at least one finding `is_fail` (HIGH + full weight). WARN-level
409
409
  * (weighted-down or < HIGH) findings print but never fail the build.
410
410
  */
411
- export function report(findings: readonly Finding[], opts: { check_label: string }): number {
411
+ export function report(
412
+ findings: readonly Finding[],
413
+ opts: { check_label: string; scanned_roots?: readonly string[] },
414
+ ): number {
412
415
  const check_label = opts.check_label;
413
416
  if (findings.length === 0) {
414
- process.stdout.write(`✅ ${check_label}: clean (${_corpus_note()}).\n`);
417
+ process.stdout.write(`✅ ${check_label}: clean (${_corpus_note(opts.scanned_roots)}).\n`);
415
418
  return 0;
416
419
  }
417
420
 
@@ -459,8 +462,19 @@ function _pyG(n: number): string {
459
462
  return s;
460
463
  }
461
464
 
462
- function _corpus_note(): string {
463
- return 'scanned ' + DEFAULT_SCAN_ROOTS.join(', ');
465
+ // The clean-path line names the corpus the caller ACTUALLY walked, not the
466
+ // shared default. Two of five callers diverge from `DEFAULT_SCAN_ROOTS`
467
+ // (`lint_skill_frontmatter_safety` replaces the set outright,
468
+ // `lint_mcp_config_security` appends `src/templates`), so a hardcoded note made
469
+ // this gate report a scope in both directions at once: it claimed `src/rules`
470
+ // and `dist/agent-src`, which it never reads, and omitted `src/subagents`,
471
+ // which is the one root holding the artefact its over-broad-grant check exists
472
+ // for. A reader verifying that root by running the gate read the opposite of
473
+ // the truth. Same contract as `_lib/scan_scope.reportScanned` — the emitted
474
+ // scope is the scope that was walked.
475
+ function _corpus_note(scanned_roots?: readonly string[]): string {
476
+ const roots = scanned_roots && scanned_roots.length > 0 ? scanned_roots : DEFAULT_SCAN_ROOTS;
477
+ return 'scanned ' + roots.join(', ');
464
478
  }
465
479
 
466
480
  // ---------------------------------------------------------------------
@@ -538,6 +538,66 @@ export function fingerprint(defect_class: DefectClass, evidence: string): string
538
538
  }
539
539
 
540
540
  /** Fold a finding into an existing record, or open a new one. */
541
+ // ── creation rate cap ──────────────────────────────────────────────
542
+ //
543
+ // `upsertFinding` folds on fingerprint, so a *recurring* defect increments one
544
+ // record and adds no file. What nothing bounded is **creation**: the fingerprint
545
+ // is `defect_class + evidence`, and evidence is a quoted span from the turn, so a
546
+ // defect whose span varies per turn mints a fresh record every time. The only
547
+ // cap in the module pair was `MAX_EVIDENCE`, which bounds a span's *length* and
548
+ // says nothing about how many records may exist.
549
+ //
550
+ // The cap is per `source` because the two intake paths have different plausible
551
+ // rates and a runaway on one must not consume the other's headroom: a
552
+ // `user-reported` record needs the user to complain, and `self-detected` fires at
553
+ // most once per turn per detector. `NEW_RECORDS_PER_SOURCE_PER_WINDOW` sits above
554
+ // any plausible day (three detector classes, a few evidence variants each) and
555
+ // far below a runaway (one per turn over a long session).
556
+ export const NEW_RECORDS_PER_SOURCE_PER_WINDOW = 20;
557
+ export const CREATION_WINDOW_MS = 24 * 60 * 60 * 1000;
558
+
559
+ /**
560
+ * How many records of `source` were CREATED inside the window ending at `now`.
561
+ * Counts `first_seen`, never `last_seen` — an old record still being folded into
562
+ * is not creation and must not consume the budget.
563
+ */
564
+ export function recentCreations(
565
+ existing: readonly DefectRecord[],
566
+ source: DefectSource,
567
+ now: string,
568
+ ): number {
569
+ const end = Date.parse(now);
570
+ if (Number.isNaN(end)) {
571
+ return 0;
572
+ }
573
+ return existing.filter((r) => {
574
+ if (r.source !== source) {
575
+ return false;
576
+ }
577
+ const t = Date.parse(r.first_seen);
578
+ // An unparseable stamp is counted: a malformed record is exactly what a
579
+ // runaway writer produces, and failing open there would uncap the store.
580
+ return Number.isNaN(t) || (end - t < CREATION_WINDOW_MS && end - t >= 0);
581
+ }).length;
582
+ }
583
+
584
+ /**
585
+ * Whether opening a NEW record for `finding` is over budget. A fingerprint that
586
+ * already exists is a fold, never a creation, so it is never capped — the cap
587
+ * must not silence a defect the store is already tracking.
588
+ */
589
+ export function creationCapReached(
590
+ existing: readonly DefectRecord[],
591
+ finding: DefectFinding,
592
+ now: string,
593
+ ): boolean {
594
+ const fp = fingerprint(finding.defect_class, finding.evidence);
595
+ if (existing.some((r) => r.fingerprint === fp)) {
596
+ return false;
597
+ }
598
+ return recentCreations(existing, finding.source, now) >= NEW_RECORDS_PER_SOURCE_PER_WINDOW;
599
+ }
600
+
541
601
  export function mergeRecord(
542
602
  existing: DefectRecord | null,
543
603
  finding: DefectFinding,
@@ -11,7 +11,14 @@
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
 
14
- import { type DefectFinding, type DefectRecord, fingerprint, mergeRecord } from './self_repair.js';
14
+ import {
15
+ creationCapReached,
16
+ type DefectFinding,
17
+ type DefectRecord,
18
+ type DefectSource,
19
+ fingerprint,
20
+ mergeRecord,
21
+ } from './self_repair.js';
15
22
 
16
23
  export const STORE_REL = path.join('agents', 'runtime', 'self-repair');
17
24
 
@@ -61,7 +68,10 @@ export function listRecords(root: string): DefectRecord[] {
61
68
  }
62
69
  const out: DefectRecord[] = [];
63
70
  for (const n of names.sort()) {
64
- if (!n.endsWith('.json')) {
71
+ // The overflow counter shares the directory and the extension but is not
72
+ // a record. `isRecord` would reject it anyway; naming it here keeps the
73
+ // exclusion intentional rather than incidental.
74
+ if (!n.endsWith('.json') || n === OVERFLOW_FILE) {
65
75
  continue;
66
76
  }
67
77
  const rec = readRecord(root, n.slice(0, -'.json'.length));
@@ -77,13 +87,63 @@ export function openRecords(root: string): DefectRecord[] {
77
87
  return listRecords(root).filter((r) => r.status === 'open');
78
88
  }
79
89
 
90
+ /**
91
+ * Per-source counters for findings the creation cap refused. ONE file for the
92
+ * whole store, so the overflow record is bounded by construction — it is the
93
+ * counter a runaway writer increments instead of the file it would otherwise
94
+ * mint. Deliberately NOT a `DefectRecord`: an overflow is not a defect and must
95
+ * not need a `DefectClass`, an issue-form dropdown entry, or an egress route.
96
+ */
97
+ export const OVERFLOW_FILE = '_overflow.json';
98
+
99
+ export type OverflowCounts = Partial<Record<DefectSource, { dropped: number; last_seen: string }>>;
100
+
101
+ function overflowPath(root: string): string {
102
+ return path.join(storeDir(root), OVERFLOW_FILE);
103
+ }
104
+
105
+ export function readOverflow(root: string): OverflowCounts {
106
+ try {
107
+ const parsed: unknown = JSON.parse(fs.readFileSync(overflowPath(root), 'utf-8'));
108
+ return typeof parsed === 'object' && parsed !== null ? (parsed as OverflowCounts) : {};
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
113
+
114
+ function bumpOverflow(root: string, source: DefectSource, now: string): void {
115
+ const counts = readOverflow(root);
116
+ const prev = counts[source]?.dropped ?? 0;
117
+ counts[source] = { dropped: prev + 1, last_seen: now };
118
+ const dir = storeDir(root);
119
+ fs.mkdirSync(dir, { recursive: true });
120
+ fs.writeFileSync(overflowPath(root), `${JSON.stringify(counts, null, 2)}\n`, 'utf-8');
121
+ }
122
+
80
123
  /**
81
124
  * Fold a finding into the store: increments the existing record when the
82
- * fingerprint matches, otherwise opens a new one. Returns the stored record.
125
+ * fingerprint matches, otherwise opens a new one. Returns the stored record, or
126
+ * `null` when the per-source creation cap refused to open a new one.
127
+ *
128
+ * A refusal is COUNTED, never silent — `readOverflow` carries the per-source
129
+ * drop tally, so "the cap fired" is a readable fact rather than a defect that
130
+ * disappeared. A cap that quietly discarded reports would break this loop's own
131
+ * Iron Law (a defect is queued, never shrugged off) in the name of bounding it.
83
132
  */
84
- export function upsertFinding(root: string, finding: DefectFinding, now: string): DefectRecord {
133
+ export function upsertFinding(
134
+ root: string,
135
+ finding: DefectFinding,
136
+ now: string,
137
+ ): DefectRecord | null {
85
138
  const fp = fingerprint(finding.defect_class, finding.evidence);
86
- const merged = mergeRecord(readRecord(root, fp), finding, now);
139
+ const existing = readRecord(root, fp);
140
+ // The fold path never consults the cap: a fingerprint already in the store
141
+ // adds no file, so capping it would silence a tracked defect for no bound.
142
+ if (existing === null && creationCapReached(listRecords(root), finding, now)) {
143
+ bumpOverflow(root, finding.source, now);
144
+ return null;
145
+ }
146
+ const merged = mergeRecord(existing, finding, now);
87
147
  writeRecord(root, merged);
88
148
  return merged;
89
149
  }
@@ -232,10 +232,35 @@ export interface MainSessionRecycleEnvelope {
232
232
  * of silent. Required; an empty list is a claim, not a default.
233
233
  */
234
234
  not_carried_forward: string[];
235
- /** Choices already made, each with its one-line rationale in the same line. */
235
+ /**
236
+ * Choices already made, each with its one-line rationale in the same line.
237
+ *
238
+ * A line MAY close with a reversibility tag — `[reversible]` or
239
+ * `[irreversible]` — so the successor can tell "we picked A over B, easy to
240
+ * revisit" from "we already migrated the data". The tag is OPTIONAL, so no
241
+ * committed envelope is retroactively invalid; when present its spelling is
242
+ * validated, which is what keeps it a checkable field rather than a doctrine
243
+ * nobody can verify. No claim is made that it improves resumption — that
244
+ * stays the registered, unmeasured `envelope_resume_success` metric.
245
+ */
236
246
  decisions?: string[];
237
247
  /** Binding constraints the successor must not silently re-open. */
238
248
  constraints?: string[];
249
+ /**
250
+ * Paths the successor must NOT modify — a parallel worktree, a file another
251
+ * session holds, a generated projection whose source lives elsewhere.
252
+ *
253
+ * Distinct from `constraints`, which carries decisions in prose: this is a
254
+ * list of path refs, so "was this path off limits?" is answerable by
255
+ * comparison instead of by reading. That checkability is the whole reason it
256
+ * is a field rather than a sentence — the same bar the handoff-envelope
257
+ * adjudication set (a field whose presence is checkable survives where a
258
+ * doctrine whose effect is unmeasured did not).
259
+ *
260
+ * Optional: an envelope with nothing off limits states nothing here, and an
261
+ * empty list is not a claim that everything is writable.
262
+ */
263
+ do_not_touch?: string[];
239
264
  /** Open worker CHECKPOINT envelopes by path — never inlined bodies. */
240
265
  open_worker_envelopes?: string[];
241
266
  /** Artifact paths (deliverables, notes, evidence) — refs, never bodies. */
@@ -444,6 +469,54 @@ export function hasBoundaryMarker(block: string): boolean {
444
469
  );
445
470
  }
446
471
 
472
+ /** The two accepted reversibility tags, exact spellings. */
473
+ export const DECISION_REVERSIBILITY_TAGS: readonly string[] = ['reversible', 'irreversible'];
474
+
475
+ /**
476
+ * Validate the OPTIONAL trailing reversibility tag on a decision line.
477
+ *
478
+ * Fires only on a line that closes with a bracket whose content is trying to be
479
+ * one of the two tags — `[reversble]`, `[Irreversible!]`, `[reversible?]`. A
480
+ * bracket used for anything else (`[ADR-109]`, `[see #1273]`) is left alone, and
481
+ * a line with no bracket at all is the untagged default. So the check catches a
482
+ * typo in a tag someone MEANT to write, which is the only failure a
483
+ * presence-checkable optional field can actually have: a silently misspelled tag
484
+ * reads as "untagged" and loses the distinction the tag exists to carry.
485
+ */
486
+ export function decisionTagErrors(decisions: unknown): string[] {
487
+ if (!Array.isArray(decisions)) {
488
+ return [];
489
+ }
490
+ const errors: string[] = [];
491
+ for (const line of decisions) {
492
+ if (typeof line !== 'string') {
493
+ continue;
494
+ }
495
+ const m = /\[([^\]]*)\]\s*$/.exec(line.trimEnd());
496
+ if (m === null) {
497
+ continue;
498
+ }
499
+ const inner = (m[1] ?? '').trim();
500
+ const lowered = inner.toLowerCase();
501
+ if (DECISION_REVERSIBILITY_TAGS.includes(lowered)) {
502
+ if (inner !== lowered) {
503
+ errors.push(
504
+ `decisions: reversibility tag must be lower-case — got \`[${inner}]\`, expected \`[${lowered}]\``,
505
+ );
506
+ }
507
+ continue;
508
+ }
509
+ if (/^i?rr?ever[a-z]*[^a-z]*$/i.test(lowered)) {
510
+ errors.push(
511
+ `decisions: \`[${inner}]\` is not a reversibility tag — use exactly ` +
512
+ DECISION_REVERSIBILITY_TAGS.map((t) => `\`[${t}]\``).join(' or ') +
513
+ ', or drop the bracket',
514
+ );
515
+ }
516
+ }
517
+ return errors;
518
+ }
519
+
447
520
  /** Every key `validateRecycleEnvelope` accepts — anything else is schema-invalid. */
448
521
  const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
449
522
  'capsule_version',
@@ -457,6 +530,7 @@ const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
457
530
  'not_carried_forward',
458
531
  'decisions',
459
532
  'constraints',
533
+ 'do_not_touch',
460
534
  'open_worker_envelopes',
461
535
  'artifact_paths',
462
536
  'assumptions',
@@ -519,7 +593,10 @@ export function validateRecycleEnvelope(input: unknown): string[] {
519
593
  checkList(errors, 'remaining', e['remaining'], MAX_LINE_CHARS, true);
520
594
  checkList(errors, 'not_carried_forward', e['not_carried_forward'], MAX_LINE_CHARS, true);
521
595
  checkList(errors, 'decisions', e['decisions'], MAX_LINE_CHARS, false);
596
+ errors.push(...decisionTagErrors(e['decisions']));
522
597
  checkList(errors, 'constraints', e['constraints'], MAX_LINE_CHARS, false);
598
+ // Path refs, so the REF budget applies — not the prose one `constraints` uses.
599
+ checkList(errors, 'do_not_touch', e['do_not_touch'], MAX_REF_CHARS, false);
523
600
  checkList(errors, 'open_worker_envelopes', e['open_worker_envelopes'], MAX_REF_CHARS, false);
524
601
  checkList(errors, 'artifact_paths', e['artifact_paths'], MAX_REF_CHARS, false);
525
602
  checkList(errors, 'suggested_skills', e['suggested_skills'], MAX_REF_CHARS, false);
@@ -27,6 +27,7 @@ import * as fs from 'node:fs';
27
27
  import * as path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
 
30
+ import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
30
31
  import { isSoloConcluded, type QuorumResult } from './quorum.js';
31
32
  import type { AbsentReason } from './transport_resolver.js';
32
33
 
@@ -139,8 +140,7 @@ function _utf8Bytes(cp: number): string {
139
140
  }
140
141
 
141
142
  function _kill_switch_active(): boolean {
142
- const value = process.env[_KILL_SWITCH_ENV] ?? '';
143
- return !(value === '' || value === '0' || value === 'false' || value === 'False');
143
+ return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
144
144
  }
145
145
 
146
146
  export interface AppendEventOptions {
@@ -58,6 +58,7 @@ import type { AdvisorPlan } from './advisors.js';
58
58
  import {
59
59
  advisor_system_prompt,
60
60
  ANTI_CONFORMITY_DIRECTIVE,
61
+ describe_verdict_mismatch,
61
62
  STANCE_LINE_CONTRACT,
62
63
  build_extraction_user_prompt,
63
64
  build_peer_review_user_prompt,
@@ -65,6 +66,7 @@ import {
65
66
  peer_review_synthesis_addendum,
66
67
  synthesis_template,
67
68
  system_prompt_for,
69
+ VERDICT_LINE_CONTRACT,
68
70
  } from './prompts.js';
69
71
  import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate_gates.js';
70
72
  import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
@@ -1927,6 +1929,23 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1927
1929
  // addendum so both extensions stack predictably.
1928
1930
  template = template ? `${template}\n\n${CHAIRMAN_FIELDS_ADDENDUM}` : CHAIRMAN_FIELDS_ADDENDUM;
1929
1931
  }
1932
+ if (opts.stance_tally === true && template !== '') {
1933
+ // Phase 2.1: the synthesis owes the same machine-readable closing line
1934
+ // the members owe. Appended only when a tally will be rendered — there
1935
+ // is nothing to check a verdict against otherwise. Composed last so it
1936
+ // is the final instruction, matching the contract's "and nothing after
1937
+ // it". The template's own `VERDICT: <option-label>` placeholder is
1938
+ // parsed as absent (see `parse_verdict_line`), so the un-summarised
1939
+ // render stays green.
1940
+ //
1941
+ // The `template !== ''` guard is load-bearing: an empty template is
1942
+ // `prose_synthesis: true`, documented at `:1859-1862` as a BARE slot.
1943
+ // Appending here would replace `*to be summarised by the host agent*`
1944
+ // with raw contract instructions and silently override an option the
1945
+ // caller set on purpose — a creative-lens pass would start reading like
1946
+ // a filled-in form.
1947
+ template = `${template}\n\n${VERDICT_LINE_CONTRACT}`;
1948
+ }
1930
1949
  let body: string;
1931
1950
  if (template) {
1932
1951
  body = template;
@@ -1951,6 +1970,25 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1951
1970
  .map((r) => ({ member: `${r.provider}:${r.model}`, text: r.text })),
1952
1971
  );
1953
1972
  blocks.push(render_vote_tally(tally));
1973
+ // Phase 2.1: the verdict half of the same projection. A synthesis that
1974
+ // states a `VERDICT:` line the tally did not clear is contradicting its
1975
+ // own count, and the reader of the rendered pass is exactly who needs to
1976
+ // see that.
1977
+ //
1978
+ // Surfaced, never thrown, and the ordering above is part of the fix: a
1979
+ // throw here would discard the whole artifact — every member response,
1980
+ // the peer review, the quorum bookkeeping — AFTER every provider call
1981
+ // was paid for, which is the hazard this roadmap already recorded for
1982
+ // the sibling shape check. `describe_verdict_mismatch` returns the
1983
+ // string; `assert_synthesis_matches_tally` still throws for a caller
1984
+ // holding a finished synthesis it can refuse.
1985
+ //
1986
+ // Conditional by construction: an absent verdict line yields null, so
1987
+ // every synthesis written before the contract shipped stays byte-identical.
1988
+ const mismatch = describe_verdict_mismatch(body, tally);
1989
+ if (mismatch !== null) {
1990
+ blocks.push(`> **Verdict/tally mismatch:** ${mismatch}`);
1991
+ }
1954
1992
  }
1955
1993
  blocks.push(`## Convergence / Divergence\n\n${body}`);
1956
1994
  // Phase 4.1: closest to the synthesis it was extracted from — ahead of
@@ -460,9 +460,19 @@ export const REQUIRED_SYNTHESIS_SECTIONS = ['Kill criteria', 'Concrete next step
460
460
  * fills the Convergence / Divergence slot) — not the template prompt. Every
461
461
  * lens must close with a non-empty `### Kill criteria` and `### Concrete next
462
462
  * step`; a missing or placeholder-empty section throws `SynthesisRenderError`
463
- * naming the first offender. Called on the synthesis-emit path (the chairman
464
- * synthesis in Phase 2, and any future synthesis-record step); safe to call on
465
- * decision and creative lenses alike, since both now require the two sections.
463
+ * naming the first offender. Safe to call on decision and creative lenses
464
+ * alike, since both now require the two sections.
465
+ *
466
+ * **Not wired onto the emit path, deliberately.** This docstring used to claim
467
+ * it was "called on the synthesis-emit path"; measured, the function had zero
468
+ * production call sites and only the test imported it. Wiring it
469
+ * unconditionally into `render()` is not available either: with no chairman the
470
+ * rendered body is the literal `*to be summarised by the host agent*`, which
471
+ * carries neither section, so every templated render would throw (verified —
472
+ * the probe raises `missing the required "### Kill criteria" section`). A
473
+ * caller that HAS a completed synthesis calls this; the templated default path
474
+ * has nothing to validate. The tally check below is the half that could be
475
+ * wired, because it is conditional on a verdict line the template never emits.
466
476
  */
467
477
  export function assert_synthesis_sections(text: string): void {
468
478
  const lines = text.split('\n');
@@ -488,6 +498,158 @@ export function assert_synthesis_sections(text: string): void {
488
498
  }
489
499
  }
490
500
 
501
+ /** The reserved verdict label a synthesis uses when the tally did not converge. */
502
+ export const SPLIT_VERDICT_LABEL = 'split';
503
+
504
+ // Mirrors `_STANCE_RE`'s shape discipline: an explicit, single-field closing
505
+ // line, never read out of surrounding prose.
506
+ //
507
+ // **Case-SENSITIVE, deliberately.** The `i` flag was here and was wrong: the
508
+ // pattern is line-anchored, so `Verdict: option A is the stronger choice` —
509
+ // ordinary chairman prose opening a line — parsed as a machine-readable verdict
510
+ // carrying the whole sentence as its label. That is precisely the prose
511
+ // inference the paragraph above forbids, arriving through the regex instead of
512
+ // through a fallback. `VERDICT_LINE_CONTRACT` asks for the literal uppercase
513
+ // token, so requiring it costs a compliant author nothing and closes the class.
514
+ const _VERDICT_RE = /^\s*VERDICT:\s*(.+?)\s*$/gm;
515
+
516
+ /**
517
+ * Mandatory closing-line contract for a synthesis rendered alongside a stance
518
+ * tally. The synthesis states its verdict as a machine-readable label rather
519
+ * than leaving it to be mined out of the recommendation prose — the same
520
+ * discipline `STANCE_LINE_CONTRACT` imposes on members.
521
+ */
522
+ export const VERDICT_LINE_CONTRACT = `Close the synthesis with EXACTLY this line, and nothing after it:
523
+ VERDICT: <option-label>
524
+ Use the SAME <option-label> the Vote Tally cleared. Use \`${SPLIT_VERDICT_LABEL}\` when no option cleared the threshold — an honest split is a verdict, and claiming a winner the tally did not record is a render error.`;
525
+
526
+ /**
527
+ * Parse the synthesis verdict line. Returns the LAST match (the final,
528
+ * most-authoritative one) or `null` when no well-formed line exists.
529
+ *
530
+ * `null` is a repair marker, never a guess: this function does not fall back to
531
+ * reading agreement out of the prose. That is the invariant `stance_tally`
532
+ * states for members ("the tally never infers a stance from the surrounding
533
+ * prose") and the one `check_finding_dispositions` states for the release
534
+ * ledger ("a comment is mutable and unaudited; it is transport, not a record").
535
+ */
536
+ export function parse_verdict_line(text: string): { label: string; display: string } | null {
537
+ _VERDICT_RE.lastIndex = 0;
538
+ let last: RegExpExecArray | null = null;
539
+ let m: RegExpExecArray | null;
540
+ while ((m = _VERDICT_RE.exec(text)) !== null) {
541
+ last = m;
542
+ }
543
+ if (last === null) {
544
+ return null;
545
+ }
546
+ const raw = (last[1] ?? '').trim();
547
+ if (raw.length === 0) {
548
+ return null;
549
+ }
550
+ // An angle-bracketed label is the CONTRACT's own placeholder, not a verdict.
551
+ // This matters on the templated path: with no chairman the rendered body IS
552
+ // `VERDICT_LINE_CONTRACT`'s template text, so a parser without this guard
553
+ // would read `<option-label>` as a claimed winner and throw on every
554
+ // un-summarised render. Same treatment `assert_synthesis_sections` gives a
555
+ // placeholder-empty section.
556
+ if (/^<.*>$/.test(raw)) {
557
+ return null;
558
+ }
559
+ return { label: raw.toLowerCase(), display: raw };
560
+ }
561
+
562
+ /**
563
+ * Validate a completed synthesis against the stance tally it was rendered
564
+ * beside: a verdict naming a winner the tally did not clear throws
565
+ * `SynthesisRenderError`, the same way a missing section does.
566
+ *
567
+ * The defect this closes: `assert_synthesis_sections` is a *shape* check, so a
568
+ * synthesis could report agreement over a tally that recorded dissent and
569
+ * nothing downstream disagreed. That is the class behind the recorded 9.14.0
570
+ * release failure, where the head read "Security and correctness: none" over a
571
+ * critical finding — `check_finding_dispositions.ts` is the same check on the
572
+ * release surface.
573
+ *
574
+ * **An absent verdict line does not throw.** It is a repair marker under the
575
+ * same rule as an unparseable stance: the fail-safe direction is to record that
576
+ * the claim was not made, never to infer one from prose. That also keeps every
577
+ * synthesis rendered before `VERDICT_LINE_CONTRACT` shipped green.
578
+ */
579
+ export function assert_synthesis_matches_tally(text: string, tally: TallyView): void {
580
+ const mismatch = describe_verdict_mismatch(text, tally);
581
+ if (mismatch !== null) {
582
+ throw new SynthesisRenderError(mismatch);
583
+ }
584
+ }
585
+
586
+ /** The slice of a `StanceTallyResult` the verdict check reads. */
587
+ export interface TallyView {
588
+ consensus: { label: string } | null;
589
+ split: boolean;
590
+ options: ReadonlyArray<{ label: string }>;
591
+ }
592
+
593
+ /**
594
+ * The same comparison as `assert_synthesis_matches_tally`, returning the
595
+ * mismatch as a string instead of throwing — `null` means "no contradiction".
596
+ *
597
+ * **Why both shapes exist.** Throwing is right for a caller holding a finished
598
+ * synthesis it can refuse. It is wrong on the render path: `render()` throws
599
+ * before the tally and the Convergence blocks are pushed, so a mismatch would
600
+ * destroy the ENTIRE artifact — every member response, the peer review, the
601
+ * quorum bookkeeping — *after* every provider call has already been paid for.
602
+ * The module's own answer for an unverifiable claim is the `needs_repair`
603
+ * marker `render_vote_tally` prints, not a discarded pass, and the roadmap had
604
+ * already recorded that unconditionally wiring the sibling shape check was
605
+ * unsafe for this same reason. So the render path surfaces the contradiction
606
+ * where a reader will see it and keeps the artifact.
607
+ */
608
+ export function describe_verdict_mismatch(text: string, tally: TallyView): string | null {
609
+ const verdict = parse_verdict_line(text);
610
+ if (verdict === null) {
611
+ return null; // repair marker, not a guess
612
+ }
613
+ // The reserved sense of `split` yields to a real option that carries the
614
+ // same label (R2 finding 8): the label vocabulary is free text mined from
615
+ // member `STANCE:` lines, so nothing stops an option genuinely called
616
+ // `split`, and when the tally clears it the CORRECT verdict would otherwise
617
+ // read as a contradiction. Deciding by whether the tally actually holds
618
+ // that option keeps one namespace without an escape syntax authors would
619
+ // have to learn.
620
+ const optionNamedSplit = tally.options.some(
621
+ (o) => o.label.toLowerCase() === SPLIT_VERDICT_LABEL,
622
+ );
623
+ const claimsSplit = verdict.label === SPLIT_VERDICT_LABEL && !optionNamedSplit;
624
+
625
+ if (tally.consensus === null) {
626
+ if (claimsSplit) {
627
+ return null;
628
+ }
629
+ // `split` is the tally's own word for this state; reading it here rather
630
+ // than re-deriving "no consensus ⇒ split" keeps the message honest when
631
+ // the tally is empty (zero options is not a split across zero options).
632
+ const how = tally.split
633
+ ? `split across ${tally.options.length} option(s)`
634
+ : 'no option-level stances parsed';
635
+ return (
636
+ `synthesis claims "${verdict.display}" but the tally recorded no consensus ` +
637
+ `(${how}); use \`${SPLIT_VERDICT_LABEL}\` or re-run the round`
638
+ );
639
+ }
640
+
641
+ if (claimsSplit) {
642
+ return `synthesis claims a split but the tally cleared "${tally.consensus.label}"`;
643
+ }
644
+ if (verdict.label !== tally.consensus.label.toLowerCase()) {
645
+ return (
646
+ `synthesis claims "${verdict.display}" but the tally cleared ` +
647
+ `"${tally.consensus.label}"`
648
+ );
649
+ }
650
+ return null;
651
+ }
652
+
491
653
  /**
492
654
  * Drop any *whole line* containing a host-agent identity substring.
493
655
  *
@@ -93,6 +93,16 @@ export function evaluateQuorum(
93
93
  * full-attendance one. Advisory render and telemetry only: no gate reads it,
94
94
  * and nothing downstream may branch on it without its own decision record.
95
95
  *
96
+ * ONE such record now exists, and it is deliberately narrow: ADR-224
97
+ * (`docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md`) authorizes a
98
+ * `min_present: 2` floor to branch on this predicate **for gate-class passes
99
+ * only**, decided against a measured 12.5 % solo-conclusion rate. Nothing has
100
+ * landed yet — no caller branches on it today — so the sentence above still
101
+ * describes the code. It is stated here so a reader finds the authorization
102
+ * instead of re-deriving the prohibition, and so the next branch is visibly a
103
+ * separate argument: the exception is gate-class passes, not solo status
104
+ * generally.
105
+ *
96
106
  * `total` is deliberately NOT consulted. A council configured with a single
97
107
  * member concludes solo by construction, and that is still a conclusion
98
108
  * reached on one voice — collapsing it into "not solo" would hide exactly
@@ -42,6 +42,7 @@ import * as os from 'node:os';
42
42
  import * as path from 'node:path';
43
43
 
44
44
  import { claude_config_dir, CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_ID } from '../_lib/claude_plugin.js';
45
+ import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
45
46
  import { atomic_write_json } from '../hooks/state_io.js';
46
47
  import { AI_TEAM_DEFAULTS, load_ai_team_config, type AiTeamConfig } from './config.js';
47
48
 
@@ -170,8 +171,7 @@ export function circuit_breaker_notice(bound: number): string {
170
171
  // ── ledger (events-log convention) ──────────────────────────────────
171
172
 
172
173
  function _kill_switch_active(): boolean {
173
- const value = process.env[_KILL_SWITCH_ENV] ?? '';
174
- return !(value === '' || value === '0' || value === 'false' || value === 'False');
174
+ return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
175
175
  }
176
176
 
177
177
  function _iso_seconds_z(d: Date): string {