@event4u/agent-config 9.33.0 → 9.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +154 -145
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/agent-handoff.md +15 -3
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  7. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  8. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  9. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  10. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  11. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  12. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  13. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  14. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  15. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  18. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  19. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  20. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  21. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  22. package/dist/cli/registry.js +2 -2
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
  25. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  26. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  27. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  28. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  29. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  30. package/dist/cli-delegate/cmd_conformance.js +2 -2
  31. package/dist/cli-delegate/cmd_doctor.js +2 -2
  32. package/dist/cli-delegate/cmd_export.js +2 -2
  33. package/dist/cli-delegate/cmd_handoff.js +3 -3
  34. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  35. package/dist/cli-delegate/cmd_prune.js +2 -2
  36. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  37. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +2 -2
  41. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  42. package/dist/cli-delegate/cmd_validate.js +2 -2
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +13 -13
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +2 -2
  47. package/dist/discovery/dormancy-report.md +17 -0
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +3 -3
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +3 -3
  52. package/dist/hooks/dispatch.js +355 -85
  53. package/dist/install/install.mjs +3 -0
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +192 -12
  57. package/dist/server/schemas/settings.js +1 -0
  58. package/dist/server/schemas/settings.js.map +1 -1
  59. package/docs/contracts/settings-classes.md +3 -2
  60. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  61. package/package.json +2 -2
  62. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  63. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  64. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  65. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  66. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  67. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  68. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  69. package/src/config/agent-settings.template.yml +11 -2
  70. package/src/config/quorum-attendance-budget.json +11 -3
  71. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  72. package/src/scripts/_dispatch.bash +6 -2
  73. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  74. package/src/scripts/_lib/security_lint.ts +18 -4
  75. package/src/scripts/_lib/self_repair.ts +60 -0
  76. package/src/scripts/_lib/self_repair_store.ts +65 -5
  77. package/src/scripts/_lib/session_register.ts +67 -1
  78. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  79. package/src/scripts/ai_council/config.ts +60 -0
  80. package/src/scripts/ai_council/events_log.ts +105 -6
  81. package/src/scripts/ai_council/orchestrator.ts +38 -0
  82. package/src/scripts/ai_council/prompts.ts +165 -3
  83. package/src/scripts/ai_council/quorum.ts +87 -0
  84. package/src/scripts/ai_team/review_gate.ts +2 -2
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/check_claims.ts +51 -2
  87. package/src/scripts/ci_time_ratio.ts +3 -1
  88. package/src/scripts/council_cli.ts +36 -1
  89. package/src/scripts/discovery_graph.ts +201 -27
  90. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  91. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  92. package/src/scripts/lint_framework_leakage.ts +195 -28
  93. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  94. package/src/scripts/lint_handoffs.ts +92 -2
  95. package/src/scripts/lint_hedge_words.ts +317 -0
  96. package/src/scripts/lint_mcp_config_security.ts +1 -1
  97. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  98. package/src/scripts/memory_lookup.ts +1 -2
  99. package/src/scripts/schemas/command.schema.json +4 -0
  100. package/src/scripts/schemas/skill.schema.json +4 -0
  101. package/src/scripts/self_repair_cli.ts +26 -1
  102. package/src/scripts/session_register_hook.ts +238 -18
  103. package/src/scripts/sessions_cli.ts +377 -12
  104. package/src/scripts/skill_linter.ts +58 -2
  105. package/src/server/schemas/settings.ts +3 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Staged-confirmation store — the I/O half, and the half that makes
3
+ * exactly-once real.
4
+ *
5
+ * `staged_confirmation.ts` decides what a confirmation attempt MEANS. It cannot
6
+ * make the transition happen only once: two processes that both read a
7
+ * `pending` record and both conclude `execute` is a check-then-act race, and no
8
+ * pure function closes it. So the claim is a **directory rename**, which the
9
+ * filesystem performs atomically — the first caller moves
10
+ * `pending/<token>.json` to `resolved/`, every later caller gets `ENOENT` and
11
+ * learns the stage is already taken.
12
+ *
13
+ * That is also why the state lives in the path and not only in a field: a
14
+ * read-modify-write of a `state:` key would reopen exactly the window the
15
+ * rename closes.
16
+ *
17
+ * Records live under `agents/runtime/staged-confirmations/`, which the repo
18
+ * `.gitignore` already covers via the `/agents/runtime/` catch-all — a staged
19
+ * action is machine-generated local state, never a tracked artefact. The store
20
+ * mirrors `_lib/self_repair_store.ts`: one file per identity, every read
21
+ * tolerant of a malformed file, and no throw on a missing directory.
22
+ */
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+
26
+ import {
27
+ confirmOnce,
28
+ declineStage,
29
+ type ConfirmOutcome,
30
+ type StageState,
31
+ type StagedAction,
32
+ stageStatus,
33
+ } from './staged_confirmation.js';
34
+
35
+ export const STORE_REL = path.join('agents', 'runtime', 'staged-confirmations');
36
+
37
+ export function storeDir(root: string): string {
38
+ return path.join(root, STORE_REL);
39
+ }
40
+
41
+ function pendingDir(root: string): string {
42
+ return path.join(storeDir(root), 'pending');
43
+ }
44
+
45
+ function resolvedDir(root: string): string {
46
+ return path.join(storeDir(root), 'resolved');
47
+ }
48
+
49
+ /**
50
+ * A token is a filename before it is an identifier, so every site that turns
51
+ * one into a path checks it first — the read verbs, the write verb, and the
52
+ * record guard itself.
53
+ *
54
+ * `deriveToken` returns a 16-char sha256 prefix, so a token this module mints
55
+ * is always safe. Everything else is a way in: `readPending`,
56
+ * `claimConfirmation` and `declineConfirmation` take a caller-supplied token;
57
+ * `putPending` joins `stage.token` from a caller-built record (verified: it
58
+ * wrote outside the store root); and `isStage` accepted any string, so a record
59
+ * written by something else carried its token into the prune path. All four are
60
+ * guarded, and `path.join(pendingDir(root), '../x.json')` resolving outside
61
+ * `pending/` is what makes that necessary.
62
+ */
63
+ const _TOKEN_RE = /^[0-9a-f]{8,64}$/;
64
+
65
+ export function isSafeToken(token: string): boolean {
66
+ return _TOKEN_RE.test(token);
67
+ }
68
+
69
+ /**
70
+ * Every field the module later reads, `staged_at` included.
71
+ *
72
+ * It checked four of five while still asserting `v is StagedAction`, so a
73
+ * record that was valid JSON but carried no `staged_at` passed the guard and
74
+ * reached `listPending`'s `staged_at.localeCompare(...)`: a TypeError thrown
75
+ * through `roadmap_gates.renderPending`, i.e. one malformed file on disk taking
76
+ * out a shipped gate. An unsound guard is worse than no guard — it makes the
77
+ * caller's `!== null` check read as a proof that it is not.
78
+ */
79
+ function isStage(v: unknown): v is StagedAction {
80
+ if (typeof v !== 'object' || v === null) {
81
+ return false;
82
+ }
83
+ const s = v as Partial<StagedAction>;
84
+ return (
85
+ typeof s.token === 'string' &&
86
+ isSafeToken(s.token) &&
87
+ typeof s.action === 'string' &&
88
+ typeof s.object === 'string' &&
89
+ typeof s.staged_at === 'string' &&
90
+ typeof s.expires_at === 'string'
91
+ );
92
+ }
93
+
94
+ function readStageFile(file: string): StagedAction | null {
95
+ try {
96
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'));
97
+ return isStage(parsed) ? parsed : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function writeStageFile(file: string, stage: StagedAction): void {
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(file, `${JSON.stringify(stage, null, 2)}\n`, 'utf-8');
106
+ }
107
+
108
+ /**
109
+ * Persist a freshly staged action. Returns the path written.
110
+ *
111
+ * Throws on an unsafe token rather than returning: a caller reaching here with
112
+ * one built the record itself and has a bug, and `writeStageFile` creates
113
+ * directories, so a silent refusal would look like a successful stage nothing
114
+ * can ever confirm.
115
+ */
116
+ export function putPending(root: string, stage: StagedAction): string {
117
+ if (!isSafeToken(stage.token)) {
118
+ throw new Error(
119
+ `staged-confirmation: refusing to write under an unsafe token '${stage.token}' — ` +
120
+ 'a token is a filename and must match the deriveToken shape',
121
+ );
122
+ }
123
+ const file = path.join(pendingDir(root), `${stage.token}.json`);
124
+ writeStageFile(file, stage);
125
+ return file;
126
+ }
127
+
128
+ export function readPending(root: string, token: string): StagedAction | null {
129
+ if (!isSafeToken(token)) {
130
+ return null;
131
+ }
132
+ return readStageFile(path.join(pendingDir(root), `${token}.json`));
133
+ }
134
+
135
+ export function readResolved(root: string, token: string): StagedAction | null {
136
+ if (!isSafeToken(token)) {
137
+ return null;
138
+ }
139
+ return readStageFile(path.join(resolvedDir(root), `${token}.json`));
140
+ }
141
+
142
+ /**
143
+ * Every stage still in `pending/`, with its effective status at `now` so an
144
+ * expired one is never rendered as awaiting approval. Sorted by `staged_at`
145
+ * (oldest first, ties by token) so two runs on an unchanged store print
146
+ * byte-identical output.
147
+ */
148
+ export function listPending(
149
+ root: string,
150
+ now: number,
151
+ ): { stage: StagedAction; status: StageState | 'expired' }[] {
152
+ let names: string[];
153
+ try {
154
+ names = fs.readdirSync(pendingDir(root));
155
+ } catch {
156
+ return [];
157
+ }
158
+ const out: { stage: StagedAction; status: StageState | 'expired' }[] = [];
159
+ for (const n of names.sort()) {
160
+ if (!n.endsWith('.json')) {
161
+ continue;
162
+ }
163
+ const stage = readStageFile(path.join(pendingDir(root), n));
164
+ if (stage !== null) {
165
+ out.push({ stage, status: stageStatus(stage, now) });
166
+ }
167
+ }
168
+ out.sort(
169
+ (a, b) =>
170
+ a.stage.staged_at.localeCompare(b.stage.staged_at) ||
171
+ a.stage.token.localeCompare(b.stage.token),
172
+ );
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Atomically claim a pending stage and decide the outcome.
178
+ *
179
+ * Order matters and is the whole mechanism:
180
+ *
181
+ * 1. Read the record. An absent one is `token-mismatch` — from the caller's
182
+ * side an unknown token and a wrong token are the same fact, and inventing
183
+ * a third outcome for "was never staged" would only be actionable if the
184
+ * store were a public index, which it is not.
185
+ * 2. Ask `confirmOnce` what the outcome WOULD be. A mismatch, a decline, or
186
+ * an expiry never claims anything — a stale surface must not consume a
187
+ * stage a human might still legitimately confirm.
188
+ * 3. Only for `execute`: rename `pending/` → `resolved/`. That is the claim.
189
+ * `ENOENT` means a concurrent caller won the race, so this caller reports
190
+ * `already-confirmed` and does NOT act — the exactly-once guarantee.
191
+ * 4. Write the confirmed record into `resolved/`, overwriting the moved file
192
+ * with the same content plus `state`/`resolved_at`.
193
+ */
194
+ export function claimConfirmation(
195
+ root: string,
196
+ token: string,
197
+ now: number,
198
+ ): { outcome: ConfirmOutcome; stage: StagedAction | null } {
199
+ if (!isSafeToken(token)) {
200
+ // Nothing this module staged can have that shape, so the honest answer
201
+ // is the same one an unknown token gets — and refusing here is what
202
+ // keeps the rename below off an arbitrary path.
203
+ return { outcome: 'token-mismatch', stage: null };
204
+ }
205
+ const pendingFile = path.join(pendingDir(root), `${token}.json`);
206
+ const stage = readStageFile(pendingFile);
207
+ if (stage === null) {
208
+ const already = readResolved(root, token);
209
+ if (already !== null) {
210
+ // `pruneExpired` moves a stage here WITHOUT rewriting `state`, so the
211
+ // status is derived rather than read off the field — otherwise an
212
+ // expired stage swept out of `pending/` would report as confirmed,
213
+ // which is the one wrong answer available here.
214
+ const status = stageStatus(already, now);
215
+ const outcome: ConfirmOutcome =
216
+ status === 'expired' ? 'expired' : status === 'declined' ? 'declined' : 'already-confirmed';
217
+ return { outcome, stage: already };
218
+ }
219
+ return { outcome: 'token-mismatch', stage: null };
220
+ }
221
+
222
+ const decided = confirmOnce(stage, token, now);
223
+ if (decided.outcome !== 'execute') {
224
+ return decided;
225
+ }
226
+
227
+ const resolvedFile = path.join(resolvedDir(root), `${token}.json`);
228
+ fs.mkdirSync(resolvedDir(root), { recursive: true });
229
+ try {
230
+ fs.renameSync(pendingFile, resolvedFile);
231
+ } catch {
232
+ // Lost the race, or the pending file vanished between the read and the
233
+ // rename. Either way this caller has NOT claimed the stage and must not
234
+ // act on it.
235
+ return { outcome: 'already-confirmed', stage: readResolved(root, token) ?? stage };
236
+ }
237
+ writeStageFile(resolvedFile, decided.stage);
238
+ return decided;
239
+ }
240
+
241
+ /** Decline a pending stage. Same claim discipline as `claimConfirmation`. */
242
+ export function declineConfirmation(
243
+ root: string,
244
+ token: string,
245
+ now: number,
246
+ ): { declined: boolean; stage: StagedAction | null } {
247
+ if (!isSafeToken(token)) {
248
+ return { declined: false, stage: null };
249
+ }
250
+ const pendingFile = path.join(pendingDir(root), `${token}.json`);
251
+ const stage = readStageFile(pendingFile);
252
+ if (stage === null) {
253
+ return { declined: false, stage: readResolved(root, token) };
254
+ }
255
+ const decided = declineStage(stage, token, now);
256
+ if (!decided.declined) {
257
+ return { declined: false, stage };
258
+ }
259
+ const resolvedFile = path.join(resolvedDir(root), `${token}.json`);
260
+ fs.mkdirSync(resolvedDir(root), { recursive: true });
261
+ try {
262
+ fs.renameSync(pendingFile, resolvedFile);
263
+ } catch {
264
+ return { declined: false, stage: readResolved(root, token) ?? stage };
265
+ }
266
+ writeStageFile(resolvedFile, decided.stage);
267
+ return { declined: true, stage: decided.stage };
268
+ }
269
+
270
+ /**
271
+ * Move expired stages out of `pending/` so the enumeration stays a list of
272
+ * live decisions. Housekeeping only — `stageStatus` already derives expiry, so
273
+ * skipping the sweep changes what is *listed*, never what may execute.
274
+ */
275
+ export function pruneExpired(root: string, now: number): number {
276
+ let names: string[];
277
+ try {
278
+ names = fs.readdirSync(pendingDir(root));
279
+ } catch {
280
+ return 0;
281
+ }
282
+ let moved = 0;
283
+ for (const name of names.sort()) {
284
+ if (!name.endsWith('.json')) {
285
+ continue;
286
+ }
287
+ // Move the file that was ENUMERATED, never a path rebuilt from the
288
+ // record's `token` field. The two disagree whenever a filename and its
289
+ // token do — a record written by anything but `putPending` — and the
290
+ // rebuilt path then hits ENOENT, gets swallowed by the catch below, and
291
+ // the stage sits in `pending/` forever while `moved` under-reports it.
292
+ const from = path.join(pendingDir(root), name);
293
+ const stage = readStageFile(from);
294
+ if (stage === null || stageStatus(stage, now) !== 'expired') {
295
+ continue;
296
+ }
297
+ const to = path.join(resolvedDir(root), name);
298
+ fs.mkdirSync(resolvedDir(root), { recursive: true });
299
+ try {
300
+ fs.renameSync(from, to);
301
+ } catch {
302
+ continue;
303
+ }
304
+ moved += 1;
305
+ }
306
+ return moved;
307
+ }
@@ -1207,9 +1207,17 @@ hooks:
1207
1207
  # language — detector B: the user-visible prose is not in the pinned
1208
1208
  # language, with code, quotes, paths and identifiers already
1209
1209
  # excluded (19 measured).
1210
+ # verification — detector C: the turn changed a file and then ran nothing that
1211
+ # could have checked it — no test, type-check, lint or build call
1212
+ # follows the LAST edit. Ordering is the point: a run before the
1213
+ # final edit did not exercise it, which is the freshness clause
1214
+ # verify-before-complete states. Reads the turn's tool calls, not
1215
+ # the prose. Deliberately narrow on what counts as verification —
1216
+ # a missed detection costs one unguarded turn, a false refusal
1217
+ # teaches you to switch the gate off.
1210
1218
  #
1211
- # Both detectors default ON *within* the master switch, so opting in needs
1212
- # one line, and either can be silenced without editing the hook manifest.
1219
+ # All three detectors default ON *within* the master switch, so opting in needs
1220
+ # one line, and any one can be silenced without editing the hook manifest.
1213
1221
  # A refused turn continues in the same turn and is never refused twice —
1214
1222
  # re-entrancy is `stop_hook_active` plus a marker keyed on the last user
1215
1223
  # message, never on the reply.
@@ -1217,6 +1225,7 @@ hooks:
1217
1225
  enabled: false
1218
1226
  promissory: true
1219
1227
  language: true
1228
+ verification: true
1220
1229
 
1221
1230
  # PreToolUse RTK-wrap nudge (token-saving Phase 3). Default-OFF. When enabled
1222
1231
  # AND `rtk` is on PATH (a live probe — not a self-reported flag), warns (exit
@@ -1,11 +1,11 @@
1
1
  {
2
- "_comment": "Pre-registered council ATTENDANCE metrics (road-to-inbox-harvest-2026-08-b Phase 1.4). Registered here rather than in roadmap prose because a roadmap is archived when it closes and a metric outlives it — the sibling budget files (hook-token-budget.json, recycle-threshold-budget.json) already carry the owner / review_by / honest-gap discipline this reuses. The instrument is the quorum_result line appendQuorumEvent writes to agents/runtime/council/events.log (schema v2); nothing here is measured yet, and no threshold is committed before data exists.",
2
+ "_comment": "Pre-registered council ATTENDANCE metrics (road-to-inbox-harvest-2026-08-b Phase 1.4). Registered here rather than in roadmap prose because a roadmap is archived when it closes and a metric outlives it — the sibling budget files (hook-token-budget.json, recycle-threshold-budget.json) already carry the owner / review_by / honest-gap discipline this reuses. The instrument is the quorum_result line appendQuorumEvent writes to agents/runtime/council/events.log (schema v4 since the ADR-224 shadow floor added gate_class + floor_would_hold at v3 and min_present at v4; v2 lines carry none of the three and v3 lines carry no min_present, so a rate over them must exclude rather than assume them); nothing here is measured yet, and no threshold is committed before data exists.",
3
3
  "schema_version": 1,
4
4
  "registered_at": "2026-08-10",
5
5
  "owner": "maintainer",
6
6
  "review_by": "2026-11-10",
7
7
  "unit": "quorum_result event lines in agents/runtime/council/events.log",
8
- "privacy": "a quorum_result line carries {phase, command, dispatch, verdict, threshold, configured_total, total, present, solo, absent[{member, reason}]} — counts, closed vocabularies, and member ids only. The type has no field able to hold free-form content, so the CLI's own `detail` string (provider error text, which can embed paths and prompt fragments) is dropped at the boundary rather than scrubbed.",
8
+ "privacy": "a quorum_result line carries {phase, command, dispatch, verdict, threshold, configured_total, total, present, solo, gate_class, min_present, floor_would_hold, absent[{member, reason}]} — counts, closed vocabularies, booleans, and member ids only. The type has no field able to hold free-form content, so the CLI's own `detail` string (provider error text, which can embed paths and prompt fragments) is dropped at the boundary rather than scrubbed. The two v3 additions are booleans and the v4 addition is a small integer read from config, none of which can widen that surface.",
9
9
  "phase_split": "A pass emits `pre_run` (build_members — who could be constructed) and, on the `run` path, `post_run` (_postRunQuorum — who actually returned a usable response). They disagree whenever a member constructs and then fails mid-flight, so a rate computed over both double-counts. A `pre_run` line is NOT evidence a pass ran: `command: estimate` is a spend-free preview, and a `run` / `debate` line is written before the `--confirm` gate (which defaults to false and returns before any provider call) and before the necessity and size-fit gates. A `post_run` line is the only line that proves providers were called — which is why every rate below is post_run-scoped, and why `command` exists at all.",
10
10
  "attendance_rate": {
11
11
  "definition": "of the members that entered the pass, how many answered: present / total per post_run pass. Bounded by 1 by construction (evaluateQuorum clamps present into [0, total]).",
@@ -21,7 +21,14 @@
21
21
  "solo_conclusion_rate": {
22
22
  "definition": "share of post_run passes with `solo: true` — written by the isSoloConcluded predicate in quorum.ts, never re-derived from the numbers here, so one change to the rule cannot leave this definition stale. ceil(n/2) makes 1-of-2 a legitimate conclusion; this measures how often that legitimacy is used, not whether it should be. THREE cases carry solo: true and they are not the same finding — a degraded multi-member council (configured_total > 1), a council configured with one member (configured_total = 1, dispatch = full), and a deliberate --single dispatch (dispatch = single). Split on dispatch and configured_total before reading the rate.",
23
23
  "instrument": "quorum_result lines, phase=post_run, command=run, field solo, split by dispatch + configured_total",
24
- "threshold": "none committed before data. The quorum-solo-floor blocker pre-registers the three outcomes this rate decides between — a third CLI member, a gate-scoped min_present: 2, or a published null under 5 % — and that choice is made against real data, never ahead of it. It is decided on the degraded case only; a --single pass is the operator asking for one voice, not the council failing to assemble."
24
+ "threshold": "DECIDED 2026-08-11 against real data — see ADR-224 (docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md). The quorum-solo-floor blocker pre-registered three outcomes — a third CLI member, a gate-scoped min_present: 2, or a published null under 5 %. Read at 2026-08-11T09:30Z with the definition above: 1 of 8 post_run/command=run passes carried a degraded solo conclusion = 12.5 %, so the under-5 % null did not fire and the gate-scoped floor was chosen (council 2/2). It was decided on the degraded case only; a --single pass is the operator asking for one voice, not the council failing to assemble. Still no numeric threshold is committed here, deliberately: at n=8 the 95 % interval around 1/8 spans roughly 0.3 %-53 %, so the rate established urgency and not certainty, and ADR-224's review trigger reopens the null at n=40. What this metric measures does NOT change; once the floor lands, how often it FIRES is a second and different question needing its own instrument."
25
+ },
26
+ "shadow_floor_fire_rate": {
27
+ "definition": "share of post_run passes where a min_present floor WOULD have held the pass, had one been enforced: `floor_would_hold: true` over post_run/command=run lines. Written by the wouldSoloFloorHold predicate in quorum.ts, never re-derived from present/threshold here, for the same reason solo_conclusion_rate defers to isSoloConcluded — one definition, so a change to the rule cannot leave this file stale. This is the instrument solo_conclusion_rate's threshold note said the FIRING question would need; it answers 'how often would the floor hold a gate', which is NOT the question solo_conclusion_rate answers ('how often did a pass conclude on one voice'). The two are NOT the same rate and diverge in both directions. solo: true / floor_would_hold: false — a council configured with one member (the ceiling clamps to 1, deliberately). solo: false / floor_would_hold: true — any pass concluding on 2+ voices under a configured quorum_min_present above 2. They also AGREE on the case that matters most: a construction-degraded roster (2 configured, 1 constructed, 1 answered) is solo: true and floor_would_hold: true, because the floor is judged against max(total, configured_total) — judging it against total alone made this pass, the very one ADR-224 was decided on, structurally unable to fire.",
28
+ "instrument": "quorum_result lines, phase=post_run, command=run, field floor_would_hold, split by dispatch + configured_total AND BY min_present (schema v4+)",
29
+ "threshold": "none committed before data — and deliberately none at the review date either unless a gate-class caller exists by then. A fire-rate over passes that gate nothing measures a counterfactual, not a risk; it is registered now so ADR-224's review trigger (b) has an accumulating series rather than a series that starts on the day someone remembers to add one.",
30
+ "split_by_min_present_or_the_rate_is_uninterpretable": "The floor this rate counts against is `quorum_min_present`, which lives in the USER-GLOBAL .ai-council.yml (ADR-104) — per developer machine, gitignored, invisible in this repository. Schema v3 consumed that value and did not record it, so two operators produced byte-identical lines while measuring different counterfactuals, and a pooled rate over them was a mean of incomparable things. Schema v4 records it as `min_present`; group on it before reading, and treat v3 lines as a separate stratum whose floor is UNKNOWN rather than assuming the default. The sharpest case: `quorum_min_present: 1` is valid config and, per the predicate's own test, can never hold anything — so it drives this rate to a permanent zero. Without min_present on the line that zero is indistinguishable from 'the floor genuinely never would have fired', which is precisely the reading ADR-224's review trigger (b) would act on. No threshold was loosened by adding this field; nothing was gated on it before.",
31
+ "nothing_is_enforced": "No value of this metric holds, delays or fails a council pass. wouldSoloFloorHold is a counterfactual with no caller branching on it, and quorum_min_present configures only what the counterfactual is computed against. A reader must not cite this rate as evidence that the floor protected anything — it has never protected anything."
25
32
  },
26
33
  "absent_reason_distribution": {
27
34
  "definition": "count per reason over absent[] entries — no_binary / no_auth / timeout / quota (AbsentReason) plus the two runtime fallbacks the CLI writes directly, `unavailable` (transport resolved to nothing) and `binary_missing` (a CLI client refused to construct). Distinguishes a council degraded by configuration from one degraded by provider behaviour, which have opposite fixes.",
@@ -34,6 +41,7 @@
34
41
  "`lens` and `invocation` are empty on every pre_run line — neither is known at member-construction time — so any per-lens breakdown is post_run-only.",
35
42
  "`command: debate` carries the pre_run half ONLY. There is no _postRunQuorum on the debate path, and defining `usable response` for a multi-round debate is a separate question this registration does not answer. So debate contributes to absent_reason_distribution and to nothing else: attendance_rate and solo_conclusion_rate cover the `run` path, and a reader must not read them as covering every metered pass.",
36
43
  "A pre_run line proves construction happened, never that a pass followed — the `--confirm` gate (default false), the necessity gate and the size-fit gate all abort afterwards and nothing on the line records it. Any count over pre_run lines is an upper bound; pair it with a post_run line before treating it as a pass.",
44
+ "`gate_class` is `false` on EVERY line written so far and will stay false until a caller declares otherwise — nothing in the tree branches on QuorumStatus to hold a gate, so there is no gate-class caller to mark. shadow_floor_fire_rate is therefore a rate over ALL post_run passes, not over gate-class ones, and the gate-class-scoped rate ADR-224 actually cares about is UNMEASURED, not zero. Do not read a `false` majority as evidence that gate-class passes are rare; it is evidence that the concept has no consumer yet.",
37
45
  "The CLI emit wiring itself has no test. The two modules are covered directly, but nothing exercises build_members / cmd_run / cmd_debate end-to-end against a captured log — and `_emitQuorumEvent` forwards no AppendEventOptions, so there is no target to capture into. Two defects in this file's first version (a `dispatch` written from the flag rather than the outcome, and a rate mixing config entries with clients) were found by review rather than by a test, which is what that gap costs."
38
46
  ]
39
47
  }
@@ -57,8 +57,13 @@ export function templateEnvelope(): Record<string, unknown> {
57
57
  acceptance_criteria: ['<what done means, one line each>'],
58
58
  remaining: ['<open work, one line each>'],
59
59
  not_carried_forward: ['<what the successor must re-derive from source>'],
60
- decisions: ['<decision — one-line rationale>'],
60
+ decisions: ['<decision — one-line rationale> [reversible]'],
61
61
  constraints: ['<binding constraint>'],
62
+ // Ships EMPTY, unlike the prose lists above: the entries are path refs,
63
+ // and a `<placeholder>` left in by a hurried writer would be a fake path
64
+ // rather than obvious filler. An empty list is also the honest default —
65
+ // nothing off limits is the common case.
66
+ do_not_touch: [],
62
67
  open_worker_envelopes: [],
63
68
  artifact_paths: ['<path to a deliverable / note / evidence file>'],
64
69
  assumptions: [],
@@ -194,7 +194,10 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
194
194
  code-graph Deterministic code-graph engine (ADR-124, Class A).
195
195
  Usage: code-graph build|validate|detect|query|explain|affected|path [options]
196
196
  sessions:list List live agent sessions on this repository (shared
197
- session register). Flags: --json
197
+ session register) PLUS the unmerged branches checked
198
+ out in other worktrees — the axis that needs no
199
+ claim. Flags: --json, --branches (adds the branch
200
+ axis to the JSON; the human form always shows it)
198
201
  sessions:claim Claim a roadmap for this session so other sessions skip
199
202
  it; --release clears the claim
200
203
  session:recycle Validate + write the main-session recycle envelope (--verify validates only)
@@ -209,7 +212,8 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
209
212
  first. Flags: --all (include maintainer/external),
210
213
  --json, --reply (reply-close form: the one blocking
211
214
  decision in full, the rest as a count; prints
212
- nothing when none are yours)
215
+ nothing when none are yours), --pending (staged
216
+ requires_confirmation actions awaiting you)
213
217
  capabilities:index Regenerate CAPABILITIES.yaml — the package coverage index
214
218
  (capability area → coverage → backing skills/commands → gaps).
215
219
  Pass --check to fail if stale (for CI). Reads src/ (package repo).
@@ -0,0 +1,37 @@
1
+ /**
2
+ * One definition of "is this env kill-switch on?".
3
+ *
4
+ * WHY THIS EXISTS: the predicate was written out twice, byte-for-byte —
5
+ * `ai_council/events_log.ts` and `ai_team/review_gate.ts` each carried a
6
+ * private `_kill_switch_active()` with the identical body AND the identical
7
+ * env var (`AGENT_CONFIG_NO_EVENTS_LOG`). `diff` over the two blocks was empty
8
+ * and no shared helper existed. Two copies of a boolean coercion is two places
9
+ * for the next accepted spelling to land in only one.
10
+ *
11
+ * The subtle half is why `'false'` is listed at all: `process.env` values are
12
+ * strings, so the obvious `Boolean(process.env.X)` reads the *string* `'false'`
13
+ * as true and silently arms a switch the operator explicitly disarmed. The
14
+ * off-set is therefore explicit — unset, `''`, `'0'`, `'false'`, `'False'` —
15
+ * and everything else is on, so a typo fails toward the safer direction (the
16
+ * switch suppresses a log; a spurious suppression is visible, a spurious write
17
+ * is not).
18
+ *
19
+ * SCOPE, stated because the source over-claimed it. `grep -rn "=== 'false'"
20
+ * src/scripts/` returns 10 hits; only the two migrated here share one
21
+ * predicate. The others are deliberately different and stay put:
22
+ * `orchestration_record.ts` is a CLI-flag coercion whose invalid-string
23
+ * fall-through is documented, `turn_end_gate_hook.ts` additionally accepts
24
+ * `yes`/`on`, and `validate_frontmatter.ts` is a YAML scalar parser. Folding
25
+ * those in would change three behaviours to remove three lines.
26
+ */
27
+
28
+ /** The env values that mean "off". Everything else — including a typo — is on. */
29
+ const _OFF_VALUES: ReadonlySet<string> = new Set(['', '0', 'false', 'False']);
30
+
31
+ /**
32
+ * True when the named environment variable is set to anything outside the
33
+ * off-set. Unset reads as off.
34
+ */
35
+ export function isEnvKillSwitchActive(name: string): boolean {
36
+ return !_OFF_VALUES.has(process.env[name] ?? '');
37
+ }
@@ -408,10 +408,13 @@ export function* iter_corpus(
408
408
  * Exit 1 iff at least one finding `is_fail` (HIGH + full weight). WARN-level
409
409
  * (weighted-down or < HIGH) findings print but never fail the build.
410
410
  */
411
- export function report(findings: readonly Finding[], opts: { check_label: string }): number {
411
+ export function report(
412
+ findings: readonly Finding[],
413
+ opts: { check_label: string; scanned_roots?: readonly string[] },
414
+ ): number {
412
415
  const check_label = opts.check_label;
413
416
  if (findings.length === 0) {
414
- process.stdout.write(`✅ ${check_label}: clean (${_corpus_note()}).\n`);
417
+ process.stdout.write(`✅ ${check_label}: clean (${_corpus_note(opts.scanned_roots)}).\n`);
415
418
  return 0;
416
419
  }
417
420
 
@@ -459,8 +462,19 @@ function _pyG(n: number): string {
459
462
  return s;
460
463
  }
461
464
 
462
- function _corpus_note(): string {
463
- return 'scanned ' + DEFAULT_SCAN_ROOTS.join(', ');
465
+ // The clean-path line names the corpus the caller ACTUALLY walked, not the
466
+ // shared default. Two of five callers diverge from `DEFAULT_SCAN_ROOTS`
467
+ // (`lint_skill_frontmatter_safety` replaces the set outright,
468
+ // `lint_mcp_config_security` appends `src/templates`), so a hardcoded note made
469
+ // this gate report a scope in both directions at once: it claimed `src/rules`
470
+ // and `dist/agent-src`, which it never reads, and omitted `src/subagents`,
471
+ // which is the one root holding the artefact its over-broad-grant check exists
472
+ // for. A reader verifying that root by running the gate read the opposite of
473
+ // the truth. Same contract as `_lib/scan_scope.reportScanned` — the emitted
474
+ // scope is the scope that was walked.
475
+ function _corpus_note(scanned_roots?: readonly string[]): string {
476
+ const roots = scanned_roots && scanned_roots.length > 0 ? scanned_roots : DEFAULT_SCAN_ROOTS;
477
+ return 'scanned ' + roots.join(', ');
464
478
  }
465
479
 
466
480
  // ---------------------------------------------------------------------
@@ -538,6 +538,66 @@ export function fingerprint(defect_class: DefectClass, evidence: string): string
538
538
  }
539
539
 
540
540
  /** Fold a finding into an existing record, or open a new one. */
541
+ // ── creation rate cap ──────────────────────────────────────────────
542
+ //
543
+ // `upsertFinding` folds on fingerprint, so a *recurring* defect increments one
544
+ // record and adds no file. What nothing bounded is **creation**: the fingerprint
545
+ // is `defect_class + evidence`, and evidence is a quoted span from the turn, so a
546
+ // defect whose span varies per turn mints a fresh record every time. The only
547
+ // cap in the module pair was `MAX_EVIDENCE`, which bounds a span's *length* and
548
+ // says nothing about how many records may exist.
549
+ //
550
+ // The cap is per `source` because the two intake paths have different plausible
551
+ // rates and a runaway on one must not consume the other's headroom: a
552
+ // `user-reported` record needs the user to complain, and `self-detected` fires at
553
+ // most once per turn per detector. `NEW_RECORDS_PER_SOURCE_PER_WINDOW` sits above
554
+ // any plausible day (three detector classes, a few evidence variants each) and
555
+ // far below a runaway (one per turn over a long session).
556
+ export const NEW_RECORDS_PER_SOURCE_PER_WINDOW = 20;
557
+ export const CREATION_WINDOW_MS = 24 * 60 * 60 * 1000;
558
+
559
+ /**
560
+ * How many records of `source` were CREATED inside the window ending at `now`.
561
+ * Counts `first_seen`, never `last_seen` — an old record still being folded into
562
+ * is not creation and must not consume the budget.
563
+ */
564
+ export function recentCreations(
565
+ existing: readonly DefectRecord[],
566
+ source: DefectSource,
567
+ now: string,
568
+ ): number {
569
+ const end = Date.parse(now);
570
+ if (Number.isNaN(end)) {
571
+ return 0;
572
+ }
573
+ return existing.filter((r) => {
574
+ if (r.source !== source) {
575
+ return false;
576
+ }
577
+ const t = Date.parse(r.first_seen);
578
+ // An unparseable stamp is counted: a malformed record is exactly what a
579
+ // runaway writer produces, and failing open there would uncap the store.
580
+ return Number.isNaN(t) || (end - t < CREATION_WINDOW_MS && end - t >= 0);
581
+ }).length;
582
+ }
583
+
584
+ /**
585
+ * Whether opening a NEW record for `finding` is over budget. A fingerprint that
586
+ * already exists is a fold, never a creation, so it is never capped — the cap
587
+ * must not silence a defect the store is already tracking.
588
+ */
589
+ export function creationCapReached(
590
+ existing: readonly DefectRecord[],
591
+ finding: DefectFinding,
592
+ now: string,
593
+ ): boolean {
594
+ const fp = fingerprint(finding.defect_class, finding.evidence);
595
+ if (existing.some((r) => r.fingerprint === fp)) {
596
+ return false;
597
+ }
598
+ return recentCreations(existing, finding.source, now) >= NEW_RECORDS_PER_SOURCE_PER_WINDOW;
599
+ }
600
+
541
601
  export function mergeRecord(
542
602
  existing: DefectRecord | null,
543
603
  finding: DefectFinding,
@@ -11,7 +11,14 @@
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
 
14
- import { type DefectFinding, type DefectRecord, fingerprint, mergeRecord } from './self_repair.js';
14
+ import {
15
+ creationCapReached,
16
+ type DefectFinding,
17
+ type DefectRecord,
18
+ type DefectSource,
19
+ fingerprint,
20
+ mergeRecord,
21
+ } from './self_repair.js';
15
22
 
16
23
  export const STORE_REL = path.join('agents', 'runtime', 'self-repair');
17
24
 
@@ -61,7 +68,10 @@ export function listRecords(root: string): DefectRecord[] {
61
68
  }
62
69
  const out: DefectRecord[] = [];
63
70
  for (const n of names.sort()) {
64
- if (!n.endsWith('.json')) {
71
+ // The overflow counter shares the directory and the extension but is not
72
+ // a record. `isRecord` would reject it anyway; naming it here keeps the
73
+ // exclusion intentional rather than incidental.
74
+ if (!n.endsWith('.json') || n === OVERFLOW_FILE) {
65
75
  continue;
66
76
  }
67
77
  const rec = readRecord(root, n.slice(0, -'.json'.length));
@@ -77,13 +87,63 @@ export function openRecords(root: string): DefectRecord[] {
77
87
  return listRecords(root).filter((r) => r.status === 'open');
78
88
  }
79
89
 
90
+ /**
91
+ * Per-source counters for findings the creation cap refused. ONE file for the
92
+ * whole store, so the overflow record is bounded by construction — it is the
93
+ * counter a runaway writer increments instead of the file it would otherwise
94
+ * mint. Deliberately NOT a `DefectRecord`: an overflow is not a defect and must
95
+ * not need a `DefectClass`, an issue-form dropdown entry, or an egress route.
96
+ */
97
+ export const OVERFLOW_FILE = '_overflow.json';
98
+
99
+ export type OverflowCounts = Partial<Record<DefectSource, { dropped: number; last_seen: string }>>;
100
+
101
+ function overflowPath(root: string): string {
102
+ return path.join(storeDir(root), OVERFLOW_FILE);
103
+ }
104
+
105
+ export function readOverflow(root: string): OverflowCounts {
106
+ try {
107
+ const parsed: unknown = JSON.parse(fs.readFileSync(overflowPath(root), 'utf-8'));
108
+ return typeof parsed === 'object' && parsed !== null ? (parsed as OverflowCounts) : {};
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
113
+
114
+ function bumpOverflow(root: string, source: DefectSource, now: string): void {
115
+ const counts = readOverflow(root);
116
+ const prev = counts[source]?.dropped ?? 0;
117
+ counts[source] = { dropped: prev + 1, last_seen: now };
118
+ const dir = storeDir(root);
119
+ fs.mkdirSync(dir, { recursive: true });
120
+ fs.writeFileSync(overflowPath(root), `${JSON.stringify(counts, null, 2)}\n`, 'utf-8');
121
+ }
122
+
80
123
  /**
81
124
  * Fold a finding into the store: increments the existing record when the
82
- * fingerprint matches, otherwise opens a new one. Returns the stored record.
125
+ * fingerprint matches, otherwise opens a new one. Returns the stored record, or
126
+ * `null` when the per-source creation cap refused to open a new one.
127
+ *
128
+ * A refusal is COUNTED, never silent — `readOverflow` carries the per-source
129
+ * drop tally, so "the cap fired" is a readable fact rather than a defect that
130
+ * disappeared. A cap that quietly discarded reports would break this loop's own
131
+ * Iron Law (a defect is queued, never shrugged off) in the name of bounding it.
83
132
  */
84
- export function upsertFinding(root: string, finding: DefectFinding, now: string): DefectRecord {
133
+ export function upsertFinding(
134
+ root: string,
135
+ finding: DefectFinding,
136
+ now: string,
137
+ ): DefectRecord | null {
85
138
  const fp = fingerprint(finding.defect_class, finding.evidence);
86
- const merged = mergeRecord(readRecord(root, fp), finding, now);
139
+ const existing = readRecord(root, fp);
140
+ // The fold path never consults the cap: a fingerprint already in the store
141
+ // adds no file, so capping it would silence a tracked defect for no bound.
142
+ if (existing === null && creationCapReached(listRecords(root), finding, now)) {
143
+ bumpOverflow(root, finding.source, now);
144
+ return null;
145
+ }
146
+ const merged = mergeRecord(existing, finding, now);
87
147
  writeRecord(root, merged);
88
148
  return merged;
89
149
  }