peaks-loop 4.0.8 → 4.0.9

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 (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/commands/_register.js +2 -0
  3. package/dist/cli/commands/baseline-commands.d.ts +3 -0
  4. package/dist/cli/commands/baseline-commands.js +146 -0
  5. package/dist/cli/commands/statusline-commands.js +1 -1
  6. package/dist/services/capability-audit-service/cross-check.d.ts +8 -0
  7. package/dist/services/capability-audit-service/cross-check.js +11 -0
  8. package/dist/services/capability-audit-service/index.d.ts +3 -0
  9. package/dist/services/capability-audit-service/index.js +2 -0
  10. package/dist/services/capability-audit-service/runner.d.ts +27 -0
  11. package/dist/services/capability-audit-service/runner.js +40 -0
  12. package/dist/services/capability-audit-service/staleness.d.ts +1 -0
  13. package/dist/services/capability-audit-service/staleness.js +7 -0
  14. package/dist/services/capability-audit-service/types.d.ts +24 -0
  15. package/dist/services/capability-audit-service/types.js +1 -0
  16. package/dist/services/capability-baseline/store.d.ts +33 -0
  17. package/dist/services/capability-baseline/store.js +80 -0
  18. package/dist/services/capability-baseline/types.d.ts +45 -0
  19. package/dist/services/capability-baseline/types.js +5 -0
  20. package/dist/services/capability-baseline/validator.d.ts +19 -0
  21. package/dist/services/capability-baseline/validator.js +38 -0
  22. package/dist/services/capability-guard-runner/contracts/J01.d.ts +2 -0
  23. package/dist/services/capability-guard-runner/contracts/J01.js +48 -0
  24. package/dist/services/capability-guard-runner/contracts/J02.d.ts +2 -0
  25. package/dist/services/capability-guard-runner/contracts/J02.js +34 -0
  26. package/dist/services/capability-guard-runner/contracts/J03.d.ts +2 -0
  27. package/dist/services/capability-guard-runner/contracts/J03.js +24 -0
  28. package/dist/services/capability-guard-runner/contracts/J04.d.ts +2 -0
  29. package/dist/services/capability-guard-runner/contracts/J04.js +40 -0
  30. package/dist/services/capability-guard-runner/contracts/J05.d.ts +2 -0
  31. package/dist/services/capability-guard-runner/contracts/J05.js +21 -0
  32. package/dist/services/capability-guard-runner/contracts/J06.d.ts +2 -0
  33. package/dist/services/capability-guard-runner/contracts/J06.js +43 -0
  34. package/dist/services/capability-guard-runner/contracts/J07.d.ts +2 -0
  35. package/dist/services/capability-guard-runner/contracts/J07.js +53 -0
  36. package/dist/services/capability-guard-runner/contracts/J08.d.ts +2 -0
  37. package/dist/services/capability-guard-runner/contracts/J08.js +43 -0
  38. package/dist/services/capability-guard-runner/contracts/J09.d.ts +2 -0
  39. package/dist/services/capability-guard-runner/contracts/J09.js +44 -0
  40. package/dist/services/capability-guard-runner/contracts/J10.d.ts +2 -0
  41. package/dist/services/capability-guard-runner/contracts/J10.js +40 -0
  42. package/dist/services/capability-guard-runner/contracts/J11.d.ts +2 -0
  43. package/dist/services/capability-guard-runner/contracts/J11.js +39 -0
  44. package/dist/services/capability-guard-runner/contracts/J12.d.ts +2 -0
  45. package/dist/services/capability-guard-runner/contracts/J12.js +34 -0
  46. package/dist/services/capability-guard-runner/contracts/J13.d.ts +2 -0
  47. package/dist/services/capability-guard-runner/contracts/J13.js +45 -0
  48. package/dist/services/capability-guard-runner/contracts/J14.d.ts +2 -0
  49. package/dist/services/capability-guard-runner/contracts/J14.js +36 -0
  50. package/dist/services/capability-guard-runner/contracts/J15.d.ts +2 -0
  51. package/dist/services/capability-guard-runner/contracts/J15.js +40 -0
  52. package/dist/services/capability-guard-runner/diff.d.ts +2 -0
  53. package/dist/services/capability-guard-runner/diff.js +7 -0
  54. package/dist/services/capability-guard-runner/runner.d.ts +9 -0
  55. package/dist/services/capability-guard-runner/runner.js +22 -0
  56. package/dist/services/capability-guard-runner/types.d.ts +39 -0
  57. package/dist/services/capability-guard-runner/types.js +1 -0
  58. package/dist/services/evolution/evolution-types.d.ts +16 -16
  59. package/dist/services/final-review/final-review-service.d.ts +8 -0
  60. package/dist/services/final-review/final-review-service.js +14 -0
  61. package/dist/services/final-review/final-review-types.d.ts +1 -1
  62. package/dist/services/job/job-progress-store.d.ts +2 -2
  63. package/dist/services/job/job-types.d.ts +26 -26
  64. package/dist/services/observability/observability-service.d.ts +2 -2
  65. package/dist/services/sediment/json-schema.d.ts +4 -4
  66. package/dist/services/session/session-resume-service.d.ts +4 -0
  67. package/dist/services/session/session-resume-service.js +4 -0
  68. package/dist/services/skills/skill-statusline-renderer.d.ts +46 -56
  69. package/dist/services/skills/skill-statusline-renderer.js +312 -100
  70. package/package.json +5 -4
@@ -6,6 +6,10 @@
6
6
  * prepend to its own prompt. The output is LLM-friendly: stable
7
7
  * headings, monospace paths, and bullet lists — easy for the model to
8
8
  * scan and reason about.
9
+ *
10
+ * Resume locates the deepestGate (the latest completed gate in the
11
+ * workflow) so the skill can pick up the next step without re-asking
12
+ * the user for state already captured at that gate.
9
13
  */
10
14
  import { existsSync, readFileSync } from 'node:fs';
11
15
  import { basename, sep } from 'node:path';
@@ -14,66 +14,44 @@ export type StatusLineCapability = 'ansi-unicode' | 'unicode' | 'ascii';
14
14
  export interface StatusLineRenderOptions {
15
15
  readonly capability: StatusLineCapability;
16
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
17
  export declare function resolveStatusLineCapability(input: {
73
18
  readonly env: NodeJS.ProcessEnv;
74
19
  readonly isTTY: boolean;
75
20
  readonly forced?: StatusLineCapability;
76
21
  }): StatusLineCapability;
22
+ export declare function isNoColorEnv(env: NodeJS.ProcessEnv): boolean;
23
+ /**
24
+ * Visible-character width of an ANSI-bearing string. Skips every
25
+ * `\x1b[...m` escape so the count reflects what the terminal paints,
26
+ * not the byte length on the wire.
27
+ */
28
+ export declare function visibleCharWidth(s: string): number;
29
+ interface AnsiToken {
30
+ readonly kind: 'text' | 'esc';
31
+ readonly value: string;
32
+ }
33
+ /**
34
+ * Split an ANSI string into a flat token stream. Escape sequences are
35
+ * grouped as `\x1b[...m` (single token, kind='esc'); everything else
36
+ * is grouped as a single 'text' run. Used by {@link applyMarquee} to
37
+ * locate visible-cell ranges inside an SGR-bearing string.
38
+ */
39
+ export declare function tokenizeAnsi(s: string): readonly AnsiToken[];
40
+ /**
41
+ * Apply the marquee highlight band to an ANSI-bearing string. Pure.
42
+ * - `bandStart` / `bandEnd` are visible-cell indices (0-based).
43
+ * - Cells in [bandStart, bandEnd] receive a `\x1b[1;38;2;224;224;224m`
44
+ * prefix; cells after the band receive the closing `\x1b[0m` reset
45
+ * so the rest of the line keeps its original SGR.
46
+ * - Empty strings and zero-width bands return the input unchanged.
47
+ */
48
+ export declare function applyMarqueeHighlight(s: string, bandStart: number, bandEnd: number): string;
49
+ /**
50
+ * Compute the marquee band for a string at time `nowMs` and re-emit
51
+ * the string with the band applied. ASCII tier is a pass-through (no
52
+ * SGR to inject) — the scanner has no visible effect on plain text.
53
+ */
54
+ export declare function applyMarquee(s: string, nowMs: number, capability: StatusLineCapability): string;
77
55
  /**
78
56
  * Render the status line. Pure — no I/O, no side effects. The output is
79
57
  * a single short line suitable for the bottom-of-terminal status bar.
@@ -85,5 +63,17 @@ export declare function resolveStatusLineCapability(input: {
85
63
  * Compact-state precedence: when `model.compact.kind !== 'none'` the
86
64
  * compact bar replaces the active/idle/stale content. The C1 baseline
87
65
  * line is preserved when the compact state is `none`.
66
+ *
67
+ * The full line is wrapped by {@link applyMarquee} so the brand-purple
68
+ * text + non-focal bar cells share a single colour surface while the
69
+ * scanner band paints its `#E0E0E0` highlight over a moving slice.
70
+ *
71
+ * Idle suppression: the marquee is OFF when `model.state === 'idle'`
72
+ * (and no compact state is active). Idle keeps the slow-blink `○`
73
+ * as the only motion — the user is asking whether the harness is
74
+ * running a skill; the scan band would compete for attention and
75
+ * obscure that question. Active / stale / invalid-presence / compact
76
+ * states all keep the marquee.
88
77
  */
89
- export declare function renderStatusLine(model: StatusLineModel, options?: StatusLineRenderOptions): string;
78
+ export declare function renderStatusLine(model: StatusLineModel, options?: StatusLineRenderOptions, env?: NodeJS.ProcessEnv): string;
79
+ export {};
@@ -28,12 +28,39 @@ const BRAND_RGB = '38;2;90;101;216';
28
28
  const BRAND_SGR_OPEN = `\x1b[1;${BRAND_RGB}m`;
29
29
  const BRAND_SGR_FULL_OPEN = `\x1b[${BRAND_RGB}m`;
30
30
  const BRAND_SGR_CLOSE = '\x1b[0m';
31
+ /**
32
+ * Highlight color used by the marquee scan band. `#E0E0E0` bright-grey
33
+ * + bold = "bleached" foreground that reads as a scanner sweeping
34
+ * over the colored text. Kept distinct from brand white (`#FFFFFF`)
35
+ * because pure white on `#5A65D8` brand purple reads as a strobe;
36
+ * the off-white tint gives the same "scanner" affordance without the
37
+ * flicker.
38
+ */
39
+ const HIGHLIGHT_RGB = '38;2;224;224;224';
40
+ const HIGHLIGHT_SGR_OPEN = `\x1b[1;${HIGHLIGHT_RGB}m`;
31
41
  function accent(text) {
32
42
  return `${BRAND_SGR_OPEN}${text}${BRAND_SGR_CLOSE}`;
33
43
  }
34
44
  function accentGlyph(glyph) {
35
45
  return `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
36
46
  }
47
+ // (kept for the brandText/active-dot helpers that inline the same SGR; the
48
+ // buildPalette factory owns per-token SGR injection now and does not call
49
+ // through these wrappers.)
50
+ /**
51
+ * Render the supplied text in the brand purple, honouring the
52
+ * `noColor` flag. Returns plain text when NO_COLOR is set or the
53
+ * tier is ASCII. Used by the active / stale / compact renders for
54
+ * tokens that the {@link buildPalette} cannot pre-stamp (skill
55
+ * name, mode, age, attention labels).
56
+ */
57
+ function brandRun(text, noColor, capability) {
58
+ if (text.length === 0)
59
+ return text;
60
+ if (noColor || capability === 'ascii')
61
+ return text;
62
+ return `${BRAND_SGR_OPEN}${text}${BRAND_SGR_CLOSE}`;
63
+ }
37
64
  /**
38
65
  * Slow-blink variant of the accent glyph. Used for the idle indicator
39
66
  * so the user perceives a pulse on the statusline. The ANSI slow-blink
@@ -44,68 +71,70 @@ function accentGlyph(glyph) {
44
71
  function blinkingAccentGlyph(glyph) {
45
72
  return `\x1b[5;1;${BRAND_RGB}m${glyph}\x1b[0m`;
46
73
  }
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',
74
+ /**
75
+ * Build the per-capability palette. `noColor=true` strips every brand
76
+ * ANSI SGR (warning / failed remain because they are SEMANTIC alarms,
77
+ * not brand colour). The unicode glyphs are kept as-is — NO_COLOR
78
+ * (https://no-color.org) addresses ANSI sequences, not UTF-8 chars.
79
+ */
80
+ function buildPalette(capability, noColor) {
81
+ // Brand SGR helpers, noColor-aware.
82
+ const brand = (text) => noColor || capability === 'ascii' ? text : `${BRAND_SGR_OPEN}${text}${BRAND_SGR_CLOSE}`;
83
+ const brandGlyph = (glyph) => noColor || capability === 'ascii' ? glyph : `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
84
+ const dimBrand = (text) => {
85
+ if (noColor || capability === 'ascii')
86
+ return text;
87
+ return `\x1b[2;${BRAND_RGB}m${text}\x1b[0m`;
88
+ };
89
+ const blinkBrand = (glyph) => {
90
+ if (noColor || capability === 'ascii')
91
+ return glyph;
92
+ return `\x1b[5;1;${BRAND_RGB}m${glyph}\x1b[0m`;
93
+ };
94
+ // Warning + failed stay semantic — they are alarms, not brand colour.
95
+ // noColor still suppresses them because a tiny log consumer that
96
+ // sets NO_COLOR expects raw text.
97
+ const warning = noColor || capability === 'ascii' ? '!' : '\x1b[33m!\x1b[0m';
98
+ const failed = noColor || capability === 'ascii' ? 'x' : '\x1b[31m✕\x1b[0m';
99
+ if (capability === 'ascii') {
100
+ return {
101
+ active: '*',
102
+ idle: 'o',
103
+ warning,
104
+ inlineSeparator: ' . ',
105
+ trailSeparator: ' -> ',
106
+ idleLabel: 'empty',
107
+ invalidMessage: 'presence unreadable',
108
+ compact: {
109
+ queued: '[', preparing: '+', compacting: '+', verifying: '+',
110
+ completed: '*', failed,
111
+ },
112
+ barFilled: '#',
113
+ barEmpty: '-',
114
+ ratioArrow: '->',
115
+ };
116
+ }
117
+ return {
118
+ active: brandGlyph('●'),
119
+ idle: blinkBrand('○'),
120
+ warning,
121
+ inlineSeparator: brand(' · '),
122
+ trailSeparator: brand(' → '),
123
+ idleLabel: brand('empty'),
124
+ invalidMessage: brand('presence unreadable'),
96
125
  compact: {
97
- queued: '[',
98
- preparing: '+',
99
- compacting: '+',
100
- verifying: '+',
101
- completed: '*',
102
- failed: 'x',
126
+ queued: brandGlyph('◐'),
127
+ preparing: brandGlyph('◑'),
128
+ compacting: brandGlyph('◒'),
129
+ verifying: brandGlyph('◓'),
130
+ completed: brandGlyph('✓'),
131
+ failed,
103
132
  },
104
- barFilled: '#',
105
- barEmpty: '-',
106
- ratioArrow: '->',
107
- },
108
- };
133
+ barFilled: brand('█'),
134
+ barEmpty: dimBrand('░'),
135
+ ratioArrow: dimBrand('→'),
136
+ };
137
+ }
109
138
  const BREATHING_GLYPHS_UNICODE = ['●', '◐', '◑', '◒', '◓'];
110
139
  const BREATHING_GLYPHS_ASCII = ['*', 'o', '+', '~', '|'];
111
140
  const BREATHING_PERIOD_MS = 2_400;
@@ -133,23 +162,23 @@ function pickBreathingGlyph(capability, nowMs) {
133
162
  const index = Math.floor((nowMs % BREATHING_PERIOD_MS) / (BREATHING_PERIOD_MS / set.length)) % set.length;
134
163
  return set[index];
135
164
  }
136
- function renderActiveDot(capability, nowMs) {
165
+ function renderActiveDot(capability, nowMs, noColor) {
137
166
  // Brief: the active dot carries the project accent (`#5A65D8` bold)
138
167
  // in both colored tiers. The breathing glyph is wrapped in a fresh
139
168
  // SGR on every render so the IDE sees a single accent per refresh.
169
+ // NO_COLOR strips the SGR but keeps the glyph (still rotates).
140
170
  const glyph = pickBreathingGlyph(capability, nowMs);
141
- if (capability === 'ascii')
171
+ if (noColor || capability === 'ascii')
142
172
  return glyph;
143
- return accentGlyph(glyph);
173
+ return `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
144
174
  }
145
- function brandText(capability) {
175
+ function brandText(capability, noColor) {
146
176
  // 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;
177
+ // colored tiers. The `ascii` tier and NO_COLOR stay plain text so
178
+ // log / file consumers never see escape codes.
179
+ if (noColor || capability === 'ascii')
180
+ return BRAND;
181
+ return `${BRAND_SGR_OPEN}${BRAND}${BRAND_SGR_CLOSE}`;
153
182
  }
154
183
  /**
155
184
  * Default capability when none is supplied. Per the brief: "if backward
@@ -159,8 +188,8 @@ function brandText(capability) {
159
188
  * default is the unicode palette (no escape codes).
160
189
  */
161
190
  const DEFAULT_CAPABILITY = 'unicode';
162
- function paletteFor(capability) {
163
- return PALETTES[capability ?? DEFAULT_CAPABILITY];
191
+ function paletteFor(capability, noColor) {
192
+ return buildPalette(capability ?? DEFAULT_CAPABILITY, noColor);
164
193
  }
165
194
  function formatAge(ageMs) {
166
195
  if (ageMs === null)
@@ -182,35 +211,35 @@ function rootLabel(projectRoot) {
182
211
  * is obvious at the call site and so each state has a single
183
212
  * responsibility.
184
213
  */
185
- function renderActive(presence, palette, nowMs, capability) {
214
+ function renderActive(presence, palette, nowMs, capability, noColor) {
186
215
  if (!presence) {
187
216
  return `${palette.idle} ${palette.idleLabel}`;
188
217
  }
189
218
  const attentionLabel = isAttentionGate(presence.gate);
190
219
  if (attentionLabel !== null) {
191
220
  // Attention gate — surface the warning glyph + human-readable label.
192
- return `${palette.warning} ${presence.skill}${palette.inlineSeparator}${attentionLabel}`;
221
+ return `${palette.warning} ${brandRun(presence.skill, noColor, capability)}${palette.inlineSeparator}${brandRun(attentionLabel, noColor, capability)}`;
193
222
  }
194
223
  const skill = presence.skill;
195
- const dot = renderActiveDot(capability, nowMs);
224
+ const dot = renderActiveDot(capability, nowMs, noColor);
196
225
  const beeParent = BEE_TO_PARENT[skill];
197
226
  const modeToken = typeof presence.mode === 'string' && presence.mode.length > 0
198
- ? ` [${presence.mode}]`
227
+ ? brandRun(` [${presence.mode}]`, noColor, capability)
199
228
  : '';
200
229
  // Bee-tier skills surface both layers: the active bee name plus
201
230
  // the full parent orchestrator name. Mode is shown for every
202
231
  // active skill (not just peaks-code) so the line carries the same
203
232
  // mode taxonomy for any bee in flight.
204
233
  if (beeParent !== undefined) {
205
- return `${dot} ${skill} ↑${beeParent}${modeToken}`;
234
+ return `${dot} ${brandRun(skill, noColor, capability)} ${brandRun(`↑${beeParent}`, noColor, capability)}${modeToken}`;
206
235
  }
207
- return `${dot} ${skill}${modeToken}`;
236
+ return `${dot} ${brandRun(skill, noColor, capability)}${modeToken}`;
208
237
  }
209
- function renderStale(presence, ageMs, palette) {
238
+ function renderStale(presence, ageMs, palette, capability, noColor) {
210
239
  const skill = presence?.skill ?? 'unknown';
211
240
  const age = formatAge(ageMs);
212
- const ageSuffix = age ? `${palette.inlineSeparator}${age}` : '';
213
- return `${palette.warning} ${skill}${ageSuffix}`;
241
+ const ageSuffix = age ? `${palette.inlineSeparator}${brandRun(age, noColor, capability)}` : '';
242
+ return `${palette.warning} ${brandRun(skill, noColor, capability)}${ageSuffix}`;
214
243
  }
215
244
  function renderInvalid(palette) {
216
245
  return `${palette.warning} ${palette.invalidMessage}`;
@@ -339,6 +368,18 @@ function renderCompact(state, palette) {
339
368
  * is a logger, a file, or any non-interactive sink, and only enables ANSI
340
369
  * under an explicit supported condition (TTY + no env veto).
341
370
  */
371
+ /**
372
+ * NO_COLOR (https://no-color.org) is the cross-industry signal that NO
373
+ * ANSI escape sequences should be emitted. The unicode/ansi-unicode
374
+ * tiers honour it by stripping every brand SGR while keeping the UTF-8
375
+ * glyphs (●, █, ░, ◐, …) — NO_COLOR addresses ANSI specifically.
376
+ * PEAKS_STATUSLINE_ASCII is the stricter override that also drops the
377
+ * unicode glyphs; both can coexist and the ASCII override wins.
378
+ */
379
+ function isNoColor(env) {
380
+ const v = env['NO_COLOR'];
381
+ return typeof v === 'string' && v.length > 0 && v !== '0' && v.toLowerCase() !== 'false';
382
+ }
342
383
  export function resolveStatusLineCapability(input) {
343
384
  if (input.forced !== undefined) {
344
385
  return input.forced;
@@ -350,12 +391,158 @@ export function resolveStatusLineCapability(input) {
350
391
  if (typeof asciiFlag === 'string' && (asciiFlag === '1' || asciiFlag === 'true' || asciiFlag === 'yes')) {
351
392
  return 'ascii';
352
393
  }
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.
394
+ // NO_COLOR is honoured at render time (see {@link renderStatusLine});
395
+ // capability stays unicode so the UTF-8 glyphs survive — only the
396
+ // brand SGR is suppressed.
357
397
  return input.isTTY ? 'ansi-unicode' : 'unicode';
358
398
  }
399
+ export function isNoColorEnv(env) {
400
+ return isNoColor(env);
401
+ }
402
+ /**
403
+ * Marquee scan band — a single-pass light band that sweeps left ↔ right
404
+ * across the entire status line on a 2 s round trip. The band's
405
+ * foreground color is `#E0E0E0` (off-white) with `1;` (bold) — see
406
+ * {@link HIGHLIGHT_SGR_OPEN}. Cells OUTSIDE the band keep their
407
+ * original SGR (brand purple or semantic warning/failed); only cells
408
+ * INSIDE the band are temporarily re-painted to the highlight color.
409
+ *
410
+ * Why it does NOT use SGR 7 (reverse video):
411
+ * - Reverse video inverts BOTH background and foreground, which on a
412
+ * `#5A65D8` background swaps to a white background — visually that
413
+ * reads as a solid white block instead of a scan band.
414
+ * - The off-white foreground + bold keeps the original background
415
+ * visible behind each glyph, so the band reads as "lit text" not
416
+ * as a solid stripe — closer to the CSDN-style scan-band reference
417
+ * image.
418
+ *
419
+ * Phase formula:
420
+ * phase ∈ [0, 1) over MARQUEE_PERIOD_MS
421
+ * sweep = phase < 0.5 ? phase * 2 : (1 - phase) * 2 ← triangle wave 0→1→0
422
+ * center = sweep * (width - 1)
423
+ * band covers visible-cell range [center - half, center + half]
424
+ *
425
+ * When the band would land outside the visible range (center ≤ half
426
+ * OR center ≥ width-1-half) it is clipped — the band never appears
427
+ * off-line. At `nowMs = 0` the band sits at the LEFT edge covering
428
+ * cells [0, bandWidth); this is a deliberate deterministic anchor
429
+ * for tests that pin `withPinnedClock(0, ...)`.
430
+ *
431
+ * ASCII tier: skipped — there are no SGR codes to inject.
432
+ */
433
+ const MARQUEE_PERIOD_MS = 2_000;
434
+ const MARQUEE_BAND_WIDTH = 5;
435
+ /**
436
+ * Visible-character width of an ANSI-bearing string. Skips every
437
+ * `\x1b[...m` escape so the count reflects what the terminal paints,
438
+ * not the byte length on the wire.
439
+ */
440
+ export function visibleCharWidth(s) {
441
+ let w = 0;
442
+ for (let i = 0; i < s.length; i++) {
443
+ if (s[i] === '\x1b' && s[i + 1] === '[') {
444
+ const m = s.indexOf('m', i + 2);
445
+ if (m !== -1) {
446
+ i = m;
447
+ continue;
448
+ }
449
+ }
450
+ w++;
451
+ }
452
+ return w;
453
+ }
454
+ /**
455
+ * Split an ANSI string into a flat token stream. Escape sequences are
456
+ * grouped as `\x1b[...m` (single token, kind='esc'); everything else
457
+ * is grouped as a single 'text' run. Used by {@link applyMarquee} to
458
+ * locate visible-cell ranges inside an SGR-bearing string.
459
+ */
460
+ export function tokenizeAnsi(s) {
461
+ const out = [];
462
+ let i = 0;
463
+ let buf = '';
464
+ const flush = () => {
465
+ if (buf.length > 0) {
466
+ out.push({ kind: 'text', value: buf });
467
+ buf = '';
468
+ }
469
+ };
470
+ while (i < s.length) {
471
+ if (s[i] === '\x1b' && s[i + 1] === '[') {
472
+ const m = s.indexOf('m', i + 2);
473
+ if (m !== -1) {
474
+ flush();
475
+ out.push({ kind: 'esc', value: s.slice(i, m + 1) });
476
+ i = m + 1;
477
+ continue;
478
+ }
479
+ }
480
+ buf += s[i];
481
+ i++;
482
+ }
483
+ flush();
484
+ return out;
485
+ }
486
+ /**
487
+ * Apply the marquee highlight band to an ANSI-bearing string. Pure.
488
+ * - `bandStart` / `bandEnd` are visible-cell indices (0-based).
489
+ * - Cells in [bandStart, bandEnd] receive a `\x1b[1;38;2;224;224;224m`
490
+ * prefix; cells after the band receive the closing `\x1b[0m` reset
491
+ * so the rest of the line keeps its original SGR.
492
+ * - Empty strings and zero-width bands return the input unchanged.
493
+ */
494
+ export function applyMarqueeHighlight(s, bandStart, bandEnd) {
495
+ if (s.length === 0)
496
+ return s;
497
+ if (bandEnd < 0 || bandStart > bandEnd)
498
+ return s;
499
+ const tokens = tokenizeAnsi(s);
500
+ const parts = [];
501
+ let visibleIdx = 0;
502
+ let inside = false;
503
+ for (const tok of tokens) {
504
+ if (tok.kind === 'esc') {
505
+ parts.push(tok.value);
506
+ continue;
507
+ }
508
+ for (let k = 0; k < tok.value.length; k++) {
509
+ const cellPos = visibleIdx + k;
510
+ const inBand = cellPos >= bandStart && cellPos <= bandEnd;
511
+ if (inBand && !inside) {
512
+ parts.push(HIGHLIGHT_SGR_OPEN);
513
+ inside = true;
514
+ }
515
+ else if (!inBand && inside) {
516
+ parts.push('\x1b[0m');
517
+ inside = false;
518
+ }
519
+ parts.push(tok.value[k]);
520
+ }
521
+ visibleIdx += tok.value.length;
522
+ }
523
+ if (inside)
524
+ parts.push('\x1b[0m');
525
+ return parts.join('');
526
+ }
527
+ /**
528
+ * Compute the marquee band for a string at time `nowMs` and re-emit
529
+ * the string with the band applied. ASCII tier is a pass-through (no
530
+ * SGR to inject) — the scanner has no visible effect on plain text.
531
+ */
532
+ export function applyMarquee(s, nowMs, capability) {
533
+ if (capability === 'ascii')
534
+ return s;
535
+ const width = visibleCharWidth(s);
536
+ if (width === 0)
537
+ return s;
538
+ const phase = (nowMs % MARQUEE_PERIOD_MS) / MARQUEE_PERIOD_MS;
539
+ const sweep = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
540
+ const center = Math.round(sweep * (width - 1));
541
+ const halfBand = Math.floor(MARQUEE_BAND_WIDTH / 2);
542
+ const bandStart = Math.max(0, center - halfBand);
543
+ const bandEnd = Math.min(width - 1, center + halfBand);
544
+ return applyMarqueeHighlight(s, bandStart, bandEnd);
545
+ }
359
546
  /**
360
547
  * Render the status line. Pure — no I/O, no side effects. The output is
361
548
  * a single short line suitable for the bottom-of-terminal status bar.
@@ -367,34 +554,59 @@ export function resolveStatusLineCapability(input) {
367
554
  * Compact-state precedence: when `model.compact.kind !== 'none'` the
368
555
  * compact bar replaces the active/idle/stale content. The C1 baseline
369
556
  * line is preserved when the compact state is `none`.
557
+ *
558
+ * The full line is wrapped by {@link applyMarquee} so the brand-purple
559
+ * text + non-focal bar cells share a single colour surface while the
560
+ * scanner band paints its `#E0E0E0` highlight over a moving slice.
561
+ *
562
+ * Idle suppression: the marquee is OFF when `model.state === 'idle'`
563
+ * (and no compact state is active). Idle keeps the slow-blink `○`
564
+ * as the only motion — the user is asking whether the harness is
565
+ * running a skill; the scan band would compete for attention and
566
+ * obscure that question. Active / stale / invalid-presence / compact
567
+ * states all keep the marquee.
370
568
  */
371
- export function renderStatusLine(model, options) {
569
+ export function renderStatusLine(model, options, env) {
372
570
  const capability = options?.capability ?? DEFAULT_CAPABILITY;
373
- const palette = paletteFor(capability);
571
+ const noColor = env !== undefined ? isNoColor(env) : false;
572
+ const palette = paletteFor(capability, noColor);
374
573
  const root = rootLabel(model.projectRoot);
375
574
  const rootSuffix = root ? `${palette.trailSeparator}${root}` : '';
376
- const brand = brandText(capability);
575
+ const brand = brandText(capability, noColor);
377
576
  // Breathing pulse: key off a single wall-clock read at the start of
378
577
  // render so the output is deterministic for a given invocation. The
379
578
  // glyph remains a single cell so total visible width is constant.
380
579
  const nowMs = Date.now();
381
580
  const compactSegment = renderCompact(model.compact, palette);
382
- if (compactSegment.length > 0) {
581
+ let line;
582
+ const hasCompact = compactSegment.length > 0;
583
+ if (hasCompact) {
383
584
  // Compact state replaces the active / stale / idle skill content.
384
585
  // `invalid-presence` still surfaces its own diagnostic when compact
385
586
  // is also `invalid` (the compact diagnostic wins, since it's the
386
587
  // more recent failure mode).
387
- return `${brand} ${compactSegment}${rootSuffix}`;
588
+ line = `${brand} ${compactSegment}${rootSuffix}`;
388
589
  }
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}`;
394
- case 'invalid-presence':
395
- return `${brand} ${renderInvalid(palette)}${rootSuffix}`;
396
- case 'idle':
397
- default:
398
- return `${brand} ${renderIdle(palette)}${rootSuffix}`;
590
+ else {
591
+ switch (model.state) {
592
+ case 'active':
593
+ line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor)}${rootSuffix}`;
594
+ break;
595
+ case 'stale':
596
+ line = `${brand} ${renderStale(model.presence, model.ageMs, palette, capability, noColor)}${rootSuffix}`;
597
+ break;
598
+ case 'invalid-presence':
599
+ line = `${brand} ${renderInvalid(palette)}${rootSuffix}`;
600
+ break;
601
+ case 'idle':
602
+ default:
603
+ line = `${brand} ${renderIdle(palette)}${rootSuffix}`;
604
+ break;
605
+ }
399
606
  }
607
+ // Marquee is OFF for idle (and only idle). Compact states always
608
+ // carry the band because the compact bar IS the headline. NO_COLOR
609
+ // skips the band entirely — there's no SGR to inject.
610
+ const shouldMarquee = !noColor && (hasCompact || model.state !== 'idle');
611
+ return shouldMarquee ? applyMarquee(line, nowMs, capability) : line;
400
612
  }