@aemonge-dev/pi-stardeck 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aemonge-dev/pi-stardeck",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -48,10 +48,11 @@
48
48
  "check:openspec": "node scripts/check-openspec.mjs",
49
49
  "test:tooling": "node --import tsx/esm --test test/*.test.ts",
50
50
  "test:status": "npm --prefix packages/status test",
51
+ "test:work-state": "npm --prefix packages/work-state test",
51
52
  "test:stardeck": "npm --prefix packages/pi-stardeck test",
52
53
  "test:footer": "npm --prefix packages/footer test",
53
54
  "test:vim": "npm --prefix packages/pi-vim-top-border test",
54
- "test:components": "npm run test:status && npm run test:stardeck && npm run test:footer && npm run test:vim"
55
+ "test:components": "npm run test:status && npm run test:work-state && npm run test:stardeck && npm run test:footer && npm run test:vim"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "@earendil-works/pi-ai": "*",
@@ -43,7 +43,7 @@ The additive header protocol projects selection independently from live work:
43
43
 
44
44
  ```ts
45
45
  selection: {
46
- source: 'openspec' | 'session-work';
46
+ source: 'openspec' | 'subject' | 'legacy';
47
47
  titles: string[]; // one or two bounded, normalized titles
48
48
  color: HeaderColor;
49
49
  } | null;
@@ -66,7 +66,7 @@ function standardActivityAge(ageMs: number): string {
66
66
  return `${Math.min(99, Math.floor(hours / 24))}d`;
67
67
  }
68
68
 
69
- function compactActivityAge(ageMs: number): string {
69
+ export function compactActivityAge(ageMs: number): string {
70
70
  const age = boundedAge(ageMs);
71
71
  const seconds = Math.floor(age / 1_000);
72
72
  if (seconds < 100) return `${seconds}s`;
@@ -237,15 +237,18 @@ function alignedSingleRow(left: string, right: string, width: number): string {
237
237
  return `${fittedLeft}${' '.repeat(gap)}${right}`;
238
238
  }
239
239
 
240
- function workTile(state: HeaderDeckState, theme: Theme): string {
240
+ function workTile(state: HeaderDeckState, theme: Theme, width: number): string {
241
241
  // Line 2 right: Work — derived from OpenSpec focus, never authored here.
242
- // A fresh session keeps the slot with the accepted dim hint; pre-title
243
- // publishers leave it silent (absent facts remove their slots).
242
+ // Width policy: the full tile from 56; 40–55 the attention/progress side
243
+ // yields first and the item count survives longest; below 40 the slot
244
+ // stays empty. A work slot without facts renders nothing — absent facts
245
+ // remove their slots, never sentinel dashes or narration.
246
+ if (width < 40) return '';
244
247
  const title = state.header?.title ?? null;
245
248
  if (!title) return '';
246
249
  const work = title.work;
247
- if (!work) return color(theme, 'dim', '— no change owns the moment');
248
- return deckWorkSegments(work)
250
+ if (!work) return '';
251
+ return deckWorkSegments(work, Date.now(), width < 56)
249
252
  .map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
250
253
  .join('');
251
254
  }
@@ -754,7 +757,7 @@ export function renderHeaderDeck(
754
757
  // Line 2 (four-line deck): the Subject leads with Work on the right end
755
758
  // (Human amendment 2026-10-23 — no clock, no chrome). Narrow frames: the
756
759
  // Work slot yields before the Subject does.
757
- const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
760
+ const titleRight = workTile(state, theme, boundedWidth);
758
761
  const focusLines = titleBandLines(state, boundedWidth, theme);
759
762
  const titleRows = [
760
763
  alignedSingleRow(focusLines[0] ?? '', titleRight, boundedWidth),
@@ -96,12 +96,7 @@ export type HeaderWork = {
96
96
  activityPath?: HeaderActivitySegment[];
97
97
  };
98
98
 
99
- export const HEADER_SELECTION_SOURCES = [
100
- 'openspec',
101
- 'session-work',
102
- 'subject',
103
- 'legacy',
104
- ] as const;
99
+ export const HEADER_SELECTION_SOURCES = ['openspec', 'subject', 'legacy'] as const;
105
100
 
106
101
  export type HeaderSelection = {
107
102
  source: (typeof HEADER_SELECTION_SOURCES)[number];
@@ -12,7 +12,9 @@
12
12
  * the legacy selection/activity rendering.
13
13
  */
14
14
 
15
- export const DECK_TITLE_STATE_VERSION = 1 as const;
15
+ import { compactActivityAge } from './activity-age.ts';
16
+
17
+ export const DECK_TITLE_STATE_VERSION = 2 as const;
16
18
 
17
19
  export const DECK_TITLE_ACTIVITY_CLASSES = [
18
20
  'editing',
@@ -38,16 +40,27 @@ const TASK_ID_MAX = 32;
38
40
  const SPAN_ID_MAX = 64;
39
41
  const WAIVER_ID_MAX = 64;
40
42
  const NOTICE_MAX = 180;
43
+ const ARTIFACTS_MAX = 16;
41
44
  const TURN_TOKENS_MAX = 999_999_999;
42
45
  const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
43
46
 
47
+ export type DeckTitleArtifactState = {
48
+ id: string;
49
+ done: boolean;
50
+ items?: { done: number; total: number };
51
+ };
52
+
44
53
  export type DeckTitleWorkState = {
45
54
  change: string;
46
55
  task?: string;
47
- done?: number;
48
- total?: number;
49
56
  schemaName?: string;
50
- ladder?: { done: number; total: number; rung?: string };
57
+ artifacts?: DeckTitleArtifactState[];
58
+ /** The artifact the work is in — first not done, else last done. */
59
+ currentArtifact?: string;
60
+ /** Attention facts, derived from publisher-owned truth only. */
61
+ awaitingValidation?: boolean;
62
+ focusAt?: number;
63
+ focusStale?: boolean;
51
64
  };
52
65
 
53
66
  export type DeckTitleActivityState = {
@@ -121,46 +134,66 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
121
134
  if (!isRecord(value)) return undefined;
122
135
  const change = boundedText(value.change, IDENTIFIER_MAX);
123
136
  if (!change) return undefined;
124
- // Counts are optional as a pair: absent means unknown (never fabricated
125
- // zeros); present-but-invalid rejects the object.
126
- const hasCounts = 'done' in value && 'total' in value;
127
- if ('done' in value !== 'total' in value) return undefined;
128
- const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
129
- const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
130
- if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
131
- return undefined;
132
- }
133
137
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
134
138
  if (task === null) return undefined;
135
139
  const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
136
140
  required: false,
137
141
  });
138
142
  if (schemaName === null) return undefined;
139
- let ladder: { done: number; total: number; rung?: string } | undefined;
140
- if ('ladder' in value) {
141
- if (!isRecord(value.ladder)) return undefined;
142
- const ladderDone = boundedTotal(value.ladder.done);
143
- const ladderTotal = boundedTotal(value.ladder.total);
144
- if (ladderDone === null || ladderTotal === null) return undefined;
145
- const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
146
- if (ladderRung === null) return undefined;
147
- ladder = {
148
- done: ladderDone,
149
- total: ladderTotal,
150
- ...(ladderRung ? { rung: ladderRung } : {}),
151
- };
143
+ const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
144
+ required: false,
145
+ });
146
+ if (currentArtifact === null) return undefined;
147
+ let artifacts: DeckTitleArtifactState[] | undefined;
148
+ if ('artifacts' in value) {
149
+ if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
150
+ return undefined;
151
+ }
152
+ const parsed: DeckTitleArtifactState[] = [];
153
+ for (const entry of value.artifacts) {
154
+ if (!isRecord(entry)) return undefined;
155
+ const id = boundedText(entry.id, TASK_ID_MAX);
156
+ if (!id) return undefined;
157
+ if (typeof entry.done !== 'boolean') return undefined;
158
+ const artifactDone = entry.done;
159
+ let items: { done: number; total: number } | undefined;
160
+ if ('items' in entry) {
161
+ if (!isRecord(entry.items)) return undefined;
162
+ const done = boundedTotal(entry.items.done);
163
+ const total = boundedTotal(entry.items.total);
164
+ if (done === null || total === null) return undefined;
165
+ items = { done, total };
166
+ }
167
+ parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
168
+ }
169
+ artifacts = parsed;
170
+ }
171
+ if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
172
+ return undefined;
173
+ }
174
+ let focusAt: number | undefined;
175
+ if ('focusAt' in value) {
176
+ if (
177
+ typeof value.focusAt !== 'number' ||
178
+ !Number.isSafeInteger(value.focusAt) ||
179
+ value.focusAt < 0
180
+ ) {
181
+ return undefined;
182
+ }
183
+ focusAt = value.focusAt;
184
+ }
185
+ if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
186
+ return undefined;
152
187
  }
153
- const counts =
154
- typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
155
- ? { done: boundedDone, total: boundedTotalValue }
156
- : {};
157
188
  const extras = {
158
189
  ...(schemaName ? { schemaName } : {}),
159
- ...(ladder ? { ladder } : {}),
190
+ ...(artifacts ? { artifacts } : {}),
191
+ ...(currentArtifact ? { currentArtifact } : {}),
192
+ ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
193
+ ...(focusAt !== undefined ? { focusAt } : {}),
194
+ ...(value.focusStale === true ? { focusStale: true } : {}),
160
195
  };
161
- return task === undefined
162
- ? { change, ...counts, ...extras }
163
- : { change, task, ...counts, ...extras };
196
+ return task === undefined ? { change, ...extras } : { change, task, ...extras };
164
197
  }
165
198
 
166
199
  function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
@@ -232,33 +265,66 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
232
265
  export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
233
266
 
234
267
  /**
235
- * The Work label as colored segments — the human-chosen design: `change ⟩
236
- * done/total rung › 󰄱 done/total` in one hue per state (accent, success at
237
- * complete with a filled checkbox and bold counts). The rung name comes
238
- * straight from OpenSpec's status artifacts — never an invented phase
239
- * word. Separators are chrome with hard deck-wide colors — `⟩`
240
- * thinkingHigh-bold like every semantic separator, `›` accent like every
241
- * minor one. Unknown facts degrade honestly; the bare change is the floor.
268
+ * The Work label as colored segments — the truthful, de-duplicated tile:
269
+ * `execute 4/4 › 󰄱 12/15` — current artifact + artifact progress + the
270
+ * current artifact's file-truth item count. The change name lives on the
271
+ * subject side and never repeats here. Attention is derived, never authored:
272
+ * `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
273
+ * (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
274
+ * group. Unknown facts drop their segments — absent items never render as
275
+ * zeros. Compact width: the attention/progress side yields first, the item
276
+ * count survives longest. Separators are chrome with hard deck-wide colors —
277
+ * `›` accent like every minor separator.
242
278
  */
243
- export function deckWorkSegments(work: DeckTitleWorkState): DeckWorkSegment[] {
244
- const complete =
245
- work.done !== undefined &&
246
- work.total !== undefined &&
247
- work.total > 0 &&
248
- work.done >= work.total;
249
- const base = complete ? 'success' : 'accent';
250
- const segments: DeckWorkSegment[] = [{ text: work.change, color: base }];
251
- const ladder = work.ladder;
252
- if (ladder) {
253
- segments.push({ text: ' ⟩ ', color: 'thinkingHigh', bold: true });
254
- const group = `${ladder.done}/${ladder.total}${ladder.rung ? ` ${ladder.rung}` : ''}`;
255
- segments.push({ text: group, color: base });
279
+ export function deckWorkSegments(
280
+ work: DeckTitleWorkState,
281
+ now: number = Date.now(),
282
+ compact = false,
283
+ ): DeckWorkSegment[] {
284
+ if (work.awaitingValidation) {
285
+ return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
286
+ }
287
+ const segments: DeckWorkSegment[] = [];
288
+ const stale = work.focusStale === true && work.focusAt !== undefined;
289
+ if (!compact) {
290
+ if (stale) {
291
+ segments.push({
292
+ text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
293
+ color: 'dim',
294
+ });
295
+ } else {
296
+ const artifacts = work.artifacts;
297
+ const current = work.currentArtifact;
298
+ if (artifacts && artifacts.length > 0 && current) {
299
+ const done = artifacts.filter((artifact) => artifact.done).length;
300
+ const currentItems = artifacts.find(
301
+ (artifact) => artifact.id === current,
302
+ )?.items;
303
+ const itemsSettled =
304
+ currentItems === undefined ||
305
+ currentItems.total === 0 ||
306
+ currentItems.done >= currentItems.total;
307
+ // One hue per state: success only when the artifacts are done AND
308
+ // the current artifact's items are settled — otherwise work is open.
309
+ const complete = done === artifacts.length && itemsSettled;
310
+ segments.push({
311
+ text: `${current} ${done}/${artifacts.length}`,
312
+ color: complete ? 'success' : 'accent',
313
+ });
314
+ }
315
+ }
256
316
  }
257
- if (work.done !== undefined && work.total !== undefined) {
258
- segments.push({ text: ' › ', color: 'accent' });
317
+ const items = work.currentArtifact
318
+ ? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
319
+ : undefined;
320
+ if (items && items.total > 0) {
321
+ const complete = items.done >= items.total;
322
+ if (segments.length > 0) {
323
+ segments.push({ text: ' › ', color: 'accent' });
324
+ }
259
325
  segments.push({
260
- text: `${complete ? '✔' : '󰄱'} ${work.done}/${work.total}`,
261
- color: base,
326
+ text: `${complete ? '✔' : '󰄱'} ${items.done}/${items.total}`,
327
+ color: complete ? 'success' : 'accent',
262
328
  ...(complete ? { bold: true } : {}),
263
329
  });
264
330
  }
@@ -90,10 +90,10 @@ branch entry. The agent-callable `openspec_focus` tool shares the command's `sta
90
90
  safeguarded `set`, deliberate `replace`, validating `validate`, and persistent `clear`
91
91
  transitions; the `awaitingValidation` flag marks a focused task as awaiting Human
92
92
  validation and auto-clears on the next `set`, `replace`, or `clear`. The
93
- agent-callable `title` tool sets the sanitized session title shown when no OpenSpec
94
- focus owns the row. The retired `work_focus` and `stage` instruments are gone: stages
95
- derive from events and Taskflow phases, and legacy `session-work` entries from old
96
- sessions still restore and render, fail-soft.
93
+ retired `work_focus`, `stage`, and `title` instruments are gone: stages
94
+ derive from events and Taskflow phases, the focus row falls back to the
95
+ native session name (named by `pi-title`) and then to legacy `session-subject`
96
+ entries from old sessions, read defensively and fail-soft.
97
97
 
98
98
  Global routing belongs to native Ramona policy rather than this status package. This
99
99
  package does not inject parent routing prompts, block parent tools, create Session Work