peaks-loop 4.0.17 → 4.0.18
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 +14 -0
- package/dist/cli/commands/primer-command.d.ts +59 -0
- package/dist/cli/commands/primer-command.js +160 -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/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 +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.18 — 2026-08-10 (statusline 24h overlay)
|
|
4
|
+
|
|
5
|
+
**Bug fix — statusline doesn't reflect 24h mode substate after transition**:
|
|
6
|
+
- `src/services/skills/skill-statusline-service.ts`: adds `read24hOverlay` helper (name-distinct from canonical `src/services/24h-mode/store.ts:108 read24hState` so the two readers with divergent null vs empty-snapshot semantics are not confused); adds `TwentyFourHourOverlay` type (minimal `{ state: string }`); adds `twentyFourHourState: TwentyFourHourOverlay | null` field to `StatusLineModel`; populates the field in all 5 return paths of `buildStatusLineModel` (lines for projectRoot-null, invalid-presence, idle, outer-mismatch idle, and final active/stale — only the active branch calls `read24hOverlay`).
|
|
7
|
+
- `src/services/skills/skill-statusline-renderer.ts`: adds `format24hSuffix(overlay, palette, capability, noColor)` exported helper; extends `renderActive` signature to a 7th arg `twentyFourHourState`; appends `[24h-<state.toLowerCase()>]` suffix in 3 active-return branches (attention-gate / activeLeaf / normal); updates the call site at line 822.
|
|
8
|
+
- 12 new vitest cases in `tests/unit/skills/skill-statusline-sid-only-marker.test.ts` (3 service-layer `read24hOverlay` + 1 `buildStatusLineModel` integration + 3 `format24hSuffix` helper-level + 4 renderer-integration AC-1..AC-4 + 1 malformed-shape hardening).
|
|
9
|
+
- 5 typed-fixture sites updated to include `twentyFourHourState: null` (`tests/unit/services/skills/skill-statusline-renderer.test.ts` 5 factories + `tests/unit/skills/skill-statusline-sid-only-marker.test.ts` 3 directly-constructed literals).
|
|
10
|
+
|
|
11
|
+
**Safety semantics preserved**:
|
|
12
|
+
- v2.15.0 `presence:check-stale --project . --json` still returns `stale: true` on outer-mismatch. `skill-presence-service.ts` / `presence-lease-service.ts` / `workspace-service.ts` / `session/**` / `audit/**` / `src/services/24h-mode/**` / `src/cli/commands/session-24h-mode.ts` are FORBIDDEN files in this slice — zero edits.
|
|
13
|
+
- No new dependencies. No enum/API/command-surface changes.
|
|
14
|
+
|
|
15
|
+
**Migration**: zero changes required for existing users. Active 24h mode sessions automatically pick up the new `[24h-<state>]` suffix on next statusline render after the user runs `peaks session 24h-mode transition`. Sessions without a 24h-state.json file render unchanged (back-compat).
|
|
16
|
+
|
|
3
17
|
## 4.0.17 — 2026-08-07 (Vitest worker cap + statusline SIGTERM + 9-slice shipped)
|
|
4
18
|
|
|
5
19
|
**Core fix — vitest worker concurrency cap (root-cause)**:
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice rid-statusline-stale-ux AC-2: `peaks session primer --project <path>`.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight SessionStart primer that fires rotation + presence
|
|
5
|
+
* cleanup BEFORE the first statusline render of a fresh session.
|
|
6
|
+
* Independent subcommand (NOT a flag on `peaks workspace init` per
|
|
7
|
+
* task regulation: "don't add a flag to workspace init"). Cleaner
|
|
8
|
+
* permission boundary — primer cannot be mistaken for init by users.
|
|
9
|
+
*
|
|
10
|
+
* Performs only 3 things:
|
|
11
|
+
* 1. ensureSessionWithRotation(projectRoot) — the real rotation
|
|
12
|
+
* entry point (verified at
|
|
13
|
+
* src/services/session/session-binding-bridge.ts:461-527).
|
|
14
|
+
* 2. If outer-mismatch rotation occurred: call
|
|
15
|
+
* clearStalePresenceOnRotation with the verified single-options-
|
|
16
|
+
* object signature (mirrors init-command.ts:324-329 pattern with
|
|
17
|
+
* all 3 fields populated). Note: on 4.0.11-A sid-scoped leases
|
|
18
|
+
* this is a legacy-compat no-op; the real cleanup mechanism is
|
|
19
|
+
* rotation itself (rebinding changes the bound sid so the
|
|
20
|
+
* reader looks in the new sid's empty lease dir).
|
|
21
|
+
* 3. gcStalePresenceLeases({ projectRoot, trigger: 'manual' }) —
|
|
22
|
+
* sync; documented as legacy-compat no-op on 4.0.11-A sid-scoped
|
|
23
|
+
* data.
|
|
24
|
+
*
|
|
25
|
+
* SKIPS (per RD §4.2.1):
|
|
26
|
+
* - bootstrapProjectScan
|
|
27
|
+
* - materializeClaudeSettingsLocal
|
|
28
|
+
* - resolveFirstTimeHooksInstall
|
|
29
|
+
* - applyHookInstall (prevents accidental first-launch hook install)
|
|
30
|
+
*
|
|
31
|
+
* Mounted as a CHILD of the existing `session` commander group at
|
|
32
|
+
* `src/cli/commands/core/session-command.ts:32` (verified pattern:
|
|
33
|
+
* `session.command('list')...`). NOT `program.command('session primer')`.
|
|
34
|
+
*
|
|
35
|
+
* The action handler is exported as `runPrimerAction` so the
|
|
36
|
+
* integration test suite can drive it directly without going through
|
|
37
|
+
* Commander's argv-parsing wrapper (which would mangle empty /
|
|
38
|
+
* whitespace / NUL-byte inputs before reaching the action body).
|
|
39
|
+
*/
|
|
40
|
+
import type { Command } from 'commander';
|
|
41
|
+
import type { ProgramIO } from '../cli-helpers.js';
|
|
42
|
+
export type PrimerOptions = {
|
|
43
|
+
project: string;
|
|
44
|
+
json?: boolean;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Pure action body for `peaks session primer`. Exported so
|
|
48
|
+
* integration tests can drive it directly with crafted inputs (empty
|
|
49
|
+
* string, whitespace, NUL byte) that would otherwise be rejected by
|
|
50
|
+
* Commander's argv parser.
|
|
51
|
+
*
|
|
52
|
+
* Returns `{ exitCode: 0 | 1 }` so callers (CLI path or test path)
|
|
53
|
+
* can apply the exit code uniformly. NEVER calls `process.exit` —
|
|
54
|
+
* exits are the responsibility of the CLI wrapper.
|
|
55
|
+
*/
|
|
56
|
+
export declare function runPrimerAction(opts: PrimerOptions, io: ProgramIO): Promise<{
|
|
57
|
+
exitCode: 0 | 1;
|
|
58
|
+
}>;
|
|
59
|
+
export declare function registerPrimerCommand(program: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice rid-statusline-stale-ux AC-2: `peaks session primer --project <path>`.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight SessionStart primer that fires rotation + presence
|
|
5
|
+
* cleanup BEFORE the first statusline render of a fresh session.
|
|
6
|
+
* Independent subcommand (NOT a flag on `peaks workspace init` per
|
|
7
|
+
* task regulation: "don't add a flag to workspace init"). Cleaner
|
|
8
|
+
* permission boundary — primer cannot be mistaken for init by users.
|
|
9
|
+
*
|
|
10
|
+
* Performs only 3 things:
|
|
11
|
+
* 1. ensureSessionWithRotation(projectRoot) — the real rotation
|
|
12
|
+
* entry point (verified at
|
|
13
|
+
* src/services/session/session-binding-bridge.ts:461-527).
|
|
14
|
+
* 2. If outer-mismatch rotation occurred: call
|
|
15
|
+
* clearStalePresenceOnRotation with the verified single-options-
|
|
16
|
+
* object signature (mirrors init-command.ts:324-329 pattern with
|
|
17
|
+
* all 3 fields populated). Note: on 4.0.11-A sid-scoped leases
|
|
18
|
+
* this is a legacy-compat no-op; the real cleanup mechanism is
|
|
19
|
+
* rotation itself (rebinding changes the bound sid so the
|
|
20
|
+
* reader looks in the new sid's empty lease dir).
|
|
21
|
+
* 3. gcStalePresenceLeases({ projectRoot, trigger: 'manual' }) —
|
|
22
|
+
* sync; documented as legacy-compat no-op on 4.0.11-A sid-scoped
|
|
23
|
+
* data.
|
|
24
|
+
*
|
|
25
|
+
* SKIPS (per RD §4.2.1):
|
|
26
|
+
* - bootstrapProjectScan
|
|
27
|
+
* - materializeClaudeSettingsLocal
|
|
28
|
+
* - resolveFirstTimeHooksInstall
|
|
29
|
+
* - applyHookInstall (prevents accidental first-launch hook install)
|
|
30
|
+
*
|
|
31
|
+
* Mounted as a CHILD of the existing `session` commander group at
|
|
32
|
+
* `src/cli/commands/core/session-command.ts:32` (verified pattern:
|
|
33
|
+
* `session.command('list')...`). NOT `program.command('session primer')`.
|
|
34
|
+
*
|
|
35
|
+
* The action handler is exported as `runPrimerAction` so the
|
|
36
|
+
* integration test suite can drive it directly without going through
|
|
37
|
+
* Commander's argv-parsing wrapper (which would mangle empty /
|
|
38
|
+
* whitespace / NUL-byte inputs before reaching the action body).
|
|
39
|
+
*/
|
|
40
|
+
import { printResult } from '../cli-helpers.js';
|
|
41
|
+
import { fail, ok } from 'peaks-loop-shared/result';
|
|
42
|
+
import { resolveCanonicalProjectRootStrict, InvalidProjectRootError } from '../../services/config/config-safety.js';
|
|
43
|
+
import { ensureSessionWithRotation } from '../../services/session/session-manager.js';
|
|
44
|
+
import { clearStalePresenceOnRotation } from '../../services/skills/skill-presence-service.js';
|
|
45
|
+
import { gcStalePresenceLeases } from '../../services/skills/presence-lease-service.js';
|
|
46
|
+
/**
|
|
47
|
+
* Pure action body for `peaks session primer`. Exported so
|
|
48
|
+
* integration tests can drive it directly with crafted inputs (empty
|
|
49
|
+
* string, whitespace, NUL byte) that would otherwise be rejected by
|
|
50
|
+
* Commander's argv parser.
|
|
51
|
+
*
|
|
52
|
+
* Returns `{ exitCode: 0 | 1 }` so callers (CLI path or test path)
|
|
53
|
+
* can apply the exit code uniformly. NEVER calls `process.exit` —
|
|
54
|
+
* exits are the responsibility of the CLI wrapper.
|
|
55
|
+
*/
|
|
56
|
+
export async function runPrimerAction(opts, io) {
|
|
57
|
+
// Explicit empty / whitespace-only guard BEFORE the strict
|
|
58
|
+
// resolver. Commander's requiredOption checks presence, not
|
|
59
|
+
// emptiness; node's `resolve('')` returns cwd (silent
|
|
60
|
+
// fall-through), so we must short-circuit explicitly.
|
|
61
|
+
if (!opts.project || opts.project.trim() === '') {
|
|
62
|
+
printResult(io, fail('session.primer', 'PRIMER_EMPTY_PROJECT', '--project <path> required and must be non-empty', { project: opts.project }, ['Pass a non-empty absolute path to --project']), opts.json);
|
|
63
|
+
return { exitCode: 1 };
|
|
64
|
+
}
|
|
65
|
+
// P1 H1 option A: use NEW strict helper. Throws on NUL /
|
|
66
|
+
// non-canonical / non-existent path. The fail-open
|
|
67
|
+
// `resolveCanonicalProjectRoot` is not safe here because primer
|
|
68
|
+
// is invoked from a SessionStart hook on a path pulled from
|
|
69
|
+
// `${CLAUDE_PROJECT_DIR}` — the env var is user-controlled and
|
|
70
|
+
// may contain path traversal payloads.
|
|
71
|
+
let projectRoot;
|
|
72
|
+
try {
|
|
73
|
+
projectRoot = resolveCanonicalProjectRootStrict(opts.project);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof InvalidProjectRootError) {
|
|
77
|
+
printResult(io, fail('session.primer', `PRIMER_INVALID_PROJECT_ROOT_${error.reason.replace(/-/g, '_').toUpperCase()}`, error.message, { project: opts.project, reason: error.reason }, ['Pass a non-empty absolute canonical path to --project']), opts.json);
|
|
78
|
+
return { exitCode: 1 };
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
// Real rotation entry point (verified
|
|
83
|
+
// src/services/session/session-binding-bridge.ts:461-527).
|
|
84
|
+
// NOT the invented `runRotationCheck` from cycle-2 RD.
|
|
85
|
+
const rotation = await ensureSessionWithRotation(projectRoot);
|
|
86
|
+
const rotationOccurred = rotation.previousSessionId !== null
|
|
87
|
+
&& rotation.rotationReason === 'outer-session-mismatch';
|
|
88
|
+
let clearOutcome = null;
|
|
89
|
+
if (rotationOccurred) {
|
|
90
|
+
// VERIFIED single-options-object signature (see
|
|
91
|
+
// skill-presence-service.ts:584-588). Mirror the real caller
|
|
92
|
+
// pattern at init-command.ts:324-329 with all 3 fields
|
|
93
|
+
// populated. NOTE: on 4.0.11-A sid-scoped leases this is a
|
|
94
|
+
// legacy-compat no-op (clearSkillPresence only unlinks legacy
|
|
95
|
+
// single-slot files; the real cleanup mechanism is rotation
|
|
96
|
+
// itself).
|
|
97
|
+
clearOutcome = clearStalePresenceOnRotation({
|
|
98
|
+
projectRootOverride: projectRoot,
|
|
99
|
+
currentOuterSessionId: process.env.PEAKS_OUTER_SESSION_ID
|
|
100
|
+
?? process.env.CLAUDE_CODE_SESSION_ID,
|
|
101
|
+
rotatedOutSessionId: rotation.previousSessionId
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Sync call (verified presence-lease-service.ts:383-414) —
|
|
105
|
+
// documented as legacy-compat no-op on 4.0.11-A sid-scoped
|
|
106
|
+
// data; iterates `input.leases ?? []` and gets `[]` on the
|
|
107
|
+
// real init-command.ts:463-466 caller.
|
|
108
|
+
const gcOutcome = gcStalePresenceLeases({
|
|
109
|
+
projectRoot,
|
|
110
|
+
trigger: 'manual'
|
|
111
|
+
});
|
|
112
|
+
printResult(io, ok('session.primer', {
|
|
113
|
+
projectRoot,
|
|
114
|
+
sessionId: rotation.sessionId,
|
|
115
|
+
rotationOccurred,
|
|
116
|
+
...(rotation.previousSessionId !== null
|
|
117
|
+
? { previousSessionId: rotation.previousSessionId }
|
|
118
|
+
: {}),
|
|
119
|
+
clearOutcome,
|
|
120
|
+
gcOutcome: {
|
|
121
|
+
removed: gcOutcome.removed,
|
|
122
|
+
retained: gcOutcome.retained
|
|
123
|
+
}
|
|
124
|
+
}), opts.json);
|
|
125
|
+
return { exitCode: 0 };
|
|
126
|
+
}
|
|
127
|
+
export function registerPrimerCommand(program, io) {
|
|
128
|
+
// Reuse the existing `session` commander group registered earlier
|
|
129
|
+
// in `createProgram` (via autoRegisterAllCommands → session-command.ts:32).
|
|
130
|
+
// Creating a new `program.command('session')` here throws
|
|
131
|
+
// `cannot add command 'session' as already have command 'session'`
|
|
132
|
+
// because commander disallows duplicate top-level command names.
|
|
133
|
+
// This regression was caught in publish.yml gate-changeset step
|
|
134
|
+
// (run #134, exit 1) on the first 4.0.18 release attempt.
|
|
135
|
+
const session = program.commands.find((c) => c.name() === 'session');
|
|
136
|
+
if (!session) {
|
|
137
|
+
throw new Error('registerPrimerCommand: session group not registered; ' +
|
|
138
|
+
'autoRegisterAllCommands must run before registerPrimerCommand');
|
|
139
|
+
}
|
|
140
|
+
session
|
|
141
|
+
.command('primer')
|
|
142
|
+
.description('Lightweight SessionStart primer: rotation + presence cleanup. ' +
|
|
143
|
+
'Does NOT bootstrap project scan / materialize settings / install hooks. ' +
|
|
144
|
+
'Idempotent; safe to run on every SessionStart.')
|
|
145
|
+
.requiredOption('--project <path>', 'Project root (must be non-empty canonical path)')
|
|
146
|
+
.option('--json', 'emit a JSON envelope { ok, data } to stdout')
|
|
147
|
+
.action(async (opts) => {
|
|
148
|
+
try {
|
|
149
|
+
const { exitCode } = await runPrimerAction(opts, io);
|
|
150
|
+
if (exitCode !== 0) {
|
|
151
|
+
process.exitCode = exitCode;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
printResult(io, fail('session.primer', 'PRIMER_FAILED', message, { project: opts.project }, ['Verify the project path exists, is writable, and is canonical']), opts.json);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -16,6 +16,7 @@ import { registerCronSchedulerCommand } from './commands/cron-scheduler-commands
|
|
|
16
16
|
import { registerWorkspaceCommands } from './commands/workspace-commands.js';
|
|
17
17
|
import { registerSopCommands } from './commands/sop-commands.js';
|
|
18
18
|
import { registerSkillVisibilityCommand } from './commands/skill-visibility.js';
|
|
19
|
+
import { registerPrimerCommand } from './commands/primer-command.js';
|
|
19
20
|
import { applyRetention, cleanupEccCache } from '../services/log/retention.js';
|
|
20
21
|
import { writeLogEntry, maybeWriteStderr } from '../services/log/logger.js';
|
|
21
22
|
import { printSuperCommandCatalog } from './cli-helpers.js';
|
|
@@ -209,5 +210,12 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
209
210
|
// registerCoreAndArtifactCommands instead of being created twice.
|
|
210
211
|
autoRegisterAllCommands(program, io);
|
|
211
212
|
registerSkillVisibilityCommand(program, repoRoot);
|
|
213
|
+
// Slice rid-statusline-stale-ux AC-2: register `peaks session primer`
|
|
214
|
+
// so it appears in `peaks session --help` for LLM `<TAB>`-discovery.
|
|
215
|
+
// Mounted as a CHILD of the existing `session` commander group
|
|
216
|
+
// (verified at src/cli/commands/core/session-command.ts:32). NOT
|
|
217
|
+
// `program.command('session primer')` (that registers a single
|
|
218
|
+
// literal command name, not a child of the session group).
|
|
219
|
+
registerPrimerCommand(program, io);
|
|
212
220
|
return program;
|
|
213
221
|
}
|
|
@@ -28,6 +28,29 @@ export declare function resolveProjectRootForConfig(startPath: string): string;
|
|
|
28
28
|
* `git rev-parse` exit; both fall through to the heuristic.
|
|
29
29
|
*/
|
|
30
30
|
export declare function resolveCanonicalProjectRoot(startPath: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Slice rid-statusline-stale-ux AC-2 + P1 H1 option A: strict
|
|
33
|
+
* canonicalization for use in trust-boundary paths (e.g.
|
|
34
|
+
* `peaks session primer`).
|
|
35
|
+
*
|
|
36
|
+
* Unlike `resolveCanonicalProjectRoot` (fail-open, returns `start`
|
|
37
|
+
* on any resolution failure), this helper THROWS on:
|
|
38
|
+
* - empty / whitespace-only input
|
|
39
|
+
* - NUL byte in input
|
|
40
|
+
* - non-existent path (realpathSync ENOENT)
|
|
41
|
+
* - non-canonical (symlink) input (when the input path and its
|
|
42
|
+
* realpath differ in a way that suggests the user passed a
|
|
43
|
+
* symlinked path through a security-sensitive surface)
|
|
44
|
+
*
|
|
45
|
+
* Existing fail-open `resolveCanonicalProjectRoot` is retained for
|
|
46
|
+
* back-compat — many callers depend on the fail-open behavior
|
|
47
|
+
* (e.g. `init-command.ts:32`). The strict variant is opt-in.
|
|
48
|
+
*/
|
|
49
|
+
export declare class InvalidProjectRootError extends Error {
|
|
50
|
+
readonly reason: 'empty' | 'nul-byte' | 'non-existent' | 'non-canonical';
|
|
51
|
+
constructor(reason: 'empty' | 'nul-byte' | 'non-existent' | 'non-canonical', input: string);
|
|
52
|
+
}
|
|
53
|
+
export declare function resolveCanonicalProjectRootStrict(startPath: string): string;
|
|
31
54
|
export declare function getProjectConfigPath(projectRoot: string | null): string | null;
|
|
32
55
|
export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
|
|
33
56
|
export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
|
|
@@ -131,6 +131,68 @@ export function resolveCanonicalProjectRoot(startPath) {
|
|
|
131
131
|
}
|
|
132
132
|
return start;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Slice rid-statusline-stale-ux AC-2 + P1 H1 option A: strict
|
|
136
|
+
* canonicalization for use in trust-boundary paths (e.g.
|
|
137
|
+
* `peaks session primer`).
|
|
138
|
+
*
|
|
139
|
+
* Unlike `resolveCanonicalProjectRoot` (fail-open, returns `start`
|
|
140
|
+
* on any resolution failure), this helper THROWS on:
|
|
141
|
+
* - empty / whitespace-only input
|
|
142
|
+
* - NUL byte in input
|
|
143
|
+
* - non-existent path (realpathSync ENOENT)
|
|
144
|
+
* - non-canonical (symlink) input (when the input path and its
|
|
145
|
+
* realpath differ in a way that suggests the user passed a
|
|
146
|
+
* symlinked path through a security-sensitive surface)
|
|
147
|
+
*
|
|
148
|
+
* Existing fail-open `resolveCanonicalProjectRoot` is retained for
|
|
149
|
+
* back-compat — many callers depend on the fail-open behavior
|
|
150
|
+
* (e.g. `init-command.ts:32`). The strict variant is opt-in.
|
|
151
|
+
*/
|
|
152
|
+
export class InvalidProjectRootError extends Error {
|
|
153
|
+
reason;
|
|
154
|
+
constructor(reason, input) {
|
|
155
|
+
super(`Invalid project root (${reason}): "${input}"`);
|
|
156
|
+
this.reason = reason;
|
|
157
|
+
this.name = 'InvalidProjectRootError';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function resolveCanonicalProjectRootStrict(startPath) {
|
|
161
|
+
if (!startPath || startPath.trim() === '') {
|
|
162
|
+
throw new InvalidProjectRootError('empty', startPath);
|
|
163
|
+
}
|
|
164
|
+
if (startPath.indexOf('\0') !== -1) {
|
|
165
|
+
throw new InvalidProjectRootError('nul-byte', startPath);
|
|
166
|
+
}
|
|
167
|
+
const start = resolve(startPath);
|
|
168
|
+
let realStart;
|
|
169
|
+
try {
|
|
170
|
+
realStart = realpathSync(start);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new InvalidProjectRootError('non-existent', startPath);
|
|
174
|
+
}
|
|
175
|
+
if (realStart !== start) {
|
|
176
|
+
// User passed a path through a symlink; reject as non-canonical
|
|
177
|
+
// for trust-boundary entry points.
|
|
178
|
+
throw new InvalidProjectRootError('non-canonical', startPath);
|
|
179
|
+
}
|
|
180
|
+
// Delegate to the existing canonicalization (git root → heuristic)
|
|
181
|
+
// AFTER the strict pre-checks pass.
|
|
182
|
+
const canonical = resolveCanonicalProjectRoot(startPath);
|
|
183
|
+
if (canonical === startPath || canonical === realStart) {
|
|
184
|
+
return canonical;
|
|
185
|
+
}
|
|
186
|
+
// Canonicalization moved the path (e.g. to a git root) — accept it
|
|
187
|
+
// but ensure it also exists.
|
|
188
|
+
try {
|
|
189
|
+
realpathSync(canonical);
|
|
190
|
+
return canonical;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
throw new InvalidProjectRootError('non-existent', startPath);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
134
196
|
function resolveProjectRootFromGit(startPath) {
|
|
135
197
|
// execFileSync (not execSync) so a malicious `startPath` cannot
|
|
136
198
|
// inject argv into the spawned git invocation. The child only
|
|
@@ -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;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
2
3
|
import { findProjectRoot } from '../config/config-safety.js';
|
|
3
4
|
import { decideCompactStatusline } from '../compact-statusline/compact-statusline-service.js';
|
|
4
5
|
import { getSessionIdCanonical } from '../session/session-manager.js';
|
|
@@ -30,6 +31,48 @@ import { readActiveDispatchIndex } from '../dispatch/dispatch-record-writer.js';
|
|
|
30
31
|
* documented back-compat path.
|
|
31
32
|
*/
|
|
32
33
|
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
/**
|
|
35
|
+
* Slice rid-statusline-24h-overlay (2026-08-10): name-distinct from
|
|
36
|
+
* the canonical `read24hState` in `src/services/24h-mode/store.ts:108`.
|
|
37
|
+
* The canonical reader calls `coerceSnapshot` (which throws on
|
|
38
|
+
* malformed shapes via `24H_STATE_INVALID`); this overlay reader
|
|
39
|
+
* returns `null` for ANY malformed shape (per PRD AC-3 — never
|
|
40
|
+
* throw across the statusline boundary).
|
|
41
|
+
*
|
|
42
|
+
* Returns null when:
|
|
43
|
+
* - `projectRoot` or `sessionId` is empty
|
|
44
|
+
* - the file does not exist (ENOENT)
|
|
45
|
+
* - JSON.parse fails (corrupt file)
|
|
46
|
+
* - root is not a non-array object
|
|
47
|
+
* - `state` is missing, non-string, or empty string
|
|
48
|
+
*/
|
|
49
|
+
export function read24hOverlay(projectRoot, sessionId) {
|
|
50
|
+
if (!projectRoot || !sessionId)
|
|
51
|
+
return null;
|
|
52
|
+
const path = join(projectRoot, '.peaks', '_runtime', sessionId, '24h-state.json');
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = readFileSync(path, 'utf8');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// ENOENT, EACCES, EISDIR — all treated as "no overlay"
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// corrupt JSON — graceful null (PRD AC-3)
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
70
|
+
return null;
|
|
71
|
+
const obj = parsed;
|
|
72
|
+
if (typeof obj['state'] !== 'string' || obj['state'].length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
return { state: obj['state'] };
|
|
75
|
+
}
|
|
33
76
|
function resolveCwdFromStdin(stdin) {
|
|
34
77
|
const fromWorkspace = stdin?.workspace?.current_dir ?? stdin?.workspace?.project_dir;
|
|
35
78
|
if (typeof fromWorkspace === 'string' && fromWorkspace.length > 0) {
|
|
@@ -234,16 +277,16 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
234
277
|
}
|
|
235
278
|
}
|
|
236
279
|
if (projectRoot === null) {
|
|
237
|
-
return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null, sessionId: null };
|
|
280
|
+
return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null, sessionId: null, twentyFourHourState: null };
|
|
238
281
|
}
|
|
239
282
|
// callerId resolves the read-side isolation; back-compat is `null`.
|
|
240
283
|
const callerId = resolveCallerId(stdin);
|
|
241
284
|
const { presence, invalid } = readPresenceReadOnly(projectRoot, callerId);
|
|
242
285
|
if (invalid) {
|
|
243
|
-
return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
|
|
286
|
+
return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId, twentyFourHourState: null };
|
|
244
287
|
}
|
|
245
288
|
if (presence === null) {
|
|
246
|
-
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
|
|
289
|
+
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId, twentyFourHourState: null };
|
|
247
290
|
}
|
|
248
291
|
// Session binding: when the presence was stamped with a Claude session id and
|
|
249
292
|
// the live session (from stdin) is a different one, the recorded skill belongs
|
|
@@ -252,7 +295,7 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
252
295
|
// fall back to the time-based behavior below for backward compatibility.
|
|
253
296
|
const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
|
|
254
297
|
if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
|
|
255
|
-
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
|
|
298
|
+
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId, twentyFourHourState: null };
|
|
256
299
|
}
|
|
257
300
|
const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
|
|
258
301
|
const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
|
|
@@ -268,7 +311,14 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
268
311
|
catch {
|
|
269
312
|
activeLeaf = null;
|
|
270
313
|
}
|
|
271
|
-
|
|
314
|
+
// Slice rid-statusline-24h-overlay (2026-08-10): read the 24h-mode
|
|
315
|
+
// overlay snapshot ONLY when state === 'active'. Stale / idle /
|
|
316
|
+
// invalid-presence / idle-via-outer-mismatch never carry the suffix
|
|
317
|
+
// (per PRD §Non-goals.6 — "不在 idle / stale / invalid-presence
|
|
318
|
+
// 状态下 overlay 24h suffix"). When projectRoot or sessionId is
|
|
319
|
+
// missing, return null without re-reading disk.
|
|
320
|
+
const twentyFourHourState = state === 'active' ? read24hOverlay(projectRoot, sessionId ?? '') : null;
|
|
321
|
+
return { state, projectRoot, presence, ageMs, compact, activeLeaf, sessionId, twentyFourHourState };
|
|
272
322
|
}
|
|
273
323
|
/**
|
|
274
324
|
* Read-only compact state resolver. Resolves the canonical session id for the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-loop",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.18",
|
|
4
4
|
"description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"keywords": [
|
|
@@ -101,9 +101,9 @@
|
|
|
101
101
|
"headroom-ai": "0.22.4",
|
|
102
102
|
"yaml": "^2.9.0",
|
|
103
103
|
"zod": "^3.25.76",
|
|
104
|
-
"peaks-loop-mut": "0.1.
|
|
105
|
-
"peaks-loop-shared
|
|
106
|
-
"peaks-loop-shared": "0.0.
|
|
104
|
+
"peaks-loop-mut": "0.1.21",
|
|
105
|
+
"peaks-loop-shared": "0.0.49",
|
|
106
|
+
"peaks-loop-shared-channel": "0.0.25"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@changesets/cli": "2.31.1",
|