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,58 @@
1
+ /**
2
+ * The one fact that goes stale inside a session: the time.
3
+ *
4
+ * The session-start reminder stamps the clock once, so a conversation picked up
5
+ * the next morning still believes it is yesterday. This line rides along with
6
+ * every prompt instead, in the principal's own timezone, because "is it morning"
7
+ * is the question the agent keeps getting wrong.
8
+ */
9
+
10
+ import { identity, isEnabled } from "./settings";
11
+
12
+ const FIELDS = {
13
+ weekday: "short",
14
+ year: "numeric",
15
+ month: "2-digit",
16
+ day: "2-digit",
17
+ hour: "2-digit",
18
+ minute: "2-digit",
19
+ hourCycle: "h23",
20
+ } as const;
21
+
22
+ function fieldsIn(now: Date, timeZone: string): Record<string, string> {
23
+ const parts = new Intl.DateTimeFormat("en-US", { ...FIELDS, timeZone }).formatToParts(
24
+ now
25
+ );
26
+ return Object.fromEntries(parts.map((p) => [p.type, p.value]));
27
+ }
28
+
29
+ /** The IANA name Intl settles on, or null when it does not recognise the input. */
30
+ export function canonicalTimeZone(timeZone: string): string | null {
31
+ try {
32
+ return new Intl.DateTimeFormat("en-US", { timeZone }).resolvedOptions().timeZone;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function isValidTimeZone(timeZone: string): boolean {
39
+ return canonicalTimeZone(timeZone) !== null;
40
+ }
41
+
42
+ /** A configured timezone is user input, and Intl throws on a bad one. UTC is always true. */
43
+ function zoneOrUtc(timeZone: string): string {
44
+ return timeZone && isValidTimeZone(timeZone) ? timeZone : "UTC";
45
+ }
46
+
47
+ /** @lintignore exercised directly by test/wall-clock.test.ts */
48
+ export function wallClockLine(now: Date, configuredZone: string): string {
49
+ const zone = zoneOrUtc(configuredZone);
50
+ const f = fieldsIn(now, zone);
51
+ return `Now: ${f.weekday} ${f.year}-${f.month}-${f.day} ${f.hour}:${f.minute} ${zone}`;
52
+ }
53
+
54
+ export function getWallClockReminder(now: Date = new Date()): string | null {
55
+ if (!isEnabled("wallClock")) return null;
56
+ const line = wallClockLine(now, identity().principal.timezone);
57
+ return `<system-reminder>${line}</system-reminder>`;
58
+ }
@@ -16,20 +16,22 @@ import { parseArgs } from "node:util";
16
16
  import { ensureDir, paths } from "../../hooks/lib/paths";
17
17
  import { emit } from "../lib/emit";
18
18
 
19
- interface HandoffEntry {
19
+ export interface HandoffEntry {
20
20
  timestamp: string;
21
21
  title: string;
22
22
  status: "in-progress" | "completed";
23
23
  handoff: string;
24
24
  artifacts: string[];
25
25
  source: "deliberate" | "auto";
26
+ /** What the work needs from the human before it can move — the one thing an agent cannot unblock. */
27
+ waitingOn?: string;
26
28
  }
27
29
 
28
30
  function handoffPath(): string {
29
31
  return resolve(ensureDir(paths.state()), "last-handoff.json");
30
32
  }
31
33
 
32
- function readHandoffs(): Record<string, HandoffEntry> {
34
+ export function readHandoffs(): Record<string, HandoffEntry> {
33
35
  const p = handoffPath();
34
36
  if (!existsSync(p)) return {};
35
37
  try {
@@ -47,23 +49,31 @@ function writeHandoffs(handoffs: Record<string, HandoffEntry>): number {
47
49
  return Object.keys(trimmed).length;
48
50
  }
49
51
 
50
- function writeHandoffNote(
51
- cwd: string,
52
- title: string,
53
- text: string,
54
- done: boolean
55
- ): { file: string; status: HandoffEntry["status"]; kept: number } {
52
+ interface NoteInput {
53
+ cwd: string;
54
+ title: string;
55
+ text: string;
56
+ done: boolean;
57
+ waitingOn?: string;
58
+ }
59
+
60
+ function writeHandoffNote(note: NoteInput): {
61
+ file: string;
62
+ status: HandoffEntry["status"];
63
+ kept: number;
64
+ } {
56
65
  const handoffs = readHandoffs();
57
- handoffs[cwd] = {
66
+ handoffs[note.cwd] = {
58
67
  timestamp: new Date().toISOString(),
59
- title,
60
- status: done ? "completed" : "in-progress",
61
- handoff: text,
68
+ title: note.title,
69
+ status: note.done ? "completed" : "in-progress",
70
+ handoff: note.text,
62
71
  artifacts: [],
63
72
  source: "deliberate",
73
+ ...(note.waitingOn ? { waitingOn: note.waitingOn } : {}),
64
74
  };
65
75
  const kept = writeHandoffs(handoffs);
66
- return { file: handoffPath(), status: handoffs[cwd].status, kept };
76
+ return { file: handoffPath(), status: handoffs[note.cwd].status, kept };
67
77
  }
68
78
 
69
79
  function run() {
@@ -72,6 +82,7 @@ function run() {
72
82
  options: {
73
83
  title: { type: "string" },
74
84
  text: { type: "string" },
85
+ waiting: { type: "string" },
75
86
  done: { type: "boolean" },
76
87
  help: { type: "boolean", short: "h" },
77
88
  },
@@ -88,6 +99,7 @@ Usage:
88
99
  Arguments:
89
100
  --title Brief title of what was being worked on (5-10 words)
90
101
  --text What remains unfinished — decisions made, next steps, blockers
102
+ --waiting What this needs from you before it can move (a decision, an answer, access)
91
103
  --done Mark as completed; suppresses "pick up where you left off" injection
92
104
 
93
105
  Output: writes to memory/state/last-handoff.json keyed by cwd
@@ -96,12 +108,12 @@ Output: writes to memory/state/last-handoff.json keyed by cwd
96
108
  }
97
109
 
98
110
  if (values.done) {
99
- const result = writeHandoffNote(
100
- process.cwd(),
101
- values.title || "session",
102
- values.text || "",
103
- true
104
- );
111
+ const result = writeHandoffNote({
112
+ cwd: process.cwd(),
113
+ title: values.title || "session",
114
+ text: values.text || "",
115
+ done: true,
116
+ });
105
117
  emit.receipt(result.file, { status: result.status, entries: result.kept });
106
118
  process.exit(0);
107
119
  }
@@ -111,7 +123,13 @@ Output: writes to memory/state/last-handoff.json keyed by cwd
111
123
  process.exit(1);
112
124
  }
113
125
 
114
- const result = writeHandoffNote(process.cwd(), values.title, values.text, false);
126
+ const result = writeHandoffNote({
127
+ cwd: process.cwd(),
128
+ title: values.title,
129
+ text: values.text,
130
+ done: false,
131
+ waitingOn: values.waiting,
132
+ });
115
133
  emit.receipt(result.file, { status: result.status, entries: result.kept });
116
134
  }
117
135
 
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Usage:
9
9
  * bun ~/.pal/tools/project.ts list
10
- * bun ~/.pal/tools/project.ts create [name] [--path PATH] [--objectives "..."]
10
+ * bun ~/.pal/tools/project.ts create [name] [--path PATH] [--objectives "..."] [--serves goal|revenue|fun]
11
+ * bun ~/.pal/tools/project.ts serves <name> <goal|revenue|fun> [note]
11
12
  * bun ~/.pal/tools/project.ts resume <name>
12
13
  * bun ~/.pal/tools/project.ts complete | archive | pause | unpause <name>
13
14
  * bun ~/.pal/tools/project.ts add-next <name> "text"
@@ -38,6 +39,7 @@ import {
38
39
  readProject,
39
40
  writeProject,
40
41
  } from "../../hooks/lib/projects";
42
+ import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
41
43
 
42
44
  function now(): string {
43
45
  return new Date().toISOString();
@@ -83,6 +85,8 @@ function cmdCreate(args: string[]): void {
83
85
  path: { type: "string" },
84
86
  name: { type: "string" },
85
87
  objectives: { type: "string" },
88
+ serves: { type: "string" },
89
+ "serves-note": { type: "string" },
86
90
  },
87
91
  allowPositionals: true,
88
92
  });
@@ -111,6 +115,10 @@ function cmdCreate(args: string[]): void {
111
115
  .join("\n")
112
116
  : undefined;
113
117
 
118
+ if (values.serves !== undefined && !isServesKind(values.serves)) {
119
+ fail(`--serves must be one of: ${SERVES_KINDS.join(", ")}`);
120
+ }
121
+
114
122
  const project: ProjectProgress = {
115
123
  name,
116
124
  path,
@@ -118,11 +126,31 @@ function cmdCreate(args: string[]): void {
118
126
  created: now(),
119
127
  updated: now(),
120
128
  ...(goalLines ? { goal: goalLines } : {}),
129
+ ...(isServesKind(values.serves)
130
+ ? { serves: values.serves, serves_by: "user" as const }
131
+ : {}),
132
+ ...(values["serves-note"] ? { serves_note: values["serves-note"] } : {}),
121
133
  };
122
134
  writeProject(project);
123
135
  ok({ created: true, project });
124
136
  }
125
137
 
138
+ /** The answer the scaffolder asks for, and the one place importance can be corrected. */
139
+ function cmdServes(args: string[]): void {
140
+ const [name, kind, ...note] = args;
141
+ if (!name || !kind) fail("Usage: serves <name> <goal|revenue|fun> [note]");
142
+ if (!isServesKind(kind)) fail(`serves must be one of: ${SERVES_KINDS.join(", ")}`);
143
+
144
+ const outcome = setServes({
145
+ name,
146
+ kind,
147
+ note: note.join(" ").trim() || undefined,
148
+ by: "user",
149
+ });
150
+ if (outcome === "missing") fail(`No project named "${name}".`);
151
+ ok({ project: name, serves: kind, by: "user" });
152
+ }
153
+
126
154
  // ── resume ────────────────────────────────────────────────────────
127
155
 
128
156
  // resume returns a lean orientation view: all narrative sections, but the
@@ -386,7 +414,7 @@ function statusFromBox(box: string): IscStatus {
386
414
  return "open";
387
415
  }
388
416
 
389
- interface Isc {
417
+ export interface Isc {
390
418
  id: number;
391
419
  text: string;
392
420
  status: IscStatus;
@@ -412,7 +440,7 @@ function decodeIscText(stored: string): string {
412
440
  return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
413
441
  }
414
442
 
415
- function parseIscs(criteria: string): Isc[] {
443
+ export function parseIscs(criteria: string): Isc[] {
416
444
  const out: Isc[] = [];
417
445
  for (const line of criteria.split("\n")) {
418
446
  const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
@@ -753,7 +781,8 @@ function help(): void {
753
781
 
754
782
  Commands:
755
783
  list show all registered projects
756
- create [name] [--path PATH] [--objectives X] register a project
784
+ create [name] [--path PATH] [--objectives X] [--serves KIND] register a project
785
+ serves <name> <goal|revenue|fun> [note] say what it is for — outranks PAL's guess
757
786
  resume <name> print lean project view (open-ISC titles; full text via show-isc)
758
787
  complete <name> mark complete
759
788
  archive <name> mark archived
@@ -796,6 +825,9 @@ function run(): void {
796
825
  case "create":
797
826
  cmdCreate(rest);
798
827
  return;
828
+ case "serves":
829
+ cmdServes(rest);
830
+ return;
799
831
  case "resume":
800
832
  cmdResume(rest);
801
833
  return;
@@ -0,0 +1,332 @@
1
+ /**
2
+ * The numbers behind the control room. Every panel reads through the same
3
+ * functions the session-start reminder uses, so the page and the agent can
4
+ * never disagree about a project's state.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { basename, resolve } from "node:path";
9
+ import { loadReflectNudge } from "../../hooks/handlers/reflect-trigger";
10
+ import { type AgendaMove, readAgenda } from "../../hooks/lib/agenda-store";
11
+ import {
12
+ isMaintainerEnv,
13
+ loadAlgorithmReviewNudge,
14
+ } from "../../hooks/lib/algorithm-review";
15
+ import { loadAnalyzeNudge } from "../../hooks/lib/analyze-nudge";
16
+ import { paths } from "../../hooks/lib/paths";
17
+ import { isStale, type ProjectProgress, readAllProjects } from "../../hooks/lib/projects";
18
+ import { readProjectHistory } from "../../hooks/lib/work-tracking";
19
+ import { type HandoffEntry, readHandoffs } from "../agent/handoff-note";
20
+ import { parseIscs } from "../agent/project";
21
+ import { anchorSlugOf, type LedgerFilter, queryLedger } from "../ledger/query";
22
+
23
+ const DAY_MS = 86_400_000;
24
+ const HANDOFF_FRESH_DAYS = 7;
25
+ const SESSION_WINDOW_DAYS = 30;
26
+ const SERIES_LENGTH = 60;
27
+
28
+ export interface ProjectCard {
29
+ slug: string;
30
+ path: string | null;
31
+ status: string;
32
+ updated: string;
33
+ ageDays: number;
34
+ stale: boolean;
35
+ openIscs: number;
36
+ next: string[];
37
+ blockers: string[];
38
+ lastSession: { date: string; title: string } | null;
39
+ sessions30d: number;
40
+ asking: string[];
41
+ }
42
+
43
+ export interface HandoffCard {
44
+ slug: string | null;
45
+ label: string;
46
+ cwd: string;
47
+ title: string;
48
+ sentence: string;
49
+ handoff: string;
50
+ at: string;
51
+ ageDays: number;
52
+ source: HandoffEntry["source"];
53
+ }
54
+
55
+ export interface AgendaView {
56
+ generatedAt: string | null;
57
+ ageHours: number | null;
58
+ stale: boolean;
59
+ moves: AgendaMove[];
60
+ }
61
+
62
+ export interface DueBadge {
63
+ state: "due" | "clear" | "n/a";
64
+ detail: string;
65
+ }
66
+
67
+ export interface RatingPoint {
68
+ ts: string;
69
+ rating: number;
70
+ }
71
+
72
+ export interface SignalView {
73
+ synthesizedAt: string | null;
74
+ ratings: {
75
+ count: number;
76
+ avg: number;
77
+ recentAvg: number;
78
+ lowCount: number;
79
+ trend: string;
80
+ } | null;
81
+ algorithm: {
82
+ reflectionCount: number;
83
+ passRate: number;
84
+ avgSentiment: number;
85
+ } | null;
86
+ series: RatingPoint[];
87
+ due: {
88
+ analysis: DueBadge;
89
+ algorithmReview: DueBadge;
90
+ relationshipReflect: DueBadge;
91
+ };
92
+ }
93
+
94
+ export interface AgentsRow {
95
+ slug: string;
96
+ actions: number;
97
+ runtimes: Record<string, number>;
98
+ machines: number;
99
+ actors: number;
100
+ sessions: number;
101
+ }
102
+
103
+ export interface AgentsView {
104
+ since: string;
105
+ projects: AgentsRow[];
106
+ }
107
+
108
+ function daysBetween(from: string, now: Date): number {
109
+ const age = now.getTime() - new Date(from).getTime();
110
+ return Number.isFinite(age) && age > 0 ? Math.floor(age / DAY_MS) : 0;
111
+ }
112
+
113
+ function isoDay(at: Date): string {
114
+ return at.toISOString().slice(0, 10);
115
+ }
116
+
117
+ function sessionsSince(path: string | null, since: Date): number {
118
+ if (!path) return 0;
119
+ const floor = isoDay(since);
120
+ return readProjectHistory(path, 200).filter((h) => h.date >= floor).length;
121
+ }
122
+
123
+ export function freshHandoffs(now: Date): [string, HandoffEntry][] {
124
+ return Object.entries(readHandoffs()).filter(
125
+ ([, h]) =>
126
+ h.status === "in-progress" &&
127
+ h.handoff &&
128
+ daysBetween(h.timestamp, now) < HANDOFF_FRESH_DAYS
129
+ );
130
+ }
131
+
132
+ export function askingReasons(
133
+ p: ProjectProgress,
134
+ handoff: HandoffEntry | undefined,
135
+ stale: boolean
136
+ ): string[] {
137
+ const reasons: string[] = [];
138
+ if (handoff) reasons.push("handoff in progress");
139
+ const blockers = p.blockers?.length ?? 0;
140
+ if (blockers > 0) reasons.push(blockers === 1 ? "1 blocker" : `${blockers} blockers`);
141
+ if (stale && p.status === "active") reasons.push("gone quiet");
142
+ return reasons;
143
+ }
144
+
145
+ function toCard(
146
+ p: ProjectProgress,
147
+ handoff: HandoffEntry | undefined,
148
+ now: Date
149
+ ): ProjectCard {
150
+ const path = p.path ?? null;
151
+ const stale = isStale(p);
152
+ const history = path ? readProjectHistory(path, 1) : [];
153
+ const last = history.at(-1);
154
+ return {
155
+ slug: p.name,
156
+ path,
157
+ status: p.status,
158
+ updated: p.updated,
159
+ ageDays: daysBetween(p.updated, now),
160
+ stale,
161
+ openIscs: parseIscs(p.criteria ?? "").filter((i) => i.status === "open").length,
162
+ next: p.next ?? [],
163
+ blockers: p.blockers ?? [],
164
+ lastSession: last ? { date: last.date, title: last.title } : null,
165
+ sessions30d: sessionsSince(
166
+ path,
167
+ new Date(now.getTime() - SESSION_WINDOW_DAYS * DAY_MS)
168
+ ),
169
+ asking: askingReasons(p, handoff, stale),
170
+ };
171
+ }
172
+
173
+ export function sortBoard(cards: ProjectCard[]): ProjectCard[] {
174
+ return [...cards].sort(
175
+ (a, b) => b.asking.length - a.asking.length || b.updated.localeCompare(a.updated)
176
+ );
177
+ }
178
+
179
+ export function board(now: Date = new Date()): ProjectCard[] {
180
+ const handoffs = new Map(freshHandoffs(now));
181
+ return sortBoard(
182
+ readAllProjects().map((p) =>
183
+ toCard(p, p.path ? handoffs.get(p.path) : undefined, now)
184
+ )
185
+ );
186
+ }
187
+
188
+ export function firstSentence(text: string): string {
189
+ const flat = text.replace(/\s+/g, " ").trim();
190
+ const match = new RegExp(/^.*?[.!?](?=\s|$)/).exec(flat);
191
+ return match ? match[0] : flat;
192
+ }
193
+
194
+ /** An automatic handoff is a transcript excerpt; the user's own words are the part worth a sentence. */
195
+ export function handoffSentence(text: string): string {
196
+ const [, userTurn] = text.split(/Last user message:\s*/, 2);
197
+ const spoken = userTurn ? userTurn.split(/\s*Last assistant response:/, 1)[0] : text;
198
+ return firstSentence(spoken);
199
+ }
200
+
201
+ export function handoffs(now: Date = new Date()): HandoffCard[] {
202
+ const slugByPath = new Map(
203
+ readAllProjects()
204
+ .filter((p) => p.path)
205
+ .map((p) => [p.path as string, p.name])
206
+ );
207
+ return freshHandoffs(now)
208
+ .map(([cwd, h]) => ({
209
+ slug: slugByPath.get(cwd) ?? null,
210
+ label: slugByPath.get(cwd) ?? basename(cwd),
211
+ cwd,
212
+ title: h.title,
213
+ sentence: handoffSentence(h.handoff),
214
+ handoff: h.handoff,
215
+ at: h.timestamp,
216
+ ageDays: daysBetween(h.timestamp, now),
217
+ source: h.source,
218
+ }))
219
+ .sort((a, b) => b.at.localeCompare(a.at));
220
+ }
221
+
222
+ /**
223
+ * The page never writes the agenda — it says how old the one on disk is, so a
224
+ * morning reading yesterday's three moves knows that is what it is looking at.
225
+ */
226
+ export function agenda(now: Date = new Date()): AgendaView {
227
+ const stored = readAgenda();
228
+ if (!stored) return { generatedAt: null, ageHours: null, stale: true, moves: [] };
229
+ const ageHours = (now.getTime() - new Date(stored.generatedAt).getTime()) / 3_600_000;
230
+ return {
231
+ generatedAt: stored.generatedAt,
232
+ ageHours: Math.max(0, Math.round(ageHours)),
233
+ stale: !(ageHours < 24),
234
+ moves: stored.moves,
235
+ };
236
+ }
237
+
238
+ /** The nudge text is the verdict; the badge only strips its heading and emoji. */
239
+ export function badgeFromNudge(nudge: string): DueBadge {
240
+ if (!nudge) return { state: "clear", detail: "" };
241
+ const detail = nudge.split("\n").slice(1).join(" ").replace(/^\W+/, "").trim();
242
+ return { state: "due", detail };
243
+ }
244
+
245
+ export function dueBadges(now: Date = new Date(), maintainer = isMaintainerEnv()) {
246
+ return {
247
+ analysis: badgeFromNudge(loadAnalyzeNudge(now)),
248
+ algorithmReview: maintainer
249
+ ? badgeFromNudge(loadAlgorithmReviewNudge(now))
250
+ : { state: "n/a" as const, detail: "only in a maintainer checkout" },
251
+ relationshipReflect: badgeFromNudge(loadReflectNudge()),
252
+ };
253
+ }
254
+
255
+ export function ratingSeries(limit = SERIES_LENGTH): RatingPoint[] {
256
+ const file = resolve(paths.signals(), "ratings.jsonl");
257
+ if (!existsSync(file)) return [];
258
+ const points: RatingPoint[] = [];
259
+ for (const line of readFileSync(file, "utf-8").split("\n")) {
260
+ if (!line.trim()) continue;
261
+ try {
262
+ const row = JSON.parse(line) as { ts?: string; rating?: number };
263
+ if (typeof row.rating === "number" && row.ts)
264
+ points.push({ ts: row.ts, rating: row.rating });
265
+ } catch {}
266
+ }
267
+ return points.slice(-limit);
268
+ }
269
+
270
+ function readSynthesis(): Record<string, unknown> | null {
271
+ const file = resolve(paths.state(), "synthesis.json");
272
+ if (!existsSync(file)) return null;
273
+ try {
274
+ return JSON.parse(readFileSync(file, "utf-8"));
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ export function signal(now: Date = new Date()): SignalView {
281
+ const s = readSynthesis();
282
+ const ratings = s?.ratings as SignalView["ratings"] | undefined;
283
+ const algorithm = s?.algorithm as SignalView["algorithm"] | undefined;
284
+ return {
285
+ synthesizedAt: typeof s?.timestamp === "string" ? s.timestamp : null,
286
+ ratings: ratings ?? null,
287
+ algorithm: algorithm
288
+ ? {
289
+ reflectionCount: algorithm.reflectionCount,
290
+ passRate: algorithm.passRate,
291
+ avgSentiment: algorithm.avgSentiment,
292
+ }
293
+ : null,
294
+ series: ratingSeries(),
295
+ due: dueBadges(now),
296
+ };
297
+ }
298
+
299
+ function count<T>(items: T[], key: (item: T) => string): number {
300
+ return new Set(items.map(key)).size;
301
+ }
302
+
303
+ export function agentsAtWork(filter: LedgerFilter, now: Date = new Date()): AgentsView {
304
+ const since = filter.since ?? new Date(now.getTime() - SESSION_WINDOW_DAYS * DAY_MS);
305
+ const pathBySlug = new Map(
306
+ readAllProjects()
307
+ .filter((p) => p.path)
308
+ .map((p) => [p.name, p.path as string])
309
+ );
310
+ const bySlug = Map.groupBy(
311
+ queryLedger({ ...filter, since }),
312
+ (e) => anchorSlugOf(e.target) ?? "unanchored"
313
+ );
314
+ const projects = [...bySlug.entries()].map(([slug, entries]) => ({
315
+ slug,
316
+ actions: entries.length,
317
+ runtimes: Object.fromEntries(
318
+ Map.groupBy(entries, (e) => e.runtime)
319
+ .entries()
320
+ .map(([runtime, group]) => [runtime, group.length])
321
+ ),
322
+ machines: count(entries, (e) => e.machine),
323
+ actors: count(entries, (e) => e.actor),
324
+ sessions: sessionsSince(pathBySlug.get(slug) ?? null, since),
325
+ }));
326
+ return {
327
+ since: since.toISOString(),
328
+ projects: projects.sort(
329
+ (a, b) => b.actions - a.actions || a.slug.localeCompare(b.slug)
330
+ ),
331
+ };
332
+ }