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 +18 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/code-runtime-commands.js +30 -19
- package/dist/services/code/auto-compact-lifecycle.d.ts +39 -3
- package/dist/services/code/auto-compact-lifecycle.js +41 -6
- package/dist/services/code/auto-compact-orchestrator.js +20 -11
- package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +11 -2
- package/dist/services/compact-statusline/compact-lifecycle-store.js +19 -1
- package/dist/services/compact-statusline/compact-statusline-service.d.ts +1 -1
- package/dist/services/compact-statusline/compact-statusline-service.js +22 -0
- package/dist/services/skills/skill-statusline-renderer.js +30 -5
- package/dist/services/skills/statusline-palette.d.ts +6 -0
- package/dist/services/skills/statusline-palette.js +4 -1
- package/package.json +5 -5
- package/skills/peaks-code/SKILL.md +2 -2
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** | [](https://www.npmjs.com/package/peaks-loop) — 4.0.
|
|
143
|
+
| **Latest** | [](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
|
-
| **最新版本** | [](https://www.npmjs.com/package/peaks-loop) — 4.0.
|
|
143
|
+
| **最新版本** | [](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
|
|
159
|
-
'
|
|
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:
|
|
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 =
|
|
215
|
+
action = 'red-line';
|
|
214
216
|
next = 'peaks code auto-compact';
|
|
215
217
|
}
|
|
216
218
|
else if (probe.ratio >= 0.85) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
? `
|
|
271
|
+
? `MANDATORY auto-compact (≥85%, every mode). Code MUST call \`${next}\` WITHOUT confirmation.`
|
|
259
272
|
: action === 'soft-warn'
|
|
260
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
57
|
-
|
|
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
|
-
/**
|
|
104
|
-
|
|
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
|
|
201
|
-
//
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
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'
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
554
|
+
base = renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf, model.twentyFourHourState);
|
|
532
555
|
break;
|
|
533
556
|
case 'stale':
|
|
534
|
-
|
|
557
|
+
base = renderStale(model.presence, model.ageMs, palette, capability, noColor);
|
|
535
558
|
break;
|
|
536
559
|
case 'invalid-presence':
|
|
537
|
-
|
|
560
|
+
base = renderInvalid(palette);
|
|
538
561
|
break;
|
|
539
562
|
case 'idle':
|
|
540
563
|
default:
|
|
541
|
-
|
|
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.
|
|
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-
|
|
106
|
-
"peaks-loop-shared
|
|
107
|
-
"peaks-loop-
|
|
108
|
-
"peaks-loop-
|
|
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 —
|
|
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`
|
|
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
|
|