portable-agent-layer 0.70.0 → 0.71.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 (42) 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/personal-context.ts +67 -0
  9. package/src/cli/server.ts +13 -7
  10. package/src/cli/setup-identity.ts +13 -1
  11. package/src/hooks/handlers/agenda.ts +223 -0
  12. package/src/hooks/handlers/inject-retrieval.ts +6 -2
  13. package/src/hooks/lib/agenda-store.ts +41 -0
  14. package/src/hooks/lib/paths.ts +0 -1
  15. package/src/hooks/lib/projects.ts +16 -1
  16. package/src/hooks/lib/serves.ts +60 -0
  17. package/src/hooks/lib/stop.ts +14 -0
  18. package/src/hooks/lib/telos-goals.ts +144 -0
  19. package/src/hooks/lib/telos-topics.ts +68 -0
  20. package/src/hooks/lib/token-usage.ts +3 -1
  21. package/src/hooks/lib/wall-clock.ts +58 -0
  22. package/src/tools/agent/handoff-note.ts +38 -20
  23. package/src/tools/agent/project.ts +36 -4
  24. package/src/tools/control-room/data.ts +332 -0
  25. package/src/tools/control-room/matrix.ts +182 -0
  26. package/src/tools/control-room/server.ts +150 -0
  27. package/src/tools/control-room/ui/agenda.tsx +43 -0
  28. package/src/tools/control-room/ui/agents.tsx +67 -0
  29. package/src/tools/control-room/ui/app.css +857 -0
  30. package/src/tools/control-room/ui/app.tsx +74 -0
  31. package/src/tools/control-room/ui/board.tsx +82 -0
  32. package/src/tools/control-room/ui/format.ts +31 -0
  33. package/src/tools/control-room/ui/handoffs.tsx +37 -0
  34. package/src/tools/control-room/ui/index.html +19 -0
  35. package/src/tools/control-room/ui/ledger.tsx +136 -0
  36. package/src/tools/control-room/ui/matrix.tsx +117 -0
  37. package/src/tools/control-room/ui/panel.tsx +60 -0
  38. package/src/tools/control-room/ui/signal.tsx +161 -0
  39. package/assets/templates/ledger-page.html +0 -213
  40. package/src/cli/setup-telos.ts +0 -52
  41. package/src/hooks/lib/setup.ts +0 -60
  42. package/src/tools/ledger/server.ts +0 -111
@@ -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 "../agent/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
+ }
@@ -0,0 +1,67 @@
1
+ import type { AgentsRow, AgentsView } from "../data";
2
+ import { Panel, Pending, useLoaded } from "./panel";
3
+
4
+ function Row({ r, max }: { r: AgentsRow; max: number }) {
5
+ return (
6
+ <tr>
7
+ <td>
8
+ {r.slug}
9
+ <span className="bar">
10
+ <i style={{ width: `${(r.actions / max) * 100}%` }} />
11
+ </span>
12
+ </td>
13
+ <td className="num">{r.actions}</td>
14
+ <td>
15
+ <span className="runtimes">
16
+ {Object.entries(r.runtimes).map(([runtime, n]) => (
17
+ <span key={runtime} className="tag">
18
+ {runtime} {n}
19
+ </span>
20
+ ))}
21
+ </span>
22
+ </td>
23
+ <td className="num">{r.machines}</td>
24
+ <td className="num">{r.actors}</td>
25
+ <td className="num">{r.sessions}</td>
26
+ </tr>
27
+ );
28
+ }
29
+
30
+ export function Agents() {
31
+ const view = useLoaded<AgentsView>("/api/agents");
32
+ const max =
33
+ view.state === "ready" ? Math.max(1, ...view.data.projects.map((p) => p.actions)) : 1;
34
+ return (
35
+ <Panel
36
+ index="04 · activity"
37
+ title="Agents at work"
38
+ span={7}
39
+ order={3}
40
+ aside={view.state === "ready" ? `since ${view.data.since.slice(0, 10)}` : ""}
41
+ >
42
+ <Pending value={view} />
43
+ {view.state === "ready" && view.data.projects.length === 0 && (
44
+ <div className="empty">No recorded actions in the window.</div>
45
+ )}
46
+ {view.state === "ready" && view.data.projects.length > 0 && (
47
+ <table>
48
+ <thead>
49
+ <tr>
50
+ <th>project</th>
51
+ <th className="num">actions</th>
52
+ <th>runtimes</th>
53
+ <th className="num">machines</th>
54
+ <th className="num">actors</th>
55
+ <th className="num">sessions</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ {view.data.projects.map((r) => (
60
+ <Row key={r.slug} r={r} max={max} />
61
+ ))}
62
+ </tbody>
63
+ </table>
64
+ )}
65
+ </Panel>
66
+ );
67
+ }