portable-agent-layer 0.70.0 → 0.72.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.
Files changed (81) hide show
  1. package/README.md +5 -1
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/onboarding/SKILL.md +109 -0
  4. package/assets/skills/projects/SKILL.md +11 -2
  5. package/assets/templates/pal-settings.json +1 -0
  6. package/package.json +5 -1
  7. package/src/cli/index.ts +39 -12
  8. package/src/cli/migrate.ts +1 -1
  9. package/src/cli/personal-context.ts +67 -0
  10. package/src/cli/server.ts +13 -7
  11. package/src/cli/setup-identity.ts +13 -1
  12. package/src/cli/skill.ts +1 -1
  13. package/src/hooks/CompactRecover.ts +28 -86
  14. package/src/hooks/LedgerUnapplied.ts +3 -28
  15. package/src/hooks/LoadContext.ts +33 -60
  16. package/src/hooks/SecurityValidator.ts +16 -109
  17. package/src/hooks/handlers/agenda.ts +223 -0
  18. package/src/hooks/handlers/failure-principle.ts +19 -44
  19. package/src/hooks/handlers/inject-retrieval.ts +6 -2
  20. package/src/hooks/handlers/session-intelligence.ts +13 -70
  21. package/src/hooks/lib/agenda-store.ts +41 -0
  22. package/src/hooks/lib/capture-store.ts +103 -0
  23. package/src/hooks/lib/compact-recall.ts +89 -0
  24. package/src/hooks/lib/failure-principle.ts +98 -0
  25. package/src/hooks/lib/ledger-hook.ts +35 -0
  26. package/src/hooks/lib/ledger.ts +48 -1
  27. package/src/hooks/lib/paths.ts +0 -1
  28. package/src/hooks/lib/projects.ts +16 -1
  29. package/src/hooks/lib/security-gate.ts +159 -0
  30. package/src/hooks/lib/serves.ts +60 -0
  31. package/src/hooks/lib/session-context.ts +74 -0
  32. package/src/hooks/lib/stop.ts +14 -0
  33. package/src/hooks/lib/telos-goals.ts +144 -0
  34. package/src/hooks/lib/telos-topics.ts +68 -0
  35. package/src/hooks/lib/token-usage.ts +3 -1
  36. package/src/hooks/lib/wall-clock.ts +58 -0
  37. package/src/tools/agent/algorithm-reflect.ts +28 -97
  38. package/src/tools/agent/analyze.ts +19 -120
  39. package/src/tools/agent/handoff-note.ts +40 -70
  40. package/src/tools/agent/project.ts +47 -136
  41. package/src/tools/agent/relationship-note.ts +27 -46
  42. package/src/tools/agent/synthesize.ts +1 -1
  43. package/src/tools/agent/thread.ts +43 -123
  44. package/src/tools/control-room/data.ts +332 -0
  45. package/src/tools/control-room/matrix.ts +182 -0
  46. package/src/tools/control-room/server.ts +150 -0
  47. package/src/tools/control-room/ui/agenda.tsx +43 -0
  48. package/src/tools/control-room/ui/agents.tsx +67 -0
  49. package/src/tools/control-room/ui/app.css +857 -0
  50. package/src/tools/control-room/ui/app.tsx +74 -0
  51. package/src/tools/control-room/ui/board.tsx +82 -0
  52. package/src/tools/control-room/ui/format.ts +31 -0
  53. package/src/tools/control-room/ui/handoffs.tsx +37 -0
  54. package/src/tools/control-room/ui/index.html +19 -0
  55. package/src/tools/control-room/ui/ledger.tsx +137 -0
  56. package/src/tools/control-room/ui/matrix.tsx +117 -0
  57. package/src/tools/control-room/ui/panel.tsx +60 -0
  58. package/src/tools/control-room/ui/signal.tsx +161 -0
  59. package/src/tools/ledger/view.ts +3 -0
  60. package/src/tools/lib/algorithm-reflect.ts +84 -0
  61. package/src/tools/lib/analyze-report.ts +120 -0
  62. package/src/tools/lib/handoff-note.ts +88 -0
  63. package/src/tools/lib/note-flags.ts +59 -0
  64. package/src/tools/lib/project-isc.ts +151 -0
  65. package/src/tools/lib/relationship-reflect.ts +402 -0
  66. package/src/tools/lib/self-model.ts +499 -0
  67. package/src/tools/lib/session-usage.ts +216 -0
  68. package/src/tools/lib/skill-doctor.ts +457 -0
  69. package/src/tools/lib/thread.ts +119 -0
  70. package/src/tools/lib/token-report.ts +173 -0
  71. package/src/tools/lib/transcript-usage.ts +42 -0
  72. package/src/tools/lib/usage-buckets.ts +329 -0
  73. package/src/tools/relationship-reflect.ts +48 -412
  74. package/src/tools/self-model.ts +76 -558
  75. package/src/tools/session-summary.ts +8 -215
  76. package/src/tools/skill-doctor.ts +9 -444
  77. package/src/tools/token-cost.ts +18 -428
  78. package/assets/templates/ledger-page.html +0 -213
  79. package/src/cli/setup-telos.ts +0 -52
  80. package/src/hooks/lib/setup.ts +0 -60
  81. package/src/tools/ledger/server.ts +0 -111
@@ -0,0 +1,332 @@
1
+ /**
2
+ * The numbers behind the control room. Every panel reads through the same
3
+ * functions the session-start reminder uses, so the page and the agent can
4
+ * never disagree about a project's state.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { basename, resolve } from "node:path";
9
+ import { loadReflectNudge } from "../../hooks/handlers/reflect-trigger";
10
+ import { type AgendaMove, readAgenda } from "../../hooks/lib/agenda-store";
11
+ import {
12
+ isMaintainerEnv,
13
+ loadAlgorithmReviewNudge,
14
+ } from "../../hooks/lib/algorithm-review";
15
+ import { loadAnalyzeNudge } from "../../hooks/lib/analyze-nudge";
16
+ import { paths } from "../../hooks/lib/paths";
17
+ import { isStale, type ProjectProgress, readAllProjects } from "../../hooks/lib/projects";
18
+ import { readProjectHistory } from "../../hooks/lib/work-tracking";
19
+ import { anchorSlugOf, type LedgerFilter, queryLedger } from "../ledger/query";
20
+ import { type HandoffEntry, readHandoffs } from "../lib/handoff-note";
21
+ import { parseIscs } from "../lib/project-isc";
22
+
23
+ const DAY_MS = 86_400_000;
24
+ const HANDOFF_FRESH_DAYS = 7;
25
+ const SESSION_WINDOW_DAYS = 30;
26
+ const SERIES_LENGTH = 60;
27
+
28
+ export interface ProjectCard {
29
+ slug: string;
30
+ path: string | null;
31
+ status: string;
32
+ updated: string;
33
+ ageDays: number;
34
+ stale: boolean;
35
+ openIscs: number;
36
+ next: string[];
37
+ blockers: string[];
38
+ lastSession: { date: string; title: string } | null;
39
+ sessions30d: number;
40
+ asking: string[];
41
+ }
42
+
43
+ export interface HandoffCard {
44
+ slug: string | null;
45
+ label: string;
46
+ cwd: string;
47
+ title: string;
48
+ sentence: string;
49
+ handoff: string;
50
+ at: string;
51
+ ageDays: number;
52
+ source: HandoffEntry["source"];
53
+ }
54
+
55
+ export interface AgendaView {
56
+ generatedAt: string | null;
57
+ ageHours: number | null;
58
+ stale: boolean;
59
+ moves: AgendaMove[];
60
+ }
61
+
62
+ export interface DueBadge {
63
+ state: "due" | "clear" | "n/a";
64
+ detail: string;
65
+ }
66
+
67
+ export interface RatingPoint {
68
+ ts: string;
69
+ rating: number;
70
+ }
71
+
72
+ export interface SignalView {
73
+ synthesizedAt: string | null;
74
+ ratings: {
75
+ count: number;
76
+ avg: number;
77
+ recentAvg: number;
78
+ lowCount: number;
79
+ trend: string;
80
+ } | null;
81
+ algorithm: {
82
+ reflectionCount: number;
83
+ passRate: number;
84
+ avgSentiment: number;
85
+ } | null;
86
+ series: RatingPoint[];
87
+ due: {
88
+ analysis: DueBadge;
89
+ algorithmReview: DueBadge;
90
+ relationshipReflect: DueBadge;
91
+ };
92
+ }
93
+
94
+ export interface AgentsRow {
95
+ slug: string;
96
+ actions: number;
97
+ runtimes: Record<string, number>;
98
+ machines: number;
99
+ actors: number;
100
+ sessions: number;
101
+ }
102
+
103
+ export interface AgentsView {
104
+ since: string;
105
+ projects: AgentsRow[];
106
+ }
107
+
108
+ function daysBetween(from: string, now: Date): number {
109
+ const age = now.getTime() - new Date(from).getTime();
110
+ return Number.isFinite(age) && age > 0 ? Math.floor(age / DAY_MS) : 0;
111
+ }
112
+
113
+ function isoDay(at: Date): string {
114
+ return at.toISOString().slice(0, 10);
115
+ }
116
+
117
+ function sessionsSince(path: string | null, since: Date): number {
118
+ if (!path) return 0;
119
+ const floor = isoDay(since);
120
+ return readProjectHistory(path, 200).filter((h) => h.date >= floor).length;
121
+ }
122
+
123
+ export function freshHandoffs(now: Date): [string, HandoffEntry][] {
124
+ return Object.entries(readHandoffs()).filter(
125
+ ([, h]) =>
126
+ h.status === "in-progress" &&
127
+ h.handoff &&
128
+ daysBetween(h.timestamp, now) < HANDOFF_FRESH_DAYS
129
+ );
130
+ }
131
+
132
+ export function askingReasons(
133
+ p: ProjectProgress,
134
+ handoff: HandoffEntry | undefined,
135
+ stale: boolean
136
+ ): string[] {
137
+ const reasons: string[] = [];
138
+ if (handoff) reasons.push("handoff in progress");
139
+ const blockers = p.blockers?.length ?? 0;
140
+ if (blockers > 0) reasons.push(blockers === 1 ? "1 blocker" : `${blockers} blockers`);
141
+ if (stale && p.status === "active") reasons.push("gone quiet");
142
+ return reasons;
143
+ }
144
+
145
+ function toCard(
146
+ p: ProjectProgress,
147
+ handoff: HandoffEntry | undefined,
148
+ now: Date
149
+ ): ProjectCard {
150
+ const path = p.path ?? null;
151
+ const stale = isStale(p);
152
+ const history = path ? readProjectHistory(path, 1) : [];
153
+ const last = history.at(-1);
154
+ return {
155
+ slug: p.name,
156
+ path,
157
+ status: p.status,
158
+ updated: p.updated,
159
+ ageDays: daysBetween(p.updated, now),
160
+ stale,
161
+ openIscs: parseIscs(p.criteria ?? "").filter((i) => i.status === "open").length,
162
+ next: p.next ?? [],
163
+ blockers: p.blockers ?? [],
164
+ lastSession: last ? { date: last.date, title: last.title } : null,
165
+ sessions30d: sessionsSince(
166
+ path,
167
+ new Date(now.getTime() - SESSION_WINDOW_DAYS * DAY_MS)
168
+ ),
169
+ asking: askingReasons(p, handoff, stale),
170
+ };
171
+ }
172
+
173
+ export function sortBoard(cards: ProjectCard[]): ProjectCard[] {
174
+ return [...cards].sort(
175
+ (a, b) => b.asking.length - a.asking.length || b.updated.localeCompare(a.updated)
176
+ );
177
+ }
178
+
179
+ export function board(now: Date = new Date()): ProjectCard[] {
180
+ const handoffs = new Map(freshHandoffs(now));
181
+ return sortBoard(
182
+ readAllProjects().map((p) =>
183
+ toCard(p, p.path ? handoffs.get(p.path) : undefined, now)
184
+ )
185
+ );
186
+ }
187
+
188
+ export function firstSentence(text: string): string {
189
+ const flat = text.replace(/\s+/g, " ").trim();
190
+ const match = new RegExp(/^.*?[.!?](?=\s|$)/).exec(flat);
191
+ return match ? match[0] : flat;
192
+ }
193
+
194
+ /** An automatic handoff is a transcript excerpt; the user's own words are the part worth a sentence. */
195
+ export function handoffSentence(text: string): string {
196
+ const [, userTurn] = text.split(/Last user message:\s*/, 2);
197
+ const spoken = userTurn ? userTurn.split(/\s*Last assistant response:/, 1)[0] : text;
198
+ return firstSentence(spoken);
199
+ }
200
+
201
+ export function handoffs(now: Date = new Date()): HandoffCard[] {
202
+ const slugByPath = new Map(
203
+ readAllProjects()
204
+ .filter((p) => p.path)
205
+ .map((p) => [p.path as string, p.name])
206
+ );
207
+ return freshHandoffs(now)
208
+ .map(([cwd, h]) => ({
209
+ slug: slugByPath.get(cwd) ?? null,
210
+ label: slugByPath.get(cwd) ?? basename(cwd),
211
+ cwd,
212
+ title: h.title,
213
+ sentence: handoffSentence(h.handoff),
214
+ handoff: h.handoff,
215
+ at: h.timestamp,
216
+ ageDays: daysBetween(h.timestamp, now),
217
+ source: h.source,
218
+ }))
219
+ .sort((a, b) => b.at.localeCompare(a.at));
220
+ }
221
+
222
+ /**
223
+ * The page never writes the agenda — it says how old the one on disk is, so a
224
+ * morning reading yesterday's three moves knows that is what it is looking at.
225
+ */
226
+ export function agenda(now: Date = new Date()): AgendaView {
227
+ const stored = readAgenda();
228
+ if (!stored) return { generatedAt: null, ageHours: null, stale: true, moves: [] };
229
+ const ageHours = (now.getTime() - new Date(stored.generatedAt).getTime()) / 3_600_000;
230
+ return {
231
+ generatedAt: stored.generatedAt,
232
+ ageHours: Math.max(0, Math.round(ageHours)),
233
+ stale: !(ageHours < 24),
234
+ moves: stored.moves,
235
+ };
236
+ }
237
+
238
+ /** The nudge text is the verdict; the badge only strips its heading and emoji. */
239
+ export function badgeFromNudge(nudge: string): DueBadge {
240
+ if (!nudge) return { state: "clear", detail: "" };
241
+ const detail = nudge.split("\n").slice(1).join(" ").replace(/^\W+/, "").trim();
242
+ return { state: "due", detail };
243
+ }
244
+
245
+ export function dueBadges(now: Date = new Date(), maintainer = isMaintainerEnv()) {
246
+ return {
247
+ analysis: badgeFromNudge(loadAnalyzeNudge(now)),
248
+ algorithmReview: maintainer
249
+ ? badgeFromNudge(loadAlgorithmReviewNudge(now))
250
+ : { state: "n/a" as const, detail: "only in a maintainer checkout" },
251
+ relationshipReflect: badgeFromNudge(loadReflectNudge()),
252
+ };
253
+ }
254
+
255
+ export function ratingSeries(limit = SERIES_LENGTH): RatingPoint[] {
256
+ const file = resolve(paths.signals(), "ratings.jsonl");
257
+ if (!existsSync(file)) return [];
258
+ const points: RatingPoint[] = [];
259
+ for (const line of readFileSync(file, "utf-8").split("\n")) {
260
+ if (!line.trim()) continue;
261
+ try {
262
+ const row = JSON.parse(line) as { ts?: string; rating?: number };
263
+ if (typeof row.rating === "number" && row.ts)
264
+ points.push({ ts: row.ts, rating: row.rating });
265
+ } catch {}
266
+ }
267
+ return points.slice(-limit);
268
+ }
269
+
270
+ function readSynthesis(): Record<string, unknown> | null {
271
+ const file = resolve(paths.state(), "synthesis.json");
272
+ if (!existsSync(file)) return null;
273
+ try {
274
+ return JSON.parse(readFileSync(file, "utf-8"));
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ export function signal(now: Date = new Date()): SignalView {
281
+ const s = readSynthesis();
282
+ const ratings = s?.ratings as SignalView["ratings"] | undefined;
283
+ const algorithm = s?.algorithm as SignalView["algorithm"] | undefined;
284
+ return {
285
+ synthesizedAt: typeof s?.timestamp === "string" ? s.timestamp : null,
286
+ ratings: ratings ?? null,
287
+ algorithm: algorithm
288
+ ? {
289
+ reflectionCount: algorithm.reflectionCount,
290
+ passRate: algorithm.passRate,
291
+ avgSentiment: algorithm.avgSentiment,
292
+ }
293
+ : null,
294
+ series: ratingSeries(),
295
+ due: dueBadges(now),
296
+ };
297
+ }
298
+
299
+ function count<T>(items: T[], key: (item: T) => string): number {
300
+ return new Set(items.map(key)).size;
301
+ }
302
+
303
+ export function agentsAtWork(filter: LedgerFilter, now: Date = new Date()): AgentsView {
304
+ const since = filter.since ?? new Date(now.getTime() - SESSION_WINDOW_DAYS * DAY_MS);
305
+ const pathBySlug = new Map(
306
+ readAllProjects()
307
+ .filter((p) => p.path)
308
+ .map((p) => [p.name, p.path as string])
309
+ );
310
+ const bySlug = Map.groupBy(
311
+ queryLedger({ ...filter, since }),
312
+ (e) => anchorSlugOf(e.target) ?? "unanchored"
313
+ );
314
+ const projects = [...bySlug.entries()].map(([slug, entries]) => ({
315
+ slug,
316
+ actions: entries.length,
317
+ runtimes: Object.fromEntries(
318
+ Map.groupBy(entries, (e) => e.runtime)
319
+ .entries()
320
+ .map(([runtime, group]) => [runtime, group.length])
321
+ ),
322
+ machines: count(entries, (e) => e.machine),
323
+ actors: count(entries, (e) => e.actor),
324
+ sessions: sessionsSince(pathBySlug.get(slug) ?? null, since),
325
+ }));
326
+ return {
327
+ since: since.toISOString(),
328
+ projects: projects.sort(
329
+ (a, b) => b.actions - a.actions || a.slug.localeCompare(b.slug)
330
+ ),
331
+ };
332
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The urgent/important grid, over projects and stated goals together.
3
+ *
4
+ * Two rules, both readable off disk, because a screen you open before a terminal
5
+ * cannot wait for a model. Importance comes from what a project serves, which
6
+ * PAL guesses once and the user can overrule. Urgency comes from what the files
7
+ * already say: a blocker, an unfinished handoff, a date coming up, or an
8
+ * important thing that has gone quiet.
9
+ *
10
+ * Every placement carries the reason it landed there, so a wrong guess argues
11
+ * with the user instead of hiding from them.
12
+ */
13
+
14
+ import {
15
+ PROJECT_STALE_DAYS_DEFAULT,
16
+ type ProjectProgress,
17
+ readAllProjects,
18
+ type ServesAuthority,
19
+ type ServesKind,
20
+ } from "../../hooks/lib/projects";
21
+ import { isImportant, SERVES_MEANING } from "../../hooks/lib/serves";
22
+ import { dueFrom, readTelosGoals, type TelosGoal } from "../../hooks/lib/telos-goals";
23
+ import type { HandoffEntry } from "../lib/handoff-note";
24
+ import { freshHandoffs } from "./data";
25
+
26
+ const URGENT_WITHIN_DAYS = 14;
27
+ const RANKED_STATUSES = new Set(["active", "paused"]);
28
+
29
+ export interface MatrixItem {
30
+ kind: "project" | "goal";
31
+ id: string;
32
+ label: string;
33
+ detail: string;
34
+ urgent: boolean;
35
+ important: boolean;
36
+ urgentBecause: string[];
37
+ importantBecause: string;
38
+ serves: ServesKind | null;
39
+ servesBy: ServesAuthority | null;
40
+ due: string | null;
41
+ waitingOn: string | null;
42
+ }
43
+
44
+ export interface Matrix {
45
+ now: MatrixItem[];
46
+ plan: MatrixItem[];
47
+ noise: MatrixItem[];
48
+ later: MatrixItem[];
49
+ /** Projects PAL has not guessed a purpose for yet — they rank as unimportant until it does. */
50
+ unranked: number;
51
+ }
52
+
53
+ function dueSoon(due: string | null, now: Date): boolean {
54
+ if (!due) return false;
55
+ const at = new Date(`${due}T23:59:59Z`).getTime();
56
+ if (!Number.isFinite(at)) return false;
57
+ return at - now.getTime() <= URGENT_WITHIN_DAYS * 86_400_000;
58
+ }
59
+
60
+ function earliestDue(lines: string[]): string | null {
61
+ const dates = lines.map(dueFrom).filter((d): d is string => d !== null);
62
+ return dates.length > 0 ? dates.sort()[0] : null;
63
+ }
64
+
65
+ function plural(count: number, noun: string): string {
66
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
67
+ }
68
+
69
+ /** Measured against the grid's own clock, so a placement can be pinned in a test. */
70
+ function quietSince(updated: string | undefined, now: Date): boolean {
71
+ if (!updated) return false;
72
+ const age = now.getTime() - new Date(updated).getTime();
73
+ return Number.isFinite(age) && age > PROJECT_STALE_DAYS_DEFAULT * 86_400_000;
74
+ }
75
+
76
+ /**
77
+ * A fun project going quiet is just quiet. An important one going quiet is
78
+ * rotting, which is the only reading of staleness worth interrupting a morning for.
79
+ */
80
+ function projectUrgency(
81
+ p: ProjectProgress,
82
+ important: boolean,
83
+ waiting: string | null,
84
+ due: string | null,
85
+ hasHandoff: boolean,
86
+ now: Date
87
+ ): string[] {
88
+ const reasons: string[] = [];
89
+ const blockers = p.blockers?.length ?? 0;
90
+ if (blockers > 0) reasons.push(plural(blockers, "blocker"));
91
+ if (waiting) reasons.push("waiting on you");
92
+ else if (hasHandoff) reasons.push("handoff in progress");
93
+ if (dueSoon(due, now)) reasons.push(`next step dated ${due}`);
94
+ if (important && p.status === "active" && quietSince(p.updated, now)) {
95
+ reasons.push("gone quiet");
96
+ }
97
+ return reasons;
98
+ }
99
+
100
+ function projectItem(
101
+ p: ProjectProgress,
102
+ handoffs: Map<string, HandoffEntry>,
103
+ now: Date
104
+ ): MatrixItem {
105
+ const entry = p.path ? handoffs.get(p.path) : undefined;
106
+ const waiting = entry?.waitingOn ?? null;
107
+ const important = isImportant(p.serves);
108
+ const due = earliestDue(p.next ?? []);
109
+ const urgentBecause = projectUrgency(
110
+ p,
111
+ important,
112
+ waiting,
113
+ due,
114
+ entry !== undefined,
115
+ now
116
+ );
117
+
118
+ return {
119
+ kind: "project",
120
+ id: p.name,
121
+ label: p.name,
122
+ detail: p.serves_note ?? p.next?.[0] ?? "",
123
+ urgent: urgentBecause.length > 0,
124
+ important,
125
+ urgentBecause,
126
+ importantBecause: p.serves
127
+ ? SERVES_MEANING[p.serves]
128
+ : "no purpose on record yet — set one to rank it",
129
+ serves: p.serves ?? null,
130
+ servesBy: p.serves_by ?? null,
131
+ due,
132
+ waitingOn: waiting,
133
+ };
134
+ }
135
+
136
+ function goalItem(goal: TelosGoal, now: Date): MatrixItem {
137
+ const urgent = dueSoon(goal.due, now);
138
+ return {
139
+ kind: "goal",
140
+ id: goal.id,
141
+ label: goal.title,
142
+ detail: goal.horizon ?? goal.text,
143
+ urgent,
144
+ important: true,
145
+ urgentBecause: urgent ? [`dated ${goal.due}`] : [],
146
+ importantBecause: "a goal you stated",
147
+ serves: null,
148
+ servesBy: null,
149
+ due: goal.due,
150
+ waitingOn: null,
151
+ };
152
+ }
153
+
154
+ function quadrant(items: MatrixItem[], urgent: boolean, important: boolean) {
155
+ return items
156
+ .filter((i) => i.urgent === urgent && i.important === important)
157
+ .sort(
158
+ (a, b) =>
159
+ b.urgentBecause.length - a.urgentBecause.length || a.label.localeCompare(b.label)
160
+ );
161
+ }
162
+
163
+ /** @lintignore exercised directly by test/control-room-matrix.test.ts */
164
+ export function buildMatrix(items: MatrixItem[], unranked: number): Matrix {
165
+ return {
166
+ now: quadrant(items, true, true),
167
+ plan: quadrant(items, false, true),
168
+ noise: quadrant(items, true, false),
169
+ later: quadrant(items, false, false),
170
+ unranked,
171
+ };
172
+ }
173
+
174
+ export function matrix(now: Date = new Date()): Matrix {
175
+ const handoffs = new Map(freshHandoffs(now));
176
+ const ranked = readAllProjects().filter((p) => RANKED_STATUSES.has(p.status));
177
+ const items = [
178
+ ...ranked.map((p) => projectItem(p, handoffs, now)),
179
+ ...readTelosGoals().map((g) => goalItem(g, now)),
180
+ ];
181
+ return buildMatrix(items, ranked.filter((p) => !p.serves).length);
182
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The control room — one local page over ~/.pal, meant to be opened before a
3
+ * terminal. Loopback only, read only, no state of its own: every request goes
4
+ * back to the files.
5
+ *
6
+ * `pal cli server start|stop|status` owns the process; this file only serves.
7
+ */
8
+
9
+ import { loadMachine } from "../../hooks/lib/machine";
10
+ import { readAllProjects } from "../../hooks/lib/projects";
11
+ import { isServesKind, setServes } from "../../hooks/lib/serves";
12
+ import { type LedgerFilter, ledgerFiles, parseSince } from "../ledger/query";
13
+ import { ledgerView } from "../ledger/view";
14
+ import { agenda, agentsAtWork, board, handoffs, signal } from "./data";
15
+ import { matrix } from "./matrix";
16
+ import index from "./ui/index.html";
17
+
18
+ export const DEFAULT_PORT = 7250;
19
+ export const LOOPBACK = "127.0.0.1";
20
+
21
+ export interface ServerStatus {
22
+ pid: number;
23
+ port: number;
24
+ startedAt: string;
25
+ ledgerFiles: number;
26
+ machine: string;
27
+ }
28
+
29
+ function json(body: unknown, status = 200): Response {
30
+ return Response.json(body, { status });
31
+ }
32
+
33
+ /** A window the page cannot parse is an error, not the whole ledger. */
34
+ function filterFromQuery(params: URLSearchParams): LedgerFilter | string {
35
+ const filter: LedgerFilter = {};
36
+ const project = params.get("project");
37
+ if (project) filter.project = project;
38
+ for (const key of ["since", "until"] as const) {
39
+ const spec = params.get(key);
40
+ if (!spec) continue;
41
+ const at = parseSince(spec);
42
+ if (!at) return `Unrecognised ${key}: ${spec}`;
43
+ filter[key] = at;
44
+ }
45
+ return filter;
46
+ }
47
+
48
+ function projects(): Response {
49
+ const slugs = readAllProjects()
50
+ .map((p) => p.name)
51
+ .sort((a, b) => a.localeCompare(b));
52
+ return json(slugs.map((slug) => ({ slug })));
53
+ }
54
+
55
+ function withFilter(url: URL, view: (filter: LedgerFilter) => unknown): Response {
56
+ const filter = filterFromQuery(url.searchParams);
57
+ if (typeof filter === "string") return json({ error: filter }, 400);
58
+ return json(view(filter));
59
+ }
60
+
61
+ function status(port: number, startedAt: string): Response {
62
+ const body: ServerStatus = {
63
+ pid: process.pid,
64
+ port,
65
+ startedAt,
66
+ ledgerFiles: ledgerFiles().length,
67
+ machine: loadMachine().label,
68
+ };
69
+ return json(body);
70
+ }
71
+
72
+ /**
73
+ * The one thing the page may change. Importance is a guess until the user
74
+ * corrects it, and a correction is worth one click — but this stays a single
75
+ * named field on a single record, not a write surface over ~/.pal.
76
+ */
77
+ async function overrideServes(request: Request): Promise<Response> {
78
+ let body: unknown;
79
+ try {
80
+ body = await request.json();
81
+ } catch {
82
+ return json({ error: "expected a JSON body" }, 400);
83
+ }
84
+ const { project, serves, note } = (body ?? {}) as Record<string, unknown>;
85
+ if (typeof project !== "string" || !project) {
86
+ return json({ error: "project is required" }, 400);
87
+ }
88
+ if (!isServesKind(serves)) {
89
+ return json({ error: "serves must be goal, revenue or fun" }, 400);
90
+ }
91
+
92
+ const outcome = setServes({
93
+ name: project,
94
+ kind: serves,
95
+ note: typeof note === "string" && note ? note : undefined,
96
+ by: "user",
97
+ });
98
+ if (outcome === "missing") return json({ error: `no such project: ${project}` }, 404);
99
+ return json({ project, serves, by: "user" });
100
+ }
101
+
102
+ export function startControlRoom(port: number = DEFAULT_PORT) {
103
+ const startedAt = new Date().toISOString();
104
+ return Bun.serve({
105
+ hostname: LOOPBACK,
106
+ port,
107
+ development: false,
108
+ routes: { "/": index },
109
+ fetch(request, server) {
110
+ const url = new URL(request.url);
111
+ if (request.method === "POST" && url.pathname === "/api/serves") {
112
+ return overrideServes(request);
113
+ }
114
+ if (request.method !== "GET") return json({ error: "read only" }, 405);
115
+ switch (url.pathname) {
116
+ case "/api/agenda":
117
+ return json(agenda());
118
+ case "/api/matrix":
119
+ return json(matrix());
120
+ case "/api/board":
121
+ return json(board());
122
+ case "/api/handoffs":
123
+ return json(handoffs());
124
+ case "/api/signal":
125
+ return json(signal());
126
+ case "/api/agents":
127
+ return withFilter(url, agentsAtWork);
128
+ case "/api/ledger":
129
+ return withFilter(url, ledgerView);
130
+ case "/api/projects":
131
+ return projects();
132
+ case "/api/status":
133
+ return status(server.port ?? port, startedAt);
134
+ default:
135
+ return json({ error: "not found" }, 404);
136
+ }
137
+ },
138
+ });
139
+ }
140
+
141
+ function portFromArgv(argv: string[]): number {
142
+ const flag = argv.find((arg) => arg.startsWith("--port="));
143
+ const port = flag ? Number(flag.slice("--port=".length)) : DEFAULT_PORT;
144
+ return Number.isInteger(port) && port >= 0 ? port : DEFAULT_PORT;
145
+ }
146
+
147
+ if (import.meta.main) {
148
+ const server = startControlRoom(portFromArgv(process.argv.slice(2)));
149
+ console.log(`http://${LOOPBACK}:${server.port}/`);
150
+ }
@@ -0,0 +1,43 @@
1
+ import type { AgendaView } from "../data";
2
+ import { Pending, useLoaded } from "./panel";
3
+
4
+ function Age({ view }: { view: AgendaView }) {
5
+ if (!view.generatedAt) return <span className="agenda-age">never written</span>;
6
+ const hours = view.ageHours ?? 0;
7
+ const text = hours < 1 ? "written just now" : `written ${hours}h ago`;
8
+ return (
9
+ <span className={view.stale ? "agenda-age stale" : "agenda-age"}>
10
+ {view.stale ? `${text} — out of date` : text}
11
+ </span>
12
+ );
13
+ }
14
+
15
+ export function Agenda() {
16
+ const loaded = useLoaded<AgendaView>("/api/agenda");
17
+
18
+ return (
19
+ <section className="agenda">
20
+ <header>
21
+ <h2>Today</h2>
22
+ {loaded.state === "ready" && <Age view={loaded.data} />}
23
+ </header>
24
+ <Pending value={loaded} />
25
+ {loaded.state === "ready" &&
26
+ (loaded.data.moves.length === 0 ? (
27
+ <p className="agenda-empty">
28
+ No moves yet. They are written when a session ends — finish one and come back.
29
+ </p>
30
+ ) : (
31
+ <ol className="moves">
32
+ {loaded.data.moves.map((move, i) => (
33
+ <li key={move.move}>
34
+ <span className="rank">{i + 1}</span>
35
+ <span className="move">{move.move}</span>
36
+ <span className="because">{move.because}</span>
37
+ </li>
38
+ ))}
39
+ </ol>
40
+ ))}
41
+ </section>
42
+ );
43
+ }