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.
- package/package.json +10 -7
- package/src/cli/server.ts +7 -0
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/telos-goals.ts +8 -2
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/agent/project.ts +22 -20
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +76 -5
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +91 -19
- package/src/tools/control-room/prefs.ts +96 -0
- package/src/tools/control-room/server-config.ts +8 -0
- package/src/tools/control-room/server.ts +196 -11
- package/src/tools/control-room/snooze.ts +65 -0
- package/src/tools/control-room/static.ts +68 -0
- package/src/tools/control-room/ui/app.tsx +196 -50
- package/src/tools/control-room/ui/attention-bell.tsx +118 -0
- package/src/tools/control-room/ui/components/README.md +18 -0
- package/src/tools/control-room/ui/components/badge.tsx +38 -0
- package/src/tools/control-room/ui/components/button.tsx +43 -0
- package/src/tools/control-room/ui/components/card.tsx +45 -0
- package/src/tools/control-room/ui/components/input.tsx +24 -0
- package/src/tools/control-room/ui/components/label.tsx +18 -0
- package/src/tools/control-room/ui/components/popover.tsx +37 -0
- package/src/tools/control-room/ui/components/separator.tsx +25 -0
- package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
- package/src/tools/control-room/ui/components/switch.tsx +27 -0
- package/src/tools/control-room/ui/components/table.tsx +60 -0
- package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
- package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +19 -0
- package/src/tools/control-room/ui/format.ts +0 -12
- package/src/tools/control-room/ui/frame.tsx +125 -0
- package/src/tools/control-room/ui/index.html +2 -2
- package/src/tools/control-room/ui/lib/api.ts +27 -0
- package/src/tools/control-room/ui/lib/cn.ts +6 -0
- package/src/tools/control-room/ui/lib/write.ts +43 -0
- package/src/tools/control-room/ui/package.json +28 -0
- package/src/tools/control-room/ui/parts.tsx +235 -0
- package/src/tools/control-room/ui/screens/log.tsx +274 -0
- package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
- package/src/tools/control-room/ui/screens/projects.tsx +128 -0
- package/src/tools/control-room/ui/screens/settings.tsx +205 -0
- package/src/tools/control-room/ui/screens/today.tsx +391 -0
- package/src/tools/control-room/ui/theme.css +127 -0
- package/src/tools/control-room/ui/vite.config.ts +36 -0
- package/src/tools/control-room/writes.ts +106 -0
- package/src/tools/ledger/outcomes.ts +9 -0
- package/src/tools/ledger/query.ts +2 -0
- package/src/tools/ledger/view.ts +31 -10
- package/src/tools/lib/handoff-note.ts +14 -0
- package/src/tools/lib/project-isc.ts +61 -0
- package/src/tools/control-room/ui/agenda.tsx +0 -43
- package/src/tools/control-room/ui/agents.tsx +0 -67
- package/src/tools/control-room/ui/app.css +0 -857
- package/src/tools/control-room/ui/board.tsx +0 -82
- package/src/tools/control-room/ui/handoffs.tsx +0 -37
- package/src/tools/control-room/ui/ledger.tsx +0 -137
- package/src/tools/control-room/ui/matrix.tsx +0 -117
- package/src/tools/control-room/ui/panel.tsx +0 -60
- 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.
|
|
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
|
-
"
|
|
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 =
|
|
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[] }>(
|
|
189
|
-
|
|
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(
|
|
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
|
|
|
@@ -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
|
+
}
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -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[] {
|
|
@@ -42,13 +42,16 @@ import {
|
|
|
42
42
|
import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
|
|
43
43
|
import {
|
|
44
44
|
archiveLine,
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|