peaks-loop 4.0.17 → 4.0.19
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 +59 -0
- package/dist/cli/commands/doctor/invoke-from-code.d.ts +10 -0
- package/dist/cli/commands/doctor/invoke-from-code.js +30 -0
- package/dist/cli/commands/primer-command.d.ts +59 -0
- package/dist/cli/commands/primer-command.js +160 -0
- package/dist/cli/commands/sub-agent/detached.d.ts +29 -0
- package/dist/cli/commands/sub-agent/detached.js +60 -0
- package/dist/cli/commands/vendor-detect.d.ts +10 -0
- package/dist/cli/commands/vendor-detect.js +16 -0
- package/dist/cli/program.js +8 -0
- package/dist/services/config/config-safety.d.ts +23 -0
- package/dist/services/config/config-safety.js +62 -0
- package/dist/services/dispatch/dispatch-record-writer.d.ts +86 -9
- package/dist/services/dispatch/dispatch-record-writer.js +58 -10
- package/dist/services/skills/hooks-settings-service.js +21 -2
- package/dist/services/skills/{outer-cache-hook-constants.d.ts → session-start-hook-constants.d.ts} +20 -0
- package/dist/services/skills/{outer-cache-hook-constants.js → session-start-hook-constants.js} +20 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +50 -1
- package/dist/services/skills/skill-statusline-renderer.js +96 -8
- package/dist/services/skills/skill-statusline-service.d.ts +41 -0
- package/dist/services/skills/skill-statusline-service.js +56 -6
- package/package.json +5 -4
- package/skills/bee/peaks-qa/SKILL.md +4 -0
- package/skills/bee/peaks-rd/SKILL.md +4 -0
- package/skills/peaks-code/SKILL.md +3 -0
- package/skills/peaks-code/references/sub-agent-dispatch.md +16 -0
|
@@ -34,16 +34,13 @@ export interface DispatchRecord {
|
|
|
34
34
|
* defaulted to `null` on read so v3 records upgrade cleanly.
|
|
35
35
|
*/
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
* is
|
|
39
|
-
* `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* in this file. The literal type ('3.1') is the source of
|
|
43
|
-
* truth for "this record is v3.1-form"; readers check
|
|
44
|
-
* `version === '3.1'` for forward-compatible dispatching.
|
|
37
|
+
* Phase A Task 8: schema bumped to v4.1.0 (additive). The bump
|
|
38
|
+
* is purely additive — new fields (`mode`, `vendor`,
|
|
39
|
+
* `autoCompactEvents`, `tokenUsage`) all default safely on
|
|
40
|
+
* read for legacy v4.0.0 / v3.2 / v3.1 / v3 / v2 / v1 records.
|
|
41
|
+
* No existing field semantics changed.
|
|
45
42
|
*/
|
|
46
|
-
readonly version: '4.
|
|
43
|
+
readonly version: '4.1.0';
|
|
47
44
|
readonly createdAt: string;
|
|
48
45
|
readonly completedAt: string | null;
|
|
49
46
|
readonly outcome: DispatchOutcome;
|
|
@@ -136,6 +133,49 @@ export interface DispatchRecord {
|
|
|
136
133
|
readonly workflowId: string | null;
|
|
137
134
|
readonly graphNodeId: string | null;
|
|
138
135
|
readonly graphRef: string | null;
|
|
136
|
+
/**
|
|
137
|
+
* Phase A Task 8: dispatch execution mode. `in-process` is the
|
|
138
|
+
* current behavior (LLM-side runner, no separate OS process).
|
|
139
|
+
* `detached` is the new path: `peaks sub-agent dispatch --mode
|
|
140
|
+
* detached` spawns a real child OS process running a different
|
|
141
|
+
* LLM vendor (claude / codex / copilot) and reports back via
|
|
142
|
+
* the dispatch record. v4.1.0 is the additive bump; legacy v4.0.0
|
|
143
|
+
* records upgrade to `in-process` on read.
|
|
144
|
+
*/
|
|
145
|
+
readonly mode: 'in-process' | 'detached';
|
|
146
|
+
/**
|
|
147
|
+
* Phase A Task 8: vendor id when `mode='detached'`. `null` when
|
|
148
|
+
* the dispatch is in-process. Reserved for future use; current
|
|
149
|
+
* detached sub-agents use `claude` but the schema also accepts
|
|
150
|
+
* `codex` and `copilot` for the vendor-neutral adapter layer.
|
|
151
|
+
*/
|
|
152
|
+
readonly vendor: 'claude' | 'codex' | 'copilot' | null;
|
|
153
|
+
/**
|
|
154
|
+
* Phase A Task 8: G8 autoCompact events accumulated by the child
|
|
155
|
+
* LLM during a detached run. Each event records the threshold
|
|
156
|
+
* that fired (0.85 = first warning, 0.95 = second warning) plus
|
|
157
|
+
* token counts before/after. Empty for in-process dispatches
|
|
158
|
+
* and for legacy records upgraded on read.
|
|
159
|
+
*/
|
|
160
|
+
readonly autoCompactEvents: ReadonlyArray<{
|
|
161
|
+
readonly at: number;
|
|
162
|
+
readonly threshold: '0.85' | '0.95';
|
|
163
|
+
readonly tokensBefore: number;
|
|
164
|
+
readonly tokensAfter: number;
|
|
165
|
+
readonly scratchFile?: string;
|
|
166
|
+
}>;
|
|
167
|
+
/**
|
|
168
|
+
* Phase A Task 8: G8 token-usage accounting for detached sub-agents.
|
|
169
|
+
* Detached runs have "unlimited spend but recorded" semantics —
|
|
170
|
+
* the cost is recorded for audit but not enforced. `null` when
|
|
171
|
+
* the dispatch is in-process (no detached accounting) or for
|
|
172
|
+
* legacy records upgraded on read.
|
|
173
|
+
*/
|
|
174
|
+
readonly tokenUsage: {
|
|
175
|
+
readonly promptTokens: number;
|
|
176
|
+
readonly completionTokens: number;
|
|
177
|
+
readonly totalCostUsd?: number;
|
|
178
|
+
} | null;
|
|
139
179
|
}
|
|
140
180
|
/** Input for the initial write. */
|
|
141
181
|
export type WriteInitialDispatchInput = {
|
|
@@ -172,6 +212,43 @@ export type WriteInitialDispatchInput = {
|
|
|
172
212
|
workflowId?: string | null;
|
|
173
213
|
graphNodeId?: string | null;
|
|
174
214
|
graphRef?: string | null;
|
|
215
|
+
/**
|
|
216
|
+
* Phase A Task 8: dispatch execution mode. Default `'in-process'`
|
|
217
|
+
* preserves the current LLM-side runner behavior. `'detached'`
|
|
218
|
+
* triggers the new real-OS-process path via
|
|
219
|
+
* `peaks sub-agent dispatch --mode detached`.
|
|
220
|
+
*/
|
|
221
|
+
mode?: 'in-process' | 'detached';
|
|
222
|
+
/**
|
|
223
|
+
* Phase A Task 8: vendor id when `mode='detached'`. Required by
|
|
224
|
+
* the adapter layer to know which CLI / runtime to spawn. The
|
|
225
|
+
* schema accepts the three vendors peaks-loop has adapters for
|
|
226
|
+
* (claude / codex / copilot). Ignored when `mode='in-process'`.
|
|
227
|
+
*/
|
|
228
|
+
vendor?: 'claude' | 'codex' | 'copilot';
|
|
229
|
+
/**
|
|
230
|
+
* Phase A Task 8: G8 autoCompact events accumulated by the child
|
|
231
|
+
* LLM. Optional on input — most dispatches start with an empty
|
|
232
|
+
* array and the detached runner appends events as they fire.
|
|
233
|
+
*/
|
|
234
|
+
autoCompactEvents?: Array<{
|
|
235
|
+
at: number;
|
|
236
|
+
threshold: '0.85' | '0.95';
|
|
237
|
+
tokensBefore: number;
|
|
238
|
+
tokensAfter: number;
|
|
239
|
+
scratchFile?: string;
|
|
240
|
+
}>;
|
|
241
|
+
/**
|
|
242
|
+
* Phase A Task 8: G8 token-usage accounting. Detached runs
|
|
243
|
+
* record spend for audit (unlimited, but persisted). Optional
|
|
244
|
+
* on input; the detached runner fills this in as it streams
|
|
245
|
+
* usage from the vendor API.
|
|
246
|
+
*/
|
|
247
|
+
tokenUsage?: {
|
|
248
|
+
promptTokens: number;
|
|
249
|
+
completionTokens: number;
|
|
250
|
+
totalCostUsd?: number;
|
|
251
|
+
};
|
|
175
252
|
};
|
|
176
253
|
/** Heartbeat write input. */
|
|
177
254
|
export type AppendHeartbeatInput = {
|
|
@@ -86,7 +86,7 @@ export function writeInitialDispatchRecord(input) {
|
|
|
86
86
|
function buildInitialDispatchRecord(input, now) {
|
|
87
87
|
const { role, requestId, sessionId, prompt, toolCall, batchId } = input;
|
|
88
88
|
return {
|
|
89
|
-
version: '4.
|
|
89
|
+
version: '4.1.0',
|
|
90
90
|
createdAt: now().toISOString(),
|
|
91
91
|
completedAt: null,
|
|
92
92
|
outcome: 'no-execution',
|
|
@@ -141,6 +141,22 @@ function buildInitialDispatchRecord(input, now) {
|
|
|
141
141
|
workflowId: typeof input.workflowId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(input.workflowId) ? input.workflowId : null,
|
|
142
142
|
graphNodeId: typeof input.graphNodeId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(input.graphNodeId) ? input.graphNodeId : null,
|
|
143
143
|
graphRef: typeof input.graphRef === 'string' && input.graphRef.length > 0 ? input.graphRef : null,
|
|
144
|
+
// Phase A Task 8: detached sub-agent mode (default in-process).
|
|
145
|
+
mode: input.mode === 'detached' ? 'detached' : 'in-process',
|
|
146
|
+
vendor: input.vendor === 'claude' || input.vendor === 'codex' || input.vendor === 'copilot' ? input.vendor : null,
|
|
147
|
+
autoCompactEvents: Array.isArray(input.autoCompactEvents)
|
|
148
|
+
? input.autoCompactEvents.filter((e) => typeof e?.at === 'number' &&
|
|
149
|
+
(e?.threshold === '0.85' || e?.threshold === '0.95') &&
|
|
150
|
+
typeof e?.tokensBefore === 'number' &&
|
|
151
|
+
typeof e?.tokensAfter === 'number')
|
|
152
|
+
: [],
|
|
153
|
+
tokenUsage: typeof input.tokenUsage === 'object' && input.tokenUsage !== null && typeof input.tokenUsage.promptTokens === 'number' && typeof input.tokenUsage.completionTokens === 'number'
|
|
154
|
+
? {
|
|
155
|
+
promptTokens: input.tokenUsage.promptTokens,
|
|
156
|
+
completionTokens: input.tokenUsage.completionTokens,
|
|
157
|
+
...(typeof input.tokenUsage.totalCostUsd === 'number' ? { totalCostUsd: input.tokenUsage.totalCostUsd } : {}),
|
|
158
|
+
}
|
|
159
|
+
: null,
|
|
144
160
|
};
|
|
145
161
|
}
|
|
146
162
|
function activeDispatchIndexPath(projectRoot, sessionId) {
|
|
@@ -663,18 +679,19 @@ function upgradeRecord(parsed) {
|
|
|
663
679
|
throw new Error('Dispatch record root must be an object');
|
|
664
680
|
}
|
|
665
681
|
const obj = parsed;
|
|
666
|
-
// Slice 4.0.8: 3.2 → 4.0.0 schema bump.
|
|
667
|
-
// to '4.
|
|
668
|
-
// accepted transparently and
|
|
682
|
+
// Slice 4.0.8: 3.2 → 4.0.0 schema bump. Phase A Task 8: 4.0.0 → 4.1.0
|
|
683
|
+
// (additive). The literal type narrows to '4.1.0' but legacy v4.0.0 /
|
|
684
|
+
// v3.2 / v3.1 / 3 / 2 / 1 records are accepted transparently and
|
|
685
|
+
// upgraded on read.
|
|
669
686
|
const rawVersion = obj.version;
|
|
670
|
-
if (rawVersion !== '4.0.0' && rawVersion !== '3.2' && rawVersion !== '3.1' && rawVersion !== 3 && rawVersion !== 2 && rawVersion !== 1) {
|
|
671
|
-
throw new Error(`Dispatch record version mismatch: expected '4.0.0', '3.2', '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
|
|
672
|
-
'The v1 → v4.
|
|
687
|
+
if (rawVersion !== '4.1.0' && rawVersion !== '4.0.0' && rawVersion !== '3.2' && rawVersion !== '3.1' && rawVersion !== 3 && rawVersion !== 2 && rawVersion !== 1) {
|
|
688
|
+
throw new Error(`Dispatch record version mismatch: expected '4.1.0', '4.0.0', '3.2', '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
|
|
689
|
+
'The v1 → v4.1.0 migration is in-file; records from much older or newer builds must be regenerated.');
|
|
673
690
|
}
|
|
674
691
|
const legacy = parseUpgradeRecordLegacyFields(obj);
|
|
675
692
|
const migration = parseUpgradeRecordMigrationFields(obj);
|
|
676
693
|
return {
|
|
677
|
-
version: '4.
|
|
694
|
+
version: '4.1.0',
|
|
678
695
|
createdAt: legacy.createdAt,
|
|
679
696
|
completedAt: legacy.completedAt,
|
|
680
697
|
outcome: legacy.outcome,
|
|
@@ -697,7 +714,16 @@ function upgradeRecord(parsed) {
|
|
|
697
714
|
mergeBackAttempts: migration.mergeBackAttempts,
|
|
698
715
|
workflowId: migration.workflowId,
|
|
699
716
|
graphNodeId: migration.graphNodeId,
|
|
700
|
-
graphRef: migration.graphRef
|
|
717
|
+
graphRef: migration.graphRef,
|
|
718
|
+
// Phase A Task 8: detached sub-agent fields. Legacy records
|
|
719
|
+
// (pre-4.1.0) default mode='in-process', vendor=null,
|
|
720
|
+
// autoCompactEvents=[], tokenUsage=null. See
|
|
721
|
+
// parseUpgradeRecordMigrationFields for the per-field
|
|
722
|
+
// validation rules.
|
|
723
|
+
mode: migration.mode,
|
|
724
|
+
vendor: migration.vendor,
|
|
725
|
+
autoCompactEvents: migration.autoCompactEvents,
|
|
726
|
+
tokenUsage: migration.tokenUsage
|
|
701
727
|
};
|
|
702
728
|
}
|
|
703
729
|
/**
|
|
@@ -812,7 +838,29 @@ function parseUpgradeRecordMigrationFields(obj) {
|
|
|
812
838
|
// fields to `null` so a legacy record upgrades transparently.
|
|
813
839
|
workflowId: typeof obj.workflowId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(obj.workflowId) ? obj.workflowId : null,
|
|
814
840
|
graphNodeId: typeof obj.graphNodeId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(obj.graphNodeId) ? obj.graphNodeId : null,
|
|
815
|
-
graphRef: typeof obj.graphRef === 'string' ? obj.graphRef : null
|
|
841
|
+
graphRef: typeof obj.graphRef === 'string' ? obj.graphRef : null,
|
|
842
|
+
// Phase A Task 8: 4.0.0 → 4.1.0 migration. Pre-4.1.0 records
|
|
843
|
+
// have no mode / vendor / autoCompactEvents / tokenUsage
|
|
844
|
+
// fields. Default to safe in-process / null / [] / null so
|
|
845
|
+
// legacy records upgrade transparently without breaking
|
|
846
|
+
// consumers (e.g. the dashboard, the merge-back-runner).
|
|
847
|
+
mode: obj.mode === 'detached' ? 'detached' : 'in-process',
|
|
848
|
+
vendor: obj.vendor === 'claude' || obj.vendor === 'codex' || obj.vendor === 'copilot' ? obj.vendor : null,
|
|
849
|
+
autoCompactEvents: Array.isArray(obj.autoCompactEvents)
|
|
850
|
+
? obj.autoCompactEvents.filter((e) => typeof e?.at === 'number' &&
|
|
851
|
+
(e?.threshold === '0.85' || e?.threshold === '0.95') &&
|
|
852
|
+
typeof e?.tokensBefore === 'number' &&
|
|
853
|
+
typeof e?.tokensAfter === 'number')
|
|
854
|
+
: [],
|
|
855
|
+
tokenUsage: typeof obj.tokenUsage === 'object' && obj.tokenUsage !== null && typeof obj.tokenUsage.promptTokens === 'number' && typeof obj.tokenUsage.completionTokens === 'number'
|
|
856
|
+
? {
|
|
857
|
+
promptTokens: obj.tokenUsage.promptTokens,
|
|
858
|
+
completionTokens: obj.tokenUsage.completionTokens,
|
|
859
|
+
...(typeof obj.tokenUsage.totalCostUsd === 'number'
|
|
860
|
+
? { totalCostUsd: obj.tokenUsage.totalCostUsd }
|
|
861
|
+
: {}),
|
|
862
|
+
}
|
|
863
|
+
: null
|
|
816
864
|
};
|
|
817
865
|
}
|
|
818
866
|
function isObject(v) {
|
|
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import { assertSafeSettingsFile } from '../ide/shared/safe-path.js';
|
|
5
5
|
import { atomicWriteJson, readJsonObjectFile } from '../ide/shared/atomic-json.js';
|
|
6
6
|
import { getAdapter } from '../ide/ide-registry.js';
|
|
7
|
-
import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL } from './
|
|
7
|
+
import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
|
|
8
8
|
// --- Module-level defaults (claude-code) -----------------------------------
|
|
9
9
|
// These exports remain for backward compat — tests and downstream callers
|
|
10
10
|
// that only care about Claude Code can keep importing them. The per-IDE
|
|
@@ -211,6 +211,20 @@ function resolveHookEntries(ide, _skipProgress = false) {
|
|
|
211
211
|
command: HOOK_OUTER_CACHE_COMMAND,
|
|
212
212
|
event: HOOK_OUTER_CACHE_EVENT
|
|
213
213
|
});
|
|
214
|
+
// Slice rid-statusline-stale-ux AC-2: SessionStart outer-cache
|
|
215
|
+
// write is followed by `peaks session primer` entry so that
|
|
216
|
+
// rotation + presence cleanup fire BEFORE the first statusline
|
|
217
|
+
// render of a fresh session. The new subcommand is idempotent
|
|
218
|
+
// and short-circuits when the binding already matches — safe to
|
|
219
|
+
// call on every SessionStart. Order matters conceptually:
|
|
220
|
+
// outer-cache write must come first (the primer's
|
|
221
|
+
// ensureSessionWithRotation reads the current outer session id).
|
|
222
|
+
entries.push({
|
|
223
|
+
sentinel: HOOK_WORKSPACE_INIT_SENTINEL,
|
|
224
|
+
matcher: '',
|
|
225
|
+
command: HOOK_WORKSPACE_INIT_COMMAND,
|
|
226
|
+
event: HOOK_WORKSPACE_INIT_EVENT
|
|
227
|
+
});
|
|
214
228
|
// Slice 2026-08-06-codegate-vendor-neutral: code-gate entry on
|
|
215
229
|
// Edit|Write|MultiEdit matcher. Vendor-neutral command
|
|
216
230
|
// (`peaks code-gate --json`); the CLI adapter lives in
|
|
@@ -242,7 +256,12 @@ function resolveLegacySentinels(ide) {
|
|
|
242
256
|
// the rest of the peaks-managed entries.
|
|
243
257
|
const base = [HOOK_ENFORCE_SENTINEL, LEGACY_PROGRESS_START_SENTINEL, HOOK_CODE_GATE_SENTINEL];
|
|
244
258
|
if (ide === 'claude-code') {
|
|
245
|
-
|
|
259
|
+
// Slice rid-statusline-stale-ux AC-2: include the SessionStart
|
|
260
|
+
// workspace-init primer sentinel so uninstall strips it alongside
|
|
261
|
+
// the gate-enforce entry, and so hand-added entries matching this
|
|
262
|
+
// sentinel are recognized as peaks-managed (not stripped as
|
|
263
|
+
// non-Peaks).
|
|
264
|
+
return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL];
|
|
246
265
|
}
|
|
247
266
|
return base;
|
|
248
267
|
}
|
package/dist/services/skills/{outer-cache-hook-constants.d.ts → session-start-hook-constants.d.ts}
RENAMED
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* Only Claude Code supports SessionStart today; future adapters opt
|
|
16
16
|
* in by extending `resolveHookEntries` and adding an adapter-driven
|
|
17
17
|
* substitution for the `${...}` project-dir placeholder.
|
|
18
|
+
*
|
|
19
|
+
* Slice rid-statusline-stale-ux AC-2: renamed from
|
|
20
|
+
* `outer-cache-hook-constants.ts` (per RD §10 M-3) and extended with
|
|
21
|
+
* the SessionStart workspace-init primer constants. The primer fires
|
|
22
|
+
* `peaks session primer --project <path>` immediately after the
|
|
23
|
+
* outer-cache write so rotation + presence cleanup run BEFORE the
|
|
24
|
+
* first statusline render of a fresh session.
|
|
18
25
|
*/
|
|
19
26
|
/** Sentinel substring identifying a SessionStart outer-cache hook entry. */
|
|
20
27
|
export declare const HOOK_OUTER_CACHE_SENTINEL = "peaks outer-cache write";
|
|
@@ -22,3 +29,16 @@ export declare const HOOK_OUTER_CACHE_SENTINEL = "peaks outer-cache write";
|
|
|
22
29
|
export declare const HOOK_OUTER_CACHE_COMMAND = "peaks outer-cache write --project \"${CLAUDE_PROJECT_DIR}\"";
|
|
23
30
|
/** SessionStart hook event key for Claude Code. */
|
|
24
31
|
export declare const HOOK_OUTER_CACHE_EVENT = "SessionStart";
|
|
32
|
+
/**
|
|
33
|
+
* Slice rid-statusline-stale-ux AC-2: SessionStart workspace-init
|
|
34
|
+
* primer sentinel. Identifies the SessionStart entry that runs
|
|
35
|
+
* `peaks session primer --project <path>` so rotation + presence
|
|
36
|
+
* cleanup fire on every fresh session, before the first statusline
|
|
37
|
+
* render of that session. The primer is idempotent and short-
|
|
38
|
+
* circuits when the binding already matches.
|
|
39
|
+
*/
|
|
40
|
+
export declare const HOOK_WORKSPACE_INIT_SENTINEL = "peaks session primer";
|
|
41
|
+
/** SessionStart hook command for the primer. */
|
|
42
|
+
export declare const HOOK_WORKSPACE_INIT_COMMAND = "peaks session primer --project \"${CLAUDE_PROJECT_DIR}\"";
|
|
43
|
+
/** SessionStart hook event key (same as outer-cache). */
|
|
44
|
+
export declare const HOOK_WORKSPACE_INIT_EVENT = "SessionStart";
|
package/dist/services/skills/{outer-cache-hook-constants.js → session-start-hook-constants.js}
RENAMED
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* Only Claude Code supports SessionStart today; future adapters opt
|
|
16
16
|
* in by extending `resolveHookEntries` and adding an adapter-driven
|
|
17
17
|
* substitution for the `${...}` project-dir placeholder.
|
|
18
|
+
*
|
|
19
|
+
* Slice rid-statusline-stale-ux AC-2: renamed from
|
|
20
|
+
* `outer-cache-hook-constants.ts` (per RD §10 M-3) and extended with
|
|
21
|
+
* the SessionStart workspace-init primer constants. The primer fires
|
|
22
|
+
* `peaks session primer --project <path>` immediately after the
|
|
23
|
+
* outer-cache write so rotation + presence cleanup run BEFORE the
|
|
24
|
+
* first statusline render of a fresh session.
|
|
18
25
|
*/
|
|
19
26
|
/** Sentinel substring identifying a SessionStart outer-cache hook entry. */
|
|
20
27
|
export const HOOK_OUTER_CACHE_SENTINEL = 'peaks outer-cache write';
|
|
@@ -22,3 +29,16 @@ export const HOOK_OUTER_CACHE_SENTINEL = 'peaks outer-cache write';
|
|
|
22
29
|
export const HOOK_OUTER_CACHE_COMMAND = `peaks outer-cache write --project "\${CLAUDE_PROJECT_DIR}"`;
|
|
23
30
|
/** SessionStart hook event key for Claude Code. */
|
|
24
31
|
export const HOOK_OUTER_CACHE_EVENT = 'SessionStart';
|
|
32
|
+
/**
|
|
33
|
+
* Slice rid-statusline-stale-ux AC-2: SessionStart workspace-init
|
|
34
|
+
* primer sentinel. Identifies the SessionStart entry that runs
|
|
35
|
+
* `peaks session primer --project <path>` so rotation + presence
|
|
36
|
+
* cleanup fire on every fresh session, before the first statusline
|
|
37
|
+
* render of that session. The primer is idempotent and short-
|
|
38
|
+
* circuits when the binding already matches.
|
|
39
|
+
*/
|
|
40
|
+
export const HOOK_WORKSPACE_INIT_SENTINEL = 'peaks session primer';
|
|
41
|
+
/** SessionStart hook command for the primer. */
|
|
42
|
+
export const HOOK_WORKSPACE_INIT_COMMAND = `peaks session primer --project "\${CLAUDE_PROJECT_DIR}"`;
|
|
43
|
+
/** SessionStart hook event key (same as outer-cache). */
|
|
44
|
+
export const HOOK_WORKSPACE_INIT_EVENT = 'SessionStart';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StatusLineModel } from './skill-statusline-service.js';
|
|
1
|
+
import type { StatusLineModel, TwentyFourHourOverlay } from './skill-statusline-service.js';
|
|
2
2
|
import { computeRootSuffix as computeRootSuffixImpl } from './skill-statusline-sid-suffix.js';
|
|
3
3
|
export { formatShortSid } from './skill-statusline-sid-suffix.js';
|
|
4
4
|
export declare const computeRootSuffix: typeof computeRootSuffixImpl;
|
|
@@ -17,6 +17,55 @@ export type StatusLineCapability = 'ansi-unicode' | 'unicode' | 'ascii';
|
|
|
17
17
|
export interface StatusLineRenderOptions {
|
|
18
18
|
readonly capability: StatusLineCapability;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Glyph palette per capability. Status glyphs (active / idle / warning),
|
|
22
|
+
* separators (· / › / ▸ / ASCII variants), and the diagnostic marker
|
|
23
|
+
* (`presence unreadable`). Unicode glyphs use widely-supported characters;
|
|
24
|
+
* ASCII keeps byte-identical shape across encodings.
|
|
25
|
+
*
|
|
26
|
+
* Compact glyphs are the unicode quadrant marks ◐◑◒◓, the check ✓, and the
|
|
27
|
+
* ballot ✕. ASCII uses bracket/plus/star/x. Bar characters are █/░ in
|
|
28
|
+
* unicode and #/- in ASCII. Ratios are joined with `·` (` . ` for ASCII)
|
|
29
|
+
* and the before→after arrow is `→` (-> for ASCII).
|
|
30
|
+
*/
|
|
31
|
+
interface StatusPalette {
|
|
32
|
+
readonly active: string;
|
|
33
|
+
readonly idleStale: string;
|
|
34
|
+
readonly idle: string;
|
|
35
|
+
readonly warning: string;
|
|
36
|
+
readonly inlineSeparator: string;
|
|
37
|
+
readonly trailSeparator: string;
|
|
38
|
+
readonly idleLabel: string;
|
|
39
|
+
readonly invalidMessage: string;
|
|
40
|
+
readonly compact: CompactPalette;
|
|
41
|
+
readonly barFilled: string;
|
|
42
|
+
readonly barEmpty: string;
|
|
43
|
+
readonly ratioArrow: string;
|
|
44
|
+
}
|
|
45
|
+
interface CompactPalette {
|
|
46
|
+
readonly queued: string;
|
|
47
|
+
readonly preparing: string;
|
|
48
|
+
readonly compacting: string;
|
|
49
|
+
readonly verifying: string;
|
|
50
|
+
readonly completed: string;
|
|
51
|
+
readonly failed: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function formatHumanAge(ageMs: number | null): string;
|
|
54
|
+
/**
|
|
55
|
+
* Slice rid-statusline-24h-overlay (2026-08-10): format the 24h-mode
|
|
56
|
+
* overlay suffix appended after the existing `<baseMode>` token in
|
|
57
|
+
* the ACTIVE state. Returns `''` when the overlay is `null` (missing
|
|
58
|
+
* file / corrupt file / wrong shape — see `read24hOverlay`).
|
|
59
|
+
*
|
|
60
|
+
* Output shape (ASCII palette): ` . [24h-<lowercase-state>]`
|
|
61
|
+
* Output shape (unicode palette): ` · [24h-<lowercase-state>]`
|
|
62
|
+
*
|
|
63
|
+
* en-US only; i18n deferred to a future slice (PRD §Non-goals.5).
|
|
64
|
+
* The `[24h-...]` prefix is a deliberate marker so the user can
|
|
65
|
+
* visually distinguish a 24h-mode substate from a base-mode token
|
|
66
|
+
* like `[full-auto]`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function format24hSuffix(overlay: TwentyFourHourOverlay | null, palette: StatusPalette, capability: StatusLineCapability, noColor: boolean): string;
|
|
20
69
|
export declare function resolveStatusLineCapability(input: {
|
|
21
70
|
readonly env: NodeJS.ProcessEnv;
|
|
22
71
|
readonly isTTY: boolean;
|
|
@@ -108,6 +108,12 @@ function buildPalette(capability, noColor) {
|
|
|
108
108
|
return {
|
|
109
109
|
active: '*',
|
|
110
110
|
idle: 'o',
|
|
111
|
+
// Slice rid-statusline-stale-ux AC-1: stale residue uses the same
|
|
112
|
+
// glyph as `idle` (`o`) but stays static (no slow-blink — the user
|
|
113
|
+
// is reading a *previous* session's residue, not a live idle state).
|
|
114
|
+
// The neutral copy `(previous session · N days ago)` is the
|
|
115
|
+
// decisive signal; the glyph is auxiliary.
|
|
116
|
+
idleStale: 'o',
|
|
111
117
|
warning,
|
|
112
118
|
inlineSeparator: ' . ',
|
|
113
119
|
trailSeparator: ' -> ',
|
|
@@ -125,6 +131,11 @@ function buildPalette(capability, noColor) {
|
|
|
125
131
|
return {
|
|
126
132
|
active: brandGlyph('●'),
|
|
127
133
|
idle: blinkBrand('○'),
|
|
134
|
+
// Slice rid-statusline-stale-ux AC-1: muted slate (`#AAAAC8` dim)
|
|
135
|
+
// — slow-blink OFF, distinct from `idle` (slow-blink brand) and
|
|
136
|
+
// `warning` (yellow invalid-presence). The stale branch is
|
|
137
|
+
// semantically "previous-session residue" (neutral), NOT an error.
|
|
138
|
+
idleStale: dimBrand('○'),
|
|
128
139
|
warning,
|
|
129
140
|
inlineSeparator: brand(' · '),
|
|
130
141
|
trailSeparator: brand(' → '),
|
|
@@ -181,6 +192,10 @@ function paletteFor(capability, noColor) {
|
|
|
181
192
|
return buildPalette(capability ?? DEFAULT_CAPABILITY, noColor);
|
|
182
193
|
}
|
|
183
194
|
function formatAge(ageMs) {
|
|
195
|
+
// Slice rid-statusline-stale-ux AC-1: this legacy `stale <N>h/m`
|
|
196
|
+
// token is NO LONGER used by renderStale. Kept exported / defined
|
|
197
|
+
// for any downstream caller / test that still asserts against the
|
|
198
|
+
// `stale` substring. The active renderer path uses `formatHumanAge`.
|
|
184
199
|
if (ageMs === null)
|
|
185
200
|
return '';
|
|
186
201
|
const hours = Math.round(ageMs / (60 * 60 * 1000));
|
|
@@ -189,11 +204,69 @@ function formatAge(ageMs) {
|
|
|
189
204
|
const minutes = Math.max(1, Math.round(ageMs / (60 * 1000)));
|
|
190
205
|
return `stale ${minutes}m`;
|
|
191
206
|
}
|
|
207
|
+
// Slice rid-statusline-stale-ux AC-1 + perf H2: human-friendly neutral
|
|
208
|
+
// age label for the stale branch. en-US strings per RD §5 R1 (codebase
|
|
209
|
+
// consistency); zh-CN deferred to a future i18n slice. Backed by a
|
|
210
|
+
// bounded Map cache (parity with `formatShortSid` memoization at
|
|
211
|
+
// `skill-statusline-sid-suffix.ts`).
|
|
212
|
+
const HUMAN_AGE_CACHE = new Map();
|
|
213
|
+
const HUMAN_AGE_CACHE_LIMIT = 16;
|
|
214
|
+
export function formatHumanAge(ageMs) {
|
|
215
|
+
if (ageMs === null)
|
|
216
|
+
return 'unknown';
|
|
217
|
+
const cached = HUMAN_AGE_CACHE.get(ageMs);
|
|
218
|
+
if (cached !== undefined)
|
|
219
|
+
return cached;
|
|
220
|
+
let result;
|
|
221
|
+
if (ageMs < 5 * 60 * 1000) {
|
|
222
|
+
result = 'just now';
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const minutes = Math.round(ageMs / (60 * 1000));
|
|
226
|
+
if (minutes < 60) {
|
|
227
|
+
result = `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const hours = Math.round(ageMs / (60 * 60 * 1000));
|
|
231
|
+
if (hours < 24) {
|
|
232
|
+
result = `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const days = Math.round(ageMs / (24 * 60 * 60 * 1000));
|
|
236
|
+
result = `${days} day${days === 1 ? '' : 's'} ago`;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (HUMAN_AGE_CACHE.size >= HUMAN_AGE_CACHE_LIMIT)
|
|
241
|
+
HUMAN_AGE_CACHE.clear();
|
|
242
|
+
HUMAN_AGE_CACHE.set(ageMs, result);
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
192
245
|
function rootLabel(projectRoot) {
|
|
193
246
|
if (!projectRoot)
|
|
194
247
|
return '';
|
|
195
248
|
return basename(projectRoot);
|
|
196
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Slice rid-statusline-24h-overlay (2026-08-10): format the 24h-mode
|
|
252
|
+
* overlay suffix appended after the existing `<baseMode>` token in
|
|
253
|
+
* the ACTIVE state. Returns `''` when the overlay is `null` (missing
|
|
254
|
+
* file / corrupt file / wrong shape — see `read24hOverlay`).
|
|
255
|
+
*
|
|
256
|
+
* Output shape (ASCII palette): ` . [24h-<lowercase-state>]`
|
|
257
|
+
* Output shape (unicode palette): ` · [24h-<lowercase-state>]`
|
|
258
|
+
*
|
|
259
|
+
* en-US only; i18n deferred to a future slice (PRD §Non-goals.5).
|
|
260
|
+
* The `[24h-...]` prefix is a deliberate marker so the user can
|
|
261
|
+
* visually distinguish a 24h-mode substate from a base-mode token
|
|
262
|
+
* like `[full-auto]`.
|
|
263
|
+
*/
|
|
264
|
+
export function format24hSuffix(overlay, palette, capability, noColor) {
|
|
265
|
+
if (!overlay)
|
|
266
|
+
return '';
|
|
267
|
+
const label = `[24h-${overlay.state.toLowerCase()}]`;
|
|
268
|
+
return `${palette.inlineSeparator}${brandRun(label, noColor, capability)}`;
|
|
269
|
+
}
|
|
197
270
|
/**
|
|
198
271
|
* `formatShortSid` + `computeRootSuffix` were extracted to
|
|
199
272
|
* `./skill-statusline-sid-suffix.ts` in the slice
|
|
@@ -223,14 +296,19 @@ function rootLabel(projectRoot) {
|
|
|
223
296
|
* forced every bee role to render with a `↑<parent>` marker was removed
|
|
224
297
|
* in this slice; the dual-skill layout above replaces it.
|
|
225
298
|
*/
|
|
226
|
-
function renderActive(presence, palette, nowMs, capability, noColor, activeLeaf) {
|
|
299
|
+
function renderActive(presence, palette, nowMs, capability, noColor, activeLeaf, twentyFourHourState) {
|
|
227
300
|
if (!presence) {
|
|
228
301
|
return `${palette.idle} ${palette.idleLabel}`;
|
|
229
302
|
}
|
|
303
|
+
// Slice rid-statusline-24h-overlay (2026-08-10): the 24h suffix
|
|
304
|
+
// is appended on every active-return branch that carries a skill
|
|
305
|
+
// token. The `!presence` branch (idle mark, no skill token) is
|
|
306
|
+
// intentionally NOT modified — 24h overlays are active-only.
|
|
307
|
+
const suffix = format24hSuffix(twentyFourHourState, palette, capability, noColor);
|
|
230
308
|
const attentionLabel = isAttentionGate(presence.gate);
|
|
231
309
|
if (attentionLabel !== null) {
|
|
232
310
|
// Attention gate — surface the warning glyph + human-readable label.
|
|
233
|
-
return `${palette.warning} ${brandRun(presence.skill, noColor, capability)}${palette.inlineSeparator}${brandRun(attentionLabel, noColor, capability)}`;
|
|
311
|
+
return `${palette.warning} ${brandRun(presence.skill, noColor, capability)}${palette.inlineSeparator}${brandRun(attentionLabel, noColor, capability)}${suffix}`;
|
|
234
312
|
}
|
|
235
313
|
const skill = presence.skill;
|
|
236
314
|
const dot = renderActiveDot(capability, nowMs, noColor);
|
|
@@ -244,15 +322,25 @@ function renderActive(presence, palette, nowMs, capability, noColor, activeLeaf)
|
|
|
244
322
|
? ` ${brandRun(`(+${activeLeaf.pendingCount - 1})`, noColor, capability)}`
|
|
245
323
|
: '';
|
|
246
324
|
const sep = brandRun(' | ', noColor, capability);
|
|
247
|
-
return `${dot} ${leaf}${tail}${sep}${brandRun(skill, noColor, capability)}${modeToken}`;
|
|
325
|
+
return `${dot} ${leaf}${tail}${sep}${brandRun(skill, noColor, capability)}${modeToken}${suffix}`;
|
|
248
326
|
}
|
|
249
|
-
return `${dot} ${brandRun(skill, noColor, capability)}${modeToken}`;
|
|
327
|
+
return `${dot} ${brandRun(skill, noColor, capability)}${modeToken}${suffix}`;
|
|
250
328
|
}
|
|
251
329
|
function renderStale(presence, ageMs, palette, capability, noColor) {
|
|
330
|
+
// Slice rid-statusline-stale-ux AC-1: stale presence belongs to a
|
|
331
|
+
// *previous* session (outer-session-mismatch). Line still emits the
|
|
332
|
+
// recorded skill name (per C-1 — `expect(out).toContain('peaks-code')`
|
|
333
|
+
// must keep passing), but uses `palette.idleStale` (new muted palette,
|
|
334
|
+
// distinct from idle and warning) and neutral
|
|
335
|
+
// `(previous session · <human age>)` suffix instead of legacy
|
|
336
|
+
// `stale <Nh>` token. ASCII capability replaces the `·` middle dot
|
|
337
|
+
// with `-` so byte-identical shape holds across encodings.
|
|
252
338
|
const skill = presence?.skill ?? 'unknown';
|
|
253
|
-
const age =
|
|
254
|
-
const
|
|
255
|
-
|
|
339
|
+
const age = formatHumanAge(ageMs);
|
|
340
|
+
const sep = capability === 'ascii' ? '-' : '·';
|
|
341
|
+
const label = age ? `(previous session ${sep} ${age})` : '(previous session)';
|
|
342
|
+
const ageSuffix = `${palette.inlineSeparator}${brandRun(label, noColor, capability)}`;
|
|
343
|
+
return `${palette.idleStale} ${brandRun(skill, noColor, capability)}${ageSuffix}`;
|
|
256
344
|
}
|
|
257
345
|
function renderInvalid(palette) {
|
|
258
346
|
return `${palette.warning} ${palette.invalidMessage}`;
|
|
@@ -623,7 +711,7 @@ export function renderStatusLine(model, options, env) {
|
|
|
623
711
|
else {
|
|
624
712
|
switch (model.state) {
|
|
625
713
|
case 'active':
|
|
626
|
-
line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf)}${rootSuffix}`;
|
|
714
|
+
line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf, model.twentyFourHourState)}${rootSuffix}`;
|
|
627
715
|
break;
|
|
628
716
|
case 'stale':
|
|
629
717
|
line = `${brand} ${renderStale(model.presence, model.ageMs, palette, capability, noColor)}${rootSuffix}`;
|
|
@@ -41,6 +41,47 @@ export type StatusLineModel = {
|
|
|
41
41
|
* existent root.
|
|
42
42
|
*/
|
|
43
43
|
sessionId: string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Slice rid-statusline-24h-overlay (2026-08-10): the 24h-mode
|
|
46
|
+
* overlay snapshot read from `.peaks/_runtime/<sid>/24h-state.json`,
|
|
47
|
+
* or `null` when no file exists / file is corrupt / file has wrong
|
|
48
|
+
* shape. The renderer reads this to append `[24h-<state>]` after
|
|
49
|
+
* the existing `<baseMode>` token in the ACTIVE state only.
|
|
50
|
+
*
|
|
51
|
+
* Always present (never undefined). `null` means "no overlay" —
|
|
52
|
+
* the renderer skips the suffix without re-reading disk.
|
|
53
|
+
*/
|
|
54
|
+
twentyFourHourState: TwentyFourHourOverlay | null;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Slice rid-statusline-24h-overlay (2026-08-10): minimal overlay
|
|
58
|
+
* type returned by `read24hOverlay`. The renderer only consumes
|
|
59
|
+
* `state` (to format `[24h-<state.toLowerCase()>]`). The canonical
|
|
60
|
+
* schema at `src/services/24h-mode/state.ts:55-66` carries additional
|
|
61
|
+
* fields (`attempts: Record<DecisionKey, number>`, `enteredAt`,
|
|
62
|
+
* `checkpoints`, etc.) but the overlay is deliberately MINIMAL —
|
|
63
|
+
* it tolerates forward compatibility with new states the writer
|
|
64
|
+
* may add, and it never throws on malformed shapes (PRD AC-3:
|
|
65
|
+
* graceful null on any invalid input).
|
|
66
|
+
*/
|
|
67
|
+
export type TwentyFourHourOverlay = {
|
|
68
|
+
state: string;
|
|
44
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* Slice rid-statusline-24h-overlay (2026-08-10): name-distinct from
|
|
72
|
+
* the canonical `read24hState` in `src/services/24h-mode/store.ts:108`.
|
|
73
|
+
* The canonical reader calls `coerceSnapshot` (which throws on
|
|
74
|
+
* malformed shapes via `24H_STATE_INVALID`); this overlay reader
|
|
75
|
+
* returns `null` for ANY malformed shape (per PRD AC-3 — never
|
|
76
|
+
* throw across the statusline boundary).
|
|
77
|
+
*
|
|
78
|
+
* Returns null when:
|
|
79
|
+
* - `projectRoot` or `sessionId` is empty
|
|
80
|
+
* - the file does not exist (ENOENT)
|
|
81
|
+
* - JSON.parse fails (corrupt file)
|
|
82
|
+
* - root is not a non-array object
|
|
83
|
+
* - `state` is missing, non-string, or empty string
|
|
84
|
+
*/
|
|
85
|
+
export declare function read24hOverlay(projectRoot: string, sessionId: string): TwentyFourHourOverlay | null;
|
|
45
86
|
export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
|
|
46
87
|
export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;
|