moflo 4.10.25-rc.1 → 4.10.26

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.
@@ -7,17 +7,28 @@
7
7
  * deterministic and unit-testable instead of a prose decision Claude makes
8
8
  * over and over per run.
9
9
  *
10
- * Rule: default to single-agent Sonnet review. Only escalate to a 3-agent
11
- * fan-out when diff signals genuinely warrant it. Opus is never selected
12
- * the existing skill already documents that.
10
+ * Rule: default to single-agent Sonnet review. Escalate to a 3-agent Sonnet
11
+ * fan-out (NORMAL) when diff signals warrant it, and to a 3-agent Opus fan-out
12
+ * (DEEP) only for genuinely architectural diffs — ordinary review is
13
+ * breadth-bound (Sonnet wins), but architectural review is depth-bound (Opus
14
+ * earns its cost). The most extreme diffs additionally suggest handing off to
15
+ * Claude Code's built-in /simplify via escalate.suggested. (#1222 follow-up)
16
+ *
17
+ * Opus escalation is gated on genuine new-logic evidence, NEVER raw volume:
18
+ * TS/JS uses net-new declarations; other languages use net-new lines
19
+ * (added − deleted, aggregate → relocation/churn cancels out). Noise
20
+ * (lockfiles, snapshots, generated/vendored) and docs/data never count toward
21
+ * the opus bar. So a lockfile bump, a reformatting sweep, or a big rename can
22
+ * never reach Opus.
13
23
  *
14
24
  * Outputs JSON:
15
25
  * {
16
- * "tier": "TRIVIAL" | "SMALL" | "NORMAL",
17
- * "model": "sonnet",
26
+ * "tier": "TRIVIAL" | "SMALL" | "NORMAL" | "DEEP",
27
+ * "model": "sonnet" | "haiku" | "opus",
18
28
  * "agentCount": 0 | 1 | 3,
29
+ * "escalate": { suggested: bool, target: "builtin-simplify"|null, reason: string|null },
19
30
  * "reasoning": [string, ...],
20
- * "stats": { added, deleted, fileCount, declAdded, declRemoved, ... }
31
+ * "stats": { added, deleted, fileCount, declAdded, declRemoved, tsjsLOC, tsjsNetDecls, otherNetAdded, ... }
21
32
  * }
22
33
  *
23
34
  * Usage:
@@ -45,34 +56,97 @@ const SECURITY_PATHS = [
45
56
  /(?:^|[\\\/])\.claude[\\\/]helpers[\\\/]gate/i,
46
57
  ];
47
58
 
48
- function safeExec(cmd) {
49
- try { return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); }
50
- catch { return ''; }
59
+ // ── File-family classification for the opus-escalation gate (#1222) ───────────
60
+ // Opus is gated on genuine new-logic evidence, never raw volume so generated
61
+ // noise and docs/data are stripped before measuring, and TS/JS (where the decl
62
+ // parser is accurate) is measured by net-new declarations while other languages
63
+ // fall back to net-new lines.
64
+
65
+ // Generated / vendored noise — inflates LOC without adding reviewable logic.
66
+ const NOISE_FILE = [
67
+ /(?:^|[\\\/])(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|composer\.lock|Cargo\.lock|poetry\.lock|Gemfile\.lock|go\.sum)$/i,
68
+ /\.snap$/i,
69
+ /(?:^|[\\\/])__snapshots__[\\\/]/i,
70
+ /(?:^|[\\\/])(?:dist|build|out|coverage|node_modules)[\\\/]/i,
71
+ /\.min\.(?:js|css)$/i,
72
+ /\.(?:map|bundle\.js)$/i,
73
+ /(?:^|[\\\/])vendor[\\\/]/i,
74
+ ];
75
+
76
+ // Docs / data — reviewed at normal tiers, but never counted toward the opus bar.
77
+ const DOCDATA_FILE = /\.(?:md|mdx|markdown|txt|rst|json|json5|ya?ml|toml|ini|cfg|conf|xml|csv|tsv|svg|properties|env)$/i;
78
+
79
+ // TS/JS source — the declaration parser is accurate here, so the opus gate uses
80
+ // net-new declarations for these files.
81
+ const TSJS_FILE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
82
+
83
+ /**
84
+ * Bucket a file path into the family the opus-escalation gate cares about:
85
+ * 'noise' / 'docdata' (both excluded from the opus bar), 'tsjs' (decl-gated),
86
+ * or 'othercode' (net-line-gated). Pure function over the path string.
87
+ */
88
+ function fileFamily(filename) {
89
+ if (NOISE_FILE.some((rx) => rx.test(filename))) return 'noise';
90
+ if (TSJS_FILE.test(filename)) return 'tsjs';
91
+ if (DOCDATA_FILE.test(filename)) return 'docdata';
92
+ return 'othercode';
93
+ }
94
+
95
+ // Default "no escalation" marker attached to every non-DEEP decision so the
96
+ // output shape (decision.escalate) is stable for every consumer.
97
+ function noEscalate() {
98
+ return { suggested: false, target: null, reason: null };
99
+ }
100
+
101
+ function safeExec(cmd, opts) {
102
+ try {
103
+ return execSync(cmd, {
104
+ encoding: 'utf-8',
105
+ stdio: ['pipe', 'pipe', 'pipe'],
106
+ ...(opts && opts.cwd ? { cwd: opts.cwd } : {}),
107
+ });
108
+ } catch { return ''; }
51
109
  }
52
110
 
53
111
  // Detect the consumer's default branch. Hardcoding 'main' silently miscalibrates
54
112
  // classification on repos that use 'master', 'develop', etc. — empty diff →
55
113
  // TRIVIAL → gate stamps clean without any real review.
56
114
  let _cachedDefaultBranch = null;
57
- function detectDefaultBranch() {
58
- if (_cachedDefaultBranch !== null) return _cachedDefaultBranch;
115
+ function detectDefaultBranch(cwd) {
116
+ // Cache by cwd so tests probing multiple repos in-process don't return a
117
+ // single stale value; CLI use passes no cwd and benefits from the cache.
118
+ if (cwd === undefined && _cachedDefaultBranch !== null) return _cachedDefaultBranch;
119
+ const opts = cwd ? { cwd } : undefined;
59
120
 
60
121
  // Preferred: origin/HEAD points to whatever the remote considers default.
61
- const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD').trim();
62
- if (symbolic.startsWith('origin/')) return (_cachedDefaultBranch = symbolic.slice('origin/'.length));
122
+ const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD', opts).trim();
123
+ if (symbolic.startsWith('origin/')) {
124
+ const v = symbolic.slice('origin/'.length);
125
+ if (cwd === undefined) _cachedDefaultBranch = v;
126
+ return v;
127
+ }
63
128
 
64
129
  // Fallback: local init.defaultBranch (set by `git init -b <name>` or config).
65
- const configured = safeExec('git config --get init.defaultBranch').trim();
66
- if (configured) return (_cachedDefaultBranch = configured);
130
+ const configured = safeExec('git config --get init.defaultBranch', opts).trim();
131
+ if (configured) {
132
+ if (cwd === undefined) _cachedDefaultBranch = configured;
133
+ return configured;
134
+ }
67
135
 
68
136
  // Last resort: 'main' (most common modern default).
69
- return (_cachedDefaultBranch = 'main');
137
+ if (cwd === undefined) _cachedDefaultBranch = 'main';
138
+ return 'main';
139
+ }
140
+
141
+ function _resetCacheForTest() {
142
+ _cachedDefaultBranch = null;
70
143
  }
71
144
 
72
- function readDiffFromGit(base) {
145
+ function readDiffFromGit(base, cwd) {
146
+ const opts = cwd ? { cwd } : undefined;
73
147
  // Combined diff: committed-since-base + working-tree
74
- const committed = safeExec(`git diff ${base}...HEAD`);
75
- const working = safeExec('git diff HEAD');
148
+ const committed = safeExec(`git diff ${base}...HEAD`, opts);
149
+ const working = safeExec('git diff HEAD', opts);
76
150
  return committed + (working ? '\n' + working : '');
77
151
  }
78
152
 
@@ -125,6 +199,11 @@ function parseDiff(diff) {
125
199
  let added = 0, deleted = 0, declAdded = 0, declRemoved = 0;
126
200
  let newFiles = 0, renamedFiles = 0;
127
201
  let securityHit = false;
202
+ // Family-segregated signals for the opus-escalation gate (#1222). Noise +
203
+ // docs/data still contribute to the global totals (so existing
204
+ // TRIVIAL/SMALL/NORMAL routing is unchanged) but never to the opus bar.
205
+ let tsjsAdded = 0, tsjsDeleted = 0, tsjsDeclAdded = 0, tsjsDeclRemoved = 0;
206
+ let otherAdded = 0, otherDeleted = 0;
128
207
  for (const f of files.values()) {
129
208
  added += f.added;
130
209
  deleted += f.deleted;
@@ -133,6 +212,14 @@ function parseDiff(diff) {
133
212
  if (f.isNew) newFiles++;
134
213
  if (f.isRenamed) renamedFiles++;
135
214
  if (SECURITY_PATHS.some(rx => rx.test(f.filename))) securityHit = true;
215
+
216
+ const fam = fileFamily(f.filename);
217
+ if (fam === 'tsjs') {
218
+ tsjsAdded += f.added; tsjsDeleted += f.deleted;
219
+ tsjsDeclAdded += f.declAdded; tsjsDeclRemoved += f.declRemoved;
220
+ } else if (fam === 'othercode') {
221
+ otherAdded += f.added; otherDeleted += f.deleted;
222
+ }
136
223
  }
137
224
 
138
225
  return {
@@ -141,6 +228,13 @@ function parseDiff(diff) {
141
228
  fileCount: files.size,
142
229
  newFiles, renamedFiles,
143
230
  securityHit,
231
+ // Opus-gate signals (#1222): net-new declarations for TS/JS, net-new lines
232
+ // for other code. Aggregate net → relocation/churn cancels to ~0.
233
+ tsjsLOC: tsjsAdded + tsjsDeleted,
234
+ tsjsNetDecls: tsjsDeclAdded - tsjsDeclRemoved,
235
+ tsjsDeclAdded,
236
+ otherNetAdded: otherAdded - otherDeleted,
237
+ otherLOC: otherAdded + otherDeleted,
144
238
  files: [...files.keys()],
145
239
  };
146
240
  }
@@ -154,13 +248,13 @@ function decide(stats) {
154
248
  const totalChange = stats.added + stats.deleted;
155
249
 
156
250
  if (totalChange === 0) {
157
- return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, reasoning: ['empty diff — nothing to review'], stats };
251
+ return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, escalate: noEscalate(), reasoning: ['empty diff — nothing to review'], stats };
158
252
  }
159
253
 
160
254
  // TRIVIAL: tiny diff, no declarations changed
161
255
  if (totalChange <= 10 && stats.fileCount <= 1 && stats.netDecls === 0 && stats.declAdded === 0 && stats.declRemoved === 0) {
162
256
  reasoning.push(`≤10 LOC in 1 file with no declaration changes`);
163
- return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, reasoning, stats };
257
+ return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, escalate: noEscalate(), reasoning, stats };
164
258
  }
165
259
 
166
260
  // Mechanical relocation detection — the #906 case.
@@ -182,11 +276,60 @@ function decide(stats) {
182
276
  // Haiku is sufficient for mechanical moves: code already existed and worked,
183
277
  // so review reduces to copy-paste-divergence / dead-after-move pattern checks
184
278
  // — exactly haiku's strength. ~5x cheaper than sonnet on relocation-shape diffs.
185
- return { tier: 'SMALL', model: 'haiku', agentCount: 1, reasoning, stats };
279
+ return { tier: 'SMALL', model: 'haiku', agentCount: 1, escalate: noEscalate(), reasoning, stats };
280
+ }
281
+
282
+ // ── Architectural escalation to Opus (#1222) ────────────────────────────────
283
+ // Two rungs above NORMAL, BOTH gated on genuine new-logic evidence so volume
284
+ // alone never escalates: TS/JS by net-new declarations, other languages by
285
+ // net-new lines. The relocation guard above already returned SMALL, so
286
+ // mechanical moves never reach here, and noise/docs/data were stripped from
287
+ // these signals in parseDiff.
288
+ //
289
+ // • DEEP → runs a 3-agent Opus pass automatically (depth-bound
290
+ // architectural review; ordinary review stays Sonnet).
291
+ // • DEEP + handoff → also suggests Claude Code's built-in /simplify for the
292
+ // most extreme diffs (escalate.suggested = true). The
293
+ // Opus pass still runs as the floor; the handoff is a
294
+ // prompt, not an auto-switch.
295
+ const tsjsLOC = stats.tsjsLOC || 0;
296
+ const tsjsNetDecls = stats.tsjsNetDecls || 0;
297
+ const tsjsDeclAdded = stats.tsjsDeclAdded || 0;
298
+ const otherNetAdded = stats.otherNetAdded || 0;
299
+
300
+ // The new-subsystem triggers count TS/JS declarations only (tsjs-scoped, not
301
+ // global) so a docs/data file with a fenced `export function` code sample
302
+ // can't leak into the opus gate — consistent with the net-new-logic contract.
303
+ const handoffTriggers = [];
304
+ if (tsjsLOC > 4000 && tsjsNetDecls >= 25) handoffTriggers.push(`${tsjsLOC} LOC of TS/JS with ${tsjsNetDecls} net-new declarations`);
305
+ if (otherNetAdded > 3000) handoffTriggers.push(`${otherNetAdded} net-new lines of non-TS/JS source`);
306
+ if (stats.newFiles >= 10 && tsjsDeclAdded >= 30 && tsjsNetDecls >= 20) handoffTriggers.push(`${stats.newFiles} new files with ${tsjsDeclAdded} new TS/JS declarations`);
307
+
308
+ if (handoffTriggers.length > 0) {
309
+ return {
310
+ tier: 'DEEP', model: 'opus', agentCount: 3,
311
+ escalate: { suggested: true, target: 'builtin-simplify', reason: handoffTriggers.join('; ') },
312
+ reasoning: handoffTriggers, stats,
313
+ };
314
+ }
315
+
316
+ const deepTriggers = [];
317
+ if (tsjsLOC > 1500 && tsjsNetDecls >= 10) deepTriggers.push(`${tsjsLOC} LOC of TS/JS with ${tsjsNetDecls} net-new declarations`);
318
+ if (otherNetAdded > 1200) deepTriggers.push(`${otherNetAdded} net-new lines of non-TS/JS source`);
319
+ if (stats.newFiles >= 5 && tsjsDeclAdded >= 15 && tsjsNetDecls >= 10) deepTriggers.push(`${stats.newFiles} new files with ${tsjsDeclAdded} new TS/JS declarations`);
320
+ if (stats.securityHit && stats.netDecls >= 8) deepTriggers.push(`security-sensitive path with ${stats.netDecls} net-new declarations`);
321
+
322
+ if (deepTriggers.length > 0) {
323
+ return {
324
+ tier: 'DEEP', model: 'opus', agentCount: 3,
325
+ escalate: noEscalate(),
326
+ reasoning: deepTriggers, stats,
327
+ };
186
328
  }
187
329
 
188
330
  // Escalation triggers — any one trips NORMAL (3 agents).
189
- // Always Sonnet — Opus is never the right model for /simplify per skill rule.
331
+ // Sonnet — ordinary cross-cutting review is breadth-bound, so 3 Sonnet agents
332
+ // are the right tool; Opus is reserved for the DEEP (architectural) tier above.
190
333
  const triggers = [];
191
334
  if (totalChange > 500) triggers.push(`>500 LOC changed (${totalChange})`);
192
335
  if (stats.fileCount >= 5 && stats.netDecls >= 3) triggers.push(`${stats.fileCount} files with ${stats.netDecls} net new declarations`);
@@ -194,21 +337,21 @@ function decide(stats) {
194
337
  if (stats.newFiles >= 3 && stats.declAdded >= 5) triggers.push(`${stats.newFiles} new files with ${stats.declAdded} new declarations`);
195
338
 
196
339
  if (triggers.length > 0) {
197
- return { tier: 'NORMAL', model: 'sonnet', agentCount: 3, reasoning: triggers, stats };
340
+ return { tier: 'NORMAL', model: 'sonnet', agentCount: 3, escalate: noEscalate(), reasoning: triggers, stats };
198
341
  }
199
342
 
200
343
  // Default: SMALL — single sonnet agent
201
344
  reasoning.push(`small/medium diff: ${totalChange} LOC across ${stats.fileCount} file(s), +${stats.declAdded}/-${stats.declRemoved} decls`);
202
- return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
345
+ return { tier: 'SMALL', model: 'sonnet', agentCount: 1, escalate: noEscalate(), reasoning, stats };
203
346
  }
204
347
 
205
348
  function classifyDiff(diffText) {
206
349
  return decide(parseDiff(diffText));
207
350
  }
208
351
 
209
- function classifyFromGit(base) {
210
- const resolved = base || detectDefaultBranch();
211
- return classifyDiff(readDiffFromGit(resolved));
352
+ function classifyFromGit(base, cwd) {
353
+ const resolved = base || detectDefaultBranch(cwd);
354
+ return classifyDiff(readDiffFromGit(resolved, cwd));
212
355
  }
213
356
 
214
357
  if (require.main === module) {
@@ -232,4 +375,4 @@ if (require.main === module) {
232
375
  }
233
376
  }
234
377
 
235
- module.exports = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch };
378
+ module.exports = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch, _resetCacheForTest };
@@ -28,11 +28,12 @@ The classifier auto-detects the repo's default branch (origin/HEAD, then `init.d
28
28
  Output:
29
29
  ```json
30
30
  {
31
- "tier": "TRIVIAL" | "SMALL" | "NORMAL",
32
- "model": "sonnet",
31
+ "tier": "TRIVIAL" | "SMALL" | "NORMAL" | "DEEP",
32
+ "model": "sonnet" | "haiku" | "opus",
33
33
  "agentCount": 0 | 1 | 3,
34
+ "escalate": { "suggested": false, "target": null, "reason": null },
34
35
  "reasoning": ["..."],
35
- "stats": { "added": ..., "deleted": ..., "declAdded": ..., "declRemoved": ..., "netDecls": ..., "fileCount": ..., "securityHit": ... }
36
+ "stats": { "added": ..., "deleted": ..., "declAdded": ..., "declRemoved": ..., "netDecls": ..., "fileCount": ..., "securityHit": ..., "tsjsLOC": ..., "tsjsNetDecls": ..., "otherNetAdded": ... }
36
37
  }
37
38
  ```
38
39
 
@@ -69,6 +70,17 @@ Reserved for **genuinely cross-cutting** changes that single-agent review can't
69
70
 
70
71
  Three agents exist to cover orthogonal axes (Reuse / Quality / Efficiency) when the change is broad enough that one agent's tool-call budget can't survey it all. For single-file edits, one focused agent always covers all three axes — three is duplication, not coverage.
71
72
 
73
+ ### DEEP — three parallel Opus agents (architectural, rare)
74
+ The top rung, for genuinely **architectural** change where *depth* of reasoning — not just breadth — earns Opus's cost (judging whether a large refactor picked the right abstractions is reasoning, not surveying). The classifier escalates to DEEP only on real new-logic evidence, **never raw volume** — any of:
75
+ - `>1500 LOC of TS/JS AND ≥10 net-new declarations`
76
+ - `>1200 net-new lines of non-TS/JS source` (other languages are gated on net lines because the declaration parser is TS/JS-shaped)
77
+ - `5+ new files AND ≥15 new declarations AND ≥10 net` (a real new subsystem)
78
+ - `security-sensitive path AND ≥8 net-new declarations`
79
+
80
+ DEEP runs **automatically — no prompt.** Noise (lockfiles, snapshots, generated/vendored) and docs/data are stripped before measuring, and the signal is *net of churn* (TS/JS by net-new declarations, other code by net-new lines), so a lockfile bump, a reformatting sweep, or a large rename/relocation can never reach Opus — they cancel to ~0 and stay SMALL/NORMAL.
81
+
82
+ For the most **extreme** diffs (`>4000 LOC TS/JS + ≥25 net decls`, `>3000 net-new non-TS/JS lines`, or `10+ new files + ≥30 decls + ≥20 net`), the classifier also sets `escalate.suggested = true` with `target: "builtin-simplify"`. The Opus pass still runs as the floor; you then **offer** the user a handoff to Claude Code's built-in `/simplify` (Phase 3) — a suggestion, not an auto-switch.
83
+
72
84
  ## Phase 2.5: Validation pass (re-run after fixes)
73
85
 
74
86
  If `/flo-simplify` already ran on this branch in this session AND the only edits since are fixes driven by the prior pass's findings, default to **self-review tier** regardless of LOC count. The fan-out already happened; the fix is small relative to the diff that was already reviewed.
@@ -79,7 +91,7 @@ Escalate one tier (self-review → SMALL agent) only if the fix introduced any o
79
91
  - A new dependency or import from a previously-untouched module
80
92
  - A change to control flow not covered in the original findings
81
93
 
82
- Do **not** escalate to NORMAL on a validation pass. If the fix is so structural that NORMAL is warranted, treat it as a fresh diff and start over from Phase 1.
94
+ Do **not** escalate to NORMAL or DEEP on a validation pass. If the fix is so structural that NORMAL/DEEP is warranted, treat it as a fresh diff and start over from Phase 1.
83
95
 
84
96
  ## Phase 2.7: Model selection
85
97
 
@@ -87,7 +99,7 @@ Do **not** escalate to NORMAL on a validation pass. If the fix is so structural
87
99
 
88
100
  - `sonnet` (default) — real logic changes, single agent or three-agent fan-out.
89
101
  - `haiku` — mostly-relocation diffs (mechanical moves where pattern-matching beats deep reasoning, ~5x cheaper).
90
- - `opus` — never. Code review is breadth-bound, not depth-bound; the three-agent fan-out at sonnet is the high-effort tier.
102
+ - `opus` — the **DEEP** tier only. Architectural review is depth-bound (judging whether a large refactor picked the right abstractions is reasoning, not surveying), so the classifier returns opus when — and only when — the diff clears the DEEP bar. Never pick opus yourself for SMALL/NORMAL; ordinary review is breadth-bound and three sonnet agents are the right tool.
91
103
 
92
104
  If you fell back to prose rules in Phase 2 (no classifier available), use `sonnet` unconditionally. Pass the classifier's `model` field verbatim to Agent's `model` parameter.
93
105
 
@@ -123,6 +135,15 @@ Launch three agents in a single message — Reuse, Quality, Efficiency — passi
123
135
 
124
136
  **Efficiency**: unnecessary work, missed concurrency, hot-path bloat, recurring no-op updates, TOCTOU existence checks, unbounded structures, over-broad reads.
125
137
 
138
+ ### DEEP: three parallel Opus agents (architectural) — and maybe a handoff
139
+ Same three-agent fan-out as NORMAL (Reuse / Quality / Efficiency in one message), but pass `model: "opus"` to each — the classifier already decided depth is warranted. **Print one line first** so the heavier cost is never silent, e.g. `distill: DEEP — 1,800 LOC of new logic, ran opus depth pass`.
140
+
141
+ If the classifier set `escalate.suggested = true`, then **after** the Opus pass finishes, surface a handoff suggestion to the user — never switch automatically:
142
+
143
+ > This change is large enough (`<escalate.reason>`) that Claude Code's built-in `/simplify` — a deeper, heavier multi-agent pass — may add value beyond this review. Want to run it? It costs more tokens, so it's your call.
144
+
145
+ Then stop and let the user decide. Do **not** invoke the built-in `/simplify` yourself — the handoff is the user's choice, and the Opus pass you just ran already covers the diff.
146
+
126
147
  ## Phase 4: Fix or skip
127
148
 
128
149
  Aggregate findings. Fix each one directly. False positives or not-worth-fixing — note and skip without arguing. If self-review found nothing, just confirm clean and exit.
@@ -87,11 +87,11 @@ mcp__moflo__memory_store {
87
87
  namespace: "learnings",
88
88
  key: "<stable descriptive slug, e.g. pattern:daemon-port-resolver or gotcha:windows-spell-path>",
89
89
  value: "<the lesson> — Why: <why it matters>. How to apply: <what to do next time>.",
90
- tags: ["<topic>", "<area>"]
90
+ tags: ["<topic>", "<area>", "source:meditate-manual"]
91
91
  }
92
92
  ```
93
93
 
94
- Keep keys stable and descriptive so the next `/meditate` updates rather than re-adds. In `--preview` mode, **stop here** — print the candidates and their would-be keys/dedup verdicts, write nothing.
94
+ Always include the `source:meditate-manual` tag — it lets the Luminarium "Learnings" panel attribute each lesson to `/meditate` vs the automatic auto-meditate distill. Keep keys stable and descriptive so the next `/meditate` updates rather than re-adds. In `--preview` mode, **stop here** — print the candidates and their would-be keys/dedup verdicts, write nothing.
95
95
 
96
96
  ## Step 4 — Report
97
97
 
@@ -39,11 +39,12 @@ import { randomBytes } from 'crypto';
39
39
  // ── Tunables (exported for tests) ───────────────────────────────────────────
40
40
  /** Model used for the bounded distillation pass (cheap; Haiku is a formatter). */
41
41
  export const HAIKU_MODEL_ID = 'claude-haiku-4-5-20251001';
42
- /** Min gap between in-session capture directives, so a chatty correction streak
43
- * can't spam the model. */
44
- export const INJECT_WINDOW_MS = 8 * 60_000;
42
+ /** Min gap between in-session capture directives — a debounce so a burst of
43
+ * signals in quick succession injects the directive at most once per window.
44
+ * Shorter window = more capture opportunities (and more injected context). */
45
+ export const INJECT_WINDOW_MS = 5 * 60_000;
45
46
  /** Hard cap on directives per session (belt-and-braces over the time window). */
46
- export const INJECT_MAX_PER_SESSION = 6;
47
+ export const INJECT_MAX_PER_SESSION = 10;
47
48
  /** Ledger size cap — distilled entries are pruned first, then oldest. */
48
49
  export const MAX_LEDGER_ENTRIES = 200;
49
50
  /** Keep this many already-distilled entries for idempotency/debug before pruning. */
@@ -148,8 +149,23 @@ const DECISION_PATTERNS = [
148
149
  /\b(decided|decision)\b/i,
149
150
  /\bgoing with\b/i,
150
151
  /\bthe plan is\b/i,
152
+ /\bthe approach is\b/i,
151
153
  /\bwe should (use|go with|do)\b/i,
152
154
  /\bi'?ll go with\b/i,
155
+ // Selection / preference / finalization phrasing.
156
+ /\bgo with (option|approach|the|that|a)\b/i,
157
+ /\bi'?d rather\b/i,
158
+ /\b(we'?ll|let'?s) keep\b/i,
159
+ /\bfinal (decision|answer|call)\b/i,
160
+ // Approval ATTACHED to a concrete action/decision — "go ahead and ship the
161
+ // fix", "ok, use the daemon", "proceed with option B". Bare approval alone
162
+ // ("ok"/"yes"/"go ahead"/"sounds good") deliberately does NOT fire: it must
163
+ // carry an action to count as a decision moment, else it would trip on nearly
164
+ // every agreeing turn and burn the per-session injection budget. The live
165
+ // model's "append nothing" remains the final precision filter.
166
+ /\bgo ahead (and|with)\b/i,
167
+ /\bproceed with\b/i,
168
+ /\b(yes|yeah|yep|sure|ok|okay|sounds good|lgtm)[\s,!.]+(go with|use|ship|build|implement|add|proceed)\b/i,
153
169
  ];
154
170
 
155
171
  // Tight, low-false-positive markers only — tool output mentions "error" benignly
@@ -162,6 +178,32 @@ const ERROR_PATTERNS = [
162
178
  /\b[1-9]\d* (failed|failing)\b/i,
163
179
  ];
164
180
 
181
+ // Bare approval that, on its own, looks like mere assent ("yes", "ok", "go
182
+ // ahead") — but confirms a real decision when the PRIOR assistant turn proposed
183
+ // one. Anchored at the start of the user's message so it matches a reply that
184
+ // LEADS with approval. Paired with PROPOSAL_PATTERNS below as the precision gate.
185
+ const APPROVAL_PATTERNS = [
186
+ /^\s*(y(es|ep|eah|up)?|sure|ok(ay)?|kk?|roger|agreed?|perfect|great|nice|sounds good|lgtm|do it|go ahead|proceed|approved?|let'?s do it|make it so|ship it)\b/i,
187
+ ];
188
+
189
+ // Proposal / recommendation / options language the assistant uses when it asks
190
+ // the user to choose or approve. Scanned against the recent transcript tail; a
191
+ // match here means a bare approval in the prompt is confirming a real decision,
192
+ // so "yes" after "I recommend we use the daemon" fires, but "ok thanks" after a
193
+ // plain status report does not. Recall-biased — the live model still filters.
194
+ const PROPOSAL_PATTERNS = [
195
+ /\bi (propose|recommend|suggest|advise)\b/i,
196
+ /\bi'?(d|ll) (recommend|suggest|propose|go with|use|implement|add|build|refactor)\b/i,
197
+ /\bwe (could|should|can|might) (use|go with|implement|refactor|add|switch|split)\b/i,
198
+ /\b(option|approach|plan|choice) (a|b|c|1|2|3|one|two)\b/i,
199
+ /\b(two|three|several|a few) (options|approaches|choices|ways)\b/i,
200
+ /\bshould i (proceed|go ahead|use|implement|start|continue|create|add)\b/i,
201
+ /\b(would|do) you (like|want) me to\b/i,
202
+ /\bhere'?s (the|my|a) (plan|approach|proposal|recommendation)\b/i,
203
+ /\brecommendation\b/i,
204
+ /\bpropos(e|ed|al)\b/i,
205
+ ];
206
+
165
207
  function anyMatch(patterns, text) {
166
208
  if (!text) return false;
167
209
  for (const re of patterns) if (re.test(text)) return true;
@@ -182,6 +224,12 @@ export function detectSignal(prompt, transcriptTail = '') {
182
224
  const t = typeof transcriptTail === 'string' ? transcriptTail : '';
183
225
  if (anyMatch(CORRECTION_PATTERNS, p)) return { hit: true, kind: 'correction' };
184
226
  if (anyMatch(DECISION_PATTERNS, p) || anyMatch(DECISION_PATTERNS, t)) return { hit: true, kind: 'decision' };
227
+ // Bare approval in the prompt that confirms a proposal/recommendation the
228
+ // assistant made in the immediately prior turn — "yes"/"go ahead" answering
229
+ // "I recommend we use the daemon". The prior-turn proposal (in transcriptTail)
230
+ // is the precision gate: a contextless "ok thanks" with nothing proposed above
231
+ // it does NOT fire.
232
+ if (anyMatch(APPROVAL_PATTERNS, p) && anyMatch(PROPOSAL_PATTERNS, t)) return { hit: true, kind: 'decision' };
185
233
  if (anyMatch(ERROR_PATTERNS, t)) return { hit: true, kind: 'error_fix' };
186
234
  return { hit: false, kind: null };
187
235
  }
@@ -241,11 +289,11 @@ export function buildCaptureDirective(kind) {
241
289
  return [
242
290
  `[auto-meditate] A ${label} just occurred this session.`,
243
291
  `FIRST: fully address the user's request as you normally would — do NOT let this note change your answer.`,
244
- `THEN, only if a durable, reusable lesson emerged, append exactly ONE final line of the form:`,
292
+ `THEN, if a reusable lesson emerged, append exactly ONE final line of the form:`,
245
293
  `<meditate-capture>LESSON</meditate-capture>`,
246
294
  `where LESSON is a single concise sentence (a pattern, gotcha, or decision+rationale).`,
247
295
  DURABILITY_BAR,
248
- `If nothing clears that bar, append nothingsilence is the correct, common outcome. Never emit more than one such line.`,
296
+ `When in doubt, capture ita borderline lesson is cheap and the distill dedups; append nothing only for pure session state or git history. Never emit more than one such line.`,
249
297
  ].join('\n');
250
298
  }
251
299
 
@@ -488,6 +536,7 @@ export function buildDistillPrompt(entries) {
488
536
  `1. For EACH candidate, call mcp__moflo__memory_search { namespace: "learnings", query: <bare keywords>, threshold: 0.6, limit: 5 }.`,
489
537
  `2. If the top hit is the SAME fact at similarity >= 0.80, call mcp__moflo__memory_store with that SAME key (upsert), merging any new nuance — do NOT create a near-duplicate.`,
490
538
  `3. Otherwise call mcp__moflo__memory_store { namespace: "learnings", key: <stable descriptive slug>, value: "<lesson> — Why: <why it matters>. How to apply: <what to do next time>.", tags: [<topic>, <area>] }.`,
539
+ ` PROVENANCE (#1203): every memory_store you make — both the step-2 upsert and the step-3 new write — MUST include the tag "source:auto-meditate" in its tags array, so the Luminarium "Learnings" panel can attribute these to the automatic distill pass.`,
491
540
  `4. Skip any candidate that does not clear the durability bar below, or that merely restates an existing entry.`,
492
541
  ``,
493
542
  DURABILITY_BAR,
@@ -69,6 +69,10 @@ function runHeadless(projectRoot, prompt) {
69
69
  ...process.env,
70
70
  CLAUDE_CODE_HEADLESS: 'true', // mark the child so its own hooks no-op (#860)
71
71
  ANTHROPIC_MODEL: HAIKU_MODEL_ID, // cheap formatter model
72
+ // Deterministic provenance: the write path stamps source:auto-meditate on
73
+ // these learnings writes even if Haiku omits the tag it's asked to add
74
+ // (#1203 follow-up). Honoured by storeEntry in memory/entries-write.ts.
75
+ MOFLO_LEARNINGS_SOURCE: 'auto-meditate',
72
76
  };
73
77
 
74
78
  let child;