portable-agent-layer 0.72.0 → 0.73.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 (67) hide show
  1. package/package.json +10 -7
  2. package/src/cli/server.ts +7 -0
  3. package/src/hooks/handlers/agenda.ts +195 -7
  4. package/src/hooks/lib/agenda-store.ts +2 -0
  5. package/src/hooks/lib/goal-links.ts +83 -0
  6. package/src/hooks/lib/models.ts +1 -1
  7. package/src/hooks/lib/projects.ts +5 -0
  8. package/src/hooks/lib/settings.ts +2 -0
  9. package/src/hooks/lib/telos-goals.ts +8 -2
  10. package/src/hooks/lib/token-usage.ts +2 -0
  11. package/src/tools/agent/project.ts +22 -20
  12. package/src/tools/control-room/attention.ts +146 -0
  13. package/src/tools/control-room/data.ts +76 -5
  14. package/src/tools/control-room/detail.ts +158 -0
  15. package/src/tools/control-room/matrix.ts +91 -19
  16. package/src/tools/control-room/prefs.ts +96 -0
  17. package/src/tools/control-room/server-config.ts +8 -0
  18. package/src/tools/control-room/server.ts +196 -11
  19. package/src/tools/control-room/snooze.ts +65 -0
  20. package/src/tools/control-room/static.ts +68 -0
  21. package/src/tools/control-room/ui/app.tsx +196 -50
  22. package/src/tools/control-room/ui/attention-bell.tsx +118 -0
  23. package/src/tools/control-room/ui/components/README.md +18 -0
  24. package/src/tools/control-room/ui/components/badge.tsx +38 -0
  25. package/src/tools/control-room/ui/components/button.tsx +43 -0
  26. package/src/tools/control-room/ui/components/card.tsx +45 -0
  27. package/src/tools/control-room/ui/components/input.tsx +24 -0
  28. package/src/tools/control-room/ui/components/label.tsx +18 -0
  29. package/src/tools/control-room/ui/components/popover.tsx +37 -0
  30. package/src/tools/control-room/ui/components/separator.tsx +25 -0
  31. package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
  32. package/src/tools/control-room/ui/components/switch.tsx +27 -0
  33. package/src/tools/control-room/ui/components/table.tsx +60 -0
  34. package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
  35. package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
  36. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
  37. package/src/tools/control-room/ui/dist/index.html +19 -0
  38. package/src/tools/control-room/ui/format.ts +0 -12
  39. package/src/tools/control-room/ui/frame.tsx +125 -0
  40. package/src/tools/control-room/ui/index.html +2 -2
  41. package/src/tools/control-room/ui/lib/api.ts +27 -0
  42. package/src/tools/control-room/ui/lib/cn.ts +6 -0
  43. package/src/tools/control-room/ui/lib/write.ts +43 -0
  44. package/src/tools/control-room/ui/package.json +28 -0
  45. package/src/tools/control-room/ui/parts.tsx +235 -0
  46. package/src/tools/control-room/ui/screens/log.tsx +274 -0
  47. package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
  48. package/src/tools/control-room/ui/screens/projects.tsx +128 -0
  49. package/src/tools/control-room/ui/screens/settings.tsx +205 -0
  50. package/src/tools/control-room/ui/screens/today.tsx +391 -0
  51. package/src/tools/control-room/ui/theme.css +127 -0
  52. package/src/tools/control-room/ui/vite.config.ts +36 -0
  53. package/src/tools/control-room/writes.ts +106 -0
  54. package/src/tools/ledger/outcomes.ts +9 -0
  55. package/src/tools/ledger/query.ts +2 -0
  56. package/src/tools/ledger/view.ts +31 -10
  57. package/src/tools/lib/handoff-note.ts +14 -0
  58. package/src/tools/lib/project-isc.ts +61 -0
  59. package/src/tools/control-room/ui/agenda.tsx +0 -43
  60. package/src/tools/control-room/ui/agents.tsx +0 -67
  61. package/src/tools/control-room/ui/app.css +0 -857
  62. package/src/tools/control-room/ui/board.tsx +0 -82
  63. package/src/tools/control-room/ui/handoffs.tsx +0 -37
  64. package/src/tools/control-room/ui/ledger.tsx +0 -137
  65. package/src/tools/control-room/ui/matrix.tsx +0 -117
  66. package/src/tools/control-room/ui/panel.tsx +0 -60
  67. 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.73.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/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;
@@ -137,6 +138,12 @@ async function cmdStart(args: string[]): Promise<number> {
137
138
  return 0;
138
139
  }
139
140
 
141
+ if (!isBuilt() && !buildPage()) {
142
+ return fail(
143
+ `The control room's page is not built and could not be. Run: ${BUILD_COMMAND}`
144
+ );
145
+ }
146
+
140
147
  spawnDetachedInference(SERVER_SCRIPT, [`--port=${port}`], "control-room");
141
148
  const status = await waitUntilAnswering(port);
142
149
  if (!status)
@@ -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
 
@@ -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 {
@@ -42,13 +42,16 @@ import {
42
42
  import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
43
43
  import {
44
44
  archiveLine,
45
- dropEmptyArchiveHeadings,
45
+ completeIsc,
46
46
  encodeIscText,
47
47
  ISC_BOX,
48
+ type IscMove,
49
+ type IscSections,
48
50
  iscTitle,
49
51
  nextIscId,
50
52
  parseIscs,
51
53
  removeIscLine,
54
+ reopenIsc,
52
55
  selectIscs,
53
56
  taskSlug,
54
57
  } from "../lib/project-isc";
@@ -440,17 +443,25 @@ function cmdCompleteIsc(args: string[]): void {
440
443
  const id = Number(args[1] ?? fail("Usage: complete-isc <name> <id>"));
441
444
  if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
442
445
  const p = requireProject(name);
443
- if (parseIscs(p.changelog ?? "").some((i) => i.id === id)) {
446
+ const move = completeIsc(sectionsOf(p), id);
447
+ if (!move.ok) fail(`${move.reason} in project "${name}"`);
448
+ if (move.already) {
444
449
  ok({ checked: true, id, alreadyDone: true });
445
450
  return;
446
451
  }
447
- const { line, rest } = removeIscLine(p.criteria ?? "", id);
448
- if (!line) fail(`ISC-${id} not found in project "${name}"`);
449
- p.criteria = rest;
450
- p.changelog = archiveLine(p.changelog, line.replace("[ ]", "[x]"));
452
+ applyIscMove(p, move);
453
+ ok({ checked: true, id, archived: true });
454
+ }
455
+
456
+ function sectionsOf(p: ProjectProgress): IscSections {
457
+ return { criteria: p.criteria ?? "", changelog: p.changelog ?? "" };
458
+ }
459
+
460
+ function applyIscMove(p: ProjectProgress, move: IscMove & { ok: true }): void {
461
+ p.criteria = move.criteria;
462
+ p.changelog = move.changelog;
451
463
  p.updated = now();
452
464
  writeProject(p);
453
- ok({ checked: true, id, archived: true });
454
465
  }
455
466
 
456
467
  // Reopening pulls the line back out of the Changelog (or legacy Criteria) into
@@ -460,22 +471,13 @@ function cmdReopenIsc(args: string[]): void {
460
471
  const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
461
472
  if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
462
473
  const p = requireProject(name);
463
- if (parseIscs(p.criteria ?? "").some((i) => i.id === id && i.status === "open")) {
474
+ const move = reopenIsc(sectionsOf(p), id);
475
+ if (!move.ok) fail(`${move.reason} in project "${name}"`);
476
+ if (move.already) {
464
477
  ok({ checked: false, id, alreadyOpen: true });
465
478
  return;
466
479
  }
467
- let removed = removeIscLine(p.changelog ?? "", id);
468
- if (removed.line) {
469
- p.changelog = dropEmptyArchiveHeadings(removed.rest);
470
- } else {
471
- removed = removeIscLine(p.criteria ?? "", id);
472
- if (removed.line) p.criteria = removed.rest;
473
- }
474
- if (!removed.line) fail(`ISC-${id} not found in project "${name}"`);
475
- const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
476
- p.criteria = p.criteria ? `${p.criteria.trimEnd()}\n${openLine}` : openLine;
477
- p.updated = now();
478
- writeProject(p);
480
+ applyIscMove(p, move);
479
481
  ok({ checked: false, id });
480
482
  }
481
483