create-claude-cabinet 0.52.0 → 0.53.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 (38) hide show
  1. package/lib/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/templates/CLAUDE.md +164 -20
  4. package/templates/cabinet/watchtower-contracts.md +202 -10
  5. package/templates/mux/bin/mux +5 -0
  6. package/templates/mux/config/mux-server.py +7 -5
  7. package/templates/scripts/__tests__/category-expiry.test.mjs +130 -0
  8. package/templates/scripts/__tests__/detector-registry.test.mjs +41 -7
  9. package/templates/scripts/__tests__/extraction-classification.test.mjs +87 -0
  10. package/templates/scripts/__tests__/friction-actionability.test.mjs +180 -0
  11. package/templates/scripts/__tests__/memory-budget-debt.test.mjs +264 -0
  12. package/templates/scripts/__tests__/ring2-pattern-gate.test.mjs +315 -0
  13. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +75 -16
  14. package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +167 -105
  15. package/templates/scripts/__tests__/roster-ambient-render.test.mjs +132 -0
  16. package/templates/scripts/__tests__/routine-dispatch.test.mjs +29 -1
  17. package/templates/scripts/__tests__/routine-moment-expiry.test.mjs +192 -0
  18. package/templates/scripts/skill-validator.sh +7 -4
  19. package/templates/scripts/watchtower-lib.mjs +58 -5
  20. package/templates/scripts/watchtower-queue.mjs +54 -7
  21. package/templates/scripts/watchtower-ring1.mjs +74 -0
  22. package/templates/scripts/watchtower-ring2.mjs +442 -81
  23. package/templates/scripts/watchtower-ring3-close.mjs +271 -82
  24. package/templates/scripts/watchtower-routines.mjs +107 -1
  25. package/templates/skills/cabinet-anthropic-insider/SKILL.md +35 -2
  26. package/templates/skills/cabinet-anti-confirmation/SKILL.md +24 -0
  27. package/templates/skills/cabinet-cc-health/SKILL.md +22 -0
  28. package/templates/skills/cabinet-data-integrity/SKILL.md +19 -0
  29. package/templates/skills/cabinet-debugger/SKILL.md +36 -0
  30. package/templates/skills/cabinet-deployment/SKILL.md +22 -0
  31. package/templates/skills/cabinet-elegance/SKILL.md +12 -0
  32. package/templates/skills/cabinet-goal-alignment/SKILL.md +11 -0
  33. package/templates/skills/cabinet-historian/SKILL.md +33 -0
  34. package/templates/skills/cabinet-organized-mind/SKILL.md +11 -0
  35. package/templates/skills/cabinet-process-therapist/SKILL.md +132 -6
  36. package/templates/skills/cabinet-qa/SKILL.md +63 -0
  37. package/templates/skills/cabinet-roster-check/SKILL.md +12 -0
  38. package/templates/skills/cabinet-workflow-cop/SKILL.md +60 -0
@@ -994,14 +994,14 @@ Rules:
994
994
  // Phase 2c: Work item closure
995
995
  // ---------------------------------------------------------------------------
996
996
 
997
+ // `sessionStartIso` is accepted for call-site compatibility and is no longer
998
+ // read: act:9eebbac4's create-vs-complete day-window filter — the only consumer
999
+ // of the session's own date — was deleted in act:ea23b3a5 after measurement
1000
+ // showed it suppressed the highest-precision cohort and exempted the lowest.
1001
+ // See the calibration note on completionReviewEmitGuard.
997
1002
  async function workItemClosure(compressed, project, threadIds = [], sessionStartIso = null) {
998
1003
  const projectPath = project.path;
999
1004
  log('Phase 2c: Work item closure');
1000
- // The transcript's own day — the create-vs-complete filter compares the
1001
- // action row's `created` date against the SESSION's date, so a reprocess
1002
- // of an old transcript filters against that day, never today.
1003
- const sessionDate = typeof sessionStartIso === 'string' && sessionStartIso
1004
- ? sessionStartIso.slice(0, 10) : null;
1005
1005
 
1006
1006
  // No resolved project directory (unresolved identity) — there is no
1007
1007
  // right pib.db to evaluate against; evaluating the RUNNER's would close
@@ -1058,10 +1058,16 @@ async function workItemClosure(compressed, project, threadIds = [], sessionStart
1058
1058
 
1059
1059
  An action that was merely CREATED during this session is NOT completed — creating or filing a work item is not doing it. Use "none" for actions whose only appearance is their own creation, unless the transcript also shows the work itself being done.
1060
1060
 
1061
- Output JSON array: [{"fid":"act:XXXXXXXX","confidence":"high|medium|low|none","evidence":"brief reason"}]
1061
+ Also return "quote": a VERBATIM span copied from the session transcript that states this work is finished. Copy it exactly — do not paraphrase, summarize, or reconstruct it. Quote from the transcript itself, not from the list of open actions above. If no such span exists, return "" — an empty quote is a valid and useful answer, and inventing one is worse than none.
1062
+
1063
+ Output JSON array: [{"fid":"act:XXXXXXXX","confidence":"high|medium|low|none","evidence":"brief reason","quote":"verbatim span or empty string"}]
1062
1064
  Output ONLY the JSON array, no other text.`;
1063
1065
 
1064
- const userMessage = `Open actions:\n${actionList}\n\nSession transcript:\n${recentSlice(compressed, COMPLETION_TRANSCRIPT_BUDGET)}`;
1066
+ // The SAME slice is the model's haystack and the quote checker's — captured
1067
+ // once so the two can never drift apart (a quote can only be verified against
1068
+ // the text the model was actually shown).
1069
+ const transcriptSlice = recentSlice(compressed, COMPLETION_TRANSCRIPT_BUDGET);
1070
+ const userMessage = `Open actions:\n${actionList}\n\nSession transcript:\n${transcriptSlice}`;
1065
1071
 
1066
1072
  let evaluations = [];
1067
1073
  try {
@@ -1082,16 +1088,12 @@ Output ONLY the JSON array, no other text.`;
1082
1088
 
1083
1089
  // Emit guards (act:ec508dbe): one prepared status re-check statement,
1084
1090
  // reused across evaluations, and a per-fid dedup corpus built once from
1085
- // pending ∪ recently-closed completion-review items. Both setups FAIL OPEN
1091
+ // pending ∪ recently-terminal completion-review items. Both setups FAIL OPEN
1086
1092
  // (distinct logError, empty guard) — never wholesale suppression.
1087
- // The SELECT also carries `created` for the create-vs-complete filter
1088
- // (act:9eebbac4): the mechanical "was this action born this session?"
1089
- // fact — the compressed transcript strips tool_result bodies, so a
1090
- // transcript scan cannot reliably see creations; the db row can.
1091
1093
  let statusStmt = null;
1092
1094
  try {
1093
1095
  statusStmt = db.prepare(
1094
- 'SELECT status, created FROM actions WHERE fid = ? AND deleted_at IS NULL');
1096
+ 'SELECT status FROM actions WHERE fid = ? AND deleted_at IS NULL');
1095
1097
  } catch (e) {
1096
1098
  logError(`Phase 2c: could not prepare emit-time status re-check (${e.message}) — failing open`);
1097
1099
  }
@@ -1104,7 +1106,14 @@ Output ONLY the JSON array, no other text.`;
1104
1106
  ...listItems({
1105
1107
  project: project.name,
1106
1108
  category: 'completion-review',
1107
- statuses: ['resolved', 'dismissed'],
1109
+ // `expired` and `superseded` are load-bearing since act:ea23b3a5 gave
1110
+ // this category a 14d expiry EQUAL to COMPLETION_REVIEW_DEDUP_DAYS:
1111
+ // without them a fid leaves every corpus at exactly the moment its
1112
+ // item expires, and the next session refiles it — forever. That is
1113
+ // Detector Symmetry §2's named failure ("dedup consults only pending
1114
+ // items, so an excluded state that files at all refiles after every
1115
+ // dismissal").
1116
+ statuses: ['resolved', 'dismissed', 'expired', 'superseded'],
1108
1117
  since,
1109
1118
  }),
1110
1119
  ];
@@ -1112,13 +1121,20 @@ Output ONLY the JSON array, no other text.`;
1112
1121
  logError(`Phase 2c: could not load existing completion-review items (${e.message}) — failing open`);
1113
1122
  }
1114
1123
 
1124
+ // Quote instrumentation (act:ea23b3a5) — counted for EVERY evaluation,
1125
+ // including suppressed ones, because "would a gate have been safe?" is a
1126
+ // question about the items we stop filing as much as the ones we file.
1127
+ const quoteTally = { found: 0, 'not-found': 0, 'absent-or-too-short': 0, 'no-decodable-transcript': 0 };
1128
+
1115
1129
  for (const evalItem of evaluations) {
1116
1130
  if (!evalItem.fid || evalItem.confidence === 'none') continue;
1117
1131
 
1132
+ const quoteCheck = verifyCompletionQuote(evalItem.quote, transcriptSlice);
1133
+ quoteTally[quoteCheck.reason] = (quoteTally[quoteCheck.reason] || 0) + 1;
1134
+
1118
1135
  const guard = completionReviewEmitGuard(evalItem.fid, {
1119
1136
  statusStmt,
1120
1137
  existingItems: existingCompletionItems,
1121
- sessionDate,
1122
1138
  confidence: evalItem.confidence,
1123
1139
  });
1124
1140
  if (!guard.emit) {
@@ -1147,6 +1163,11 @@ Output ONLY the JSON array, no other text.`;
1147
1163
  fid: evalItem.fid,
1148
1164
  confidence: evalItem.confidence,
1149
1165
  reason: evalItem.evidence,
1166
+ // Recorded, never acted on (act:ea23b3a5) — the corpus a future
1167
+ // round needs to judge whether a real evidence gate is buildable.
1168
+ quote: typeof evalItem.quote === 'string' ? evalItem.quote : null,
1169
+ quote_verified: quoteCheck.verified,
1170
+ quote_check: quoteCheck.reason,
1150
1171
  closed_by: 'ring3-close',
1151
1172
  },
1152
1173
  options: [
@@ -1164,6 +1185,10 @@ Output ONLY the JSON array, no other text.`;
1164
1185
 
1165
1186
  db.close();
1166
1187
  log(`Phase 2c: ${queued} completion candidates queued for review${skipped ? ` (${skipped} skipped by emit guards)` : ''}`);
1188
+ // Positive confirmation, not silence: a run with no verified quotes must be
1189
+ // distinguishable from a run where the instrumentation never fired.
1190
+ log(`Phase 2c: quote check (instrumentation only, gates nothing) — `
1191
+ + Object.entries(quoteTally).map(([k, n]) => `${k}=${n}`).join(', '));
1167
1192
  return { closed: 0, queued };
1168
1193
  }
1169
1194
 
@@ -1676,37 +1701,25 @@ function buildExtractionCorpora(project, { phase } = {}) {
1676
1701
  // item emits anyway — a DB hiccup degrades to today's behavior, never to
1677
1702
  // silent wholesale suppression. The try/catch is the guard's own so a
1678
1703
  // throw can't abort the remaining evaluations.
1679
- // 1b. Create-vs-complete filter (act:9eebbac4): skip when the action's db
1680
- // `created` date falls inside this session's DAY window (the session's
1681
- // start day, +1 for past-midnight spill `created` is day-granular by
1682
- // schema) AND the model's confidence is below 'high' — an action born
1683
- // this session whose only
1684
- // evidence is its own creation is not a completion candidate (12 of 66
1685
- // live items were this). The conjunct keeps the legitimate
1686
- // create-then-complete flow: high confidence means the transcript shows
1687
- // the work itself done, and that still emits. Mechanical fact (db row,
1688
- // same prepared statement as the status re-check the compressed
1689
- // transcript strips tool results and cannot show creations), model
1690
- // hallucination-proof, and FAIL-OPEN in every gap: no sessionDate, no
1691
- // created column, or a thrown lookup all emit.
1692
- // 2. Per-fid dedup: skip when an existing completion-review item for the
1693
- // same fid is in `existingItems` (the caller builds that set from
1694
- // pending ∪ resolved/dismissed-within-COMPLETION_REVIEW_DEDUP_DAYS).
1704
+ // 2. Confidence inversion (act:ea23b3a5): skip when the model rated this fid
1705
+ // 'high'. The full calibration note sits on the check itself. This is the
1706
+ // ONE fail-closed arm in the guard, and it REPLACED act:9eebbac4's
1707
+ // create-vs-complete day-window filter, which encoded the opposite theory
1708
+ // and measured net-negative.
1709
+ // 3. Per-fid dedup: skip when an existing completion-review item for the
1710
+ // same fid is in `existingItems` (the caller builds that set from pending
1711
+ // resolved/dismissed/expired/superseded within
1712
+ // COMPLETION_REVIEW_DEDUP_DAYS `expired` and `superseded` are
1713
+ // load-bearing since the category's expiry became 14d, equal to the dedup
1714
+ // window: without them an item drops out of every corpus at exactly the
1715
+ // moment it expires and refiles forever).
1695
1716
  //
1696
- // `sessionDate` is the transcript's own day (YYYY-MM-DD) at reprocess the
1697
- // SESSION's date, never today's. `confidence` is the model's for this fid.
1698
- // New params are optional: callers/tests using the original shape are
1699
- // untouched (both checks self-skip).
1717
+ // `confidence` is the model's rating for this fid. Every param is optional and
1718
+ // each check self-skips, so callers/tests using the original shape are
1719
+ // untouched.
1700
1720
  //
1701
1721
  // Returns { emit: true } or { emit: false, reason }.
1702
- function dayAfter(dateStr) {
1703
- const t = Date.parse(`${dateStr}T00:00:00Z`);
1704
- return Number.isFinite(t)
1705
- ? new Date(t + 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
1706
- : null;
1707
- }
1708
-
1709
- function completionReviewEmitGuard(fid, { statusStmt, existingItems = [], sessionDate = null, confidence = null } = {}) {
1722
+ function completionReviewEmitGuard(fid, { statusStmt, existingItems = [], confidence = null } = {}) {
1710
1723
  if (statusStmt) {
1711
1724
  try {
1712
1725
  const row = statusStmt.get(fid);
@@ -1714,32 +1727,36 @@ function completionReviewEmitGuard(fid, { statusStmt, existingItems = [], sessio
1714
1727
  if (status !== 'open' && status !== 'in-progress') {
1715
1728
  return { emit: false, reason: `status now '${status ?? 'gone'}'` };
1716
1729
  }
1717
- const created = row ? row.created : null;
1718
- // Day window [sessionDate, sessionDate+1]: pib-db `created` is
1719
- // day-granular (schema CHECK), so "born this session" is really "born
1720
- // on the session's day" (+1 covers a past-midnight spill). The upper
1721
- // bound matters at reprocess: an action created in a LATER session
1722
- // must not be suppressed by an old transcript's re-run. A sibling
1723
- // same-day session's creations do fall in the window — the below-high
1724
- // conjunct is the designed escape (real completion evidence emits).
1725
- // windowEnd is a REQUIRED conjunct: an unparseable-but-truthy
1726
- // sessionDate must self-skip the filter (fail OPEN), not silently drop
1727
- // the upper bound and lexically suppress everything below it.
1728
- const windowEnd = sessionDate ? dayAfter(sessionDate) : null;
1729
- if (sessionDate && windowEnd && created
1730
- && String(confidence).toLowerCase() !== 'high'
1731
- && String(created) >= sessionDate
1732
- && String(created) <= windowEnd) {
1733
- return {
1734
- emit: false,
1735
- reason: `created ${created}, within this session's day window (${sessionDate}/+1) — creation is not completion`,
1736
- };
1737
- }
1738
1730
  } catch (e) {
1739
1731
  // FAIL-OPEN: emit despite the failed re-check.
1740
1732
  logError(`Phase 2c: emit-time status re-check threw for ${fid} (${e.message}) — failing open, item will emit`);
1741
1733
  }
1742
1734
  }
1735
+ // CONFIDENCE IS ANTI-PREDICTIVE HERE — measured, not assumed (act:ea23b3a5,
1736
+ // 2026-07-26, over all 223 items this category had ever filed, each joined
1737
+ // against the cited action's OWN project db):
1738
+ //
1739
+ // high 12 acted-on / 28 dismissed-noise 30% precision
1740
+ // medium+low 48 acted-on / 32 dismissed-noise 60% precision
1741
+ //
1742
+ // The model rates 'high' when a session TALKS about an action — files it,
1743
+ // plans it, discusses it — not when it quietly finishes one. act:9eebbac4's
1744
+ // create-vs-complete filter encoded the opposite theory (it exempted 'high'
1745
+ // from the born-this-session skip), so it suppressed the BEST cohort
1746
+ // (same-day creation + medium/low: 74% precision, 20 acted-on / 7 noise)
1747
+ // while passing the worst (same-day + high: 33%). Split at that filter's
1748
+ // 2026-07-12 ship date, the category went 0-for-23 — zero acted-on, 19
1749
+ // dismissed as noise. The filter is deleted; this replaces it.
1750
+ //
1751
+ // CALIBRATION FENCE: this holds for the Phase 2c confidence rubric AS
1752
+ // WRITTEN. Rewording that rubric changes what 'high' means and invalidates
1753
+ // the measurement — re-run it before touching the prompt's confidence text.
1754
+ if (String(confidence).toLowerCase() === 'high') {
1755
+ return {
1756
+ emit: false,
1757
+ reason: "confidence 'high' is anti-predictive for completion (30% vs 60%) — see the calibration note",
1758
+ };
1759
+ }
1743
1760
  const existing = existingItems.find(
1744
1761
  i => i.plan_fid === fid || i.evidence?.fid === fid);
1745
1762
  if (existing) {
@@ -1751,6 +1768,78 @@ function completionReviewEmitGuard(fid, { statusStmt, existingItems = [], sessio
1751
1768
  return { emit: true };
1752
1769
  }
1753
1770
 
1771
+ // ---------------------------------------------------------------------------
1772
+ // Completion-quote verification — INSTRUMENTATION ONLY (act:ea23b3a5)
1773
+ // ---------------------------------------------------------------------------
1774
+ //
1775
+ // Does the model's claimed completion span actually appear in the transcript it
1776
+ // was shown? This records an answer; it deliberately does NOT gate emission,
1777
+ // for two reasons worth keeping written down.
1778
+ //
1779
+ // (1) The haystack is not prose. `preprocessTranscript` ends with
1780
+ // `kept.map(e => JSON.stringify(e)).join('\n')`, so a real newline inside a
1781
+ // message is the two characters `\` + `n`, and a quote character is `\"`. A
1782
+ // substring test against those bytes REJECTS ordinary multi-line prose —
1783
+ // the shape most genuine completion statements take — while reliably
1784
+ // ACCEPTING serialized tool arguments: every TodoWrite call leaves
1785
+ // `{"todos":[{"content":"…","status":"completed"` in the haystack,
1786
+ // pre-escaped and quotable verbatim. So the matcher decodes first, and
1787
+ // `decodeTranscriptText` drops tool_use `input_summary` blobs on purpose.
1788
+ // (2) Even decoded, containment proves the SPAN EXISTS — never that the span
1789
+ // means the work is done. The 2026-07-26 corpus contains a dismissal whose
1790
+ // own reason quotes "act:a587b30f — filed (…)": a verbatim quote of a
1791
+ // FILING. A gate built on this would launder that into evidence.
1792
+ //
1793
+ // So: measure first (the discipline act:de4a7020 stalled on and this program
1794
+ // finally ran). The stamped `evidence.quote_verified` plus the per-session
1795
+ // counts are the data a future round needs to decide whether a real gate —
1796
+ // semantic, not lexical — is worth building.
1797
+
1798
+ const COMPLETION_QUOTE_MIN_CHARS = 20;
1799
+
1800
+ function normalizeQuoteText(s) {
1801
+ return typeof s === 'string' ? s.replace(/\s+/g, ' ').trim().toLowerCase() : '';
1802
+ }
1803
+
1804
+ // decodeTranscriptText — recover human-readable text from a preprocessTranscript
1805
+ // haystack. Each line is one JSON message whose `content` is a string or an
1806
+ // array of blocks; only plain strings and `text` blocks count. Tool-use
1807
+ // summaries are EXCLUDED by design (see note 1 above). An unparseable line is
1808
+ // skipped, never raw-matched — matching the raw line would reintroduce the
1809
+ // escaped-bytes problem this function exists to remove.
1810
+ export function decodeTranscriptText(jsonl) {
1811
+ if (typeof jsonl !== 'string' || !jsonl) return '';
1812
+ const out = [];
1813
+ for (const line of jsonl.split('\n')) {
1814
+ const trimmed = line.trim();
1815
+ if (!trimmed) continue;
1816
+ let entry;
1817
+ try { entry = JSON.parse(trimmed); } catch { continue; }
1818
+ const content = entry && entry.content;
1819
+ if (typeof content === 'string') { out.push(content); continue; }
1820
+ if (!Array.isArray(content)) continue;
1821
+ for (const block of content) {
1822
+ if (typeof block === 'string') out.push(block);
1823
+ else if (block && block.type === 'text' && typeof block.text === 'string') out.push(block.text);
1824
+ }
1825
+ }
1826
+ return out.join('\n');
1827
+ }
1828
+
1829
+ // Returns { verified: boolean, reason: 'found' | 'not-found' | 'absent-or-too-short'
1830
+ // | 'no-decodable-transcript' }. Never throws; never affects `emit`.
1831
+ export function verifyCompletionQuote(quote, transcriptJsonl) {
1832
+ const needle = normalizeQuoteText(quote);
1833
+ if (needle.length < COMPLETION_QUOTE_MIN_CHARS) {
1834
+ return { verified: false, reason: 'absent-or-too-short' };
1835
+ }
1836
+ const haystack = normalizeQuoteText(decodeTranscriptText(transcriptJsonl));
1837
+ if (!haystack) return { verified: false, reason: 'no-decodable-transcript' };
1838
+ return haystack.includes(needle)
1839
+ ? { verified: true, reason: 'found' }
1840
+ : { verified: false, reason: 'not-found' };
1841
+ }
1842
+
1754
1843
  // ---------------------------------------------------------------------------
1755
1844
  // Phase 2d: Knowledge extraction → inbox
1756
1845
  // ---------------------------------------------------------------------------
@@ -1795,11 +1884,14 @@ async function decisionExtraction(compressed, project, sessionId, transcriptPath
1795
1884
  - "pib-db-trigger" = a deferred action with a trigger condition
1796
1885
  - "upstream-feedback" = friction with Claude Code itself
1797
1886
  - "derivation" = the fact is DERIVABLE (see DERIVABLE below) — its home is the recomputation itself, not a written record
1887
+ - "session-record" = the fact is PERISHABLE (see PERISHABLE below) — its only durable home is the record of the session that observed it, never memory
1798
1888
 
1799
1889
  Only extract items that represent NEW knowledge — things learned or decided in this session that aren't yet captured. Skip items that are routine, obvious, or just restating existing conventions.
1800
1890
 
1801
1891
  DERIVABLE: a fact is derivable when it can be recomputed on demand from a live source rather than remembered — a memory of a derivable fact is a stale cache that goes wrong the moment reality moves on. Examples: "prod is at commit 35a5d15" (recompute: check the deploy log or git log on the remote), "the census run already executed" (recompute: check the run log or the action's status in pib-db), "invoice #1's boundary is 95.25h through July 12" (recompute: re-run the timelog query). When an item is derivable, set "derivable": true and "derivation" to a short instruction for HOW to recompute it (a command, a query, a file to check), and set "home" to "derivation". NEVER omit a derivable item from the output — it is always included, just routed to its derivation instead of memory.
1802
1892
 
1893
+ PERISHABLE: a fact can be non-derivable and STILL not be durable — true at the moment of this session and destined to become false as work proceeds, with no live source to recompute it from. Examples: "phase 7 hasn't started yet" (false the day phase 7 starts), "usage this month is 42 runs" (a count that moves), "the fix is deployed but not yet verified" (a transition state). A perishable fact is a point-in-time snapshot, NOT a lesson or a durable decision — writing it to memory files a sentence that silently goes wrong. The durability test: would this sentence still be true a month from now if nobody touched anything? A durable lesson would; a snapshot would not. When an item is perishable, set "perishable": true and "perishes_when" to a short statement of WHAT event or time makes it false, and set "home" to "session-record". If a fact is BOTH recomputable and perishable (a deploy SHA is both), classify it DERIVABLE — the recomputation instruction is the better record. NEVER omit a perishable item from the output — it is always included, just routed to the session record instead of memory.
1894
+
1803
1895
  SUBJECT: for every item, set "subject" to the actual project, tool, or system the knowledge is ABOUT. This is usually the same as the project you're working in, but flag it when it differs — a lesson about Claude Code itself learned while doing client work, or a lesson about a third-party API or service, belongs to THAT subject, not the filing project.
1804
1896
 
1805
1897
  UNCLASSIFIABLE: if an item is clearly worth surfacing but does not cleanly fit any type or home above, set "type" to "unclassifiable" and "unclassifiable_reason" to a one-line reason why — this makes your uncertainty visible instead of forcing a confident wrong label. Still include "title" and "content"; "home" and "derivable" are not required for an unclassifiable item.
@@ -1815,7 +1907,7 @@ For each item, assess how time-sensitive routing is. Urgency means HOW FAST THE
1815
1907
 
1816
1908
  Lessons and preferences are almost NEVER urgent, no matter how important they are — an important-but-durable item is "normal", not "urgent".
1817
1909
 
1818
- Output JSON array: [{"type":"decision|constraint|lesson|preference|unclassifiable","home":"memory|claude-md|pib-db-trigger|upstream-feedback|derivation","urgency":"urgent|normal|low","title":"short title","content":"detailed description","covered_by":"exact already-saved title that covers this, or \\"none\\"","subject":"the actual project, tool, or system this is about","derivable":false,"derivation":null,"unclassifiable_reason":null,"relates_to":"exact pending title that this relates to, or \\"none\\"","relation":"pairs_with|answers|contradicts|none"}]
1910
+ Output JSON array: [{"type":"decision|constraint|lesson|preference|unclassifiable","home":"memory|claude-md|pib-db-trigger|upstream-feedback|derivation|session-record","urgency":"urgent|normal|low","title":"short title","content":"detailed description","covered_by":"exact already-saved title that covers this, or \\"none\\"","subject":"the actual project, tool, or system this is about","derivable":false,"derivation":null,"perishable":false,"perishes_when":null,"unclassifiable_reason":null,"relates_to":"exact pending title that this relates to, or \\"none\\"","relation":"pairs_with|answers|contradicts|none"}]
1819
1911
  Output ONLY the JSON array, no other text. If nothing found, output [].`;
1820
1912
 
1821
1913
  // M1b injection: show the model what's already saved so it self-filters dupes
@@ -1910,14 +2002,18 @@ Output ONLY the JSON array, no other text. If nothing found, output [].`;
1910
2002
  }
1911
2003
 
1912
2004
  try {
1913
- // Three routing shapes (act:471dd701, N2 of grp:retro-remeaning):
2005
+ // Four routing shapes (act:471dd701 N2 + act:a69c21f8 follow-up):
1914
2006
  // unclassifiable (model uncertainty made visible, never a forced
1915
2007
  // home guess), derivable (routed to its recomputation, never
1916
- // written to memory), and the four pre-existing homes.
2008
+ // written to memory), perishable (a point-in-time snapshot routed
2009
+ // to the session record, never written to memory; derivable WINS
2010
+ // when both flags are set — the derivation is the better record),
2011
+ // and the four pre-existing homes.
1917
2012
  const isUnclassifiable = item.type === 'unclassifiable';
1918
2013
  const isDerivable = item.derivable === true;
1919
- const home = isDerivable ? 'derivation' : item.home;
1920
- const isMemory = !isUnclassifiable && !isDerivable && home === 'memory';
2014
+ const isPerishable = !isDerivable && item.perishable === true;
2015
+ const home = isDerivable ? 'derivation' : isPerishable ? 'session-record' : item.home;
2016
+ const isMemory = !isUnclassifiable && !isDerivable && !isPerishable && home === 'memory';
1921
2017
 
1922
2018
  let options;
1923
2019
  let draftArtifact = null;
@@ -1933,6 +2029,12 @@ Output ONLY the JSON array, no other text. If nothing found, output [].`;
1933
2029
  { key: 'dismiss', label: 'Dismiss' },
1934
2030
  ];
1935
2031
  summary = item.derivation ? `${item.content}\n\nDerivation: ${item.derivation}` : item.content;
2032
+ } else if (isPerishable) {
2033
+ options = [
2034
+ { key: 'acknowledge', label: 'Acknowledge — point-in-time snapshot, not durable' },
2035
+ { key: 'dismiss', label: 'Dismiss' },
2036
+ ];
2037
+ summary = item.perishes_when ? `${item.content}\n\nPerishes: ${item.perishes_when}` : item.content;
1936
2038
  } else if (isMemory) {
1937
2039
  options = [
1938
2040
  { key: 'write', label: 'Write to memory' },
@@ -1962,6 +2064,7 @@ Output ONLY the JSON array, no other text. If nothing found, output [].`;
1962
2064
  session_id: sessionId,
1963
2065
  ...(item.subject ? { subject: item.subject } : {}),
1964
2066
  ...(isDerivable ? { derivable: true, derivation: item.derivation } : {}),
2067
+ ...(isPerishable ? { perishable: true, perishes_when: item.perishes_when } : {}),
1965
2068
  ...(isUnclassifiable ? { unclassifiable_reason: item.unclassifiable_reason } : {}),
1966
2069
  },
1967
2070
  options,
@@ -2008,6 +2111,22 @@ Output ONLY the JSON array, no other text. If nothing found, output [].`;
2008
2111
  // Phase 2e: Audit pattern capture
2009
2112
  // ---------------------------------------------------------------------------
2010
2113
 
2114
+ // Exported so the hermetic suite can assert the exclusions survive edits
2115
+ // (act:09184ad7). Ring 2's shape gate (patternHasRequiredShape in
2116
+ // watchtower-ring2.mjs) enforces the Evidence/Gap half mechanically; the
2117
+ // three exclusions below are prompt-only — the 2026-07-26 consolidation
2118
+ // found all three classes filed as pattern promotions (no-gap positives,
2119
+ // truncated-transcript disclaimers, document echoes), and without the test
2120
+ // they are one refactor away from silently vanishing.
2121
+ export const QUALITY_PATTERN_SYSTEM_PROMPT = `You are analyzing a Claude Code session transcript for recurring quality patterns — issues, gaps, friction, or anti-patterns that surface during any kind of work (coding, debugging, planning, auditing, reviewing). Identify patterns worth learning from: things that keep going wrong, systematic gaps, workflow friction, or quality issues that a team member should watch for in future sessions.
2122
+
2123
+ Only report PROBLEMATIC patterns:
2124
+ - Do NOT emit a section for behavior that was correct — a "no gap" observation is not a pattern.
2125
+ - Do NOT emit observations about the transcript artifact itself (truncated, compressed, or cut off mid-record); those describe the recording, not the work.
2126
+ - Do NOT reproduce document content the session merely read or wrote (status tables, wave ledgers, dependency graphs, checklists, setup recipes) as a pattern — a pattern is a recurring behavioral tendency, not a document excerpt.
2127
+
2128
+ Output as markdown with ## headers for each pattern found. Include **Evidence:** (what you observed) and **Gap:** (what's missing or broken) for each — sections missing either block are discarded downstream. If no meaningful patterns, output "No recurring patterns detected."`;
2129
+
2011
2130
  async function qualityPatternCapture(compressed, projectPath) {
2012
2131
  log('Phase 2e: Quality pattern capture');
2013
2132
 
@@ -2027,7 +2146,7 @@ async function qualityPatternCapture(compressed, projectPath) {
2027
2146
  }
2028
2147
  }
2029
2148
 
2030
- const systemPrompt = `You are analyzing a Claude Code session transcript for recurring quality patterns — issues, gaps, friction, or anti-patterns that surface during any kind of work (coding, debugging, planning, auditing, reviewing). Identify patterns worth learning from: things that keep going wrong, systematic gaps, workflow friction, or quality issues that a team member should watch for in future sessions. Output as markdown with ## headers for each pattern found. Include **Evidence:** (what you observed) and **Gap:** (what's missing or broken) for each. If no meaningful patterns, output "No recurring patterns detected."`;
2149
+ const systemPrompt = QUALITY_PATTERN_SYSTEM_PROMPT;
2031
2150
 
2032
2151
  const userMessage = triageHistory
2033
2152
  ? `Session transcript:\n${recentSlice(compressed, 30000)}\n\nAudit triage history:\n${triageHistory.slice(0, 10000)}`
@@ -2130,19 +2249,47 @@ Output ONLY the JSON object.`,
2130
2249
  // Phase 2g: Upstream friction
2131
2250
  // ---------------------------------------------------------------------------
2132
2251
 
2133
- async function upstreamFriction(compressed, project, threadIds = []) {
2252
+ // The closed vocabulary of actionable routes (act:ea23b3a5). A friction report
2253
+ // with no named party who could act on it is not a report, it is a mood: five
2254
+ // of the 2026-07-26 dismissals were observations like "Claude made iterative
2255
+ // browser tweaks without writing to file" — true, and addressed to nobody.
2256
+ //
2257
+ // This is Phase 2f's verification gate applied to a second lens. That gate
2258
+ // (act:3f3f9a31) files a methodology capture only when the claimed artifact
2259
+ // verifiably exists on disk, after 11 of 11 unverified captures were dismissed
2260
+ // over three weeks. Same shape here: file only when the finding names its route.
2261
+ export const FRICTION_ROUTES = ['cc-feedback', 'project-tracker', 'config-change'];
2262
+
2263
+ export function frictionRouteIsActionable(route) {
2264
+ return !!route
2265
+ && FRICTION_ROUTES.includes(route.type)
2266
+ && typeof route.detail === 'string'
2267
+ && route.detail.trim().length > 0;
2268
+ }
2269
+
2270
+ async function upstreamFriction(compressed, project, threadIds = [], { callFn = claudeCall } = {}) {
2134
2271
  const projectPath = project.path;
2135
2272
  log('Phase 2g: Upstream friction');
2136
2273
 
2137
2274
  const systemPrompt = `You are analyzing a Claude Code session transcript for friction with Claude Code itself (bugs, limitations, confusing behavior, missing features, workarounds). Only report genuine CC friction, not user errors or project-specific issues.
2138
2275
 
2139
- If friction found, output JSON: [{"title":"short title","description":"what happened","severity":"high|medium|low"}]
2276
+ For each finding, decide what can actually be DONE about it, and route it:
2277
+
2278
+ - "route": {"type":"cc-feedback","detail":"..."} — a fix belongs in the Claude Cabinet repo. detail names what would change there.
2279
+ - "route": {"type":"project-tracker","detail":"..."} — a fix belongs in THIS project. detail names the work.
2280
+ - "route": {"type":"config-change","detail":"..."} — a setting, hook, or doc the operator controls. detail names it.
2281
+ - "durable": true — no one can fix it (a platform constraint, an environment quirk), but the WORKAROUND is worth remembering next time. Say the workaround in "description".
2282
+ - Neither — a transient incident (a network blip, a credit limit, a one-off tool error) or a general observation about how Claude behaved. These are NOT upstream friction. Return them with no route and durable:false; they will be counted and dropped.
2283
+
2284
+ An observation with no named party who could act on it and nothing durable to remember is not a report. Do not invent a route to make something filable.
2285
+
2286
+ Output JSON: [{"title":"short title","description":"what happened","severity":"high|medium|low","route":{"type":"...","detail":"..."}|null,"durable":true|false}]
2140
2287
  If NO friction found, output exactly: []
2141
2288
 
2142
2289
  Be conservative. False positives waste time. Output ONLY the JSON array.`;
2143
2290
 
2144
2291
  try {
2145
- const response = await claudeCall(systemPrompt, recentSlice(compressed, 40000));
2292
+ const response = await callFn(systemPrompt, recentSlice(compressed, 40000));
2146
2293
  const jsonMatch = response.match(/\[[\s\S]*\]/);
2147
2294
  if (!jsonMatch) {
2148
2295
  log('Phase 2g: No upstream friction (parse failure)');
@@ -2156,23 +2303,59 @@ Be conservative. False positives waste time. Output ONLY the JSON array.`;
2156
2303
  return;
2157
2304
  }
2158
2305
 
2306
+ const tally = { routed: 0, durable: 0, dropped: 0 };
2307
+
2159
2308
  for (const item of frictionItems) {
2160
- createItem({
2309
+ const base = {
2161
2310
  project: project.name,
2162
2311
  project_path: projectPath,
2163
2312
  ...(project.unresolved ? { project_unresolved: true } : {}),
2164
- category: 'upstream-friction',
2165
- urgency: item.severity === 'high' ? 'urgent' : 'normal',
2166
- title: item.title,
2167
- summary: item.description,
2168
- context_anchor: 'ring3-close friction scan',
2169
- evidence: { severity: item.severity },
2170
2313
  filed_by: 'ring3-close',
2171
2314
  thread_ids: threadIds,
2172
- });
2315
+ };
2316
+
2317
+ if (frictionRouteIsActionable(item.route)) {
2318
+ createItem({
2319
+ ...base,
2320
+ category: 'upstream-friction',
2321
+ urgency: item.severity === 'high' ? 'urgent' : 'normal',
2322
+ title: item.title,
2323
+ summary: `${item.description}\n\nRoute: ${item.route.type} — ${item.route.detail}`,
2324
+ context_anchor: 'ring3-close friction scan',
2325
+ evidence: { severity: item.severity, route: item.route },
2326
+ });
2327
+ tally.routed++;
2328
+ continue;
2329
+ }
2330
+
2331
+ // The third arm, and it comes from the data: of the five friction items
2332
+ // the operator KEPT, four exited as `captured-to-memory` — platform
2333
+ // constraints with a durable workaround ("Claude-in-Chrome cannot connect
2334
+ // while Claude Desktop is running"). Those have no upstream route and
2335
+ // never will, and dropping them would discard the useful half of this
2336
+ // lens. They are knowledge, so they go where knowledge goes.
2337
+ if (item.durable === true) {
2338
+ createItem({
2339
+ ...base,
2340
+ category: 'knowledge-extraction',
2341
+ urgency: 'low',
2342
+ title: item.title,
2343
+ summary: item.description,
2344
+ context_anchor: 'ring3-close friction scan (durable constraint, no upstream route)',
2345
+ evidence: { type: 'constraint', home: 'memory', severity: item.severity, source: 'friction-lens' },
2346
+ });
2347
+ tally.durable++;
2348
+ continue;
2349
+ }
2350
+
2351
+ tally.dropped++;
2173
2352
  }
2174
2353
 
2175
- log(`Phase 2g: ${frictionItems.length} upstream friction item(s) queued`);
2354
+ // All three counts, always. Silence here would be indistinguishable from
2355
+ // the lens never running — and the drop count is the number this change
2356
+ // exists to move, so it must be observable.
2357
+ log(`Phase 2g: friction — ${tally.routed} routed, ${tally.durable} durable→knowledge, `
2358
+ + `${tally.dropped} dropped (no route, nothing durable)`);
2176
2359
  } catch (e) {
2177
2360
  logError(`Phase 2g: Friction detection failed: ${e.message}`);
2178
2361
  }
@@ -3861,6 +4044,12 @@ export {
3861
4044
  threadCursorLines,
3862
4045
  buildExtractionCorpora,
3863
4046
  completionReviewEmitGuard,
4047
+ // Exported for the quote-instrumentation suite (act:ea23b3a5): a fixture
4048
+ // must be built by the REAL preprocessor, because its JSON-serialized output
4049
+ // — not plain prose — is what the matcher actually faces.
4050
+ preprocessTranscript,
4051
+ // Friction actionability gate (act:ea23b3a5)
4052
+ upstreamFriction,
3864
4053
  discoverSessionAdvisors,
3865
4054
  parseAdvisorFindings,
3866
4055
  advisorPass,