mixdog 0.9.24 → 0.9.25

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.
@@ -1,106 +1,106 @@
1
- # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
- # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
- # boot, double-init refusal, hostile-env survival.
4
- #
5
- # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
-
7
- $ErrorActionPreference = 'Stop'
8
-
9
- $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
- $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
- $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
- $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
- $PgVer = '16.4'
14
- $PgvectorVer = '0.8.2'
15
-
16
- $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
- $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
-
19
- $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
-
21
- try {
22
- Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
-
24
- Write-Host "==> Test 1: HEAD reaches release asset"
25
- $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
- Write-Host " HTTP $($resp.StatusCode)"
27
-
28
- Write-Host "==> Test 2: full download + sha256"
29
- $TarPath = Join-Path $Work $Asset
30
- Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
- $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
- Write-Host " sha256=$sha"
33
-
34
- Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
- $CorruptPath = "$Work\corrupt.tar.gz"
36
- Copy-Item $TarPath $CorruptPath
37
- $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
- $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
- $stream.Position = 102400
40
- $stream.Write($bytes, 0, 1)
41
- $stream.Close()
42
- $CorruptDir = "$Work\corrupt"
43
- New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
- $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
- if (-not $TarProc.WaitForExit(15000)) {
46
- Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
- Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
- } elseif ($TarProc.ExitCode -eq 0) {
49
- Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
- } else {
51
- Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
- }
53
-
54
- Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
- $FreshDir = "$Work\fresh"
56
- New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
- & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
- if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
-
60
- $PgBin = "$FreshDir\bin"
61
- $Data = "$FreshDir\pgdata"
62
- $Log = "$FreshDir\pg.log"
63
- $Port = 55897
64
-
65
- # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
- $SavedPath = $env:PATH
67
- $SavedPgRoot = $env:PGROOT
68
- $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
- $env:PGROOT = $null
70
- $env:PGDATA = $null
71
-
72
- try {
73
- & "$PgBin\postgres.exe" --version
74
- if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
- & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
- if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
- & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
- if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
-
80
- try {
81
- & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
- if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
- $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
- if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
- Write-Host " PASS: fresh-extract boot + vector extension"
86
- } finally {
87
- & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
- }
89
-
90
- Write-Host "==> Test 5: second initdb on initialized dir refused"
91
- $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
- if ($rc -eq 0) {
93
- Write-Host " WARN: second initdb succeeded (unexpected)"
94
- } else {
95
- Write-Host " PASS: second initdb refused (exit $rc)"
96
- }
97
- } finally {
98
- $env:PATH = $SavedPath
99
- $env:PGROOT = $SavedPgRoot
100
- }
101
-
102
- Write-Host "==> All negative-path smokes: PASS"
103
- }
104
- finally {
105
- Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
- }
1
+ # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
+ # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
+ # boot, double-init refusal, hostile-env survival.
4
+ #
5
+ # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
+
7
+ $ErrorActionPreference = 'Stop'
8
+
9
+ $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
+ $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
+ $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
+ $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
+ $PgVer = '16.4'
14
+ $PgvectorVer = '0.8.2'
15
+
16
+ $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
+ $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
+
19
+ $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
+
21
+ try {
22
+ Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
+
24
+ Write-Host "==> Test 1: HEAD reaches release asset"
25
+ $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
+ Write-Host " HTTP $($resp.StatusCode)"
27
+
28
+ Write-Host "==> Test 2: full download + sha256"
29
+ $TarPath = Join-Path $Work $Asset
30
+ Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
+ $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
+ Write-Host " sha256=$sha"
33
+
34
+ Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
+ $CorruptPath = "$Work\corrupt.tar.gz"
36
+ Copy-Item $TarPath $CorruptPath
37
+ $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
+ $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
+ $stream.Position = 102400
40
+ $stream.Write($bytes, 0, 1)
41
+ $stream.Close()
42
+ $CorruptDir = "$Work\corrupt"
43
+ New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
+ $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
+ if (-not $TarProc.WaitForExit(15000)) {
46
+ Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
+ Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
+ } elseif ($TarProc.ExitCode -eq 0) {
49
+ Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
+ } else {
51
+ Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
+ }
53
+
54
+ Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
+ $FreshDir = "$Work\fresh"
56
+ New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
+ & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
+ if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
+
60
+ $PgBin = "$FreshDir\bin"
61
+ $Data = "$FreshDir\pgdata"
62
+ $Log = "$FreshDir\pg.log"
63
+ $Port = 55897
64
+
65
+ # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
+ $SavedPath = $env:PATH
67
+ $SavedPgRoot = $env:PGROOT
68
+ $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
+ $env:PGROOT = $null
70
+ $env:PGDATA = $null
71
+
72
+ try {
73
+ & "$PgBin\postgres.exe" --version
74
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
+ & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
+ & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
+ if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
+
80
+ try {
81
+ & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
+ $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
+ if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
+ Write-Host " PASS: fresh-extract boot + vector extension"
86
+ } finally {
87
+ & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
+ }
89
+
90
+ Write-Host "==> Test 5: second initdb on initialized dir refused"
91
+ $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
+ if ($rc -eq 0) {
93
+ Write-Host " WARN: second initdb succeeded (unexpected)"
94
+ } else {
95
+ Write-Host " PASS: second initdb refused (exit $rc)"
96
+ }
97
+ } finally {
98
+ $env:PATH = $SavedPath
99
+ $env:PGROOT = $SavedPgRoot
100
+ }
101
+
102
+ Write-Host "==> All negative-path smokes: PASS"
103
+ }
104
+ finally {
105
+ Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
+ }
@@ -88,4 +88,4 @@ for (const target of ['heavy-worker', 'worker']) {
88
88
  console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
89
89
  console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
90
90
  console.log(` same-path re-reads >=3x: ${rereads}`);
91
- }
91
+ }
@@ -3,8 +3,10 @@
3
3
  You review user-curated CORE memory against the current project memory. Each
4
4
  entry is shown with its most-related current memory. Emit ONE verdict line per
5
5
  entry id. The system may conservatively apply safe compression updates and
6
- strict duplicate merges automatically; deletes and broad rewrites require
7
- explicit user confirmation. Extracting a lesson from a narrative is a BROAD
6
+ strict duplicate merges automatically, and now also auto-applies deletes you
7
+ tag as clear junk (see the delete reasons below), capped per run for safety;
8
+ non-junk or unreasoned deletes and broad rewrites still require explicit user
9
+ confirmation. Extracting a lesson from a narrative is a BROAD
8
10
  rewrite — emit it only as a proposal (proposal mode), never as an auto-applied
9
11
  conservative "safe compression" update; conservative mode must not lossy-rewrite
10
12
  user-curated core. The first character of your response is a digit.
@@ -64,7 +66,12 @@ or inconclusive, keep the CORE entry.
64
66
  so a CORE copy is redundant); OR is sourced from code, rules files, or skill
65
67
  docs, or is an implementation spec, constant, measurement, resolved-bug
66
68
  story, or status snapshot that fits no L1/L2/L3 layer. Do not delete an entry
67
- that adds durable specifics the rule itself does not state.
69
+ that adds durable specifics the rule itself does not state. ALWAYS append a
70
+ reason tag: `<id>|delete|<reason>` where `<reason>` is one of `duplicate`,
71
+ `default` (restates a built-in/default rule), `restatement`, `obsolete`,
72
+ `implemented`, `resolved`, `stale`, `past_event`, or `log`. A bare
73
+ `<id>|delete` with no reason, or any reason outside that set, is treated as
74
+ "needs confirmation" and only removed on an explicit APPLY CYCLE3 run.
68
75
 
69
76
  A verbose durable entry is always `update`, never `keep`.
70
77
  Delete is the rarest verdict. Prefer `keep` for durable rules/preferences and
@@ -97,7 +104,7 @@ One line per entry id, any order:
97
104
  <id>|update|<element>|<summary>
98
105
  <id>|merge|<target_id>|<source_ids_csv>
99
106
  <id>|superseded|<newer_id>
100
- <id>|delete
107
+ <id>|delete|<reason>
101
108
  ```
102
109
 
103
110
  `summary` ≤120 chars, one clause. No literal `|` or newline inside a field
@@ -112,6 +112,15 @@ A/B pending rows that encode lasting behavior or map anchors; transient
112
112
  When `Active > cap`, contract strictly: any active entry without a concrete
113
113
  A/B reason must archive.
114
114
 
115
+ **Structural block (enforced, not advisory).** Pending `task`/`issue` rows and
116
+ any row that reads as status/review churn or a benchmark/measurement result
117
+ snapshot are blocked from promotion by the pipeline itself — an `active` verdict
118
+ on them is dropped and the row is **held pending and re-judged on a later
119
+ cycle** (it is neither promoted nor force-archived here). Do NOT archive such a
120
+ row just because it cannot promote now: leave it to be re-confirmed, or promote
121
+ the durable L2 lesson distilled from it under a durable category. Durable
122
+ rules/preferences/constraints promote normally.
123
+
115
124
  If useful content is buried inside work narrative, keep only the durable L2
116
125
  behavior lesson (via `update` on an active row, or `active` for a pending row);
117
126
  archive the surrounding story.
@@ -1,3 +1,3 @@
1
- # Channels
2
-
3
- - Channel features are handled by the runtime.
1
+ # Channels
2
+
3
+ - Channel features are handled by the runtime.
@@ -435,14 +435,21 @@ class DiscordBackend {
435
435
  const msg = await ch.messages.fetch(messageId);
436
436
  if (msg.attachments.size === 0) return [];
437
437
  const results = [];
438
+ // Optional filter bounds what gets fetched (e.g. image/* only) so a
439
+ // caller after images never pulls unrelated large attachments.
440
+ const filter = typeof opts.filter === "function" ? opts.filter : null;
438
441
  for (const att of msg.attachments.values()) {
439
- const path = await this.downloadSingleAttachment(att, opts);
440
- results.push({
442
+ const meta = {
441
443
  id: att.id,
442
- path,
443
444
  name: safeAttName(att),
444
445
  contentType: att.contentType ?? "unknown",
445
446
  size: att.size
447
+ };
448
+ if (filter && !filter(meta)) continue;
449
+ const path = await this.downloadSingleAttachment(att, opts);
450
+ results.push({
451
+ ...meta,
452
+ path
446
453
  });
447
454
  }
448
455
  return results;
@@ -10,6 +10,9 @@ import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
10
10
  // preserving): serial inbound queue, backend.onMessage transcript (re)bind +
11
11
  // steal logic, and handleInbound voice-transcription + parent notify. Bound to
12
12
  // live runtime getters and shared primitives.
13
+ function isImageAttachment(contentType) {
14
+ return typeof contentType === "string" && contentType.toLowerCase().startsWith("image/");
15
+ }
13
16
  export function createInboundHandler({
14
17
  getBackend,
15
18
  getConfig,
@@ -283,6 +286,46 @@ async function handleInbound(msg, route, options = {}) {
283
286
  }
284
287
  }
285
288
  const hasVoiceAtt = voiceAtts.length > 0;
289
+ // ── Inbound image attachments → downloaded local paths (vision) ──────
290
+ // Mirror the voice path: download image/* attachments to the inbox and
291
+ // hand their local paths downstream so the agent session can attach them
292
+ // as real image content blocks. A short per-attempt timeout bounds the
293
+ // serial inboundQueue against a slow/broken image; on failure we degrade
294
+ // to the metadata-only marker (attMeta below) instead of dropping.
295
+ const imageAtts = msg.attachments.filter((a) => isImageAttachment(a.contentType));
296
+ let imagePaths = [];
297
+ if (imageAtts.length > 0) {
298
+ try {
299
+ const files = (await retryOnNetwork(
300
+ () => getBackend().downloadAttachment(msg.chatId, msg.messageId, {
301
+ timeoutMs: 20_000,
302
+ filter: (a) => isImageAttachment(a.contentType),
303
+ }),
304
+ { label: "image.download" }
305
+ )) || [];
306
+ imagePaths = imageAtts
307
+ .map((a) => files.find((df) => df.id === a.id) ?? null)
308
+ .filter(Boolean)
309
+ .map((f) => f.path)
310
+ .filter((p) => typeof p === "string" && p.length > 0);
311
+ if (imagePaths.length > 0) {
312
+ process.stderr.write(`mixdog: inbound images downloaded (${imagePaths.length})\n`);
313
+ }
314
+ } catch (err) {
315
+ const netFail = isNetworkError(err);
316
+ process.stderr.write(`mixdog: image.download error${netFail ? " (network, retries exhausted)" : ""}: ${err}\n`);
317
+ const marker = netFail
318
+ ? "[attachment: image download failed (network)]"
319
+ : "[attachment: image download failed]";
320
+ text = text ? `${text} ${marker}` : marker;
321
+ }
322
+ }
323
+ // An image-only message can arrive with empty text; channelNotificationModelContent
324
+ // drops empty content (runtime-core), which would strip meta.image_paths and lose
325
+ // the image. Give it a non-empty marker so the notification (and its images) flow.
326
+ if (imagePaths.length > 0 && !String(text || "").trim()) {
327
+ text = "[image]";
328
+ }
286
329
  const attMeta = msg.attachments.length > 0 && !hasVoiceAtt ? {
287
330
  attachment_count: String(msg.attachments.length),
288
331
  attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
@@ -300,7 +343,8 @@ async function handleInbound(msg, route, options = {}) {
300
343
  ...route.sourceLabel ? { source_label: route.sourceLabel } : {}
301
344
  } : {},
302
345
  ...attMeta,
303
- ...msg.imagePath ? { image_path: msg.imagePath } : {}
346
+ ...msg.imagePath ? { image_path: msg.imagePath } : {},
347
+ ...imagePaths.length > 0 ? { image_paths: JSON.stringify(imagePaths) } : {}
304
348
  };
305
349
  const notificationContent = messageBody;
306
350
  sendNotifyToParent("notifications/claude/channel", {
@@ -333,7 +333,13 @@ export function createCycleScheduler(deps) {
333
333
  try {
334
334
  let c3Options = {
335
335
  coalescedRetry: true,
336
- onCoalescedSuccess: async () => { await setCycleLastRun('cycle3', Date.now()); markCycleDone('cycle3', true) },
336
+ onCoalescedSuccess: async (result) => {
337
+ // Only a real, error-free pass persists success; a run that returned
338
+ // an error (LLM/unparseable) must not stamp last_success_at.
339
+ if (result?.error) { markCycleDone('cycle3', false, result.error); return }
340
+ await setCycleLastRun('cycle3', Date.now())
341
+ markCycleDone('cycle3', true)
342
+ },
337
343
  }
338
344
  if (typeof c3Options?.callLlm !== 'function') {
339
345
  c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
@@ -498,6 +504,16 @@ export function createCycleScheduler(deps) {
498
504
  // so clear the marker (health/backlog reset to this process's state).
499
505
  _cycleRunning = null
500
506
  _writeCycleStateFile()
507
+ // Hydrate health success timestamps from the persisted per-cycle last-run
508
+ // meta. Without this, a restart re-inits _cycleHealth to last_success_at=0
509
+ // and the state file reports 0 until the next run — for cycle3 that is up
510
+ // to 24h later, so a genuinely-successful cycle3 looks like it never ran.
511
+ Promise.resolve(getCycleLastRun()).then((last) => {
512
+ if (last?.cycle1 > 0 && !_cycleHealth.cycle1.last_success_at) _cycleHealth.cycle1.last_success_at = last.cycle1
513
+ if (last?.cycle2 > 0 && !_cycleHealth.cycle2.last_success_at) _cycleHealth.cycle2.last_success_at = last.cycle2
514
+ if (last?.cycle3 > 0 && !_cycleHealth.cycle3.last_success_at) _cycleHealth.cycle3.last_success_at = last.cycle3
515
+ _writeCycleStateFile()
516
+ }).catch(() => {})
501
517
  _scheduleNextCheck()
502
518
  _startupTimeout = setTimeout(() => { void _runCheckCyclesGuarded() }, 30_000)
503
519
  }
@@ -11,6 +11,65 @@ const LLM_JUDGE_CAP = 20
11
11
 
12
12
  const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
13
13
 
14
+ // Category grades the pipeline trusts outright: user/cycle1 tagged durable
15
+ // knowledge. These are NEVER content-scanned — a curated constraint like
16
+ // "95% pass threshold" or a rule that cites a metric must still promote.
17
+ const DURABLE_TRUSTED_CATEGORIES = new Set(['rule', 'constraint', 'decision', 'preference', 'goal'])
18
+
19
+ // Deterministic (non-LLM) signature for transient work-state: status/review
20
+ // churn and benchmark/measurement RESULT snapshots. Narrow on purpose — it
21
+ // requires snapshot phrasing (a measurement verb or an explicit result-metric
22
+ // noun), NOT a bare percentage, so SLO/threshold constraints don't trip it.
23
+ // Applied only to non-durable categories (e.g. 'fact'), catching benchmark
24
+ // snapshots mis-tagged as durable-ish. The gate prompt only *discourages*
25
+ // these; this is the structural enforcement.
26
+ const TRANSIENT_SNAPSHOT_RE = new RegExp(
27
+ [
28
+ 'terminal-bench',
29
+ '\\bbenchmark(ed|ing|\\s+(run|result|score))\\b',
30
+ 'pass@\\d',
31
+ '\\btokens?\\s?\\/\\s?s(ec)?\\b',
32
+ 'status\\s+snapshot',
33
+ 'review(er)?\\s+(validation\\s+)?cycles?',
34
+ 'in[-\\s]progress',
35
+ '\\bWIP\\b',
36
+ '\\b(scored|achieved|measured|reached)\\b[^.]{0,40}\\d+(\\.\\d+)?\\s?%',
37
+ '\\d+(\\.\\d+)?\\s?%\\s+(pass\\s+rate|accuracy|score|throughput|latency)',
38
+ ].join('|'),
39
+ 'i',
40
+ )
41
+
42
+ // Structural promotion gate: is this row transient content (task/issue chatter
43
+ // or a status/benchmark snapshot) that must not be promoted pending→active?
44
+ // task/issue block by category. Durable knowledge categories are trusted and
45
+ // skip the content scan. Everything else (e.g. 'fact') is content-scanned so
46
+ // snapshots mis-categorized as durable are still blocked.
47
+ export function isTransientPromotion(row) {
48
+ if (!row) return false
49
+ const cat = String(row.category ?? '').toLowerCase()
50
+ if (TRANSIENT_PROMOTE_CATEGORIES.has(cat)) return true
51
+ if (DURABLE_TRUSTED_CATEGORIES.has(cat)) return false
52
+ return TRANSIENT_SNAPSHOT_RE.test(`${row.element ?? ''} ${row.summary ?? ''}`)
53
+ }
54
+
55
+ // Strip pending→active promotions for transient content out of the status
56
+ // batch BEFORE the cap clamp. Blocked rows are simply dropped from the batch,
57
+ // so they stay pending (held for re-confirmation on a later cycle) — never
58
+ // archived here, and reviewed_at is still bumped via the caller's reviewedIds.
59
+ export function blockTransientPromotions(statusBatch, rowsById) {
60
+ if (!statusBatch?.length) return { batch: statusBatch ?? [], blocked: 0 }
61
+ let blocked = 0
62
+ const batch = statusBatch.filter((item) => {
63
+ if (item.was_pending && item.new_status === 'active'
64
+ && isTransientPromotion(rowsById.get(Number(item.entry_id)))) {
65
+ blocked += 1
66
+ return false
67
+ }
68
+ return true
69
+ })
70
+ return { batch, blocked }
71
+ }
72
+
14
73
  // After the gate, cap how many pending→active promotions may land in one batch.
15
74
  // Overflow stays pending (not archived). Tiebreak: durable category before
16
75
  // task/issue, then score DESC, then older last_seen_at, then id ASC.
@@ -11,7 +11,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
11
11
  import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
12
12
  import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
13
13
  import {
14
- applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
14
+ applyBatchStatusVerdicts, clampPendingPromotions, blockTransientPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
15
15
  } from './memory-cycle2-mutations.mjs'
16
16
  import {
17
17
  CYCLE2_ACTIVE_TARGET_CAP, CYCLE2_ACTIVE_MIN_FLOOR, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
@@ -54,6 +54,7 @@ function mergeCycle2Results(a, b) {
54
54
  rejected_verb: Number(a.rejected_verb || 0) + Number(b.rejected_verb || 0),
55
55
  merge_rejected: Number(a.merge_rejected || 0) + Number(b.merge_rejected || 0),
56
56
  missing_core_summary: Number(a.missing_core_summary || 0) + Number(b.missing_core_summary || 0),
57
+ promotion_blocked: Number(a.promotion_blocked || 0) + Number(b.promotion_blocked || 0),
57
58
  core_embedding_backfill: Number(a.core_embedding_backfill || 0) + Number(b.core_embedding_backfill || 0),
58
59
  core_candidates_nominated: Number(a.core_candidates_nominated || 0) + Number(b.core_candidates_nominated || 0),
59
60
  rescore: mergeNestedNumeric(a.rescore, b.rescore),
@@ -211,6 +212,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
211
212
  updated: 0, kept: 0, rejected_verb: 0,
212
213
  merge_rejected: 0,
213
214
  missing_core_summary: 0,
215
+ promotion_blocked: 0,
214
216
  core_embedding_backfill: 0,
215
217
  core_candidates_nominated: 0,
216
218
  rescore: { updated: 0 },
@@ -523,8 +525,13 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
523
525
  }
524
526
  guardedBatch.push(item)
525
527
  }
528
+ // Structural transient block runs BEFORE the cap clamp: task/issue chatter
529
+ // and status/benchmark snapshots can never promote pending→active, so they
530
+ // are stripped here (held pending) regardless of remaining cap slots.
531
+ const blockRes = blockTransientPromotions(guardedBatch, rowsById)
532
+ stats.promotion_blocked = blockRes.blocked
526
533
  const activeCountForClamp = Math.max(0, activeCount - reservedDemotions)
527
- const clampRes = clampPendingPromotions(guardedBatch, rowsById, activeCountForClamp, activeTargetCap)
534
+ const clampRes = clampPendingPromotions(blockRes.batch, rowsById, activeCountForClamp, activeTargetCap)
528
535
  stats.promotion_clamped = clampRes.clamped
529
536
  const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
530
537
  stats.promoted += batchRes.promoted
@@ -702,7 +709,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
702
709
  ` core_backfill=${stats.core_embedding_backfill}` +
703
710
  ` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
704
711
  ` | gate promoted=${stats.promoted} archived=${stats.archived}` +
705
- ` promotion_clamped=${stats.promotion_clamped}` +
712
+ ` promotion_clamped=${stats.promotion_clamped} promotion_blocked=${stats.promotion_blocked}` +
706
713
  ` updated=${stats.updated} kept=${stats.kept}` +
707
714
  ` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
708
715
  ` missing_core=${stats.missing_core_summary}` +