peaks-loop 4.0.11 → 4.0.12

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 (25) hide show
  1. package/CHANGELOG.md +30 -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/workspace/reconcile-command.js +5 -2
  6. package/dist/services/code/orchestrator-can-do.d.ts +114 -0
  7. package/dist/services/code/orchestrator-can-do.js +205 -0
  8. package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
  9. package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
  10. package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
  11. package/dist/services/doctor/doctor-service/types.d.ts +9 -0
  12. package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
  13. package/dist/services/hooks/presence-marker-detector.js +45 -85
  14. package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
  15. package/dist/services/sc/sc-service.d.ts +11 -7
  16. package/dist/services/sc/sc-service.js +29 -26
  17. package/dist/services/session/binding-store.d.ts +6 -6
  18. package/dist/services/skills/hooks-settings-service.d.ts +1 -1
  19. package/dist/services/skills/hooks-settings-service.js +1 -1
  20. package/dist/services/skills/skill-presence-service.d.ts +0 -1
  21. package/dist/services/skills/skill-presence-service.js +112 -102
  22. package/dist/services/skills/skill-statusline-service.js +71 -39
  23. package/package.json +4 -4
  24. package/skills/peaks-code/SKILL.md +6 -0
  25. package/skills/peaks-code/references/session-overload-signal-index.md +60 -0
@@ -2,7 +2,15 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
4
  import { ensureMemoryBootstrap } from '../memory/project-memory-service.js';
5
- import { getSessionMeta } from '../session/session-manager.js';
5
+ import { getSessionId, getSessionMeta } from '../session/session-manager.js';
6
+ // Slice 4.0.8 compat shim: the canonical write path lives in
7
+ // `presence-lease-service.ts`. The legacy shim dynamically imports
8
+ // it inside `setSkillPresence` so the cold path of the legacy
9
+ // `getSkillPresence` / `checkStalePresence` / `clearStalePresenceOnRotation`
10
+ // read paths stays cheap. The static imports below are reserved for
11
+ // the migration window and are referenced through the dynamic
12
+ // `leaseMod.*` accessor at runtime.
13
+ import { listPresenceLeases } from './presence-lease-service.js';
6
14
  // Re-export the 4.0.8 compat surface so legacy callers
7
15
  // (`presence-service` consumers in `code-mode-gate-commands.ts`,
8
16
  // `mode-enforcement.ts`, `code-job-shape-commands.ts`, etc.) keep
@@ -37,14 +45,13 @@ function getCurrentOuterSessionId() {
37
45
  return claude;
38
46
  return undefined;
39
47
  }
40
- // As of slice 2026-06-05-peaks-runtime-layer the orchestrator's
41
- // active-skill marker lives under `.peaks/_runtime/active-skill.json`.
42
- // The legacy `.peaks/.active-skill.json` path is preserved as a
43
- // read-only fallback for one minor release so older CLI versions (or
44
- // trees that have not been migrated by `peaks workspace reconcile`)
45
- // keep working without a forced re-init.
46
- const PRESENCE_FILE = join('.peaks', '_runtime', 'active-skill.json');
47
- const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
48
+ // Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
49
+ // project-level single-slot presence files
50
+ // `.peaks/_runtime/active-skill.json` and `.peaks/.active-skill.json`
51
+ // are REMOVED from this module. The canonical sid-scoped lease
52
+ // projection (`.peaks/_runtime/<sid>/leases/presence-*.json`) is the
53
+ // only source of truth. The legacy constants `PRESENCE_FILE` and
54
+ // `PRESENCE_FILE_LEGACY` are deleted; readers use `listPresenceLeases`.
48
55
  const SESSION_FILE = join('.peaks', '_runtime', 'session.json');
49
56
  const SESSION_FILE_LEGACY = '.peaks/.session.json';
50
57
  function resolveProjectRoot(override) {
@@ -52,36 +59,6 @@ function resolveProjectRoot(override) {
52
59
  return resolve(override);
53
60
  return findProjectRoot(process.cwd()) ?? process.cwd();
54
61
  }
55
- function resolvePresencePath(projectRootOverride) {
56
- return resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE);
57
- }
58
- /**
59
- * Back-compat read for the active-skill marker. Prefers the new
60
- * canonical `.peaks/_runtime/active-skill.json`; falls back to the
61
- * legacy `.peaks/.active-skill.json` for one minor release.
62
- *
63
- * Returns the parsed SkillPresence object, or null when neither
64
- * file is present / valid. The legacy file is never written by
65
- * current code — only the new path receives writes.
66
- */
67
- function readSkillPresenceBackCompat(projectRootOverride) {
68
- const presencePath = resolvePresencePath(projectRootOverride);
69
- const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
70
- const pathToRead = existsSync(presencePath) ? presencePath : legacyPath;
71
- if (!existsSync(pathToRead))
72
- return null;
73
- try {
74
- const raw = readFileSync(pathToRead, 'utf8');
75
- const parsed = JSON.parse(raw);
76
- if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
77
- return null;
78
- }
79
- return { presence: parsed, path: pathToRead };
80
- }
81
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
82
- return null;
83
- }
84
- }
85
62
  /**
86
63
  * Resolve the active peaks session id from
87
64
  * `.peaks/_runtime/session.json` (legacy: `.peaks/.session.json`).
@@ -112,6 +89,43 @@ export function getCurrentSessionId(projectRootOverride) {
112
89
  return null;
113
90
  }
114
91
  }
92
+ /**
93
+ * Slice 4.0.11 statusline-sid-scoped-lease C: read the canonical
94
+ * sid-scoped lease projection and project the freshest in-flight
95
+ * lease into the legacy `SkillPresence` shape that the public
96
+ * `getSkillPresence` / `checkStalePresence` /
97
+ * `clearStalePresenceOnRotation` consumers expect. The projection
98
+ * is lossy (the lease does not carry `outerSessionId`; `setAt`
99
+ * maps to `startedAt`; `lastHeartbeat` is reused as `lastHeartbeat`).
100
+ *
101
+ * Returns `null` when the bound session has no in-flight leases
102
+ * (`running` or `preparing`); terminalized leases are ignored so
103
+ * the read does not resurrect a stale skill on a fresh session.
104
+ */
105
+ function readSkillPresenceFromLease(projectRootOverride) {
106
+ const projectRoot = resolveProjectRoot(projectRootOverride);
107
+ const sessionId = getSessionId(projectRoot);
108
+ if (sessionId === null)
109
+ return null;
110
+ const leases = listPresenceLeases(projectRoot, sessionId);
111
+ const inFlight = leases.filter((l) => l.status === 'running' || l.status === 'preparing');
112
+ if (inFlight.length === 0)
113
+ return null;
114
+ inFlight.sort((a, b) => b.lastHeartbeat.localeCompare(a.lastHeartbeat));
115
+ const lease = inFlight[0];
116
+ if (lease === undefined)
117
+ return null;
118
+ // The lease carries `callerId` (the adapter / harness session id),
119
+ // not the legacy `outerSessionId` field. Project it for back-compat
120
+ // with `clearStalePresenceOnRotation`'s outer-mismatch check.
121
+ return {
122
+ skill: lease.skill,
123
+ sessionId,
124
+ outerSessionId: lease.callerId,
125
+ setAt: lease.startedAt,
126
+ lastHeartbeat: lease.lastHeartbeat
127
+ };
128
+ }
115
129
  /**
116
130
  * Look up the outer-session-id that was bound to the *current* peaks
117
131
  * session, i.e. the one written to the per-session
@@ -160,23 +174,14 @@ function getBoundOuterSessionId(projectRootOverride) {
160
174
  * legacy `.peaks/.active-skill.json` for one minor release.
161
175
  */
162
176
  function getPreviousOuterSessionId(projectRootOverride) {
163
- const result = readSkillPresenceBackCompat(projectRootOverride);
177
+ const result = readSkillPresenceFromLease(projectRootOverride);
164
178
  if (result === null)
165
179
  return undefined;
166
- const parsed = result.presence;
167
- if (typeof parsed.outerSessionId === 'string' && parsed.outerSessionId.length > 0) {
168
- return parsed.outerSessionId;
169
- }
170
- // Legacy field name. Honour it on the read side so v1.2.x
171
- // presence files do not show as a false mismatch.
172
- if (typeof parsed.claudeSessionId === 'string' && parsed.claudeSessionId.length > 0) {
173
- return parsed.claudeSessionId;
180
+ if (typeof result.outerSessionId === 'string' && result.outerSessionId.length > 0) {
181
+ return result.outerSessionId;
174
182
  }
175
183
  return undefined;
176
184
  }
177
- export function exportSkillPresence(projectRootOverride) {
178
- return resolvePresencePath(projectRootOverride);
179
- }
180
185
  // ============================================================================
181
186
  // Slice 020 — caller-keyed active-skill marker (D6).
182
187
  // ============================================================================
@@ -336,12 +341,12 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
336
341
  catch { /* fall through to legacy write */ }
337
342
  })();
338
343
  }
339
- const presencePath = resolvePresencePath(projectRootOverride);
340
- const presenceDir = dirname(presencePath);
341
- if (!existsSync(presenceDir)) {
342
- mkdirSync(presenceDir, { recursive: true });
343
- }
344
- writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
344
+ // Slice 4.0.11 statusline-sid-scoped-lease A: the canonical write
345
+ // path is now exclusively `presence-lease-service.setPresenceLease`.
346
+ // The legacy single-slot marker file write was removed in this
347
+ // slice: every `setSkillPresence` call flows through the sid-scoped
348
+ // lease + presence-index machinery above. The legacy
349
+ // `active-skill.json` file is NOT recreated here.
345
350
  // Skill-activation side effect: ensure `.peaks/memory/` and a full-shape
346
351
  // empty `index.json` exist for the project. This is the user-facing fix
347
352
  // for "stock projects never get a memory directory or index". Every peaks
@@ -354,26 +359,17 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
354
359
  return presence;
355
360
  }
356
361
  export function getSkillPresence(projectRootOverride) {
357
- const result = readSkillPresenceBackCompat(projectRootOverride);
358
- if (result === null)
359
- return null;
360
- const { presence, path: presencePath } = result;
361
- if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
362
- const currentSessionId = getCurrentSessionId(projectRootOverride);
363
- if (currentSessionId && presence.sessionId !== currentSessionId) {
364
- try {
365
- unlinkSync(presencePath);
366
- }
367
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
368
- // best effort
369
- }
370
- return null;
371
- }
372
- }
373
- return presence;
362
+ // Slice 4.0.11 statusline-sid-scoped-lease C: read from the
363
+ // canonical sid-scoped lease projection. The legacy
364
+ // `unlinkSync(presencePath)` purge on session-mismatch is
365
+ // removed: the lease projection is keyed off the bound session
366
+ // id, so a mismatch cannot happen — `listPresenceLeases`
367
+ // already filters by the session-id resolved from the workspace
368
+ // binding.
369
+ return readSkillPresenceFromLease(projectRootOverride);
374
370
  }
375
371
  export function checkStalePresence(opts) {
376
- const result = readSkillPresenceBackCompat(opts?.projectRootOverride);
372
+ const result = readSkillPresenceFromLease(opts?.projectRootOverride);
377
373
  // `opts?.currentOuter === undefined` is the omitted-key case. A
378
374
  // falsy string `''` is an explicit "no signal" (used by tests to
379
375
  // simulate a CLI run with no harness env vars).
@@ -389,7 +385,7 @@ export function checkStalePresence(opts) {
389
385
  recordedOuterSessionId: undefined
390
386
  };
391
387
  }
392
- const recorded = result.presence.outerSessionId;
388
+ const recorded = result.outerSessionId;
393
389
  // Suppress false-positives when NEITHER side recorded an outer
394
390
  // session id (legacy project, no harness signal). Two unknowns
395
391
  // are not a swap — they are "no signal available yet".
@@ -399,7 +395,7 @@ export function checkStalePresence(opts) {
399
395
  return {
400
396
  stale: false,
401
397
  reason: null,
402
- presence: result.presence,
398
+ presence: result,
403
399
  currentOuterSessionId: current,
404
400
  recordedOuterSessionId: recorded
405
401
  };
@@ -408,7 +404,7 @@ export function checkStalePresence(opts) {
408
404
  return {
409
405
  stale: false,
410
406
  reason: null,
411
- presence: result.presence,
407
+ presence: result,
412
408
  currentOuterSessionId: current,
413
409
  recordedOuterSessionId: recorded
414
410
  };
@@ -416,7 +412,7 @@ export function checkStalePresence(opts) {
416
412
  return {
417
413
  stale: true,
418
414
  reason: 'outer-session-mismatch',
419
- presence: result.presence,
415
+ presence: result,
420
416
  currentOuterSessionId: current,
421
417
  recordedOuterSessionId: recorded
422
418
  };
@@ -444,11 +440,11 @@ export function checkStalePresence(opts) {
444
440
  * decide what the "rotated-out" id was.
445
441
  */
446
442
  export function clearStalePresenceOnRotation(opts) {
447
- const result = readSkillPresenceBackCompat(opts.projectRootOverride);
443
+ const result = readSkillPresenceFromLease(opts.projectRootOverride);
448
444
  if (result === null) {
449
445
  return { cleared: false, reason: 'no-presence' };
450
446
  }
451
- const recorded = result.presence.outerSessionId;
447
+ const recorded = result.outerSessionId;
452
448
  const current = opts.currentOuterSessionId;
453
449
  // If the recorded outer id matches the new (current) outer id,
454
450
  // this presence is NOT stale — the user just reconnected from
@@ -483,24 +479,25 @@ export function clearStalePresenceOnRotation(opts) {
483
479
  };
484
480
  }
485
481
  export function touchSkillHeartbeat(projectRootOverride) {
486
- const result = readSkillPresenceBackCompat(projectRootOverride);
487
- if (result === null)
482
+ // Slice 4.0.11 statusline-sid-scoped-lease C: read from the
483
+ // canonical sid-scoped lease projection. The legacy
484
+ // unlink-on-session-mismatch purge is removed: the lease is
485
+ // keyed off the bound session id, so the mismatch path cannot
486
+ // fire (a lease under session A is never returned when the bound
487
+ // session is B).
488
+ const presence = readSkillPresenceFromLease(projectRootOverride);
489
+ if (presence === null)
488
490
  return null;
489
- const { presence, path: presencePath } = result;
490
- if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
491
- const currentSessionId = getCurrentSessionId(projectRootOverride);
492
- if (currentSessionId && presence.sessionId !== currentSessionId) {
493
- try {
494
- unlinkSync(presencePath);
495
- }
496
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
497
- // best effort
498
- }
499
- return null;
500
- }
501
- }
491
+ // Slice 4.0.11 statusline-sid-scoped-lease A: the legacy write to
492
+ // the single-slot marker file was removed. Heartbeat refresh now
493
+ // lives exclusively in `presence-lease-service.setPresenceLease`
494
+ // (the canonical sid-scoped lease). This function remains as a
495
+ // read-only back-compat shim for the 4-B / 4-C sub-slices; callers
496
+ // still receive the in-memory `presence` (with the new
497
+ // `lastHeartbeat` stamped) but the legacy `active-skill.json` file
498
+ // is no longer touched. The canonical lease / presence-index
499
+ // entries are the source of truth for heartbeat freshness.
502
500
  presence.lastHeartbeat = new Date().toISOString();
503
- writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
504
501
  return presence;
505
502
  }
506
503
  export function clearSkillPresence(projectRootOverride) {
@@ -518,12 +515,25 @@ export function clearSkillPresence(projectRootOverride) {
518
515
  // `.peaks/.active-skill.json` so a stale marker from a prior CLI
519
516
  // version cannot resurrect after a fresh `clear`. The canonical
520
517
  // lease + index entries are NOT touched by this shim — they
521
- // remain under the workflow's terminalize lock until
522
- // `terminalizeWorkflow` or the next workflow init reclaims them.
523
- const presencePath = resolvePresencePath(projectRootOverride);
524
- const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
518
+ // Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
519
+ // single-slot presence files (`.peaks/_runtime/active-skill.json`
520
+ // and `.peaks/.active-skill.json`) are removed. The canonical
521
+ // sid-scoped lease projection (under
522
+ // `.peaks/_runtime/<sid>/leases/`) is untouched here — workflow
523
+ // leases route through `terminalizeWorkflow`, NOT this shim.
524
+ //
525
+ // This shim remains as a best-effort stale-file cleanup for
526
+ // projects that still carry the deprecated single-slot file from
527
+ // a pre-4.0.11 CLI version. After one minor release the legacy
528
+ // paths are gone and the loop is a no-op. Returns true when a
529
+ // stale file was actually removed.
530
+ const projectRoot = resolveProjectRoot(projectRootOverride);
531
+ const candidates = [
532
+ resolve(projectRoot, '.peaks', '_runtime', 'active-skill.json'),
533
+ resolve(projectRoot, '.peaks', '.active-skill.json')
534
+ ];
525
535
  let cleared = false;
526
- for (const p of [presencePath, legacyPath]) {
536
+ for (const p of candidates) {
527
537
  if (!existsSync(p))
528
538
  continue;
529
539
  try {
@@ -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,16 +119,23 @@ 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.
126
+ * - `callerId === null` → enumerate `listPresenceLeases` for the
127
+ * canonical session and pick the most recent in-flight lease. Back-compat
128
+ * for non-IDE callers (e.g. legacy CLI invocations) that have no callerId.
129
+ *
130
+ * No fallback to `.peaks/_runtime/active-skill.json` (or its legacy path):
131
+ * the canonical lease projection is the single source of truth. When no
132
+ * in-flight leases exist, the read returns `{ presence: null, invalid: false }`
133
+ * and the renderer falls back to the idle state.
121
134
  */
122
135
  function readPresenceReadOnly(projectRoot, callerId) {
123
136
  if (callerId !== null) {
124
137
  try {
125
- const resolution = resolveActiveSkillForCaller(projectRoot, { legacyPresence: true, callerId });
138
+ const resolution = resolveActiveSkillForCaller(projectRoot, { callerId });
126
139
  if (resolution.source === 'none' || resolution.skill === null) {
127
140
  return { presence: null, invalid: false };
128
141
  }
@@ -138,36 +151,55 @@ function readPresenceReadOnly(projectRoot, callerId) {
138
151
  return { presence: null, invalid: true };
139
152
  }
140
153
  }
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)) {
154
+ // callerId === null branch: enumerate the canonical session dir's leases
155
+ // and pick the most recent in-flight lease. This is the back-compat path
156
+ // for non-IDE callers that don't supply a callerId.
157
+ let sessionId = null;
158
+ try {
159
+ sessionId = getSessionIdCanonical(projectRoot);
160
+ }
161
+ catch {
162
+ return { presence: null, invalid: false };
163
+ }
164
+ if (sessionId === null) {
146
165
  return { presence: null, invalid: false };
147
166
  }
167
+ let leases = [];
148
168
  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
- };
169
+ leases = listPresenceLeases(projectRoot, sessionId);
167
170
  }
168
171
  catch {
169
172
  return { presence: null, invalid: true };
170
173
  }
174
+ const inFlight = leases
175
+ .filter((l) => l.status === 'preparing' || l.status === 'running')
176
+ .filter((l) => typeof l.skill === 'string' && l.skill.length > 0);
177
+ if (inFlight.length === 0) {
178
+ return { presence: null, invalid: false };
179
+ }
180
+ // Most recent wins — sort by `lastHeartbeat` desc, fall back to `startedAt`.
181
+ const sorted = inFlight.slice().sort((a, b) => {
182
+ const hb = b.lastHeartbeat.localeCompare(a.lastHeartbeat);
183
+ if (hb !== 0)
184
+ return hb;
185
+ return b.startedAt.localeCompare(a.startedAt);
186
+ });
187
+ const latest = sorted[0];
188
+ if (latest === undefined || typeof latest.skill !== 'string' || latest.skill.length === 0) {
189
+ return { presence: null, invalid: false };
190
+ }
191
+ // Leases on disk may carry an optional `mode` field (the lease constructor
192
+ // spreads `input.mode` when present); the typed `SkillPresenceLease` does
193
+ // not declare it, so widen the read shape here.
194
+ const latestMode = latest.mode;
195
+ return {
196
+ presence: {
197
+ skill: latest.skill,
198
+ ...(typeof latestMode === 'string' && latestMode.length > 0 ? { mode: latestMode } : {}),
199
+ setAt: latest.startedAt,
200
+ },
201
+ invalid: false,
202
+ };
171
203
  }
172
204
  export function buildStatusLineModel(stdin, nowMs) {
173
205
  const cwd = resolveCwdFromStdin(stdin);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.11",
3
+ "version": "4.0.12",
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-mut": "0.1.16",
104
+ "peaks-loop-shared-channel": "0.0.20",
105
+ "peaks-loop-shared": "0.0.43"
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
+ ```