portable-agent-layer 0.71.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/migrate.ts +1 -1
- package/src/cli/server.ts +7 -0
- package/src/cli/skill.ts +1 -1
- package/src/hooks/CompactRecover.ts +28 -86
- package/src/hooks/LedgerUnapplied.ts +3 -28
- package/src/hooks/LoadContext.ts +33 -60
- package/src/hooks/SecurityValidator.ts +16 -109
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/capture-store.ts +103 -0
- package/src/hooks/lib/compact-recall.ts +89 -0
- package/src/hooks/lib/failure-principle.ts +98 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/session-context.ts +74 -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/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +29 -77
- package/src/tools/agent/project.ts +34 -153
- package/src/tools/agent/relationship-note.ts +27 -46
- package/src/tools/agent/synthesize.ts +1 -1
- package/src/tools/agent/thread.ts +43 -123
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +78 -7
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +92 -20
- 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 +34 -10
- package/src/tools/lib/algorithm-reflect.ts +84 -0
- package/src/tools/lib/analyze-report.ts +120 -0
- package/src/tools/lib/handoff-note.ts +102 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +212 -0
- package/src/tools/lib/relationship-reflect.ts +402 -0
- package/src/tools/lib/self-model.ts +499 -0
- package/src/tools/lib/session-usage.ts +216 -0
- package/src/tools/lib/skill-doctor.ts +457 -0
- package/src/tools/lib/thread.ts +119 -0
- package/src/tools/lib/token-report.ts +173 -0
- package/src/tools/lib/transcript-usage.ts +42 -0
- package/src/tools/lib/usage-buckets.ts +329 -0
- package/src/tools/relationship-reflect.ts +48 -412
- package/src/tools/self-model.ts +76 -558
- package/src/tools/session-summary.ts +8 -215
- package/src/tools/skill-doctor.ts +9 -444
- package/src/tools/token-cost.ts +18 -428
- 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 -136
- 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
|
@@ -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
|
-
import { type HandoffEntry, readHandoffs } from "../agent/handoff-note";
|
|
20
|
-
import { parseIscs } from "../agent/project";
|
|
21
25
|
import { anchorSlugOf, type LedgerFilter, queryLedger } from "../ledger/query";
|
|
26
|
+
import { viewStats } from "../ledger/view";
|
|
27
|
+
import { type HandoffEntry, readHandoffs } from "../lib/handoff-note";
|
|
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
|
+
}
|
|
@@ -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,
|
|
@@ -20,10 +24,12 @@ import {
|
|
|
20
24
|
} from "../../hooks/lib/projects";
|
|
21
25
|
import { isImportant, SERVES_MEANING } from "../../hooks/lib/serves";
|
|
22
26
|
import { dueFrom, readTelosGoals, type TelosGoal } from "../../hooks/lib/telos-goals";
|
|
23
|
-
import type { HandoffEntry } from "../
|
|
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";
|