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,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop handler: what to do tomorrow morning.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs, both too slow and too expensive for a page load, so both happen
|
|
5
|
+
* here and land in files the morning screen only reads.
|
|
6
|
+
*
|
|
7
|
+
* 1. Guess what each project serves, once, so importance can be ranked at all.
|
|
8
|
+
* A guess never overwrites the user's own answer.
|
|
9
|
+
* 2. Write three moves for the day — sentences, not project names, because the
|
|
10
|
+
* answer to "what now" is rarely "open a repository".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { matrix } from "../../tools/control-room/matrix";
|
|
14
|
+
import { type AgendaMove, readAgenda, writeAgenda } from "../lib/agenda-store";
|
|
15
|
+
import { canInfer, inference } from "../lib/inference";
|
|
16
|
+
import { logDebug, logError } from "../lib/log";
|
|
17
|
+
import { readAllProjects } from "../lib/projects";
|
|
18
|
+
import { isServesKind, SERVES_KINDS, setServes } from "../lib/serves";
|
|
19
|
+
import { readTelosGoals } from "../lib/telos-goals";
|
|
20
|
+
import { logTokenUsage } from "../lib/token-usage";
|
|
21
|
+
|
|
22
|
+
const FRESH_HOURS = 6;
|
|
23
|
+
const MAX_PROJECTS_PER_GUESS = 40;
|
|
24
|
+
|
|
25
|
+
function hoursSince(iso: string, now: Date): number {
|
|
26
|
+
const at = new Date(iso).getTime();
|
|
27
|
+
if (!Number.isFinite(at)) return Number.POSITIVE_INFINITY;
|
|
28
|
+
return (now.getTime() - at) / 3_600_000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SERVES_SCHEMA = {
|
|
32
|
+
type: "object" as const,
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
properties: {
|
|
35
|
+
projects: {
|
|
36
|
+
type: "array" as const,
|
|
37
|
+
description: "One entry per project you were given, no others",
|
|
38
|
+
items: {
|
|
39
|
+
type: "object" as const,
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
properties: {
|
|
42
|
+
name: { type: "string" as const },
|
|
43
|
+
serves: { type: "string" as const, enum: SERVES_KINDS },
|
|
44
|
+
note: {
|
|
45
|
+
type: "string" as const,
|
|
46
|
+
description: "Six words at most on what it serves",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ["name", "serves", "note"] as const,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
required: ["projects"] as const,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const MOVES_SCHEMA = {
|
|
57
|
+
type: "object" as const,
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
properties: {
|
|
60
|
+
moves: {
|
|
61
|
+
type: "array" as const,
|
|
62
|
+
description: "Exactly three, most consequential first",
|
|
63
|
+
items: {
|
|
64
|
+
type: "object" as const,
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
properties: {
|
|
67
|
+
move: {
|
|
68
|
+
type: "string" as const,
|
|
69
|
+
description: "One sentence, an action the user can start today",
|
|
70
|
+
},
|
|
71
|
+
because: {
|
|
72
|
+
type: "string" as const,
|
|
73
|
+
description: "One short clause naming the evidence it came from",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
required: ["move", "because"] as const,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ["moves"] as const,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function goalsBrief(): string {
|
|
84
|
+
const goals = readTelosGoals();
|
|
85
|
+
if (goals.length === 0) return "The user has not written any goals down yet.";
|
|
86
|
+
return goals
|
|
87
|
+
.map((g) => {
|
|
88
|
+
const by = g.due ? ` [by ${g.due}]` : "";
|
|
89
|
+
return `- ${g.text}${by}`;
|
|
90
|
+
})
|
|
91
|
+
.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A model's JSON is untrusted input like any other payload. */
|
|
95
|
+
function parsePayload<T>(raw: string, caller: string): T | null {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(raw) as T;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logError(caller, err);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Only projects with no purpose on record — a guess is made once, not nightly. */
|
|
105
|
+
async function guessMissingServes(sessionId?: string): Promise<number> {
|
|
106
|
+
const missing = readAllProjects()
|
|
107
|
+
.filter((p) => !p.serves && (p.status === "active" || p.status === "paused"))
|
|
108
|
+
.slice(0, MAX_PROJECTS_PER_GUESS);
|
|
109
|
+
if (missing.length === 0) return 0;
|
|
110
|
+
|
|
111
|
+
const described = missing
|
|
112
|
+
.map((p) => {
|
|
113
|
+
const purpose = (p.goal ?? p.problem ?? "").replace(/\s+/g, " ").slice(0, 200);
|
|
114
|
+
return `- ${p.name}: ${purpose || "no description on record"}`;
|
|
115
|
+
})
|
|
116
|
+
.join("\n");
|
|
117
|
+
|
|
118
|
+
const result = await inference({
|
|
119
|
+
system: [
|
|
120
|
+
"You are told a person's goals and a list of their projects.",
|
|
121
|
+
"For each project, decide which of three things it serves:",
|
|
122
|
+
'"goal" — it moves one of the stated goals forward;',
|
|
123
|
+
'"revenue" — it is a way the work could pay, even speculatively;',
|
|
124
|
+
'"fun" — it is kept for its own sake.',
|
|
125
|
+
"Judge from the goals and the project description only. Never assume a project is unimportant because it is small or quiet.",
|
|
126
|
+
"Return one entry per project you were given.",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
user: `Their goals:\n${goalsBrief()}\n\nTheir projects:\n${described}`,
|
|
129
|
+
maxTokens: 700,
|
|
130
|
+
timeout: 90000,
|
|
131
|
+
jsonSchema: SERVES_SCHEMA,
|
|
132
|
+
caller: "agenda-serves",
|
|
133
|
+
sessionId,
|
|
134
|
+
});
|
|
135
|
+
if (result.usage) logTokenUsage("agenda-serves", result.usage);
|
|
136
|
+
if (!result.success || !result.output) return 0;
|
|
137
|
+
|
|
138
|
+
const parsed = parsePayload<{
|
|
139
|
+
projects: { name: string; serves: string; note: string }[];
|
|
140
|
+
}>(result.output, "agenda:serves");
|
|
141
|
+
if (!parsed?.projects) return 0;
|
|
142
|
+
|
|
143
|
+
const known = new Set(missing.map((p) => p.name));
|
|
144
|
+
let written = 0;
|
|
145
|
+
for (const guess of parsed.projects) {
|
|
146
|
+
if (!known.has(guess.name) || !isServesKind(guess.serves)) continue;
|
|
147
|
+
const outcome = setServes({
|
|
148
|
+
name: guess.name,
|
|
149
|
+
kind: guess.serves,
|
|
150
|
+
note: guess.note,
|
|
151
|
+
by: "inferred",
|
|
152
|
+
});
|
|
153
|
+
if (outcome === "written") written++;
|
|
154
|
+
}
|
|
155
|
+
return written;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function matrixBrief(): string {
|
|
159
|
+
const grid = matrix();
|
|
160
|
+
const lines = [...grid.now, ...grid.plan, ...grid.noise].map((item) => {
|
|
161
|
+
const why = item.urgentBecause.join(", ") || "nothing pressing";
|
|
162
|
+
const waiting = item.waitingOn ? ` — waiting on the user for: ${item.waitingOn}` : "";
|
|
163
|
+
return `- [${item.kind}] ${item.label}: ${item.importantBecause}; ${why}${waiting}`;
|
|
164
|
+
});
|
|
165
|
+
return lines.length > 0 ? lines.join("\n") : "Nothing is ranked yet.";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function writeMoves(sessionId?: string): Promise<boolean> {
|
|
169
|
+
const result = await inference({
|
|
170
|
+
system: [
|
|
171
|
+
"You write the first three lines a person reads in the morning.",
|
|
172
|
+
"You are given their goals and a ranked list of their projects and goals with the reason each was ranked.",
|
|
173
|
+
"Write exactly three moves, most consequential first.",
|
|
174
|
+
"A move is a sentence naming an action, not a project name: 'Send ACE the mapping one-pager' beats 'work on ontology'.",
|
|
175
|
+
"Prefer what is blocked on the person themselves, then what serves a goal, then what is merely urgent.",
|
|
176
|
+
"Never invent a fact that is not in what you were given.",
|
|
177
|
+
].join("\n"),
|
|
178
|
+
user: `Their goals:\n${goalsBrief()}\n\nWhat is ranked and why:\n${matrixBrief()}`,
|
|
179
|
+
maxTokens: 400,
|
|
180
|
+
timeout: 90000,
|
|
181
|
+
jsonSchema: MOVES_SCHEMA,
|
|
182
|
+
caller: "agenda-moves",
|
|
183
|
+
sessionId,
|
|
184
|
+
});
|
|
185
|
+
if (result.usage) logTokenUsage("agenda-moves", result.usage);
|
|
186
|
+
if (!result.success || !result.output) return false;
|
|
187
|
+
|
|
188
|
+
const parsed = parsePayload<{ moves: AgendaMove[] }>(result.output, "agenda:moves");
|
|
189
|
+
const moves = (parsed?.moves ?? []).filter((m) => m.move).slice(0, 3);
|
|
190
|
+
if (moves.length === 0) return false;
|
|
191
|
+
|
|
192
|
+
await writeAgenda({ generatedAt: new Date().toISOString(), moves });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Named so the caller — and a test — can tell a skip from a failure. */
|
|
197
|
+
export type AgendaOutcome = "fresh" | "no-inference" | "written" | "failed";
|
|
198
|
+
|
|
199
|
+
/** @lintignore exercised directly by test/agenda-handler.test.ts */
|
|
200
|
+
export async function refreshAgenda(
|
|
201
|
+
now: Date = new Date(),
|
|
202
|
+
sessionId?: string
|
|
203
|
+
): Promise<AgendaOutcome> {
|
|
204
|
+
const existing = readAgenda();
|
|
205
|
+
if (existing && hoursSince(existing.generatedAt, now) < FRESH_HOURS) return "fresh";
|
|
206
|
+
if (!canInfer()) return "no-inference";
|
|
207
|
+
|
|
208
|
+
const guessed = await guessMissingServes(sessionId);
|
|
209
|
+
const wrote = await writeMoves(sessionId);
|
|
210
|
+
logDebug("agenda", `serves guessed: ${guessed}, moves written: ${wrote}`);
|
|
211
|
+
return wrote ? "written" : "failed";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (process.argv[2] === "--run") {
|
|
215
|
+
const sid = process.argv[3];
|
|
216
|
+
try {
|
|
217
|
+
const outcome = await refreshAgenda(new Date(), sid === "" ? undefined : sid);
|
|
218
|
+
logDebug("agenda", outcome);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
logError("agenda:run", err);
|
|
221
|
+
}
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
@@ -14,6 +14,7 @@ import { ensureIndex } from "../lib/retrieval-index";
|
|
|
14
14
|
import { isEnabled } from "../lib/settings";
|
|
15
15
|
import { getSkillReminder } from "../lib/skill-match";
|
|
16
16
|
import { getSteeringReminder } from "../lib/steering";
|
|
17
|
+
import { getWallClockReminder } from "../lib/wall-clock";
|
|
17
18
|
|
|
18
19
|
const BUDGET_MS = 250;
|
|
19
20
|
|
|
@@ -79,11 +80,14 @@ function writeForAgent(reminder: string): void {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/** Merge every prompt-time source — contextual steering, skill
|
|
83
|
-
* retrieval — into one payload, or null when none of them
|
|
83
|
+
/** Merge every prompt-time source — the wall clock, contextual steering, skill
|
|
84
|
+
* matches, prior-lesson retrieval — into one payload, or null when none of them
|
|
85
|
+
* produced anything. The clock leads: it is the only part that is true of the
|
|
86
|
+
* moment rather than of the prompt.
|
|
84
87
|
* @lintignore dynamically imported by opencode plugin */
|
|
85
88
|
export async function getPromptContext(prompt: string): Promise<string | null> {
|
|
86
89
|
const parts = [
|
|
90
|
+
getWallClockReminder(),
|
|
87
91
|
getSteeringReminder(prompt),
|
|
88
92
|
getSkillReminder(prompt),
|
|
89
93
|
await getRetrievalReminder(prompt),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the morning's three moves live between sessions.
|
|
3
|
+
*
|
|
4
|
+
* A file, not a computation: the page reads it, the stop handler writes it, and
|
|
5
|
+
* neither has to know how the other works.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { writeFile } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { paths } from "./paths";
|
|
12
|
+
|
|
13
|
+
export interface AgendaMove {
|
|
14
|
+
move: string;
|
|
15
|
+
because: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Agenda {
|
|
19
|
+
generatedAt: string;
|
|
20
|
+
moves: AgendaMove[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @lintignore exercised directly by test/agenda-store.test.ts */
|
|
24
|
+
export function agendaPath(): string {
|
|
25
|
+
return resolve(paths.state(), "agenda.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readAgenda(): Agenda | null {
|
|
29
|
+
const path = agendaPath();
|
|
30
|
+
if (!existsSync(path)) return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Agenda;
|
|
33
|
+
return Array.isArray(parsed.moves) && parsed.generatedAt ? parsed : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function writeAgenda(agenda: Agenda): Promise<void> {
|
|
40
|
+
await writeFile(agendaPath(), `${JSON.stringify(agenda, null, 2)}\n`, "utf-8");
|
|
41
|
+
}
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -62,6 +62,7 @@ export const paths = {
|
|
|
62
62
|
work: () => ensureDir(home("memory", "work")),
|
|
63
63
|
backups: () => ensureDir(home("backups")),
|
|
64
64
|
debug: () => ensureDir(home("debug")),
|
|
65
|
+
serverState: () => home("server.json"),
|
|
65
66
|
} as const;
|
|
66
67
|
|
|
67
68
|
// Platform directories (env override or cross-platform defaults)
|
|
@@ -24,6 +24,11 @@ import { detectRemote } from "./remote";
|
|
|
24
24
|
|
|
25
25
|
export type ProjectStatus = "active" | "paused" | "complete" | "archived";
|
|
26
26
|
|
|
27
|
+
/** What a project is for. The three answers importance can be ranked from. */
|
|
28
|
+
export type ServesKind = "goal" | "revenue" | "fun";
|
|
29
|
+
/** Who decided it. A user answer outranks a guess and survives re-inference. */
|
|
30
|
+
export type ServesAuthority = "inferred" | "user";
|
|
31
|
+
|
|
27
32
|
export interface ProjectProgress {
|
|
28
33
|
name: string;
|
|
29
34
|
/** Resolved for this machine at read time; absent when not checked out here. */
|
|
@@ -36,6 +41,10 @@ export interface ProjectProgress {
|
|
|
36
41
|
next?: string[];
|
|
37
42
|
blockers?: string[];
|
|
38
43
|
handoff?: string;
|
|
44
|
+
/** What this project is for — the fact importance is ranked from. */
|
|
45
|
+
serves?: ServesKind;
|
|
46
|
+
serves_note?: string;
|
|
47
|
+
serves_by?: ServesAuthority;
|
|
39
48
|
// ISA body sections
|
|
40
49
|
problem?: string;
|
|
41
50
|
goal?: string;
|
|
@@ -100,7 +109,7 @@ export function legacyJsonToProgress(raw: unknown): ProjectProgress | null {
|
|
|
100
109
|
return p;
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
const PROJECT_STALE_DAYS_DEFAULT = 14;
|
|
112
|
+
export const PROJECT_STALE_DAYS_DEFAULT = 14;
|
|
104
113
|
|
|
105
114
|
const PROJECT_MARKERS = [
|
|
106
115
|
".git",
|
|
@@ -123,6 +132,9 @@ type IsaMeta = {
|
|
|
123
132
|
next?: string[];
|
|
124
133
|
blockers?: string[];
|
|
125
134
|
handoff?: string;
|
|
135
|
+
serves?: ServesKind;
|
|
136
|
+
serves_note?: string;
|
|
137
|
+
serves_by?: ServesAuthority;
|
|
126
138
|
};
|
|
127
139
|
|
|
128
140
|
const BODY_SECTIONS: Array<[string, keyof ProjectProgress]> = [
|
|
@@ -269,6 +281,9 @@ export function writeProject(p: ProjectProgress): void {
|
|
|
269
281
|
if (p.next?.length) meta.next = p.next;
|
|
270
282
|
if (p.blockers?.length) meta.blockers = p.blockers;
|
|
271
283
|
if (p.handoff) meta.handoff = p.handoff;
|
|
284
|
+
if (p.serves) meta.serves = p.serves;
|
|
285
|
+
if (p.serves_note) meta.serves_note = p.serves_note;
|
|
286
|
+
if (p.serves_by) meta.serves_by = p.serves_by;
|
|
272
287
|
writeFileSync(ensureAndGetIsaFile(p.name), stringify(meta, buildBody(p)), "utf-8");
|
|
273
288
|
}
|
|
274
289
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a project is for, and what that makes it worth.
|
|
3
|
+
*
|
|
4
|
+
* Importance cannot be read off a repository: two projects with identical
|
|
5
|
+
* activity can be a client's livelihood and a weekend toy. So the record carries
|
|
6
|
+
* one fact PAL guesses and the user can overrule — never a name or a rank
|
|
7
|
+
* written into source, because next month the same code serves something else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
readProject,
|
|
12
|
+
type ServesAuthority,
|
|
13
|
+
type ServesKind,
|
|
14
|
+
writeProject,
|
|
15
|
+
} from "./projects";
|
|
16
|
+
|
|
17
|
+
export const SERVES_KINDS: ServesKind[] = ["goal", "revenue", "fun"];
|
|
18
|
+
|
|
19
|
+
export const SERVES_MEANING: Record<ServesKind, string> = {
|
|
20
|
+
goal: "serves a stated goal",
|
|
21
|
+
revenue: "a way this could pay",
|
|
22
|
+
fun: "kept for its own sake",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A goal and a revenue bet are worth protecting time for; fun is not. */
|
|
26
|
+
export function isImportant(kind: ServesKind | undefined): boolean {
|
|
27
|
+
return kind === "goal" || kind === "revenue";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isServesKind(value: unknown): value is ServesKind {
|
|
31
|
+
return typeof value === "string" && SERVES_KINDS.includes(value as ServesKind);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ServesUpdate {
|
|
35
|
+
name: string;
|
|
36
|
+
kind: ServesKind;
|
|
37
|
+
note?: string;
|
|
38
|
+
by: ServesAuthority;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A guess never overwrites an answer. This is the whole reason the record
|
|
43
|
+
* stores who decided: re-inference runs freely and the user is only asked once.
|
|
44
|
+
*
|
|
45
|
+
* Writing does bump `updated`, like every other write to the record — a hobby
|
|
46
|
+
* project that becomes the thing people depend on should rank as touched the
|
|
47
|
+
* moment that is written down, not fourteen days later.
|
|
48
|
+
*/
|
|
49
|
+
export function setServes(update: ServesUpdate): "written" | "kept" | "missing" {
|
|
50
|
+
const project = readProject(update.name);
|
|
51
|
+
if (!project) return "missing";
|
|
52
|
+
if (update.by === "inferred" && project.serves_by === "user") return "kept";
|
|
53
|
+
|
|
54
|
+
project.serves = update.kind;
|
|
55
|
+
project.serves_by = update.by;
|
|
56
|
+
if (update.note) project.serves_note = update.note;
|
|
57
|
+
project.updated = new Date().toISOString();
|
|
58
|
+
writeProject(project);
|
|
59
|
+
return "written";
|
|
60
|
+
}
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -50,6 +50,7 @@ export async function runStopHandlers(
|
|
|
50
50
|
// inference and write results to disk; they don't block this hook.
|
|
51
51
|
await detachSessionIntelligence(transcript, options.sessionId);
|
|
52
52
|
await detachFailurePrinciple(transcript);
|
|
53
|
+
detachAgenda(options.sessionId);
|
|
53
54
|
// Failure auto-graduation is intentionally NOT wired here: every pattern it
|
|
54
55
|
// ever promoted was a frustration log, not a principle. Wisdom frames are
|
|
55
56
|
// populated by Claude in-conversation (see wisdom.ts header). The handler
|
|
@@ -161,6 +162,19 @@ async function writeTranscriptTmp(transcript: string): Promise<string> {
|
|
|
161
162
|
return file;
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The agenda reads files, not the transcript, so it needs no tmp copy — but it
|
|
167
|
+
* calls a model, so it detaches like the others. It no-ops on a fresh agenda.
|
|
168
|
+
*/
|
|
169
|
+
function detachAgenda(sessionId?: string): void {
|
|
170
|
+
try {
|
|
171
|
+
const scriptPath = resolve(assets.hooks(), "handlers", "agenda.ts");
|
|
172
|
+
spawnDetachedInference(scriptPath, ["--run", sessionId ?? ""], "agenda");
|
|
173
|
+
} catch (err) {
|
|
174
|
+
logError("detachAgenda", err);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
/** Spawn a detached child to run session-intelligence on a tmp copy of the transcript. */
|
|
165
179
|
async function detachSessionIntelligence(
|
|
166
180
|
transcript: string,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's stated goals, read as ranked items rather than prose.
|
|
3
|
+
*
|
|
4
|
+
* A goal belongs on the morning screen next to the projects — "find clients"
|
|
5
|
+
* outranks every repository and is not one. Parsing is deliberately deterministic:
|
|
6
|
+
* the page must render without a model call, so a date is found by reading, not
|
|
7
|
+
* by asking.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { palHome } from "./paths";
|
|
13
|
+
|
|
14
|
+
export interface TelosGoal {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
text: string;
|
|
18
|
+
/** Horizon heading the entry sat under, when GOALS.md uses them. */
|
|
19
|
+
horizon: string | null;
|
|
20
|
+
/** First date the entry names, as an ISO day. */
|
|
21
|
+
due: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MONTHS = [
|
|
25
|
+
"january",
|
|
26
|
+
"february",
|
|
27
|
+
"march",
|
|
28
|
+
"april",
|
|
29
|
+
"may",
|
|
30
|
+
"june",
|
|
31
|
+
"july",
|
|
32
|
+
"august",
|
|
33
|
+
"september",
|
|
34
|
+
"october",
|
|
35
|
+
"november",
|
|
36
|
+
"december",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const ISO_DAY = /\b(\d{4})-(\d{2})-(\d{2})\b/;
|
|
40
|
+
const MONTH_YEAR = new RegExp(String.raw`\b(${MONTHS.join("|")})\s+(\d{4})\b`, "i");
|
|
41
|
+
const QUARTER = /\bQ([1-4])\s*,?\s*(\d{4})\b/i;
|
|
42
|
+
|
|
43
|
+
function endOfMonth(year: number, monthIndex: number): string {
|
|
44
|
+
return new Date(Date.UTC(year, monthIndex + 1, 0)).toISOString().slice(0, 10);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The last day the entry could still be met, which is what urgency measures against. */
|
|
48
|
+
export function dueFrom(text: string): string | null {
|
|
49
|
+
const iso = ISO_DAY.exec(text);
|
|
50
|
+
if (iso) return iso[0];
|
|
51
|
+
|
|
52
|
+
const monthYear = MONTH_YEAR.exec(text);
|
|
53
|
+
if (monthYear) {
|
|
54
|
+
return endOfMonth(Number(monthYear[2]), MONTHS.indexOf(monthYear[1].toLowerCase()));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const quarter = QUARTER.exec(text);
|
|
58
|
+
if (quarter) return endOfMonth(Number(quarter[2]), Number(quarter[1]) * 3 - 1);
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function firstSentenceOf(text: string): string {
|
|
64
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
65
|
+
const match = new RegExp(/^.*?[.!?](?=\s|$)/).exec(flat);
|
|
66
|
+
return match ? match[0] : flat;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slug(title: string, index: number): string {
|
|
70
|
+
const base = title
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
73
|
+
.trim()
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.slice(0, 5)
|
|
76
|
+
.join("-");
|
|
77
|
+
return base || `goal-${index + 1}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isScaffolding(line: string): boolean {
|
|
81
|
+
const l = line.trim();
|
|
82
|
+
return !l || l.startsWith("<!--") || l.startsWith("-->") || /^(-{3,}|_{3,})$/.test(l);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bullets are entries when a block has them; otherwise the paragraph is the
|
|
87
|
+
* entry. Both shapes ship — the scaffold offers horizon headings with bullets,
|
|
88
|
+
* and people write prose anyway.
|
|
89
|
+
*/
|
|
90
|
+
function entriesIn(lines: string[]): string[] {
|
|
91
|
+
const bullets = lines
|
|
92
|
+
.filter((l) => /^\s*[-*+]\s+\S/.test(l))
|
|
93
|
+
.map((l) => l.replace(/^\s*[-*+]\s+/, "").trim());
|
|
94
|
+
if (bullets.length > 0) return bullets;
|
|
95
|
+
const paragraph = lines.join(" ").trim();
|
|
96
|
+
return paragraph ? [paragraph] : [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function goalsFrom(content: string): TelosGoal[] {
|
|
100
|
+
const goals: TelosGoal[] = [];
|
|
101
|
+
let horizon: string | null = null;
|
|
102
|
+
let block: string[] = [];
|
|
103
|
+
|
|
104
|
+
const flush = () => {
|
|
105
|
+
for (const text of entriesIn(block)) {
|
|
106
|
+
const title = firstSentenceOf(text);
|
|
107
|
+
goals.push({
|
|
108
|
+
id: slug(title, goals.length),
|
|
109
|
+
title,
|
|
110
|
+
text: text.replace(/\s+/g, " ").trim(),
|
|
111
|
+
horizon,
|
|
112
|
+
due: dueFrom(text),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
block = [];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (const line of content.split("\n")) {
|
|
119
|
+
if (line.trim().startsWith("#")) {
|
|
120
|
+
flush();
|
|
121
|
+
const heading = line.replace(/^#+\s*/, "").trim();
|
|
122
|
+
horizon = /^goals$/i.test(heading) ? null : heading;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (isScaffolding(line)) {
|
|
126
|
+
flush();
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
block.push(line);
|
|
130
|
+
}
|
|
131
|
+
flush();
|
|
132
|
+
|
|
133
|
+
return goals;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readTelosGoals(home: string = palHome()): TelosGoal[] {
|
|
137
|
+
const path = resolve(home, "telos", "GOALS.md");
|
|
138
|
+
if (!existsSync(path)) return [];
|
|
139
|
+
try {
|
|
140
|
+
return goalsFrom(readFileSync(path, "utf-8"));
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TELOS topics, and the one rule for whether a topic has been answered.
|
|
3
|
+
*
|
|
4
|
+
* Install does not ask these questions any more — the onboarding skill does,
|
|
5
|
+
* whenever the user is ready to answer them. Both that skill and the doctor
|
|
6
|
+
* read the answer from here through `pal cli telos`, so the prose in the skill
|
|
7
|
+
* cannot drift away from the code that decides.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { palHome } from "./paths";
|
|
13
|
+
|
|
14
|
+
interface TelosTopic {
|
|
15
|
+
key: string;
|
|
16
|
+
file: string;
|
|
17
|
+
/** Interviewed first, and the only ones the doctor reports on. */
|
|
18
|
+
priority: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TELOS_TOPICS: TelosTopic[] = [
|
|
22
|
+
{ key: "mission", file: "telos/MISSION.md", priority: true },
|
|
23
|
+
{ key: "goals", file: "telos/GOALS.md", priority: true },
|
|
24
|
+
{ key: "challenges", file: "telos/CHALLENGES.md", priority: true },
|
|
25
|
+
{ key: "strategies", file: "telos/STRATEGIES.md", priority: true },
|
|
26
|
+
{ key: "beliefs", file: "telos/BELIEFS.md", priority: true },
|
|
27
|
+
{ key: "models", file: "telos/MODELS.md", priority: false },
|
|
28
|
+
{ key: "narratives", file: "telos/NARRATIVES.md", priority: false },
|
|
29
|
+
{ key: "learned", file: "telos/LEARNED.md", priority: false },
|
|
30
|
+
{ key: "ideas", file: "telos/IDEAS.md", priority: false },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** A shipped scaffold is headings, comments, rules and empty bullets — nothing said. */
|
|
34
|
+
function isScaffolding(line: string): boolean {
|
|
35
|
+
const l = line.trim();
|
|
36
|
+
if (!l) return true;
|
|
37
|
+
if (l.startsWith("#")) return true;
|
|
38
|
+
if (l.startsWith("<!--") || l.startsWith("-->")) return true;
|
|
39
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(l)) return true;
|
|
40
|
+
return /^[-*+]$/.test(l);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Anything the scaffold did not put there counts, table rows included.
|
|
45
|
+
* @lintignore exercised directly by test/telos-topics.test.ts
|
|
46
|
+
*/
|
|
47
|
+
export function hasRealContent(filePath: string): boolean {
|
|
48
|
+
if (!existsSync(filePath)) return false;
|
|
49
|
+
try {
|
|
50
|
+
return readFileSync(filePath, "utf-8")
|
|
51
|
+
.split("\n")
|
|
52
|
+
.some((line) => !isScaffolding(line));
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TopicStatus extends TelosTopic {
|
|
59
|
+
path: string;
|
|
60
|
+
answered: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function telosStatus(home: string = palHome()): TopicStatus[] {
|
|
64
|
+
return TELOS_TOPICS.map((topic) => {
|
|
65
|
+
const path = resolve(home, topic.file);
|
|
66
|
+
return { ...topic, path, answered: hasRealContent(path) };
|
|
67
|
+
});
|
|
68
|
+
}
|