mixdog 0.9.12 → 0.9.14

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 (58) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/defaults/cycle3-review-prompt.md +14 -0
  5. package/src/defaults/memory-promote-prompt.md +9 -9
  6. package/src/lib/rules-builder.cjs +15 -11
  7. package/src/mixdog-session-runtime.mjs +10 -0
  8. package/src/rules/agent/00-common.md +5 -17
  9. package/src/rules/agent/00-core.md +21 -0
  10. package/src/rules/lead/lead-brief.md +7 -0
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  14. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  15. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  17. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  18. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  22. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  24. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  25. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  26. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  28. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  29. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  30. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  31. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  32. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  33. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  34. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
  35. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  36. package/src/runtime/agent/orchestrator/session/manager.mjs +30 -15
  37. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  38. package/src/runtime/channels/index.mjs +28 -1
  39. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  40. package/src/runtime/memory/index.mjs +18 -13
  41. package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
  42. package/src/runtime/memory/lib/cycle-scheduler.mjs +52 -18
  43. package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
  44. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +37 -25
  45. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
  46. package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
  47. package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
  48. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  49. package/src/runtime/memory/lib/memory.mjs +7 -1
  50. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  51. package/src/standalone/agent-tool.mjs +89 -7
  52. package/src/tui/dist/index.mjs +203 -10
  53. package/src/tui/engine/tui-steering-persist.mjs +175 -0
  54. package/src/tui/engine.mjs +46 -2
  55. package/src/ui/statusline.mjs +2 -4
  56. package/src/workflows/default/WORKFLOW.md +2 -0
  57. package/src/workflows/sequential/WORKFLOW.md +2 -0
  58. package/src/workflows/solo/WORKFLOW.md +2 -0
@@ -30,6 +30,8 @@ const CYCLE1_AUTO_RESTART_COOLDOWN_MS = 5 * 60_000
30
30
  const BACKLOG_WARN_COOLDOWN_MS = 10 * 60_000
31
31
  const BACKLOG_WARN_PENDING = 500
32
32
  const BACKLOG_WARN_FAILURES = 5
33
+ // Max back-to-back cycle2 passes per scheduled slot (config: cycle2.catchup_passes).
34
+ const CYCLE2_CATCHUP_PASSES = 4
33
35
 
34
36
  export function createCycleScheduler(deps) {
35
37
  const {
@@ -255,20 +257,41 @@ export function createCycleScheduler(deps) {
255
257
  _cycle2InFlight = true
256
258
  markCycleRunning('cycle2')
257
259
  try {
258
- let c2Options = {
259
- coalescedRetry: true,
260
- onCoalescedSuccess: _finalizeCycle2Run,
261
- }
262
- if (typeof c2Options?.callLlm !== 'function') {
263
- c2Options = { ...c2Options, callLlm: getCycle2CallLlm() }
264
- }
265
- const result = await runCycle2(getDb(), config, c2Options, dataDir)
266
- if (result?.skippedInFlight) {
267
- scheduleScheduledCycle2(config, signature, attempt + 1)
268
- } else if (result?.coalescedRetryNoop) {
269
- log('[cycle2] scheduled queue noop\n')
270
- } else if (result?.ok === false) {
271
- await _finalizeCycle2Run(result)
260
+ // Catch-up drain: one scheduled slot may run several back-to-back
261
+ // passes while pending roots remain, so a backlog above the batch
262
+ // size drains in one interval instead of one batch per hour.
263
+ const drainPasses = Math.max(1, Number(config?.catchup_passes ?? CYCLE2_CATCHUP_PASSES))
264
+ for (let pass = 0; pass < drainPasses; pass++) {
265
+ let c2Options = {
266
+ coalescedRetry: true,
267
+ onCoalescedSuccess: _finalizeCycle2Run,
268
+ }
269
+ if (typeof c2Options?.callLlm !== 'function') {
270
+ c2Options = { ...c2Options, callLlm: getCycle2CallLlm() }
271
+ }
272
+ const result = await runCycle2(getDb(), config, c2Options, dataDir)
273
+ if (result?.skippedInFlight) {
274
+ scheduleScheduledCycle2(config, signature, attempt + 1)
275
+ break
276
+ }
277
+ if (result?.coalescedRetryNoop) {
278
+ log('[cycle2] scheduled queue noop\n')
279
+ break
280
+ }
281
+ if (result?.ok === false) {
282
+ await _finalizeCycle2Run(result)
283
+ break
284
+ }
285
+ // A gate parse/coverage failure marks the run failed even with
286
+ // ok=true; never chain passes on a failing gate.
287
+ if (result?.gate_failed === true) break
288
+ if (pass + 1 >= drainPasses) break
289
+ const pendingRes = await getDb().query(
290
+ `SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`,
291
+ )
292
+ const pendingLeft = Number(pendingRes?.rows?.[0]?.c ?? 0)
293
+ if (pendingLeft <= 0) break
294
+ log(`[cycle2] catch-up pass ${pass + 2}/${drainPasses}: pending=${pendingLeft}\n`)
272
295
  }
273
296
  } catch (err) {
274
297
  log(`[cycle2] scheduled queue failed: ${err?.message || err}\n`)
@@ -324,15 +347,17 @@ export function createCycleScheduler(deps) {
324
347
  log('[cycle2] skipped: in flight\n')
325
348
  return
326
349
  }
327
- if (result.ok) {
350
+ const gateFailed = result?.gate_failed === true
351
+ if (result.ok && !gateFailed) {
328
352
  await setCycleLastRun('cycle2', Date.now())
329
353
  await setCycleLastRun('cycle2_last_error', '')
330
354
  log('[cycle2] completed\n')
331
355
  markCycleDone('cycle2', true)
332
356
  } else {
333
- await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
334
- log(`[cycle2] failed: ${result.error}\n`)
335
- markCycleDone('cycle2', false, result.error || 'unknown error')
357
+ const err = gateFailed ? 'gate_failed' : (result.error || 'unknown error')
358
+ await setCycleLastRun('cycle2_last_error', err)
359
+ log(`[cycle2] failed: ${err}\n`)
360
+ markCycleDone('cycle2', false, err)
336
361
  }
337
362
  }
338
363
 
@@ -487,6 +512,15 @@ export function createCycleScheduler(deps) {
487
512
  startCycle1Run: _startCycle1Run,
488
513
  awaitCycle1Run: _awaitCycle1Run,
489
514
  finalizeCycle2Run: _finalizeCycle2Run,
515
+ // cycle3 success recorder: MCP-driven runs go through runCycle3 directly
516
+ // (no coalesced onCoalescedSuccess), so callers stamp success here to keep
517
+ // cycles.cycle3.last_success_at honest. Non-mutating/failed runs skipped.
518
+ finalizeCycle3Run: async (result) => {
519
+ if (!result || result.skippedInFlight || result.coalescedRetryNoop) return
520
+ if (result.error) { markCycleDone('cycle3', false, result.error); return }
521
+ await setCycleLastRun('cycle3', Date.now())
522
+ markCycleDone('cycle3', true)
523
+ },
490
524
  periodicCycle1Config,
491
525
  // lifecycle
492
526
  startCycles,
@@ -122,7 +122,8 @@ export function buildEntriesText(entries) {
122
122
  const DEFAULT_CYCLE1_RULES = [
123
123
  `Chunk these entries. Emit one chunk per line, NO JSON, NO tool calls, NO prose.`,
124
124
  `Format: idx_csv|element|category|summary.`,
125
- `Every substantive @N should appear in exactly one chunk; omit entries with no standalone memory value.`,
125
+ `Target ${3}-${8} @N indexes per chunk when entries share topic, goal, or cause→resolution; singleton chunks only when one entry is truly isolated.`,
126
+ `Every substantive @N must appear in exactly one chunk; omit only entries with zero standalone memory value — excessive omissions are invalid.`,
126
127
  `Group by coherent topic, keep cause and resolution together, and never merge across [sess:] markers.`,
127
128
  `Category must be one of rule / constraint / decision / fact / goal / preference / task / issue; choose the one that best preserves future recall intent.`,
128
129
  `Keep summary compact and source-grounded; preserve decisive identifiers, constraints, causes, and outcomes when present.`,
@@ -164,7 +165,92 @@ export function parseCycle1LineFormat(raw) {
164
165
  return chunks.length > 0 ? { chunks } : null
165
166
  }
166
167
 
168
+ // Compactness guard thresholds (post-parse); retry LLM once when exceeded on non-trivial windows.
169
+ const CYCLE1_TARGET_CHUNK_MIN = 3
170
+ const CYCLE1_TARGET_CHUNK_MAX = 8
171
+ const CYCLE1_MAX_SINGLETON_RATIO = 0.6
172
+ const CYCLE1_MAX_OMITTED_RATIO = 0.35
173
+ const CYCLE1_MIN_ROWS_FOR_COMPACTNESS_RETRY = 6
174
+
167
175
  // Partition by session_id; MIN_BATCH gates total pending rows, SESSION_CAP bounds per-tick session fan-out.
176
+ function computeCycle1GroupingQuality(chunks, rowCount) {
177
+ const rows = Math.max(0, Number(rowCount) || 0)
178
+ const list = Array.isArray(chunks) ? chunks : []
179
+ const usedIdx = new Set()
180
+ const referenced = new Set()
181
+ let singletonCount = 0
182
+ let chunkCount = 0
183
+ for (const chunk of list) {
184
+ const raw = Array.isArray(chunk?._idxList) ? chunk._idxList.map(n => Number(n)) : []
185
+ if (raw.some(n => !Number.isFinite(n) || n <= 0 || n > rows)) continue
186
+ if (raw.length !== new Set(raw).size) continue
187
+ if (raw.some(n => usedIdx.has(n))) continue
188
+ chunkCount += 1
189
+ if (raw.length === 1) singletonCount += 1
190
+ for (const n of raw) {
191
+ usedIdx.add(n)
192
+ referenced.add(n)
193
+ }
194
+ }
195
+ const omittedCount = Math.max(0, rows - referenced.size)
196
+ const singleton_ratio = chunkCount > 0 ? singletonCount / chunkCount : 0
197
+ const omitted_ratio = rows > 0 ? omittedCount / rows : 0
198
+ return {
199
+ chunkCount,
200
+ singletonCount,
201
+ omittedCount,
202
+ referencedCount: referenced.size,
203
+ singleton_ratio,
204
+ omitted_ratio,
205
+ }
206
+ }
207
+
208
+ function countCycle1CommittableChunks(chunkList, entryByIdx, entryById) {
209
+ const usedIds = new Set()
210
+ let count = 0
211
+ for (const chunk of chunkList) {
212
+ const idxList = chunk._idxList.map(n => Number(n))
213
+ const outOfRange = idxList.filter(n => !entryByIdx.has(n))
214
+ if (outOfRange.length > 0) continue
215
+ const rawIds = idxList.map(n => Number(entryByIdx.get(n).id))
216
+ const dupeWithin = rawIds.length !== new Set(rawIds).size
217
+ const externalIds = rawIds.filter(n => !Number.isFinite(n) || !entryById.has(n))
218
+ const reusedIds = rawIds.filter(n => usedIds.has(n))
219
+ const memberIds = rawIds.filter(n => Number.isFinite(n) && entryById.has(n) && !usedIds.has(n))
220
+ const element = String(chunk?.element ?? '').trim()
221
+ const category = String(chunk?.category ?? '').trim().toLowerCase()
222
+ const summary = String(chunk?.summary ?? '').trim()
223
+ if (dupeWithin || externalIds.length > 0 || reusedIds.length > 0) continue
224
+ if (memberIds.length === 0 || !element || !summary || !VALID_CATEGORIES.has(category)) continue
225
+ if (_isStructurallyInvalidSummary(summary)) continue
226
+ const members = memberIds.map(id => entryById.get(id))
227
+ if (selectRootId(members) === null) continue
228
+ for (const mid of memberIds) usedIds.add(mid)
229
+ count += 1
230
+ }
231
+ return count
232
+ }
233
+
234
+ function cycle1GroupingQualityScore(metrics) {
235
+ return 1 - (metrics.omitted_ratio * 1.5 + metrics.singleton_ratio * 0.75)
236
+ }
237
+
238
+ function shouldRetryCycle1Grouping(metrics, rowCount) {
239
+ if (rowCount < CYCLE1_MIN_ROWS_FOR_COMPACTNESS_RETRY) return false
240
+ if (!metrics || metrics.chunkCount === 0) return false
241
+ return metrics.singleton_ratio > CYCLE1_MAX_SINGLETON_RATIO
242
+ || metrics.omitted_ratio > CYCLE1_MAX_OMITTED_RATIO
243
+ }
244
+
245
+ function buildCycle1CorrectiveRules(metrics) {
246
+ return [
247
+ `CORRECTION: prior grouping was too fragmented (singleton_ratio=${metrics.singleton_ratio.toFixed(2)}, omitted_ratio=${metrics.omitted_ratio.toFixed(2)}).`,
248
+ `Merge related @N into chunks of about ${CYCLE1_TARGET_CHUNK_MIN}-${CYCLE1_TARGET_CHUNK_MAX} entries when they share topic, goal, or causal chain.`,
249
+ `Use singleton chunks only when one entry is truly isolated; otherwise combine neighbors.`,
250
+ `Reference every substantive @N; minimize omissions.`,
251
+ ]
252
+ }
253
+
168
254
  const CYCLE1_MIN_BATCH = 3
169
255
  const CYCLE1_SESSION_CAP = 10
170
256
 
@@ -513,13 +599,11 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
513
599
  }
514
600
  }
515
601
 
516
- const userMessage = buildCycle1ChunkPrompt(rows)
517
602
  const llmCall = typeof options?.callLlm === 'function' ? options.callLlm : callAgentDispatch
518
603
 
519
- let raw
520
- const _tLlm = Date.now()
521
- try {
522
- raw = await llmCall({
604
+ async function callCycle1Grouping(userMessage) {
605
+ const _tLlm = Date.now()
606
+ const raw = await llmCall({
523
607
  agent: 'cycle1-agent',
524
608
  taskType: 'maintenance',
525
609
  mode: 'cycle1',
@@ -528,8 +612,64 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
528
612
  // Pin cwd to null so every memory cycle call hits the same agent cache shard.
529
613
  cwd: null,
530
614
  }, userMessage)
615
+ throwIfAborted(signal)
616
+ __mixdogMemoryLog(`[cycle1-time] window=${windowIdx} llmMs=${Date.now() - _tLlm}\n`)
617
+ const parsed = parseCycle1LineFormat(raw)
618
+ const chunks = Array.isArray(parsed?.chunks) ? parsed.chunks : null
619
+ return { raw, chunks }
620
+ }
621
+
622
+ let groupingAttempt = 1
623
+ let groupingRetried = false
624
+ let chosenQuality = null
625
+ let firstChunkList = null
626
+ let secondChunkList = null
627
+ let chunkList
628
+ try {
629
+ const first = await callCycle1Grouping(buildCycle1ChunkPrompt(rows))
630
+ if (!first.chunks) {
631
+ __mixdogMemoryLog(`[cycle1] unparseable response (window=${windowIdx}) (${String(first.raw).slice(0, 200)})\n`)
632
+ return {
633
+ committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
634
+ invalidChunks: [{ reason: 'unparseable_response', member_ids: rows.map(r => Number(r.id)) }],
635
+ failedRowIds: rows.map(r => Number(r.id)),
636
+ omittedRowIds: prefilteredRowIds,
637
+ prefilteredRowIds,
638
+ prefilterMarked,
639
+ prefilterMarkFailed,
640
+ }
641
+ }
642
+ firstChunkList = first.chunks
643
+ chunkList = firstChunkList
644
+ chosenQuality = computeCycle1GroupingQuality(chunkList, rows.length)
645
+
646
+ if (shouldRetryCycle1Grouping(chosenQuality, rows.length)) {
647
+ groupingRetried = true
648
+ const correctiveRules = [
649
+ ...DEFAULT_CYCLE1_RULES,
650
+ '',
651
+ ...buildCycle1CorrectiveRules(chosenQuality),
652
+ ]
653
+ try {
654
+ const second = await callCycle1Grouping(buildCycle1ChunkPrompt(rows, correctiveRules))
655
+ if (second.chunks) {
656
+ secondChunkList = second.chunks
657
+ const secondQuality = computeCycle1GroupingQuality(second.chunks, rows.length)
658
+ const scoreFirst = cycle1GroupingQualityScore(chosenQuality)
659
+ const scoreSecond = cycle1GroupingQualityScore(secondQuality)
660
+ if (scoreSecond > scoreFirst) {
661
+ chunkList = second.chunks
662
+ chosenQuality = secondQuality
663
+ groupingAttempt = 2
664
+ }
665
+ }
666
+ } catch (retryErr) {
667
+ if (signal?.aborted) throw retryErr.reason ?? retryErr
668
+ __mixdogMemoryLog(`[cycle1] compactness retry LLM error (window=${windowIdx}): ${retryErr.message}\n`)
669
+ }
670
+ }
531
671
  } catch (err) {
532
- if (signal?.aborted) throw signal.reason ?? err
672
+ if (signal?.aborted) throw err.reason ?? err
533
673
  __mixdogMemoryLog(`[cycle1] LLM error (window=${windowIdx}): ${err.message}\n`)
534
674
  return {
535
675
  committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
@@ -541,26 +681,29 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
541
681
  prefilterMarkFailed,
542
682
  }
543
683
  }
544
- throwIfAborted(signal)
545
- __mixdogMemoryLog(`[cycle1-time] window=${windowIdx} llmMs=${Date.now() - _tLlm}\n`)
546
684
 
547
- const parsed = parseCycle1LineFormat(raw)
548
- const chunkList = Array.isArray(parsed?.chunks) ? parsed.chunks : null
549
- if (!chunkList) {
550
- __mixdogMemoryLog(`[cycle1] unparseable response (window=${windowIdx}) (${String(raw).slice(0, 200)})\n`)
551
- return {
552
- committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
553
- invalidChunks: [{ reason: 'unparseable_response', member_ids: rows.map(r => Number(r.id)) }],
554
- failedRowIds: rows.map(r => Number(r.id)),
555
- omittedRowIds: prefilteredRowIds,
556
- prefilteredRowIds,
557
- prefilterMarked,
558
- prefilterMarkFailed,
685
+ const entryByIdx = new Map(rows.map((r, i) => [i + 1, r]))
686
+ const entryById = new Map(rows.map(r => [Number(r.id), r]))
687
+
688
+ if (countCycle1CommittableChunks(chunkList, entryByIdx, entryById) === 0) {
689
+ const fallback = groupingAttempt === 2 ? firstChunkList : secondChunkList
690
+ if (fallback && countCycle1CommittableChunks(fallback, entryByIdx, entryById) > 0) {
691
+ chunkList = fallback
692
+ groupingAttempt = groupingAttempt === 2 ? 1 : 2
693
+ chosenQuality = computeCycle1GroupingQuality(chunkList, rows.length)
694
+ __mixdogMemoryLog(
695
+ `[cycle1] grouping_fallback window=${windowIdx} chosen_attempt=${groupingAttempt}\n`,
696
+ )
559
697
  }
560
698
  }
561
699
 
562
- const entryByIdx = new Map(rows.map((r, i) => [i + 1, r]))
563
- const entryById = new Map(rows.map(r => [Number(r.id), r]))
700
+ __mixdogMemoryLog(
701
+ `[cycle1] grouping_quality window=${windowIdx} prompt_entries=${rows.length}` +
702
+ ` chunks=${chosenQuality.chunkCount} singleton_ratio=${chosenQuality.singleton_ratio.toFixed(3)}` +
703
+ ` omitted_ratio=${chosenQuality.omitted_ratio.toFixed(3)}` +
704
+ ` retry=${groupingRetried ? 1 : 0} chosen_attempt=${groupingAttempt}\n`,
705
+ )
706
+
564
707
  const usedIds = new Set()
565
708
  const committedRowIds = new Set()
566
709
  let committedChunks = 0
@@ -2,7 +2,7 @@
2
2
  // pipe-verdict parsing/validation, the rules-digest cache, the single LLM
3
3
  // gate pass (runUnifiedGate) and the Sonnet re-judge cascade. Facade
4
4
  // (memory-cycle2.mjs) re-exports the public members unchanged.
5
- import { existsSync, readFileSync } from 'fs'
5
+ import { existsSync, readFileSync, readdirSync } from 'fs'
6
6
  import { join } from 'path'
7
7
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
8
8
  import { callAgentDispatch } from './agent-ipc.mjs'
@@ -48,8 +48,8 @@ function formatEntriesForPromotePrompt(rows, pidMap, opts = {}) {
48
48
  const map = pidMap ?? buildPidMap([rows])
49
49
  // When numbered, prefix each row with its 1-based prompt-order ordinal so the
50
50
  // gate LLM can echo a row number it can see, instead of inventing one. The
51
- // ordinal domain (1..N) and the 5-digit batch-id domain must stay disjoint
52
- // see the ordinalToId invariant in runUnifiedGate.
51
+ // ordinal domain (1..N) vs batch ids that share those integers on a *different*
52
+ // row see batchOrdinalIdCollides in runUnifiedGate.
53
53
  const numbered = opts.numbered === true
54
54
  const lines = rows.map((r, i) => {
55
55
  const tag = r.project_id ? (map.get(r.project_id) ?? 'C') : 'C'
@@ -78,6 +78,17 @@ function formatUserCoreForPrompt(rows, pidMap) {
78
78
  // Parse pipe-format unified verdicts. Each line: <id>|<verb> [|...].
79
79
  // Verbs validated against the row's current status via STATUS_ALLOWED_VERBS.
80
80
  // Returns { actions, rejected } or null when no parseable lines.
81
+ // True when some entry id K in 1..N is also the row-ordinal label for a different
82
+ // entry (token K is ambiguous between id K and "row K").
83
+ export function batchOrdinalIdCollides(statusById, ordinalToId, rowCount) {
84
+ const n = Math.max(0, Number(rowCount) || 0)
85
+ for (let k = 1; k <= n; k++) {
86
+ if (!statusById.has(k)) continue
87
+ if (ordinalToId.get(k) !== k) return k
88
+ }
89
+ return null
90
+ }
91
+
81
92
  function parseUnifiedFormat(raw, statusById, ordinalToId = null) {
82
93
  if (raw == null) return null
83
94
  const text = String(raw).trim()
@@ -88,10 +99,9 @@ function parseUnifiedFormat(raw, statusById, ordinalToId = null) {
88
99
  const support = new Map()
89
100
  let sawValid = false
90
101
  // Resolve a first-field/merge token to a real batch id. The gate may echo
91
- // either the exact 5-digit batch id OR the 1-based row ordinal shown in the
92
- // numbered Entries block. The two domains are disjoint (asserted in
93
- // runUnifiedGate), so an exact-id hit always wins and an unmatched value
94
- // falls back to ordinal lookup; anything else is NaN (line treated invalid).
102
+ // either the exact batch id OR the 1-based row ordinal shown in the numbered
103
+ // Entries block. When batchOrdinalIdCollides is false, an exact-id hit wins
104
+ // and an unmatched value falls back to ordinal lookup; anything else is NaN.
95
105
  const resolveId = (tok) => {
96
106
  const n = Number(String(tok ?? '').trim())
97
107
  if (!Number.isFinite(n)) return NaN
@@ -194,13 +204,20 @@ let _currentRulesDigestTs = 0
194
204
  export function loadCurrentRulesDigest() {
195
205
  const now = Date.now()
196
206
  if (_currentRulesDigest && now - _currentRulesDigestTs < 60_000) return _currentRulesDigest
197
- const sources = [
198
- join(resourceDir(), 'rules', 'shared', '01-general.md'),
199
- join(resourceDir(), 'rules', 'shared', '01-tool.md'),
200
- join(resourceDir(), 'rules', 'shared', '04-memory.md'),
201
- join(resourceDir(), 'rules', 'shared', '06-team.md'),
202
- join(resourceDir(), 'rules', 'shared', '07-workflow.md'),
203
- ]
207
+ // Collect every rule file that loads into live sessions (lead + shared).
208
+ // Discovered dynamically so rule-layout refactors can't silently empty the
209
+ // digest again (the old hardcoded shared/* list rotted to one file and the
210
+ // dedup gate compared against nothing).
211
+ const sources = []
212
+ for (const dir of ['lead', 'shared']) {
213
+ const base = join(resourceDir(), 'rules', dir)
214
+ try {
215
+ if (!existsSync(base)) continue
216
+ for (const f of readdirSync(base).sort()) {
217
+ if (f.endsWith('.md')) sources.push(join(base, f))
218
+ }
219
+ } catch {}
220
+ }
204
221
  const parts = []
205
222
  for (const p of sources) {
206
223
  try {
@@ -333,18 +350,13 @@ export async function runUnifiedGate(db, rows, activeContext, config = {}, optio
333
350
 
334
351
  const statusById = new Map(rows.map(r => [Number(r.id), String(r.status)]))
335
352
  // Ordinal → batch-id map, keyed by 1-based prompt order (the same order the
336
- // numbered Entries block uses). The gate may echo either the real 5-digit
337
- // batch id or the row ordinal 1..N; the parser resolves both. The two domains
338
- // MUST be disjoint or an ordinal could shadow a real id (ids are 5-digit,
339
- // ordinals are <= rows.length, so disjointness always holds in practice).
340
- // On a violation a row-number line is indistinguishable from an exact-id
341
- // line, so no safe interpretation exists — skip this batch (gate failure)
342
- // rather than risk applying a verdict to the wrong entry. The cycle itself
343
- // proceeds; the batch re-queues for a later run.
353
+ // numbered Entries block uses). The gate may echo either the real batch id
354
+ // or the row ordinal 1..N; the parser resolves both when no integer token is
355
+ // ambiguous (id K present in batch but row K points at a different entry).
344
356
  const ordinalToId = new Map(rows.map((r, i) => [i + 1, Number(r.id)]))
345
- const minBatchId = Math.min(...[...statusById.keys()])
346
- if (Number.isFinite(minBatchId) && minBatchId <= rows.length) {
347
- __mixdogMemoryLog(`[cycle2] batch id ${minBatchId} collides with ordinal range 1..${rows.length} — skipping batch (no safe id resolution)\n`)
357
+ const collideToken = batchOrdinalIdCollides(statusById, ordinalToId, rows.length)
358
+ if (collideToken != null) {
359
+ __mixdogMemoryLog(`[cycle2] batch id ${collideToken} collides with row ordinal ${collideToken} (id ${ordinalToId.get(collideToken)}) — skipping batch (no safe id resolution)\n`)
348
360
  return { actions: null, rejected: new Set(), parseOk: false }
349
361
  }
350
362
 
@@ -9,6 +9,42 @@ const TIER1_THRESHOLD = 0.78
9
9
  const TIER2_LOW = 0.65
10
10
  const LLM_JUDGE_CAP = 20
11
11
 
12
+ const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
13
+
14
+ // After the gate, cap how many pending→active promotions may land in one batch.
15
+ // Overflow stays pending (not archived). Tiebreak: durable category before
16
+ // task/issue, then score DESC, then older last_seen_at, then id ASC.
17
+ export function clampPendingPromotions(statusBatch, rowsById, activeCount, activeTargetCap) {
18
+ if (!statusBatch?.length) return { batch: statusBatch ?? [], clamped: 0 }
19
+ const archives = []
20
+ const promotions = []
21
+ for (const item of statusBatch) {
22
+ if (item.was_pending && item.new_status === 'active') promotions.push(item)
23
+ else archives.push(item)
24
+ }
25
+ const slots = Math.max(0, Number(activeTargetCap) - Number(activeCount))
26
+ if (promotions.length <= slots) return { batch: statusBatch, clamped: 0 }
27
+
28
+ promotions.sort((a, b) => {
29
+ const ra = rowsById.get(Number(a.entry_id))
30
+ const rb = rowsById.get(Number(b.entry_id))
31
+ const ta = TRANSIENT_PROMOTE_CATEGORIES.has(String(ra?.category ?? '').toLowerCase()) ? 1 : 0
32
+ const tb = TRANSIENT_PROMOTE_CATEGORIES.has(String(rb?.category ?? '').toLowerCase()) ? 1 : 0
33
+ if (ta !== tb) return ta - tb
34
+ const sa = Number(ra?.score ?? 0)
35
+ const sb = Number(rb?.score ?? 0)
36
+ if (sb !== sa) return sb - sa
37
+ const la = Number(ra?.last_seen_at ?? 0)
38
+ const lb = Number(rb?.last_seen_at ?? 0)
39
+ if (la !== lb) return la - lb
40
+ return Number(a.entry_id) - Number(b.entry_id)
41
+ })
42
+
43
+ const allowed = promotions.slice(0, slots)
44
+ const clamped = promotions.length - allowed.length
45
+ return { batch: [...archives, ...allowed], clamped }
46
+ }
47
+
12
48
  // Batch CTE UPDATE for status-only verdicts (active/archived from pending or active rows).
13
49
  // Trigger handles score recompute automatically — no app-side score writes.
14
50
  export async function applyBatchStatusVerdicts(db, batch, nowMs) {
@@ -268,6 +304,7 @@ export async function runPhaseMerge(db, options = {}) {
268
304
  1 - (e.embedding <=> inner_c.embedding)::float8 AS sim
269
305
  FROM core_entries inner_c
270
306
  WHERE inner_c.embedding IS NOT NULL
307
+ AND (inner_c.status IS NULL OR inner_c.status = 'active')
271
308
  AND (inner_c.project_id IS NULL OR inner_c.project_id IS NOT DISTINCT FROM e.project_id)
272
309
  ORDER BY
273
310
  CASE WHEN inner_c.project_id IS NOT DISTINCT FROM e.project_id THEN 0 ELSE 1 END,
@@ -10,7 +10,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
10
10
  import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
11
11
  import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
12
12
  import {
13
- applyBatchStatusVerdicts, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
13
+ applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
14
14
  } from './memory-cycle2-mutations.mjs'
15
15
  import {
16
16
  CYCLE2_ACTIVE_TARGET_CAP, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
@@ -82,6 +82,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
82
82
  promoted: 0, archived: 0, merged: 0, updated: 0, kept: 0, rejected_verb: 0,
83
83
  merge_rejected: 0,
84
84
  missing_core_summary: 0,
85
+ promotion_clamped: 0,
85
86
  core_embedding_backfill: 0,
86
87
  rescore: { updated: 0 },
87
88
  phase_merge: { merged: 0, llm_calls: 0, tier1_pairs: 0, tier2_pairs: 0, core_overlap: 0 },
@@ -255,7 +256,10 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
255
256
  WHERE is_root = 1
256
257
  AND (status = 'pending' OR ($2::boolean AND status = 'active'))
257
258
  ORDER BY
258
- CASE status WHEN 'pending' THEN 0 WHEN 'active' THEN 1 END ASC,
259
+ CASE WHEN $2::boolean THEN CASE status WHEN 'active' THEN 0 WHEN 'pending' THEN 1 END
260
+ ELSE CASE status WHEN 'pending' THEN 0 WHEN 'active' THEN 1 END
261
+ END ASC,
262
+ CASE WHEN NOT $2::boolean AND LOWER(category) IN ('task', 'issue') THEN 1 ELSE 0 END ASC,
259
263
  reviewed_at ASC NULLS FIRST,
260
264
  error_count ASC,
261
265
  score ${scoreDir},
@@ -472,7 +476,14 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
472
476
  // Status verdicts are applied as one SQL batch; checkpoint before the
473
477
  // batch and then again at the next cycle2 unit boundary.
474
478
  throwIfAborted(signal)
475
- const batchRes = await applyBatchStatusVerdicts(db, statusBatch, nowMs)
479
+ let activeDemotionsInBatch = 0
480
+ for (const item of statusBatch) {
481
+ if (item.new_status === 'archived' && !item.was_pending) activeDemotionsInBatch += 1
482
+ }
483
+ const activeCountForClamp = Math.max(0, activeCount - activeDemotionsInBatch)
484
+ const clampRes = clampPendingPromotions(statusBatch, rowsById, activeCountForClamp, activeTargetCap)
485
+ stats.promotion_clamped = clampRes.clamped
486
+ const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
476
487
  stats.promoted += batchRes.promoted
477
488
  stats.archived += batchRes.archived
478
489
  }
@@ -567,10 +578,8 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
567
578
  }
568
579
  }
569
580
 
570
- // Active-cap enforcement is delegated to the gate (phases 1-3): the prompt
571
- // exposes Active/cap counts and instructs aggressive `archived` verdicts on
572
- // overflow. No deterministic safety net here — if the gate ever fails to
573
- // contain growth, fix the prompt, not bolt a fallback back on.
581
+ // Post-gate promotion clamp (clampPendingPromotions) is the deterministic
582
+ // active-cap safety net; the gate still contracts when Active > cap.
574
583
 
575
584
  // Chronic gate-failure sweep: pending roots that have failed the gate 5+
576
585
  // times AND are 30+ days old will realistically never pass (their content
@@ -604,6 +613,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
604
613
  ` core_backfill=${stats.core_embedding_backfill}` +
605
614
  ` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
606
615
  ` | gate promoted=${stats.promoted} archived=${stats.archived}` +
616
+ ` promotion_clamped=${stats.promotion_clamped}` +
607
617
  ` updated=${stats.updated} kept=${stats.kept}` +
608
618
  ` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
609
619
  ` missing_core=${stats.missing_core_summary}` +