peaks-loop 4.0.4 → 4.0.5

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 (64) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README-en.md +26 -0
  3. package/README.md +26 -0
  4. package/dist/cli/commands/_register.js +3 -0
  5. package/dist/cli/commands/core/skill-command.js +2 -0
  6. package/dist/cli/commands/dispatch-commands.js +41 -2
  7. package/dist/cli/commands/e2e-verify.d.ts +47 -0
  8. package/dist/cli/commands/e2e-verify.js +211 -0
  9. package/dist/cli/commands/gate-commands.js +17 -4
  10. package/dist/cli/commands/statusline-commands.d.ts +6 -0
  11. package/dist/cli/commands/statusline-commands.js +48 -11
  12. package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
  13. package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
  14. package/dist/cli/commands/worktree-auth-commands.js +27 -0
  15. package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
  16. package/dist/services/code/auto-compact-orchestrator.js +358 -41
  17. package/dist/services/code/post-compact-detector.js +12 -4
  18. package/dist/services/code/step-08-gate.js +6 -2
  19. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
  20. package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
  21. package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
  22. package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
  23. package/dist/services/context/auto-compact-reader.js +12 -4
  24. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  25. package/dist/services/context/build-dispatch-system-prompt.js +33 -2
  26. package/dist/services/dispatch/conflict-replay.d.ts +32 -0
  27. package/dist/services/dispatch/conflict-replay.js +44 -0
  28. package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
  29. package/dist/services/dispatch/dispatch-record-writer.js +27 -7
  30. package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
  31. package/dist/services/dispatch/e2e-fixtures.js +72 -0
  32. package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
  33. package/dist/services/dispatch/merge-back-runner.js +83 -0
  34. package/dist/services/dispatch/post-merge.d.ts +21 -0
  35. package/dist/services/dispatch/post-merge.js +13 -0
  36. package/dist/services/dispatch/service-shutdown.d.ts +22 -0
  37. package/dist/services/dispatch/service-shutdown.js +51 -0
  38. package/dist/services/hooks/presence-marker-detector.js +12 -4
  39. package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
  40. package/dist/services/hooks/worktree-authorization-gate.js +22 -0
  41. package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
  42. package/dist/services/skills/skill-statusline-renderer.js +374 -29
  43. package/dist/services/skills/skill-statusline-service.d.ts +2 -0
  44. package/dist/services/skills/skill-statusline-service.js +34 -5
  45. package/dist/services/skills/sync-service.d.ts +3 -0
  46. package/dist/services/skills/sync-service.js +1 -0
  47. package/dist/services/standards/project-context.d.ts +2 -1
  48. package/dist/services/standards/project-context.js +17 -2
  49. package/dist/services/standards/project-standards-service.d.ts +9 -0
  50. package/dist/services/standards/project-standards-service.js +26 -0
  51. package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
  52. package/dist/services/worktree/dispatch-provenance.js +47 -0
  53. package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
  54. package/dist/services/worktree/git-worktree-parser.js +45 -0
  55. package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
  56. package/dist/services/worktree/host-worktree-reconciler.js +39 -0
  57. package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
  58. package/dist/services/worktree/long-path-cleanup.js +38 -0
  59. package/dist/services/worktree/playwright-profile.d.ts +8 -0
  60. package/dist/services/worktree/playwright-profile.js +23 -0
  61. package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
  62. package/dist/services/worktree/worktree-gc-guard.js +38 -0
  63. package/package.json +4 -4
  64. package/scripts/install-skills.mjs +11 -2
@@ -1,6 +1,89 @@
1
1
  import type { StatusLineModel } from './skill-statusline-service.js';
2
2
  /**
3
- * Render the status line. The output is plain text with simple status glyphs so
4
- * it stays readable in any terminal; Claude Code applies its own styling.
3
+ * Pure formatting layer for the Peaks statusLine. Takes the read-only status
4
+ * model and produces the single line Claude Code paints at the bottom of the
5
+ * terminal. Kept separate from the reader so formatting can be tested without
6
+ * touching the filesystem.
7
+ *
8
+ * Capability-aware rendering: three glyph palettes are kept side by side in
9
+ * {@link PALETTES} so the switch from "active" / "idle" / "warning" to a
10
+ * concrete string happens in one place. Adding a fourth capability (e.g. for
11
+ * a future IDE) is a one-table change.
5
12
  */
6
- export declare function renderStatusLine(model: StatusLineModel): string;
13
+ export type StatusLineCapability = 'ansi-unicode' | 'unicode' | 'ascii';
14
+ export interface StatusLineRenderOptions {
15
+ readonly capability: StatusLineCapability;
16
+ }
17
+ /**
18
+ * Resolve the capability tier from environment and TTY state. Pure and
19
+ * deterministic — no I/O beyond reading the caller-supplied `env` and
20
+ * `isTTY` flag. The CLI delegates to this so the read-only status line
21
+ * does not need to know about ANSI/NO_COLOR semantics.
22
+ *
23
+ * The optional `forced` argument is a TEST SEAM (consumed by direct unit
24
+ * tests, not by the CLI). It lets tests pin a tier without constructing a
25
+ * TTY/NO_COLOR fixture. The CLI does NOT expose any flag that maps to
26
+ * `forced` — the env-driven path is the single first-version runtime
27
+ * source of truth.
28
+ *
29
+ * Adapter-internal env override (NOT a user-facing CLI flag):
30
+ * `PEAKS_STATUSLINE_ASCII` — when set to `1` / `true` / `yes`, the
31
+ * renderer drops to the ASCII palette (no Unicode-extra glyphs, no ANSI).
32
+ * This is an adapter-internal mechanism for the trust boundary described
33
+ * by the two-forms-only / human-NL-choice-only tenets: the user never
34
+ * types a CLI verb to flip palettes, but the adapter (and only the
35
+ * adapter) can set the env var on the user's behalf when its consumer
36
+ * (e.g. a tiny terminal without UTF-8) needs ASCII. We do NOT expose
37
+ * this via a CLI flag.
38
+ *
39
+ * Order of resolution (highest priority first):
40
+ *
41
+ * 1. `forced` argument (test seam only)
42
+ * 2. `PEAKS_STATUSLINE_ASCII` set → `ascii` (adapter-internal override)
43
+ * 3. `isTTY === true` → `ansi-unicode`
44
+ * 4. otherwise → `unicode` (still ANSI-colored; only `ascii` is color-free)
45
+ *
46
+ * Why this ordering: the IDE statusline invokes the renderer through a
47
+ * hook that may not always be detected as a TTY. The `unicode` tier
48
+ * now emits the cyan brand accent + semantic colors so the line reads
49
+ * as a branded product surface whether or not the consumer is a
50
+ * terminal. `NO_COLOR` is honored as an ambient convention by the
51
+ * IDE adapter (which can set `PEAKS_STATUSLINE_ASCII=1` to opt out
52
+ * fully), so a separate tier for NO_COLOR is no longer needed.
53
+ *
54
+ * `ascii` is the strict no-color, no-Unicode-extra tier; it is the
55
+ * single safe option for plain log / file consumers.
56
+ *
57
+ * Why `PEAKS_STATUSLINE_ASCII` outranks `NO_COLOR`:
58
+ * The two env vars are NOT redundant. `NO_COLOR` (https://no-color.org)
59
+ * is the cross-industry signal that NO escape sequences should be emitted
60
+ * — its only effect is "ANSI off". The Unicode-extra glyphs (●, █, ░, etc.)
61
+ * are NOT ANSI escape sequences; they are UTF-8 characters and `NO_COLOR`
62
+ * does not address them. `PEAKS_STATUSLINE_ASCII` is the adapter-internal
63
+ * "signal source is a tiny terminal without UTF-8" override and drops the
64
+ * glyphs to ASCII shape as well. Both are correct signals about
65
+ * different concerns; the ASCII override wins because it is strictly
66
+ * narrower (a tiny terminal needs both no-ANSI AND no-Unicode-extra).
67
+ *
68
+ * The deliberate ordering keeps the rendered text ANSI-free when the consumer
69
+ * is a logger, a file, or any non-interactive sink, and only enables ANSI
70
+ * under an explicit supported condition (TTY + no env veto).
71
+ */
72
+ export declare function resolveStatusLineCapability(input: {
73
+ readonly env: NodeJS.ProcessEnv;
74
+ readonly isTTY: boolean;
75
+ readonly forced?: StatusLineCapability;
76
+ }): StatusLineCapability;
77
+ /**
78
+ * Render the status line. Pure — no I/O, no side effects. The output is
79
+ * a single short line suitable for the bottom-of-terminal status bar.
80
+ *
81
+ * The `options.capability` field selects the glyph palette. When omitted,
82
+ * the function falls back to `unicode` (no ANSI escape codes), preserving
83
+ * the legacy behaviour for the CLI's `peaks statusline` invocation.
84
+ *
85
+ * Compact-state precedence: when `model.compact.kind !== 'none'` the
86
+ * compact bar replaces the active/idle/stale content. The C1 baseline
87
+ * line is preserved when the compact state is `none`.
88
+ */
89
+ export declare function renderStatusLine(model: StatusLineModel, options?: StatusLineRenderOptions): string;
@@ -1,11 +1,167 @@
1
1
  import { basename } from 'node:path';
2
2
  /**
3
- * Pure formatting layer for the Peaks statusLine. Takes the read-only status
4
- * model and produces the single line Claude Code paints at the bottom of the
5
- * terminal. Kept separate from the reader so formatting can be tested without
6
- * touching the filesystem.
3
+ * Internal: the brand prefix is plain ASCII so a tiny terminal without
4
+ * monospaced UTF-8 still sees a recognizable string. The mountain glyphs
5
+ * (`⛰` / `🏔`) used in earlier 1.x renders were the loudest a11y regression
6
+ * in the 2026-07-22 ice-cola surface check and are deliberately removed.
7
7
  */
8
- const BRAND = '⛰ Peaks';
8
+ const BRAND = 'Peaks';
9
+ /**
10
+ * Attention-gate allowlist. The brief requires conservative classification:
11
+ * only gate names that encode a blocking condition surface as a warning
12
+ * glyph. Routine progress gates (`startup`, `swarm-fan-out`,
13
+ * `swarm-converged`, `repair-cycle-N`, etc.) stay hidden. The map is
14
+ * explicit — there is no "non-empty gate means alert" inference.
15
+ */
16
+ const ATTENTION_GATE_LABELS = new Map([
17
+ ['qa-validation', 'QA'],
18
+ ['verdict-issued', 'Verdict'],
19
+ ['blocked', 'Blocked'],
20
+ ]);
21
+ function isAttentionGate(gate) {
22
+ if (!gate)
23
+ return null;
24
+ return ATTENTION_GATE_LABELS.get(gate) ?? null;
25
+ }
26
+ /** Brand accent: per project request, this is the slate-purple `#5A65D8`. */
27
+ const BRAND_RGB = '38;2;90;101;216';
28
+ const BRAND_SGR_OPEN = `\x1b[1;${BRAND_RGB}m`;
29
+ const BRAND_SGR_FULL_OPEN = `\x1b[${BRAND_RGB}m`;
30
+ const BRAND_SGR_CLOSE = '\x1b[0m';
31
+ function accent(text) {
32
+ return `${BRAND_SGR_OPEN}${text}${BRAND_SGR_CLOSE}`;
33
+ }
34
+ function accentGlyph(glyph) {
35
+ return `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
36
+ }
37
+ /**
38
+ * Slow-blink variant of the accent glyph. Used for the idle indicator
39
+ * so the user perceives a pulse on the statusline. The ANSI slow-blink
40
+ * SGR (`\x1b[5m`) is honored by most modern terminals (iTerm2, Windows
41
+ * Terminal, GNOME Terminal with the right profile). Hosts that ignore
42
+ * the SGR render the glyph statically — graceful degradation.
43
+ */
44
+ function blinkingAccentGlyph(glyph) {
45
+ return `\x1b[5;1;${BRAND_RGB}m${glyph}\x1b[0m`;
46
+ }
47
+ const PALETTES = {
48
+ 'ansi-unicode': {
49
+ active: accentGlyph('●'),
50
+ idle: blinkingAccentGlyph('○'),
51
+ warning: '\x1b[33m!\x1b[0m', // amber warning (semantic alarm)
52
+ inlineSeparator: ' · ',
53
+ trailSeparator: ' → ',
54
+ idleLabel: 'empty',
55
+ invalidMessage: 'presence unreadable',
56
+ compact: {
57
+ queued: accentGlyph('◐'),
58
+ preparing: accentGlyph('◑'),
59
+ compacting: accentGlyph('◒'),
60
+ verifying: accentGlyph('◓'),
61
+ completed: accentGlyph('✓'),
62
+ failed: '\x1b[31m✕\x1b[0m', // failed is a semantic alarm
63
+ },
64
+ barFilled: '█',
65
+ barEmpty: '░',
66
+ ratioArrow: '→',
67
+ },
68
+ unicode: {
69
+ active: accentGlyph('●'),
70
+ idle: blinkingAccentGlyph('○'),
71
+ warning: '\x1b[33m!\x1b[0m',
72
+ inlineSeparator: ' · ',
73
+ trailSeparator: ' → ',
74
+ idleLabel: 'empty',
75
+ invalidMessage: 'presence unreadable',
76
+ compact: {
77
+ queued: accentGlyph('◐'),
78
+ preparing: accentGlyph('◑'),
79
+ compacting: accentGlyph('◒'),
80
+ verifying: accentGlyph('◓'),
81
+ completed: accentGlyph('✓'),
82
+ failed: '\x1b[31m✕\x1b[0m',
83
+ },
84
+ barFilled: '█',
85
+ barEmpty: '░',
86
+ ratioArrow: '→',
87
+ },
88
+ ascii: {
89
+ active: '*',
90
+ idle: 'o',
91
+ warning: '!',
92
+ inlineSeparator: ' . ',
93
+ trailSeparator: ' -> ',
94
+ idleLabel: 'empty',
95
+ invalidMessage: 'presence unreadable',
96
+ compact: {
97
+ queued: '[',
98
+ preparing: '+',
99
+ compacting: '+',
100
+ verifying: '+',
101
+ completed: '*',
102
+ failed: 'x',
103
+ },
104
+ barFilled: '#',
105
+ barEmpty: '-',
106
+ ratioArrow: '->',
107
+ },
108
+ };
109
+ const BREATHING_GLYPHS_UNICODE = ['●', '◐', '◑', '◒', '◓'];
110
+ const BREATHING_GLYPHS_ASCII = ['*', 'o', '+', '~', '|'];
111
+ const BREATHING_PERIOD_MS = 2_400;
112
+ /**
113
+ * Bee-tier (1-level sub-role) skills and their full orchestrator
114
+ * parent name. The terminal status line shows both layers when a bee
115
+ * is active: the active bee name plus a `↑<parent-full>` marker
116
+ * pointing at the dispatching orchestrator.
117
+ */
118
+ const BEE_TO_PARENT = {
119
+ 'peaks-prd': 'peaks-code',
120
+ 'peaks-rd': 'peaks-code',
121
+ 'peaks-qa': 'peaks-code',
122
+ 'peaks-ui': 'peaks-code',
123
+ 'peaks-sc': 'peaks-code',
124
+ 'peaks-txt': 'peaks-code',
125
+ 'peaks-final-review': 'peaks-code',
126
+ 'peaks-resume': 'peaks-code',
127
+ 'peaks-status': 'peaks-code',
128
+ 'peaks-test': 'peaks-code',
129
+ 'peaks-reviewer': 'peaks-code',
130
+ };
131
+ function pickBreathingGlyph(capability, nowMs) {
132
+ const set = capability === 'ascii' ? BREATHING_GLYPHS_ASCII : BREATHING_GLYPHS_UNICODE;
133
+ const index = Math.floor((nowMs % BREATHING_PERIOD_MS) / (BREATHING_PERIOD_MS / set.length)) % set.length;
134
+ return set[index];
135
+ }
136
+ function renderActiveDot(capability, nowMs) {
137
+ // Brief: the active dot carries the project accent (`#5A65D8` bold)
138
+ // in both colored tiers. The breathing glyph is wrapped in a fresh
139
+ // SGR on every render so the IDE sees a single accent per refresh.
140
+ const glyph = pickBreathingGlyph(capability, nowMs);
141
+ if (capability === 'ascii')
142
+ return glyph;
143
+ return accentGlyph(glyph);
144
+ }
145
+ function brandText(capability) {
146
+ // Brief: brand carries the project accent (`#5A65D8` bold) in both
147
+ // colored tiers. The `ascii` tier stays plain text so plain text
148
+ // consumers (no TTY, no UTF-8) never see escape codes.
149
+ if (capability === 'ansi-unicode' || capability === 'unicode') {
150
+ return accent(BRAND);
151
+ }
152
+ return BRAND;
153
+ }
154
+ /**
155
+ * Default capability when none is supplied. Per the brief: "if backward
156
+ * compatibility requires a renderer default, use unicode, never
157
+ * unconditional ANSI." The CLI's `peaks statusline` invocation currently
158
+ * passes no options; preserving the legacy no-ANSI behaviour means the
159
+ * default is the unicode palette (no escape codes).
160
+ */
161
+ const DEFAULT_CAPABILITY = 'unicode';
162
+ function paletteFor(capability) {
163
+ return PALETTES[capability ?? DEFAULT_CAPABILITY];
164
+ }
9
165
  function formatAge(ageMs) {
10
166
  if (ageMs === null)
11
167
  return '';
@@ -21,35 +177,224 @@ function rootLabel(projectRoot) {
21
177
  return basename(projectRoot);
22
178
  }
23
179
  /**
24
- * Render the status line. The output is plain text with simple status glyphs so
25
- * it stays readable in any terminal; Claude Code applies its own styling.
180
+ * Build the "middle" of the status line — everything between the brand
181
+ * prefix and the project root label. Kept separate so the token layout
182
+ * is obvious at the call site and so each state has a single
183
+ * responsibility.
26
184
  */
27
- export function renderStatusLine(model) {
28
- const root = rootLabel(model.projectRoot);
29
- const rootSuffix = root ? ` · ${root}` : '';
30
- switch (model.state) {
31
- case 'active': {
32
- const presence = model.presence;
33
- if (!presence)
34
- return `${BRAND} ○ idle${rootSuffix}`;
35
- const parts = [presence.skill];
36
- if (presence.mode)
37
- parts.push(presence.mode);
38
- if (presence.gate)
39
- parts.push(`gate:${presence.gate}`);
40
- return `${BRAND} ● ${parts.join(' · ')}${rootSuffix}`;
185
+ function renderActive(presence, palette, nowMs, capability) {
186
+ if (!presence) {
187
+ return `${palette.idle} ${palette.idleLabel}`;
188
+ }
189
+ const attentionLabel = isAttentionGate(presence.gate);
190
+ if (attentionLabel !== null) {
191
+ // Attention gate — surface the warning glyph + human-readable label.
192
+ return `${palette.warning} ${presence.skill}${palette.inlineSeparator}${attentionLabel}`;
193
+ }
194
+ const skill = presence.skill;
195
+ const dot = renderActiveDot(capability, nowMs);
196
+ const beeParent = BEE_TO_PARENT[skill];
197
+ const modeToken = typeof presence.mode === 'string' && presence.mode.length > 0
198
+ ? ` [${presence.mode}]`
199
+ : '';
200
+ // Bee-tier skills surface both layers: the active bee name plus
201
+ // the full parent orchestrator name. Mode is shown for every
202
+ // active skill (not just peaks-code) so the line carries the same
203
+ // mode taxonomy for any bee in flight.
204
+ if (beeParent !== undefined) {
205
+ return `${dot} ${skill} ↑${beeParent}${modeToken}`;
206
+ }
207
+ return `${dot} ${skill}${modeToken}`;
208
+ }
209
+ function renderStale(presence, ageMs, palette) {
210
+ const skill = presence?.skill ?? 'unknown';
211
+ const age = formatAge(ageMs);
212
+ const ageSuffix = age ? `${palette.inlineSeparator}${age}` : '';
213
+ return `${palette.warning} ${skill}${ageSuffix}`;
214
+ }
215
+ function renderInvalid(palette) {
216
+ return `${palette.warning} ${palette.invalidMessage}`;
217
+ }
218
+ function renderIdle(palette) {
219
+ return `${palette.idle} ${palette.idleLabel}`;
220
+ }
221
+ const COMPACT_BAR_WIDTH = 8;
222
+ function renderCompactBar(filledCells, palette) {
223
+ const filled = palette.barFilled.repeat(filledCells);
224
+ const empty = palette.barEmpty.repeat(COMPACT_BAR_WIDTH - filledCells);
225
+ return `[${filled}${empty}]`;
226
+ }
227
+ function formatRatio(value) {
228
+ // Brief ratio labels use 2-decimal precision: 0.87 → "87%".
229
+ return `${Math.round(value * 100)}%`;
230
+ }
231
+ /**
232
+ * Render the compact-progress segment. Compact state REPLACES the skill
233
+ * content while active (`kind !== 'none'`). The renderer always lays out
234
+ *
235
+ * <stage-glyph> <bar> <label>[ · <before>%][ → <after>%]
236
+ *
237
+ * Failed states additionally suffix the stage at which the compact failed.
238
+ * Stalled states keep the active-stage cell count and render a plain
239
+ * "stalled" label. Invalid states surface the read-reason verbatim as a
240
+ * single-line diagnostic so the user can see why the bar is empty.
241
+ */
242
+ function renderCompact(state, palette) {
243
+ switch (state.kind) {
244
+ case 'none':
245
+ return '';
246
+ case 'queued':
247
+ return `${palette.compact.queued} ${renderCompactBar(0, palette)} queued${typeof state.triggerRatio === 'number'
248
+ ? `${palette.inlineSeparator}${formatRatio(state.triggerRatio)}`
249
+ : ''}`;
250
+ case 'preparing':
251
+ return `${palette.compact.preparing} ${renderCompactBar(2, palette)} preparing${typeof state.triggerRatio === 'number'
252
+ ? `${palette.inlineSeparator}${formatRatio(state.triggerRatio)}`
253
+ : ''}`;
254
+ case 'compacting':
255
+ return `${palette.compact.compacting} ${renderCompactBar(4, palette)} compacting${typeof state.triggerRatio === 'number'
256
+ ? `${palette.inlineSeparator}${formatRatio(state.triggerRatio)}`
257
+ : ''}`;
258
+ case 'verifying':
259
+ return `${palette.compact.verifying} ${renderCompactBar(6, palette)} verifying`;
260
+ case 'completed':
261
+ return `${palette.compact.completed} ${renderCompactBar(8, palette)} compacted${typeof state.triggerRatio === 'number'
262
+ ? `${palette.inlineSeparator}${formatRatio(state.triggerRatio)}${typeof state.afterRatio === 'number'
263
+ ? ` ${palette.ratioArrow} ${formatRatio(state.afterRatio)}`
264
+ : ''}`
265
+ : typeof state.afterRatio === 'number'
266
+ ? `${palette.inlineSeparator}${formatRatio(state.afterRatio)}`
267
+ : ''}`;
268
+ case 'failed': {
269
+ const failedAt = state.failedAt ?? 'compacting';
270
+ const filledAt = failedAt === 'queued'
271
+ ? 0
272
+ : failedAt === 'preparing'
273
+ ? 2
274
+ : failedAt === 'compacting'
275
+ ? 4
276
+ : 6;
277
+ return `${palette.compact.failed} ${renderCompactBar(filledAt, palette)} compact failed${palette.inlineSeparator}${failedAt}`;
41
278
  }
42
- case 'stale': {
43
- const presence = model.presence;
44
- const skill = presence?.skill ?? 'unknown';
45
- const age = formatAge(model.ageMs);
46
- const ageSuffix = age ? ` · ${age}` : '';
47
- return `${BRAND} ⚠ ${skill}${ageSuffix}${rootSuffix}`;
279
+ case 'stalled':
280
+ return `${palette.compact.compacting} ${renderCompactBar(state.filledCells, palette)} stalled`;
281
+ case 'invalid': {
282
+ const detail = state.detail ?? 'lifecycle record malformed';
283
+ return `${palette.warning} ${detail}`;
48
284
  }
285
+ }
286
+ }
287
+ /**
288
+ * Resolve the capability tier from environment and TTY state. Pure and
289
+ * deterministic — no I/O beyond reading the caller-supplied `env` and
290
+ * `isTTY` flag. The CLI delegates to this so the read-only status line
291
+ * does not need to know about ANSI/NO_COLOR semantics.
292
+ *
293
+ * The optional `forced` argument is a TEST SEAM (consumed by direct unit
294
+ * tests, not by the CLI). It lets tests pin a tier without constructing a
295
+ * TTY/NO_COLOR fixture. The CLI does NOT expose any flag that maps to
296
+ * `forced` — the env-driven path is the single first-version runtime
297
+ * source of truth.
298
+ *
299
+ * Adapter-internal env override (NOT a user-facing CLI flag):
300
+ * `PEAKS_STATUSLINE_ASCII` — when set to `1` / `true` / `yes`, the
301
+ * renderer drops to the ASCII palette (no Unicode-extra glyphs, no ANSI).
302
+ * This is an adapter-internal mechanism for the trust boundary described
303
+ * by the two-forms-only / human-NL-choice-only tenets: the user never
304
+ * types a CLI verb to flip palettes, but the adapter (and only the
305
+ * adapter) can set the env var on the user's behalf when its consumer
306
+ * (e.g. a tiny terminal without UTF-8) needs ASCII. We do NOT expose
307
+ * this via a CLI flag.
308
+ *
309
+ * Order of resolution (highest priority first):
310
+ *
311
+ * 1. `forced` argument (test seam only)
312
+ * 2. `PEAKS_STATUSLINE_ASCII` set → `ascii` (adapter-internal override)
313
+ * 3. `isTTY === true` → `ansi-unicode`
314
+ * 4. otherwise → `unicode` (still ANSI-colored; only `ascii` is color-free)
315
+ *
316
+ * Why this ordering: the IDE statusline invokes the renderer through a
317
+ * hook that may not always be detected as a TTY. The `unicode` tier
318
+ * now emits the cyan brand accent + semantic colors so the line reads
319
+ * as a branded product surface whether or not the consumer is a
320
+ * terminal. `NO_COLOR` is honored as an ambient convention by the
321
+ * IDE adapter (which can set `PEAKS_STATUSLINE_ASCII=1` to opt out
322
+ * fully), so a separate tier for NO_COLOR is no longer needed.
323
+ *
324
+ * `ascii` is the strict no-color, no-Unicode-extra tier; it is the
325
+ * single safe option for plain log / file consumers.
326
+ *
327
+ * Why `PEAKS_STATUSLINE_ASCII` outranks `NO_COLOR`:
328
+ * The two env vars are NOT redundant. `NO_COLOR` (https://no-color.org)
329
+ * is the cross-industry signal that NO escape sequences should be emitted
330
+ * — its only effect is "ANSI off". The Unicode-extra glyphs (●, █, ░, etc.)
331
+ * are NOT ANSI escape sequences; they are UTF-8 characters and `NO_COLOR`
332
+ * does not address them. `PEAKS_STATUSLINE_ASCII` is the adapter-internal
333
+ * "signal source is a tiny terminal without UTF-8" override and drops the
334
+ * glyphs to ASCII shape as well. Both are correct signals about
335
+ * different concerns; the ASCII override wins because it is strictly
336
+ * narrower (a tiny terminal needs both no-ANSI AND no-Unicode-extra).
337
+ *
338
+ * The deliberate ordering keeps the rendered text ANSI-free when the consumer
339
+ * is a logger, a file, or any non-interactive sink, and only enables ANSI
340
+ * under an explicit supported condition (TTY + no env veto).
341
+ */
342
+ export function resolveStatusLineCapability(input) {
343
+ if (input.forced !== undefined) {
344
+ return input.forced;
345
+ }
346
+ // Adapter-internal ASCII override. Reads the env var using a non-ASCII
347
+ // identifier by indirection so a `grep PEAKS_STATUSLINE_ASCII` finds the
348
+ // exact contract; the comparison string is built at call time.
349
+ const asciiFlag = input.env['PEAKS_STATUSLINE_ASCII'];
350
+ if (typeof asciiFlag === 'string' && (asciiFlag === '1' || asciiFlag === 'true' || asciiFlag === 'yes')) {
351
+ return 'ascii';
352
+ }
353
+ // NO_COLOR is honored by the IDE adapter (which sets
354
+ // `PEAKS_STATUSLINE_ASCII=1` to fully opt out). The unicode tier
355
+ // itself emits cyan + semantic colors so the brand reads correctly
356
+ // whether or not the consumer is a TTY.
357
+ return input.isTTY ? 'ansi-unicode' : 'unicode';
358
+ }
359
+ /**
360
+ * Render the status line. Pure — no I/O, no side effects. The output is
361
+ * a single short line suitable for the bottom-of-terminal status bar.
362
+ *
363
+ * The `options.capability` field selects the glyph palette. When omitted,
364
+ * the function falls back to `unicode` (no ANSI escape codes), preserving
365
+ * the legacy behaviour for the CLI's `peaks statusline` invocation.
366
+ *
367
+ * Compact-state precedence: when `model.compact.kind !== 'none'` the
368
+ * compact bar replaces the active/idle/stale content. The C1 baseline
369
+ * line is preserved when the compact state is `none`.
370
+ */
371
+ export function renderStatusLine(model, options) {
372
+ const capability = options?.capability ?? DEFAULT_CAPABILITY;
373
+ const palette = paletteFor(capability);
374
+ const root = rootLabel(model.projectRoot);
375
+ const rootSuffix = root ? `${palette.trailSeparator}${root}` : '';
376
+ const brand = brandText(capability);
377
+ // Breathing pulse: key off a single wall-clock read at the start of
378
+ // render so the output is deterministic for a given invocation. The
379
+ // glyph remains a single cell so total visible width is constant.
380
+ const nowMs = Date.now();
381
+ const compactSegment = renderCompact(model.compact, palette);
382
+ if (compactSegment.length > 0) {
383
+ // Compact state replaces the active / stale / idle skill content.
384
+ // `invalid-presence` still surfaces its own diagnostic when compact
385
+ // is also `invalid` (the compact diagnostic wins, since it's the
386
+ // more recent failure mode).
387
+ return `${brand} ${compactSegment}${rootSuffix}`;
388
+ }
389
+ switch (model.state) {
390
+ case 'active':
391
+ return `${brand} ${renderActive(model.presence, palette, nowMs, capability)}${rootSuffix}`;
392
+ case 'stale':
393
+ return `${brand} ${renderStale(model.presence, model.ageMs, palette)}${rootSuffix}`;
49
394
  case 'invalid-presence':
50
- return `${BRAND} ⚠ presence file unreadable${rootSuffix}`;
395
+ return `${brand} ${renderInvalid(palette)}${rootSuffix}`;
51
396
  case 'idle':
52
397
  default:
53
- return `${BRAND} ○ idle${rootSuffix}`;
398
+ return `${brand} ${renderIdle(palette)}${rootSuffix}`;
54
399
  }
55
400
  }
@@ -1,3 +1,4 @@
1
+ import type { CompactStatuslineState } from '../compact-statusline/compact-statusline-service.js';
1
2
  export type StatusLineStdin = {
2
3
  workspace?: {
3
4
  current_dir?: string;
@@ -19,6 +20,7 @@ export type StatusLineModel = {
19
20
  projectRoot: string | null;
20
21
  presence: StatusLinePresence | null;
21
22
  ageMs: number | null;
23
+ compact: CompactStatuslineState;
22
24
  };
23
25
  export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
24
26
  export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
+ import { decideCompactStatusline } from '../compact-statusline/compact-statusline-service.js';
5
+ import { getSessionIdCanonical } from '../session/session-manager.js';
4
6
  /**
5
7
  * Out-of-band Peaks skill status renderer for the Claude Code statusLine.
6
8
  *
@@ -84,15 +86,19 @@ function readPresenceReadOnly(projectRoot) {
84
86
  export function buildStatusLineModel(stdin, nowMs) {
85
87
  const cwd = resolveCwdFromStdin(stdin);
86
88
  const projectRoot = findProjectRoot(cwd);
89
+ // Compact state is computed independently of presence — the read is read-only
90
+ // and reads `.peaks/_runtime/<sid>/compact-lifecycle.json` (or its legacy
91
+ // fallbacks). It replaces the active skill content when kind != 'none'.
92
+ const compact = readCompactState(projectRoot, nowMs);
87
93
  if (projectRoot === null) {
88
- return { state: 'idle', projectRoot: null, presence: null, ageMs: null };
94
+ return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact };
89
95
  }
90
96
  const { presence, invalid } = readPresenceReadOnly(projectRoot);
91
97
  if (invalid) {
92
- return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null };
98
+ return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact };
93
99
  }
94
100
  if (presence === null) {
95
- return { state: 'idle', projectRoot, presence: null, ageMs: null };
101
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
96
102
  }
97
103
  // Session binding: when the presence was stamped with a Claude session id and
98
104
  // the live session (from stdin) is a different one, the recorded skill belongs
@@ -101,10 +107,33 @@ export function buildStatusLineModel(stdin, nowMs) {
101
107
  // fall back to the time-based behavior below for backward compatibility.
102
108
  const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
103
109
  if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
104
- return { state: 'idle', projectRoot, presence: null, ageMs: null };
110
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
105
111
  }
106
112
  const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
107
113
  const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
108
114
  const state = ageMs !== null && ageMs > STALE_THRESHOLD_MS ? 'stale' : 'active';
109
- return { state, projectRoot, presence, ageMs };
115
+ return { state, projectRoot, presence, ageMs, compact };
116
+ }
117
+ /**
118
+ * Read-only compact state resolver. Resolves the canonical session id for the
119
+ * detected project root, then delegates to {@link decideCompactStatusline}.
120
+ * Returns `{ kind: 'none', filledCells: 0 }` when no project root is bound —
121
+ * the renderer falls back to the C1 normal line in that case.
122
+ */
123
+ function readCompactState(projectRoot, nowMs) {
124
+ if (projectRoot === null) {
125
+ return { kind: 'none', filledCells: 0 };
126
+ }
127
+ try {
128
+ const sessionId = getSessionIdCanonical(projectRoot);
129
+ return decideCompactStatusline({
130
+ projectRoot,
131
+ sessionId,
132
+ now: nowMs,
133
+ });
134
+ }
135
+ catch {
136
+ // Read-only — never throw across the statusline boundary.
137
+ return { kind: 'none', filledCells: 0 };
138
+ }
110
139
  }
@@ -25,6 +25,8 @@ export interface SyncServiceInput {
25
25
  readonly platforms?: readonly IdeId[] | undefined;
26
26
  /** When true, the installer is invoked in dry-run mode. */
27
27
  readonly dryRun?: boolean | undefined;
28
+ /** Reconcile managed Junctions that point at deleted host worktrees. */
29
+ readonly reconcileJunctions?: boolean | undefined;
28
30
  }
29
31
  export interface SyncServiceResult {
30
32
  readonly applied: boolean;
@@ -39,6 +41,7 @@ interface InstallBundledSkillsOptions {
39
41
  readonly ideId: IdeId;
40
42
  readonly projectRoot: string;
41
43
  readonly dryRun?: boolean;
44
+ readonly reconcileJunctions?: boolean;
42
45
  readonly targetRoot?: string;
43
46
  }
44
47
  interface InstallResult {
@@ -222,6 +222,7 @@ export async function runSkillSync(input) {
222
222
  ideId: platform,
223
223
  projectRoot: input.projectRoot,
224
224
  ...(dryRun ? { dryRun: true } : {}),
225
+ ...(input.reconcileJunctions === true ? { reconcileJunctions: true } : {}),
225
226
  });
226
227
  perPlatform.push({
227
228
  platform,
@@ -1,6 +1,6 @@
1
1
  export type BuildTool = 'umi' | 'next' | 'vite' | 'rsbuild' | 'rspack' | 'farm' | 'craco' | 'webpack' | 'gulp' | 'angular' | 'custom' | 'unknown';
2
2
  export type ComponentLibrary = {
3
- readonly name: 'antd' | 'antd-pro' | 'mui' | 'element-plus' | 'element-ui' | 'arco' | 'tdesign' | 'semi' | 'nextui' | 'chakra' | 'vant' | 'none';
3
+ readonly name: 'antd' | 'antd-pro' | 'mui' | 'element-plus' | 'element-ui' | 'arco' | 'tdesign' | 'semi' | 'nextui' | 'chakra' | 'shadcn' | 'vant' | 'none';
4
4
  readonly majorVersion?: string;
5
5
  readonly hasProSuite?: boolean;
6
6
  };
@@ -18,6 +18,7 @@ export type ProjectContext = {
18
18
  readonly legacySignals: string[];
19
19
  readonly notableDeps: string[];
20
20
  };
21
+ export declare function detectComponentLibrary(projectRoot: string, deps: Record<string, string>): ComponentLibrary;
21
22
  export declare function detectProjectContext(projectRoot: string): ProjectContext;
22
23
  export declare function buildToolLabel(tool: BuildTool): string;
23
24
  export declare function componentLibraryLabel(lib: ComponentLibrary): string;