portable-agent-layer 0.72.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +10 -7
  2. package/src/cli/server.ts +7 -0
  3. package/src/hooks/handlers/agenda.ts +195 -7
  4. package/src/hooks/lib/agenda-store.ts +2 -0
  5. package/src/hooks/lib/goal-links.ts +83 -0
  6. package/src/hooks/lib/models.ts +1 -1
  7. package/src/hooks/lib/projects.ts +5 -0
  8. package/src/hooks/lib/settings.ts +2 -0
  9. package/src/hooks/lib/telos-goals.ts +8 -2
  10. package/src/hooks/lib/token-usage.ts +2 -0
  11. package/src/tools/agent/project.ts +22 -20
  12. package/src/tools/control-room/attention.ts +146 -0
  13. package/src/tools/control-room/data.ts +76 -5
  14. package/src/tools/control-room/detail.ts +158 -0
  15. package/src/tools/control-room/matrix.ts +91 -19
  16. package/src/tools/control-room/prefs.ts +96 -0
  17. package/src/tools/control-room/server-config.ts +8 -0
  18. package/src/tools/control-room/server.ts +196 -11
  19. package/src/tools/control-room/snooze.ts +65 -0
  20. package/src/tools/control-room/static.ts +68 -0
  21. package/src/tools/control-room/ui/app.tsx +196 -50
  22. package/src/tools/control-room/ui/attention-bell.tsx +118 -0
  23. package/src/tools/control-room/ui/components/README.md +18 -0
  24. package/src/tools/control-room/ui/components/badge.tsx +38 -0
  25. package/src/tools/control-room/ui/components/button.tsx +43 -0
  26. package/src/tools/control-room/ui/components/card.tsx +45 -0
  27. package/src/tools/control-room/ui/components/input.tsx +24 -0
  28. package/src/tools/control-room/ui/components/label.tsx +18 -0
  29. package/src/tools/control-room/ui/components/popover.tsx +37 -0
  30. package/src/tools/control-room/ui/components/separator.tsx +25 -0
  31. package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
  32. package/src/tools/control-room/ui/components/switch.tsx +27 -0
  33. package/src/tools/control-room/ui/components/table.tsx +60 -0
  34. package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
  35. package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
  36. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
  37. package/src/tools/control-room/ui/dist/index.html +19 -0
  38. package/src/tools/control-room/ui/format.ts +0 -12
  39. package/src/tools/control-room/ui/frame.tsx +125 -0
  40. package/src/tools/control-room/ui/index.html +2 -2
  41. package/src/tools/control-room/ui/lib/api.ts +27 -0
  42. package/src/tools/control-room/ui/lib/cn.ts +6 -0
  43. package/src/tools/control-room/ui/lib/write.ts +43 -0
  44. package/src/tools/control-room/ui/package.json +28 -0
  45. package/src/tools/control-room/ui/parts.tsx +235 -0
  46. package/src/tools/control-room/ui/screens/log.tsx +274 -0
  47. package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
  48. package/src/tools/control-room/ui/screens/projects.tsx +128 -0
  49. package/src/tools/control-room/ui/screens/settings.tsx +205 -0
  50. package/src/tools/control-room/ui/screens/today.tsx +391 -0
  51. package/src/tools/control-room/ui/theme.css +127 -0
  52. package/src/tools/control-room/ui/vite.config.ts +36 -0
  53. package/src/tools/control-room/writes.ts +106 -0
  54. package/src/tools/ledger/outcomes.ts +9 -0
  55. package/src/tools/ledger/query.ts +2 -0
  56. package/src/tools/ledger/view.ts +31 -10
  57. package/src/tools/lib/handoff-note.ts +14 -0
  58. package/src/tools/lib/project-isc.ts +61 -0
  59. package/src/tools/control-room/ui/agenda.tsx +0 -43
  60. package/src/tools/control-room/ui/agents.tsx +0 -67
  61. package/src/tools/control-room/ui/app.css +0 -857
  62. package/src/tools/control-room/ui/board.tsx +0 -82
  63. package/src/tools/control-room/ui/handoffs.tsx +0 -37
  64. package/src/tools/control-room/ui/ledger.tsx +0 -137
  65. package/src/tools/control-room/ui/matrix.tsx +0 -117
  66. package/src/tools/control-room/ui/panel.tsx +0 -60
  67. package/src/tools/control-room/ui/signal.tsx +0 -161
@@ -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 { isStale, type ProjectProgress, readAllProjects } from "../../hooks/lib/projects";
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
- export interface AgentsRow {
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((i) => i.status === "open").length,
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(p, p.path ? handoffs.get(p.path) : undefined, now)
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
- PROJECT_STALE_DAYS_DEFAULT,
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() <= URGENT_WITHIN_DAYS * 86_400_000;
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 > PROJECT_STALE_DAYS_DEFAULT * 86_400_000;
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 (important && p.status === "active" && quietSince(p.updated, now)) {
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
- urgentBecause,
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(goal: TelosGoal, now: Date): MatrixItem {
137
- const urgent = dueSoon(goal.due, now);
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 ranked = readAllProjects().filter((p) => RANKED_STATUSES.has(p.status));
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
- ...readTelosGoals().map((g) => goalItem(g, now)),
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
  }