peaks-loop 4.0.11 → 4.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli/commands/code-commands.js +2 -0
  3. package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
  4. package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
  5. package/dist/cli/commands/core/doctor-command.js +26 -3
  6. package/dist/cli/commands/workspace/reconcile-command.js +5 -2
  7. package/dist/services/code/orchestrator-can-do.d.ts +114 -0
  8. package/dist/services/code/orchestrator-can-do.js +205 -0
  9. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.d.ts +61 -0
  10. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.js +268 -0
  11. package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
  12. package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
  13. package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
  14. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  15. package/dist/services/doctor/doctor-service/report/final-summary.d.ts +13 -5
  16. package/dist/services/doctor/doctor-service/report/final-summary.js +19 -9
  17. package/dist/services/doctor/doctor-service/types.d.ts +64 -0
  18. package/dist/services/doctor/doctor-service.d.ts +2 -1
  19. package/dist/services/doctor/doctor-service.js +1 -0
  20. package/dist/services/doctor/index.d.ts +1 -1
  21. package/dist/services/doctor/index.js +1 -1
  22. package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
  23. package/dist/services/hooks/presence-marker-detector.js +45 -85
  24. package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
  25. package/dist/services/sc/sc-service.d.ts +11 -7
  26. package/dist/services/sc/sc-service.js +29 -26
  27. package/dist/services/session/binding-store.d.ts +6 -6
  28. package/dist/services/skills/hooks-settings-service.d.ts +1 -1
  29. package/dist/services/skills/hooks-settings-service.js +1 -1
  30. package/dist/services/skills/skill-presence-service.d.ts +0 -1
  31. package/dist/services/skills/skill-presence-service.js +112 -102
  32. package/dist/services/skills/skill-statusline-renderer.d.ts +3 -1
  33. package/dist/services/skills/skill-statusline-renderer.js +37 -1
  34. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  35. package/dist/services/skills/skill-statusline-service.js +109 -53
  36. package/dist/services/skills/skill-statusline-sid-suffix.d.ts +55 -0
  37. package/dist/services/skills/skill-statusline-sid-suffix.js +65 -0
  38. package/package.json +4 -4
  39. package/skills/peaks-code/SKILL.md +6 -0
  40. package/skills/peaks-code/references/session-overload-signal-index.md +60 -0
@@ -1,28 +1,34 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
1
  import { resolve } from 'node:path';
3
2
  import { findProjectRoot } from '../config/config-safety.js';
4
3
  import { decideCompactStatusline } from '../compact-statusline/compact-statusline-service.js';
5
4
  import { getSessionIdCanonical } from '../session/session-manager.js';
6
5
  import { resolveActiveSkillForCaller } from '../audit/enforcers/active-skill-resolver.js';
6
+ import { listPresenceLeases } from './presence-lease-service.js';
7
7
  import { readActiveDispatchIndex } from '../dispatch/dispatch-record-writer.js';
8
8
  /**
9
9
  * Out-of-band Peaks skill status renderer for the Claude Code statusLine.
10
10
  *
11
11
  * Claude Code invokes the configured statusLine command on every turn and pipes
12
- * a JSON session payload on stdin. This renderer reads the durable presence file
13
- * (`.peaks/_runtime/active-skill.json`, with a one-minor-release back-compat
14
- * fallback to `.peaks/.active-skill.json`) and prints a single line that
15
- * Claude Code paints at the bottom of the terminal. Because it is rendered
16
- * by the harness — not emitted as LLM tokens — the signal cannot be forgotten
17
- * by the model, cannot be confused with normal output, and survives context
18
- * compaction.
12
+ * a JSON session payload on stdin. This renderer reads the canonical
13
+ * sid-scoped lease projection
14
+ * (`.peaks/_runtime/<sid>/leases/presence-<caller>-<workflow>.json` +
15
+ * the per-caller index under `presence-index/<caller>.json`) and prints a
16
+ * single line that Claude Code paints at the bottom of the terminal. Because
17
+ * it is rendered by the harness — not emitted as LLM tokens — the signal
18
+ * cannot be forgotten by the model, cannot be confused with normal output,
19
+ * and survives context compaction.
19
20
  *
20
21
  * This module is intentionally READ-ONLY. Unlike getSkillPresence in
21
22
  * skill-presence-service.ts, it never deletes or rewrites the presence file:
22
23
  * the statusLine runs on every turn and must have zero side effects.
24
+ *
25
+ * Slice 2026-08-05-statusline-sid-scoped-lease-B: the read no longer falls
26
+ * back to the project-level `.peaks/_runtime/active-skill.json` (or its
27
+ * legacy `.peaks/.active-skill.json`). The canonical lease projection is
28
+ * the only source. When `callerId === null` (non-IDE caller), the read
29
+ * picks the most recent in-flight lease across all callers; this is the
30
+ * documented back-compat path.
23
31
  */
24
- const PRESENCE_FILE = '.peaks/_runtime/active-skill.json';
25
- const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
26
32
  const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
27
33
  function resolveCwdFromStdin(stdin) {
28
34
  const fromWorkspace = stdin?.workspace?.current_dir ?? stdin?.workspace?.project_dir;
@@ -113,61 +119,99 @@ function readActiveLeaf(projectRoot, sessionId) {
113
119
  * Read the presence file without any side effects. Returns null when the file is
114
120
  * absent (idle) and a sentinel object for malformed content (invalid-presence).
115
121
  *
116
- * When `callerId` is non-null, delegates to the canonical lease resolver
117
- * (presence-lease-service via active-skill-resolver) so the read is
118
- * session+caller-isolated. When `callerId` is null, falls back to the
119
- * project-level single-file read (back-compat for callers that don't pass
120
- * a callerId yet, e.g. legacy CLI invocations).
122
+ * Both branches now route through the canonical sid-scoped lease projection
123
+ * (slice 2026-08-05-statusline-sid-scoped-lease-B):
124
+ * - `callerId !== null` → `resolveActiveSkillForCaller` with the canonical
125
+ * (non-legacy) lease projection, filtered to this callerId. When the
126
+ * callerId-filtered resolution returns `source: 'none'` (no lease under
127
+ * this callerId), retry once with `callerId: null` so the read falls back
128
+ * to the session's most-recent in-flight lease. AC4 multi-tenant isolation
129
+ * is preserved: when callerId A DOES have a lease, the first call returns
130
+ * it and the fallback never fires — callerId B's lease is never surfaced
131
+ * to callerId A. (Slice 2026-08-05-statusline-empty-render-and-short-sid-suffix.)
132
+ * - `callerId === null` → enumerate `listPresenceLeases` for the
133
+ * canonical session and pick the most recent in-flight lease. Back-compat
134
+ * for non-IDE callers (e.g. legacy CLI invocations) that have no callerId.
135
+ *
136
+ * No fallback to `.peaks/_runtime/active-skill.json` (or its legacy path):
137
+ * the canonical lease projection is the single source of truth. When no
138
+ * in-flight leases exist, the read returns `{ presence: null, invalid: false }`
139
+ * and the renderer falls back to the idle state.
121
140
  */
122
141
  function readPresenceReadOnly(projectRoot, callerId) {
123
142
  if (callerId !== null) {
143
+ let firstResolution = null;
124
144
  try {
125
- const resolution = resolveActiveSkillForCaller(projectRoot, { legacyPresence: true, callerId });
126
- if (resolution.source === 'none' || resolution.skill === null) {
127
- return { presence: null, invalid: false };
128
- }
145
+ firstResolution = resolveActiveSkillForCaller(projectRoot, { callerId });
146
+ }
147
+ catch {
148
+ return { presence: null, invalid: true };
149
+ }
150
+ if (firstResolution.source !== 'none' && firstResolution.skill !== null) {
129
151
  return {
130
152
  presence: {
131
- skill: resolution.skill,
132
- ...(resolution.mode !== null ? { mode: resolution.mode } : {}),
153
+ skill: firstResolution.skill,
154
+ ...(firstResolution.mode !== null ? { mode: firstResolution.mode } : {}),
133
155
  },
134
156
  invalid: false,
135
157
  };
136
158
  }
137
- catch {
138
- return { presence: null, invalid: true };
139
- }
159
+ // callerId didn't match any lease — fall back to the session's most
160
+ // recent in-flight lease (the callerId === null branch below). This
161
+ // rescues the case where the harness pipes a `CLAUDE_CODE_SESSION_ID`
162
+ // that differs from the active lease's callerId (peaks-code sessions
163
+ // started from a different outer session id, etc.).
164
+ return readPresenceReadOnly(projectRoot, null);
165
+ }
166
+ // callerId === null branch: enumerate the canonical session dir's leases
167
+ // and pick the most recent in-flight lease. This is the back-compat path
168
+ // for non-IDE callers that don't supply a callerId.
169
+ let sessionId = null;
170
+ try {
171
+ sessionId = getSessionIdCanonical(projectRoot);
140
172
  }
141
- const presencePath = resolve(projectRoot, PRESENCE_FILE);
142
- // Back-compat: prefer the new canonical path; fall back to the legacy
143
- // `.peaks/.active-skill.json` for one minor release.
144
- const pathToRead = existsSync(presencePath) ? presencePath : resolve(projectRoot, PRESENCE_FILE_LEGACY);
145
- if (!existsSync(pathToRead)) {
173
+ catch {
146
174
  return { presence: null, invalid: false };
147
175
  }
176
+ if (sessionId === null) {
177
+ return { presence: null, invalid: false };
178
+ }
179
+ let leases = [];
148
180
  try {
149
- const parsed = JSON.parse(readFileSync(pathToRead, 'utf8'));
150
- if (!parsed || typeof parsed !== 'object') {
151
- return { presence: null, invalid: true };
152
- }
153
- const candidate = parsed;
154
- if (typeof candidate.skill !== 'string' || candidate.skill.length === 0) {
155
- return { presence: null, invalid: true };
156
- }
157
- return {
158
- presence: {
159
- skill: candidate.skill,
160
- ...(typeof candidate.mode === 'string' ? { mode: candidate.mode } : {}),
161
- ...(typeof candidate.gate === 'string' ? { gate: candidate.gate } : {}),
162
- ...(typeof candidate.setAt === 'string' ? { setAt: candidate.setAt } : {}),
163
- ...(typeof candidate.claudeSessionId === 'string' ? { claudeSessionId: candidate.claudeSessionId } : {})
164
- },
165
- invalid: false
166
- };
181
+ leases = listPresenceLeases(projectRoot, sessionId);
167
182
  }
168
183
  catch {
169
184
  return { presence: null, invalid: true };
170
185
  }
186
+ const inFlight = leases
187
+ .filter((l) => l.status === 'preparing' || l.status === 'running')
188
+ .filter((l) => typeof l.skill === 'string' && l.skill.length > 0);
189
+ if (inFlight.length === 0) {
190
+ return { presence: null, invalid: false };
191
+ }
192
+ // Most recent wins — sort by `lastHeartbeat` desc, fall back to `startedAt`.
193
+ const sorted = inFlight.slice().sort((a, b) => {
194
+ const hb = b.lastHeartbeat.localeCompare(a.lastHeartbeat);
195
+ if (hb !== 0)
196
+ return hb;
197
+ return b.startedAt.localeCompare(a.startedAt);
198
+ });
199
+ const latest = sorted[0];
200
+ if (latest === undefined || typeof latest.skill !== 'string' || latest.skill.length === 0) {
201
+ return { presence: null, invalid: false };
202
+ }
203
+ // Leases on disk may carry an optional `mode` field (the lease constructor
204
+ // spreads `input.mode` when present); the typed `SkillPresenceLease` does
205
+ // not declare it, so widen the read shape here.
206
+ const latestMode = latest.mode;
207
+ return {
208
+ presence: {
209
+ skill: latest.skill,
210
+ ...(typeof latestMode === 'string' && latestMode.length > 0 ? { mode: latestMode } : {}),
211
+ setAt: latest.startedAt,
212
+ },
213
+ invalid: false,
214
+ };
171
215
  }
172
216
  export function buildStatusLineModel(stdin, nowMs) {
173
217
  const cwd = resolveCwdFromStdin(stdin);
@@ -176,17 +220,30 @@ export function buildStatusLineModel(stdin, nowMs) {
176
220
  // and reads `.peaks/_runtime/<sid>/compact-lifecycle.json` (or its legacy
177
221
  // fallbacks). It replaces the active skill content when kind != 'none'.
178
222
  const compact = readCompactState(projectRoot, nowMs);
223
+ // Slice 2026-08-05-statusline-sid-only-marker: resolve the canonical
224
+ // session id once, up front, so the renderer can read it from the model
225
+ // without re-running `getSessionIdCanonical`. `null` when the project
226
+ // root is unbound OR when no `.peaks/_runtime/<sid>/` session exists.
227
+ let sessionId = null;
228
+ if (projectRoot !== null) {
229
+ try {
230
+ sessionId = getSessionIdCanonical(projectRoot);
231
+ }
232
+ catch {
233
+ sessionId = null;
234
+ }
235
+ }
179
236
  if (projectRoot === null) {
180
- return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null };
237
+ return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null, sessionId: null };
181
238
  }
182
239
  // callerId resolves the read-side isolation; back-compat is `null`.
183
240
  const callerId = resolveCallerId(stdin);
184
241
  const { presence, invalid } = readPresenceReadOnly(projectRoot, callerId);
185
242
  if (invalid) {
186
- return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
243
+ return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
187
244
  }
188
245
  if (presence === null) {
189
- return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
246
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
190
247
  }
191
248
  // Session binding: when the presence was stamped with a Claude session id and
192
249
  // the live session (from stdin) is a different one, the recorded skill belongs
@@ -195,7 +252,7 @@ export function buildStatusLineModel(stdin, nowMs) {
195
252
  // fall back to the time-based behavior below for backward compatibility.
196
253
  const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
197
254
  if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
198
- return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
255
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
199
256
  }
200
257
  const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
201
258
  const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
@@ -206,13 +263,12 @@ export function buildStatusLineModel(stdin, nowMs) {
206
263
  // bee skill (e.g. `peaks-rd`) alongside the orchestrator (e.g. `peaks-code`).
207
264
  let activeLeaf = null;
208
265
  try {
209
- const sessionId = getSessionIdCanonical(projectRoot);
210
266
  activeLeaf = readActiveLeaf(projectRoot, sessionId);
211
267
  }
212
268
  catch {
213
269
  activeLeaf = null;
214
270
  }
215
- return { state, projectRoot, presence, ageMs, compact, activeLeaf };
271
+ return { state, projectRoot, presence, ageMs, compact, activeLeaf, sessionId };
216
272
  }
217
273
  /**
218
274
  * Read-only compact state resolver. Resolves the canonical session id for the
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pure short-sid suffix helpers for the Peaks statusLine.
3
+ *
4
+ * Extracted from `skill-statusline-renderer.ts` (slice
5
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
6
+ * repair cycle) to keep the renderer under the Karpathy 800-line cap.
7
+ * The module is I/O-free and only depends on the read-only
8
+ * `StatusLineModel` shape from `skill-statusline-service.ts` plus the
9
+ * `StatusPalette` interface still owned by the renderer. Re-exports
10
+ * both helpers so the existing test surface (`formatShortSid` direct
11
+ * tests + `computeRootSuffix` helper-level tests in
12
+ * `tests/unit/skills/skill-statusline-sid-only-marker.test.ts`) keeps
13
+ * importing from the renderer for back-compat.
14
+ *
15
+ * Pure module — no I/O, no clock dependency, no side effects.
16
+ */
17
+ import type { StatusLineModel } from './skill-statusline-service.js';
18
+ /**
19
+ * Minimal palette slice consumed by {@link computeRootSuffix}. The full
20
+ * `StatusPalette` is defined as a module-private interface inside the
21
+ * renderer; the suffix helper only needs `trailSeparator`. Re-declared
22
+ * structurally so the helper has no upward import on the renderer.
23
+ */
24
+ export interface SidSuffixPalette {
25
+ readonly trailSeparator: string;
26
+ }
27
+ /**
28
+ * Format a session id as a short, terminal-friendly tag.
29
+ *
30
+ * Returns the last kebab-segment of `sessionId` (e.g.
31
+ * `2026-08-04-session-3fe1be` → `3fe1be`), or the empty string when
32
+ * the input is empty. Pure; safe for ASCII rendering across
33
+ * PowerShell, Git Bash, and zsh (AC7).
34
+ *
35
+ * Slice 2026-08-05-statusline-empty-render-and-short-sid-suffix.
36
+ */
37
+ export declare function formatShortSid(sessionId: string): string;
38
+ /**
39
+ * Compute the project-root cell of the status line. Returns the
40
+ * `<trailSep><root>` portion (and optionally the ` [shortSid]` suffix).
41
+ *
42
+ * Pure helper — no I/O. The `sessionId` is read from the model that the
43
+ * service already resolved once via `getSessionIdCanonical`. Per-state
44
+ * matrix (see the docstring inside `renderStatusLine` in
45
+ * `skill-statusline-renderer.ts` for the AC/PB references):
46
+ *
47
+ * - state === 'invalid-presence' → no sid (G2 invariant)
48
+ * - state === 'idle' | 'stale' | 'active' → sid iff model.sessionId resolves
49
+ * to a non-empty shortSid string
50
+ * - rootLabelText === '' → empty string
51
+ *
52
+ * Exported so tests can drive the helper without spinning up the full
53
+ * renderer (marquee, palette, etc.).
54
+ */
55
+ export declare function computeRootSuffix(model: StatusLineModel, rootLabelText: string, palette: SidSuffixPalette): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure short-sid suffix helpers for the Peaks statusLine.
3
+ *
4
+ * Extracted from `skill-statusline-renderer.ts` (slice
5
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
6
+ * repair cycle) to keep the renderer under the Karpathy 800-line cap.
7
+ * The module is I/O-free and only depends on the read-only
8
+ * `StatusLineModel` shape from `skill-statusline-service.ts` plus the
9
+ * `StatusPalette` interface still owned by the renderer. Re-exports
10
+ * both helpers so the existing test surface (`formatShortSid` direct
11
+ * tests + `computeRootSuffix` helper-level tests in
12
+ * `tests/unit/skills/skill-statusline-sid-only-marker.test.ts`) keeps
13
+ * importing from the renderer for back-compat.
14
+ *
15
+ * Pure module — no I/O, no clock dependency, no side effects.
16
+ */
17
+ /**
18
+ * Format a session id as a short, terminal-friendly tag.
19
+ *
20
+ * Returns the last kebab-segment of `sessionId` (e.g.
21
+ * `2026-08-04-session-3fe1be` → `3fe1be`), or the empty string when
22
+ * the input is empty. Pure; safe for ASCII rendering across
23
+ * PowerShell, Git Bash, and zsh (AC7).
24
+ *
25
+ * Slice 2026-08-05-statusline-empty-render-and-short-sid-suffix.
26
+ */
27
+ export function formatShortSid(sessionId) {
28
+ if (sessionId.length === 0)
29
+ return '';
30
+ const last = sessionId.split('-').pop();
31
+ return last ?? sessionId;
32
+ }
33
+ /**
34
+ * Compute the project-root cell of the status line. Returns the
35
+ * `<trailSep><root>` portion (and optionally the ` [shortSid]` suffix).
36
+ *
37
+ * Pure helper — no I/O. The `sessionId` is read from the model that the
38
+ * service already resolved once via `getSessionIdCanonical`. Per-state
39
+ * matrix (see the docstring inside `renderStatusLine` in
40
+ * `skill-statusline-renderer.ts` for the AC/PB references):
41
+ *
42
+ * - state === 'invalid-presence' → no sid (G2 invariant)
43
+ * - state === 'idle' | 'stale' | 'active' → sid iff model.sessionId resolves
44
+ * to a non-empty shortSid string
45
+ * - rootLabelText === '' → empty string
46
+ *
47
+ * Exported so tests can drive the helper without spinning up the full
48
+ * renderer (marquee, palette, etc.).
49
+ */
50
+ export function computeRootSuffix(model, rootLabelText, palette) {
51
+ if (!rootLabelText)
52
+ return '';
53
+ let suffix = `${palette.trailSeparator}${rootLabelText}`;
54
+ if (model.state === 'invalid-presence')
55
+ return suffix;
56
+ // Defensive: older callers may build a model without `sessionId`
57
+ // (the field is optional in some test fixtures predating the
58
+ // sid-only-marker slice). Treat undefined the same as null.
59
+ if (model.sessionId === null || model.sessionId === undefined)
60
+ return suffix;
61
+ const short = formatShortSid(model.sessionId);
62
+ if (short.length === 0)
63
+ return suffix;
64
+ return `${suffix} [${short}]`;
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.11",
3
+ "version": "4.0.13",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -100,9 +100,9 @@
100
100
  "headroom-ai": "0.22.4",
101
101
  "yaml": "^2.9.0",
102
102
  "zod": "^3.25.76",
103
- "peaks-loop-shared-channel": "0.0.19",
104
- "peaks-loop-mut": "0.1.15",
105
- "peaks-loop-shared": "0.0.42"
103
+ "peaks-loop-shared": "0.0.44",
104
+ "peaks-loop-mut": "0.1.17",
105
+ "peaks-loop-shared-channel": "0.0.21"
106
106
  },
107
107
  "peerDependencies": {
108
108
  "@alibaba-group/open-code-review": "1.3.1"
@@ -102,6 +102,10 @@ Full content extracted to **`references/startup-sequence.md`** (Steps 0 / 0.5-0.
102
102
 
103
103
  **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.
104
104
 
105
+ ### Peaks-Loop Step 0.51 — `orchestrator-can-do` probe (slice 2026-08-05, lesson-encoded CLI)
106
+
107
+ > **Before deciding to push a slice to the next session**, run `peaks code orchestrator-can-do --slice-spec '<text>' --json`. The command evaluates 4 boundary questions (source code? sub-agent available? requires user decision? context sustainable?) and returns whether the slice can be done in the current session. Default to `canDoInSession=true` unless hard blockers are present; sub-agent dispatch (`peaks sub-agent dispatch rd`) is the canonical delegation path for source-code changes. This encodes the 2026-08-05 lesson — orchestrator MUST NOT Edit/Write `src/` directly, but MUST delegate via sub-agent dispatch. See `.peaks/memory/2026-08-05-peaks-code-orchestrator-capability-misjudgment.md`.
108
+
105
109
  ### Peaks-Loop Step N+2: Auto-compact at the warning line (v2.13.0 zero-pause contract)
106
110
 
107
111
  > **Zero-pause contract.** When context usage crosses the pre-compact threshold, peaks-loop **automatically fires `peaks compact auto --execute` — the LLM does NOT prompt the user to run `/compact` manually**. Stale prose that says "ask the user to compact" silently stalls the workflow. The v2.13.0 contract makes auto-compact a system responsibility, not a user action.
@@ -127,6 +131,8 @@ Full content extracted to **`references/startup-sequence.md`** (Steps 0 / 0.5-0.
127
131
 
128
132
  If the prose audit (`peaks audit red-lines`) flags any of the above, the slice is **blocked** until the prose is rewritten.
129
133
 
134
+ See also: references/session-overload-signal-index.md for the 7-signal lookup table the LLM consults before deciding to open a new session.
135
+
130
136
  ## CLI Drift Index (sediment 2026-07-09)
131
137
 
132
138
  > **Reading guide:** Verified against peaks-loop 4.0.0-beta.6. Each drift below is annotated inline at the relevant step with a `> CLI reality check`. On `error: unknown option ...`, **read the inline reality check first** before guessing.
@@ -0,0 +1,60 @@
1
+ # Session Overload Signal Index
2
+
3
+ > Single-source lookup for the LLM-side "should I open a new session?" decision.
4
+ > Slice 2026-08-05-session-overload-signal-index.
5
+
6
+ ## What this doc is
7
+
8
+ When the LLM senses context pressure, it must consult these 7 signals before
9
+ deciding to (a) auto-compact, (b) refuse the next slice, or (c) ask the user to
10
+ open a new session. The contract is: **LLM never re-asks the user about
11
+ cost/length/context** (red line #2 of `references/job-loop.md`). This index is
12
+ the lookup table the LLM must consult in lieu of intuition.
13
+
14
+ ## The 7 signals
15
+
16
+ | # | Signal source | Threshold | File / CLI | LLM action |
17
+ |---|---|---|---|---|
18
+ | 1 | prompt size (main session) | 50% / 75% / 80% / 90% | `src/services/context/threshold.ts`; probe via `peaks code context-now` | soft-warn at 50%; consider compact at 75%; MUST NOT auto-compact mid-batch (D6.e); Job mode ≥ 0.85 ⇒ MANDATORY `peaks compact auto --execute` |
19
+ | 2 | auto-compact zone | 0.85 pre-compact / 0.95 red-line | `src/services/code/auto-compact-orchestrator.ts`; `--enforce-job-mode` flag | Auto-fires `peaks compact auto --execute`; LLM MUST NOT prompt user to compact (zero-pause contract v2.13.0) |
20
+ | 3 | sub-agent dispatch prompt size | 50% / 75% / 80% | `src/services/context/context-guard.ts`; `peaks sub-agent-dispatch-guard` PreToolUse hook | soft-warn at 50%; CONTEXT_NEAR_LIMIT at 75%; hard-reject at 80% (CLI + hook double-guard) |
21
+ | 4 | statusline compact bar | visual | `src/services/compact-statusline/compact-statusline-service.ts` | ambient UI; surfaces queued/preparing/compacting/verifying/completed/failed/stalled |
22
+ | 5 | in-flight batch deferral | D6.e | `src/services/code/auto-compact-modes.ts` | defer compact until batch lands (NOT "new session") |
23
+ | 6 | compact stalled | 120s heartbeat timeout | `compact-lifecycle-store.ts` | ambient "stalled" detail in statusline |
24
+ | 7 | sub-agent heartbeat missing | per-role TTL | `peaks sub-agent heartbeat`; `peaks heartbeat-watch` | orchestrator-side warning, not LLM-driven "new session" |
25
+
26
+ ## Hard rules
27
+
28
+ - peaks-code SKILL.md red line #2 (see `references/job-loop.md`):
29
+ **Re-ask the user about cost / length / context is forbidden**.
30
+ The LLM MUST NOT prompt the user to open a new session based on overload
31
+ intuition alone; it MUST consult the 7 signals above and prefer auto-compact /
32
+ sub-agent dispatch guard / heartbeat-watch first.
33
+ - "Open a new session" is a **last resort** after auto-compact has been
34
+ attempted and the slice cannot fit. The user's role is to receive the
35
+ handoff capsule, not to micro-manage session lifecycle.
36
+
37
+ ## Decision flowchart
38
+
39
+ ```
40
+ LLM senses context pressure
41
+ ├─► Probe `peaks code context-now --json`
42
+ │ ├─ ratio < 0.50 → continue (no action)
43
+ │ ├─ 0.50 ≤ ratio < 0.75 → soft-warn; continue
44
+ │ ├─ 0.75 ≤ ratio < 0.85 → compact-zone candidate
45
+ │ │ ├─ Job mode → MUST auto-compact now
46
+ │ │ └─ non-Job → wait for next idle turn, then compact
47
+ │ ├─ 0.85 ≤ ratio < 0.95 → pre-compact zone; auto-compact MUST fire
48
+ │ └─ ratio ≥ 0.95 → red-line; synchronous gate (Karpathy §4)
49
+ ├─► Check statusline compact bar (signal #4)
50
+ │ ├─ queued / preparing / compacting / verifying → wait
51
+ │ ├─ failed → read failure reason, do NOT spawn new session
52
+ │ └─ stalled (>120s) → surface to user as a blocker, not a new-session ask
53
+ └─► Check sub-agent dispatch guard (signal #3)
54
+ ├─ 75% CONTEXT_NEAR_LIMIT → warn, continue with smaller prompt
55
+ └─ 80% PROMPT_TOO_LARGE → hard-reject; refactor the prompt
56
+
57
+ Only after ALL three checks above have been attempted without resolution:
58
+ emit the `peaks session split-handoff` capsule (NOT in this slice; follow-up)
59
+ and the user opens a new session in their own time.
60
+ ```