@xhulz/nina 0.37.0 → 0.38.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 (130) hide show
  1. package/package.json +1 -1
  2. package/releases/0.38.0/core/GAPS.md +125 -0
  3. package/releases/0.38.0/core/answered.json +32 -0
  4. package/releases/0.38.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  5. package/releases/0.38.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  6. package/releases/0.38.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  7. package/releases/0.38.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  8. package/releases/0.38.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  9. package/releases/0.38.0/core/defaults/tree/.claude/agents/qa.md +2 -0
  10. package/releases/0.38.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  11. package/releases/0.38.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  12. package/releases/0.38.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  13. package/releases/0.38.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  14. package/releases/0.38.0/core/tree/.claude/agents/architect.md +127 -0
  15. package/releases/0.38.0/core/tree/.claude/agents/dba.md +110 -0
  16. package/releases/0.38.0/core/tree/.claude/agents/devops.md +114 -0
  17. package/releases/0.38.0/core/tree/.claude/agents/implementer.md +156 -0
  18. package/releases/0.38.0/core/tree/.claude/agents/integration-tester.md +168 -0
  19. package/releases/0.38.0/core/tree/.claude/agents/planner.md +132 -0
  20. package/releases/0.38.0/core/tree/.claude/agents/qa.md +152 -0
  21. package/releases/0.38.0/core/tree/.claude/agents/reviewer.md +183 -0
  22. package/releases/0.38.0/core/tree/.claude/agents/secops.md +121 -0
  23. package/releases/0.38.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  24. package/releases/0.38.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  25. package/releases/0.38.0/core/tree/.claude/agents-overview.md +64 -0
  26. package/releases/0.38.0/core/tree/.claude/frontend.md +10 -0
  27. package/releases/0.38.0/core/tree/.claude/graph.md +75 -0
  28. package/releases/0.38.0/core/tree/.claude/patterns.md +213 -0
  29. package/releases/0.38.0/core/tree/.claude/pills/README.md +147 -0
  30. package/releases/0.38.0/core/tree/.claude/pipeline.md +101 -0
  31. package/releases/0.38.0/core/tree/.claude/retrieval.md +103 -0
  32. package/releases/0.38.0/core/tree/.claude/router.md +198 -0
  33. package/releases/0.38.0/core/tree/.claude/templates/integration.md +45 -0
  34. package/releases/0.38.0/core/tree/CLAUDE.md +161 -0
  35. package/releases/0.38.0/core/tree/scripts/cost-watch.mjs +18 -0
  36. package/releases/0.38.0/core/tree/scripts/edit-guard.mjs +16 -0
  37. package/releases/0.38.0/core/tree/scripts/harness-check.mjs +71 -0
  38. package/releases/0.38.0/core/tree/scripts/loop-gate.mjs +19 -0
  39. package/releases/0.38.0/core/vocabulary.json +13 -0
  40. package/releases/0.38.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  41. package/releases/0.38.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  42. package/releases/0.38.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  43. package/releases/0.38.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  44. package/releases/0.38.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  45. package/releases/0.38.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  46. package/releases/0.38.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  47. package/releases/0.38.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  48. package/releases/0.38.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  49. package/releases/0.38.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  50. package/releases/0.38.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
  51. package/releases/0.38.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  52. package/releases/0.38.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  53. package/releases/0.38.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  54. package/releases/0.38.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  55. package/releases/0.38.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  56. package/releases/0.38.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  57. package/releases/0.38.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  58. package/releases/0.38.0/surfaces/db/tree/.claude/graph.md +8 -0
  59. package/releases/0.38.0/surfaces/db/tree/.claude/patterns.md +19 -0
  60. package/releases/0.38.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  61. package/releases/0.38.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  62. package/releases/0.38.0/surfaces/db/tree/.claude/router.md +4 -0
  63. package/releases/0.38.0/surfaces/db/tree/CLAUDE.md +17 -0
  64. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  65. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  66. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
  67. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  68. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  69. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  70. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  71. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  72. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  73. package/releases/0.38.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  74. package/releases/0.38.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  75. package/releases/0.38.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  76. package/releases/0.38.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  77. package/releases/0.38.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  78. package/releases/0.38.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  79. package/releases/0.38.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  80. package/releases/0.38.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  81. package/releases/0.38.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  82. package/releases/0.38.0/surfaces/frontend/tree/.claude/router.md +14 -0
  83. package/releases/0.38.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  84. package/releases/0.38.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  85. package/releases/0.38.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  86. package/releases/0.38.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  87. package/releases/0.38.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  88. package/releases/0.38.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  89. package/releases/0.38.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  90. package/releases/0.38.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  91. package/releases/0.38.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  92. package/releases/0.38.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  93. package/releases/0.38.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  94. package/releases/0.38.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  95. package/releases/0.38.0/surfaces/integrations/tree/.claude/router.md +6 -0
  96. package/releases/0.38.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  97. package/releases/0.38.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  98. package/releases/0.38.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  99. package/releases/0.38.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  100. package/releases/0.38.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  101. package/releases/0.38.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  102. package/releases/0.38.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  103. package/releases/0.38.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  104. package/releases/0.38.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  105. package/releases/0.38.0/surfaces/money/tree/.claude/graph.md +2 -0
  106. package/releases/0.38.0/surfaces/money/tree/.claude/patterns.md +49 -0
  107. package/releases/0.38.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  108. package/releases/0.38.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  109. package/releases/0.38.0/surfaces/money/tree/CLAUDE.md +11 -0
  110. package/releases/0.38.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  111. package/releases/0.38.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  112. package/releases/0.38.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  113. package/releases/0.38.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  114. package/releases/0.38.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  115. package/releases/0.38.0/surfaces/pii/tree/CLAUDE.md +5 -0
  116. package/releases/0.38.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  117. package/releases/0.38.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
  118. package/releases/0.38.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  119. package/releases/0.38.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  120. package/releases/0.38.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  121. package/releases/0.38.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  122. package/releases/0.38.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  123. package/releases/0.38.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  124. package/src/commands/learn.mjs +226 -71
  125. package/src/commands/pills.mjs +32 -9
  126. package/src/commands/stats.mjs +4 -4
  127. package/src/deep.mjs +26 -4
  128. package/src/gate.mjs +10 -0
  129. package/src/lessons.mjs +55 -0
  130. package/src/wiring.mjs +2 -1
@@ -29,7 +29,7 @@ import { HARNESS, legacyHint, slugFor, snapshotsDir } from '../paths.mjs';
29
29
  import { localDay, projectRecords } from '../store.mjs';
30
30
  import { isLoopBack, runOf } from '../transcripts.mjs';
31
31
  import { REQUIRES, byVersion, layerRootFor } from './compose.mjs';
32
- import { GRADUATION_AT, frontmatter, graduationTarget, list, pillFiles, roleGates, tidyRetired } from './pills.mjs';
32
+ import { GRADUATION_AT, RETIRED, frontmatter, graduationTarget, list, pillFiles, roleGates, tidyRetired } from './pills.mjs';
33
33
  import { decodeProjectDir } from './stats.mjs';
34
34
  import { snapshot } from './snapshot.mjs';
35
35
  import { loadProject, projectGateDir, readLedger, replay } from '../gate.mjs';
@@ -106,6 +106,38 @@ export async function lessons(target) {
106
106
  return out;
107
107
  }
108
108
 
109
+ /** How long after a loop-back's report a dispatch can still be its fix. */
110
+ const SENT_BACK_HOURS = 24;
111
+
112
+ /**
113
+ * The stage each loop-back sent work back to: the next one dispatched in its session, after it reported, among
114
+ * the stages the project's graph routes that verdict of that stage to — or the only one it routes it to.
115
+ *
116
+ * @param {object[]} records - Recorded dispatches.
117
+ * @param {Map<string, Map<string, Map<string, number>>>|null} loops - The graph's capped loop-back edges.
118
+ * @returns {Map<object, string>} Loop-back record → the stage it sent work back to, where one can be told.
119
+ */
120
+ export function sentBackTo(records, loops) {
121
+ const out = new Map();
122
+ if (!loops) return out;
123
+ const bySession = new Map();
124
+ for (const r of records) if (r.session) bySession.set(r.session, [...(bySession.get(r.session) ?? []), r]);
125
+ for (const rs of bySession.values()) rs.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
126
+ for (const r of records) {
127
+ if (!readable(r.verdict) || !isLoopBack(r.verdict)) continue;
128
+ const targets = loops.get(r.role)?.get(r.verdict);
129
+ if (!targets || targets.size === 0) continue;
130
+ const back = String(r.result_ts ?? r.ts);
131
+ // Within a day of the report: one project ran six weeks in one session, and the next architect there could be
132
+ // five days on, with no telling it was this loop-back's fix.
133
+ const within = (x) => Date.parse(x.ts) - Date.parse(back) <= SENT_BACK_HOURS * 3_600_000;
134
+ const next = (bySession.get(r.session) ?? []).find((x) => String(x.ts) > back && within(x) && targets.has(x.role));
135
+ const to = next?.role ?? (targets.size === 1 ? [...targets.keys()][0] : null);
136
+ if (to) out.set(r, to);
137
+ }
138
+ return out;
139
+ }
140
+
109
141
  /**
110
142
  * Roles that looped back often enough, since their newest lesson, that one is overdue.
111
143
  *
@@ -118,9 +150,13 @@ export async function lessons(target) {
118
150
  * lesson: a count alone sends them digging through weeks of transcripts, which is how a warning
119
151
  * becomes one more thing to ignore.
120
152
  */
121
- export function overdue(records, known, now = new Date(), days = CAPTURE_DAYS) {
153
+ export function overdue(records, known, now = new Date(), days = CAPTURE_DAYS, owners = new Map()) {
122
154
  const cutoff = day(new Date(now.getTime() - days * 86_400_000).toISOString());
123
- const roles = [...new Set(records.map((r) => r.role).filter(Boolean))];
155
+ // A loop-back is the lesson of the stage it sent work back to, where that is known (`sentBackTo`): charged
156
+ // to the stage that gave the verdict, the reviewer owed the implementer's lessons, and only a pill naming
157
+ // every checker in its applies_to ever settled the count.
158
+ const ownerOf = (r) => owners.get(r) ?? r.role;
159
+ const roles = [...new Set(records.map(ownerOf).filter(Boolean))];
124
160
  const out = [];
125
161
  for (const role of roles) {
126
162
  // A retired lesson was still written down: it retired because its rule moved into the harness,
@@ -138,7 +174,7 @@ export function overdue(records, known, now = new Date(), days = CAPTURE_DAYS) {
138
174
  // thing capture exists to catch.
139
175
  const at = floor === newest ? taught?.at ?? null : null;
140
176
  const after = (r) => day(r.ts) > floor || (at !== null && day(r.ts) === floor && String(r.ts) > at);
141
- const loops = records.filter((r) => r.role === role && readable(r.verdict) && isLoopBack(r.verdict) && after(r));
177
+ const loops = records.filter((r) => ownerOf(r) === role && readable(r.verdict) && isLoopBack(r.verdict) && after(r));
142
178
  if (loops.length >= CAPTURE_AT) {
143
179
  const recent = loops
144
180
  .sort((a, b) => String(b.ts).localeCompare(String(a.ts)))
@@ -177,50 +213,129 @@ export function applied(records, known) {
177
213
  };
178
214
  }
179
215
 
216
+ /** How many days either side of a lesson's date its role is measured over: the same on both sides. */
217
+ const VERIFY_DAYS = 14;
218
+
219
+ /** A day as `YYYY-MM-DD`, moved by some days. */
220
+ const shift = (date, days) => new Date(Date.parse(`${date}T12:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
221
+
180
222
  /**
181
- * The loop-back rate of a lesson's roles before it was written and after.
223
+ * The 95% Wilson interval of a share.
182
224
  *
183
- * Not proof — the work changes too, and a small sample swings — but without it the pipeline
184
- * cannot tell a lesson that helped from one that did not, even in principle.
225
+ * @param {number} k - How many.
226
+ * @param {number} n - Out of how many.
227
+ * @returns {[number, number]}
228
+ */
229
+ export function wilson(k, n) {
230
+ if (n === 0) return [0, 1];
231
+ const z = 1.96;
232
+ const p = k / n;
233
+ const d = 1 + (z * z) / n;
234
+ const c = p + (z * z) / (2 * n);
235
+ const m = z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n));
236
+ return [(c - m) / d, (c + m) / d];
237
+ }
238
+
239
+ /**
240
+ * How often a lesson's stage had its work sent back before the lesson was written and after, stage by stage.
241
+ *
242
+ * It measured the rate of the stage's own verdicts. For a lesson about the implementer that was its BLOCKED
243
+ * rate, mostly unreadable, while the reviewer's rejections of its work — what the lesson was about — went to
244
+ * the reviewer's account; a lesson for several stages pooled them and read one model; and "before" and
245
+ * "after" were all of history either side, so a lesson written in a project's first days had nothing to
246
+ * compare. Now a stage is read on its own, over the same fourteen days either side, as the share of its
247
+ * rounds that a later stage sent back to it (`sentBackTo`), beside the same share for every other stage the
248
+ * graph sends work back to. A change is called only where the two intervals do not overlap. Not proof — the
249
+ * work changes too — but a number where one can be had, and the reason where it cannot.
185
250
  *
186
251
  * @param {object[]} records - Recorded dispatches.
187
252
  * @param {Awaited<ReturnType<typeof lessons>>} known - The project's pills.
188
- * Each comes with a control: every OTHER role, split at the same date. A lesson is written because
189
- * its role just started failing, so "after" is partly the same bad patch; and the whole pipeline's
190
- * rate moves with the work. The control does not remove either, but it shows whether the lesson's
191
- * roles moved differently from everything else, which the lesson's own numbers cannot.
192
- *
193
- * @returns {{rel: string, date: string, on: string|null, comparable: boolean, was?: string, before?: {n: number, loops: number}, after?: {n: number, loops: number}, control?: {before: {n: number, loops: number}, after: {n: number, loops: number}}}[]}
253
+ * @param {{owners?: Map<object, string>, targets?: Set<string>|null}} [graph] - Who each loop-back sent work
254
+ * back to, and the stages the graph sends work back to at all.
255
+ * @returns {{rel: string, date: string, role: string, comparable: boolean, why?: string, on?: string|null,
256
+ * was?: string, before?: {n: number, loops: number}, after?: {n: number, loops: number},
257
+ * change?: 'lower'|'higher'|'unclear', control?: {before: {n: number, loops: number}, after: {n: number, loops: number}}}[]}
194
258
  */
195
- export function verified(records, known) {
259
+ export function verified(records, known, { owners = new Map(), targets = null } = {}) {
196
260
  const out = [];
261
+ const ownerOf = (r) => owners.get(r) ?? r.role;
262
+ const sentBack = records.filter((r) => readable(r.verdict) && isLoopBack(r.verdict));
263
+ const ranOn = (r) => (r.usage_model ? `${r.usage_model}${r.effort ? ` · ${r.effort}` : ''}` : null);
197
264
  for (const lesson of known.filter((l) => l.date)) {
198
- const rate = (rs, pick) => {
199
- const s = rs.filter(pick);
200
- return { n: s.length, loops: s.filter((r) => isLoopBack(r.verdict)).length };
201
- };
202
- const all = records.filter((r) => lesson.roles.includes(r.role) && readable(r.verdict));
203
- const others = records.filter((r) => r.role && !lesson.roles.includes(r.role) && readable(r.verdict));
204
- const pre = (r) => day(r.ts) < lesson.date;
205
- const post = (r) => day(r.ts) > lesson.date;
206
- if (rate(all, pre).n < VERIFY_MIN || rate(all, post).n < VERIFY_MIN) continue;
207
- // Read on one model and effort level: the one the roles ran on most after the lesson. A lesson written
208
- // the week a role moved to a stricter model read as making it worse — one project's reviewer went from
209
- // 2% to 29% "after" a lesson, and from Sonnet to Opus in the same days. Where the roles ran on it too
210
- // little before the lesson, the two sides are not comparable, and that is said rather than a number.
211
- const ranOn = (r) => (r.usage_model ? `${r.usage_model}${r.effort ? ` · ${r.effort}` : ''}` : null);
212
- const tally = new Map();
213
- for (const r of all.filter(post)) if (ranOn(r)) tally.set(ranOn(r), (tally.get(ranOn(r)) ?? 0) + 1);
214
- const on = [...tally].sort((x, y) => y[1] - x[1])[0]?.[0] ?? null;
215
- const mine = on ? all.filter((r) => ranOn(r) === on) : all;
216
- const before = rate(mine, pre);
217
- const after = rate(mine, post);
218
- if (before.n < VERIFY_MIN || after.n < VERIFY_MIN) {
219
- const was = [...new Set(all.filter(pre).map(ranOn).filter(Boolean))].join(', ') || 'no recorded model';
220
- out.push({ rel: lesson.rel, date: lesson.date, on, was, comparable: false });
221
- continue;
265
+ const from = shift(lesson.date, -VERIFY_DAYS);
266
+ const until = shift(lesson.date, VERIFY_DAYS);
267
+ const pre = (r) => day(r.ts) >= from && day(r.ts) < lesson.date;
268
+ const post = (r) => day(r.ts) > lesson.date && day(r.ts) <= until;
269
+ for (const role of lesson.roles) {
270
+ const base = { rel: lesson.rel, date: lesson.date, role };
271
+ if (targets && !targets.has(role)) {
272
+ out.push({ ...base, comparable: false, why: `nothing in the graph sends work back to ${role}` });
273
+ continue;
274
+ }
275
+ const rounds = records.filter((r) => r.role === role && readable(r.verdict));
276
+ const backs = sentBack.filter((r) => ownerOf(r) === role);
277
+ const side = (pick, mine) => {
278
+ const n = mine.filter(pick).length;
279
+ return { n, loops: Math.min(n, backs.filter(pick).length) };
280
+ };
281
+ if (side(pre, rounds).n < VERIFY_MIN || side(post, rounds).n < VERIFY_MIN) {
282
+ const [b, a] = [side(pre, rounds).n, side(post, rounds).n];
283
+ out.push({ ...base, comparable: false, why: `too few rounds of ${role} either side (${b} before, ${a} after, in ${VERIFY_DAYS} days each)` });
284
+ continue;
285
+ }
286
+ // Read on the model and effort the stage ran on most after the lesson: one written the week its stage moved
287
+ // to a stricter model read as making it worse — one reviewer went from 2% to 29%, and from Sonnet to Opus.
288
+ const tally = new Map();
289
+ for (const r of rounds.filter(post)) if (ranOn(r)) tally.set(ranOn(r), (tally.get(ranOn(r)) ?? 0) + 1);
290
+ const on = [...tally].sort((x, y) => y[1] - x[1])[0]?.[0] ?? null;
291
+ const mine = on ? rounds.filter((r) => ranOn(r) === on) : rounds;
292
+ const before = side(pre, mine);
293
+ const after = side(post, mine);
294
+ if (before.n < VERIFY_MIN || after.n < VERIFY_MIN) {
295
+ const was = [...new Set(rounds.filter(pre).map(ranOn).filter(Boolean))].join(', ') || 'no recorded model';
296
+ out.push({ ...base, on, was, comparable: false, why: `${role} ran on ${on} after it, and before it on ${was}` });
297
+ continue;
298
+ }
299
+ const [bLo, bHi] = wilson(before.loops, before.n);
300
+ const [aLo, aHi] = wilson(after.loops, after.n);
301
+ const change = aHi < bLo ? 'lower' : aLo > bHi ? 'higher' : 'unclear';
302
+ const others = [...(targets ?? new Set(sentBack.map(ownerOf)))].filter((x) => x !== role);
303
+ const theirs = records.filter((r) => others.includes(r.role) && readable(r.verdict));
304
+ const otherBacks = sentBack.filter((r) => others.includes(ownerOf(r)));
305
+ const control = (pick) => ({ n: theirs.filter(pick).length, loops: Math.min(theirs.filter(pick).length, otherBacks.filter(pick).length) });
306
+ out.push({ ...base, comparable: true, on, before, after, change, control: { before: control(pre), after: control(post) } });
222
307
  }
223
- out.push({ rel: lesson.rel, date: lesson.date, on, comparable: true, before, after, control: { before: rate(others, pre), after: rate(others, post) } });
308
+ }
309
+ return out;
310
+ }
311
+
312
+ /**
313
+ * Lessons that came back after the rule they graduated into reached this project: their newest request is
314
+ * closed, and the pill has been counted more times since it was filed. The pill is retired by then, and
315
+ * nothing read a retired pill's count — one project's lesson graduated twice and was learned a third time,
316
+ * while two retired pills went from 3 to 8 and from 5 to 11 without a word to anyone.
317
+ *
318
+ * @param {Awaited<ReturnType<typeof lessons>>} known - The project's pills, retired ones included.
319
+ * @param {Awaited<ReturnType<typeof filedRequests>>} filed - Its requests.
320
+ * @returns {{lesson: object, request: object, recurred: number}[]}
321
+ */
322
+ export function relapsed(known, filed) {
323
+ // A pill with a request still open is waiting on the harness, whatever the dates say. Of the closed ones, the
324
+ // newest by date — and on one date the one that counted most, since a reopening is filed after what it reopens;
325
+ // by the order a directory lists them, a reopening filed the same day read as older, and was filed again.
326
+ const open = new Set(filed.filter((r) => r.status === 'open').map((r) => r.pill));
327
+ const newest = new Map();
328
+ for (const r of filed) {
329
+ if (!r.pill || r.status !== 'closed' || open.has(r.pill)) continue;
330
+ const had = newest.get(r.pill);
331
+ if (!had || r.date > had.date || (r.date === had.date && Number(r.occurrences) > Number(had.occurrences))) newest.set(r.pill, r);
332
+ }
333
+ const out = [];
334
+ for (const [pill, request] of newest) {
335
+ const shelved = pill.replace(/^\.claude\/pills\//, `.claude/pills/${RETIRED}/`);
336
+ const lesson = known.find((l) => l.rel === pill || l.rel === shelved);
337
+ const then = Number(request.occurrences);
338
+ if (lesson && Number.isFinite(then) && lesson.occurrences > then) out.push({ lesson, request, recurred: lesson.occurrences - then });
224
339
  }
225
340
  return out;
226
341
  }
@@ -250,7 +365,17 @@ export async function filedRequests(target) {
250
365
  const out = [];
251
366
  for (const f of (await readdir(dir).catch(() => [])).filter((f) => f.endsWith('.md'))) {
252
367
  const fields = frontmatter(await readFile(join(dir, f), 'utf8')) ?? {};
253
- out.push({ file: f, id: f.replace(/\.md$/, ''), status: fields.status ?? '', pill: fields.pill ?? '', target: fields.target ?? '', date: fields.date ?? '' });
368
+ out.push({
369
+ file: f,
370
+ id: f.replace(/\.md$/, ''),
371
+ status: fields.status ?? '',
372
+ pill: fields.pill ?? '',
373
+ target: fields.target ?? '',
374
+ date: fields.date ?? '',
375
+ occurrences: fields.occurrences ?? '',
376
+ rule_in: fields.rule_in ?? '',
377
+ answered_in: fields.answered_in ?? '',
378
+ });
254
379
  }
255
380
  return out;
256
381
  }
@@ -474,15 +599,23 @@ async function deepReport(target, records, known, argv) {
474
599
  * @param {Awaited<ReturnType<typeof lessons>>[number]} lesson - A lesson that names its roles.
475
600
  * @returns {Promise<{file: string, layer: string, why: string}>}
476
601
  */
477
- async function fileRequest(target, profile, layerRoot, lesson) {
602
+ async function fileRequest(target, profile, layerRoot, lesson, { reopens = null, recurred = 0 } = {}) {
478
603
  const target_ = graduationTarget(lesson.roles, await roleGates(layerRoot));
479
604
  const date = localDay(new Date().toISOString());
605
+ // Asked about the pill where it was learned, a retired one included: its request names that path.
606
+ const pill = reopens?.pill ?? lesson.rel;
480
607
  // The directory separator becomes `__`, so `reviewer/a.b.md` and `reviewer-a/b.md` stay two
481
608
  // names — flattening both `/` and `.` to `-` gave them the same one.
482
- const name = lesson.rel.replace(/^\.claude\/pills\//, '').replace(/\.md$/, '').replace(/\//g, '__');
609
+ const name = pill.replace(/^\.claude\/pills\//, '').replace(/\.md$/, '').replace(/\//g, '__');
483
610
  const dir = join(target, HARNESS, 'requests');
484
- const file = `${date}-${name}.md`;
611
+ // Never over a request already written: the same lesson can be sent more than once on one day.
612
+ let file = `${date}-${name}.md`;
613
+ for (let n = 1; existsSync(join(dir, file)); n += 1) file = `${date}-${name}-again${n > 1 ? `-${n}` : ''}.md`;
485
614
  await mkdir(dir, { recursive: true });
615
+ const heading = reopens
616
+ ? `**A lesson that graduated into \`${reopens.rule_in || reopens.target}\` in ${reopens.answered_in || 'a release'} came back ${recurred} time(s) since** — ` +
617
+ `the rule did not hold here. It reopens ${HARNESS}/requests/${reopens.file}.`
618
+ : `**A lesson this project has learned ${lesson.occurrences} time(s), proposed as a rule in \`${target_.layer}\`** — ${target_.why}.`;
486
619
  await writeFile(
487
620
  join(dir, file),
488
621
  [
@@ -491,12 +624,13 @@ async function fileRequest(target, profile, layerRoot, lesson) {
491
624
  'status: open',
492
625
  `date: ${date}`,
493
626
  `core: ${profile.core}`,
494
- `target: ${target_.layer}`,
495
- `pill: ${lesson.rel}`,
627
+ `target: ${reopens?.target || target_.layer}`,
628
+ `pill: ${pill}`,
496
629
  `occurrences: ${lesson.occurrences}`,
630
+ ...(reopens ? [`reopens: ${reopens.file}`, `rule_in: ${oneLine(reopens.rule_in)}`] : []),
497
631
  '---',
498
632
  '',
499
- `**A lesson this project has learned ${lesson.occurrences} time(s), proposed as a rule in \`${target_.layer}\`** — ${target_.why}.`,
633
+ heading,
500
634
  '',
501
635
  `Learned against core ${profile.core}. The rule lands in a release of the harness, and the`,
502
636
  '`nina upgrade` that installs that release closes this request and retires the pill — not before.',
@@ -504,6 +638,7 @@ async function fileRequest(target, profile, layerRoot, lesson) {
504
638
  lesson.body,
505
639
  '',
506
640
  ].join('\n'),
641
+ { flag: 'wx' },
507
642
  );
508
643
  return { file, layer: target_.layer, why: target_.why };
509
644
  }
@@ -589,7 +724,11 @@ export async function learn(argv, ctx) {
589
724
  }
590
725
  if (argv.includes('--close')) return close(target, argv[argv.indexOf('--close') + 1] ?? '');
591
726
 
592
- const late = overdue(records, known, new Date(), days);
727
+ // Who each loop-back sent work back to, read off the project's own graph.
728
+ const loops = loadProject(target)?.loops ?? null;
729
+ const owners = sentBackTo(records, loops);
730
+ const targets = loops ? new Set([...loops.values()].flatMap((byToken) => [...byToken.values()].flatMap((to) => [...to.keys()]))) : null;
731
+ const late = overdue(records, known, new Date(), days, owners);
593
732
  const filed = await filedRequests(target);
594
733
  const owed = unfiled(known, filed);
595
734
 
@@ -614,20 +753,22 @@ export async function learn(argv, ctx) {
614
753
  // once. The per-line actions matter too: one hint for the whole detector told a project to
615
754
  // "write the lesson" when the lesson was written three times over and only needed sending.
616
755
  const sent = [];
617
- const stuck = owed
618
- .filter((l) => l.roles.length === 0)
619
- .map((lesson) => ({ lesson, why: 'names no role in applies_to, so no layer can take it', fix: 'fix its frontmatter — `nina pills` says what is wrong' }));
756
+ // A lesson that came back after its rule shipped goes back to the harness the way it went the first time.
757
+ const outgoing = [...owed.map((lesson) => ({ lesson })), ...relapsed(known, filed).map(({ lesson, request, recurred }) => ({ lesson, reopens: request, recurred }))];
758
+ const stuck = outgoing
759
+ .filter((o) => o.lesson.roles.length === 0)
760
+ .map((o) => ({ ...o, why: 'names no role in applies_to, so no layer can take it', fix: 'fix its frontmatter — `nina pills` says what is wrong' }));
620
761
  // Sent from the Stop hook, a request was news only the person heard: its one line said the model would
621
762
  // be told, and by the next message the lesson was no longer owed, so nothing told it. It is sent by the
622
763
  // run that hands its findings to the model, or by hand; the Stop hook says it is about to go.
623
- const due = beforeModel() ? owed.filter((l) => l.roles.length > 0) : [];
624
- for (const lesson of beforeModel() ? [] : owed.filter((l) => l.roles.length > 0)) {
764
+ const due = beforeModel() ? outgoing.filter((o) => o.lesson.roles.length > 0) : [];
765
+ for (const o of beforeModel() ? [] : outgoing.filter((o) => o.lesson.roles.length > 0)) {
625
766
  // A detector that throws is reported as one that could not run, every turn, and takes the
626
767
  // rest of its findings with it — so a request that cannot be written is a finding instead.
627
768
  try {
628
- sent.push({ lesson, ...(await fileRequest(target, profile, resolved.dir, lesson)) });
769
+ sent.push({ ...o, ...(await fileRequest(target, profile, resolved.dir, o.lesson, o)) });
629
770
  } catch (error) {
630
- stuck.push({ lesson, why: `could not be written to ${HARNESS}/requests/ — ${error.message}`, fix: `make it writable, or send it by hand: \`nina learn --graduate ${lesson.rel}\`` });
771
+ stuck.push({ ...o, why: `could not be written to ${HARNESS}/requests/ — ${error.message}`, fix: `make it writable, or send it by hand: \`nina learn --graduate ${o.lesson.rel}\`` });
631
772
  }
632
773
  }
633
774
  if (late.length === 0 && sent.length === 0 && due.length === 0 && stuck.length === 0 && !exported) {
@@ -643,15 +784,19 @@ export async function learn(argv, ctx) {
643
784
  for (const r of o.recent) console.log(` ${r.ts} ${r.desc || '(no description)'} session ${r.session}`);
644
785
  console.log(' → ask of each whether it would happen again; if so, write the pill, or bump `occurrences` and `last_seen` on the one that already says it');
645
786
  }
787
+ const what = (o) =>
788
+ o.reopens
789
+ ? `${o.lesson.rel} came back ${o.recurred} time(s) after its rule shipped in ${o.reopens.answered_in || 'a release'} (${o.reopens.rule_in || o.reopens.target})`
790
+ : `${o.lesson.rel} has recurred ${o.lesson.occurrences} times`;
646
791
  for (const s of sent) {
647
- console.log(` ${s.lesson.rel} has recurred ${s.lesson.occurrences} times — sent to the harness as ${HARNESS}/requests/${s.file}, proposing a rule in ${s.layer}`);
792
+ console.log(` ${what(s)} — sent to the harness as ${HARNESS}/requests/${s.file}${s.reopens ? ', reopening the rule' : `, proposing a rule in ${s.layer}`}`);
648
793
  console.log(' → commit it with the pill; nothing else is owed here. `nina upgrade` to the release that answers it closes it and retires the pill');
649
794
  }
650
- for (const lesson of due) {
651
- console.log(` ${lesson.rel} has recurred ${lesson.occurrences} times — it goes to the harness as a request before your next message`);
795
+ for (const o of due) {
796
+ console.log(` ${what(o)} — it goes to the harness as a request before your next message`);
652
797
  }
653
798
  for (const s of stuck) {
654
- console.log(` ${s.lesson.rel} has recurred ${s.lesson.occurrences} times and ${s.why}`);
799
+ console.log(` ${what(s)} and ${s.why}`);
655
800
  console.log(` → ${s.fix}`);
656
801
  }
657
802
  if (exported) console.log(` ${exported}`);
@@ -673,7 +818,7 @@ export async function learn(argv, ctx) {
673
818
  const ready = active.filter((l) => l.occurrences >= GRADUATION_AT);
674
819
  const open = filed.filter((r) => r.status === 'open');
675
820
  const use = applied(records, known);
676
- const checks = verified(records, known);
821
+ const checks = verified(records, known, { owners, targets });
677
822
  const span = records.length ? `${day(records[0].ts)} → ${day(records.at(-1).ts)}` : '';
678
823
 
679
824
  console.log(`\n learning — core ${profile.core}\n`);
@@ -703,19 +848,28 @@ export async function learn(argv, ctx) {
703
848
  );
704
849
  for (const l of ready.filter((l) => owed.includes(l))) console.log(` not sent yet: ${l.rel} — the next \`harness:check\` sends it`);
705
850
  for (const r of open) console.log(` open since ${r.date}: ${r.pill} → ${r.target}. \`nina upgrade\` to the release that answers it closes it`);
706
- if (checks.length === 0) console.log(` verify no lesson has ${VERIFY_MIN}+ readable verdicts on both sides of its date yet`);
707
- checks.forEach((c, i) => {
708
- const head = ` ${i === 0 ? 'verify ' : ' '} ${c.rel.replace(/^\.claude\/pills\//, '')} (${c.date})`;
709
- if (!c.comparable) {
710
- console.log(`${head}: not comparable — the role ran on ${c.on} after it, and before it on ${c.was}`);
711
- return;
712
- }
851
+ for (const r of relapsed(known, filed)) {
852
+ console.log(` ✗ ${r.lesson.rel} came back ${r.recurred} time(s) after its rule shipped in ${r.request.answered_in || 'a release'} — the rule did not hold; \`harness:check\` reopens it`);
853
+ }
854
+ const measured = checks.filter((c) => c.comparable);
855
+ const unmeasured = checks.filter((c) => !c.comparable);
856
+ if (measured.length === 0) console.log(` verify no lesson could be measured yet — ${VERIFY_MIN}+ rounds of its stage on the same model are needed in the ${VERIFY_DAYS} days either side of its date`);
857
+ measured.forEach((c, i) => {
858
+ const said = { lower: 'sent back less since', higher: 'sent back more since', unclear: 'no clear change' }[c.change];
713
859
  console.log(
714
- `${head}${c.on ? `, on ${c.on}` : ''}: ` +
715
- `${pct(c.before.loops, c.before.n)} → ${pct(c.after.loops, c.after.n)} loop-back (n ${c.before.n} → ${c.after.n}); ` +
716
- `every other role ${pct(c.control.before.loops, c.control.before.n)} → ${pct(c.control.after.loops, c.control.after.n)}`,
860
+ ` ${i === 0 ? 'verify ' : ' '} ${c.rel.replace(/^\.claude\/pills\//, '')} (${c.date}) · ${c.role}${c.on ? `, on ${c.on}` : ''}: ` +
861
+ `${pct(c.before.loops, c.before.n)} → ${pct(c.after.loops, c.after.n)} of its rounds sent back (n ${c.before.n} → ${c.after.n}) — ${said}; ` +
862
+ `the other stages ${pct(c.control.before.loops, c.control.before.n)} → ${pct(c.control.after.loops, c.control.after.n)}`,
717
863
  );
718
864
  });
865
+ if (unmeasured.length > 0) {
866
+ const reasons = new Map();
867
+ for (const c of unmeasured) {
868
+ const reason = c.why.startsWith('too few') ? 'too few rounds either side' : c.why.startsWith('nothing') ? 'a stage nothing sends work back to' : 'a stage that changed model across its date';
869
+ reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
870
+ }
871
+ console.log(` ${unmeasured.length} more could not be measured: ${[...reasons].map(([why, n]) => `${n} ${why}`).join(', ')}`);
872
+ }
719
873
  // The loops the lessons are about, as the gate counted them — beside the lessons, because a loop the
720
874
  // gate held is a lesson waiting to be asked for.
721
875
  const since = new Date(Date.now() - days * 86_400_000).toISOString();
@@ -858,7 +1012,8 @@ export async function requests(argv = [], ctx = { root: '.' }) {
858
1012
  }
859
1013
  // The maintainer's side of graduation used to be "run nina requests and remember to" — the one
860
1014
  // step with nothing to prompt it. This repo's own harness:check runs this every turn.
861
- for (const r of unanswered) console.log(` ${r.fields.date} ${r.fields.pill} → ${r.fields.target} (core ${r.fields.core}) ${where(r)}`);
1015
+ const reopened = (r) => (r.fields.reopens ? ` — reopens a rule that did not hold (${r.fields.rule_in || r.fields.target})` : '');
1016
+ for (const r of unanswered) console.log(` ${r.fields.date} ${r.fields.pill} → ${r.fields.target} (core ${r.fields.core})${reopened(r)} ${where(r)}`);
862
1017
  for (const r of unreleased) console.log(` answered, not released: ${r.id}`);
863
1018
  console.log(
864
1019
  `requests: ${[
@@ -881,7 +1036,7 @@ export async function requests(argv = [], ctx = { root: '.' }) {
881
1036
  : working[r.id]
882
1037
  ? 'answered in the working core — cut a release to carry it'
883
1038
  : `waiting — nina requests --answer ${r.id} --in <layer file>, or --decline … --why …`;
884
- console.log(` ${r.fields.date} ${r.fields.target.padEnd(20)} core ${r.fields.core} ${r.fields.pill}`);
1039
+ console.log(` ${r.fields.date} ${r.fields.target.padEnd(20)} core ${r.fields.core} ${r.fields.pill}${r.fields.reopens ? ` — reopens a rule that did not hold (${r.fields.rule_in || r.fields.target})` : ''}`);
885
1040
  console.log(` ${where(r)}`);
886
1041
  console.log(` ${state}`);
887
1042
  }
@@ -81,14 +81,17 @@ const CITATION = /^[^\s:]+(:\d+)?$/;
81
81
  * @param {string} text - The whole file.
82
82
  * @returns {Record<string, string> | null} The fields, or `null` when there is no frontmatter.
83
83
  */
84
- export function frontmatter(text) {
84
+ export function frontmatter(raw) {
85
+ // Saved on Windows, a pill read as having no frontmatter at all; and `occurrences: 4 # seen in 5.1` read as
86
+ // 1 — a comment after a value is YAML's, and ends at the value.
87
+ const text = String(raw ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
85
88
  if (!text.startsWith('---\n')) return null;
86
89
  const end = text.indexOf('\n---', 3);
87
90
  if (end === -1) return null;
88
91
  const fields = {};
89
92
  for (const line of text.slice(4, end + 1).split('\n')) {
90
93
  const field = /^([a-z_]+):\s*(.*)$/.exec(line);
91
- if (field) fields[field[1]] = field[2].trim();
94
+ if (field) fields[field[1]] = field[2].replace(/\s+#.*$/, '').trim();
92
95
  }
93
96
  return fields;
94
97
  }
@@ -308,6 +311,9 @@ export function validate(pill, text, roles, today) {
308
311
  problems.push(`${label} has last_seen "${fields.last_seen}" — expected YYYY-MM-DD`);
309
312
  } else if (fields.last_seen && fields.date && fields.last_seen < fields.date) {
310
313
  problems.push(`${label} was last seen ${fields.last_seen}, before it was first learned (${fields.date})`);
314
+ } else if (fields.last_seen && fields.last_seen > today) {
315
+ // The capture detector counts loop-backs after a stage's newest lesson: one seen in the future silenced it.
316
+ problems.push(`${label} was last seen ${fields.last_seen}, which is in the future — no loop-back of its stage would be counted until then`);
311
317
  }
312
318
  if (fields.occurrences !== undefined && !/^[1-9]\d*$/.test(fields.occurrences)) {
313
319
  problems.push(`${label} has occurrences "${fields.occurrences}" — expected a positive whole number`);
@@ -384,6 +390,15 @@ export async function pills(argv, ctx) {
384
390
  /* without a profile the candidate is still worth naming; only its target is unknown */
385
391
  }
386
392
 
393
+ // What this project has already asked the harness for, by pill: a request is how a lesson leaves a project.
394
+ const requested = new Map();
395
+ for (const f of (await readdir(join(target, HARNESS, 'requests')).catch(() => [])).filter((f) => f.endsWith('.md'))) {
396
+ const fields = frontmatter(await readFile(join(target, HARNESS, 'requests', f), 'utf8').catch(() => ''));
397
+ if (fields?.pill) requested.set(fields.pill, fields);
398
+ }
399
+ /** Every stage a pill was ever written for, by its directory or its applies_to, retired ones included. */
400
+ const taught = new Set();
401
+
387
402
  const changedCache = new Map();
388
403
  const candidates = [];
389
404
  let resolved = 0;
@@ -393,7 +408,9 @@ export async function pills(argv, ctx) {
393
408
  const result = validate(pill, await readFile(pill.path, 'utf8'), roles, today);
394
409
  problems.push(...result.problems);
395
410
  notes.push(...result.notes);
396
- if (result.uncited) uncited += 1;
411
+ if (pill.dir && pill.dir !== 'shared' && pill.dir !== RETIRED) taught.add(pill.dir);
412
+ for (const role of list(result.fields?.applies_to)) taught.add(role);
413
+ if (result.uncited && result.fields?.status !== 'retired') uncited += 1;
397
414
  if (result.fields?.status === 'retired') retired += 1;
398
415
  else active.push({ ...pill, date: result.fields?.date });
399
416
 
@@ -429,23 +446,29 @@ export async function pills(argv, ctx) {
429
446
  if (cited > 0) notes.push(`${resolved} of ${cited} citation(s) still resolve`);
430
447
 
431
448
  // A lesson learned three times is not a correction any more; it is a rule nobody wrote down.
449
+ // A project cannot write the harness, and a pill retired before the release that carries its rule arrives
450
+ // leaves the lesson nowhere: it goes as a request, and waits.
432
451
  for (const c of candidates) {
433
- const target = gates && c.applies.length > 0 ? graduationTarget(c.applies, gates) : null;
452
+ const home = gates && c.applies.length > 0 ? graduationTarget(c.applies, gates) : null;
453
+ const asked = requested.get(`.claude/pills/${c.label}`);
434
454
  notes.push(
435
455
  `${c.label} has recurred ${c.seen} times — that is a rule, not a pill.` +
436
- (target ? ` Its home is ${target.layer}, since ${target.why}.` : '') +
437
- ' Write it there, then set this pill retired.',
456
+ (home ? ` Its home is ${home.layer}, since ${home.why}.` : '') +
457
+ (asked
458
+ ? ` It went to the harness as a request on ${asked.date} (${asked.status}); the \`nina upgrade\` to the release that answers it retires this pill.`
459
+ : ' `harness:check` sends it to the harness as a request, or `nina learn --graduate` does now; keep it active until the release that answers it retires it.'),
438
460
  );
439
461
  }
440
462
 
441
463
  // One line, not one per pill: what matters is how much of the corpus a staleness check
442
464
  // could ever reach, and naming eleven files says that worse than counting them.
443
465
  if (uncited > 0) {
444
- notes.push(`${uncited} of ${files.length} pill(s) cite no code, so staleness cannot be checked mechanically`);
466
+ notes.push(`${uncited} of ${active.length} active pill(s) cite no code, so staleness cannot be checked mechanically`);
445
467
  }
446
468
 
447
- const covered = new Set(files.flatMap((p) => (p.dir && p.dir !== 'shared' ? [p.dir] : [])));
448
- const uncovered = roles.filter((r) => !covered.has(r));
469
+ // Read off each pill's applies_to as well as its directory: counted by directory, a project whose lessons were
470
+ // all shared was told none had ever been written for the stages they named.
471
+ const uncovered = roles.filter((r) => !taught.has(r));
449
472
  if (uncovered.length > 0 && files.length > 0) {
450
473
  notes.push(`no pill has ever been written for ${uncovered.join(', ')}`);
451
474
  }
@@ -5,10 +5,10 @@
5
5
  * can be judged by whether it ever stops anything rather than by whether it is in the
6
6
  * table.
7
7
  *
8
- * It also reports the one rule that governs the harness's own improvement. The router
9
- * writes a pill on every loop-back, and nothing ever checked whether that happened — so
10
- * the rule ran at a fraction of its declared rate for months, invisibly, while the
11
- * loop-backs it was supposed to harvest were being counted right here.
8
+ * It also reports the one rule that governs the harness's own improvement: every loop-back is looked
9
+ * at for a lesson. Nothing ever checked whether that happened — the rule ran at a fraction of its
10
+ * declared rate for months, invisibly, while the loop-backs it was supposed to harvest were being
11
+ * counted right here.
12
12
  */
13
13
 
14
14
  import { amber, bar, bold, dim, heading, note, pink, stacked } from '../look.mjs';
package/src/deep.mjs CHANGED
@@ -152,6 +152,28 @@ export function reducePrompt(causes, known) {
152
152
  ].join('\n');
153
153
  }
154
154
 
155
+ /**
156
+ * The causes a batch's answer adds: one per report the batch held, the first the answer gave for it. Checked
157
+ * against the causes already taken, a ref an answer repeated was taken twice, and the count of reports given
158
+ * no cause came out short by as many.
159
+ *
160
+ * @param {{ref: string}[]} taken - Causes from earlier batches.
161
+ * @param {{ref: string}[]} batch - The reports this answer is about.
162
+ * @param {unknown[]} answered - What the answer gave.
163
+ * @returns {{ref: string, cause: string}[]}
164
+ */
165
+ export function newCauses(taken, batch, answered) {
166
+ const refs = new Set(batch.map((r) => r.ref));
167
+ const seen = new Set(taken.map((c) => c.ref));
168
+ const out = [];
169
+ for (const c of Array.isArray(answered) ? answered : []) {
170
+ if (!c || !refs.has(c.ref) || seen.has(c.ref) || typeof c.cause !== 'string') continue;
171
+ seen.add(c.ref);
172
+ out.push(c);
173
+ }
174
+ return out;
175
+ }
176
+
155
177
  /**
156
178
  * Reads the loop-backs and says what they were about.
157
179
  *
@@ -178,9 +200,7 @@ export async function deepLearn({ records, known, projectDir, since, model, api,
178
200
  made += 1;
179
201
  if (failed || !Array.isArray(answer?.causes)) return stop(failed ?? 'the map answer was not the shape asked for');
180
202
  cost += spent;
181
- const refs = new Set(batch.map((r) => r.ref));
182
- const seen = new Set(causes.map((c) => c.ref));
183
- causes.push(...answer.causes.filter((c) => refs.has(c.ref) && !seen.has(c.ref) && typeof c.cause === 'string'));
203
+ causes.push(...newCauses(causes, batch, answer.causes));
184
204
  }
185
205
  progress('reduce');
186
206
  const { answer, cost: spent, failed } = ask(reducePrompt(causes, known), REDUCE_SCHEMA, options);
@@ -189,7 +209,9 @@ export async function deepLearn({ records, known, projectDir, since, model, api,
189
209
  cost += spent;
190
210
  // Believed only as far as it can be checked: refs that were read, and pills that exist.
191
211
  const read = new Set(causes.map((c) => c.ref));
192
- const pills = new Set(known.map((p) => p.rel));
212
+ // Only an active pill covers a cause here: the grouping is shown no retired one, and one named is a lesson that
213
+ // left this project for the harness, not one this project still holds.
214
+ const pills = new Set(known.filter((p) => !p.retired).map((p) => p.rel));
193
215
  const clusters = answer.clusters
194
216
  .map((c) => ({
195
217
  ...c,
package/src/gate.mjs CHANGED
@@ -60,6 +60,7 @@ import { fileURLToPath } from 'node:url';
60
60
  import { TERMINALS, declaredTokens, parseGraph } from './graph.mjs';
61
61
  import { gateDir, slugFor } from './paths.mjs';
62
62
  import { declaredIssues, isLoopBack, saidToAgent } from './transcripts.mjs';
63
+ import { lessonIndex } from './lessons.mjs';
63
64
 
64
65
  /** The package this runs from: its releases say whether a pinned version ships the gate at all. */
65
66
  const PACKAGE = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -718,6 +719,15 @@ export function handle(input, { root, now = new Date(), pkg = PACKAGE }) {
718
719
  return null;
719
720
  case 'SubagentStop':
720
721
  return onStop(input, project, path, at);
722
+ case 'SubagentStart': {
723
+ // Not the loop's business, but the one hook every stage passes through as it begins: its lessons. Claude
724
+ // Code reads `additionalContext` from this event's `hookSpecificOutput` into the subagent's context as it
725
+ // starts — its hook schema and handler say so in 2.1.283, and the handler in 2.1.158 — though the help text
726
+ // it prints for a malformed hook output does not list the event.
727
+ const role = input.agent_type;
728
+ const text = role && project.graph.stages.has(role) ? lessonIndex(root, role) : null;
729
+ return text ? { hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext: text } } : null;
730
+ }
721
731
  case 'PostToolUse':
722
732
  return onPost(input, project, path, at);
723
733
  case 'PreToolUse':