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