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.
- package/package.json +10 -7
- package/src/cli/index.ts +21 -2
- package/src/cli/server.ts +33 -0
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/update-check.ts +1 -1
- 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
|
@@ -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
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is waiting on you, and what you have already seen.
|
|
3
|
+
*
|
|
4
|
+
* Every item here is a template over a fact PAL already holds — a refused tool
|
|
5
|
+
* call, an unanswered handoff, a project with no purpose on record. Nothing is
|
|
6
|
+
* inferred: the bell is the one surface that must be instant and always right,
|
|
7
|
+
* and a model in that path would fail silently exactly when it mattered.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
13
|
+
import { readAllProjects } from "../../hooks/lib/projects";
|
|
14
|
+
import { queryLedger } from "../ledger/query";
|
|
15
|
+
import { displayTarget } from "../ledger/view";
|
|
16
|
+
import { freshHandoffs } from "./data";
|
|
17
|
+
import { type AttentionSource, readPrefs } from "./prefs";
|
|
18
|
+
|
|
19
|
+
const DAY_MS = 86_400_000;
|
|
20
|
+
const REFUSAL_WINDOW_DAYS = 7;
|
|
21
|
+
const MAX_ITEMS = 40;
|
|
22
|
+
|
|
23
|
+
export interface AttentionItem {
|
|
24
|
+
id: string;
|
|
25
|
+
source: AttentionSource;
|
|
26
|
+
severity: "alarm" | "waiting" | "note";
|
|
27
|
+
title: string;
|
|
28
|
+
detail: string;
|
|
29
|
+
at: string;
|
|
30
|
+
project: string | null;
|
|
31
|
+
/** Where clicking it should land. */
|
|
32
|
+
href: string;
|
|
33
|
+
read: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AttentionView {
|
|
37
|
+
unread: number;
|
|
38
|
+
items: AttentionItem[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ReadState = Record<string, string>;
|
|
42
|
+
|
|
43
|
+
function readFile(): string {
|
|
44
|
+
return resolve(ensureDir(paths.state()), "attention-read.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readMarks(): ReadState {
|
|
48
|
+
const file = readFile();
|
|
49
|
+
if (!existsSync(file)) return {};
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(file, "utf-8")) as ReadState;
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeMarks(marks: ReadState): void {
|
|
58
|
+
writeFileSync(readFile(), `${JSON.stringify(marks, null, 2)}\n`, "utf-8");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function markRead(ids: string[], now: Date = new Date()): number {
|
|
62
|
+
const marks = readMarks();
|
|
63
|
+
for (const id of ids) marks[id] = now.toISOString();
|
|
64
|
+
writeMarks(marks);
|
|
65
|
+
return ids.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function refusals(now: Date): AttentionItem[] {
|
|
69
|
+
const since = new Date(now.getTime() - REFUSAL_WINDOW_DAYS * DAY_MS);
|
|
70
|
+
return queryLedger({ since })
|
|
71
|
+
.filter((e) => e.outcome === "denied" || e.outcome === "blocked")
|
|
72
|
+
.map((e) => ({
|
|
73
|
+
id: `refusal:${e.id}`,
|
|
74
|
+
source: "refusals" as const,
|
|
75
|
+
severity: "alarm" as const,
|
|
76
|
+
title: `${e.outcome === "blocked" ? "Blocked" : "Denied"}: ${e.tool}`,
|
|
77
|
+
detail: e.reason ?? e.command ?? displayTarget(e.target),
|
|
78
|
+
at: e.ts,
|
|
79
|
+
project: anchorProject(e.target),
|
|
80
|
+
href: `/log?outcome=${e.outcome}`,
|
|
81
|
+
read: false,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function anchorProject(target: string): string | null {
|
|
86
|
+
const match = /^\{proj:([^}]+)\}/.exec(target);
|
|
87
|
+
return match ? match[1] : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function waiting(now: Date): AttentionItem[] {
|
|
91
|
+
const slugByPath = new Map(
|
|
92
|
+
readAllProjects()
|
|
93
|
+
.filter((p) => p.path)
|
|
94
|
+
.map((p) => [p.path as string, p.name])
|
|
95
|
+
);
|
|
96
|
+
return freshHandoffs(now)
|
|
97
|
+
.filter(([, h]) => h.waitingOn)
|
|
98
|
+
.map(([cwd, h]) => {
|
|
99
|
+
const slug = slugByPath.get(cwd) ?? null;
|
|
100
|
+
return {
|
|
101
|
+
id: `waiting:${cwd}:${h.timestamp}`,
|
|
102
|
+
source: "waiting" as const,
|
|
103
|
+
severity: "waiting" as const,
|
|
104
|
+
title: `${slug ?? cwd} is waiting on you`,
|
|
105
|
+
detail: h.waitingOn as string,
|
|
106
|
+
at: h.timestamp,
|
|
107
|
+
project: slug,
|
|
108
|
+
href: slug ? `/projects/${slug}` : "/",
|
|
109
|
+
read: false,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unranked(): AttentionItem[] {
|
|
115
|
+
return readAllProjects()
|
|
116
|
+
.filter((p) => p.status === "active" && !p.serves)
|
|
117
|
+
.map((p) => ({
|
|
118
|
+
id: `unranked:${p.name}`,
|
|
119
|
+
source: "unranked" as const,
|
|
120
|
+
severity: "note" as const,
|
|
121
|
+
title: `${p.name} has no purpose on record`,
|
|
122
|
+
detail: "It ranks as unimportant until you say what it serves.",
|
|
123
|
+
at: p.updated,
|
|
124
|
+
project: p.name,
|
|
125
|
+
href: `/projects/${p.name}`,
|
|
126
|
+
read: false,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SOURCES: Record<AttentionSource, (now: Date) => AttentionItem[]> = {
|
|
131
|
+
refusals,
|
|
132
|
+
waiting,
|
|
133
|
+
unranked: () => unranked(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function attention(now: Date = new Date()): AttentionView {
|
|
137
|
+
const prefs = readPrefs();
|
|
138
|
+
const marks = readMarks();
|
|
139
|
+
const items = (Object.keys(SOURCES) as AttentionSource[])
|
|
140
|
+
.filter((source) => prefs.attention[source])
|
|
141
|
+
.flatMap((source) => SOURCES[source](now))
|
|
142
|
+
.map((item) => ({ ...item, read: marks[item.id] !== undefined }))
|
|
143
|
+
.sort((a, b) => b.at.localeCompare(a.at))
|
|
144
|
+
.slice(0, MAX_ITEMS);
|
|
145
|
+
return { unread: items.filter((i) => !i.read).length, items };
|
|
146
|
+
}
|
|
@@ -14,11 +14,19 @@ import {
|
|
|
14
14
|
} from "../../hooks/lib/algorithm-review";
|
|
15
15
|
import { loadAnalyzeNudge } from "../../hooks/lib/analyze-nudge";
|
|
16
16
|
import { paths } from "../../hooks/lib/paths";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
isStale,
|
|
19
|
+
type ProjectProgress,
|
|
20
|
+
readAllProjects,
|
|
21
|
+
type ServesAuthority,
|
|
22
|
+
type ServesKind,
|
|
23
|
+
} from "../../hooks/lib/projects";
|
|
18
24
|
import { readProjectHistory } from "../../hooks/lib/work-tracking";
|
|
19
25
|
import { anchorSlugOf, type LedgerFilter, queryLedger } from "../ledger/query";
|
|
26
|
+
import { viewStats } from "../ledger/view";
|
|
20
27
|
import { type HandoffEntry, readHandoffs } from "../lib/handoff-note";
|
|
21
28
|
import { parseIscs } from "../lib/project-isc";
|
|
29
|
+
import { readSnoozes, snoozedUntil } from "./snooze";
|
|
22
30
|
|
|
23
31
|
const DAY_MS = 86_400_000;
|
|
24
32
|
const HANDOFF_FRESH_DAYS = 7;
|
|
@@ -38,6 +46,19 @@ export interface ProjectCard {
|
|
|
38
46
|
lastSession: { date: string; title: string } | null;
|
|
39
47
|
sessions30d: number;
|
|
40
48
|
asking: string[];
|
|
49
|
+
/** What the project is for, and who decided — the fact importance is ranked from. */
|
|
50
|
+
serves: ServesKind | null;
|
|
51
|
+
servesBy: ServesAuthority | null;
|
|
52
|
+
/** Which agents touched it, and how often, over the same 30 days. */
|
|
53
|
+
runtimes: Record<string, number>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SummaryView {
|
|
57
|
+
since: string;
|
|
58
|
+
days: number;
|
|
59
|
+
actions: number;
|
|
60
|
+
refusals: number;
|
|
61
|
+
rating: number | null;
|
|
41
62
|
}
|
|
42
63
|
|
|
43
64
|
export interface HandoffCard {
|
|
@@ -91,7 +112,7 @@ export interface SignalView {
|
|
|
91
112
|
};
|
|
92
113
|
}
|
|
93
114
|
|
|
94
|
-
|
|
115
|
+
interface AgentsRow {
|
|
95
116
|
slug: string;
|
|
96
117
|
actions: number;
|
|
97
118
|
runtimes: Record<string, number>;
|
|
@@ -142,10 +163,29 @@ export function askingReasons(
|
|
|
142
163
|
return reasons;
|
|
143
164
|
}
|
|
144
165
|
|
|
166
|
+
function runtimesBySlug(since: Date): Map<string, Record<string, number>> {
|
|
167
|
+
const bySlug = Map.groupBy(
|
|
168
|
+
queryLedger({ since }),
|
|
169
|
+
(e) => anchorSlugOf(e.target) ?? "unanchored"
|
|
170
|
+
);
|
|
171
|
+
return new Map(
|
|
172
|
+
[...bySlug.entries()].map(([slug, entries]) => [
|
|
173
|
+
slug,
|
|
174
|
+
Object.fromEntries(
|
|
175
|
+
Map.groupBy(entries, (e) => e.runtime)
|
|
176
|
+
.entries()
|
|
177
|
+
.map(([runtime, group]) => [runtime, group.length])
|
|
178
|
+
),
|
|
179
|
+
])
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
145
183
|
function toCard(
|
|
146
184
|
p: ProjectProgress,
|
|
147
185
|
handoff: HandoffEntry | undefined,
|
|
148
|
-
now: Date
|
|
186
|
+
now: Date,
|
|
187
|
+
runtimes: Record<string, number>,
|
|
188
|
+
snoozes: Record<string, string>
|
|
149
189
|
): ProjectCard {
|
|
150
190
|
const path = p.path ?? null;
|
|
151
191
|
const stale = isStale(p);
|
|
@@ -158,7 +198,9 @@ function toCard(
|
|
|
158
198
|
updated: p.updated,
|
|
159
199
|
ageDays: daysBetween(p.updated, now),
|
|
160
200
|
stale,
|
|
161
|
-
openIscs: parseIscs(p.criteria ?? "").filter(
|
|
201
|
+
openIscs: parseIscs(p.criteria ?? "").filter(
|
|
202
|
+
(i) => i.status === "open" && !snoozedUntil(p.name, i.id, snoozes)
|
|
203
|
+
).length,
|
|
162
204
|
next: p.next ?? [],
|
|
163
205
|
blockers: p.blockers ?? [],
|
|
164
206
|
lastSession: last ? { date: last.date, title: last.title } : null,
|
|
@@ -167,6 +209,9 @@ function toCard(
|
|
|
167
209
|
new Date(now.getTime() - SESSION_WINDOW_DAYS * DAY_MS)
|
|
168
210
|
),
|
|
169
211
|
asking: askingReasons(p, handoff, stale),
|
|
212
|
+
serves: p.serves ?? null,
|
|
213
|
+
servesBy: p.serves_by ?? null,
|
|
214
|
+
runtimes,
|
|
170
215
|
};
|
|
171
216
|
}
|
|
172
217
|
|
|
@@ -178,9 +223,17 @@ export function sortBoard(cards: ProjectCard[]): ProjectCard[] {
|
|
|
178
223
|
|
|
179
224
|
export function board(now: Date = new Date()): ProjectCard[] {
|
|
180
225
|
const handoffs = new Map(freshHandoffs(now));
|
|
226
|
+
const runtimes = runtimesBySlug(new Date(now.getTime() - SESSION_WINDOW_DAYS * DAY_MS));
|
|
227
|
+
const snoozes = readSnoozes(now);
|
|
181
228
|
return sortBoard(
|
|
182
229
|
readAllProjects().map((p) =>
|
|
183
|
-
toCard(
|
|
230
|
+
toCard(
|
|
231
|
+
p,
|
|
232
|
+
p.path ? handoffs.get(p.path) : undefined,
|
|
233
|
+
now,
|
|
234
|
+
runtimes.get(p.name) ?? {},
|
|
235
|
+
snoozes
|
|
236
|
+
)
|
|
184
237
|
)
|
|
185
238
|
);
|
|
186
239
|
}
|
|
@@ -296,6 +349,24 @@ export function signal(now: Date = new Date()): SignalView {
|
|
|
296
349
|
};
|
|
297
350
|
}
|
|
298
351
|
|
|
352
|
+
/**
|
|
353
|
+
* The morning tile: how much happened, how much was refused, and how it was
|
|
354
|
+
* rated — three numbers over one window, so the page asks once instead of
|
|
355
|
+
* pulling the whole ledger down to count it in the browser.
|
|
356
|
+
*/
|
|
357
|
+
export function summary(days: number, now: Date = new Date()): SummaryView {
|
|
358
|
+
const since = new Date(now.getTime() - days * DAY_MS);
|
|
359
|
+
const stats = viewStats(queryLedger({ since }));
|
|
360
|
+
const ratings = readSynthesis()?.ratings as SignalView["ratings"] | undefined;
|
|
361
|
+
return {
|
|
362
|
+
since: since.toISOString(),
|
|
363
|
+
days,
|
|
364
|
+
actions: stats.total,
|
|
365
|
+
refusals: stats.refusals,
|
|
366
|
+
rating: ratings?.recentAvg ?? null,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
299
370
|
function count<T>(items: T[], key: (item: T) => string): number {
|
|
300
371
|
return new Set(items.map(key)).size;
|
|
301
372
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One project, assembled. The board answers "which projects want you"; this
|
|
3
|
+
* answers "what is the state of this one" — the criteria it is judged against,
|
|
4
|
+
* the decisions behind it, and what the agents have been doing to it.
|
|
5
|
+
*
|
|
6
|
+
* Two of its sections live on the record as prose rather than fields, so they
|
|
7
|
+
* are parsed here: decisions are written by `add-decision` in a fixed shape, and
|
|
8
|
+
* context is whatever bullets the ISA carries.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
readProject,
|
|
13
|
+
type ServesAuthority,
|
|
14
|
+
type ServesKind,
|
|
15
|
+
} from "../../hooks/lib/projects";
|
|
16
|
+
import { SERVES_MEANING } from "../../hooks/lib/serves";
|
|
17
|
+
import { anchorSlugOf, queryLedger } from "../ledger/query";
|
|
18
|
+
import { type LedgerViewRow, viewRows } from "../ledger/view";
|
|
19
|
+
import type { HandoffEntry } from "../lib/handoff-note";
|
|
20
|
+
import { type Isc, parseIscs } from "../lib/project-isc";
|
|
21
|
+
import { freshHandoffs, handoffSentence } from "./data";
|
|
22
|
+
import { readSnoozes, snoozedUntil } from "./snooze";
|
|
23
|
+
|
|
24
|
+
const DAY_MS = 86_400_000;
|
|
25
|
+
const RECENT_ACTIONS = 8;
|
|
26
|
+
const ACTIVITY_WINDOW_DAYS = 14;
|
|
27
|
+
|
|
28
|
+
export interface Decision {
|
|
29
|
+
date: string | null;
|
|
30
|
+
text: string;
|
|
31
|
+
why: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DetailHandoff {
|
|
35
|
+
sentence: string;
|
|
36
|
+
full: string;
|
|
37
|
+
at: string;
|
|
38
|
+
source: HandoffEntry["source"];
|
|
39
|
+
waitingOn: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SnoozableIsc extends Isc {
|
|
43
|
+
snoozedUntil: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProjectDetailView {
|
|
47
|
+
slug: string;
|
|
48
|
+
status: string;
|
|
49
|
+
path: string | null;
|
|
50
|
+
remote: string | null;
|
|
51
|
+
goal: string;
|
|
52
|
+
purpose: string;
|
|
53
|
+
serves: ServesKind | null;
|
|
54
|
+
servesBy: ServesAuthority | null;
|
|
55
|
+
placed: string | null;
|
|
56
|
+
updated: string;
|
|
57
|
+
iscs: SnoozableIsc[];
|
|
58
|
+
next: string[];
|
|
59
|
+
blockers: string[];
|
|
60
|
+
decisions: Decision[];
|
|
61
|
+
context: string[];
|
|
62
|
+
handoff: DetailHandoff | null;
|
|
63
|
+
runtimes: Record<string, number>;
|
|
64
|
+
recent: LedgerViewRow[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DECISION_LINE = /^-\s*(\d{4}-\d{2}-\d{2}):\s*(.+?)(?:\s*\((.+)\))?\s*$/;
|
|
68
|
+
|
|
69
|
+
/** `add-decision` writes "- YYYY-MM-DD: what (why)"; anything else is kept whole. */
|
|
70
|
+
export function parseDecisions(section: string | undefined): Decision[] {
|
|
71
|
+
if (!section) return [];
|
|
72
|
+
const decisions: Decision[] = [];
|
|
73
|
+
for (const line of section.split("\n")) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (!trimmed) continue;
|
|
76
|
+
const match = DECISION_LINE.exec(trimmed);
|
|
77
|
+
decisions.push(
|
|
78
|
+
match
|
|
79
|
+
? { date: match[1], text: match[2].trim(), why: match[3]?.trim() ?? null }
|
|
80
|
+
: { date: null, text: trimmed.replace(/^-\s*/, ""), why: null }
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return decisions.reverse();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Bullets when the section has them, otherwise each non-empty line stands alone. */
|
|
87
|
+
export function parseBullets(section: string | undefined): string[] {
|
|
88
|
+
if (!section) return [];
|
|
89
|
+
const lines = section
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((l) => l.trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
const bullets = lines.filter((l) => /^[-*+]\s+\S/.test(l));
|
|
94
|
+
const chosen = bullets.length > 0 ? bullets : lines;
|
|
95
|
+
return chosen.map((l) => l.replace(/^[-*+]\s+/, ""));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detailHandoff(entry: HandoffEntry | undefined): DetailHandoff | null {
|
|
99
|
+
if (!entry) return null;
|
|
100
|
+
return {
|
|
101
|
+
sentence: handoffSentence(entry.handoff),
|
|
102
|
+
full: entry.handoff,
|
|
103
|
+
at: entry.timestamp,
|
|
104
|
+
source: entry.source,
|
|
105
|
+
waitingOn: entry.waitingOn ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function activity(slug: string, since: Date) {
|
|
110
|
+
const entries = queryLedger({ since }).filter((e) => anchorSlugOf(e.target) === slug);
|
|
111
|
+
const runtimes = Object.fromEntries(
|
|
112
|
+
Map.groupBy(entries, (e) => e.runtime)
|
|
113
|
+
.entries()
|
|
114
|
+
.map(([runtime, group]) => [runtime, group.length])
|
|
115
|
+
);
|
|
116
|
+
return { runtimes, recent: viewRows(entries).slice(0, RECENT_ACTIONS) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function projectDetail(
|
|
120
|
+
slug: string,
|
|
121
|
+
now: Date = new Date()
|
|
122
|
+
): ProjectDetailView | null {
|
|
123
|
+
const project = readProject(slug);
|
|
124
|
+
if (!project) return null;
|
|
125
|
+
|
|
126
|
+
const handoffs = new Map(freshHandoffs(now));
|
|
127
|
+
const snoozes = readSnoozes(now);
|
|
128
|
+
const { runtimes, recent } = activity(
|
|
129
|
+
slug,
|
|
130
|
+
new Date(now.getTime() - ACTIVITY_WINDOW_DAYS * DAY_MS)
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
slug: project.name,
|
|
135
|
+
status: project.status,
|
|
136
|
+
path: project.path ?? null,
|
|
137
|
+
remote: project.remote ?? null,
|
|
138
|
+
goal: project.goal ?? "",
|
|
139
|
+
purpose: project.serves
|
|
140
|
+
? SERVES_MEANING[project.serves]
|
|
141
|
+
: "no purpose on record yet — set one to rank it",
|
|
142
|
+
serves: project.serves ?? null,
|
|
143
|
+
servesBy: project.serves_by ?? null,
|
|
144
|
+
placed: project.placed ?? null,
|
|
145
|
+
updated: project.updated,
|
|
146
|
+
iscs: [
|
|
147
|
+
...parseIscs(project.criteria ?? ""),
|
|
148
|
+
...parseIscs(project.changelog ?? ""),
|
|
149
|
+
].map((isc) => ({ ...isc, snoozedUntil: snoozedUntil(slug, isc.id, snoozes) })),
|
|
150
|
+
next: project.next ?? [],
|
|
151
|
+
blockers: project.blockers ?? [],
|
|
152
|
+
decisions: parseDecisions(project.decisions),
|
|
153
|
+
context: parseBullets(project.context),
|
|
154
|
+
handoff: detailHandoff(project.path ? handoffs.get(project.path) : undefined),
|
|
155
|
+
runtimes,
|
|
156
|
+
recent,
|
|
157
|
+
};
|
|
158
|
+
}
|