@event4u/agent-config 9.34.0 → 9.36.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 +64 -77
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
- package/dist/agent-src/rules/improve-before-implement.md +1 -1
- package/dist/agent-src/rules/ui-audit-gate.md +35 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
- package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
- package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/AGENTS.md +1 -1
- 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-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
- package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
- package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
- package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +3 -3
- package/dist/cli-delegate/cmd_refresh.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +8 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +3 -3
- package/dist/cli-delegate/cmd_uninstall.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +14 -14
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +789 -401
- package/dist/install/install.mjs +21 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +99 -7
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +5 -2
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
- package/package.json +2 -2
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/AGENTS.md +1 -1
- 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 +43 -2
- package/src/config/gate-coverage.yml +12 -0
- package/src/config/quorum-attendance-budget.json +10 -2
- package/src/scripts/_cli/cmd_doctor.ts +32 -0
- package/src/scripts/_cli/cmd_migrate.ts +26 -3
- package/src/scripts/_cli/cmd_refresh.ts +29 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/hook_settings.ts +79 -0
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/ui_surface.ts +97 -0
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +103 -4
- package/src/scripts/ai_council/quorum.ts +83 -6
- package/src/scripts/capture_skill_catalogue.ts +483 -0
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/hook_manifest.yaml +18 -3
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/design_slop_hook.ts +1 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/lint_roadmap_complexity.ts +60 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/report_consultation_rate.ts +358 -0
- package/src/scripts/report_skill_activation.ts +5 -1
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/server/schemas/settings.ts +12 -0
- /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
- /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook_settings — read a `hooks.<section>.enabled` flag out of
|
|
3
|
+
* `.agent-settings.yml` without dragging a YAML parser into a hook.
|
|
4
|
+
*
|
|
5
|
+
* Every default-OFF hook needs the same two-level lookup, and the tree already
|
|
6
|
+
* carries two hand-rolled copies of it (`code_graph_nudge_hook.enabled` and the
|
|
7
|
+
* equivalent inside `design_slop_hook`). Adding a third copy for the route
|
|
8
|
+
* nudge is what this file prevents; the two existing copies are pre-existing
|
|
9
|
+
* duplication and are deliberately left alone — folding them in is a separate,
|
|
10
|
+
* behaviour-neutral change, not something to smuggle into a feature diff.
|
|
11
|
+
*
|
|
12
|
+
* Fail-closed on the flag, fail-open on the read: an unreadable or malformed
|
|
13
|
+
* settings file returns `false`, which keeps a default-OFF hook off. A hook
|
|
14
|
+
* that turned itself ON because it could not read a file would be the worst
|
|
15
|
+
* possible failure mode for an opt-in nudge.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
|
|
20
|
+
const SETTINGS_FILE = '.agent-settings.yml';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when `.agent-settings.yml` under `root` carries
|
|
24
|
+
* `hooks:\n <section>:\n enabled: true`.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately indentation-shaped rather than a real parse: the same shape the
|
|
27
|
+
* existing hooks rely on, and a hook must never fail a tool call because a
|
|
28
|
+
* dependency could not load.
|
|
29
|
+
*/
|
|
30
|
+
export function hookSectionEnabled(root: string, section: string): boolean {
|
|
31
|
+
const file = path.join(root, SETTINGS_FILE);
|
|
32
|
+
let text: string;
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.statSync(file).isFile()) return false;
|
|
35
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
const sectionPattern = new RegExp(`^(\\s+)${escaped}\\s*:\\s*$`);
|
|
42
|
+
let inHooks = false;
|
|
43
|
+
/** Indent width of the matched section header; null while outside it. */
|
|
44
|
+
let sectionIndent: number | null = null;
|
|
45
|
+
|
|
46
|
+
const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
|
|
47
|
+
|
|
48
|
+
for (const raw of text.split(/\r\n|\r|\n/)) {
|
|
49
|
+
const line = raw.replace(/\s+$/, '');
|
|
50
|
+
if (!line || line.replace(/^\s+/, '').startsWith('#')) continue;
|
|
51
|
+
|
|
52
|
+
// A non-indented line closes both scopes and may open `hooks:`.
|
|
53
|
+
if (!(line.startsWith(' ') || line.startsWith('\t'))) {
|
|
54
|
+
inHooks = /^hooks\s*:\s*$/.test(line);
|
|
55
|
+
sectionIndent = null;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (inHooks) {
|
|
60
|
+
const opened = sectionPattern.exec(line);
|
|
61
|
+
if (opened) {
|
|
62
|
+
// Remember the ACTUAL indent rather than assuming two spaces.
|
|
63
|
+
// The previous version closed the section on any line indented
|
|
64
|
+
// <= 3, so on a 4-space file a sibling section never closed and
|
|
65
|
+
// a default-OFF hook could read another section's enabled: true.
|
|
66
|
+
sectionIndent = opened[1]!.length;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// A key at or above the section header's own depth closes it.
|
|
70
|
+
if (sectionIndent !== null && indentOf(line) <= sectionIndent) {
|
|
71
|
+
sectionIndent = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (sectionIndent !== null && /^\s+enabled\s*:\s*true\b/.test(line)) return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui_surface — the ONE definition of "this path is a UI surface".
|
|
3
|
+
*
|
|
4
|
+
* Three consumers need the same answer and were about to disagree about it:
|
|
5
|
+
* the anti-slop PreToolUse hook (which had the only copy), the UI-turn
|
|
6
|
+
* definition the catalogue analyzer counts against, and the route nudge.
|
|
7
|
+
* A UI-turn rate measured against one extension set and a nudge fired against
|
|
8
|
+
* another would produce a consultation rate that no fix could ever move,
|
|
9
|
+
* because the denominator and the trigger would be different populations.
|
|
10
|
+
*
|
|
11
|
+
* `.blade.php` is deliberately part of the set and was absent from the hook's
|
|
12
|
+
* original regex: a two-part extension does not match a `\.(…)$` alternation,
|
|
13
|
+
* so every Blade template — the single largest UI surface in a Laravel
|
|
14
|
+
* consumer — read as a non-UI file. That is why `isUiPath` matches on the
|
|
15
|
+
* path tail rather than on `path.extname`, which returns `.php` for
|
|
16
|
+
* `page.blade.php` and would silently reproduce the same hole.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Single-segment UI extensions, without the leading dot. */
|
|
20
|
+
export const UI_EXTENSIONS = [
|
|
21
|
+
'html',
|
|
22
|
+
'htm',
|
|
23
|
+
'css',
|
|
24
|
+
'scss',
|
|
25
|
+
'sass',
|
|
26
|
+
'less',
|
|
27
|
+
'vue',
|
|
28
|
+
'svelte',
|
|
29
|
+
'astro',
|
|
30
|
+
'jsx',
|
|
31
|
+
'tsx',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Multi-segment UI suffixes. These cannot be expressed as an extension
|
|
36
|
+
* alternation — `path.extname('x.blade.php')` is `.php`.
|
|
37
|
+
*/
|
|
38
|
+
export const UI_COMPOUND_SUFFIXES = ['.blade.php'] as const;
|
|
39
|
+
|
|
40
|
+
/** Equivalent to the historical hook regex, plus the compound suffixes. */
|
|
41
|
+
export const UI_EXT = new RegExp(
|
|
42
|
+
`(\\.(${UI_EXTENSIONS.join('|')})|${UI_COMPOUND_SUFFIXES.map((s) => s.replace(/\./g, '\\.')).join('|')})$`,
|
|
43
|
+
'i',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** True when `p` names a file on a UI surface. Case-insensitive. */
|
|
47
|
+
export function isUiPath(p: string): boolean {
|
|
48
|
+
return UI_EXT.test(p);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Directory fragments that mark a UI tree across frameworks. The two UI rules
|
|
53
|
+
* carried only the Laravel pair (`resources/views/`, `resources/js/`), so a
|
|
54
|
+
* React, Vue, Next or Svelte consumer writing to `src/components/` matched no
|
|
55
|
+
* path trigger at all. Kept as fragments, not anchored prefixes: a consumer's
|
|
56
|
+
* UI tree is rarely at the repo root.
|
|
57
|
+
*
|
|
58
|
+
* `app/` is deliberately NOT in this list although it is the Next.js router
|
|
59
|
+
* directory. In a Laravel consumer `app/` is the entire backend, so the
|
|
60
|
+
* fragment would fire on every controller, model and job — a trigger that
|
|
61
|
+
* over-fires on the largest directory of a whole ecosystem is worse than the
|
|
62
|
+
* gap it closes. Next.js route files are caught by extension instead
|
|
63
|
+
* (`page.tsx`, `layout.tsx` match `isUiPath`), which is the precise signal.
|
|
64
|
+
*/
|
|
65
|
+
export const UI_PATH_FRAGMENTS = [
|
|
66
|
+
'resources/views/',
|
|
67
|
+
'resources/js/',
|
|
68
|
+
// `components/` already subsumes `src/components/` under substring
|
|
69
|
+
// matching — listing both here would be dead weight. The rules' own
|
|
70
|
+
// `path_prefix` list keeps both because matching there is anchored.
|
|
71
|
+
'components/',
|
|
72
|
+
'pages/',
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fragments that sit INSIDE a UI tree and are not UI. Checked first, because a
|
|
77
|
+
* substring fragment cannot express "pages/ but not pages/api/" on its own —
|
|
78
|
+
* and `pages/api/` is server-only Next.js code, the exact over-fire the `app/`
|
|
79
|
+
* exclusion above exists to avoid. Leaving it in would have put backend files
|
|
80
|
+
* in the pre-registered UI-turn denominator, which is worse than the nudge
|
|
81
|
+
* firing on them: a rate is harder to un-break than a warning.
|
|
82
|
+
*/
|
|
83
|
+
export const UI_TREE_EXCLUSIONS = ['pages/api/'] as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True when `p` sits inside a conventional UI tree.
|
|
87
|
+
*
|
|
88
|
+
* Note this does NOT require a UI extension — that is the point: a `.js` file
|
|
89
|
+
* under `resources/js/` is UI. The cost is that a non-code file in a UI tree
|
|
90
|
+
* (a README, a fixture) also matches; callers that need the narrower answer
|
|
91
|
+
* combine this with `isUiPath`.
|
|
92
|
+
*/
|
|
93
|
+
export function isUiTreePath(p: string): boolean {
|
|
94
|
+
const normalized = p.replace(/\\/g, '/').toLowerCase();
|
|
95
|
+
if (UI_TREE_EXCLUSIONS.some((fragment) => normalized.includes(fragment))) return false;
|
|
96
|
+
return UI_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment));
|
|
97
|
+
}
|
|
@@ -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
|
|
@@ -28,7 +29,12 @@ import * as path from 'node:path';
|
|
|
28
29
|
import { fileURLToPath } from 'node:url';
|
|
29
30
|
|
|
30
31
|
import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
isSoloConcluded,
|
|
34
|
+
SOLO_FLOOR_MIN_PRESENT,
|
|
35
|
+
wouldSoloFloorHold,
|
|
36
|
+
type QuorumResult,
|
|
37
|
+
} from './quorum.js';
|
|
32
38
|
import type { AbsentReason } from './transport_resolver.js';
|
|
33
39
|
|
|
34
40
|
/**
|
|
@@ -36,8 +42,29 @@ import type { AbsentReason } from './transport_resolver.js';
|
|
|
36
42
|
* `appendQuorumEvent`. Every schema-v1 field keeps its name, type and
|
|
37
43
|
* position, so a v1 reader parses a v2 necessity line unchanged; only the
|
|
38
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.
|
|
39
66
|
*/
|
|
40
|
-
export const SCHEMA_VERSION =
|
|
67
|
+
export const SCHEMA_VERSION = 4;
|
|
41
68
|
|
|
42
69
|
export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
|
|
43
70
|
|
|
@@ -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,
|
|
@@ -96,12 +96,13 @@ export function evaluateQuorum(
|
|
|
96
96
|
* ONE such record now exists, and it is deliberately narrow: ADR-224
|
|
97
97
|
* (`docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md`) authorizes a
|
|
98
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.
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
99
|
+
* only**, decided against a measured 12.5 % solo-conclusion rate. What landed
|
|
100
|
+
* from that authorization is `wouldSoloFloorHold` below, which is a
|
|
101
|
+
* counterfactual and branches nothing — so the sentence above still describes
|
|
102
|
+
* the code: no caller holds a pass on solo status today. It is stated here so
|
|
103
|
+
* a reader finds the authorization instead of re-deriving the prohibition, and
|
|
104
|
+
* so the next branch is visibly a separate argument: the exception is
|
|
105
|
+
* gate-class passes, not solo status generally.
|
|
105
106
|
*
|
|
106
107
|
* `total` is deliberately NOT consulted. A council configured with a single
|
|
107
108
|
* member concludes solo by construction, and that is still a conclusion
|
|
@@ -112,3 +113,79 @@ export function evaluateQuorum(
|
|
|
112
113
|
export function isSoloConcluded(result: QuorumResult): boolean {
|
|
113
114
|
return result.status === 'concluded' && result.present === 1;
|
|
114
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The floor value ADR-224 named: a gate-class pass wants at least two voices.
|
|
119
|
+
*
|
|
120
|
+
* A constant rather than a bare `2` at the call site so the shadow telemetry
|
|
121
|
+
* below and any future enforcement read the same number, and so changing it
|
|
122
|
+
* is one edit with one place to argue about.
|
|
123
|
+
*/
|
|
124
|
+
export const SOLO_FLOOR_MIN_PRESENT = 2;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* WOULD a `min_present` floor have held this pass? Counterfactual only — this
|
|
128
|
+
* predicate changes nothing and no caller branches on it to hold a gate.
|
|
129
|
+
*
|
|
130
|
+
* ADR-224 chose a gate-scoped `min_present: 2` floor against a solo-conclusion
|
|
131
|
+
* rate of 1 of 8 passes, whose 95 % interval spans roughly 0.3 %–53 %. Two
|
|
132
|
+
* facts, both read off the tree rather than assumed, decided that what lands
|
|
133
|
+
* first is the measurement and not the enforcement:
|
|
134
|
+
*
|
|
135
|
+
* - **Nothing in the tree branches on `QuorumStatus` to hold anything.** The
|
|
136
|
+
* only non-`quorum.ts` reader of `'inconclusive'` is
|
|
137
|
+
* `council_cli.ts::_deserialise_quorum`, which validates a persisted string.
|
|
138
|
+
* An enforced floor would therefore change zero behaviour today — there is
|
|
139
|
+
* no gate for it to hold — while still being able to hang an advisory pass
|
|
140
|
+
* the moment a consumer appears.
|
|
141
|
+
* - **ADR-224's own review trigger (b) is "the gate-class floor lands and its
|
|
142
|
+
* own fire-rate telemetry accumulates"**, which presumes exactly this
|
|
143
|
+
* counterfactual is being recorded.
|
|
144
|
+
*
|
|
145
|
+
* So the floor is evaluated on EVERY pass and recorded, never applied. The
|
|
146
|
+
* fire-rate that accumulates from it is what makes the enforcement decision an
|
|
147
|
+
* evidence call later instead of a second point estimate.
|
|
148
|
+
*
|
|
149
|
+
* `minPresent` is clamped with the same discipline `resolveQuorumThreshold`
|
|
150
|
+
* applies, and the clamp carries meaning rather than only safety: where the
|
|
151
|
+
* ceiling resolves to 1 the floor never fires, so a council **configured**
|
|
152
|
+
* with one member is not counted as a degraded pass. That is the split
|
|
153
|
+
* `quorum-attendance-budget.json` already insists on for the solo-conclusion
|
|
154
|
+
* rate — a one-member roster and a two-member roster that lost one are
|
|
155
|
+
* different findings, and an unclamped floor would merge them.
|
|
156
|
+
*
|
|
157
|
+
* **The ceiling is the larger of `result.total` and `configuredTotal`, and it
|
|
158
|
+
* has to be.** Clamping against `result.total` alone was the first version of
|
|
159
|
+
* this function and it was structurally unable to fire on the exact case
|
|
160
|
+
* ADR-224 was decided on: `_postRunQuorum` computes `total` over the roster
|
|
161
|
+
* that CONSTRUCTED, so a two-member council where one member fails to
|
|
162
|
+
* construct reads `total = 1, present = 1`, the ceiling collapses to 1, and a
|
|
163
|
+
* conclusion reached on one of two configured voices records as "the floor
|
|
164
|
+
* would not have held". The series review trigger (b) accumulates would have
|
|
165
|
+
* been biased low by construction — a metric blind to its own target
|
|
166
|
+
* population. `configured_total` exists on the event line precisely to keep
|
|
167
|
+
* that pass distinguishable, and the fix is to consult it.
|
|
168
|
+
*
|
|
169
|
+
* `max`, not `configuredTotal` outright: `total > configuredTotal` is
|
|
170
|
+
* legitimate and not a shortfall, because `--siblings` fans one enabled config
|
|
171
|
+
* ENTRY into N clients. `configuredTotal` counts entries, `total` counts
|
|
172
|
+
* clients, and the honest ceiling is the largest roster the pass could have
|
|
173
|
+
* drawn on either way.
|
|
174
|
+
*
|
|
175
|
+
* A pass that never reached `concluded` is not held by the floor: it failed
|
|
176
|
+
* the threshold, which is a different outcome with a different cause. The two
|
|
177
|
+
* are mutually exclusive by construction, which is what makes them readable
|
|
178
|
+
* apart from the event log alone.
|
|
179
|
+
*/
|
|
180
|
+
export function wouldSoloFloorHold(
|
|
181
|
+
result: QuorumResult,
|
|
182
|
+
minPresent: number = SOLO_FLOOR_MIN_PRESENT,
|
|
183
|
+
configuredTotal?: number,
|
|
184
|
+
): boolean {
|
|
185
|
+
if (result.status !== 'concluded') {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const ceiling = Math.max(result.total, configuredTotal ?? result.total);
|
|
189
|
+
const floor = Math.min(Math.max(Math.trunc(minPresent), 1), ceiling);
|
|
190
|
+
return result.present < floor;
|
|
191
|
+
}
|