peaks-loop 4.0.45 → 4.0.46

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.46 — 2026-09-12 (85% 该压不压 + 把"派发"记成了"正在压缩")
4
+
5
+ **Highlights**:
6
+
7
+ 1. **同一个 0.85–0.95 带,四份权威说法互相打架,而代码挑了那个"什么都不做"的。** `peaks code context-now` 对**单 rid** 会话把该带降级成 `soft-warn`("仅建议");而**它自己的 help 文本**写着 *"≥0.85 emits action=auto-compact-now (**MANDATORY, not advisory**)"*;`peaks skill presence` 返回 `pre-compact`;`peaks-code` 的 SKILL.md 说该带 **auto-compact fires automatically** 并把 `context-now` 称作 **single source of truth**;你 2026-07-27 亲自校准的阈值策略写的是 **soft-mandatory**。
8
+
9
+ 后果直接:一个**照着"唯一真相源"办事**的 LLM,在 0.85–0.95 里**永远不会压缩** —— 直接违反 zero-pause 契约。现在单 rid 与 job 模式一致:该带一律 `auto-compact-now`,并把过时的 notice/help 改成如实描述代码行为。
10
+
11
+ 2. **`compact-lifecycle.json` 把"已派发"记成了"正在压缩",于是状态栏永久卡在 `stalled`。** claude-code 的 `ide-native` pathway **只在 ratio ≥ 0.95 才真的 in-band 压缩**(且 dispatcher 无论装没装上 hook 都返回 `ok: true`);于是 0.85–0.95 区间里**什么都没有在飞**,记录却在 2 分钟后被判 stale,状态栏画一条永不前进的进度条并报 `stalled`。
12
+
13
+ **比显示错更严重的**:结算逻辑 `settleOpenLifecycleRun` 只在 **ratio 跌破阈值**时结算 —— 而那条 pathway 根本不会造成下跌,**所以 `afterRatio` 永远写不上,"压缩后 ratio 确实降了"这条验证链是死的**,而它正是 zero-pause 契约唯一的验收依据。
14
+
15
+ 两半一起修,方式是**让记录说实话**:新增**静止态 `armed`**(含义是"触发器已设好,等 95% 的 in-band 时机"),并被**类型系统**排除在 `ACTIVE_STAGES` 之外 —— 只有承诺过心跳的 stage(`queued`/`preparing`/`compacting`/`verifying`)才可能 stall。状态栏据此渲染成 `◔ armed · 88% · fires at 95%`,**不画进度条**。而**真的卡住的 `compacting` 仍然正确报 `stalled`** —— 这条能力是实测确认保住的,不是假设。
16
+
17
+ **验证**:三个版本常量一致(**4.0.46**);`tsc -p tsconfig.build.json` exit 0;宽 `tsconfig.json` 保持 **142** 基线;`tests/unit` **215 files / 2173 passed / 3 skipped / 0 failed**;`pnpm build` 的 `build-integrity` OK。
18
+
19
+ **本轮顺带发现、尚未处理(不属本版修复)**:`tests/integration/**`(90 个文件)**从未进过 CI** —— `ci.yml` 的测试步骤是默认 config,其 `include` 只含 `tests/unit/**`;CI 里另有一个 job 只为单个文件特判。实测该套件 **10 files / 20 tests / 3 errors 红**。也就是说"CI 绿"与"全量绿"都**不覆盖这 90 个文件**。同一形状(一个看似覆盖全局的绿信号,实际有射程外的东西)已由 `a-diff-scoped-gate-reporting-zero-violations-may-have-checked-nothing` 记录在案。建议下一片专门处理,含那 20 个失败的分诊。
20
+
3
21
  ## 4.0.45 — 2026-09-12 (一个永远无法通过的维度 + 一个验代理的守卫)
4
22
 
5
23
  **Highlights**:
package/README-en.md CHANGED
@@ -140,7 +140,7 @@ Every lane opens with **one slash command**.
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.45 (2026-09-12) |
143
+ | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.46 (2026-09-12) |
144
144
  | **Domains** | Code (`peaks-code`) · Content (`peaks-content`) · Project health (`peaks-doctor`) · Issue sweep (`peaks-issue-fix-orchestrator`) · Custom SOP (`peaks-sop`) · Cross-domain primitives (`peaks-solo` dispatcher · `peaks-resume` · `peaks-status` · `peaks-test` · `peaks-slice-decompose`) |
145
145
  | **Sediment pool** | `~/.peaks/` local pool · twice-clean runs auto-promote to a bee · broken runs come back for you to redefine · the bee grows with your taste |
146
146
  | **Test suite** | 285+ cases · 4 packages (peaks-loop / peaks-loop-mut / peaks-loop-shared-channel / peaks-loop-shared) · **0 timeouts** · 14 BDD caller-binding edge cases |
package/README.md CHANGED
@@ -140,7 +140,7 @@ npm i -g peaks-loop
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.45(2026-09-12) |
143
+ | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.46(2026-09-12) |
144
144
  | **覆盖域** | 代码(`peaks-code`) · 内容(`peaks-content`) · 项目健康(`peaks-doctor`) · 批量修 issue(`peaks-issue-fix-orchestrator`) · 自定义 SOP(`peaks-sop`) · 通用原语(`peaks-solo` 分诊 / `peaks-resume` 续 / `peaks-status` 看 / `peaks-test` 测 / `peaks-slice-decompose` 切片) |
145
145
  | **沉淀池** | `~/.peaks/` 本地池 · 跑两次自动晋升成 bee · 跑翻车让你重定义 · bee 跟着你的口味长 |
146
146
  | **测试套件** | 1096 cases · 4 packages (peaks-loop 1015 / runtime 39 / mut 22 / shared-channel 20) · **CI 首次全绿**(ubuntu + windows) · 14 BDD caller-binding coverage |
@@ -155,16 +155,18 @@ export function registerCodeRuntimeCommands(code, io) {
155
155
  'Adapter-driven (no hard-coded IDE names): Claude Code is the MVP ' +
156
156
  'implementation; trae / codex / cursor / qoder / tongyi-lingma / ' +
157
157
  'hermes / openclaw register their own env-var via IdeAdapter.compact. ' +
158
- 'v3.1.2: when --enforce-job-mode is set OR job-shape.json says isJob=true, ' +
159
- '≥0.85 emits action=auto-compact-now (MANDATORY, not advisory) and ' +
158
+ 'v3.1.2 / 2026-09-12: ≥0.85 emits action=auto-compact-now ' +
159
+ '(MANDATORY in every mode — single-rid included) and ' +
160
160
  '≥0.95 emits action=red-line (forced hook fires next turn). ' +
161
+ '--enforce-job-mode only changes the reported `jobMode` label; ' +
162
+ 'the thresholds are identical. ' +
161
163
  'Context-window override: set env PEAKS_CONTEXT_WINDOW_TOKENS=<positive int> ' +
162
164
  'or `peaks config set --key context.windowTokens --value <positive int>`; ' +
163
165
  'the JSON envelope reports the winning layer as capacitySource ' +
164
166
  '(env-override | config | model-heuristic | default).')
165
167
  .requiredOption('--project <path>', 'target project root')
166
168
  .option('--session-id <sid>', 'override session id (default: read from active presence)')
167
- .option('--enforce-job-mode', 'v3.1.2: treat ≥0.85 as MANDATORY auto-compact (not advisory). Auto-enabled when job-shape.json says isJob=true.')
169
+ .option('--enforce-job-mode', 'v3.1.2: label the run as Job-shaped (jobMode=true). Auto-enabled when job-shape.json says isJob=true. Since 2026-09-12 the ≥0.85 MANDATORY auto-compact applies in single-rid mode too, so this flag no longer changes any action.')
168
170
  .option('--prompt-size <bytes>', 'override the bytes-from-env path; takes priority over env / statusline / transcript. Useful when CLAUDE_CONTEXT_USAGE_PERCENT is absent (e.g. Mac Claude Code).')).action(async (opts) => {
169
171
  try {
170
172
  const { readContextPercent } = await import('../../services/context/auto-compact-reader.js');
@@ -210,17 +212,21 @@ export function registerCodeRuntimeCommands(code, io) {
210
212
  let action = 'ok';
211
213
  let next = null;
212
214
  if (probe.ratio >= 0.95) {
213
- action = isJobMode ? 'red-line' : 'red-line';
215
+ action = 'red-line';
214
216
  next = 'peaks code auto-compact';
215
217
  }
216
218
  else if (probe.ratio >= 0.85) {
217
- if (isJobMode) {
218
- action = 'auto-compact-now';
219
- next = 'peaks code auto-compact';
220
- }
221
- else {
222
- action = 'soft-warn';
223
- }
219
+ // Slice 2026-09-12-compact-band-policy (defect A): the
220
+ // 0.85–0.95 band is MANDATORY auto-compact for single-rid
221
+ // sessions too. It used to be downgraded to `soft-warn` here,
222
+ // which contradicted this command's own help text, the
223
+ // `pre-compact` action from `peaks skill presence`, the
224
+ // peaks-code SKILL.md, and the 2026-07-27 user-calibrated
225
+ // threshold policy — and left an LLM that follows the "single
226
+ // source of truth" (`context-now`) never compacting in the
227
+ // zone, violating the zero-pause contract.
228
+ action = 'auto-compact-now';
229
+ next = 'peaks code auto-compact';
224
230
  }
225
231
  else if (probe.ratio >= 0.5) {
226
232
  action = 'soft-warn';
@@ -229,9 +235,16 @@ export function registerCodeRuntimeCommands(code, io) {
229
235
  : action === 'auto-compact-now' ? 'pre-compact'
230
236
  : action === 'soft-warn' ? 'soft-warn'
231
237
  : 'ok';
232
- const jobModeNotice = isJobMode
233
- ? 'Job mode enforced: ≥0.85 is MANDATORY auto-compact (v3.1.2).'
234
- : 'Advisory mode (single-rid): ≥0.85 is recommended, not mandatory.';
238
+ // Slice 2026-09-12-compact-band-policy: the two modes no longer
239
+ // differ in behaviour ABOVE 0.50 — both auto-fire at ≥0.85 and
240
+ // both red-line at ≥0.95 — so the old "advisory mode
241
+ // (single-rid)" notice was a lie the moment the downgrade was
242
+ // removed. Job mode's remaining difference is that its threshold
243
+ // policy is recorded up front (`job-shape.json`), which the
244
+ // `jobMode` field already reports. The notice now says only that.
245
+ const gateModeNotice = isJobMode
246
+ ? 'Job mode (job-shape.json isJob=true): the same ≥0.85 / ≥0.95 thresholds apply, and the decision is recorded in job-shape.json.'
247
+ : 'Single-rid mode: the same ≥0.85 / ≥0.95 thresholds apply — ≥0.85 is MANDATORY auto-compact, not advisory.';
235
248
  printResult(io, ok('code.context-now', {
236
249
  ratio: probe.ratio,
237
250
  ratioPct: `${ratioPct}%`,
@@ -255,13 +268,11 @@ export function registerCodeRuntimeCommands(code, io) {
255
268
  action === 'red-line'
256
269
  ? `RED LINE: ≥ 95%. Next: \`${next}\` (PreToolUse hook fires next turn).`
257
270
  : action === 'auto-compact-now'
258
- ? `Job-mode MANDATORY auto-compact. Code MUST call \`${next}\` WITHOUT confirmation.`
271
+ ? `MANDATORY auto-compact (≥85%, every mode). Code MUST call \`${next}\` WITHOUT confirmation.`
259
272
  : action === 'soft-warn'
260
- ? isJobMode
261
- ? `Job mode soft-warn (50–85%). Continue working; the next \`peaks code auto-compact\` will re-check.`
262
- : `Soft warn (50–85%). Continue working; the next \`peaks code auto-compact\` will re-check.`
273
+ ? `Soft warn (50–85%). Continue working; the next \`peaks code auto-compact\` will re-check.`
263
274
  : `Below 50%. No action required.`,
264
- jobModeNotice
275
+ gateModeNotice
265
276
  ]), true);
266
277
  }
267
278
  catch (err) {
@@ -16,8 +16,35 @@ import { type CompactLifecycleRecord, type CompactLifecycleStage } from '../comp
16
16
  * Stages a compact *attempt* can prove from inside the dispatching
17
17
  * process. `verifying` / `completed` are deliberately absent — see
18
18
  * `CompactLifecyclePublisher` and `settleOpenLifecycleRun` for why.
19
+ *
20
+ * `armed` is the honest resting stage for a dispatch that only
21
+ * REGISTERED a trigger (slice 2026-09-12-compact-band-policy, defect B)
22
+ * — see `resolveDispatchedStage`.
23
+ */
24
+ type ObservableDispatchStage = Extract<CompactLifecycleStage, 'queued' | 'preparing' | 'compacting' | 'armed'>;
25
+ /** Stage a failure is attributed to (mirrors the store's `failedAt` domain). */
26
+ type FailableStage = Exclude<CompactLifecycleStage, 'failed' | 'completed'>;
27
+ /**
28
+ * Slice 2026-09-12-compact-band-policy (defect B): which stage a
29
+ * *successful* dispatch can honestly claim.
30
+ *
31
+ * A dispatch is only evidence that a compact is IN FLIGHT when the
32
+ * adapter's own trigger is already satisfied. claude-code's
33
+ * `ide-native` pathway installs a PreToolUse hook that compacts
34
+ * in-band at ratio ≥ 0.95 (`AUTO_COMPACT_RED_LINE_RATIO`); at or above
35
+ * that ratio the very next tool call fires it, so `compacting` (and
36
+ * therefore `stalled` if it never lands) is the truthful reading.
37
+ *
38
+ * Below it — and for every pathway that only writes an intent
39
+ * (`llm-self-compress`), or whose shell-exec branch is a deprecated
40
+ * no-op — NOTHING is compacting. The process merely ARMED a trigger.
41
+ * Recording `compacting` there published a heartbeat nobody would ever
42
+ * send, which is what pinned the statusline at `stalled` forever.
19
43
  */
20
- type ObservableDispatchStage = Extract<CompactLifecycleStage, 'queued' | 'preparing' | 'compacting'>;
44
+ export declare function resolveDispatchedStage(input: {
45
+ readonly pathway: string;
46
+ readonly ratio: number;
47
+ }): Extract<CompactLifecycleStage, 'compacting' | 'armed'>;
21
48
  /**
22
49
  * One id per compact attempt. Timestamp-prefixed so a human reading
23
50
  * the raw record can order runs by eye; the random suffix keeps two
@@ -53,8 +80,17 @@ export declare class CompactLifecyclePublisher {
53
80
  });
54
81
  /** Publish an active stage the process has proved. */
55
82
  advance(stage: ObservableDispatchStage): void;
56
- /** Publish the terminal failure, attributed to the last stage reached. */
57
- fail(error: unknown): void;
83
+ /**
84
+ * Publish the terminal failure, attributed to the last stage reached.
85
+ *
86
+ * `at` overrides that attribution for the case where the failure
87
+ * happened INSIDE a phase that is only named after its outcome
88
+ * (slice 2026-09-12-compact-band-policy): the dispatch call is the
89
+ * compacting phase even though the resting stage is now chosen from
90
+ * the pathway the dispatch returned. `failedAt` names the phase the
91
+ * attempt died in — it is not a published heartbeat.
92
+ */
93
+ fail(error: unknown, at?: FailableStage): void;
58
94
  private write;
59
95
  }
60
96
  /**
@@ -12,6 +12,7 @@
12
12
  * returned envelope.
13
13
  */
14
14
  import { readCompactLifecycle, writeCompactLifecycle } from '../compact-statusline/compact-lifecycle-store.js';
15
+ import { AUTO_COMPACT_RED_LINE_RATIO } from '../context/auto-compact-types.js';
15
16
  /**
16
17
  * PRD-002b slice 2 — extract the few cross-cutting magic numbers
17
18
  * that actually appear at runtime call sites in this orchestrator.
@@ -20,6 +21,28 @@ import { readCompactLifecycle, writeCompactLifecycle } from '../compact-statusli
20
21
  * + spec prose references must keep the literal value visible.
21
22
  */
22
23
  const COLLAPSED_ERROR_MAX_CHARS = 160;
24
+ /**
25
+ * Slice 2026-09-12-compact-band-policy (defect B): which stage a
26
+ * *successful* dispatch can honestly claim.
27
+ *
28
+ * A dispatch is only evidence that a compact is IN FLIGHT when the
29
+ * adapter's own trigger is already satisfied. claude-code's
30
+ * `ide-native` pathway installs a PreToolUse hook that compacts
31
+ * in-band at ratio ≥ 0.95 (`AUTO_COMPACT_RED_LINE_RATIO`); at or above
32
+ * that ratio the very next tool call fires it, so `compacting` (and
33
+ * therefore `stalled` if it never lands) is the truthful reading.
34
+ *
35
+ * Below it — and for every pathway that only writes an intent
36
+ * (`llm-self-compress`), or whose shell-exec branch is a deprecated
37
+ * no-op — NOTHING is compacting. The process merely ARMED a trigger.
38
+ * Recording `compacting` there published a heartbeat nobody would ever
39
+ * send, which is what pinned the statusline at `stalled` forever.
40
+ */
41
+ export function resolveDispatchedStage(input) {
42
+ return input.pathway === 'ide-native' && input.ratio >= AUTO_COMPACT_RED_LINE_RATIO
43
+ ? 'compacting'
44
+ : 'armed';
45
+ }
23
46
  /**
24
47
  * One id per compact attempt. Timestamp-prefixed so a human reading
25
48
  * the raw record can order runs by eye; the random suffix keeps two
@@ -100,8 +123,17 @@ export class CompactLifecyclePublisher {
100
123
  redLine: this.ctx.redLine
101
124
  });
102
125
  }
103
- /** Publish the terminal failure, attributed to the last stage reached. */
104
- fail(error) {
126
+ /**
127
+ * Publish the terminal failure, attributed to the last stage reached.
128
+ *
129
+ * `at` overrides that attribution for the case where the failure
130
+ * happened INSIDE a phase that is only named after its outcome
131
+ * (slice 2026-09-12-compact-band-policy): the dispatch call is the
132
+ * compacting phase even though the resting stage is now chosen from
133
+ * the pathway the dispatch returned. `failedAt` names the phase the
134
+ * attempt died in — it is not a published heartbeat.
135
+ */
136
+ fail(error, at) {
105
137
  this.write({
106
138
  schemaVersion: 1,
107
139
  runId: this.ctx.runId,
@@ -109,7 +141,7 @@ export class CompactLifecyclePublisher {
109
141
  updatedAt: new Date().toISOString(),
110
142
  triggerRatio: this.ctx.triggerRatio,
111
143
  redLine: this.ctx.redLine,
112
- failedAt: this.lastStage,
144
+ failedAt: at ?? this.lastStage,
113
145
  errorSummary: summarizeLifecycleError(error)
114
146
  });
115
147
  }
@@ -197,9 +229,12 @@ export function settleOpenLifecycleRun(input) {
197
229
  });
198
230
  if (prior === null)
199
231
  return;
200
- // Only a run that was actually dispatched (`compacting`) can be
201
- // completed by a post-compact measurement.
202
- if (prior.stage !== 'compacting')
232
+ // Only a run that was actually dispatched can be completed by a
233
+ // post-compact measurement. Both `compacting` (the in-band trigger is
234
+ // satisfied) and `armed` (a trigger was registered and could fire at
235
+ // any time) qualify: the measured drop is proof that SOME compact
236
+ // landed, and this is the only open run to attribute it to.
237
+ if (prior.stage !== 'compacting' && prior.stage !== 'armed')
203
238
  return;
204
239
  const emit = (stage, withAfterRatio) => {
205
240
  const record = {
@@ -35,7 +35,7 @@ import { resolveOuterSessionId } from '../session/binding-status-service.js';
35
35
  import { AUTO_COMPACT_PRE_COMPACT_RATIO } from '../context/auto-compact-types.js';
36
36
  import { describeMode, thresholdFor } from './auto-compact-modes.js';
37
37
  import { resolveAutoCompactProfile } from '../mode/mode-status-service.js';
38
- import { CompactLifecyclePublisher, newCompactRunId, settleOpenLifecycleRun, summarizeLifecycleError } from './auto-compact-lifecycle.js';
38
+ import { CompactLifecyclePublisher, newCompactRunId, resolveDispatchedStage, settleOpenLifecycleRun, summarizeLifecycleError } from './auto-compact-lifecycle.js';
39
39
  const PRE_COMPACT_REASON = 'pre-compact-auto';
40
40
  /**
41
41
  * Map a context ratio to a `CompactTrigger` action. Pure; the side
@@ -435,10 +435,6 @@ export async function runAutoCompact(input) {
435
435
  const target = input.target ?? 'main';
436
436
  let dispatch;
437
437
  try {
438
- // `compacting` is the last stage this process can prove: the IDE
439
- // performs the actual compaction out-of-band, so a successful
440
- // dispatch return is NOT evidence the context shrank.
441
- lifecycle.advance('compacting');
442
438
  if (input.testHooks?.failCompacting)
443
439
  throw new Error('IDE dispatch exploded');
444
440
  // Slice 2026-06-28: when targeting the main session, write an
@@ -463,7 +459,9 @@ export async function runAutoCompact(input) {
463
459
  });
464
460
  }
465
461
  catch (error) {
466
- lifecycle.fail(error);
462
+ // `compacting` is the phase this failure died in — a phase label,
463
+ // not a claim that a compaction was in flight.
464
+ lifecycle.fail(error, 'compacting');
467
465
  return {
468
466
  ok: false,
469
467
  code: 'AUTO_COMPACT_DISPATCH_FAILED',
@@ -480,11 +478,22 @@ export async function runAutoCompact(input) {
480
478
  }
481
479
  };
482
480
  }
483
- // A dispatcher that returns `ok: false` did not compact anything —
484
- // record that as a failure at `compacting` rather than leaving the
485
- // run looking like it is still in progress.
486
- if (!dispatch.ok) {
487
- lifecycle.fail(new Error(dispatch.message));
481
+ // Slice 2026-09-12-compact-band-policy (defect B): the stage is
482
+ // chosen from what the dispatch ACTUALLY did, never from the hope
483
+ // that it compacted. `ide-native` on claude-code only installs a
484
+ // PreToolUse hook that fires at ratio ≥ 0.95 — below that nothing is
485
+ // in flight and the honest stage is `armed` (see
486
+ // `resolveDispatchedStage`). Writing `compacting` there published a
487
+ // heartbeat that would never arrive, which is exactly what pinned
488
+ // the statusline at `stalled` for 92 minutes in the field.
489
+ if (dispatch.ok) {
490
+ lifecycle.advance(resolveDispatchedStage({ pathway: dispatch.pathway, ratio: probe.ratio }));
491
+ }
492
+ else {
493
+ // A dispatcher that returns `ok: false` did not compact anything —
494
+ // record that as a failure at `compacting` rather than leaving the
495
+ // run looking like it is still in progress.
496
+ lifecycle.fail(new Error(dispatch.message), 'compacting');
488
497
  }
489
498
  // Slice 2026-07-30-compact-visibility: append a compact-history
490
499
  // event so the new 'peaks compact history' CLI and the
@@ -1,4 +1,12 @@
1
- export type CompactLifecycleStage = 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed';
1
+ export type CompactLifecycleStage = 'queued' | 'preparing' | 'compacting'
2
+ /**
3
+ * Slice 2026-09-12-compact-band-policy: a compact trigger was
4
+ * REGISTERED but no compaction has started — e.g. claude-code's
5
+ * `ide-native` pathway only installs the PreToolUse hook, which
6
+ * compacts in-band at ratio ≥ 0.95. In the 0.80–0.95 band nothing
7
+ * is in flight, so claiming `compacting` was a false heartbeat.
8
+ */
9
+ | 'armed' | 'verifying' | 'completed' | 'failed';
2
10
  export interface CompactLifecycleRecord {
3
11
  readonly schemaVersion: 1;
4
12
  readonly runId: string;
@@ -49,7 +57,8 @@ export declare function writeCompactLifecycle(input: {
49
57
  * Return kinds:
50
58
  * - `missing` — no record has ever been written for this session
51
59
  * - `valid` — record parsed and validated; stage is terminal
52
- * (completed/failed) OR is active but still fresh
60
+ * (completed/failed), resting (`armed`) OR is active
61
+ * but still fresh
53
62
  * - `invalid` — file exists but is malformed / wrong schema / out
54
63
  * of range. `reason` is a single-line English message
55
64
  * (no CLI verbs, no stack traces). The caller MUST
@@ -19,11 +19,27 @@
19
19
  // compacting / verifying) is `stalled` when (nowMs - updatedAt) >
20
20
  // staleAfterMs. Terminal stages (`completed`, `failed`) NEVER go
21
21
  // stalled — the record is the historical answer, not a heartbeat.
22
+ //
23
+ // Slice 2026-09-12-compact-band-policy (defect B): `armed` is a third
24
+ // category — a RESTING stage. It records a fact ("we registered a
25
+ // compact trigger") that stays true until a measurement supersedes it;
26
+ // no heartbeat was ever promised, so waiting in it is NORMAL and it
27
+ // must never be reported as `stalled`. Only `compacting` means "a
28
+ // compaction should be in flight right now", which is the only state
29
+ // where a missing heartbeat is real evidence of a stall.
22
30
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
23
31
  import { dirname, join } from 'node:path';
24
32
  import { getSessionDir } from '../session/getSessionDir.js';
25
33
  const LIFECYCLE_FILENAME = 'compact-lifecycle.json';
26
34
  const ERROR_SUMMARY_MAX = 160;
35
+ /**
36
+ * Stages that promise a heartbeat: each one is only written while the
37
+ * dispatching process (or the IDE it armed) is expected to make
38
+ * progress, so a stale `updatedAt` is real evidence of a stall.
39
+ *
40
+ * `armed` is deliberately NOT here — see the header. It is a resting
41
+ * state, not a heartbeat.
42
+ */
27
43
  const ACTIVE_STAGES = [
28
44
  'queued',
29
45
  'preparing',
@@ -34,6 +50,7 @@ const ALL_STAGES = [
34
50
  'queued',
35
51
  'preparing',
36
52
  'compacting',
53
+ 'armed',
37
54
  'verifying',
38
55
  'completed',
39
56
  'failed',
@@ -169,7 +186,8 @@ export function writeCompactLifecycle(input) {
169
186
  * Return kinds:
170
187
  * - `missing` — no record has ever been written for this session
171
188
  * - `valid` — record parsed and validated; stage is terminal
172
- * (completed/failed) OR is active but still fresh
189
+ * (completed/failed), resting (`armed`) OR is active
190
+ * but still fresh
173
191
  * - `invalid` — file exists but is malformed / wrong schema / out
174
192
  * of range. `reason` is a single-line English message
175
193
  * (no CLI verbs, no stack traces). The caller MUST
@@ -1,5 +1,5 @@
1
1
  import { type CompactLifecycleStage } from './compact-lifecycle-store.js';
2
- export type CompactDisplayKind = 'none' | 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed' | 'stalled' | 'invalid';
2
+ export type CompactDisplayKind = 'none' | 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed' | 'stalled' | 'invalid' | 'armed';
3
3
  export interface CompactStatuslineState {
4
4
  readonly kind: CompactDisplayKind;
5
5
  readonly filledCells: 0 | 2 | 4 | 6 | 8;
@@ -28,6 +28,9 @@
28
28
  // none → 0 cells
29
29
  // invalid → 0 cells (no false reassurance)
30
30
  // stalled → keep the active stage's cell
31
+ // armed → NO bar at all (slice 2026-09-12-compact-band-policy):
32
+ // a registered-but-idle trigger has no progress to
33
+ // report, and a bar would imply one.
31
34
  //
32
35
  // Render contract: the rendered label is a fixed-width 8-cell bar
33
36
  // (`[████░░░░]` filled from the left). NO `?` characters. NO guessed
@@ -36,6 +39,7 @@
36
39
  // measurement" hint.
37
40
  import { existsSync, readFileSync, statSync } from 'node:fs';
38
41
  import { getSessionDir } from '../session/getSessionDir.js';
42
+ import { AUTO_COMPACT_RED_LINE_RATIO } from '../context/auto-compact-types.js';
39
43
  import { readCompactLifecycle, } from './compact-lifecycle-store.js';
40
44
  /**
41
45
  * Concrete first-version stale timeout. Adjustable after real timing
@@ -70,6 +74,8 @@ const CELL_BY_STAGE = new Map([
70
74
  ['verifying', STAGE_CELL_VERIFYING],
71
75
  ['completed', STAGE_CELL_COMPLETED],
72
76
  ['failed', STAGE_CELL_COMPACTING],
77
+ // `armed` is not in the cell table: it renders WITHOUT a bar (see
78
+ // renderCompactStatusline). Lookups therefore miss and fall back to 0.
73
79
  ]);
74
80
  const FILLED = '█';
75
81
  const EMPTY = '░';
@@ -245,6 +251,12 @@ export function renderCompactStatusline(state) {
245
251
  return `compact ${renderBar(2)}`;
246
252
  case 'compacting':
247
253
  return `compact ${renderBar(STAGE_CELL_COMPACTING)}`;
254
+ // Slice 2026-09-12-compact-band-policy: a registered trigger that has
255
+ // not fired is NOT progress. Rendering a bar would imply movement
256
+ // that is not happening, so `armed` deliberately renders no bar at
257
+ // all — the label states what is true and what would change it.
258
+ case 'armed':
259
+ return `compact armed${formatArmedRatio(state)} — fires in-band at ${arRedLinePct()}`;
248
260
  case 'verifying':
249
261
  return `compact ${renderBar(STAGE_CELL_VERIFYING)}`;
250
262
  case 'completed':
@@ -257,6 +269,16 @@ export function renderCompactStatusline(state) {
257
269
  return formatInvalid(state);
258
270
  }
259
271
  }
272
+ /** The red-line ratio the armed trigger waits for, as a display percentage. */
273
+ function arRedLinePct() {
274
+ return `${Math.round(AUTO_COMPACT_RED_LINE_RATIO * 100)}%`;
275
+ }
276
+ /** ` (88% now)` when the opening trigger ratio is known; empty otherwise. */
277
+ function formatArmedRatio(state) {
278
+ return typeof state.triggerRatio === 'number'
279
+ ? ` (${Math.round(state.triggerRatio * 100)}% now)`
280
+ : '';
281
+ }
260
282
  function formatCompleted(state) {
261
283
  const bar = renderBar(STAGE_CELL_COMPLETED);
262
284
  if (typeof state.afterRatio === 'number') {
@@ -1,4 +1,5 @@
1
1
  import { basename } from 'node:path';
2
+ import { AUTO_COMPACT_RED_LINE_RATIO } from '../context/auto-compact-types.js';
2
3
  import { computeRootSuffix as computeRootSuffixImpl, } from './skill-statusline-sid-suffix.js';
3
4
  // Re-export so existing test imports
4
5
  // (`import { formatShortSid, computeRootSuffix } from '.../skill-statusline-renderer'`)
@@ -181,6 +182,10 @@ function formatRatio(value) {
181
182
  *
182
183
  * <stage-glyph> <bar> <label>[ · <before>%][ → <after>%]
183
184
  *
185
+ * `armed` is the one exception: it renders WITHOUT the bar
186
+ * (`<glyph> armed · <before>% · fires at <redLine>%`) because a bar is
187
+ * a progress claim and a registered-but-unfired trigger has no progress.
188
+ *
184
189
  * Failed states additionally suffix the stage at which the compact failed.
185
190
  * Stalled states keep the active-stage cell count and render a plain
186
191
  * "stalled" label. Invalid states surface the read-reason verbatim as a
@@ -202,6 +207,16 @@ function renderCompact(state, palette) {
202
207
  return `${palette.compact.compacting} ${renderCompactBar(4, palette)} compacting${typeof state.triggerRatio === 'number'
203
208
  ? `${palette.inlineSeparator}${formatRatio(state.triggerRatio)}`
204
209
  : ''}`;
210
+ case 'armed': {
211
+ // Slice 2026-09-12-compact-band-policy: NO bar. A bar is a
212
+ // progress claim, and a registered-but-unfired trigger has no
213
+ // progress to report — it is waiting for the ratio to reach the
214
+ // red line on its own. Say exactly that instead.
215
+ const now = typeof state.triggerRatio === 'number'
216
+ ? `${palette.inlineSeparator}${formatRatio(state.triggerRatio)}`
217
+ : '';
218
+ return `${palette.compact.armed} armed${now}${palette.inlineSeparator}fires at ${formatRatio(AUTO_COMPACT_RED_LINE_RATIO)}`;
219
+ }
205
220
  case 'verifying':
206
221
  return `${palette.compact.verifying} ${renderCompactBar(6, palette)} verifying`;
207
222
  case 'completed':
@@ -518,7 +533,14 @@ export function renderStatusLine(model, options, env) {
518
533
  const compactSegment = renderCompact(model.compact, palette);
519
534
  let line;
520
535
  const hasCompact = compactSegment.length > 0;
521
- if (hasCompact) {
536
+ // Slice 2026-09-12-compact-band-policy: `armed` is a RESTING state, not
537
+ // an in-flight compact. It can hold for the whole band between the
538
+ // auto-fire ratio and the red line, so it is appended to the normal
539
+ // line instead of REPLACING the skill token — hiding which skill is
540
+ // active for many turns would be a regression the user never asked
541
+ // for. Every other (genuinely in-flight) stage keeps replacing it.
542
+ const armedOnly = model.compact.kind === 'armed';
543
+ if (hasCompact && !armedOnly) {
522
544
  // Compact state replaces the active / stale / idle skill content.
523
545
  // `invalid-presence` still surfaces its own diagnostic when compact
524
546
  // is also `invalid` (the compact diagnostic wins, since it's the
@@ -526,21 +548,24 @@ export function renderStatusLine(model, options, env) {
526
548
  line = `${brand} ${compactSegment}${rootSuffix}`;
527
549
  }
528
550
  else {
551
+ let base;
529
552
  switch (model.state) {
530
553
  case 'active':
531
- line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf, model.twentyFourHourState)}${rootSuffix}`;
554
+ base = renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf, model.twentyFourHourState);
532
555
  break;
533
556
  case 'stale':
534
- line = `${brand} ${renderStale(model.presence, model.ageMs, palette, capability, noColor)}${rootSuffix}`;
557
+ base = renderStale(model.presence, model.ageMs, palette, capability, noColor);
535
558
  break;
536
559
  case 'invalid-presence':
537
- line = `${brand} ${renderInvalid(palette)}${rootSuffix}`;
560
+ base = renderInvalid(palette);
538
561
  break;
539
562
  case 'idle':
540
563
  default:
541
- line = `${brand} ${renderIdle(palette)}${rootSuffix}`;
564
+ base = renderIdle(palette);
542
565
  break;
543
566
  }
567
+ const armedSuffix = armedOnly ? `${palette.inlineSeparator}${compactSegment}` : '';
568
+ line = `${brand} ${base}${armedSuffix}${rootSuffix}`;
544
569
  }
545
570
  // Marquee is OFF for idle (and only idle). Compact states always
546
571
  // carry the band because the compact bar IS the headline. NO_COLOR
@@ -35,6 +35,12 @@ interface CompactPalette {
35
35
  readonly queued: string;
36
36
  readonly preparing: string;
37
37
  readonly compacting: string;
38
+ /**
39
+ * Slice 2026-09-12-compact-band-policy: a trigger is registered but no
40
+ * compaction is running. Rendered WITHOUT a bar — see
41
+ * `renderCompact` in skill-statusline-renderer.ts.
42
+ */
43
+ readonly armed: string;
38
44
  readonly verifying: string;
39
45
  readonly completed: string;
40
46
  readonly failed: string;
@@ -117,7 +117,7 @@ function buildPalette(capability, noColor) {
117
117
  idleLabel: 'empty',
118
118
  invalidMessage: 'presence unreadable',
119
119
  compact: {
120
- queued: '[', preparing: '+', compacting: '+', verifying: '+',
120
+ queued: '[', preparing: '+', compacting: '+', armed: '~', verifying: '+',
121
121
  completed: '*', failed,
122
122
  },
123
123
  barFilled: '#',
@@ -142,6 +142,9 @@ function buildPalette(capability, noColor) {
142
142
  queued: brandGlyph('◐'),
143
143
  preparing: brandGlyph('◑'),
144
144
  compacting: brandGlyph('◒'),
145
+ // Distinct from `compacting` on purpose: "waiting for the trigger"
146
+ // must not wear the same face as "a compact is in flight".
147
+ armed: brandGlyph('◔'),
145
148
  verifying: brandGlyph('◓'),
146
149
  completed: brandGlyph('✓'),
147
150
  failed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.45",
3
+ "version": "4.0.46",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -102,10 +102,10 @@
102
102
  "picomatch": "4.0.4",
103
103
  "yaml": "^2.9.0",
104
104
  "zod": "^4.4.3",
105
- "peaks-loop-mut": "0.1.43",
106
- "peaks-loop-shared-channel": "0.0.47",
107
- "peaks-loop-internal-runtime": "0.0.30",
108
- "peaks-loop-shared": "0.0.79"
105
+ "peaks-loop-internal-runtime": "0.0.31",
106
+ "peaks-loop-shared": "0.0.80",
107
+ "peaks-loop-shared-channel": "0.0.48",
108
+ "peaks-loop-mut": "0.1.44"
109
109
  },
110
110
  "devDependencies": {
111
111
  "@changesets/cli": "2.31.1",
@@ -185,7 +185,7 @@ Before the first planning action, run `peaks fresh-context preflight --prompt "<
185
185
  1. **PreToolUse hook — `peaks code gate-step-08`.** Installed by `peaks workspace init` on the `Bash` matcher; checks `job-shape.json` presence + fail-closed backup regex. If `job-shape.json` AND `progress.json` exist, surfaces `Next: slice #N of M (<currentSlice>)` so the LLM cannot wake up cold.
186
186
  2. **Size-fear ban — `peaks code emit-handoff`.** Refuses to emit a final handoff while `remaining > 0` under Job mode. Pass `--force-under-job` only with explicit user approval.
187
187
  3. **On-disk slice progress — `peaks job progress`.** `peaks job checkpoint --state done` writes `progress.json`. `peaks job progress --job-id <jid> [--allow-missing]` is the canonical reader.
188
- 4. **Forced auto-compact — `--enforce-job-mode`.** `peaks code context-now --enforce-job-mode` returns `action: 'auto-compact-now'` at ≥ 0.85. **Job mode at ≥ 0.85 is MANDATORY auto-compact** — Code MUST call `peaks code auto-compact` without confirmation.
188
+ 4. **Forced auto-compact — `peaks code context-now`.** It returns `action: 'auto-compact-now'` at ≥ 0.85. **≥ 0.85 is MANDATORY auto-compact in every mode (single-rid included)** — Code MUST call `peaks code auto-compact` without confirmation. `--enforce-job-mode` (v3.1.2) only labels the run `jobMode=true`; the ≥ 0.85 downgrade that used to apply to single-rid sessions was removed 2026-09-12.
189
189
 
190
190
  **Step 0.7 resume rule (read-FIRST):** on resume, `peaks code gate-step-08` reads `progress.json` first and surfaces `Next: slice #N of M (<currentSlice>)` so the orchestrator picks up at the right slice without re-reading the artifact tree.
191
191
 
@@ -209,7 +209,7 @@ Before the first planning action, run `peaks fresh-context preflight --prompt "<
209
209
 
210
210
  **Enforcement layers (defense in depth):**
211
211
  1. `src/services/code/auto-compact-orchestrator.ts` — `evaluateAutoCompactDecision` default-returns `shouldCompact: true` for both `pre-compact` and `red-line`. Only deferral is `inFlightBatch.hasInFlightBatch` (D6.e); no LLM/human approval branch.
212
- 2. `--enforce-job-mode` (v3.1.2) — Job mode elevates ≥0.85 to MANDATORY regardless of in-flight batch.
212
+ 2. `peaks code context-now` — ≥ 0.85 is MANDATORY (`auto-compact-now`) in every mode; `--enforce-job-mode` no longer gates that (2026-09-12). Only an in-flight sub-agent batch defers it.
213
213
  3. `peaks code gate-step-08` (PreToolUse hook) — surfaces `auto-compact-now` on every Bash call when ratio is in the zone, so the LLM cannot wake up cold and forget.
214
214
  4. Karpathy §4 exception: `peaks code auto-compact` is fired *by the orchestrator*, not by the user. If you find yourself about to write "ask the user to compact" / "prompt the user to run `/compact`", STOP — that is the regression.
215
215