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.
- package/CHANGELOG.md +30 -0
- package/dist/cli/commands/code-commands.js +2 -0
- package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
- package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
- package/dist/cli/commands/workspace/reconcile-command.js +5 -2
- package/dist/services/code/orchestrator-can-do.d.ts +114 -0
- package/dist/services/code/orchestrator-can-do.js +205 -0
- package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
- package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
- package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
- package/dist/services/doctor/doctor-service/types.d.ts +9 -0
- package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
- package/dist/services/hooks/presence-marker-detector.js +45 -85
- package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
- package/dist/services/sc/sc-service.d.ts +11 -7
- package/dist/services/sc/sc-service.js +29 -26
- package/dist/services/session/binding-store.d.ts +6 -6
- package/dist/services/skills/hooks-settings-service.d.ts +1 -1
- package/dist/services/skills/hooks-settings-service.js +1 -1
- package/dist/services/skills/skill-presence-service.d.ts +0 -1
- package/dist/services/skills/skill-presence-service.js +112 -102
- package/dist/services/skills/skill-statusline-service.js +71 -39
- package/package.json +4 -4
- package/skills/peaks-code/SKILL.md +6 -0
- 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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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 =
|
|
177
|
+
const result = readSkillPresenceFromLease(projectRootOverride);
|
|
164
178
|
if (result === null)
|
|
165
179
|
return undefined;
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* by the
|
|
18
|
-
*
|
|
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
|
-
*
|
|
117
|
-
* (
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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, {
|
|
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
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
104
|
-
"peaks-loop-
|
|
105
|
-
"peaks-loop-shared": "0.0.
|
|
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
|
+
```
|