peaks-loop 4.0.16 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README-en.md +12 -9
  3. package/README.md +15 -4
  4. package/config/eslint/.peaks-rules.cjs +39 -26
  5. package/dist/cli/commands/_register.js +2 -1
  6. package/dist/cli/commands/audit-commands.d.ts +1 -1
  7. package/dist/cli/commands/code-gate-command.d.ts +25 -0
  8. package/dist/cli/commands/code-gate-command.js +86 -0
  9. package/dist/cli/commands/code-mode-gate-commands.js +3 -4
  10. package/dist/cli/commands/code-orchestrator-can-do.js +2 -1
  11. package/dist/cli/commands/cron-commands.js +16 -4
  12. package/dist/cli/commands/dashboard-long-run.js +8 -4
  13. package/dist/cli/commands/dispatch-from-dag.d.ts +1 -1
  14. package/dist/cli/commands/hooks-commands.js +56 -3
  15. package/dist/cli/commands/primer-command.d.ts +59 -0
  16. package/dist/cli/commands/primer-command.js +160 -0
  17. package/dist/cli/commands/qa-commands.js +1 -2
  18. package/dist/cli/commands/sediment-commands.d.ts +1 -1
  19. package/dist/cli/commands/slice-commands.js +20 -8
  20. package/dist/cli/commands/statusline-commands.d.ts +1 -0
  21. package/dist/cli/commands/statusline-commands.js +17 -3
  22. package/dist/cli/commands/tech-commands.d.ts +1 -1
  23. package/dist/cli/program.js +8 -0
  24. package/dist/services/artifacts/request-artifact-service.d.ts +2 -2
  25. package/dist/services/artifacts/request-artifact-service.js +10 -1
  26. package/dist/services/audit/enforcers/lint-reference-shape.js +18 -4
  27. package/dist/services/code/auto-compact-orchestrator.js +9 -1
  28. package/dist/services/code/batch-heartbeat-poller.js +26 -0
  29. package/dist/services/code/orchestrator-can-do.d.ts +24 -0
  30. package/dist/services/code/orchestrator-can-do.js +48 -2
  31. package/dist/services/code/post-compact-detector.d.ts +1 -1
  32. package/dist/services/compact-statusline/compact-statusline-service.js +19 -11
  33. package/dist/services/config/config-safety.d.ts +23 -0
  34. package/dist/services/config/config-safety.js +62 -0
  35. package/dist/services/config/config-service.d.ts +1 -1
  36. package/dist/services/config/config-service.js +2 -4
  37. package/dist/services/container/container-lease.js +21 -8
  38. package/dist/services/context/spillover-store.d.ts +1 -1
  39. package/dist/services/crystallization/crystallization-types.js +20 -8
  40. package/dist/services/crystallization/evidence-brief-builder.js +22 -11
  41. package/dist/services/dispatch/dispatch-record-writer.js +231 -141
  42. package/dist/services/evolution/evolution-types.js +38 -21
  43. package/dist/services/feedback/feedback-promotion-service.js +9 -1
  44. package/dist/services/hooks/pre-tool-code-gate.d.ts +46 -0
  45. package/dist/services/hooks/pre-tool-code-gate.js +91 -0
  46. package/dist/services/ide/current-model-detector.js +1 -2
  47. package/dist/services/lint/detect-eslint.js +8 -5
  48. package/dist/services/lint/eslint-runner.d.ts +1 -2
  49. package/dist/services/lint/eslint-runner.js +86 -23
  50. package/dist/services/lint/npx-resolver.d.ts +6 -0
  51. package/dist/services/lint/npx-resolver.js +47 -0
  52. package/dist/services/loop/loop-release-types.js +32 -13
  53. package/dist/services/loop/spec-service.d.ts +7 -1
  54. package/dist/services/loop/spec-service.js +218 -182
  55. package/dist/services/rd/rd-service.js +22 -9
  56. package/dist/services/scan/archetype-service.js +21 -9
  57. package/dist/services/session/binding-store.js +1 -2
  58. package/dist/services/share/bundle-reader.d.ts +6 -9
  59. package/dist/services/share/bundle-reader.js +297 -223
  60. package/dist/services/skills/hooks-settings-service.d.ts +33 -0
  61. package/dist/services/skills/hooks-settings-service.js +48 -3
  62. package/dist/services/skills/{outer-cache-hook-constants.d.ts → session-start-hook-constants.d.ts} +20 -0
  63. package/dist/services/skills/{outer-cache-hook-constants.js → session-start-hook-constants.js} +20 -0
  64. package/dist/services/skills/skill-statusline-renderer.d.ts +50 -1
  65. package/dist/services/skills/skill-statusline-renderer.js +96 -8
  66. package/dist/services/skills/skill-statusline-service.d.ts +41 -0
  67. package/dist/services/skills/skill-statusline-service.js +56 -6
  68. package/dist/services/slice/calibration-store.js +21 -8
  69. package/dist/services/slice/slice-check-service.js +35 -14
  70. package/dist/services/slice/slice-decompose-service.js +459 -223
  71. package/dist/services/standards/project-context.js +135 -73
  72. package/dist/services/verdict/envelopes.d.ts +1 -2
  73. package/dist/services/verdict/envelopes.js +1 -2
  74. package/dist/services/verdict/verdict-aggregator.d.ts +1 -2
  75. package/dist/services/vm/vm-lease.js +19 -8
  76. package/dist/services/worktree/worktree-lease.d.ts +0 -10
  77. package/dist/services/worktree/worktree-lease.js +19 -8
  78. package/dist/shared/fs-utils.d.ts +1 -1
  79. package/package.json +7 -4
  80. package/skills/peaks-code/SKILL.md +46 -0
@@ -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 './outer-cache-hook-constants.js';
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
@@ -187,6 +187,18 @@ export function readInstalledEntriesFromSettings(settings, ide) {
187
187
  * entry is appended for Claude Code only (SessionStart is a Claude-Code-
188
188
  * specific event key today).
189
189
  */
190
+ /**
191
+ * Slice 2026-08-06-codegate-vendor-neutral — code-gate hook entry.
192
+ * Vendor-neutral command (`peaks code-gate --json`); the CLI adapter
193
+ * lives in `src/cli/commands/code-gate-command.ts` and the decision
194
+ * logic lives in `src/services/hooks/pre-tool-code-gate.ts`. The
195
+ * shell-script sibling (`src/services/hooks/pre-tool-code-gate.sh`)
196
+ * encodes the same logic for non-Node harnesses.
197
+ */
198
+ export const HOOK_CODE_GATE_SENTINEL = 'peaks code-gate';
199
+ export const HOOK_CODE_GATE_MATCHER = 'Edit|Write|MultiEdit';
200
+ export const HOOK_CODE_GATE_EVENT = 'PreToolUse';
201
+ export const HOOK_CODE_GATE_COMMAND = `peaks code-gate --json`;
190
202
  function resolveHookEntries(ide, _skipProgress = false) {
191
203
  const spec = resolveHookSpec(ide);
192
204
  const entries = [
@@ -199,6 +211,31 @@ function resolveHookEntries(ide, _skipProgress = false) {
199
211
  command: HOOK_OUTER_CACHE_COMMAND,
200
212
  event: HOOK_OUTER_CACHE_EVENT
201
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
+ });
228
+ // Slice 2026-08-06-codegate-vendor-neutral: code-gate entry on
229
+ // Edit|Write|MultiEdit matcher. Vendor-neutral command
230
+ // (`peaks code-gate --json`); the CLI adapter lives in
231
+ // `src/cli/commands/code-gate-command.ts` and the underlying
232
+ // decision logic is in `src/services/hooks/pre-tool-code-gate.ts`.
233
+ entries.push({
234
+ sentinel: HOOK_CODE_GATE_SENTINEL,
235
+ matcher: HOOK_CODE_GATE_MATCHER,
236
+ command: HOOK_CODE_GATE_COMMAND,
237
+ event: HOOK_CODE_GATE_EVENT
238
+ });
202
239
  }
203
240
  return entries;
204
241
  }
@@ -214,9 +251,17 @@ function resolveLegacySentinels(ide) {
214
251
  }
215
252
  // Slice 2026-08-06-session-outer-cache: include the SessionStart outer-cache
216
253
  // sentinel so uninstall strips it alongside the gate-enforce entry.
217
- const base = [HOOK_ENFORCE_SENTINEL, LEGACY_PROGRESS_START_SENTINEL];
254
+ // Slice 2026-08-06-codegate-vendor-neutral: also include the code-gate
255
+ // sentinel so uninstall strips the Edit|Write|MultiEdit entry alongside
256
+ // the rest of the peaks-managed entries.
257
+ const base = [HOOK_ENFORCE_SENTINEL, LEGACY_PROGRESS_START_SENTINEL, HOOK_CODE_GATE_SENTINEL];
218
258
  if (ide === 'claude-code') {
219
- return [...base, HOOK_OUTER_CACHE_SENTINEL];
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];
220
265
  }
221
266
  return base;
222
267
  }
@@ -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";
@@ -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 = formatAge(ageMs);
254
- const ageSuffix = age ? `${palette.inlineSeparator}${brandRun(age, noColor, capability)}` : '';
255
- return `${palette.warning} ${brandRun(skill, noColor, capability)}${ageSuffix}`;
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 { resolve } from 'node:path';
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
- return { state, projectRoot, presence, ageMs, compact, activeLeaf, sessionId };
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
@@ -22,6 +22,19 @@
22
22
  * codegraph v0.7.10 does not emit it; v2 can re-enable once
23
23
  * `.codegraph/codegraph.db` is read directly.
24
24
  */
25
+ /**
26
+ * PRD-002b slice 2 — calibration heuristic coefficients + thresholds
27
+ * extracted into named constants so the no-magic-numbers rule stops
28
+ * flagging the weight+threshold math. Values are bytewise-identical to
29
+ * the original literals.
30
+ */
31
+ const LOC_WEIGHT_PER_LINE = 0.25;
32
+ const TEST_WEIGHT_PER_FILE = 0.5;
33
+ const COMPLEXITY_WEIGHT_PER_NODE = 0.1;
34
+ const P90_P50_RATIO = 1.6;
35
+ const HIGH_CONFIDENCE_MIN_SAMPLES = 5;
36
+ const MEDIUM_CONFIDENCE_MIN_SAMPLES = 1;
37
+ const OUTPUT_PRECISION_DECIMALS = 10;
25
38
  /**
26
39
  * Compute a work-estimate envelope.
27
40
  *
@@ -48,20 +61,20 @@ export function calibrate(complexitySum, testCount, locSum, sampleSize) {
48
61
  if (!Number.isInteger(sampleSize) || sampleSize < 0) {
49
62
  throw new RangeError(`calibrate: sampleSize must be a non-negative integer, got ${sampleSize}`);
50
63
  }
51
- const minutesP50 = 0.25 * locSum + 0.5 * testCount + 0.1 * complexitySum;
52
- const minutesP90 = minutesP50 * 1.6;
53
- const confidence = sampleSize >= 5 ? 'high' : sampleSize >= 1 ? 'medium' : 'low';
64
+ const minutesP50 = LOC_WEIGHT_PER_LINE * locSum + TEST_WEIGHT_PER_FILE * testCount + COMPLEXITY_WEIGHT_PER_NODE * complexitySum;
65
+ const minutesP90 = minutesP50 * P90_P50_RATIO;
66
+ const confidence = sampleSize >= HIGH_CONFIDENCE_MIN_SAMPLES ? 'high' : sampleSize >= MEDIUM_CONFIDENCE_MIN_SAMPLES ? 'medium' : 'low';
54
67
  const rationale = sampleSize === 0
55
- ? 'v1 heuristic: 0.25 min/LoC + 0.5 min/test + 0.1 min/complexity; confidence low because no historical sample'
56
- : sampleSize < 5
57
- ? `v1 heuristic: ${sampleSize} historical sample(s) available; will switch to percentile lookup at sampleSize >= 5`
68
+ ? `v1 heuristic: ${LOC_WEIGHT_PER_LINE} min/LoC + ${TEST_WEIGHT_PER_FILE} min/test + ${COMPLEXITY_WEIGHT_PER_NODE} min/complexity; confidence low because no historical sample`
69
+ : sampleSize < HIGH_CONFIDENCE_MIN_SAMPLES
70
+ ? `v1 heuristic: ${sampleSize} historical sample(s) available; will switch to percentile lookup at sampleSize >= ${HIGH_CONFIDENCE_MIN_SAMPLES}`
58
71
  : `v1 heuristic with sampleSize ${sampleSize}; v1.1 will switch to percentile lookup`;
59
72
  return {
60
73
  complexitySum,
61
74
  testCount,
62
75
  locSum,
63
- minutesP50: Math.round(minutesP50 * 10) / 10,
64
- minutesP90: Math.round(minutesP90 * 10) / 10,
76
+ minutesP50: Math.round(minutesP50 * OUTPUT_PRECISION_DECIMALS) / OUTPUT_PRECISION_DECIMALS,
77
+ minutesP90: Math.round(minutesP90 * OUTPUT_PRECISION_DECIMALS) / OUTPUT_PRECISION_DECIMALS,
65
78
  confidence,
66
79
  rationale
67
80
  };