@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +154 -145
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
- package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
- package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +7 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +13 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +17 -0
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +355 -85
- package/dist/install/install.mjs +3 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +192 -12
- package/dist/server/schemas/settings.js +1 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +3 -2
- package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
- package/package.json +2 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/src/config/agent-settings.template.yml +11 -2
- package/src/config/quorum-attendance-budget.json +11 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/security_lint.ts +18 -4
- package/src/scripts/_lib/self_repair.ts +60 -0
- package/src/scripts/_lib/self_repair_store.ts +65 -5
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +105 -6
- package/src/scripts/ai_council/orchestrator.ts +38 -0
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +87 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/ci_time_ratio.ts +3 -1
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/lint_handoffs.ts +92 -2
- package/src/scripts/lint_hedge_words.ts +317 -0
- package/src/scripts/lint_mcp_config_security.ts +1 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/self_repair_cli.ts +26 -1
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/scripts/skill_linter.ts +58 -2
- 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
|
-
/**
|
|
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;
|
|
10
|
-
* `
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|