@aemonge-dev/pi-stardeck 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/packages/pi-stardeck/README.md +1 -1
- package/packages/pi-stardeck/src/activity-age.ts +1 -1
- package/packages/pi-stardeck/src/deck.ts +9 -6
- package/packages/pi-stardeck/src/gauge.ts +1 -6
- package/packages/pi-stardeck/src/title-state.ts +124 -58
- package/packages/status/README.md +4 -4
- package/packages/status/index.ts +33 -338
- package/packages/status/src/active-work.ts +6 -71
- package/packages/status/src/ledger.ts +1 -3
- package/packages/status/src/openspec.ts +158 -18
- package/packages/status/src/publisher.ts +6 -76
- package/packages/status/src/reducer.ts +1 -19
- package/packages/status/src/title-state.ts +85 -52
- package/packages/status/src/types.ts +15 -2
- package/packages/work-state/README.md +33 -0
- package/packages/work-state/index.ts +160 -0
- package/packages/work-state/package.json +33 -0
- package/packages/work-state/src/focus.ts +100 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aemonge-dev/pi-stardeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "A Vim- and tmux-inspired command deck for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -48,10 +48,11 @@
|
|
|
48
48
|
"check:openspec": "node scripts/check-openspec.mjs",
|
|
49
49
|
"test:tooling": "node --import tsx/esm --test test/*.test.ts",
|
|
50
50
|
"test:status": "npm --prefix packages/status test",
|
|
51
|
+
"test:work-state": "npm --prefix packages/work-state test",
|
|
51
52
|
"test:stardeck": "npm --prefix packages/pi-stardeck test",
|
|
52
53
|
"test:footer": "npm --prefix packages/footer test",
|
|
53
54
|
"test:vim": "npm --prefix packages/pi-vim-top-border test",
|
|
54
|
-
"test:components": "npm run test:status && npm run test:stardeck && npm run test:footer && npm run test:vim"
|
|
55
|
+
"test:components": "npm run test:status && npm run test:work-state && npm run test:stardeck && npm run test:footer && npm run test:vim"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
58
|
"@earendil-works/pi-ai": "*",
|
|
@@ -43,7 +43,7 @@ The additive header protocol projects selection independently from live work:
|
|
|
43
43
|
|
|
44
44
|
```ts
|
|
45
45
|
selection: {
|
|
46
|
-
source: 'openspec' | '
|
|
46
|
+
source: 'openspec' | 'subject' | 'legacy';
|
|
47
47
|
titles: string[]; // one or two bounded, normalized titles
|
|
48
48
|
color: HeaderColor;
|
|
49
49
|
} | null;
|
|
@@ -66,7 +66,7 @@ function standardActivityAge(ageMs: number): string {
|
|
|
66
66
|
return `${Math.min(99, Math.floor(hours / 24))}d`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function compactActivityAge(ageMs: number): string {
|
|
69
|
+
export function compactActivityAge(ageMs: number): string {
|
|
70
70
|
const age = boundedAge(ageMs);
|
|
71
71
|
const seconds = Math.floor(age / 1_000);
|
|
72
72
|
if (seconds < 100) return `${seconds}s`;
|
|
@@ -237,15 +237,18 @@ function alignedSingleRow(left: string, right: string, width: number): string {
|
|
|
237
237
|
return `${fittedLeft}${' '.repeat(gap)}${right}`;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
function workTile(state: HeaderDeckState, theme: Theme): string {
|
|
240
|
+
function workTile(state: HeaderDeckState, theme: Theme, width: number): string {
|
|
241
241
|
// Line 2 right: Work — derived from OpenSpec focus, never authored here.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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
|
|
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 =
|
|
760
|
+
const titleRight = workTile(state, theme, boundedWidth);
|
|
758
761
|
const focusLines = titleBandLines(state, boundedWidth, theme);
|
|
759
762
|
const titleRows = [
|
|
760
763
|
alignedSingleRow(focusLines[0] ?? '', titleRight, boundedWidth),
|
|
@@ -96,12 +96,7 @@ export type HeaderWork = {
|
|
|
96
96
|
activityPath?: HeaderActivitySegment[];
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
export const HEADER_SELECTION_SOURCES = [
|
|
100
|
-
'openspec',
|
|
101
|
-
'session-work',
|
|
102
|
-
'subject',
|
|
103
|
-
'legacy',
|
|
104
|
-
] as const;
|
|
99
|
+
export const HEADER_SELECTION_SOURCES = ['openspec', 'subject', 'legacy'] as const;
|
|
105
100
|
|
|
106
101
|
export type HeaderSelection = {
|
|
107
102
|
source: (typeof HEADER_SELECTION_SOURCES)[number];
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* the legacy selection/activity rendering.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
...(
|
|
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
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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 ? '✔' : ''} ${
|
|
261
|
-
color:
|
|
326
|
+
text: `${complete ? '✔' : ''} ${items.done}/${items.total}`,
|
|
327
|
+
color: complete ? 'success' : 'accent',
|
|
262
328
|
...(complete ? { bold: true } : {}),
|
|
263
329
|
});
|
|
264
330
|
}
|
|
@@ -90,10 +90,10 @@ branch entry. The agent-callable `openspec_focus` tool shares the command's `sta
|
|
|
90
90
|
safeguarded `set`, deliberate `replace`, validating `validate`, and persistent `clear`
|
|
91
91
|
transitions; the `awaitingValidation` flag marks a focused task as awaiting Human
|
|
92
92
|
validation and auto-clears on the next `set`, `replace`, or `clear`. The
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
sessions
|
|
93
|
+
retired `work_focus`, `stage`, and `title` instruments are gone: stages
|
|
94
|
+
derive from events and Taskflow phases, the focus row falls back to the
|
|
95
|
+
native session name (named by `pi-title`) and then to legacy `session-subject`
|
|
96
|
+
entries from old sessions, read defensively and fail-soft.
|
|
97
97
|
|
|
98
98
|
Global routing belongs to native Ramona policy rather than this status package. This
|
|
99
99
|
package does not inject parent routing prompts, block parent tools, create Session Work
|