portable-agent-layer 0.72.0 → 0.74.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 (69) hide show
  1. package/package.json +10 -7
  2. package/src/cli/index.ts +21 -2
  3. package/src/cli/server.ts +33 -0
  4. package/src/hooks/handlers/agenda.ts +195 -7
  5. package/src/hooks/handlers/update-check.ts +1 -1
  6. package/src/hooks/lib/agenda-store.ts +2 -0
  7. package/src/hooks/lib/goal-links.ts +83 -0
  8. package/src/hooks/lib/models.ts +1 -1
  9. package/src/hooks/lib/projects.ts +5 -0
  10. package/src/hooks/lib/settings.ts +2 -0
  11. package/src/hooks/lib/telos-goals.ts +8 -2
  12. package/src/hooks/lib/token-usage.ts +2 -0
  13. package/src/tools/agent/project.ts +22 -20
  14. package/src/tools/control-room/attention.ts +146 -0
  15. package/src/tools/control-room/data.ts +76 -5
  16. package/src/tools/control-room/detail.ts +158 -0
  17. package/src/tools/control-room/matrix.ts +91 -19
  18. package/src/tools/control-room/prefs.ts +96 -0
  19. package/src/tools/control-room/server-config.ts +8 -0
  20. package/src/tools/control-room/server.ts +196 -11
  21. package/src/tools/control-room/snooze.ts +65 -0
  22. package/src/tools/control-room/static.ts +68 -0
  23. package/src/tools/control-room/ui/app.tsx +196 -50
  24. package/src/tools/control-room/ui/attention-bell.tsx +118 -0
  25. package/src/tools/control-room/ui/components/README.md +18 -0
  26. package/src/tools/control-room/ui/components/badge.tsx +38 -0
  27. package/src/tools/control-room/ui/components/button.tsx +43 -0
  28. package/src/tools/control-room/ui/components/card.tsx +45 -0
  29. package/src/tools/control-room/ui/components/input.tsx +24 -0
  30. package/src/tools/control-room/ui/components/label.tsx +18 -0
  31. package/src/tools/control-room/ui/components/popover.tsx +37 -0
  32. package/src/tools/control-room/ui/components/separator.tsx +25 -0
  33. package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
  34. package/src/tools/control-room/ui/components/switch.tsx +27 -0
  35. package/src/tools/control-room/ui/components/table.tsx +60 -0
  36. package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
  37. package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
  38. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
  39. package/src/tools/control-room/ui/dist/index.html +19 -0
  40. package/src/tools/control-room/ui/format.ts +0 -12
  41. package/src/tools/control-room/ui/frame.tsx +125 -0
  42. package/src/tools/control-room/ui/index.html +2 -2
  43. package/src/tools/control-room/ui/lib/api.ts +27 -0
  44. package/src/tools/control-room/ui/lib/cn.ts +6 -0
  45. package/src/tools/control-room/ui/lib/write.ts +43 -0
  46. package/src/tools/control-room/ui/package.json +28 -0
  47. package/src/tools/control-room/ui/parts.tsx +235 -0
  48. package/src/tools/control-room/ui/screens/log.tsx +274 -0
  49. package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
  50. package/src/tools/control-room/ui/screens/projects.tsx +128 -0
  51. package/src/tools/control-room/ui/screens/settings.tsx +205 -0
  52. package/src/tools/control-room/ui/screens/today.tsx +391 -0
  53. package/src/tools/control-room/ui/theme.css +127 -0
  54. package/src/tools/control-room/ui/vite.config.ts +36 -0
  55. package/src/tools/control-room/writes.ts +106 -0
  56. package/src/tools/ledger/outcomes.ts +9 -0
  57. package/src/tools/ledger/query.ts +2 -0
  58. package/src/tools/ledger/view.ts +31 -10
  59. package/src/tools/lib/handoff-note.ts +14 -0
  60. package/src/tools/lib/project-isc.ts +61 -0
  61. package/src/tools/control-room/ui/agenda.tsx +0 -43
  62. package/src/tools/control-room/ui/agents.tsx +0 -67
  63. package/src/tools/control-room/ui/app.css +0 -857
  64. package/src/tools/control-room/ui/board.tsx +0 -82
  65. package/src/tools/control-room/ui/handoffs.tsx +0 -37
  66. package/src/tools/control-room/ui/ledger.tsx +0 -137
  67. package/src/tools/control-room/ui/matrix.tsx +0 -117
  68. package/src/tools/control-room/ui/panel.tsx +0 -60
  69. package/src/tools/control-room/ui/signal.tsx +0 -161
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,9 @@
13
13
  "README.md",
14
14
  "LICENSE"
15
15
  ],
16
+ "workspaces": [
17
+ "src/tools/control-room/ui"
18
+ ],
16
19
  "engines": {
17
20
  "bun": ">=1.4.0"
18
21
  },
@@ -53,7 +56,9 @@
53
56
  "lint-staged": "lint-staged",
54
57
  "prepare": "bun .husky/install.mjs",
55
58
  "build:skill-tools": "bun run scripts/build-skill-tools.ts",
56
- "prepack": "bun run build:skill-tools",
59
+ "build:ui": "bun run --filter @pal/control-room-ui build",
60
+ "dev:ui": "bun run --filter @pal/control-room-ui dev",
61
+ "prepack": "bun run build:skill-tools && bun run build:ui",
57
62
  "install:all": "bun run src/cli/index.ts cli install",
58
63
  "uninstall": "bun run src/cli/index.ts cli uninstall",
59
64
  "tool:synthesize": "bun run src/tools/agent/synthesize.ts",
@@ -61,7 +66,9 @@
61
66
  "tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
62
67
  "tool:reflect": "bun run src/tools/relationship-reflect.ts",
63
68
  "eval:sentiment": "bun eval/run.ts sentiment",
64
- "eval:failure-principle": "bun eval/run.ts failure-principle"
69
+ "eval:failure-principle": "bun eval/run.ts failure-principle",
70
+ "eval:goal-links": "bun eval/run.ts goal-links",
71
+ "eval:waiting-on": "bun eval/run.ts waiting-on"
65
72
  },
66
73
  "lint-staged": {
67
74
  "*.ts": [
@@ -83,8 +90,6 @@
83
90
  "@types/adm-zip": "^0.5.8",
84
91
  "@types/bun": "latest",
85
92
  "@types/node": "latest",
86
- "@types/react": "^19.2.18",
87
- "@types/react-dom": "^19.2.7",
88
93
  "husky": "^9.1.7",
89
94
  "jscpd": "^4.2.3",
90
95
  "knip": "^6.14.1",
@@ -102,8 +107,6 @@
102
107
  "marked": "18.0.4",
103
108
  "pdf-lib": "1.17.1",
104
109
  "playwright": "^1.60.0",
105
- "react": "^19.2.8",
106
- "react-dom": "^19.2.8",
107
110
  "unpdf": "^0.12.0"
108
111
  }
109
112
  }
package/src/cli/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * doctor Check prerequisites and system health
18
18
  * usage Summarize token usage and cost
19
19
  * ledger <sub> [filters] Query the action ledger (log · show · stats)
20
- * server start|stop|status The control room, a local page over ~/.pal
20
+ * server start|stop|restart|status The control room, a local page over ~/.pal
21
21
  * skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
22
22
  * skill doctor <name|--all> Evaluate one skill, or every installed skill, against the authoring best practices
23
23
  * subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
@@ -342,7 +342,7 @@ function showHelp() {
342
342
  (search · graph · stats · hubs · find · show · add · ls)
343
343
  pal cli ledger <sub> [filters] Query the action ledger (log · show · stats)
344
344
  e.g. ledger log --project X --since 7d
345
- pal cli server start|stop|status The control room: a local page to open before a terminal
345
+ pal cli server start|stop|restart|status The control room: a local page to open before a terminal
346
346
  pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
347
347
  pal cli skill doctor <name|--all> Evaluate one skill, or every installed skill
348
348
  pal cli skill author-model Print the flagship model that authors skills for the active agent
@@ -1287,9 +1287,28 @@ async function install(targets: Targets) {
1287
1287
  `Shared: ${indexedSkills} skills indexed · ${palDocsCount} docs → ~/.pal/docs/ · AGENTS.md + context digests written`
1288
1288
  );
1289
1289
 
1290
+ await refreshControlRoom();
1291
+
1290
1292
  log.success("Done. Existing config was preserved — only new entries were added.");
1291
1293
  }
1292
1294
 
1295
+ /**
1296
+ * A published install carries the page in its tarball; a checkout does not —
1297
+ * ui/dist is gitignored, so a pull leaves whatever was built last. Rebuild
1298
+ * there, then replace the process, because the API is the running code.
1299
+ */
1300
+ async function refreshControlRoom(): Promise<void> {
1301
+ const { isRepoMode } = await import("../hooks/handlers/update-check");
1302
+ const { buildPage } = await import("../tools/control-room/static");
1303
+ if (isRepoMode() && !buildPage()) {
1304
+ log.warn("Control room page could not be rebuilt — run: bun run build:ui");
1305
+ return;
1306
+ }
1307
+
1308
+ const { restartIfRunning } = await import("./server");
1309
+ if (await restartIfRunning()) log.success("Control room restarted on the new build");
1310
+ }
1311
+
1293
1312
  async function uninstall(args: string[]) {
1294
1313
  const targets = parseTargets(args);
1295
1314
 
package/src/cli/server.ts CHANGED
@@ -12,6 +12,7 @@ import { parseArgs } from "node:util";
12
12
  import { spawnDetachedInference } from "../hooks/lib/detached-inference";
13
13
  import { paths } from "../hooks/lib/paths";
14
14
  import { DEFAULT_PORT, LOOPBACK, type ServerStatus } from "../tools/control-room/server";
15
+ import { BUILD_COMMAND, buildPage, isBuilt } from "../tools/control-room/static";
15
16
 
16
17
  interface ServerState {
17
18
  pid: number;
@@ -36,6 +37,8 @@ export async function runServer(args: string[]): Promise<number> {
36
37
  return cmdStart(rest);
37
38
  case "stop":
38
39
  return cmdStop();
40
+ case "restart":
41
+ return cmdRestart();
39
42
  case "status":
40
43
  return cmdStatus();
41
44
  case undefined:
@@ -59,6 +62,7 @@ function showHelp(): void {
59
62
  Subcommands:
60
63
  start [--port <n>] Start the control room in the background (default port ${DEFAULT_PORT})
61
64
  stop Stop it
65
+ restart Replace a running one — the API only changes when the process does
62
66
  status Show whether it is running, and where
63
67
 
64
68
  The page listens on ${LOOPBACK} only.
@@ -137,6 +141,12 @@ async function cmdStart(args: string[]): Promise<number> {
137
141
  return 0;
138
142
  }
139
143
 
144
+ if (!isBuilt() && !buildPage()) {
145
+ return fail(
146
+ `The control room's page is not built and could not be. Run: ${BUILD_COMMAND}`
147
+ );
148
+ }
149
+
140
150
  spawnDetachedInference(SERVER_SCRIPT, [`--port=${port}`], "control-room");
141
151
  const status = await waitUntilAnswering(port);
142
152
  if (!status)
@@ -149,6 +159,29 @@ async function cmdStart(args: string[]): Promise<number> {
149
159
  return 0;
150
160
  }
151
161
 
162
+ /**
163
+ * The page is read off disk per request, so a rebuilt dist reaches the browser
164
+ * on the next reload — but the API routes are the running process's own code,
165
+ * and those only change when the process does.
166
+ */
167
+ async function cmdRestart(): Promise<number> {
168
+ const running = await runningServer();
169
+ if (!running) {
170
+ console.log("Not running — nothing to restart. Use `start`.");
171
+ return 0;
172
+ }
173
+ await cmdStop();
174
+ return cmdStart(["--port", String(running.port)]);
175
+ }
176
+
177
+ /**
178
+ * Used by `pal cli install`: an install that leaves an old build answering on
179
+ * the port has not finished. A server nobody started stays unstarted.
180
+ */
181
+ export async function restartIfRunning(): Promise<boolean> {
182
+ return (await runningServer()) !== null && (await cmdRestart()) === 0;
183
+ }
184
+
152
185
  /** The state file is a claim; the process answering on that port is the fact. */
153
186
  async function runningServer(): Promise<ServerState | null> {
154
187
  const state = readState();
@@ -10,10 +10,14 @@
10
10
  * answer to "what now" is rarely "open a repository".
11
11
  */
12
12
 
13
+ import { writeFile } from "node:fs/promises";
13
14
  import { matrix } from "../../tools/control-room/matrix";
15
+ import { handoffFile, readHandoffs, withWaitingOn } from "../../tools/lib/handoff-note";
14
16
  import { type AgendaMove, readAgenda, writeAgenda } from "../lib/agenda-store";
17
+ import { linkInputs, readGoalLinks, writeGoalLinks } from "../lib/goal-links";
15
18
  import { canInfer, inference } from "../lib/inference";
16
19
  import { logDebug, logError } from "../lib/log";
20
+ import { SONNET_MODEL } from "../lib/models";
17
21
  import { readAllProjects } from "../lib/projects";
18
22
  import { isServesKind, SERVES_KINDS, setServes } from "../lib/serves";
19
23
  import { readTelosGoals } from "../lib/telos-goals";
@@ -21,6 +25,7 @@ import { logTokenUsage } from "../lib/token-usage";
21
25
 
22
26
  const FRESH_HOURS = 6;
23
27
  const MAX_PROJECTS_PER_GUESS = 40;
28
+ const MAX_WAITING_PER_RUN = 3;
24
29
 
25
30
  function hoursSince(iso: string, now: Date): number {
26
31
  const at = new Date(iso).getTime();
@@ -53,6 +58,45 @@ const SERVES_SCHEMA = {
53
58
  required: ["projects"] as const,
54
59
  };
55
60
 
61
+ const LINKS_SCHEMA = {
62
+ type: "object" as const,
63
+ additionalProperties: false,
64
+ properties: {
65
+ links: {
66
+ type: "array" as const,
67
+ description: "One entry per goal you were given, no others",
68
+ items: {
69
+ type: "object" as const,
70
+ additionalProperties: false,
71
+ properties: {
72
+ goalId: { type: "string" as const },
73
+ projects: {
74
+ type: "array" as const,
75
+ description:
76
+ "Slugs of the projects that move this goal forward, copied exactly. Empty when none does.",
77
+ items: { type: "string" as const },
78
+ },
79
+ },
80
+ required: ["goalId", "projects"] as const,
81
+ },
82
+ },
83
+ },
84
+ required: ["links"] as const,
85
+ };
86
+
87
+ const WAITING_SCHEMA = {
88
+ type: "object" as const,
89
+ additionalProperties: false,
90
+ properties: {
91
+ question: {
92
+ type: "string" as const,
93
+ description:
94
+ "The one thing the work needs from the person before it can move, in their own terms. Empty string when the handoff asks nothing of them.",
95
+ },
96
+ },
97
+ required: ["question"] as const,
98
+ };
99
+
56
100
  const MOVES_SCHEMA = {
57
101
  type: "object" as const,
58
102
  additionalProperties: false,
@@ -72,8 +116,13 @@ const MOVES_SCHEMA = {
72
116
  type: "string" as const,
73
117
  description: "One short clause naming the evidence it came from",
74
118
  },
119
+ project: {
120
+ type: "string" as const,
121
+ description:
122
+ "The slug of the project this move belongs to, exactly as given, or an empty string when it belongs to none",
123
+ },
75
124
  },
76
- required: ["move", "because"] as const,
125
+ required: ["move", "because", "project"] as const,
77
126
  },
78
127
  },
79
128
  },
@@ -129,10 +178,11 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
129
178
  maxTokens: 700,
130
179
  timeout: 90000,
131
180
  jsonSchema: SERVES_SCHEMA,
181
+ model: SONNET_MODEL,
132
182
  caller: "agenda-serves",
133
183
  sessionId,
134
184
  });
135
- if (result.usage) logTokenUsage("agenda-serves", result.usage);
185
+ if (result.usage) logTokenUsage("agenda-serves", result.usage, SONNET_MODEL);
136
186
  if (!result.success || !result.output) return 0;
137
187
 
138
188
  const parsed = parsePayload<{
@@ -157,7 +207,7 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
157
207
 
158
208
  function matrixBrief(): string {
159
209
  const grid = matrix();
160
- const lines = [...grid.now, ...grid.plan, ...grid.noise].map((item) => {
210
+ const lines = rankedItems(grid).map((item) => {
161
211
  const why = item.urgentBecause.join(", ") || "nothing pressing";
162
212
  const waiting = item.waitingOn ? ` — waiting on the user for: ${item.waitingOn}` : "";
163
213
  return `- [${item.kind}] ${item.label}: ${item.importantBecause}; ${why}${waiting}`;
@@ -165,7 +215,129 @@ function matrixBrief(): string {
165
215
  return lines.length > 0 ? lines.join("\n") : "Nothing is ranked yet.";
166
216
  }
167
217
 
218
+ /**
219
+ * Only when the goals or the project set moved. A stop that changed neither
220
+ * costs nothing, which is what makes this affordable on every session.
221
+ */
222
+ async function refreshGoalLinks(
223
+ sessionId?: string
224
+ ): Promise<"fresh" | "written" | "failed"> {
225
+ const goals = readTelosGoals();
226
+ const projects = readAllProjects().filter((p) => p.status === "active");
227
+ if (goals.length === 0 || projects.length === 0) return "fresh";
228
+
229
+ const inputs = linkInputs(
230
+ goals.map((g) => g.id),
231
+ projects.map((p) => p.name)
232
+ );
233
+ if (readGoalLinks()?.inputs === inputs) return "fresh";
234
+
235
+ const described = projects
236
+ .map(
237
+ (p) =>
238
+ `- ${p.name}: ${(p.goal ?? p.problem ?? "").replace(/\s+/g, " ").slice(0, 200) || "no description on record"}`
239
+ )
240
+ .join("\n");
241
+ const listed = goals.map((g) => `- ${g.id}: ${g.text}`).join("\n");
242
+
243
+ const result = await inference({
244
+ system: [
245
+ "You are told a person's stated goals and their active projects.",
246
+ "For each goal, name the projects that move it forward.",
247
+ "Judge from the goal and the project description only. A project that merely resembles a goal in subject does not serve it — it has to move it.",
248
+ "A goal that nothing serves gets an empty list, and that is a useful answer rather than a failure.",
249
+ "Copy project slugs exactly. Return one entry per goal you were given.",
250
+ ].join("\n"),
251
+ user: `Their goals:\n${listed}\n\nTheir active projects:\n${described}`,
252
+ maxTokens: 600,
253
+ timeout: 90000,
254
+ jsonSchema: LINKS_SCHEMA,
255
+ model: SONNET_MODEL,
256
+ caller: "agenda-goal-links",
257
+ sessionId,
258
+ });
259
+ if (result.usage) logTokenUsage("agenda-goal-links", result.usage, SONNET_MODEL);
260
+ if (!result.success || !result.output) return "failed";
261
+
262
+ const parsed = parsePayload<{ links: { goalId: string; projects: unknown }[] }>(
263
+ result.output,
264
+ "agenda:goal-links"
265
+ );
266
+ if (!parsed?.links) return "failed";
267
+
268
+ const goalIds = new Set(goals.map((g) => g.id));
269
+ const slugs = new Set(projects.map((p) => p.name));
270
+ const links = parsed.links
271
+ .filter((l) => goalIds.has(l.goalId))
272
+ .map((l) => ({
273
+ goalId: l.goalId,
274
+ projects: (Array.isArray(l.projects) ? l.projects : []).filter(
275
+ (slug): slug is string => typeof slug === "string" && slugs.has(slug)
276
+ ),
277
+ }));
278
+
279
+ await writeGoalLinks({ generatedAt: new Date().toISOString(), inputs, links });
280
+ return "written";
281
+ }
282
+
283
+ /**
284
+ * An auto handoff is a transcript excerpt nobody framed, so it never carries the
285
+ * question the work is stuck on — which is exactly the field three surfaces read.
286
+ * Most handoffs are stuck on nothing, so an empty answer is the common one.
287
+ */
288
+ async function extractWaitingOn(sessionId?: string): Promise<number> {
289
+ const pending = Object.entries(readHandoffs()).filter(
290
+ ([, h]) =>
291
+ h.source === "auto" && h.status === "in-progress" && h.handoff && !h.waitingOn
292
+ );
293
+ if (pending.length === 0) return 0;
294
+
295
+ let written = 0;
296
+ for (const [cwd, entry] of pending.slice(0, MAX_WAITING_PER_RUN)) {
297
+ const result = await inference({
298
+ system: [
299
+ "You are given the last exchange of a coding session that stopped mid-work.",
300
+ "Decide whether the work is blocked on the person — a decision only they can make, a question they were asked and did not answer, an approval the work needs.",
301
+ "Most sessions are not blocked on anyone. Waiting for an agent to continue is not being blocked on the person.",
302
+ "When it is blocked, answer with the question in their own terms, under fifteen words.",
303
+ "When it is not, answer with an empty string. That is the ordinary answer.",
304
+ ].join("\n"),
305
+ user: entry.handoff,
306
+ maxTokens: 120,
307
+ timeout: 60000,
308
+ jsonSchema: WAITING_SCHEMA,
309
+ model: SONNET_MODEL,
310
+ caller: "agenda-waiting-on",
311
+ sessionId,
312
+ });
313
+ if (result.usage) logTokenUsage("agenda-waiting-on", result.usage, SONNET_MODEL);
314
+ if (!result.success || !result.output) continue;
315
+
316
+ const parsed = parsePayload<{ question: string }>(result.output, "agenda:waiting-on");
317
+ const question = parsed?.question?.trim();
318
+ if (!question) continue;
319
+ const store = withWaitingOn(readHandoffs(), cwd, question);
320
+ await writeFile(handoffFile(), JSON.stringify(store, null, 2), "utf-8");
321
+ written++;
322
+ }
323
+ return written;
324
+ }
325
+
326
+ function rankedItems(grid: ReturnType<typeof matrix>) {
327
+ return [...grid.now, ...grid.plan, ...grid.noise];
328
+ }
329
+
330
+ /** A model asked for a project name will invent one; only a slug it was shown counts. */
331
+ function knownSlug(candidate: unknown, slugs: Set<string>): string | null {
332
+ return typeof candidate === "string" && slugs.has(candidate) ? candidate : null;
333
+ }
334
+
168
335
  async function writeMoves(sessionId?: string): Promise<boolean> {
336
+ const slugs = new Set(
337
+ rankedItems(matrix())
338
+ .filter((i) => i.kind === "project")
339
+ .map((i) => i.id)
340
+ );
169
341
  const result = await inference({
170
342
  system: [
171
343
  "You write the first three lines a person reads in the morning.",
@@ -173,20 +345,31 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
173
345
  "Write exactly three moves, most consequential first.",
174
346
  "A move is a sentence naming an action, not a project name: 'Send ACE the mapping one-pager' beats 'work on ontology'.",
175
347
  "Prefer what is blocked on the person themselves, then what serves a goal, then what is merely urgent.",
348
+ "Name the project each move belongs to, copying its slug exactly from the list you were given. A move that belongs to no project takes an empty string.",
176
349
  "Never invent a fact that is not in what you were given.",
177
350
  ].join("\n"),
178
351
  user: `Their goals:\n${goalsBrief()}\n\nWhat is ranked and why:\n${matrixBrief()}`,
179
352
  maxTokens: 400,
180
353
  timeout: 90000,
181
354
  jsonSchema: MOVES_SCHEMA,
355
+ model: SONNET_MODEL,
182
356
  caller: "agenda-moves",
183
357
  sessionId,
184
358
  });
185
- if (result.usage) logTokenUsage("agenda-moves", result.usage);
359
+ if (result.usage) logTokenUsage("agenda-moves", result.usage, SONNET_MODEL);
186
360
  if (!result.success || !result.output) return false;
187
361
 
188
- const parsed = parsePayload<{ moves: AgendaMove[] }>(result.output, "agenda:moves");
189
- const moves = (parsed?.moves ?? []).filter((m) => m.move).slice(0, 3);
362
+ const parsed = parsePayload<{ moves: (AgendaMove & { project?: unknown })[] }>(
363
+ result.output,
364
+ "agenda:moves"
365
+ );
366
+ const moves: AgendaMove[] = (parsed?.moves ?? [])
367
+ .filter((m) => m.move)
368
+ .slice(0, 3)
369
+ .map(({ move, because, project }) => {
370
+ const slug = knownSlug(project, slugs);
371
+ return slug ? { move, because, project: slug } : { move, because };
372
+ });
190
373
  if (moves.length === 0) return false;
191
374
 
192
375
  await writeAgenda({ generatedAt: new Date().toISOString(), moves });
@@ -206,8 +389,13 @@ export async function refreshAgenda(
206
389
  if (!canInfer()) return "no-inference";
207
390
 
208
391
  const guessed = await guessMissingServes(sessionId);
392
+ const linked = await refreshGoalLinks(sessionId);
393
+ const waiting = await extractWaitingOn(sessionId);
209
394
  const wrote = await writeMoves(sessionId);
210
- logDebug("agenda", `serves guessed: ${guessed}, moves written: ${wrote}`);
395
+ logDebug(
396
+ "agenda",
397
+ `serves guessed: ${guessed}, links: ${linked}, waitingOn: ${waiting}, moves written: ${wrote}`
398
+ );
211
399
  return wrote ? "written" : "failed";
212
400
  }
213
401
 
@@ -46,7 +46,7 @@ function writeCache(cache: UpdateCache): void {
46
46
  }
47
47
  }
48
48
 
49
- function isRepoMode(): boolean {
49
+ export function isRepoMode(): boolean {
50
50
  return existsSync(resolve(palPkg(), ".git"));
51
51
  }
52
52
 
@@ -13,6 +13,8 @@ import { paths } from "./paths";
13
13
  export interface AgendaMove {
14
14
  move: string;
15
15
  because: string;
16
+ /** The project the move belongs to, when it belongs to one the page can open. */
17
+ project?: string;
16
18
  }
17
19
 
18
20
  export interface Agenda {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Which projects serve which stated goal, and how far along that makes it.
3
+ *
4
+ * The linkage needs judgement — "Catalyst is the starter that pays for the rest"
5
+ * serves "land two retained clients" only if you know what both mean — so a model
6
+ * draws it. The progress does not: it is criteria closed over criteria written,
7
+ * counted here, because a model returning "64%" says something unfalsifiable
8
+ * about a goal it cannot measure.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { writeFile } from "node:fs/promises";
13
+ import { resolve } from "node:path";
14
+ import { paths } from "./paths";
15
+
16
+ interface GoalLink {
17
+ goalId: string;
18
+ projects: string[];
19
+ }
20
+
21
+ export interface GoalLinks {
22
+ generatedAt: string;
23
+ /** Hash of the goals and projects the linkage was drawn from. */
24
+ inputs: string;
25
+ links: GoalLink[];
26
+ }
27
+
28
+ export interface GoalProgress {
29
+ projects: string[];
30
+ closed: number;
31
+ written: number;
32
+ }
33
+
34
+ function goalLinksPath(): string {
35
+ return resolve(paths.state(), "goal-links.json");
36
+ }
37
+
38
+ export function readGoalLinks(): GoalLinks | null {
39
+ const path = goalLinksPath();
40
+ if (!existsSync(path)) return null;
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as GoalLinks;
43
+ return Array.isArray(parsed.links) && parsed.generatedAt ? parsed : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export async function writeGoalLinks(links: GoalLinks): Promise<void> {
50
+ await writeFile(goalLinksPath(), `${JSON.stringify(links, null, 2)}\n`, "utf-8");
51
+ }
52
+
53
+ /**
54
+ * A stable fingerprint of what the linkage was drawn from, so a stop where
55
+ * neither the goals nor the projects moved costs nothing.
56
+ */
57
+ export function linkInputs(goalIds: string[], projectSlugs: string[]): string {
58
+ return Bun.hash(
59
+ JSON.stringify([[...goalIds].sort(), [...projectSlugs].sort()])
60
+ ).toString(36);
61
+ }
62
+
63
+ /**
64
+ * Counted, not estimated. A goal nothing serves has no progress rather than
65
+ * zero — "no project serves this yet" is the answer worth showing.
66
+ */
67
+ export function progressFor(
68
+ goalId: string,
69
+ links: GoalLinks | null,
70
+ criteriaBySlug: Map<string, { closed: number; written: number }>
71
+ ): GoalProgress | null {
72
+ const projects = links?.links.find((l) => l.goalId === goalId)?.projects ?? [];
73
+ const known = projects.filter((slug) => criteriaBySlug.has(slug));
74
+ if (known.length === 0) return null;
75
+ let closed = 0;
76
+ let written = 0;
77
+ for (const slug of known) {
78
+ const counts = criteriaBySlug.get(slug) as { closed: number; written: number };
79
+ closed += counts.closed;
80
+ written += counts.written;
81
+ }
82
+ return { projects: known, closed, written };
83
+ }
@@ -5,7 +5,7 @@
5
5
  import type { AgentType } from "./agent";
6
6
 
7
7
  export const HAIKU_MODEL = "claude-haiku-4-5-20251001";
8
- export const SONNET_MODEL = "claude-sonnet-4-6";
8
+ export const SONNET_MODEL = "claude-sonnet-5";
9
9
  export const FABLE_MODEL = "claude-fable-5";
10
10
 
11
11
  /**
@@ -45,6 +45,9 @@ export interface ProjectProgress {
45
45
  serves?: ServesKind;
46
46
  serves_note?: string;
47
47
  serves_by?: ServesAuthority;
48
+ /** The user's own placement on the grid, which overrules both guesses. */
49
+ placed?: string;
50
+ placed_by?: ServesAuthority;
48
51
  // ISA body sections
49
52
  problem?: string;
50
53
  goal?: string;
@@ -284,6 +287,8 @@ export function writeProject(p: ProjectProgress): void {
284
287
  if (p.serves) meta.serves = p.serves;
285
288
  if (p.serves_note) meta.serves_note = p.serves_note;
286
289
  if (p.serves_by) meta.serves_by = p.serves_by;
290
+ if (p.placed) meta.placed = p.placed;
291
+ if (p.placed_by) meta.placed_by = p.placed_by;
287
292
  writeFileSync(ensureAndGetIsaFile(p.name), stringify(meta, buildBody(p)), "utf-8");
288
293
  }
289
294
 
@@ -30,6 +30,8 @@ export interface PalSettingsData {
30
30
  * paths whose contents are never stored; it cannot shrink it.
31
31
  */
32
32
  ledger?: { redactPaths?: string[] };
33
+ /** Control-room ranking knobs and attention sources — see src/tools/control-room/prefs.ts. */
34
+ controlRoom?: Record<string, unknown>;
33
35
  /** Contextual-steering user extension: personal rules + shipped rules to suppress by tag. */
34
36
  steering?: {
35
37
  disable?: string[];
@@ -82,6 +82,11 @@ function isScaffolding(line: string): boolean {
82
82
  return !l || l.startsWith("<!--") || l.startsWith("-->") || /^(-{3,}|_{3,})$/.test(l);
83
83
  }
84
84
 
85
+ /** A scaffold's empty bullet is punctuation, not a goal you stated. */
86
+ function hasWords(text: string): boolean {
87
+ return /[a-z0-9]/i.test(text);
88
+ }
89
+
85
90
  /**
86
91
  * Bullets are entries when a block has them; otherwise the paragraph is the
87
92
  * entry. Both shapes ship — the scaffold offers horizon headings with bullets,
@@ -90,10 +95,11 @@ function isScaffolding(line: string): boolean {
90
95
  function entriesIn(lines: string[]): string[] {
91
96
  const bullets = lines
92
97
  .filter((l) => /^\s*[-*+]\s+\S/.test(l))
93
- .map((l) => l.replace(/^\s*[-*+]\s+/, "").trim());
98
+ .map((l) => l.replace(/^\s*[-*+]\s+/, "").trim())
99
+ .filter(hasWords);
94
100
  if (bullets.length > 0) return bullets;
95
101
  const paragraph = lines.join(" ").trim();
96
- return paragraph ? [paragraph] : [];
102
+ return hasWords(paragraph) ? [paragraph] : [];
97
103
  }
98
104
 
99
105
  function goalsFrom(content: string): TelosGoal[] {
@@ -16,6 +16,8 @@ type TokenCaller =
16
16
  | "relationship"
17
17
  | "self-model"
18
18
  | "agenda-serves"
19
+ | "agenda-goal-links"
20
+ | "agenda-waiting-on"
19
21
  | "agenda-moves";
20
22
 
21
23
  interface TokenUsageEntry {