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
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
-
|
|
15
|
+
type GoalProgress,
|
|
16
|
+
progressFor,
|
|
17
|
+
readGoalLinks,
|
|
18
|
+
} from "../../hooks/lib/goal-links";
|
|
19
|
+
import {
|
|
16
20
|
type ProjectProgress,
|
|
17
21
|
readAllProjects,
|
|
18
22
|
type ServesAuthority,
|
|
@@ -21,9 +25,11 @@ import {
|
|
|
21
25
|
import { isImportant, SERVES_MEANING } from "../../hooks/lib/serves";
|
|
22
26
|
import { dueFrom, readTelosGoals, type TelosGoal } from "../../hooks/lib/telos-goals";
|
|
23
27
|
import type { HandoffEntry } from "../lib/handoff-note";
|
|
28
|
+
import { parseIscs } from "../lib/project-isc";
|
|
24
29
|
import { freshHandoffs } from "./data";
|
|
30
|
+
import { type ControlRoomPrefs, readPrefs } from "./prefs";
|
|
31
|
+
import { readSnoozes, snoozedUntil } from "./snooze";
|
|
25
32
|
|
|
26
|
-
const URGENT_WITHIN_DAYS = 14;
|
|
27
33
|
const RANKED_STATUSES = new Set(["active", "paused"]);
|
|
28
34
|
|
|
29
35
|
export interface MatrixItem {
|
|
@@ -33,12 +39,16 @@ export interface MatrixItem {
|
|
|
33
39
|
detail: string;
|
|
34
40
|
urgent: boolean;
|
|
35
41
|
important: boolean;
|
|
42
|
+
/** The quadrant the user pinned this to, which overrules both guesses. */
|
|
43
|
+
placed: string | null;
|
|
36
44
|
urgentBecause: string[];
|
|
37
45
|
importantBecause: string;
|
|
38
46
|
serves: ServesKind | null;
|
|
39
47
|
servesBy: ServesAuthority | null;
|
|
40
48
|
due: string | null;
|
|
41
49
|
waitingOn: string | null;
|
|
50
|
+
/** Counted from the criteria of the projects serving it, or null when none does. */
|
|
51
|
+
progress: { projects: string[]; closed: number; written: number } | null;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
export interface Matrix {
|
|
@@ -50,11 +60,11 @@ export interface Matrix {
|
|
|
50
60
|
unranked: number;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
function dueSoon(due: string | null, now: Date): boolean {
|
|
63
|
+
function dueSoon(due: string | null, now: Date, withinDays: number): boolean {
|
|
54
64
|
if (!due) return false;
|
|
55
65
|
const at = new Date(`${due}T23:59:59Z`).getTime();
|
|
56
66
|
if (!Number.isFinite(at)) return false;
|
|
57
|
-
return at - now.getTime() <=
|
|
67
|
+
return at - now.getTime() <= withinDays * 86_400_000;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
function earliestDue(lines: string[]): string | null {
|
|
@@ -67,10 +77,10 @@ function plural(count: number, noun: string): string {
|
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/** Measured against the grid's own clock, so a placement can be pinned in a test. */
|
|
70
|
-
function quietSince(updated: string | undefined, now: Date): boolean {
|
|
80
|
+
function quietSince(updated: string | undefined, now: Date, afterDays: number): boolean {
|
|
71
81
|
if (!updated) return false;
|
|
72
82
|
const age = now.getTime() - new Date(updated).getTime();
|
|
73
|
-
return Number.isFinite(age) && age >
|
|
83
|
+
return Number.isFinite(age) && age > afterDays * 86_400_000;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
/**
|
|
@@ -83,15 +93,20 @@ function projectUrgency(
|
|
|
83
93
|
waiting: string | null,
|
|
84
94
|
due: string | null,
|
|
85
95
|
hasHandoff: boolean,
|
|
86
|
-
now: Date
|
|
96
|
+
now: Date,
|
|
97
|
+
prefs: ControlRoomPrefs
|
|
87
98
|
): string[] {
|
|
88
99
|
const reasons: string[] = [];
|
|
89
100
|
const blockers = p.blockers?.length ?? 0;
|
|
90
101
|
if (blockers > 0) reasons.push(plural(blockers, "blocker"));
|
|
91
102
|
if (waiting) reasons.push("waiting on you");
|
|
92
103
|
else if (hasHandoff) reasons.push("handoff in progress");
|
|
93
|
-
if (dueSoon(due, now)) reasons.push(`next step dated ${due}`);
|
|
94
|
-
if (
|
|
104
|
+
if (dueSoon(due, now, prefs.urgentWithinDays)) reasons.push(`next step dated ${due}`);
|
|
105
|
+
if (
|
|
106
|
+
important &&
|
|
107
|
+
p.status === "active" &&
|
|
108
|
+
quietSince(p.updated, now, prefs.quietAfterDays)
|
|
109
|
+
) {
|
|
95
110
|
reasons.push("gone quiet");
|
|
96
111
|
}
|
|
97
112
|
return reasons;
|
|
@@ -100,7 +115,8 @@ function projectUrgency(
|
|
|
100
115
|
function projectItem(
|
|
101
116
|
p: ProjectProgress,
|
|
102
117
|
handoffs: Map<string, HandoffEntry>,
|
|
103
|
-
now: Date
|
|
118
|
+
now: Date,
|
|
119
|
+
prefs: ControlRoomPrefs
|
|
104
120
|
): MatrixItem {
|
|
105
121
|
const entry = p.path ? handoffs.get(p.path) : undefined;
|
|
106
122
|
const waiting = entry?.waitingOn ?? null;
|
|
@@ -112,17 +128,20 @@ function projectItem(
|
|
|
112
128
|
waiting,
|
|
113
129
|
due,
|
|
114
130
|
entry !== undefined,
|
|
115
|
-
now
|
|
131
|
+
now,
|
|
132
|
+
prefs
|
|
116
133
|
);
|
|
117
134
|
|
|
135
|
+
const placed = placementOf(p.placed);
|
|
118
136
|
return {
|
|
119
137
|
kind: "project",
|
|
120
138
|
id: p.name,
|
|
121
139
|
label: p.name,
|
|
122
140
|
detail: p.serves_note ?? p.next?.[0] ?? "",
|
|
123
|
-
urgent: urgentBecause.length > 0,
|
|
124
|
-
important,
|
|
125
|
-
|
|
141
|
+
urgent: placed ? placed.urgent : urgentBecause.length > 0,
|
|
142
|
+
important: placed ? placed.important : important,
|
|
143
|
+
placed: p.placed ?? null,
|
|
144
|
+
urgentBecause: placed ? ["placed by you", ...urgentBecause] : urgentBecause,
|
|
126
145
|
importantBecause: p.serves
|
|
127
146
|
? SERVES_MEANING[p.serves]
|
|
128
147
|
: "no purpose on record yet — set one to rank it",
|
|
@@ -130,11 +149,17 @@ function projectItem(
|
|
|
130
149
|
servesBy: p.serves_by ?? null,
|
|
131
150
|
due,
|
|
132
151
|
waitingOn: waiting,
|
|
152
|
+
progress: null,
|
|
133
153
|
};
|
|
134
154
|
}
|
|
135
155
|
|
|
136
|
-
function goalItem(
|
|
137
|
-
|
|
156
|
+
function goalItem(
|
|
157
|
+
goal: TelosGoal,
|
|
158
|
+
now: Date,
|
|
159
|
+
prefs: ControlRoomPrefs,
|
|
160
|
+
progress: GoalProgress | null
|
|
161
|
+
): MatrixItem {
|
|
162
|
+
const urgent = dueSoon(goal.due, now, prefs.urgentWithinDays);
|
|
138
163
|
return {
|
|
139
164
|
kind: "goal",
|
|
140
165
|
id: goal.id,
|
|
@@ -142,15 +167,54 @@ function goalItem(goal: TelosGoal, now: Date): MatrixItem {
|
|
|
142
167
|
detail: goal.horizon ?? goal.text,
|
|
143
168
|
urgent,
|
|
144
169
|
important: true,
|
|
170
|
+
placed: null,
|
|
145
171
|
urgentBecause: urgent ? [`dated ${goal.due}`] : [],
|
|
146
172
|
importantBecause: "a goal you stated",
|
|
147
173
|
serves: null,
|
|
148
174
|
servesBy: null,
|
|
149
175
|
due: goal.due,
|
|
150
176
|
waitingOn: null,
|
|
177
|
+
progress,
|
|
151
178
|
};
|
|
152
179
|
}
|
|
153
180
|
|
|
181
|
+
/** The user's placement, translated back into the two axes it stands for. */
|
|
182
|
+
function placementOf(
|
|
183
|
+
placed: string | undefined
|
|
184
|
+
): { urgent: boolean; important: boolean } | null {
|
|
185
|
+
switch (placed) {
|
|
186
|
+
case "now":
|
|
187
|
+
return { urgent: true, important: true };
|
|
188
|
+
case "plan":
|
|
189
|
+
return { urgent: false, important: true };
|
|
190
|
+
case "noise":
|
|
191
|
+
return { urgent: true, important: false };
|
|
192
|
+
case "later":
|
|
193
|
+
return { urgent: false, important: false };
|
|
194
|
+
default:
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Criteria closed over criteria written, with snoozed and retired ones left out. */
|
|
200
|
+
function criteriaBySlug(
|
|
201
|
+
projects: ProjectProgress[],
|
|
202
|
+
snoozes: Record<string, string>
|
|
203
|
+
): Map<string, { closed: number; written: number }> {
|
|
204
|
+
return new Map(
|
|
205
|
+
projects.map((p) => {
|
|
206
|
+
const iscs = [
|
|
207
|
+
...parseIscs(p.criteria ?? ""),
|
|
208
|
+
...parseIscs(p.changelog ?? ""),
|
|
209
|
+
].filter((i) => i.status !== "retired" && !snoozedUntil(p.name, i.id, snoozes));
|
|
210
|
+
return [
|
|
211
|
+
p.name,
|
|
212
|
+
{ closed: iscs.filter((i) => i.status === "done").length, written: iscs.length },
|
|
213
|
+
];
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
154
218
|
function quadrant(items: MatrixItem[], urgent: boolean, important: boolean) {
|
|
155
219
|
return items
|
|
156
220
|
.filter((i) => i.urgent === urgent && i.important === important)
|
|
@@ -172,11 +236,19 @@ export function buildMatrix(items: MatrixItem[], unranked: number): Matrix {
|
|
|
172
236
|
}
|
|
173
237
|
|
|
174
238
|
export function matrix(now: Date = new Date()): Matrix {
|
|
239
|
+
const prefs = readPrefs();
|
|
175
240
|
const handoffs = new Map(freshHandoffs(now));
|
|
176
|
-
const
|
|
241
|
+
const all = readAllProjects();
|
|
242
|
+
const ranked = all.filter((p) => RANKED_STATUSES.has(p.status));
|
|
243
|
+
const links = readGoalLinks();
|
|
244
|
+
const criteria = criteriaBySlug(all, readSnoozes(now));
|
|
177
245
|
const items = [
|
|
178
|
-
...ranked.map((p) => projectItem(p, handoffs, now)),
|
|
179
|
-
...
|
|
246
|
+
...ranked.map((p) => projectItem(p, handoffs, now, prefs)),
|
|
247
|
+
...(prefs.rankGoals
|
|
248
|
+
? readTelosGoals().map((g) =>
|
|
249
|
+
goalItem(g, now, prefs, progressFor(g.id, links, criteria))
|
|
250
|
+
)
|
|
251
|
+
: []),
|
|
180
252
|
];
|
|
181
253
|
return buildMatrix(items, ranked.filter((p) => !p.serves).length);
|
|
182
254
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The knobs behind the grid and the bell.
|
|
3
|
+
*
|
|
4
|
+
* These were constants in the source, which meant "gone quiet after 14 days"
|
|
5
|
+
* was PAL's opinion rather than the user's. They live in pal-settings.json now,
|
|
6
|
+
* with the old constants as the defaults so nothing changes until someone says
|
|
7
|
+
* otherwise.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PROJECT_STALE_DAYS_DEFAULT } from "../../hooks/lib/projects";
|
|
11
|
+
import {
|
|
12
|
+
raw as rawSettings,
|
|
13
|
+
reload,
|
|
14
|
+
write as writeSettings,
|
|
15
|
+
} from "../../hooks/lib/settings";
|
|
16
|
+
|
|
17
|
+
const ATTENTION_SOURCES = ["refusals", "waiting", "unranked"] as const;
|
|
18
|
+
export type AttentionSource = (typeof ATTENTION_SOURCES)[number];
|
|
19
|
+
|
|
20
|
+
export interface ControlRoomPrefs {
|
|
21
|
+
quietAfterDays: number;
|
|
22
|
+
urgentWithinDays: number;
|
|
23
|
+
rankGoals: boolean;
|
|
24
|
+
attention: Record<AttentionSource, boolean>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PREF_DEFAULTS: ControlRoomPrefs = {
|
|
28
|
+
quietAfterDays: PROJECT_STALE_DAYS_DEFAULT,
|
|
29
|
+
urgentWithinDays: 14,
|
|
30
|
+
rankGoals: true,
|
|
31
|
+
attention: { refusals: true, waiting: true, unranked: true },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const DAY_LIMIT = 365;
|
|
35
|
+
|
|
36
|
+
function storedPrefs(): Partial<ControlRoomPrefs> {
|
|
37
|
+
const stored = rawSettings().controlRoom;
|
|
38
|
+
return (stored ?? {}) as Partial<ControlRoomPrefs>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function positiveDays(value: unknown, fallback: number): number {
|
|
42
|
+
return typeof value === "number" &&
|
|
43
|
+
Number.isInteger(value) &&
|
|
44
|
+
value > 0 &&
|
|
45
|
+
value <= DAY_LIMIT
|
|
46
|
+
? value
|
|
47
|
+
: fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readPrefs(): ControlRoomPrefs {
|
|
51
|
+
const stored = storedPrefs();
|
|
52
|
+
return {
|
|
53
|
+
quietAfterDays: positiveDays(stored.quietAfterDays, PREF_DEFAULTS.quietAfterDays),
|
|
54
|
+
urgentWithinDays: positiveDays(
|
|
55
|
+
stored.urgentWithinDays,
|
|
56
|
+
PREF_DEFAULTS.urgentWithinDays
|
|
57
|
+
),
|
|
58
|
+
rankGoals: stored.rankGoals ?? PREF_DEFAULTS.rankGoals,
|
|
59
|
+
attention: { ...PREF_DEFAULTS.attention, ...(stored.attention ?? {}) },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type PrefUpdate = Partial<ControlRoomPrefs>;
|
|
64
|
+
|
|
65
|
+
export function validatePrefs(update: PrefUpdate): string | null {
|
|
66
|
+
for (const key of ["quietAfterDays", "urgentWithinDays"] as const) {
|
|
67
|
+
const value = update[key];
|
|
68
|
+
if (value === undefined) continue;
|
|
69
|
+
if (positiveDays(value, -1) === -1) {
|
|
70
|
+
return `${key} must be a whole number of days, 1..${DAY_LIMIT}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (update.rankGoals !== undefined && typeof update.rankGoals !== "boolean") {
|
|
74
|
+
return "rankGoals must be true or false";
|
|
75
|
+
}
|
|
76
|
+
for (const [key, value] of Object.entries(update.attention ?? {})) {
|
|
77
|
+
if (!(ATTENTION_SOURCES as readonly string[]).includes(key)) {
|
|
78
|
+
return `unknown attention source: ${key}`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value !== "boolean") return `attention.${key} must be true or false`;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function writePrefs(update: PrefUpdate): ControlRoomPrefs {
|
|
86
|
+
const data = rawSettings();
|
|
87
|
+
const current = readPrefs();
|
|
88
|
+
const next: ControlRoomPrefs = {
|
|
89
|
+
...current,
|
|
90
|
+
...update,
|
|
91
|
+
attention: { ...current.attention, ...(update.attention ?? {}) },
|
|
92
|
+
};
|
|
93
|
+
writeSettings({ ...data, controlRoom: { ...next } });
|
|
94
|
+
reload();
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the control room listens. Split from server.ts so the build can read it
|
|
3
|
+
* without importing the server itself — server.ts imports the page bundle, and
|
|
4
|
+
* a bundler asked to load that would be chasing its own output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PORT = 7250;
|
|
8
|
+
export const LOOPBACK = "127.0.0.1";
|
|
@@ -9,14 +9,35 @@
|
|
|
9
9
|
import { loadMachine } from "../../hooks/lib/machine";
|
|
10
10
|
import { readAllProjects } from "../../hooks/lib/projects";
|
|
11
11
|
import { isServesKind, setServes } from "../../hooks/lib/serves";
|
|
12
|
+
import { PAGE_OUTCOMES } from "../ledger/outcomes";
|
|
12
13
|
import { type LedgerFilter, ledgerFiles, parseSince } from "../ledger/query";
|
|
13
14
|
import { ledgerView } from "../ledger/view";
|
|
14
|
-
import {
|
|
15
|
+
import { attention, markRead } from "./attention";
|
|
16
|
+
import { agenda, agentsAtWork, board, handoffs, signal, summary } from "./data";
|
|
17
|
+
import { projectDetail } from "./detail";
|
|
15
18
|
import { matrix } from "./matrix";
|
|
16
|
-
import
|
|
19
|
+
import { type PrefUpdate, readPrefs, validatePrefs, writePrefs } from "./prefs";
|
|
20
|
+
import { DEFAULT_PORT, LOOPBACK } from "./server-config";
|
|
21
|
+
import { MAX_SNOOZE_DAYS, setSnooze } from "./snooze";
|
|
22
|
+
import { indexHtml, staticAsset } from "./static";
|
|
23
|
+
import {
|
|
24
|
+
type InstallSettings,
|
|
25
|
+
isQuadrant,
|
|
26
|
+
QUADRANTS,
|
|
27
|
+
readInstallSettings,
|
|
28
|
+
setIscStatus,
|
|
29
|
+
setPlacement,
|
|
30
|
+
type WriteOutcome,
|
|
31
|
+
writeInstallSettings,
|
|
32
|
+
} from "./writes";
|
|
17
33
|
|
|
18
|
-
export
|
|
19
|
-
|
|
34
|
+
export { DEFAULT_PORT, LOOPBACK };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every path the single-page app owns. Listed rather than wildcarded, because a
|
|
38
|
+
* blanket "/*" outranks the fetch handler and would swallow /api as well.
|
|
39
|
+
*/
|
|
40
|
+
const PAGE_ROUTES = ["/", "/projects", "/projects/:slug", "/log", "/settings"];
|
|
20
41
|
|
|
21
42
|
export interface ServerStatus {
|
|
22
43
|
pid: number;
|
|
@@ -30,11 +51,25 @@ function json(body: unknown, status = 200): Response {
|
|
|
30
51
|
return Response.json(body, { status });
|
|
31
52
|
}
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
const AUTHORITIES: readonly string[] = ["user", "agent"];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Free-form on the record, so the page may name any of them: a runtime or
|
|
58
|
+
* machine label PAL has never seen simply matches nothing.
|
|
59
|
+
*/
|
|
60
|
+
const PASSTHROUGH = ["project", "runtime", "machine", "actor", "tool"] as const;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A window the page cannot parse is an error, not the whole ledger. The same
|
|
64
|
+
* goes for a closed set it spells wrong — silently answering with every row
|
|
65
|
+
* would read as "no such filter" rather than "no such value".
|
|
66
|
+
*/
|
|
34
67
|
function filterFromQuery(params: URLSearchParams): LedgerFilter | string {
|
|
35
68
|
const filter: LedgerFilter = {};
|
|
36
|
-
const
|
|
37
|
-
|
|
69
|
+
for (const key of PASSTHROUGH) {
|
|
70
|
+
const value = params.get(key);
|
|
71
|
+
if (value) filter[key] = value;
|
|
72
|
+
}
|
|
38
73
|
for (const key of ["since", "until"] as const) {
|
|
39
74
|
const spec = params.get(key);
|
|
40
75
|
if (!spec) continue;
|
|
@@ -42,6 +77,18 @@ function filterFromQuery(params: URLSearchParams): LedgerFilter | string {
|
|
|
42
77
|
if (!at) return `Unrecognised ${key}: ${spec}`;
|
|
43
78
|
filter[key] = at;
|
|
44
79
|
}
|
|
80
|
+
const outcome = params.get("outcome");
|
|
81
|
+
if (outcome) {
|
|
82
|
+
if (!(PAGE_OUTCOMES as readonly string[]).includes(outcome)) {
|
|
83
|
+
return `Unrecognised outcome: ${outcome}`;
|
|
84
|
+
}
|
|
85
|
+
filter.outcome = outcome;
|
|
86
|
+
}
|
|
87
|
+
const authority = params.get("authority");
|
|
88
|
+
if (authority) {
|
|
89
|
+
if (!AUTHORITIES.includes(authority)) return `Unrecognised authority: ${authority}`;
|
|
90
|
+
filter.authority = authority;
|
|
91
|
+
}
|
|
45
92
|
return filter;
|
|
46
93
|
}
|
|
47
94
|
|
|
@@ -52,6 +99,23 @@ function projects(): Response {
|
|
|
52
99
|
return json(slugs.map((slug) => ({ slug })));
|
|
53
100
|
}
|
|
54
101
|
|
|
102
|
+
function oneProject(slug: string | null): Response {
|
|
103
|
+
if (!slug) return json({ error: "slug is required" }, 400);
|
|
104
|
+
const detail = projectDetail(slug);
|
|
105
|
+
return detail ? json(detail) : json({ error: `no such project: ${slug}` }, 404);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const SUMMARY_DAYS_DEFAULT = 14;
|
|
109
|
+
const SUMMARY_DAYS_MAX = 365;
|
|
110
|
+
|
|
111
|
+
function summaryFor(spec: string | null): Response {
|
|
112
|
+
const days = spec ? Number(spec) : SUMMARY_DAYS_DEFAULT;
|
|
113
|
+
if (!Number.isInteger(days) || days < 1 || days > SUMMARY_DAYS_MAX) {
|
|
114
|
+
return json({ error: `days must be a whole number of 1..${SUMMARY_DAYS_MAX}` }, 400);
|
|
115
|
+
}
|
|
116
|
+
return json(summary(days));
|
|
117
|
+
}
|
|
118
|
+
|
|
55
119
|
function withFilter(url: URL, view: (filter: LedgerFilter) => unknown): Response {
|
|
56
120
|
const filter = filterFromQuery(url.searchParams);
|
|
57
121
|
if (typeof filter === "string") return json({ error: filter }, 400);
|
|
@@ -99,19 +163,130 @@ async function overrideServes(request: Request): Promise<Response> {
|
|
|
99
163
|
return json({ project, serves, by: "user" });
|
|
100
164
|
}
|
|
101
165
|
|
|
166
|
+
async function readBody(request: Request): Promise<Record<string, unknown> | null> {
|
|
167
|
+
try {
|
|
168
|
+
return ((await request.json()) ?? {}) as Record<string, unknown>;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function answer(outcome: WriteOutcome, body: Record<string, unknown>): Response {
|
|
175
|
+
return outcome.ok
|
|
176
|
+
? json({ ...body, changed: outcome.changed })
|
|
177
|
+
: json({ error: outcome.error }, outcome.status);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Close or reopen one criterion — the same rule `complete-isc` reaches. */
|
|
181
|
+
async function iscWrite(request: Request): Promise<Response> {
|
|
182
|
+
const body = await readBody(request);
|
|
183
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
184
|
+
const { project, id, status } = body;
|
|
185
|
+
if (typeof project !== "string" || !project) {
|
|
186
|
+
return json({ error: "project is required" }, 400);
|
|
187
|
+
}
|
|
188
|
+
if (typeof id !== "number" || !Number.isInteger(id) || id < 1) {
|
|
189
|
+
return json({ error: "id must be a positive integer" }, 400);
|
|
190
|
+
}
|
|
191
|
+
if (status !== "open" && status !== "done") {
|
|
192
|
+
return json({ error: "status must be open or done" }, 400);
|
|
193
|
+
}
|
|
194
|
+
return answer(setIscStatus(project, id, status === "done"), { project, id, status });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function placementWrite(request: Request): Promise<Response> {
|
|
198
|
+
const body = await readBody(request);
|
|
199
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
200
|
+
const { project, quadrant } = body;
|
|
201
|
+
if (typeof project !== "string" || !project) {
|
|
202
|
+
return json({ error: "project is required" }, 400);
|
|
203
|
+
}
|
|
204
|
+
if (quadrant !== null && !isQuadrant(quadrant)) {
|
|
205
|
+
return json(
|
|
206
|
+
{ error: `quadrant must be null or one of ${QUADRANTS.join(", ")}` },
|
|
207
|
+
400
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return answer(setPlacement(project, quadrant), { project, quadrant });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function settingsWrite(request: Request): Promise<Response> {
|
|
214
|
+
const body = await readBody(request);
|
|
215
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
216
|
+
const update: Partial<InstallSettings> = {};
|
|
217
|
+
for (const key of ["actor", "timezone"] as const) {
|
|
218
|
+
const value = body[key];
|
|
219
|
+
if (value === undefined) continue;
|
|
220
|
+
if (typeof value !== "string") return json({ error: `${key} must be a string` }, 400);
|
|
221
|
+
update[key] = value;
|
|
222
|
+
}
|
|
223
|
+
const outcome = writeInstallSettings(update);
|
|
224
|
+
return outcome.ok ? json(readInstallSettings()) : answer(outcome, {});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function snoozeWrite(request: Request): Promise<Response> {
|
|
228
|
+
const body = await readBody(request);
|
|
229
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
230
|
+
const { project, id, days } = body;
|
|
231
|
+
if (typeof project !== "string" || !project) {
|
|
232
|
+
return json({ error: "project is required" }, 400);
|
|
233
|
+
}
|
|
234
|
+
if (typeof id !== "number" || !Number.isInteger(id) || id < 1) {
|
|
235
|
+
return json({ error: "id must be a positive integer" }, 400);
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
typeof days !== "number" ||
|
|
239
|
+
!Number.isInteger(days) ||
|
|
240
|
+
days < 0 ||
|
|
241
|
+
days > MAX_SNOOZE_DAYS
|
|
242
|
+
) {
|
|
243
|
+
return json({ error: `days must be a whole number of 0..${MAX_SNOOZE_DAYS}` }, 400);
|
|
244
|
+
}
|
|
245
|
+
return json({ project, id, until: setSnooze(project, id, days) });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readWrite(request: Request): Promise<Response> {
|
|
249
|
+
const body = await readBody(request);
|
|
250
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
251
|
+
const ids = body.all === true ? attention().items.map((i) => i.id) : body.ids;
|
|
252
|
+
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) {
|
|
253
|
+
return json({ error: "ids must be an array of strings, or all must be true" }, 400);
|
|
254
|
+
}
|
|
255
|
+
return json({ marked: markRead(ids as string[]) });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function prefsWrite(request: Request): Promise<Response> {
|
|
259
|
+
const body = await readBody(request);
|
|
260
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
261
|
+
const failure = validatePrefs(body as PrefUpdate);
|
|
262
|
+
return failure ? json({ error: failure }, 400) : json(writePrefs(body as PrefUpdate));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const WRITES: Record<string, (request: Request) => Promise<Response>> = {
|
|
266
|
+
"/api/serves": overrideServes,
|
|
267
|
+
"/api/isc": iscWrite,
|
|
268
|
+
"/api/placement": placementWrite,
|
|
269
|
+
"/api/settings": settingsWrite,
|
|
270
|
+
"/api/snooze": snoozeWrite,
|
|
271
|
+
"/api/attention/read": readWrite,
|
|
272
|
+
"/api/prefs": prefsWrite,
|
|
273
|
+
};
|
|
274
|
+
|
|
102
275
|
export function startControlRoom(port: number = DEFAULT_PORT) {
|
|
103
276
|
const startedAt = new Date().toISOString();
|
|
104
277
|
return Bun.serve({
|
|
105
278
|
hostname: LOOPBACK,
|
|
106
279
|
port,
|
|
107
280
|
development: false,
|
|
108
|
-
routes:
|
|
281
|
+
routes: Object.fromEntries(PAGE_ROUTES.map((path) => [path, () => indexHtml()])),
|
|
109
282
|
fetch(request, server) {
|
|
110
283
|
const url = new URL(request.url);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
284
|
+
const write = WRITES[url.pathname];
|
|
285
|
+
if (request.method === "POST" && write) return write(request);
|
|
114
286
|
if (request.method !== "GET") return json({ error: "read only" }, 405);
|
|
287
|
+
if (!url.pathname.startsWith("/api/")) {
|
|
288
|
+
return staticAsset(url.pathname) ?? json({ error: "not found" }, 404);
|
|
289
|
+
}
|
|
115
290
|
switch (url.pathname) {
|
|
116
291
|
case "/api/agenda":
|
|
117
292
|
return json(agenda());
|
|
@@ -127,8 +302,18 @@ export function startControlRoom(port: number = DEFAULT_PORT) {
|
|
|
127
302
|
return withFilter(url, agentsAtWork);
|
|
128
303
|
case "/api/ledger":
|
|
129
304
|
return withFilter(url, ledgerView);
|
|
305
|
+
case "/api/project":
|
|
306
|
+
return oneProject(url.searchParams.get("slug"));
|
|
307
|
+
case "/api/summary":
|
|
308
|
+
return summaryFor(url.searchParams.get("days"));
|
|
130
309
|
case "/api/projects":
|
|
131
310
|
return projects();
|
|
311
|
+
case "/api/settings":
|
|
312
|
+
return json(readInstallSettings());
|
|
313
|
+
case "/api/prefs":
|
|
314
|
+
return json(readPrefs());
|
|
315
|
+
case "/api/attention":
|
|
316
|
+
return json(attention());
|
|
132
317
|
case "/api/status":
|
|
133
318
|
return status(server.port ?? port, startedAt);
|
|
134
319
|
default:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snoozed criteria — an ISC you have seen and do not want ranked this week.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the ISA on purpose: the record says what the project is judged
|
|
5
|
+
* against, and "not this week" is a fact about your attention rather than about
|
|
6
|
+
* the project. It expires by date, so nothing has to unsnooze it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
12
|
+
|
|
13
|
+
const DAY_MS = 86_400_000;
|
|
14
|
+
export const MAX_SNOOZE_DAYS = 90;
|
|
15
|
+
|
|
16
|
+
/** project#isc → the day it wakes up. */
|
|
17
|
+
export type Snoozes = Record<string, string>;
|
|
18
|
+
|
|
19
|
+
function snoozeKey(project: string, id: number): string {
|
|
20
|
+
return `${project}#${id}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function snoozeFile(): string {
|
|
24
|
+
return resolve(ensureDir(paths.state()), "snoozed.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readSnoozes(now: Date = new Date()): Snoozes {
|
|
28
|
+
const file = snoozeFile();
|
|
29
|
+
if (!existsSync(file)) return {};
|
|
30
|
+
let stored: Snoozes;
|
|
31
|
+
try {
|
|
32
|
+
stored = JSON.parse(readFileSync(file, "utf-8")) as Snoozes;
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
const today = now.toISOString().slice(0, 10);
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.entries(stored).filter(
|
|
39
|
+
([, until]) => typeof until === "string" && until > today
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function snoozedUntil(
|
|
45
|
+
project: string,
|
|
46
|
+
id: number,
|
|
47
|
+
snoozes: Snoozes
|
|
48
|
+
): string | null {
|
|
49
|
+
return snoozes[snoozeKey(project, id)] ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Zero days wakes it now, which is how the page un-snoozes. */
|
|
53
|
+
export function setSnooze(
|
|
54
|
+
project: string,
|
|
55
|
+
id: number,
|
|
56
|
+
days: number,
|
|
57
|
+
now: Date = new Date()
|
|
58
|
+
): string | null {
|
|
59
|
+
const live = readSnoozes(now);
|
|
60
|
+
const key = snoozeKey(project, id);
|
|
61
|
+
if (days <= 0) delete live[key];
|
|
62
|
+
else live[key] = new Date(now.getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
|
63
|
+
writeFileSync(snoozeFile(), `${JSON.stringify(live, null, 2)}\n`, "utf-8");
|
|
64
|
+
return live[key] ?? null;
|
|
65
|
+
}
|