@event4u/agent-config 9.33.0 → 9.35.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +154 -145
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/agent-handoff.md +15 -3
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  7. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  8. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  9. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  10. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  11. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  12. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  13. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  14. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  15. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  18. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  19. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  20. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  21. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  22. package/dist/cli/registry.js +2 -2
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
  25. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  26. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  27. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  28. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  29. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  30. package/dist/cli-delegate/cmd_conformance.js +2 -2
  31. package/dist/cli-delegate/cmd_doctor.js +2 -2
  32. package/dist/cli-delegate/cmd_export.js +2 -2
  33. package/dist/cli-delegate/cmd_handoff.js +3 -3
  34. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  35. package/dist/cli-delegate/cmd_prune.js +2 -2
  36. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  37. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +2 -2
  41. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  42. package/dist/cli-delegate/cmd_validate.js +2 -2
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +13 -13
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +2 -2
  47. package/dist/discovery/dormancy-report.md +17 -0
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +3 -3
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +3 -3
  52. package/dist/hooks/dispatch.js +355 -85
  53. package/dist/install/install.mjs +3 -0
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +192 -12
  57. package/dist/server/schemas/settings.js +1 -0
  58. package/dist/server/schemas/settings.js.map +1 -1
  59. package/docs/contracts/settings-classes.md +3 -2
  60. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  61. package/package.json +2 -2
  62. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  63. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  64. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  65. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  66. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  67. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  68. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  69. package/src/config/agent-settings.template.yml +11 -2
  70. package/src/config/quorum-attendance-budget.json +11 -3
  71. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  72. package/src/scripts/_dispatch.bash +6 -2
  73. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  74. package/src/scripts/_lib/security_lint.ts +18 -4
  75. package/src/scripts/_lib/self_repair.ts +60 -0
  76. package/src/scripts/_lib/self_repair_store.ts +65 -5
  77. package/src/scripts/_lib/session_register.ts +67 -1
  78. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  79. package/src/scripts/ai_council/config.ts +60 -0
  80. package/src/scripts/ai_council/events_log.ts +105 -6
  81. package/src/scripts/ai_council/orchestrator.ts +38 -0
  82. package/src/scripts/ai_council/prompts.ts +165 -3
  83. package/src/scripts/ai_council/quorum.ts +87 -0
  84. package/src/scripts/ai_team/review_gate.ts +2 -2
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/check_claims.ts +51 -2
  87. package/src/scripts/ci_time_ratio.ts +3 -1
  88. package/src/scripts/council_cli.ts +36 -1
  89. package/src/scripts/discovery_graph.ts +201 -27
  90. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  91. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  92. package/src/scripts/lint_framework_leakage.ts +195 -28
  93. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  94. package/src/scripts/lint_handoffs.ts +92 -2
  95. package/src/scripts/lint_hedge_words.ts +317 -0
  96. package/src/scripts/lint_mcp_config_security.ts +1 -1
  97. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  98. package/src/scripts/memory_lookup.ts +1 -2
  99. package/src/scripts/schemas/command.schema.json +4 -0
  100. package/src/scripts/schemas/skill.schema.json +4 -0
  101. package/src/scripts/self_repair_cli.ts +26 -1
  102. package/src/scripts/session_register_hook.ts +238 -18
  103. package/src/scripts/sessions_cli.ts +377 -12
  104. package/src/scripts/skill_linter.ts +58 -2
  105. package/src/server/schemas/settings.ts +3 -0
@@ -50,7 +50,16 @@
50
50
  * longer than the TTL means another session may claim your branch.
51
51
  * 2. **This is advisory, not a mutex.** Two sessions can claim in the same
52
52
  * millisecond. Nothing here provides exclusion, and no later feature may be
53
- * built on it as if it did.
53
+ * built on it as if it did. `sessions:claim` refusing to WRITE a claim a peer
54
+ * holds is a consistency check on its own write, not exclusion: it protects
55
+ * the record from being false, never the peer from being duplicated.
56
+ * 3. **The claim is per SESSION, and the bridge file has to be too.** The
57
+ * paragraph above rules `agents/runtime/state/` out for the register and the
58
+ * same argument applies to the claim that feeds it: one shared file per
59
+ * checkout means a second session in the same directory reads the first one's
60
+ * claim as its own. Measured — four live records carrying one identical,
61
+ * already-archived slug. The file is keyed on the host session id where one
62
+ * exists, and degrades to the shared path where none does.
54
63
  *
55
64
  * ## Failure is always open
56
65
  *
@@ -359,3 +368,60 @@ export function foreign_live_records(
359
368
  const own = safe_stem(session_id);
360
369
  return read_live_records(dir, options).filter((r) => safe_stem(r.session_id) !== own);
361
370
  }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Collisions — and why `branch` alone was the wrong axis
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /**
377
+ * What two live sessions are colliding over.
378
+ *
379
+ * `branch` was the only axis this register checked, and it is the CHEAP one: two
380
+ * sessions on one branch see each other immediately, and coordinating is a
381
+ * `git worktree add`. `roadmap` is the expensive one and went unchecked —
382
+ * measured twice (PR #1277/#1280, PR #1280/#1281), both times two sessions built
383
+ * the SAME roadmap phase under DIFFERENT branch names
384
+ * (`feat/dispatch-safety-confirmation` vs `feat/dispatch-safety-confirmed-execution`),
385
+ * so the branch comparison was silent by construction while a whole
386
+ * implementation was duplicated. One of the two PRs is thrown away either way.
387
+ */
388
+ export type CollisionKind = 'roadmap' | 'branch';
389
+
390
+ export interface Collision {
391
+ kind: CollisionKind;
392
+ record: SessionRecord;
393
+ }
394
+
395
+ /**
396
+ * Classify what this session collides with, roadmap-first.
397
+ *
398
+ * Ordering is the finding, not a preference: a roadmap collision means the work
399
+ * is being done twice and one branch is already wasted, while a branch collision
400
+ * means two sessions can see each other's commits. Reporting the cheap one first
401
+ * is what let the expensive one pass unremarked.
402
+ *
403
+ * A `null` slug never collides — "no roadmap claimed" is the state of every
404
+ * session before it picks one, and treating absence as a match would make the
405
+ * warning fire on every pair of fresh sessions and be switched off.
406
+ */
407
+ export function classify_collisions(
408
+ others: readonly SessionRecord[],
409
+ here: { branch: string | null; roadmap_slug: string | null },
410
+ ): Collision[] {
411
+ const out: Collision[] = [];
412
+ for (const r of others) {
413
+ if (
414
+ here.roadmap_slug !== null &&
415
+ r.roadmap_slug !== null &&
416
+ r.roadmap_slug === here.roadmap_slug
417
+ ) {
418
+ out.push({ kind: 'roadmap', record: r });
419
+ }
420
+ }
421
+ for (const r of others) {
422
+ if (here.branch !== null && r.branch === here.branch) {
423
+ out.push({ kind: 'branch', record: r });
424
+ }
425
+ }
426
+ return out;
427
+ }
@@ -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);
@@ -52,6 +52,11 @@
52
52
  * single absent member into a deadlocked release gate (council-verified
53
53
  * 2026-08-09). See `quorum.ts` for the resolver a caller applies to its
54
54
  * own present/total member counts.
55
+ * 9b. `quorum_min_present` ∈ positive integers (default 2, ADR-224). The
56
+ * floor a gate-class pass would want — SHADOW ONLY: it is recorded per
57
+ * pass and enforced nowhere, so no value of this key can hold, delay or
58
+ * fail a council pass today. See `quorum.ts::wouldSoloFloorHold` for why
59
+ * the measurement landed without the enforcement.
55
60
  *
56
61
  * Parity notes (intentional, documented):
57
62
  * - YAML is parsed with `yaml` (npm) at `version: '1.1'`, matching
@@ -71,6 +76,12 @@ import * as path from 'node:path';
71
76
  import { parse as parseYaml } from 'yaml';
72
77
 
73
78
  import * as user_global_paths from '../_lib/user_global_paths.js';
79
+ // LAYERING CONSTRAINT, load-bearing: this is a VALUE import from quorum, while
80
+ // `quorum.ts` imports `QuorumSetting` back from here. That back-edge is
81
+ // `import type` and is erased at build, so there is no runtime cycle today.
82
+ // Converting it to a value import — or adding any other runtime import from
83
+ // quorum into config — creates a real ESM cycle in the loader's init path.
84
+ import { SOLO_FLOOR_MIN_PRESENT } from './quorum.js';
74
85
 
75
86
  const _VALID_PROVIDERS: ReadonlySet<string> = new Set([
76
87
  'anthropic',
@@ -585,6 +596,25 @@ export interface CliCallBudgetConfig {
585
596
  */
586
597
  export type QuorumSetting = 'majority' | number;
587
598
 
599
+ /**
600
+ * The `min_present` floor ADR-224 authorized, as an operator-visible key.
601
+ *
602
+ * A SIBLING key rather than a widened `QuorumSetting`, deliberately.
603
+ * `QuorumSetting` is consumed by `resolveQuorumThreshold`, `evaluateQuorum`,
604
+ * `_quorum_setting_from` and their tests; widening it into a union with an
605
+ * object would touch every one of them to express a value none of them read.
606
+ * The floor is not a second threshold — `quorum` answers "did enough members
607
+ * answer to conclude", the floor answers "was that conclusion reached on too
608
+ * few voices to gate on", and they resolve at different call sites.
609
+ *
610
+ * Validated at load like every other key; defaults to
611
+ * `quorum.ts::SOLO_FLOOR_MIN_PRESENT` so the shadow fire-rate accumulates
612
+ * without the operator having to opt in — an unset floor that recorded nothing
613
+ * would leave ADR-224's review trigger (b) with no data to trigger on. Nothing
614
+ * is enforced at any value: see `wouldSoloFloorHold`.
615
+ */
616
+ export type QuorumMinPresent = number;
617
+
588
618
  export interface CouncilConfig {
589
619
  readonly enabled: boolean;
590
620
  readonly defaults: DefaultsConfig;
@@ -594,6 +624,7 @@ export interface CouncilConfig {
594
624
  readonly consensus_scoring: ConsensusScoringConfig;
595
625
  readonly cli_call_budget: CliCallBudgetConfig;
596
626
  readonly quorum: QuorumSetting;
627
+ readonly quorum_min_present: QuorumMinPresent;
597
628
  readonly necessity_classifier: NecessityClassifierConfig;
598
629
  readonly model_downgrade: ModelDowngradeConfig;
599
630
  readonly debate: DebateConfig;
@@ -822,6 +853,9 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
822
853
  _asDict(_getOr(raw, 'cli_call_budget', {})),
823
854
  );
824
855
  const quorum = _build_quorum(_get(raw, 'quorum', 'majority'));
856
+ const quorum_min_present = _build_quorum_min_present(
857
+ _get(raw, 'quorum_min_present', SOLO_FLOOR_MIN_PRESENT),
858
+ );
825
859
  const necessity_classifier = _build_necessity_classifier(
826
860
  _asDict(_getOr(raw, 'necessity_classifier', {})),
827
861
  );
@@ -871,6 +905,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
871
905
  consensus_scoring: consensus,
872
906
  cli_call_budget,
873
907
  quorum,
908
+ quorum_min_present,
874
909
  necessity_classifier,
875
910
  model_downgrade,
876
911
  debate,
@@ -1905,6 +1940,31 @@ function _build_quorum(raw: Json): QuorumSetting {
1905
1940
  );
1906
1941
  }
1907
1942
 
1943
+ /**
1944
+ * Validate `quorum_min_present` — the shadow floor's configured value.
1945
+ *
1946
+ * Rejects the same shapes `_build_quorum` rejects, and for the same reason: a
1947
+ * silently coerced floor produces a fire-rate that is an artefact of the
1948
+ * coercion rather than of the council. `_isInt` already requires
1949
+ * `typeof value === 'number'`, so booleans are excluded by it — the sibling's
1950
+ * extra `!_isBool` conjunct is a Python-ism (there `bool` really is an `int`
1951
+ * subclass) and is not repeated here.
1952
+ *
1953
+ * There is no upper bound — a floor above the roster is clamped per-pass by
1954
+ * `wouldSoloFloorHold`, where the roster is actually known, and rejecting it
1955
+ * at load would refuse a config that is legitimate the moment a member is
1956
+ * added.
1957
+ */
1958
+ function _build_quorum_min_present(raw: Json): QuorumMinPresent {
1959
+ if (_isInt(raw) && (raw as number) >= 1) {
1960
+ return raw as number;
1961
+ }
1962
+ throw new CouncilConfigError(
1963
+ `\`quorum_min_present\`=${_pyRepr(raw)} must be an integer >= 1 ` +
1964
+ `(got ${_pyTypeName(raw)}).`,
1965
+ );
1966
+ }
1967
+
1908
1968
  function _build_advisor(name: string, cfg: Dict): AdvisorConfig {
1909
1969
  if (!_isDict(cfg)) {
1910
1970
  throw new CouncilConfigError(`advisors.${name}: must be a mapping.`);
@@ -6,8 +6,9 @@
6
6
  * `<project_root>/agents/runtime/council/events.log`. The schema carries the
7
7
  * minimum needed to answer "why did the council skip / block this?" and,
8
8
  * since v2, "who actually attended?" — at retro time, without leaking prompt
9
- * content. v2 added the `quorum_result` action and nothing else; see
10
- * `appendQuorumEvent`.
9
+ * content. v2 added the `quorum_result` action and nothing else; v3 added two
10
+ * booleans to that line (`gate_class`, `floor_would_hold`) and nothing else.
11
+ * See `appendQuorumEvent`.
11
12
  *
12
13
  * Privacy floor:
13
14
  * `original_ask` is never written verbatim — the caller passes the raw
@@ -27,7 +28,13 @@ import * as fs from 'node:fs';
27
28
  import * as path from 'node:path';
28
29
  import { fileURLToPath } from 'node:url';
29
30
 
30
- import { isSoloConcluded, type QuorumResult } from './quorum.js';
31
+ import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
32
+ import {
33
+ isSoloConcluded,
34
+ SOLO_FLOOR_MIN_PRESENT,
35
+ wouldSoloFloorHold,
36
+ type QuorumResult,
37
+ } from './quorum.js';
31
38
  import type { AbsentReason } from './transport_resolver.js';
32
39
 
33
40
  /**
@@ -35,8 +42,29 @@ import type { AbsentReason } from './transport_resolver.js';
35
42
  * `appendQuorumEvent`. Every schema-v1 field keeps its name, type and
36
43
  * position, so a v1 reader parses a v2 necessity line unchanged; only the
37
44
  * action vocabulary widened.
45
+ *
46
+ * v3 adds two booleans to the `quorum_result` line — `gate_class` (declared
47
+ * by the caller) and `floor_would_hold` (the ADR-224 counterfactual). Purely
48
+ * additive, in the same sense v2 was: no field is renamed, retyped or moved,
49
+ * no action is added, and a v2 reader parses a v3 line unchanged because it
50
+ * reads fields by name and never saw these two. The version moves anyway, so
51
+ * a consumer computing the shadow fire-rate can tell a line that recorded
52
+ * `false` from a line written before the field existed — a distinction it
53
+ * cannot make from the absent field alone.
54
+ *
55
+ * v4 adds `min_present` to the same line: the floor value `floor_would_hold`
56
+ * was actually computed against. Additive in the v2/v3 sense — no field is
57
+ * renamed, retyped or moved — and it closes a provenance hole rather than
58
+ * adding a capability. `quorum_min_present` is an operator-facing key in the
59
+ * **user-global** `.ai-council.yml` (ADR-104), so before v4 two machines
60
+ * emitted byte-identical lines while measuring different counterfactuals, and
61
+ * a configured `quorum_min_present: 1` — valid, and provably unable to hold
62
+ * anything — zeroed `shadow_floor_fire_rate` with no trace on the data. A
63
+ * boolean whose meaning depends on an unrecorded per-machine value is not a
64
+ * measurement; recording the value is what makes the series comparable and a
65
+ * zero reading attributable.
38
66
  */
39
- export const SCHEMA_VERSION = 2;
67
+ export const SCHEMA_VERSION = 4;
40
68
 
41
69
  export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
42
70
 
@@ -139,8 +167,7 @@ function _utf8Bytes(cp: number): string {
139
167
  }
140
168
 
141
169
  function _kill_switch_active(): boolean {
142
- const value = process.env[_KILL_SWITCH_ENV] ?? '';
143
- return !(value === '' || value === '0' || value === 'false' || value === 'False');
170
+ return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
144
171
  }
145
172
 
146
173
  export interface AppendEventOptions {
@@ -286,6 +313,42 @@ export interface QuorumEventInput {
286
313
  readonly configuredTotal: number;
287
314
  readonly result: QuorumResult;
288
315
  readonly absent: readonly QuorumAbsence[];
316
+ /**
317
+ * Did the CALLER declare this pass gate-class? Declared, never inferred,
318
+ * and it defaults to `false` — the fail-safe direction, because an
319
+ * un-declared pass is the one nobody classified and the cost of reading it
320
+ * as advisory is a missing measurement rather than a held gate.
321
+ *
322
+ * Inference from the invocation context was the rejected alternative: it
323
+ * silently reclassifies passes whenever the context shape changes, which
324
+ * is the one failure a rate computed over this field could not detect.
325
+ * `command` + `phase` remain on the line, so a consumer that wants the
326
+ * inferred reading can still compute it and see it disagree.
327
+ *
328
+ * **No producer exists today, by design, and that is not a wiring bug.**
329
+ * Nothing in the tree branches on `QuorumStatus` to hold a gate, so there
330
+ * is no gate-class caller to mark; `council_cli.ts::_emitQuorumEvent`
331
+ * therefore omits it from its context object deliberately rather than by
332
+ * oversight, and `gate_class` is `false` on every line the tree can emit.
333
+ * The parameter is kept because the first consumer that holds something on
334
+ * quorum status IS by construction the first gate-class caller: it sets
335
+ * this, and `shadow_floor_fire_rate` filtered on it becomes the
336
+ * enforcement-relevant rate. A field introduced only at that point would
337
+ * leave every earlier line ambiguous between "not gate-class" and "written
338
+ * before anyone asked".
339
+ */
340
+ readonly gateClass?: boolean;
341
+ /**
342
+ * The floor to evaluate the counterfactual against — the operator's
343
+ * `quorum_min_present`, defaulting to `SOLO_FLOOR_MIN_PRESENT`.
344
+ *
345
+ * Written to the line as `min_present` since schema v4, not only consumed:
346
+ * it comes from the user-global config (ADR-104), so without it on the line
347
+ * a reader cannot tell which counterfactual a `floor_would_hold` refers to,
348
+ * and cannot distinguish "the floor never would have fired" from "this
349
+ * operator configured a floor that cannot fire".
350
+ */
351
+ readonly minPresent?: number;
289
352
  }
290
353
 
291
354
  /**
@@ -310,6 +373,10 @@ export function appendQuorumEvent(
310
373
  opts: AppendEventOptions = {},
311
374
  ): boolean {
312
375
  try {
376
+ // The same default `wouldSoloFloorHold` would have applied, hoisted so
377
+ // the recorded value and the evaluated value are one expression rather
378
+ // than two that happen to agree.
379
+ const minPresent = input.minPresent ?? SOLO_FLOOR_MIN_PRESENT;
313
380
  return appendEvent(
314
381
  {
315
382
  lens: input.lens,
@@ -328,6 +395,38 @@ export function appendQuorumEvent(
328
395
  // in `quorum.ts`, so a change to it cannot leave a consumer's
329
396
  // copy silently stale.
330
397
  solo: isSoloConcluded(input.result),
398
+ // Declared class, never inferred — see `QuorumEventInput`.
399
+ gate_class: input.gateClass ?? false,
400
+ // The counterfactual, written on every line: would a
401
+ // `min_present` floor have held this pass? Recorded as a FIELD
402
+ // on `quorum_result` rather than as a new action, deliberately:
403
+ // a new action is invisible to every consumer filtering
404
+ // `action === 'quorum_result'`, which would split the
405
+ // attendance population in two and silently move the
406
+ // denominator of all four registered metrics. An additive
407
+ // boolean moves no existing bucket.
408
+ //
409
+ // Mutually exclusive with `verdict: 'inconclusive'` by
410
+ // construction (`wouldSoloFloorHold` returns false unless the
411
+ // pass concluded), which is what makes "held by the floor" and
412
+ // "threshold not met" readable apart from this line alone.
413
+ // `configuredTotal` is passed, not omitted: post_run `total`
414
+ // is the roster that CONSTRUCTED, so without it a pass that
415
+ // lost a member at construction time reads total=1/present=1
416
+ // and the floor cannot fire on it — the construction-degraded
417
+ // solo case ADR-224 was actually decided on.
418
+ //
419
+ // `min_present` records the floor the counterfactual was
420
+ // computed against, resolved ONCE below and used for both the
421
+ // record and the predicate so the line can never disagree with
422
+ // the boolean it explains — the one-definition discipline
423
+ // `solo` follows by deferring to `isSoloConcluded`.
424
+ min_present: minPresent,
425
+ floor_would_hold: wouldSoloFloorHold(
426
+ input.result,
427
+ minPresent,
428
+ input.configuredTotal,
429
+ ),
331
430
  absent: input.absent.map((a) => ({ member: a.member, reason: a.reason })),
332
431
  },
333
432
  opts,
@@ -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