@aemonge-dev/pi-stardeck 0.13.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.13.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": [
@@ -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;
@@ -41,6 +41,7 @@ const SPAN_ID_MAX = 64;
41
41
  const WAIVER_ID_MAX = 64;
42
42
  const NOTICE_MAX = 180;
43
43
  const ARTIFACTS_MAX = 16;
44
+ const GATE_ACT_MAX = 180;
44
45
  const TURN_TOKENS_MAX = 999_999_999;
45
46
  const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
46
47
 
@@ -61,6 +62,8 @@ export type DeckTitleWorkState = {
61
62
  awaitingValidation?: boolean;
62
63
  focusAt?: number;
63
64
  focusStale?: boolean;
65
+ /** The gate's relayed act line — dim commentary in the optional slot. */
66
+ gateAct?: string;
64
67
  };
65
68
 
66
69
  export type DeckTitleActivityState = {
@@ -185,6 +188,8 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
185
188
  if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
186
189
  return undefined;
187
190
  }
191
+ const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
192
+ if (gateAct === null) return undefined;
188
193
  const extras = {
189
194
  ...(schemaName ? { schemaName } : {}),
190
195
  ...(artifacts ? { artifacts } : {}),
@@ -192,6 +197,7 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
192
197
  ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
193
198
  ...(focusAt !== undefined ? { focusAt } : {}),
194
199
  ...(value.focusStale === true ? { focusStale: true } : {}),
200
+ ...(gateAct ? { gateAct } : {}),
195
201
  };
196
202
  return task === undefined ? { change, ...extras } : { change, task, ...extras };
197
203
  }
@@ -265,10 +271,13 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
265
271
  export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
266
272
 
267
273
  /**
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:
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:
272
281
  * `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
273
282
  * (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
274
283
  * group. Unknown facts drop their segments — absent items never render as
@@ -284,10 +293,15 @@ export function deckWorkSegments(
284
293
  if (work.awaitingValidation) {
285
294
  return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
286
295
  }
287
- const segments: DeckWorkSegment[] = [];
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
+ ];
288
301
  const stale = work.focusStale === true && work.focusAt !== undefined;
289
302
  if (!compact) {
290
303
  if (stale) {
304
+ segments.push({ text: ' › ', color: 'accent' });
291
305
  segments.push({
292
306
  text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
293
307
  color: 'dim',
@@ -307,12 +321,23 @@ export function deckWorkSegments(
307
321
  // One hue per state: success only when the artifacts are done AND
308
322
  // the current artifact's items are settled — otherwise work is open.
309
323
  const complete = done === artifacts.length && itemsSettled;
310
- segments.push({
311
- text: `${current} ${done}/${artifacts.length}`,
312
- color: complete ? 'success' : 'accent',
313
- });
324
+ segments.push(
325
+ { text: ' › ', color: 'accent' },
326
+ {
327
+ text: `${current} ${done}/${artifacts.length}`,
328
+ color: complete ? 'success' : 'accent',
329
+ },
330
+ );
314
331
  }
315
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
+ }
316
341
  }
317
342
  const items = work.currentArtifact
318
343
  ? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
@@ -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 {
@@ -175,6 +176,10 @@ class GalacticaStatusRuntime {
175
176
  * construction, refreshed on every accepted focus change; staleness
176
177
  * compares it against the change directory's file truth. */
177
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;
178
183
  private subject: SessionSubject;
179
184
  private sessionName: string | undefined;
180
185
  private diagnosticsPath: string | undefined;
@@ -459,6 +464,19 @@ class GalacticaStatusRuntime {
459
464
  ) {
460
465
  return;
461
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
+ }
462
480
  const previous = this.store.get();
463
481
  this.updateRefreshHealth(
464
482
  'openspec',
@@ -633,23 +651,15 @@ class GalacticaStatusRuntime {
633
651
 
634
652
  private currentTitleState(snapshot = this.store.get()): TitleState {
635
653
  const facts = this.cockpitFacts;
636
- const focusedTaskId =
637
- facts.openSpec.mode === 'task' ? facts.openSpec.taskId : undefined;
638
- const taskTitle =
639
- facts.openSpec.mode === 'task' &&
640
- snapshot.openspec?.changeId === facts.openSpec.change &&
641
- !snapshot.openspec.meta.stale
642
- ? (snapshot.openspec.allTasks ?? snapshot.openspec.pendingTasks).find(
643
- (task) => task.id === focusedTaskId,
644
- )?.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
645
660
  : undefined;
646
- // The band chip renders the fallback chain — task focus, native session
647
- // name, legacy subject, directory. The terminal titlebar is pi-title's
648
- // alone; this package never paints it (pure-renderer-split Slice 2).
649
661
  const subjectTitle = formatActiveWorkTitle({
650
- focus: facts.openSpec,
651
662
  subject: facts.subject,
652
- ...(taskTitle ? { taskTitle } : {}),
653
663
  ...(this.sessionName ? { sessionName: this.sessionName } : {}),
654
664
  projectRoot: snapshot.projectRoot,
655
665
  cwd: snapshot.cwd,
@@ -660,6 +670,7 @@ class GalacticaStatusRuntime {
660
670
  openspec: snapshot.openspec,
661
671
  activity: this.titleActivity.current(),
662
672
  focusAt: this.focusAt,
673
+ ...(gateAct ? { gateAct } : {}),
663
674
  });
664
675
  }
665
676
 
@@ -697,6 +708,9 @@ class GalacticaStatusRuntime {
697
708
  if (sameOpenSpecFocus(this.focus, focus)) return;
698
709
  this.focus = focus;
699
710
  this.focusAt = Date.now();
711
+ if (focus.mode !== 'task' || this.gateAct?.change !== focus.change) {
712
+ this.gateAct = undefined;
713
+ }
700
714
  this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
701
715
  this.store.clearOpenSpec();
702
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,34 +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
- // 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);
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);
193
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
+ }
@@ -517,7 +517,6 @@ export async function collectOpenSpec(options: {
517
517
 
518
518
  let tasks: OpenSpecTask[] = [];
519
519
  let tasksPath: string | undefined;
520
- let instructionProgress: ApplyInstructionsResult['progress'];
521
520
  let hierarchy: OpenSpecHierarchyProgress | undefined;
522
521
  try {
523
522
  const instructions = parseApplyInstructions(
@@ -530,7 +529,6 @@ export async function collectOpenSpec(options: {
530
529
  );
531
530
  tasks = instructions.tasks;
532
531
  tasksPath = instructions.tasksPath;
533
- instructionProgress = instructions.progress;
534
532
 
535
533
  if (tasksPath) {
536
534
  try {
@@ -552,28 +550,38 @@ export async function collectOpenSpec(options: {
552
550
  // tile at 0/N (simplify-deck-defaults). CLI progress and list counts
553
551
  // remain fallbacks; the blocked-apply zero-guard stays for the
554
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;
555
563
  const completedTasks =
556
- tasks.length > 0
557
- ? tasks.filter((task) => task.done).length
558
- : Math.max(0, Math.trunc(instructionProgress?.complete ?? change.completedTasks));
559
- const totalTasks =
560
- tasks.length > 0
561
- ? tasks.length
562
- : 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;
563
566
  const complete =
564
- change.status === 'complete' || (totalTasks > 0 && completedTasks >= totalTasks);
567
+ change.status === 'complete' ||
568
+ (totalTasks !== undefined && totalTasks > 0 && (completedTasks ?? 0) >= totalTasks);
565
569
 
566
570
  return {
567
571
  projectRoot,
568
572
  changeId: change.name,
569
573
  title,
570
574
  phase: complete ? 'complete' : change.status,
571
- completedTasks,
572
- totalTasks,
573
- progressPercent:
574
- totalTasks > 0
575
- ? Math.max(0, Math.min(100, Math.round((completedTasks / totalTasks) * 100)))
576
- : 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
+ : {}),
577
585
  pendingTasks: tasks.filter((task) => !task.done),
578
586
  allTasks: tasks,
579
587
  ...(tasksPath ? { tasksPath } : {}),
@@ -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,
@@ -38,6 +38,7 @@ const SPAN_ID_MAX = 64;
38
38
  const WAIVER_ID_MAX = 64;
39
39
  const NOTICE_MAX = 180;
40
40
  const ARTIFACTS_MAX = 16;
41
+ const GATE_ACT_MAX = 180;
41
42
 
42
43
  export type TitleArtifactState = {
43
44
  id: string;
@@ -56,6 +57,8 @@ export type TitleWorkState = {
56
57
  awaitingValidation?: boolean;
57
58
  focusAt?: number;
58
59
  focusStale?: boolean;
60
+ /** The gate's relayed act line — dim commentary in the optional slot. */
61
+ gateAct?: string;
59
62
  };
60
63
 
61
64
  export type TitleActivityState = {
@@ -177,6 +180,8 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
177
180
  if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
178
181
  return undefined;
179
182
  }
183
+ const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
184
+ if (gateAct === null) return undefined;
180
185
  const extras = {
181
186
  ...(schemaName ? { schemaName } : {}),
182
187
  ...(artifacts ? { artifacts } : {}),
@@ -184,6 +189,7 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
184
189
  ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
185
190
  ...(focusAt !== undefined ? { focusAt } : {}),
186
191
  ...(value.focusStale === true ? { focusStale: true } : {}),
192
+ ...(gateAct ? { gateAct } : {}),
187
193
  };
188
194
  return task === undefined ? { change, ...extras } : { change, task, ...extras };
189
195
  }
@@ -242,7 +248,7 @@ export function shortenTitleTarget(value: string): string {
242
248
  export function deriveTitleWork(
243
249
  focus: OpenSpecFocus,
244
250
  openspec: OpenSpecState | null | undefined,
245
- options: { focusAt?: number } = {},
251
+ options: { focusAt?: number; gateAct?: string } = {},
246
252
  ): TitleWorkState | null {
247
253
  // Artifact facts come straight from the collector; nothing is invented
248
254
  // here — absent facts simply drop out of the contract.
@@ -267,10 +273,15 @@ export function deriveTitleWork(
267
273
  ...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
268
274
  ...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
269
275
  ...(stale ? { focusStale: true } : {}),
276
+ ...(options.gateAct ? { gateAct: options.gateAct } : {}),
270
277
  };
271
278
  }
272
279
  if (!openspec) return null;
273
- return { change: openspec.changeId, ...extras(openspec) };
280
+ return {
281
+ change: openspec.changeId,
282
+ ...extras(openspec),
283
+ ...(options.gateAct ? { gateAct: options.gateAct } : {}),
284
+ };
274
285
  }
275
286
 
276
287
  /**
@@ -285,12 +296,15 @@ export function buildTitleState(options: {
285
296
  activity?: TitleActivityState | null;
286
297
  /** Publisher-owned timestamp of the current focus fact. */
287
298
  focusAt?: number;
299
+ /** The gate's relayed act line for the focused change, if resolved. */
300
+ gateAct?: string;
288
301
  }): TitleState {
289
302
  return {
290
303
  v: TITLE_STATE_VERSION,
291
304
  subject: options.subject.state === 'set' ? options.subject.title : null,
292
305
  work: deriveTitleWork(options.focus, options.openspec, {
293
306
  focusAt: options.focusAt,
307
+ ...(options.gateAct ? { gateAct: options.gateAct } : {}),
294
308
  }),
295
309
  activity: options.activity ?? null,
296
310
  waiver: null,
@@ -91,9 +91,12 @@ export interface OpenSpecState {
91
91
  changeId: string;
92
92
  title: string;
93
93
  phase: string;
94
- completedTasks: number;
95
- totalTasks: number;
96
- 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;
97
100
  pendingTasks: OpenSpecTask[];
98
101
  allTasks?: OpenSpecTask[];
99
102
  tasksPath?: string;