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