peaks-loop 4.0.8 → 4.0.10

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 (93) hide show
  1. package/CHANGELOG.md +38 -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/container-commands.js +2 -1
  6. package/dist/cli/commands/core/skill-command.js +32 -5
  7. package/dist/cli/commands/openspec-commands.js +2 -1
  8. package/dist/cli/commands/statusline-commands.js +1 -1
  9. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
  10. package/dist/services/audit/enforcers/active-skill-resolver.js +50 -39
  11. package/dist/services/capability-audit-service/cross-check.d.ts +8 -0
  12. package/dist/services/capability-audit-service/cross-check.js +11 -0
  13. package/dist/services/capability-audit-service/index.d.ts +3 -0
  14. package/dist/services/capability-audit-service/index.js +2 -0
  15. package/dist/services/capability-audit-service/runner.d.ts +27 -0
  16. package/dist/services/capability-audit-service/runner.js +40 -0
  17. package/dist/services/capability-audit-service/staleness.d.ts +1 -0
  18. package/dist/services/capability-audit-service/staleness.js +7 -0
  19. package/dist/services/capability-audit-service/types.d.ts +24 -0
  20. package/dist/services/capability-audit-service/types.js +1 -0
  21. package/dist/services/capability-baseline/store.d.ts +33 -0
  22. package/dist/services/capability-baseline/store.js +80 -0
  23. package/dist/services/capability-baseline/types.d.ts +45 -0
  24. package/dist/services/capability-baseline/types.js +5 -0
  25. package/dist/services/capability-baseline/validator.d.ts +19 -0
  26. package/dist/services/capability-baseline/validator.js +38 -0
  27. package/dist/services/capability-guard-runner/contracts/J01.d.ts +2 -0
  28. package/dist/services/capability-guard-runner/contracts/J01.js +48 -0
  29. package/dist/services/capability-guard-runner/contracts/J02.d.ts +2 -0
  30. package/dist/services/capability-guard-runner/contracts/J02.js +34 -0
  31. package/dist/services/capability-guard-runner/contracts/J03.d.ts +2 -0
  32. package/dist/services/capability-guard-runner/contracts/J03.js +24 -0
  33. package/dist/services/capability-guard-runner/contracts/J04.d.ts +2 -0
  34. package/dist/services/capability-guard-runner/contracts/J04.js +40 -0
  35. package/dist/services/capability-guard-runner/contracts/J05.d.ts +2 -0
  36. package/dist/services/capability-guard-runner/contracts/J05.js +21 -0
  37. package/dist/services/capability-guard-runner/contracts/J06.d.ts +2 -0
  38. package/dist/services/capability-guard-runner/contracts/J06.js +43 -0
  39. package/dist/services/capability-guard-runner/contracts/J07.d.ts +2 -0
  40. package/dist/services/capability-guard-runner/contracts/J07.js +53 -0
  41. package/dist/services/capability-guard-runner/contracts/J08.d.ts +2 -0
  42. package/dist/services/capability-guard-runner/contracts/J08.js +43 -0
  43. package/dist/services/capability-guard-runner/contracts/J09.d.ts +2 -0
  44. package/dist/services/capability-guard-runner/contracts/J09.js +44 -0
  45. package/dist/services/capability-guard-runner/contracts/J10.d.ts +2 -0
  46. package/dist/services/capability-guard-runner/contracts/J10.js +40 -0
  47. package/dist/services/capability-guard-runner/contracts/J11.d.ts +2 -0
  48. package/dist/services/capability-guard-runner/contracts/J11.js +39 -0
  49. package/dist/services/capability-guard-runner/contracts/J12.d.ts +2 -0
  50. package/dist/services/capability-guard-runner/contracts/J12.js +34 -0
  51. package/dist/services/capability-guard-runner/contracts/J13.d.ts +2 -0
  52. package/dist/services/capability-guard-runner/contracts/J13.js +45 -0
  53. package/dist/services/capability-guard-runner/contracts/J14.d.ts +2 -0
  54. package/dist/services/capability-guard-runner/contracts/J14.js +36 -0
  55. package/dist/services/capability-guard-runner/contracts/J15.d.ts +2 -0
  56. package/dist/services/capability-guard-runner/contracts/J15.js +40 -0
  57. package/dist/services/capability-guard-runner/diff.d.ts +2 -0
  58. package/dist/services/capability-guard-runner/diff.js +7 -0
  59. package/dist/services/capability-guard-runner/runner.d.ts +9 -0
  60. package/dist/services/capability-guard-runner/runner.js +22 -0
  61. package/dist/services/capability-guard-runner/types.d.ts +39 -0
  62. package/dist/services/capability-guard-runner/types.js +1 -0
  63. package/dist/services/container/container-lease.js +2 -1
  64. package/dist/services/evolution/evolution-types.d.ts +16 -16
  65. package/dist/services/final-review/final-review-service.d.ts +8 -0
  66. package/dist/services/final-review/final-review-service.js +14 -0
  67. package/dist/services/final-review/final-review-types.d.ts +1 -1
  68. package/dist/services/impact/impact-scan-service.js +4 -3
  69. package/dist/services/job/job-progress-store.d.ts +2 -2
  70. package/dist/services/job/job-types.d.ts +26 -26
  71. package/dist/services/migrate-skill-name/migrate.js +2 -1
  72. package/dist/services/observability/observability-service.d.ts +2 -2
  73. package/dist/services/openspec/artifact-boundary.js +3 -2
  74. package/dist/services/openspec/coverage-evidence-reader.js +9 -8
  75. package/dist/services/prd/handoff-auto-regen.js +2 -1
  76. package/dist/services/scan/type-sanity-service.js +2 -1
  77. package/dist/services/sediment/json-schema.d.ts +4 -4
  78. package/dist/services/session/session-binding-bridge.js +17 -19
  79. package/dist/services/session/session-manager.js +36 -12
  80. package/dist/services/session/session-resume-service.d.ts +4 -0
  81. package/dist/services/session/session-resume-service.js +4 -0
  82. package/dist/services/skills/skill-statusline-renderer.d.ts +46 -56
  83. package/dist/services/skills/skill-statusline-renderer.js +335 -126
  84. package/dist/services/skills/skill-statusline-service.d.ts +6 -0
  85. package/dist/services/skills/skill-statusline-service.js +105 -7
  86. package/dist/services/vm/vm-lease.js +2 -1
  87. package/dist/services/workflow/workflow-autonomous-resume-helpers.js +3 -2
  88. package/dist/services/workspace/workspace-service.js +2 -1
  89. package/dist/services/worktree/worktree-lease.js +2 -1
  90. package/dist/shared/path-safety.js +3 -5
  91. package/dist/shared/path-utils.d.ts +48 -0
  92. package/dist/shared/path-utils.js +65 -1
  93. package/package.json +5 -4
@@ -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,112 +71,95 @@ 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
- 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
- };
140
+ const BREATHING_PERIOD_MS = 600;
131
141
  function pickBreathingGlyph(capability, nowMs) {
132
142
  const set = capability === 'ascii' ? BREATHING_GLYPHS_ASCII : BREATHING_GLYPHS_UNICODE;
133
143
  const index = Math.floor((nowMs % BREATHING_PERIOD_MS) / (BREATHING_PERIOD_MS / set.length)) % set.length;
134
144
  return set[index];
135
145
  }
136
- function renderActiveDot(capability, nowMs) {
146
+ function renderActiveDot(capability, nowMs, noColor) {
137
147
  // Brief: the active dot carries the project accent (`#5A65D8` bold)
138
148
  // in both colored tiers. The breathing glyph is wrapped in a fresh
139
149
  // SGR on every render so the IDE sees a single accent per refresh.
150
+ // NO_COLOR strips the SGR but keeps the glyph (still rotates).
140
151
  const glyph = pickBreathingGlyph(capability, nowMs);
141
- if (capability === 'ascii')
152
+ if (noColor || capability === 'ascii')
142
153
  return glyph;
143
- return accentGlyph(glyph);
154
+ return `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
144
155
  }
145
- function brandText(capability) {
156
+ function brandText(capability, noColor) {
146
157
  // 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;
158
+ // colored tiers. The `ascii` tier and NO_COLOR stay plain text so
159
+ // log / file consumers never see escape codes.
160
+ if (noColor || capability === 'ascii')
161
+ return BRAND;
162
+ return `${BRAND_SGR_OPEN}${BRAND}${BRAND_SGR_CLOSE}`;
153
163
  }
154
164
  /**
155
165
  * Default capability when none is supplied. Per the brief: "if backward
@@ -159,8 +169,8 @@ function brandText(capability) {
159
169
  * default is the unicode palette (no escape codes).
160
170
  */
161
171
  const DEFAULT_CAPABILITY = 'unicode';
162
- function paletteFor(capability) {
163
- return PALETTES[capability ?? DEFAULT_CAPABILITY];
172
+ function paletteFor(capability, noColor) {
173
+ return buildPalette(capability ?? DEFAULT_CAPABILITY, noColor);
164
174
  }
165
175
  function formatAge(ageMs) {
166
176
  if (ageMs === null)
@@ -181,36 +191,52 @@ function rootLabel(projectRoot) {
181
191
  * prefix and the project root label. Kept separate so the token layout
182
192
  * is obvious at the call site and so each state has a single
183
193
  * responsibility.
194
+ *
195
+ * Active-leaf rendering (slice 2026-08-04-rid-005-statusline-dual-skill):
196
+ * when the model carries an `activeLeaf` (an in-flight bee dispatch under
197
+ * the orchestrator), the line surfaces the leaf role alongside the
198
+ * orchestrator skill. The render priorities are:
199
+ *
200
+ * - activeLeaf === null → `${skill}` (current behavior)
201
+ * - activeLeaf.pendingCount === 1 → `${leaf} | ${skill}`
202
+ * - activeLeaf.pendingCount > 1 → `${leaf} (+${N-1}) | ${skill}`
203
+ *
204
+ * The orchestrator skill itself is rendered with its mode token; the leaf
205
+ * role is rendered without a mode (the leaf does not own the mode state —
206
+ * the orchestrator does). The 14→1 bee skill mapping that previously
207
+ * forced every bee role to render with a `↑<parent>` marker was removed
208
+ * in this slice; the dual-skill layout above replaces it.
184
209
  */
185
- function renderActive(presence, palette, nowMs, capability) {
210
+ function renderActive(presence, palette, nowMs, capability, noColor, activeLeaf) {
186
211
  if (!presence) {
187
212
  return `${palette.idle} ${palette.idleLabel}`;
188
213
  }
189
214
  const attentionLabel = isAttentionGate(presence.gate);
190
215
  if (attentionLabel !== null) {
191
216
  // Attention gate — surface the warning glyph + human-readable label.
192
- return `${palette.warning} ${presence.skill}${palette.inlineSeparator}${attentionLabel}`;
217
+ return `${palette.warning} ${brandRun(presence.skill, noColor, capability)}${palette.inlineSeparator}${brandRun(attentionLabel, noColor, capability)}`;
193
218
  }
194
219
  const skill = presence.skill;
195
- const dot = renderActiveDot(capability, nowMs);
196
- const beeParent = BEE_TO_PARENT[skill];
220
+ const dot = renderActiveDot(capability, nowMs, noColor);
197
221
  const modeToken = typeof presence.mode === 'string' && presence.mode.length > 0
198
- ? ` [${presence.mode}]`
222
+ ? brandRun(` [${presence.mode}]`, noColor, capability)
199
223
  : '';
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}`;
224
+ // Dual-skill layout: leaf role (in-flight bee) + orchestrator skill.
225
+ if (activeLeaf !== null) {
226
+ const leaf = brandRun(activeLeaf.role, noColor, capability);
227
+ const tail = activeLeaf.pendingCount > 1
228
+ ? ` ${brandRun(`(+${activeLeaf.pendingCount - 1})`, noColor, capability)}`
229
+ : '';
230
+ const sep = brandRun(' | ', noColor, capability);
231
+ return `${dot} ${leaf}${tail}${sep}${brandRun(skill, noColor, capability)}${modeToken}`;
206
232
  }
207
- return `${dot} ${skill}${modeToken}`;
233
+ return `${dot} ${brandRun(skill, noColor, capability)}${modeToken}`;
208
234
  }
209
- function renderStale(presence, ageMs, palette) {
235
+ function renderStale(presence, ageMs, palette, capability, noColor) {
210
236
  const skill = presence?.skill ?? 'unknown';
211
237
  const age = formatAge(ageMs);
212
- const ageSuffix = age ? `${palette.inlineSeparator}${age}` : '';
213
- return `${palette.warning} ${skill}${ageSuffix}`;
238
+ const ageSuffix = age ? `${palette.inlineSeparator}${brandRun(age, noColor, capability)}` : '';
239
+ return `${palette.warning} ${brandRun(skill, noColor, capability)}${ageSuffix}`;
214
240
  }
215
241
  function renderInvalid(palette) {
216
242
  return `${palette.warning} ${palette.invalidMessage}`;
@@ -339,6 +365,18 @@ function renderCompact(state, palette) {
339
365
  * is a logger, a file, or any non-interactive sink, and only enables ANSI
340
366
  * under an explicit supported condition (TTY + no env veto).
341
367
  */
368
+ /**
369
+ * NO_COLOR (https://no-color.org) is the cross-industry signal that NO
370
+ * ANSI escape sequences should be emitted. The unicode/ansi-unicode
371
+ * tiers honour it by stripping every brand SGR while keeping the UTF-8
372
+ * glyphs (●, █, ░, ◐, …) — NO_COLOR addresses ANSI specifically.
373
+ * PEAKS_STATUSLINE_ASCII is the stricter override that also drops the
374
+ * unicode glyphs; both can coexist and the ASCII override wins.
375
+ */
376
+ function isNoColor(env) {
377
+ const v = env['NO_COLOR'];
378
+ return typeof v === 'string' && v.length > 0 && v !== '0' && v.toLowerCase() !== 'false';
379
+ }
342
380
  export function resolveStatusLineCapability(input) {
343
381
  if (input.forced !== undefined) {
344
382
  return input.forced;
@@ -350,12 +388,158 @@ export function resolveStatusLineCapability(input) {
350
388
  if (typeof asciiFlag === 'string' && (asciiFlag === '1' || asciiFlag === 'true' || asciiFlag === 'yes')) {
351
389
  return 'ascii';
352
390
  }
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.
391
+ // NO_COLOR is honoured at render time (see {@link renderStatusLine});
392
+ // capability stays unicode so the UTF-8 glyphs survive — only the
393
+ // brand SGR is suppressed.
357
394
  return input.isTTY ? 'ansi-unicode' : 'unicode';
358
395
  }
396
+ export function isNoColorEnv(env) {
397
+ return isNoColor(env);
398
+ }
399
+ /**
400
+ * Marquee scan band — a single-pass light band that sweeps left ↔ right
401
+ * across the entire status line on a 0.4 s round trip. The band's
402
+ * foreground color is `#E0E0E0` (off-white) with `1;` (bold) — see
403
+ * {@link HIGHLIGHT_SGR_OPEN}. Cells OUTSIDE the band keep their
404
+ * original SGR (brand purple or semantic warning/failed); only cells
405
+ * INSIDE the band are temporarily re-painted to the highlight color.
406
+ *
407
+ * Why it does NOT use SGR 7 (reverse video):
408
+ * - Reverse video inverts BOTH background and foreground, which on a
409
+ * `#5A65D8` background swaps to a white background — visually that
410
+ * reads as a solid white block instead of a scan band.
411
+ * - The off-white foreground + bold keeps the original background
412
+ * visible behind each glyph, so the band reads as "lit text" not
413
+ * as a solid stripe — closer to the CSDN-style scan-band reference
414
+ * image.
415
+ *
416
+ * Phase formula:
417
+ * phase ∈ [0, 1) over MARQUEE_PERIOD_MS
418
+ * sweep = phase < 0.5 ? phase * 2 : (1 - phase) * 2 ← triangle wave 0→1→0
419
+ * center = sweep * (width - 1)
420
+ * band covers visible-cell range [center - half, center + half]
421
+ *
422
+ * When the band would land outside the visible range (center ≤ half
423
+ * OR center ≥ width-1-half) it is clipped — the band never appears
424
+ * off-line. At `nowMs = 0` the band sits at the LEFT edge covering
425
+ * cells [0, bandWidth); this is a deliberate deterministic anchor
426
+ * for tests that pin `withPinnedClock(0, ...)`.
427
+ *
428
+ * ASCII tier: skipped — there are no SGR codes to inject.
429
+ */
430
+ const MARQUEE_PERIOD_MS = 400;
431
+ const MARQUEE_BAND_WIDTH = 2;
432
+ /**
433
+ * Visible-character width of an ANSI-bearing string. Skips every
434
+ * `\x1b[...m` escape so the count reflects what the terminal paints,
435
+ * not the byte length on the wire.
436
+ */
437
+ export function visibleCharWidth(s) {
438
+ let w = 0;
439
+ for (let i = 0; i < s.length; i++) {
440
+ if (s[i] === '\x1b' && s[i + 1] === '[') {
441
+ const m = s.indexOf('m', i + 2);
442
+ if (m !== -1) {
443
+ i = m;
444
+ continue;
445
+ }
446
+ }
447
+ w++;
448
+ }
449
+ return w;
450
+ }
451
+ /**
452
+ * Split an ANSI string into a flat token stream. Escape sequences are
453
+ * grouped as `\x1b[...m` (single token, kind='esc'); everything else
454
+ * is grouped as a single 'text' run. Used by {@link applyMarquee} to
455
+ * locate visible-cell ranges inside an SGR-bearing string.
456
+ */
457
+ export function tokenizeAnsi(s) {
458
+ const out = [];
459
+ let i = 0;
460
+ let buf = '';
461
+ const flush = () => {
462
+ if (buf.length > 0) {
463
+ out.push({ kind: 'text', value: buf });
464
+ buf = '';
465
+ }
466
+ };
467
+ while (i < s.length) {
468
+ if (s[i] === '\x1b' && s[i + 1] === '[') {
469
+ const m = s.indexOf('m', i + 2);
470
+ if (m !== -1) {
471
+ flush();
472
+ out.push({ kind: 'esc', value: s.slice(i, m + 1) });
473
+ i = m + 1;
474
+ continue;
475
+ }
476
+ }
477
+ buf += s[i];
478
+ i++;
479
+ }
480
+ flush();
481
+ return out;
482
+ }
483
+ /**
484
+ * Apply the marquee highlight band to an ANSI-bearing string. Pure.
485
+ * - `bandStart` / `bandEnd` are visible-cell indices (0-based).
486
+ * - Cells in [bandStart, bandEnd] receive a `\x1b[1;38;2;224;224;224m`
487
+ * prefix; cells after the band receive the closing `\x1b[0m` reset
488
+ * so the rest of the line keeps its original SGR.
489
+ * - Empty strings and zero-width bands return the input unchanged.
490
+ */
491
+ export function applyMarqueeHighlight(s, bandStart, bandEnd) {
492
+ if (s.length === 0)
493
+ return s;
494
+ if (bandEnd < 0 || bandStart > bandEnd)
495
+ return s;
496
+ const tokens = tokenizeAnsi(s);
497
+ const parts = [];
498
+ let visibleIdx = 0;
499
+ let inside = false;
500
+ for (const tok of tokens) {
501
+ if (tok.kind === 'esc') {
502
+ parts.push(tok.value);
503
+ continue;
504
+ }
505
+ for (let k = 0; k < tok.value.length; k++) {
506
+ const cellPos = visibleIdx + k;
507
+ const inBand = cellPos >= bandStart && cellPos <= bandEnd;
508
+ if (inBand && !inside) {
509
+ parts.push(HIGHLIGHT_SGR_OPEN);
510
+ inside = true;
511
+ }
512
+ else if (!inBand && inside) {
513
+ parts.push('\x1b[0m');
514
+ inside = false;
515
+ }
516
+ parts.push(tok.value[k]);
517
+ }
518
+ visibleIdx += tok.value.length;
519
+ }
520
+ if (inside)
521
+ parts.push('\x1b[0m');
522
+ return parts.join('');
523
+ }
524
+ /**
525
+ * Compute the marquee band for a string at time `nowMs` and re-emit
526
+ * the string with the band applied. ASCII tier is a pass-through (no
527
+ * SGR to inject) — the scanner has no visible effect on plain text.
528
+ */
529
+ export function applyMarquee(s, nowMs, capability) {
530
+ if (capability === 'ascii')
531
+ return s;
532
+ const width = visibleCharWidth(s);
533
+ if (width === 0)
534
+ return s;
535
+ const phase = (nowMs % MARQUEE_PERIOD_MS) / MARQUEE_PERIOD_MS;
536
+ const sweep = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
537
+ const center = Math.round(sweep * (width - 1));
538
+ const halfBand = Math.floor(MARQUEE_BAND_WIDTH / 2);
539
+ const bandStart = Math.max(0, center - halfBand);
540
+ const bandEnd = Math.min(width - 1, center + halfBand);
541
+ return applyMarqueeHighlight(s, bandStart, bandEnd);
542
+ }
359
543
  /**
360
544
  * Render the status line. Pure — no I/O, no side effects. The output is
361
545
  * a single short line suitable for the bottom-of-terminal status bar.
@@ -367,34 +551,59 @@ export function resolveStatusLineCapability(input) {
367
551
  * Compact-state precedence: when `model.compact.kind !== 'none'` the
368
552
  * compact bar replaces the active/idle/stale content. The C1 baseline
369
553
  * line is preserved when the compact state is `none`.
554
+ *
555
+ * The full line is wrapped by {@link applyMarquee} so the brand-purple
556
+ * text + non-focal bar cells share a single colour surface while the
557
+ * scanner band paints its `#E0E0E0` highlight over a moving slice.
558
+ *
559
+ * Idle suppression: the marquee is OFF when `model.state === 'idle'`
560
+ * (and no compact state is active). Idle keeps the slow-blink `○`
561
+ * as the only motion — the user is asking whether the harness is
562
+ * running a skill; the scan band would compete for attention and
563
+ * obscure that question. Active / stale / invalid-presence / compact
564
+ * states all keep the marquee.
370
565
  */
371
- export function renderStatusLine(model, options) {
566
+ export function renderStatusLine(model, options, env) {
372
567
  const capability = options?.capability ?? DEFAULT_CAPABILITY;
373
- const palette = paletteFor(capability);
568
+ const noColor = env !== undefined ? isNoColor(env) : false;
569
+ const palette = paletteFor(capability, noColor);
374
570
  const root = rootLabel(model.projectRoot);
375
571
  const rootSuffix = root ? `${palette.trailSeparator}${root}` : '';
376
- const brand = brandText(capability);
572
+ const brand = brandText(capability, noColor);
377
573
  // Breathing pulse: key off a single wall-clock read at the start of
378
574
  // render so the output is deterministic for a given invocation. The
379
575
  // glyph remains a single cell so total visible width is constant.
380
576
  const nowMs = Date.now();
381
577
  const compactSegment = renderCompact(model.compact, palette);
382
- if (compactSegment.length > 0) {
578
+ let line;
579
+ const hasCompact = compactSegment.length > 0;
580
+ if (hasCompact) {
383
581
  // Compact state replaces the active / stale / idle skill content.
384
582
  // `invalid-presence` still surfaces its own diagnostic when compact
385
583
  // is also `invalid` (the compact diagnostic wins, since it's the
386
584
  // more recent failure mode).
387
- return `${brand} ${compactSegment}${rootSuffix}`;
585
+ line = `${brand} ${compactSegment}${rootSuffix}`;
388
586
  }
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}`;
587
+ else {
588
+ switch (model.state) {
589
+ case 'active':
590
+ line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf)}${rootSuffix}`;
591
+ break;
592
+ case 'stale':
593
+ line = `${brand} ${renderStale(model.presence, model.ageMs, palette, capability, noColor)}${rootSuffix}`;
594
+ break;
595
+ case 'invalid-presence':
596
+ line = `${brand} ${renderInvalid(palette)}${rootSuffix}`;
597
+ break;
598
+ case 'idle':
599
+ default:
600
+ line = `${brand} ${renderIdle(palette)}${rootSuffix}`;
601
+ break;
602
+ }
399
603
  }
604
+ // Marquee is OFF for idle (and only idle). Compact states always
605
+ // carry the band because the compact bar IS the headline. NO_COLOR
606
+ // skips the band entirely — there's no SGR to inject.
607
+ const shouldMarquee = !noColor && (hasCompact || model.state !== 'idle');
608
+ return shouldMarquee ? applyMarquee(line, nowMs, capability) : line;
400
609
  }
@@ -6,6 +6,7 @@ export type StatusLineStdin = {
6
6
  };
7
7
  cwd?: string;
8
8
  session_id?: string;
9
+ caller_id?: string;
9
10
  };
10
11
  export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
11
12
  export type StatusLinePresence = {
@@ -15,12 +16,17 @@ export type StatusLinePresence = {
15
16
  setAt?: string;
16
17
  claudeSessionId?: string;
17
18
  };
19
+ export type StatusLineActiveLeaf = {
20
+ role: string;
21
+ pendingCount: number;
22
+ };
18
23
  export type StatusLineModel = {
19
24
  state: StatusLineState;
20
25
  projectRoot: string | null;
21
26
  presence: StatusLinePresence | null;
22
27
  ageMs: number | null;
23
28
  compact: CompactStatuslineState;
29
+ activeLeaf: StatusLineActiveLeaf | null;
24
30
  };
25
31
  export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
26
32
  export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;