@aemonge-dev/pi-stardeck 0.12.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.12.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": [
@@ -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),
@@ -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
  }
@@ -171,6 +171,10 @@ class GalacticaStatusRuntime {
171
171
  private openSpecOverview: OpenSpecOverview | undefined;
172
172
  private openSpecProjectDetected: boolean | undefined;
173
173
  private focus: OpenSpecFocus;
174
+ /** Publisher-owned timestamp of the current focus fact — seeded at
175
+ * construction, refreshed on every accepted focus change; staleness
176
+ * compares it against the change directory's file truth. */
177
+ private focusAt: number | undefined;
174
178
  private subject: SessionSubject;
175
179
  private sessionName: string | undefined;
176
180
  private diagnosticsPath: string | undefined;
@@ -209,6 +213,7 @@ class GalacticaStatusRuntime {
209
213
  this.focus = focus;
210
214
  this.subject = subject;
211
215
  this.sessionName = sessionName;
216
+ this.focusAt = Date.now();
212
217
  const restoredAwaitingValidation =
213
218
  focus.mode === 'task' && focus.awaitingValidation;
214
219
  this.liveLifecycle = restoredAwaitingValidation ? 'waiting' : 'listening';
@@ -654,6 +659,7 @@ class GalacticaStatusRuntime {
654
659
  focus: facts.openSpec,
655
660
  openspec: snapshot.openspec,
656
661
  activity: this.titleActivity.current(),
662
+ focusAt: this.focusAt,
657
663
  });
658
664
  }
659
665
 
@@ -690,6 +696,7 @@ class GalacticaStatusRuntime {
690
696
  setFocus(focus: OpenSpecFocus): void {
691
697
  if (sameOpenSpecFocus(this.focus, focus)) return;
692
698
  this.focus = focus;
699
+ this.focusAt = Date.now();
693
700
  this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
694
701
  this.store.clearOpenSpec();
695
702
  if (focus.mode === 'task' && focus.awaitingValidation) {
@@ -186,5 +186,8 @@ export function formatActiveWorkTitle(options: {
186
186
  'pi';
187
187
  }
188
188
 
189
- return shorten(`π ${normalizeTitlePart(detail)}`, MAX_TITLE_POINTS);
189
+ // Bare text — line 2 carries no chrome (frozen amendment): the session
190
+ // name, change, and task stand naked, and pi-title's naming is quoted,
191
+ // never re-decorated.
192
+ return shorten(normalizeTitlePart(detail), MAX_TITLE_POINTS);
190
193
  }
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import { resolve, sep } from 'node:path';
3
3
  import type {
4
4
  CommandRunner,
5
+ OpenSpecArtifact,
5
6
  OpenSpecHierarchyProgress,
6
7
  OpenSpecOverview,
7
8
  OpenSpecState,
@@ -27,6 +28,19 @@ interface ApplyInstructionsResult {
27
28
  tasksPath?: string;
28
29
  }
29
30
 
31
+ /** Parsed `openspec status` artifacts plus their resolved files. Paths stay
32
+ * internal: they feed the file-truth item counter, never the contract. */
33
+ interface ParsedArtifact extends OpenSpecArtifact {
34
+ resolvedPath?: string;
35
+ }
36
+
37
+ interface ParsedOpenSpecStatus {
38
+ schemaName?: string;
39
+ artifacts?: ParsedArtifact[];
40
+ currentArtifact?: string;
41
+ changeRoot?: string;
42
+ }
43
+
30
44
  function isRecord(value: unknown): value is Record<string, unknown> {
31
45
  return typeof value === 'object' && value !== null && !Array.isArray(value);
32
46
  }
@@ -117,6 +131,51 @@ export function parseTaskMarkdown(content: string): OpenSpecTask[] {
117
131
  return tasks;
118
132
  }
119
133
 
134
+ /** File-truth checkbox counting: `{ done, total }` over `- [x]` / `- [ ]`
135
+ * items. Zero checkboxes is absence, not zero — `undefined` emits only
136
+ * when a readable source file actually carries countable items. */
137
+ export function parseCheckboxCounts(
138
+ content: string,
139
+ ): { done: number; total: number } | undefined {
140
+ const pattern = /^\s*-\s*\[([ xX])\]/gmu;
141
+ let total = 0;
142
+ let done = 0;
143
+ let match: RegExpExecArray | null;
144
+ while ((match = pattern.exec(content)) !== null) {
145
+ total += 1;
146
+ if ((match[1] ?? '').toLowerCase() === 'x') done += 1;
147
+ }
148
+ return total > 0 ? { done, total } : undefined;
149
+ }
150
+
151
+ /** mtime-keyed parse cache: a file is re-read only after its mtime moves.
152
+ * Unreadable files stay uncached so the next refresh retries honestly. */
153
+ const artifactItemsCache = new Map<
154
+ string,
155
+ { mtimeMs: number; counts: { done: number; total: number } | undefined }
156
+ >();
157
+
158
+ export async function readArtifactItems(
159
+ path: string,
160
+ ): Promise<{ done: number; total: number } | undefined> {
161
+ let mtimeMs: number;
162
+ try {
163
+ mtimeMs = (await fs.stat(path)).mtimeMs;
164
+ } catch {
165
+ return undefined;
166
+ }
167
+ const cached = artifactItemsCache.get(path);
168
+ if (cached && cached.mtimeMs === mtimeMs) return cached.counts;
169
+ let counts: { done: number; total: number } | undefined;
170
+ try {
171
+ counts = parseCheckboxCounts(await fs.readFile(path, 'utf8'));
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ artifactItemsCache.set(path, { mtimeMs, counts });
176
+ return counts;
177
+ }
178
+
120
179
  export function parseTaskHierarchy(
121
180
  content: string,
122
181
  ): OpenSpecHierarchyProgress | undefined {
@@ -234,31 +293,60 @@ function chooseChange(
234
293
  return selected;
235
294
  }
236
295
 
237
- function parseOpenSpecStatus(value: unknown): {
238
- schemaName?: string;
239
- ladder?: { done: number; total: number; rung?: string };
240
- } {
296
+ function parseOpenSpecStatus(value: unknown): ParsedOpenSpecStatus {
241
297
  if (!isRecord(value)) return {};
242
298
  const schemaName =
243
299
  typeof value.schemaName === 'string' && value.schemaName.trim()
244
300
  ? value.schemaName.trim()
245
301
  : undefined;
246
- let ladder: { done: number; total: number; rung?: string } | undefined;
302
+ const changeRoot =
303
+ typeof value.changeRoot === 'string' && value.changeRoot.trim()
304
+ ? value.changeRoot.trim()
305
+ : undefined;
306
+ let artifacts: ParsedArtifact[] | undefined;
307
+ let currentArtifact: string | undefined;
247
308
  if (Array.isArray(value.artifacts) && value.artifacts.length > 0) {
248
- let done = 0;
249
- let rung: string | undefined;
309
+ const parsed: ParsedArtifact[] = [];
310
+ let lastDone: string | undefined;
250
311
  for (const artifact of value.artifacts) {
251
312
  if (!isRecord(artifact)) continue;
252
- const id = typeof artifact.id === 'string' ? artifact.id : undefined;
253
- if (rung === undefined && id) rung = id;
254
- if (artifact.status === 'done') {
255
- done += 1;
256
- if (id) rung = id;
313
+ const id =
314
+ typeof artifact.id === 'string' && artifact.id.trim()
315
+ ? artifact.id.trim()
316
+ : undefined;
317
+ if (!id) continue;
318
+ const done = artifact.status === 'done';
319
+ if (done) lastDone = id;
320
+ // The artifacts array carries a relative outputPath; the item counter
321
+ // resolves it against the change root — contained paths only.
322
+ let resolvedPath: string | undefined;
323
+ const outputPath =
324
+ typeof artifact.outputPath === 'string' && artifact.outputPath.trim()
325
+ ? artifact.outputPath.trim()
326
+ : undefined;
327
+ if (changeRoot && outputPath) {
328
+ const root = resolve(changeRoot);
329
+ const candidate = resolve(root, outputPath);
330
+ if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
331
+ resolvedPath = candidate;
332
+ }
257
333
  }
334
+ parsed.push({ id, done, ...(resolvedPath ? { resolvedPath } : {}) });
335
+ }
336
+ if (parsed.length > 0) {
337
+ // The current artifact is the one the work is in: the first not yet
338
+ // done; once everything is complete, the last done artifact.
339
+ const inProgress = parsed.find((artifact) => !artifact.done);
340
+ currentArtifact = inProgress ? inProgress.id : lastDone;
341
+ artifacts = parsed;
258
342
  }
259
- ladder = { done, total: value.artifacts.length, ...(rung ? { rung } : {}) };
260
343
  }
261
- return { ...(schemaName ? { schemaName } : {}), ...(ladder ? { ladder } : {}) };
344
+ return {
345
+ ...(schemaName ? { schemaName } : {}),
346
+ ...(artifacts ? { artifacts } : {}),
347
+ ...(currentArtifact ? { currentArtifact } : {}),
348
+ ...(changeRoot ? { changeRoot } : {}),
349
+ };
262
350
  }
263
351
 
264
352
  async function runJson(
@@ -295,6 +383,37 @@ async function isIdeaChange(projectRoot: string, changeName: string): Promise<bo
295
383
  }
296
384
  }
297
385
 
386
+ /** Newest mtime over the change directory's direct files — the file-truth
387
+ * "the change moved on" timestamp attention staleness compares against.
388
+ * Change directories are flat; absent or unreadable dirs stay undefined. */
389
+ async function changeDirectoryMtime(
390
+ projectRoot: string,
391
+ changeName: string,
392
+ ): Promise<number | undefined> {
393
+ const changesRoot = resolve(projectRoot, 'openspec', 'changes');
394
+ const changeRoot = resolve(changesRoot, changeName);
395
+ if (changeRoot !== changesRoot && !changeRoot.startsWith(`${changesRoot}${sep}`)) {
396
+ return undefined;
397
+ }
398
+ let entries;
399
+ try {
400
+ entries = await fs.readdir(changeRoot, { withFileTypes: true });
401
+ } catch {
402
+ return undefined;
403
+ }
404
+ let newest: number | undefined;
405
+ for (const entry of entries) {
406
+ if (!entry.isFile()) continue;
407
+ try {
408
+ const mtimeMs = (await fs.stat(resolve(changeRoot, entry.name))).mtimeMs;
409
+ newest = newest === undefined ? mtimeMs : Math.max(newest, mtimeMs);
410
+ } catch {
411
+ // A file vanishing between readdir and stat is honest absence.
412
+ }
413
+ }
414
+ return newest;
415
+ }
416
+
298
417
  export async function collectOpenSpecOverview(options: {
299
418
  cwd: string;
300
419
  runner: CommandRunner;
@@ -361,7 +480,8 @@ export async function collectOpenSpec(options: {
361
480
  }
362
481
 
363
482
  let schemaName: string | undefined;
364
- let ladder: OpenSpecState['ladder'];
483
+ let artifacts: OpenSpecArtifact[] | undefined;
484
+ let currentArtifact: string | undefined;
365
485
  try {
366
486
  const status = parseOpenSpecStatus(
367
487
  await runJson(
@@ -372,10 +492,28 @@ export async function collectOpenSpec(options: {
372
492
  ),
373
493
  );
374
494
  schemaName = status.schemaName;
375
- ladder = status.ladder;
495
+ currentArtifact = status.currentArtifact;
496
+ if (status.artifacts) {
497
+ // Item counts come from the artifact FILES, not CLI opinion: the CLI
498
+ // cannot count custom-schema items (taskTrackingConfigured: false),
499
+ // and only readable files emit counts — absence never becomes zero.
500
+ artifacts = await Promise.all(
501
+ status.artifacts.map(async (artifact) => {
502
+ const items = artifact.resolvedPath
503
+ ? await readArtifactItems(artifact.resolvedPath)
504
+ : undefined;
505
+ return {
506
+ id: artifact.id,
507
+ done: artifact.done,
508
+ ...(items ? { items } : {}),
509
+ };
510
+ }),
511
+ );
512
+ }
376
513
  } catch {
377
- // Ladder facts are optional; the list counts remain valid without them.
514
+ // Artifact facts are optional; the list counts remain valid without them.
378
515
  }
516
+ const changeModifiedAt = await changeDirectoryMtime(projectRoot, change.name);
379
517
 
380
518
  let tasks: OpenSpecTask[] = [];
381
519
  let tasksPath: string | undefined;
@@ -441,7 +579,9 @@ export async function collectOpenSpec(options: {
441
579
  ...(tasksPath ? { tasksPath } : {}),
442
580
  ...(hierarchy ? { hierarchy } : {}),
443
581
  ...(schemaName ? { schemaName } : {}),
444
- ...(ladder ? { ladder } : {}),
582
+ ...(artifacts ? { artifacts } : {}),
583
+ ...(currentArtifact ? { currentArtifact } : {}),
584
+ ...(changeModifiedAt !== undefined ? { changeModifiedAt } : {}),
445
585
  meta: {
446
586
  source: 'openspec-cli',
447
587
  refreshedAt: now,
@@ -10,7 +10,7 @@ import type { OpenSpecState } from './types.ts';
10
10
  * code may never write any slot; it only parses defensively.
11
11
  */
12
12
 
13
- export const TITLE_STATE_VERSION = 1 as const;
13
+ export const TITLE_STATE_VERSION = 2 as const;
14
14
 
15
15
  export const TITLE_ACTIVITY_CLASSES = [
16
16
  'editing',
@@ -37,14 +37,25 @@ const TASK_ID_MAX = 32;
37
37
  const SPAN_ID_MAX = 64;
38
38
  const WAIVER_ID_MAX = 64;
39
39
  const NOTICE_MAX = 180;
40
+ const ARTIFACTS_MAX = 16;
41
+
42
+ export type TitleArtifactState = {
43
+ id: string;
44
+ done: boolean;
45
+ items?: { done: number; total: number };
46
+ };
40
47
 
41
48
  export type TitleWorkState = {
42
49
  change: string;
43
50
  task?: string;
44
- done?: number;
45
- total?: number;
46
51
  schemaName?: string;
47
- ladder?: { done: number; total: number; rung?: string };
52
+ artifacts?: TitleArtifactState[];
53
+ /** The artifact the work is in — first not done, else last done. */
54
+ currentArtifact?: string;
55
+ /** Attention facts, derived from publisher-owned truth only. */
56
+ awaitingValidation?: boolean;
57
+ focusAt?: number;
58
+ focusStale?: boolean;
48
59
  };
49
60
 
50
61
  export type TitleActivityState = {
@@ -115,46 +126,66 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
115
126
  if (!isRecord(value)) return undefined;
116
127
  const change = boundedText(value.change, IDENTIFIER_MAX);
117
128
  if (!change) return undefined;
118
- // Counts are optional as a pair: absent means unknown (never fabricated
119
- // zeros); present-but-invalid rejects the object.
120
- const hasCounts = 'done' in value && 'total' in value;
121
- if ('done' in value !== 'total' in value) return undefined;
122
- const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
123
- const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
124
- if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
125
- return undefined;
126
- }
127
129
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
128
130
  if (task === null) return undefined;
129
131
  const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
130
132
  required: false,
131
133
  });
132
134
  if (schemaName === null) return undefined;
133
- let ladder: { done: number; total: number; rung?: string } | undefined;
134
- if ('ladder' in value) {
135
- if (!isRecord(value.ladder)) return undefined;
136
- const ladderDone = boundedTotal(value.ladder.done);
137
- const ladderTotal = boundedTotal(value.ladder.total);
138
- if (ladderDone === null || ladderTotal === null) return undefined;
139
- const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
140
- if (ladderRung === null) return undefined;
141
- ladder = {
142
- done: ladderDone,
143
- total: ladderTotal,
144
- ...(ladderRung ? { rung: ladderRung } : {}),
145
- };
135
+ const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
136
+ required: false,
137
+ });
138
+ if (currentArtifact === null) return undefined;
139
+ let artifacts: TitleArtifactState[] | undefined;
140
+ if ('artifacts' in value) {
141
+ if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
142
+ return undefined;
143
+ }
144
+ const parsed: TitleArtifactState[] = [];
145
+ for (const entry of value.artifacts) {
146
+ if (!isRecord(entry)) return undefined;
147
+ const id = boundedText(entry.id, TASK_ID_MAX);
148
+ if (!id) return undefined;
149
+ if (typeof entry.done !== 'boolean') return undefined;
150
+ const artifactDone = entry.done;
151
+ let items: { done: number; total: number } | undefined;
152
+ if ('items' in entry) {
153
+ if (!isRecord(entry.items)) return undefined;
154
+ const done = boundedTotal(entry.items.done);
155
+ const total = boundedTotal(entry.items.total);
156
+ if (done === null || total === null) return undefined;
157
+ items = { done, total };
158
+ }
159
+ parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
160
+ }
161
+ artifacts = parsed;
162
+ }
163
+ if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
164
+ return undefined;
165
+ }
166
+ let focusAt: number | undefined;
167
+ if ('focusAt' in value) {
168
+ if (
169
+ typeof value.focusAt !== 'number' ||
170
+ !Number.isSafeInteger(value.focusAt) ||
171
+ value.focusAt < 0
172
+ ) {
173
+ return undefined;
174
+ }
175
+ focusAt = value.focusAt;
176
+ }
177
+ if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
178
+ return undefined;
146
179
  }
147
- const counts =
148
- typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
149
- ? { done: boundedDone, total: boundedTotalValue }
150
- : {};
151
180
  const extras = {
152
181
  ...(schemaName ? { schemaName } : {}),
153
- ...(ladder ? { ladder } : {}),
182
+ ...(artifacts ? { artifacts } : {}),
183
+ ...(currentArtifact ? { currentArtifact } : {}),
184
+ ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
185
+ ...(focusAt !== undefined ? { focusAt } : {}),
186
+ ...(value.focusStale === true ? { focusStale: true } : {}),
154
187
  };
155
- return task === undefined
156
- ? { change, ...counts, ...extras }
157
- : { change, task, ...counts, ...extras };
188
+ return task === undefined ? { change, ...extras } : { change, task, ...extras };
158
189
  }
159
190
 
160
191
  function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
@@ -202,46 +233,44 @@ export function shortenTitleTarget(value: string): string {
202
233
  return `${points.slice(0, Math.max(1, TITLE_TARGET_MAX - 1)).join('')}…`;
203
234
  }
204
235
 
205
- function boundedCount(value: number | undefined, fallback = 0): number {
206
- const candidate = value === undefined || !Number.isFinite(value) ? fallback : value;
207
- return Math.max(0, Math.min(TITLE_TOTAL_MAX, Math.round(candidate)));
208
- }
209
-
210
236
  /**
211
237
  * Derive the `work` slot from the OpenSpec focus and the focused snapshot.
212
238
  * A task focus owns its change; without a focus a passively collected change
213
- * still reports progress, just without a task id.
239
+ * still reports artifact facts, just without a task id. Counts live only on
240
+ * artifact `items` — file truth; absent counts are absent, never zeros.
214
241
  */
215
242
  export function deriveTitleWork(
216
243
  focus: OpenSpecFocus,
217
244
  openspec: OpenSpecState | null | undefined,
245
+ options: { focusAt?: number } = {},
218
246
  ): TitleWorkState | null {
219
- const counts = (state: OpenSpecState): { done: number; total: number } => ({
220
- done: boundedCount(state.hierarchy?.tasks?.completed, state.completedTasks),
221
- total: boundedCount(state.hierarchy?.tasks?.total, state.totalTasks),
222
- });
223
- // Human steering 2026-10-23: no invented phase words — the ladder's
224
- // rung name comes straight from OpenSpec.
247
+ // Artifact facts come straight from the collector; nothing is invented
248
+ // here — absent facts simply drop out of the contract.
225
249
  const extras = (state: OpenSpecState | undefined) => {
226
250
  if (!state) return {};
227
251
  return {
228
252
  ...(state.schemaName ? { schemaName: state.schemaName } : {}),
229
- ...(state.ladder ? { ladder: state.ladder } : {}),
253
+ ...(state.artifacts ? { artifacts: state.artifacts } : {}),
254
+ ...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
230
255
  };
231
256
  };
232
257
  if (focus.mode === 'task') {
233
258
  const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
234
- const taskCounts = state ? counts(state) : undefined;
259
+ const stale =
260
+ options.focusAt !== undefined &&
261
+ state?.changeModifiedAt !== undefined &&
262
+ state.changeModifiedAt > options.focusAt;
235
263
  return {
236
264
  change: focus.change,
237
265
  task: focus.taskId,
238
- ...(taskCounts ?? {}),
239
266
  ...extras(state),
267
+ ...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
268
+ ...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
269
+ ...(stale ? { focusStale: true } : {}),
240
270
  };
241
271
  }
242
272
  if (!openspec) return null;
243
- const taskCounts = counts(openspec);
244
- return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
273
+ return { change: openspec.changeId, ...extras(openspec) };
245
274
  }
246
275
 
247
276
  /**
@@ -254,11 +283,15 @@ export function buildTitleState(options: {
254
283
  focus: OpenSpecFocus;
255
284
  openspec?: OpenSpecState | null;
256
285
  activity?: TitleActivityState | null;
286
+ /** Publisher-owned timestamp of the current focus fact. */
287
+ focusAt?: number;
257
288
  }): TitleState {
258
289
  return {
259
290
  v: TITLE_STATE_VERSION,
260
291
  subject: options.subject.state === 'set' ? options.subject.title : null,
261
- work: deriveTitleWork(options.focus, options.openspec),
292
+ work: deriveTitleWork(options.focus, options.openspec, {
293
+ focusAt: options.focusAt,
294
+ }),
262
295
  activity: options.activity ?? null,
263
296
  waiver: null,
264
297
  };
@@ -60,6 +60,15 @@ export interface OpenSpecTask {
60
60
  done: boolean;
61
61
  }
62
62
 
63
+ /** One OpenSpec artifact: lifecycle from `openspec status`, item counts
64
+ * from the artifact's own file. Absent items mean unreadable or empty —
65
+ * never zeros. */
66
+ export interface OpenSpecArtifact {
67
+ id: string;
68
+ done: boolean;
69
+ items?: { done: number; total: number };
70
+ }
71
+
63
72
  export interface OpenSpecOverview {
64
73
  projectRoot: string;
65
74
  actionableChanges: number;
@@ -90,7 +99,12 @@ export interface OpenSpecState {
90
99
  tasksPath?: string;
91
100
  hierarchy?: OpenSpecHierarchyProgress;
92
101
  schemaName?: string;
93
- ladder?: { done: number; total: number; rung?: string };
102
+ artifacts?: OpenSpecArtifact[];
103
+ /** The artifact the work is currently in: first not yet done, falling
104
+ * back to the last done artifact once everything is complete. */
105
+ currentArtifact?: string;
106
+ /** Newest file mtime inside the change directory, when statable. */
107
+ changeModifiedAt?: number;
94
108
  meta: SourceMeta;
95
109
  }
96
110