@aemonge-dev/pi-stardeck 0.12.0 → 0.14.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.14.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`;
@@ -163,10 +163,6 @@ function wrapFocus(text: string, width: number, maximumLines: number): string[]
163
163
  return lines.slice(0, maximumLines);
164
164
  }
165
165
 
166
- function mirrorKey(value: string): string {
167
- return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
168
- }
169
-
170
166
  function bandWrap(
171
167
  content: string,
172
168
  width: number,
@@ -191,12 +187,10 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
191
187
  // moment` — never an empty band while the session talks.
192
188
  return bandWrap('— no subject', width, theme, 'dim');
193
189
  }
194
- // When the subject mirrors the focused change (the gate law's
195
- // "subject mirrors its title"), it renders success-green like the
196
- // PWD — the mirror visible at a glance.
197
- const work = title.work;
198
- const mirrorsFocus = work !== null && mirrorKey(subject) === mirrorKey(work.change);
199
- return bandWrap(subject, width, theme, mirrorsFocus ? 'success' : 'accent');
190
+ // Human layout law 2026-11-10: the left band is Human-owned title —
191
+ // it can never mirror openspec, so the mirror coloring is gone and
192
+ // the band renders accent unconditionally.
193
+ return bandWrap(subject, width, theme, 'accent');
200
194
  }
201
195
  // Pre-title publishers: the legacy selection keeps the band alive.
202
196
  const selection = state.header?.selection;
@@ -237,15 +231,18 @@ function alignedSingleRow(left: string, right: string, width: number): string {
237
231
  return `${fittedLeft}${' '.repeat(gap)}${right}`;
238
232
  }
239
233
 
240
- function workTile(state: HeaderDeckState, theme: Theme): string {
234
+ function workTile(state: HeaderDeckState, theme: Theme, width: number): string {
241
235
  // 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).
236
+ // Width policy: the full tile from 56; 40–55 the attention/progress side
237
+ // yields first and the item count survives longest; below 40 the slot
238
+ // stays empty. A work slot without facts renders nothing — absent facts
239
+ // remove their slots, never sentinel dashes or narration.
240
+ if (width < 40) return '';
244
241
  const title = state.header?.title ?? null;
245
242
  if (!title) return '';
246
243
  const work = title.work;
247
- if (!work) return color(theme, 'dim', '— no change owns the moment');
248
- return deckWorkSegments(work)
244
+ if (!work) return '';
245
+ return deckWorkSegments(work, Date.now(), width < 56)
249
246
  .map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
250
247
  .join('');
251
248
  }
@@ -754,7 +751,7 @@ export function renderHeaderDeck(
754
751
  // Line 2 (four-line deck): the Subject leads with Work on the right end
755
752
  // (Human amendment 2026-10-23 — no clock, no chrome). Narrow frames: the
756
753
  // Work slot yields before the Subject does.
757
- const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
754
+ const titleRight = workTile(state, theme, boundedWidth);
758
755
  const focusLines = titleBandLines(state, boundedWidth, theme);
759
756
  const titleRows = [
760
757
  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,30 @@ 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;
44
+ const GATE_ACT_MAX = 180;
41
45
  const TURN_TOKENS_MAX = 999_999_999;
42
46
  const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
43
47
 
48
+ export type DeckTitleArtifactState = {
49
+ id: string;
50
+ done: boolean;
51
+ items?: { done: number; total: number };
52
+ };
53
+
44
54
  export type DeckTitleWorkState = {
45
55
  change: string;
46
56
  task?: string;
47
- done?: number;
48
- total?: number;
49
57
  schemaName?: string;
50
- ladder?: { done: number; total: number; rung?: string };
58
+ artifacts?: DeckTitleArtifactState[];
59
+ /** The artifact the work is in — first not done, else last done. */
60
+ currentArtifact?: string;
61
+ /** Attention facts, derived from publisher-owned truth only. */
62
+ awaitingValidation?: boolean;
63
+ focusAt?: number;
64
+ focusStale?: boolean;
65
+ /** The gate's relayed act line — dim commentary in the optional slot. */
66
+ gateAct?: string;
51
67
  };
52
68
 
53
69
  export type DeckTitleActivityState = {
@@ -121,46 +137,69 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
121
137
  if (!isRecord(value)) return undefined;
122
138
  const change = boundedText(value.change, IDENTIFIER_MAX);
123
139
  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
140
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
134
141
  if (task === null) return undefined;
135
142
  const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
136
143
  required: false,
137
144
  });
138
145
  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
- };
146
+ const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
147
+ required: false,
148
+ });
149
+ if (currentArtifact === null) return undefined;
150
+ let artifacts: DeckTitleArtifactState[] | undefined;
151
+ if ('artifacts' in value) {
152
+ if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
153
+ return undefined;
154
+ }
155
+ const parsed: DeckTitleArtifactState[] = [];
156
+ for (const entry of value.artifacts) {
157
+ if (!isRecord(entry)) return undefined;
158
+ const id = boundedText(entry.id, TASK_ID_MAX);
159
+ if (!id) return undefined;
160
+ if (typeof entry.done !== 'boolean') return undefined;
161
+ const artifactDone = entry.done;
162
+ let items: { done: number; total: number } | undefined;
163
+ if ('items' in entry) {
164
+ if (!isRecord(entry.items)) return undefined;
165
+ const done = boundedTotal(entry.items.done);
166
+ const total = boundedTotal(entry.items.total);
167
+ if (done === null || total === null) return undefined;
168
+ items = { done, total };
169
+ }
170
+ parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
171
+ }
172
+ artifacts = parsed;
173
+ }
174
+ if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
175
+ return undefined;
152
176
  }
153
- const counts =
154
- typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
155
- ? { done: boundedDone, total: boundedTotalValue }
156
- : {};
177
+ let focusAt: number | undefined;
178
+ if ('focusAt' in value) {
179
+ if (
180
+ typeof value.focusAt !== 'number' ||
181
+ !Number.isSafeInteger(value.focusAt) ||
182
+ value.focusAt < 0
183
+ ) {
184
+ return undefined;
185
+ }
186
+ focusAt = value.focusAt;
187
+ }
188
+ if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
189
+ return undefined;
190
+ }
191
+ const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
192
+ if (gateAct === null) return undefined;
157
193
  const extras = {
158
194
  ...(schemaName ? { schemaName } : {}),
159
- ...(ladder ? { ladder } : {}),
195
+ ...(artifacts ? { artifacts } : {}),
196
+ ...(currentArtifact ? { currentArtifact } : {}),
197
+ ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
198
+ ...(focusAt !== undefined ? { focusAt } : {}),
199
+ ...(value.focusStale === true ? { focusStale: true } : {}),
200
+ ...(gateAct ? { gateAct } : {}),
160
201
  };
161
- return task === undefined
162
- ? { change, ...counts, ...extras }
163
- : { change, task, ...counts, ...extras };
202
+ return task === undefined ? { change, ...extras } : { change, task, ...extras };
164
203
  }
165
204
 
166
205
  function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
@@ -232,33 +271,85 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
232
271
  export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
233
272
 
234
273
  /**
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.
274
+ * The Work label as colored segments — the truthful, de-duplicated tile
275
+ * under the layout law (truthful-rendered-state 2026-11-10):
276
+ * `<slug> › <artifact> <done/total> › gate: <act> › 󰄱 <items>` — the
277
+ * slug leads because openspec identity renders exclusively on the right
278
+ * (the left band is Human-owned title), then current artifact + artifact
279
+ * progress, the gate's relayed act dim in the optional slot, and the
280
+ * current artifact's file-truth item count. Attention is derived, never authored:
281
+ * `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
282
+ * (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
283
+ * group. Unknown facts drop their segments — absent items never render as
284
+ * zeros. Compact width: the attention/progress side yields first, the item
285
+ * count survives longest. Separators are chrome with hard deck-wide colors —
286
+ * `›` accent like every minor separator.
242
287
  */
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 });
288
+ export function deckWorkSegments(
289
+ work: DeckTitleWorkState,
290
+ now: number = Date.now(),
291
+ compact = false,
292
+ ): DeckWorkSegment[] {
293
+ if (work.awaitingValidation) {
294
+ return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
256
295
  }
257
- if (work.done !== undefined && work.total !== undefined) {
258
- segments.push({ text: ' › ', color: 'accent' });
296
+ // The slug leads the right side — openspec identity renders exclusively
297
+ // here (Human layout law 2026-11-10); the left band is Human-owned.
298
+ const segments: DeckWorkSegment[] = [
299
+ { text: work.change, color: 'success', bold: true },
300
+ ];
301
+ const stale = work.focusStale === true && work.focusAt !== undefined;
302
+ if (!compact) {
303
+ if (stale) {
304
+ segments.push({ text: ' › ', color: 'accent' });
305
+ segments.push({
306
+ text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
307
+ color: 'dim',
308
+ });
309
+ } else {
310
+ const artifacts = work.artifacts;
311
+ const current = work.currentArtifact;
312
+ if (artifacts && artifacts.length > 0 && current) {
313
+ const done = artifacts.filter((artifact) => artifact.done).length;
314
+ const currentItems = artifacts.find(
315
+ (artifact) => artifact.id === current,
316
+ )?.items;
317
+ const itemsSettled =
318
+ currentItems === undefined ||
319
+ currentItems.total === 0 ||
320
+ currentItems.done >= currentItems.total;
321
+ // One hue per state: success only when the artifacts are done AND
322
+ // the current artifact's items are settled — otherwise work is open.
323
+ const complete = done === artifacts.length && itemsSettled;
324
+ segments.push(
325
+ { text: ' › ', color: 'accent' },
326
+ {
327
+ text: `${current} ${done}/${artifacts.length}`,
328
+ color: complete ? 'success' : 'accent',
329
+ },
330
+ );
331
+ }
332
+ }
333
+ // The {optional} slot: the gate's relayed act — dim commentary that
334
+ // yields first on narrow frames.
335
+ if (work.gateAct) {
336
+ segments.push(
337
+ { text: ' › ', color: 'accent' },
338
+ { text: `gate: ${work.gateAct}`, color: 'dim' },
339
+ );
340
+ }
341
+ }
342
+ const items = work.currentArtifact
343
+ ? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
344
+ : undefined;
345
+ if (items && items.total > 0) {
346
+ const complete = items.done >= items.total;
347
+ if (segments.length > 0) {
348
+ segments.push({ text: ' › ', color: 'accent' });
349
+ }
259
350
  segments.push({
260
- text: `${complete ? '✔' : '󰄱'} ${work.done}/${work.total}`,
261
- color: base,
351
+ text: `${complete ? '✔' : '󰄱'} ${items.done}/${items.total}`,
352
+ color: complete ? 'success' : 'accent',
262
353
  ...(complete ? { bold: true } : {}),
263
354
  });
264
355
  }
@@ -17,6 +17,7 @@ import {
17
17
  } from './src/active-work.ts';
18
18
  import { collectDiagnostics } from './src/diagnostics.ts';
19
19
  import { loadConfig } from './src/config.ts';
20
+ import { resolveGateAct } from './src/gate-act.ts';
20
21
  import { collectOpenSpec, collectOpenSpecOverview } from './src/openspec.ts';
21
22
  import { collectOrchestration } from './src/orchestration.ts';
22
23
  import {
@@ -171,6 +172,14 @@ class GalacticaStatusRuntime {
171
172
  private openSpecOverview: OpenSpecOverview | undefined;
172
173
  private openSpecProjectDetected: boolean | undefined;
173
174
  private focus: OpenSpecFocus;
175
+ /** Publisher-owned timestamp of the current focus fact — seeded at
176
+ * construction, refreshed on every accepted focus change; staleness
177
+ * compares it against the change directory's file truth. */
178
+ private focusAt: number | undefined;
179
+ /** The gate's relayed act line for the focused change — commentary
180
+ * beside the declared focus, never a replacement; cleared whenever
181
+ * the focus moves so stale gate truth never survives a switch. */
182
+ private gateAct: { change: string; line?: string } | undefined;
174
183
  private subject: SessionSubject;
175
184
  private sessionName: string | undefined;
176
185
  private diagnosticsPath: string | undefined;
@@ -209,6 +218,7 @@ class GalacticaStatusRuntime {
209
218
  this.focus = focus;
210
219
  this.subject = subject;
211
220
  this.sessionName = sessionName;
221
+ this.focusAt = Date.now();
212
222
  const restoredAwaitingValidation =
213
223
  focus.mode === 'task' && focus.awaitingValidation;
214
224
  this.liveLifecycle = restoredAwaitingValidation ? 'waiting' : 'listening';
@@ -454,6 +464,19 @@ class GalacticaStatusRuntime {
454
464
  ) {
455
465
  return;
456
466
  }
467
+ // The gate act is focus-owned commentary: resolved only for a task
468
+ // focus inside an OpenSpec project, after the commit guard, so a
469
+ // superseded refresh never spends a probe on stale truth.
470
+ if (focus.mode === 'task' && hasOpenSpecProject) {
471
+ this.gateAct = {
472
+ change: focus.change,
473
+ line: await resolveGateAct({
474
+ change: focus.change,
475
+ projectRoot,
476
+ runner: this.runner,
477
+ }),
478
+ };
479
+ }
457
480
  const previous = this.store.get();
458
481
  this.updateRefreshHealth(
459
482
  'openspec',
@@ -628,23 +651,15 @@ class GalacticaStatusRuntime {
628
651
 
629
652
  private currentTitleState(snapshot = this.store.get()): TitleState {
630
653
  const facts = this.cockpitFacts;
631
- const focusedTaskId =
632
- facts.openSpec.mode === 'task' ? facts.openSpec.taskId : undefined;
633
- const taskTitle =
634
- facts.openSpec.mode === 'task' &&
635
- snapshot.openspec?.changeId === facts.openSpec.change &&
636
- !snapshot.openspec.meta.stale
637
- ? (snapshot.openspec.allTasks ?? snapshot.openspec.pendingTasks).find(
638
- (task) => task.id === focusedTaskId,
639
- )?.title
654
+ // The left title is Human-owned — session name, subject, directory —
655
+ // never openspec text (truthful-rendered-state law 2026-11-10); the
656
+ // gate act rides the work slot for the right-side tile.
657
+ const gateAct =
658
+ facts.openSpec.mode === 'task' && this.gateAct?.change === facts.openSpec.change
659
+ ? this.gateAct.line
640
660
  : undefined;
641
- // The band chip renders the fallback chain — task focus, native session
642
- // name, legacy subject, directory. The terminal titlebar is pi-title's
643
- // alone; this package never paints it (pure-renderer-split Slice 2).
644
661
  const subjectTitle = formatActiveWorkTitle({
645
- focus: facts.openSpec,
646
662
  subject: facts.subject,
647
- ...(taskTitle ? { taskTitle } : {}),
648
663
  ...(this.sessionName ? { sessionName: this.sessionName } : {}),
649
664
  projectRoot: snapshot.projectRoot,
650
665
  cwd: snapshot.cwd,
@@ -654,6 +669,8 @@ class GalacticaStatusRuntime {
654
669
  focus: facts.openSpec,
655
670
  openspec: snapshot.openspec,
656
671
  activity: this.titleActivity.current(),
672
+ focusAt: this.focusAt,
673
+ ...(gateAct ? { gateAct } : {}),
657
674
  });
658
675
  }
659
676
 
@@ -690,6 +707,10 @@ class GalacticaStatusRuntime {
690
707
  setFocus(focus: OpenSpecFocus): void {
691
708
  if (sameOpenSpecFocus(this.focus, focus)) return;
692
709
  this.focus = focus;
710
+ this.focusAt = Date.now();
711
+ if (focus.mode !== 'task' || this.gateAct?.change !== focus.change) {
712
+ this.gateAct = undefined;
713
+ }
693
714
  this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
694
715
  this.store.clearOpenSpec();
695
716
  if (focus.mode === 'task' && focus.awaitingValidation) {
@@ -20,7 +20,6 @@ export const NO_SESSION_SUBJECT: SessionSubject = { state: 'clear' };
20
20
 
21
21
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
22
22
  const MAX_TITLE_POINTS = 180;
23
- const MAX_TASK_TITLE_POINTS = 72;
24
23
  const MAX_SUBJECT_POINTS = 120;
25
24
 
26
25
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -160,31 +159,26 @@ export function sameSessionSubject(
160
159
  );
161
160
  }
162
161
 
162
+ /**
163
+ * The Human-owned left title: session name, subject, then the directory
164
+ * fallbacks. Layout law 2026-11-10 (truthful-rendered-state): openspec
165
+ * identity renders exclusively on the right work tile — this chain never
166
+ * carries change, task, or gate text.
167
+ */
163
168
  export function formatActiveWorkTitle(options: {
164
- focus: OpenSpecFocus;
165
169
  subject?: SessionSubject;
166
- taskTitle?: string;
167
170
  sessionName?: string;
168
171
  projectRoot?: string;
169
172
  cwd: string;
170
173
  }): string {
171
- let detail: string;
172
- if (options.focus.mode === 'task') {
173
- const taskTitle = options.taskTitle
174
- ? shorten(normalizeTitlePart(options.taskTitle), MAX_TASK_TITLE_POINTS)
175
- : '';
176
- detail = `${options.focus.change} › ${options.focus.taskId}${taskTitle ? ` ${taskTitle}` : ''}`;
177
- } else if (normalizeTitlePart(options.sessionName ?? '')) {
178
- detail = normalizeTitlePart(options.sessionName ?? '');
179
- } else if (options.subject && options.subject.state === 'set') {
180
- detail = options.subject.title;
181
- } else {
182
- detail =
183
- normalizeTitlePart(options.sessionName ?? '') ||
184
- normalizeTitlePart(basename(options.projectRoot ?? '')) ||
185
- normalizeTitlePart(basename(options.cwd)) ||
186
- 'pi';
187
- }
188
-
189
- return shorten(`π  ${normalizeTitlePart(detail)}`, MAX_TITLE_POINTS);
174
+ const detail =
175
+ normalizeTitlePart(options.sessionName ?? '') ||
176
+ (options.subject && options.subject.state === 'set' ? options.subject.title : '') ||
177
+ normalizeTitlePart(basename(options.projectRoot ?? '')) ||
178
+ normalizeTitlePart(basename(options.cwd)) ||
179
+ 'pi';
180
+
181
+ // Bare text — line 2 carries no chrome (frozen amendment): the title
182
+ // stands naked, and pi-title's naming is quoted, never re-decorated.
183
+ return shorten(detail, MAX_TITLE_POINTS);
190
184
  }
@@ -0,0 +1,85 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { resolve, sep } from 'node:path';
3
+ import type { CommandRunner } from './types.ts';
4
+
5
+ /** Relay budget: the gate's act line is cockpit commentary — a slow or
6
+ * absent gate must cost less than a refresh, never block one. */
7
+ const GATE_PROBE_TIMEOUT_MS = 2_000;
8
+ /** Re-probe cadence when the meta file cannot be statted at all — time
9
+ * is the only cache key left, so it steps lightly. */
10
+ const ABSENT_META_TTL_MS = 30_000;
11
+
12
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
13
+
14
+ interface GateActCacheEntry {
15
+ mtimeMs?: number;
16
+ at: number;
17
+ line: string | undefined;
18
+ }
19
+
20
+ /** mtime-keyed relay cache: one probe per meta mtime, negatives cached
21
+ * exactly like hits — a missing or refusing gate must never become a
22
+ * per-render spawn storm. Keyed by contained change root, not slug. */
23
+ const gateActCache = new Map<string, GateActCacheEntry>();
24
+
25
+ function containedChangeRoot(projectRoot: string, change: string): string | undefined {
26
+ const changesRoot = resolve(projectRoot, 'openspec', 'changes');
27
+ const changeRoot = resolve(changesRoot, change);
28
+ if (changeRoot === changesRoot || !changeRoot.startsWith(`${changesRoot}${sep}`)) {
29
+ return undefined;
30
+ }
31
+ return changeRoot;
32
+ }
33
+
34
+ /**
35
+ * The gate's teaching line for one change, relayed verbatim:
36
+ * `pios-gate <slug>` — the aim — owns truth and words; this module owns
37
+ * only placement and cost. Any non-zero exit (unknown changes error),
38
+ * timeout kill, spawn failure, or empty output yields `undefined` —
39
+ * declared focus renders alone, quietly. The meta file is never parsed:
40
+ * its mtime only keys the cache.
41
+ */
42
+ export async function resolveGateAct(options: {
43
+ change: string;
44
+ projectRoot: string;
45
+ runner: CommandRunner;
46
+ now?: number;
47
+ }): Promise<string | undefined> {
48
+ const changeRoot = containedChangeRoot(options.projectRoot, options.change);
49
+ if (!changeRoot) return undefined;
50
+ const now = options.now ?? Date.now();
51
+
52
+ let mtimeMs: number | undefined;
53
+ try {
54
+ mtimeMs = (await fs.stat(resolve(changeRoot, '.openspec.yaml'))).mtimeMs;
55
+ } catch {
56
+ mtimeMs = undefined;
57
+ }
58
+
59
+ const cached = gateActCache.get(changeRoot);
60
+ if (
61
+ cached &&
62
+ (cached.mtimeMs !== undefined
63
+ ? cached.mtimeMs === mtimeMs
64
+ : mtimeMs === undefined && now - cached.at < ABSENT_META_TTL_MS)
65
+ ) {
66
+ return cached.line;
67
+ }
68
+
69
+ let line: string | undefined;
70
+ try {
71
+ const result = await options.runner('pios-gate', [options.change], {
72
+ cwd: options.projectRoot,
73
+ timeoutMs: GATE_PROBE_TIMEOUT_MS,
74
+ });
75
+ if (result.code === 0 && !result.killed) {
76
+ const firstLine = String(result.stdout).split('\n', 1)[0] ?? '';
77
+ const cleaned = firstLine.replace(CONTROL_CHARACTERS, ' ').trim();
78
+ if (cleaned) line = cleaned;
79
+ }
80
+ } catch {
81
+ // Missing binary, spawn refusal, whatever went wrong: no gate line.
82
+ }
83
+ gateActCache.set(changeRoot, { mtimeMs, at: now, line });
84
+ return line;
85
+ }
@@ -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,14 +492,31 @@ 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;
382
- let instructionProgress: ApplyInstructionsResult['progress'];
383
520
  let hierarchy: OpenSpecHierarchyProgress | undefined;
384
521
  try {
385
522
  const instructions = parseApplyInstructions(
@@ -392,7 +529,6 @@ export async function collectOpenSpec(options: {
392
529
  );
393
530
  tasks = instructions.tasks;
394
531
  tasksPath = instructions.tasksPath;
395
- instructionProgress = instructions.progress;
396
532
 
397
533
  if (tasksPath) {
398
534
  try {
@@ -414,34 +550,46 @@ export async function collectOpenSpec(options: {
414
550
  // tile at 0/N (simplify-deck-defaults). CLI progress and list counts
415
551
  // remain fallbacks; the blocked-apply zero-guard stays for the
416
552
  // no-tasks case.
553
+ // File-truth ladder: checkbox tasks first, then done/total artifacts
554
+ // (file presence via `openspec status`) — CLI task opinion cannot count
555
+ // custom schemas and pins 0/N (truthful-rendered-state). With neither
556
+ // readable, counts stay absent — absence never becomes zero.
557
+ const artifactCounts = artifacts
558
+ ? {
559
+ done: artifacts.filter((artifact) => artifact.done).length,
560
+ total: artifacts.length,
561
+ }
562
+ : undefined;
417
563
  const completedTasks =
418
- tasks.length > 0
419
- ? tasks.filter((task) => task.done).length
420
- : Math.max(0, Math.trunc(instructionProgress?.complete ?? change.completedTasks));
421
- const totalTasks =
422
- tasks.length > 0
423
- ? tasks.length
424
- : Math.max(0, Math.trunc(instructionProgress?.total ?? change.totalTasks));
564
+ tasks.length > 0 ? tasks.filter((task) => task.done).length : artifactCounts?.done;
565
+ const totalTasks = tasks.length > 0 ? tasks.length : artifactCounts?.total;
425
566
  const complete =
426
- change.status === 'complete' || (totalTasks > 0 && completedTasks >= totalTasks);
567
+ change.status === 'complete' ||
568
+ (totalTasks !== undefined && totalTasks > 0 && (completedTasks ?? 0) >= totalTasks);
427
569
 
428
570
  return {
429
571
  projectRoot,
430
572
  changeId: change.name,
431
573
  title,
432
574
  phase: complete ? 'complete' : change.status,
433
- completedTasks,
434
- totalTasks,
435
- progressPercent:
436
- totalTasks > 0
437
- ? Math.max(0, Math.min(100, Math.round((completedTasks / totalTasks) * 100)))
438
- : 0,
575
+ ...(completedTasks !== undefined ? { completedTasks } : {}),
576
+ ...(totalTasks !== undefined ? { totalTasks } : {}),
577
+ ...(totalTasks !== undefined && totalTasks > 0
578
+ ? {
579
+ progressPercent: Math.max(
580
+ 0,
581
+ Math.min(100, Math.round(((completedTasks ?? 0) / totalTasks) * 100)),
582
+ ),
583
+ }
584
+ : {}),
439
585
  pendingTasks: tasks.filter((task) => !task.done),
440
586
  allTasks: tasks,
441
587
  ...(tasksPath ? { tasksPath } : {}),
442
588
  ...(hierarchy ? { hierarchy } : {}),
443
589
  ...(schemaName ? { schemaName } : {}),
444
- ...(ladder ? { ladder } : {}),
590
+ ...(artifacts ? { artifacts } : {}),
591
+ ...(currentArtifact ? { currentArtifact } : {}),
592
+ ...(changeModifiedAt !== undefined ? { changeModifiedAt } : {}),
445
593
  meta: {
446
594
  source: 'openspec-cli',
447
595
  refreshedAt: now,
@@ -99,7 +99,13 @@ export function formatOpenSpec(
99
99
  focusedTaskId: string | null,
100
100
  compact = false,
101
101
  ): string {
102
- const progress = `${state.completedTasks}/${state.totalTasks}`;
102
+ // File-truth counts only — absent counts render no progress prefix at
103
+ // all (absence never becomes zero, truthful-rendered-state).
104
+ const progress =
105
+ state.totalTasks !== undefined
106
+ ? `${state.completedTasks ?? 0}/${state.totalTasks}`
107
+ : '';
108
+ const lead = progress ? `${progress} ` : '';
103
109
  const title = compact
104
110
  ? compactChangeTitle(state.title, 26)
105
111
  : normalizeChangeTitle(state.title);
@@ -107,16 +113,16 @@ export function formatOpenSpec(
107
113
  ? ` stale ${ageText(state.meta.updatedAt, Date.now()) ?? ''}`.trimEnd()
108
114
  : '';
109
115
 
110
- if (state.phase === 'complete') return `${progress} ${title}${stale}`;
116
+ if (state.phase === 'complete') return `${lead}${title}${stale}`;
111
117
 
112
118
  const activeTask = focusedTaskId
113
119
  ? (state.allTasks ?? state.pendingTasks).find((task) => task.id === focusedTaskId)
114
120
  : undefined;
115
121
  if (activeTask) {
116
- return `${progress} ▶ ${focusedTaskText(activeTask, compact)}${stale}`;
122
+ return `${lead}▶ ${focusedTaskText(activeTask, compact)}${stale}`;
117
123
  }
118
124
 
119
- return `${progress} ${title}${stale}`;
125
+ return `${lead}${title}${stale}`;
120
126
  }
121
127
 
122
128
  function diagnosticsColor(state: DiagnosticsState, now: number): WidgetColor {
@@ -917,16 +923,19 @@ export function buildHeaderStatusEvent(
917
923
  ...projectedActivity,
918
924
  };
919
925
  }
920
- const progress = options.openSpec?.totalTasks
921
- ? [
922
- {
923
- icon: '󰘬',
924
- completed: options.openSpec.completedTasks,
925
- total: options.openSpec.totalTasks,
926
- color: openSpecColor(options.openSpec),
927
- },
928
- ]
929
- : [];
926
+ const openSpec = options.openSpec;
927
+ const progress =
928
+ typeof openSpec?.totalTasks === 'number' &&
929
+ typeof openSpec.completedTasks === 'number'
930
+ ? [
931
+ {
932
+ icon: '󰘬',
933
+ completed: openSpec.completedTasks,
934
+ total: openSpec.totalTasks,
935
+ color: openSpecColor(openSpec),
936
+ },
937
+ ]
938
+ : [];
930
939
 
931
940
  return {
932
941
  protocol: 1,
@@ -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,28 @@ 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
+ const GATE_ACT_MAX = 180;
42
+
43
+ export type TitleArtifactState = {
44
+ id: string;
45
+ done: boolean;
46
+ items?: { done: number; total: number };
47
+ };
40
48
 
41
49
  export type TitleWorkState = {
42
50
  change: string;
43
51
  task?: string;
44
- done?: number;
45
- total?: number;
46
52
  schemaName?: string;
47
- ladder?: { done: number; total: number; rung?: string };
53
+ artifacts?: TitleArtifactState[];
54
+ /** The artifact the work is in — first not done, else last done. */
55
+ currentArtifact?: string;
56
+ /** Attention facts, derived from publisher-owned truth only. */
57
+ awaitingValidation?: boolean;
58
+ focusAt?: number;
59
+ focusStale?: boolean;
60
+ /** The gate's relayed act line — dim commentary in the optional slot. */
61
+ gateAct?: string;
48
62
  };
49
63
 
50
64
  export type TitleActivityState = {
@@ -115,46 +129,69 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
115
129
  if (!isRecord(value)) return undefined;
116
130
  const change = boundedText(value.change, IDENTIFIER_MAX);
117
131
  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
132
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
128
133
  if (task === null) return undefined;
129
134
  const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
130
135
  required: false,
131
136
  });
132
137
  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
- };
138
+ const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
139
+ required: false,
140
+ });
141
+ if (currentArtifact === null) return undefined;
142
+ let artifacts: TitleArtifactState[] | undefined;
143
+ if ('artifacts' in value) {
144
+ if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
145
+ return undefined;
146
+ }
147
+ const parsed: TitleArtifactState[] = [];
148
+ for (const entry of value.artifacts) {
149
+ if (!isRecord(entry)) return undefined;
150
+ const id = boundedText(entry.id, TASK_ID_MAX);
151
+ if (!id) return undefined;
152
+ if (typeof entry.done !== 'boolean') return undefined;
153
+ const artifactDone = entry.done;
154
+ let items: { done: number; total: number } | undefined;
155
+ if ('items' in entry) {
156
+ if (!isRecord(entry.items)) return undefined;
157
+ const done = boundedTotal(entry.items.done);
158
+ const total = boundedTotal(entry.items.total);
159
+ if (done === null || total === null) return undefined;
160
+ items = { done, total };
161
+ }
162
+ parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
163
+ }
164
+ artifacts = parsed;
165
+ }
166
+ if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
167
+ return undefined;
146
168
  }
147
- const counts =
148
- typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
149
- ? { done: boundedDone, total: boundedTotalValue }
150
- : {};
169
+ let focusAt: number | undefined;
170
+ if ('focusAt' in value) {
171
+ if (
172
+ typeof value.focusAt !== 'number' ||
173
+ !Number.isSafeInteger(value.focusAt) ||
174
+ value.focusAt < 0
175
+ ) {
176
+ return undefined;
177
+ }
178
+ focusAt = value.focusAt;
179
+ }
180
+ if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
181
+ return undefined;
182
+ }
183
+ const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
184
+ if (gateAct === null) return undefined;
151
185
  const extras = {
152
186
  ...(schemaName ? { schemaName } : {}),
153
- ...(ladder ? { ladder } : {}),
187
+ ...(artifacts ? { artifacts } : {}),
188
+ ...(currentArtifact ? { currentArtifact } : {}),
189
+ ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
190
+ ...(focusAt !== undefined ? { focusAt } : {}),
191
+ ...(value.focusStale === true ? { focusStale: true } : {}),
192
+ ...(gateAct ? { gateAct } : {}),
154
193
  };
155
- return task === undefined
156
- ? { change, ...counts, ...extras }
157
- : { change, task, ...counts, ...extras };
194
+ return task === undefined ? { change, ...extras } : { change, task, ...extras };
158
195
  }
159
196
 
160
197
  function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
@@ -202,46 +239,49 @@ export function shortenTitleTarget(value: string): string {
202
239
  return `${points.slice(0, Math.max(1, TITLE_TARGET_MAX - 1)).join('')}…`;
203
240
  }
204
241
 
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
242
  /**
211
243
  * Derive the `work` slot from the OpenSpec focus and the focused snapshot.
212
244
  * A task focus owns its change; without a focus a passively collected change
213
- * still reports progress, just without a task id.
245
+ * still reports artifact facts, just without a task id. Counts live only on
246
+ * artifact `items` — file truth; absent counts are absent, never zeros.
214
247
  */
215
248
  export function deriveTitleWork(
216
249
  focus: OpenSpecFocus,
217
250
  openspec: OpenSpecState | null | undefined,
251
+ options: { focusAt?: number; gateAct?: string } = {},
218
252
  ): 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.
253
+ // Artifact facts come straight from the collector; nothing is invented
254
+ // here — absent facts simply drop out of the contract.
225
255
  const extras = (state: OpenSpecState | undefined) => {
226
256
  if (!state) return {};
227
257
  return {
228
258
  ...(state.schemaName ? { schemaName: state.schemaName } : {}),
229
- ...(state.ladder ? { ladder: state.ladder } : {}),
259
+ ...(state.artifacts ? { artifacts: state.artifacts } : {}),
260
+ ...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
230
261
  };
231
262
  };
232
263
  if (focus.mode === 'task') {
233
264
  const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
234
- const taskCounts = state ? counts(state) : undefined;
265
+ const stale =
266
+ options.focusAt !== undefined &&
267
+ state?.changeModifiedAt !== undefined &&
268
+ state.changeModifiedAt > options.focusAt;
235
269
  return {
236
270
  change: focus.change,
237
271
  task: focus.taskId,
238
- ...(taskCounts ?? {}),
239
272
  ...extras(state),
273
+ ...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
274
+ ...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
275
+ ...(stale ? { focusStale: true } : {}),
276
+ ...(options.gateAct ? { gateAct: options.gateAct } : {}),
240
277
  };
241
278
  }
242
279
  if (!openspec) return null;
243
- const taskCounts = counts(openspec);
244
- return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
280
+ return {
281
+ change: openspec.changeId,
282
+ ...extras(openspec),
283
+ ...(options.gateAct ? { gateAct: options.gateAct } : {}),
284
+ };
245
285
  }
246
286
 
247
287
  /**
@@ -254,11 +294,18 @@ export function buildTitleState(options: {
254
294
  focus: OpenSpecFocus;
255
295
  openspec?: OpenSpecState | null;
256
296
  activity?: TitleActivityState | null;
297
+ /** Publisher-owned timestamp of the current focus fact. */
298
+ focusAt?: number;
299
+ /** The gate's relayed act line for the focused change, if resolved. */
300
+ gateAct?: string;
257
301
  }): TitleState {
258
302
  return {
259
303
  v: TITLE_STATE_VERSION,
260
304
  subject: options.subject.state === 'set' ? options.subject.title : null,
261
- work: deriveTitleWork(options.focus, options.openspec),
305
+ work: deriveTitleWork(options.focus, options.openspec, {
306
+ focusAt: options.focusAt,
307
+ ...(options.gateAct ? { gateAct: options.gateAct } : {}),
308
+ }),
262
309
  activity: options.activity ?? null,
263
310
  waiver: null,
264
311
  };
@@ -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;
@@ -82,15 +91,23 @@ export interface OpenSpecState {
82
91
  changeId: string;
83
92
  title: string;
84
93
  phase: string;
85
- completedTasks: number;
86
- totalTasks: number;
87
- progressPercent: number;
94
+ /** File-truth counts — checkbox tasks first, then done/total
95
+ * artifacts. Absent when neither is readable: absence never becomes
96
+ * zero (truthful-rendered-state). */
97
+ completedTasks?: number;
98
+ totalTasks?: number;
99
+ progressPercent?: number;
88
100
  pendingTasks: OpenSpecTask[];
89
101
  allTasks?: OpenSpecTask[];
90
102
  tasksPath?: string;
91
103
  hierarchy?: OpenSpecHierarchyProgress;
92
104
  schemaName?: string;
93
- ladder?: { done: number; total: number; rung?: string };
105
+ artifacts?: OpenSpecArtifact[];
106
+ /** The artifact the work is currently in: first not yet done, falling
107
+ * back to the last done artifact once everything is complete. */
108
+ currentArtifact?: string;
109
+ /** Newest file mtime inside the change directory, when statable. */
110
+ changeModifiedAt?: number;
94
111
  meta: SourceMeta;
95
112
  }
96
113