portable-agent-layer 0.70.0 → 0.72.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 -1
- 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 +39 -12
- package/src/cli/migrate.ts +1 -1
- package/src/cli/personal-context.ts +67 -0
- package/src/cli/server.ts +13 -7
- package/src/cli/setup-identity.ts +13 -1
- 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 +223 -0
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/inject-retrieval.ts +6 -2
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/agenda-store.ts +41 -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/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/paths.ts +0 -1
- package/src/hooks/lib/projects.ts +16 -1
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/serves.ts +60 -0
- package/src/hooks/lib/session-context.ts +74 -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/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +40 -70
- package/src/tools/agent/project.ts +47 -136
- 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/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 +137 -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/view.ts +3 -0
- 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 +88 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +151 -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/assets/templates/ledger-page.html +0 -213
- package/src/cli/setup-telos.ts +0 -52
- package/src/hooks/lib/setup.ts +0 -60
- package/src/tools/ledger/server.ts +0 -111
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
*
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
13
|
import { unlink, writeFile } from "node:fs/promises";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
isRecaptureWorthwhile,
|
|
17
|
+
learningSlug,
|
|
18
|
+
markCaptured,
|
|
19
|
+
readCapture,
|
|
20
|
+
} from "../lib/capture-store";
|
|
15
21
|
import { stringify } from "../lib/frontmatter";
|
|
16
22
|
import { canInfer, inference } from "../lib/inference";
|
|
17
23
|
import { categorizeLearning } from "../lib/learning-category";
|
|
@@ -27,68 +33,6 @@ import {
|
|
|
27
33
|
} from "../lib/transcript";
|
|
28
34
|
import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
|
|
29
35
|
|
|
30
|
-
// ── Dedup tracking ──
|
|
31
|
-
|
|
32
|
-
interface CaptureEntry {
|
|
33
|
-
filepath: string;
|
|
34
|
-
messageCount: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const MIN_NEW_MESSAGES = 10;
|
|
38
|
-
|
|
39
|
-
function capturedPath(): string {
|
|
40
|
-
return resolve(paths.state(), "captured-learnings.json");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function getPreviousCapture(sessionId: string): CaptureEntry | null {
|
|
44
|
-
const p = capturedPath();
|
|
45
|
-
if (!existsSync(p)) return null;
|
|
46
|
-
try {
|
|
47
|
-
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
48
|
-
if (Array.isArray(raw)) return null;
|
|
49
|
-
const entry = raw[sessionId];
|
|
50
|
-
if (!entry) return null;
|
|
51
|
-
if (typeof entry === "string") return { filepath: entry, messageCount: 0 };
|
|
52
|
-
return entry as CaptureEntry;
|
|
53
|
-
} catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function markCaptured(sessionId: string, filepath: string, messageCount: number): void {
|
|
59
|
-
const p = capturedPath();
|
|
60
|
-
let data: Record<string, CaptureEntry> = {};
|
|
61
|
-
try {
|
|
62
|
-
if (existsSync(p)) {
|
|
63
|
-
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
64
|
-
if (!Array.isArray(raw) && typeof raw === "object") {
|
|
65
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
66
|
-
data[k] =
|
|
67
|
-
typeof v === "string"
|
|
68
|
-
? { filepath: v, messageCount: 0 }
|
|
69
|
-
: (v as CaptureEntry);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
} catch {
|
|
74
|
-
/* start fresh */
|
|
75
|
-
}
|
|
76
|
-
data[sessionId] = { filepath, messageCount };
|
|
77
|
-
const entries = Object.entries(data);
|
|
78
|
-
if (entries.length > 50) data = Object.fromEntries(entries.slice(-50));
|
|
79
|
-
writeFileSync(p, JSON.stringify(data, null, 2), "utf-8");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function slugify(text: string): string {
|
|
83
|
-
return text
|
|
84
|
-
.toLowerCase()
|
|
85
|
-
.replace(/[^a-z0-9\s]/g, "")
|
|
86
|
-
.trim()
|
|
87
|
-
.split(/\s+/)
|
|
88
|
-
.slice(0, 4)
|
|
89
|
-
.join("-");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
36
|
// ── JSON schema for merged Haiku call ──
|
|
93
37
|
|
|
94
38
|
const INTELLIGENCE_SCHEMA = {
|
|
@@ -124,17 +68,16 @@ interface IntelligenceOutput {
|
|
|
124
68
|
|
|
125
69
|
// ── Main handler ──
|
|
126
70
|
|
|
127
|
-
|
|
71
|
+
/** @lintignore exercised directly by test/session-intelligence.test.ts */
|
|
72
|
+
export async function captureSessionIntelligence(
|
|
128
73
|
transcript: string,
|
|
129
74
|
sessionId?: string
|
|
130
75
|
): Promise<void> {
|
|
131
76
|
const messages = parseMessages(transcript);
|
|
132
77
|
if (messages.length < 6 || transcript.length < 2000) return;
|
|
133
78
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const prev = getPreviousCapture(sessionId);
|
|
137
|
-
if (prev && messages.length - prev.messageCount < MIN_NEW_MESSAGES) return;
|
|
79
|
+
if (sessionId && !isRecaptureWorthwhile(readCapture(sessionId), messages.length)) {
|
|
80
|
+
return;
|
|
138
81
|
}
|
|
139
82
|
|
|
140
83
|
// Skip if no inference path is available (no CLI binary AND no API key)
|
|
@@ -200,7 +143,7 @@ async function captureSessionIntelligence(
|
|
|
200
143
|
// ── Write session learning file ──
|
|
201
144
|
|
|
202
145
|
const category = categorizeLearning(title, summary);
|
|
203
|
-
const slug =
|
|
146
|
+
const slug = learningSlug(title);
|
|
204
147
|
const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
|
|
205
148
|
const filename = `${fileTimestamp()}_${category}_${slug}.md`;
|
|
206
149
|
|
|
@@ -224,7 +167,7 @@ async function captureSessionIntelligence(
|
|
|
224
167
|
|
|
225
168
|
// Remove previous capture for this session
|
|
226
169
|
if (sessionId) {
|
|
227
|
-
const prev =
|
|
170
|
+
const prev = readCapture(sessionId);
|
|
228
171
|
if (prev?.filepath && existsSync(prev.filepath)) {
|
|
229
172
|
try {
|
|
230
173
|
await unlink(prev.filepath);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sessions have already had their learning captured, and where the file went.
|
|
3
|
+
*
|
|
4
|
+
* A session's Stop event fires after every response, so the same session reaches
|
|
5
|
+
* the capture handler many times. Without a record of what was already written,
|
|
6
|
+
* each pass would produce another near-identical learning file for one session.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { paths } from "./paths";
|
|
12
|
+
|
|
13
|
+
export interface CaptureEntry {
|
|
14
|
+
filepath: string;
|
|
15
|
+
messageCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Below this many new messages, a re-capture would restate what was already written. */
|
|
19
|
+
const MIN_NEW_MESSAGES = 10;
|
|
20
|
+
|
|
21
|
+
/** How many sessions the file remembers before the oldest are dropped. */
|
|
22
|
+
const MAX_REMEMBERED = 50;
|
|
23
|
+
|
|
24
|
+
/** @lintignore exercised directly by test/capture-store.test.ts */
|
|
25
|
+
export function capturedPath(): string {
|
|
26
|
+
return resolve(paths.state(), "captured-learnings.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Entries were once a bare filepath string. One is read as a capture at message
|
|
31
|
+
* zero, which makes any later session look new enough to re-capture — the safe
|
|
32
|
+
* direction for a record whose message count was never written down.
|
|
33
|
+
*/
|
|
34
|
+
function asEntry(value: unknown): CaptureEntry | null {
|
|
35
|
+
if (typeof value === "string") return { filepath: value, messageCount: 0 };
|
|
36
|
+
if (value && typeof value === "object") return value as CaptureEntry;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readAll(): Record<string, CaptureEntry> {
|
|
41
|
+
const path = capturedPath();
|
|
42
|
+
if (!existsSync(path)) return {};
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
45
|
+
if (Array.isArray(raw) || !raw || typeof raw !== "object") return {};
|
|
46
|
+
const entries: Record<string, CaptureEntry> = {};
|
|
47
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
48
|
+
const entry = asEntry(value);
|
|
49
|
+
if (entry) entries[id] = entry;
|
|
50
|
+
}
|
|
51
|
+
return entries;
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readCapture(sessionId: string): CaptureEntry | null {
|
|
58
|
+
return readAll()[sessionId] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A session with no previous capture is always worth writing. One that has grown
|
|
63
|
+
* by fewer than MIN_NEW_MESSAGES since is not: the transcript window the summary
|
|
64
|
+
* is drawn from has barely moved, so the second file would say the same thing.
|
|
65
|
+
*/
|
|
66
|
+
export function isRecaptureWorthwhile(
|
|
67
|
+
previous: CaptureEntry | null,
|
|
68
|
+
messageCount: number
|
|
69
|
+
): boolean {
|
|
70
|
+
if (!previous) return true;
|
|
71
|
+
return messageCount - previous.messageCount >= MIN_NEW_MESSAGES;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function markCaptured(
|
|
75
|
+
sessionId: string,
|
|
76
|
+
filepath: string,
|
|
77
|
+
messageCount: number
|
|
78
|
+
): void {
|
|
79
|
+
const data = readAll();
|
|
80
|
+
data[sessionId] = { filepath, messageCount };
|
|
81
|
+
const entries = Object.entries(data);
|
|
82
|
+
const kept = entries.length > MAX_REMEMBERED ? entries.slice(-MAX_REMEMBERED) : entries;
|
|
83
|
+
writeFileSync(
|
|
84
|
+
capturedPath(),
|
|
85
|
+
JSON.stringify(Object.fromEntries(kept), null, 2),
|
|
86
|
+
"utf-8"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The readable half of a learning file's name. Four words, because the rest of
|
|
92
|
+
* the name is already a timestamp and a category and the whole thing has to stay
|
|
93
|
+
* a filename.
|
|
94
|
+
*/
|
|
95
|
+
export function learningSlug(title: string): string {
|
|
96
|
+
return title
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
99
|
+
.trim()
|
|
100
|
+
.split(/\s+/)
|
|
101
|
+
.slice(0, 4)
|
|
102
|
+
.join("-");
|
|
103
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last exchange before a compaction, and what is safe to do with it after.
|
|
3
|
+
*
|
|
4
|
+
* A summary can collapse the turn that was in flight when the window filled, so
|
|
5
|
+
* the originals are re-injected verbatim on the next session. Everything here
|
|
6
|
+
* used to sit inside the spawned hook: the budget split, the order the candidate
|
|
7
|
+
* files are tried in, and whether the file just read may be deleted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { paths } from "./paths";
|
|
13
|
+
|
|
14
|
+
/** Hook output is capped at 10,000 chars; the rest is headroom for the framing. */
|
|
15
|
+
const MAX_OUTPUT = 9_000;
|
|
16
|
+
|
|
17
|
+
/** The user's half. The assistant's reply is the longer of the two in most turns. */
|
|
18
|
+
const USER_SHARE = 0.4;
|
|
19
|
+
|
|
20
|
+
/** Held back for the reminder's headings and framing text. */
|
|
21
|
+
const FRAMING_RESERVE = 300;
|
|
22
|
+
|
|
23
|
+
export interface SavedExchange {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
trigger: string | null;
|
|
27
|
+
customInstructions: string | null;
|
|
28
|
+
userMessage: string;
|
|
29
|
+
assistantMessage: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RecallBudget {
|
|
33
|
+
user: number;
|
|
34
|
+
assistant: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function recallBudget(max: number = MAX_OUTPUT): RecallBudget {
|
|
38
|
+
const user = Math.floor(max * USER_SHARE);
|
|
39
|
+
return { user, assistant: max - user - FRAMING_RESERVE };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Says how much was dropped, so a truncated message cannot read as a complete one. */
|
|
43
|
+
export function truncate(s: string, max: number): string {
|
|
44
|
+
if (s.length <= max) return s;
|
|
45
|
+
return `${s.slice(0, max)}\n[... truncated ${s.length - max} chars]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function exchangeDir(): string {
|
|
49
|
+
return resolve(paths.state(), "last-exchange");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The session's own file first, then the fallback. latest.json is overwritten by
|
|
54
|
+
* every compaction, so it is right only when nothing more specific exists.
|
|
55
|
+
*/
|
|
56
|
+
export function findSavedExchange(sessionId?: string): string | null {
|
|
57
|
+
const candidates = [
|
|
58
|
+
sessionId ? resolve(exchangeDir(), `${sessionId}.json`) : null,
|
|
59
|
+
resolve(exchangeDir(), "latest.json"),
|
|
60
|
+
].filter((path): path is string => path !== null);
|
|
61
|
+
return candidates.find((path) => existsSync(path)) ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Consume-on-read, but only for the session's own file: latest.json is the
|
|
66
|
+
* safety fallback and deleting it would leave the next compaction with nothing.
|
|
67
|
+
*/
|
|
68
|
+
export function isConsumable(file: string, sessionId?: string): boolean {
|
|
69
|
+
if (!sessionId) return false;
|
|
70
|
+
return file === resolve(exchangeDir(), `${sessionId}.json`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildRecall(saved: SavedExchange, budget = recallBudget()): string {
|
|
74
|
+
return [
|
|
75
|
+
"<system-reminder>",
|
|
76
|
+
"## Last exchange before compaction",
|
|
77
|
+
"_Restored verbatim from PAL state. The compaction summary may have collapsed this; the originals are below._",
|
|
78
|
+
"",
|
|
79
|
+
"**User:**",
|
|
80
|
+
truncate(saved.userMessage || "(no user message captured)", budget.user),
|
|
81
|
+
"",
|
|
82
|
+
"**Assistant:**",
|
|
83
|
+
truncate(
|
|
84
|
+
saved.assistantMessage || "(no assistant message captured)",
|
|
85
|
+
budget.assistant
|
|
86
|
+
),
|
|
87
|
+
"</system-reminder>",
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a low-rated session is asked about, and what is kept from the answer.
|
|
3
|
+
*
|
|
4
|
+
* The handler around this is spawned detached — claude --print's cold start
|
|
5
|
+
* outruns the Stop hook's budget — so none of it was reachable from a test. The
|
|
6
|
+
* decisions are here instead: how much of the transcript the question carries,
|
|
7
|
+
* what the question is, and which of the two sources wins for each field.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { extractContent, parseMessages } from "./transcript";
|
|
11
|
+
|
|
12
|
+
/** Enough of the ending to see what went wrong, without paying for the whole session. */
|
|
13
|
+
const MAX_MESSAGES = 10;
|
|
14
|
+
|
|
15
|
+
/** Per message, so one long tool dump cannot crowd out the other nine. */
|
|
16
|
+
const MAX_CHARS_PER_MESSAGE = 300;
|
|
17
|
+
|
|
18
|
+
export interface PendingFailure {
|
|
19
|
+
rating: number;
|
|
20
|
+
context: string;
|
|
21
|
+
detailedContext?: string;
|
|
22
|
+
principle?: string;
|
|
23
|
+
responsePreview?: string;
|
|
24
|
+
userPreview?: string;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InferredPrinciple {
|
|
29
|
+
principle?: string;
|
|
30
|
+
detailedContext?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recentExchange(transcript: string): string {
|
|
34
|
+
return parseMessages(transcript)
|
|
35
|
+
.slice(-MAX_MESSAGES)
|
|
36
|
+
.map(
|
|
37
|
+
(message) =>
|
|
38
|
+
`${message.role.toUpperCase()}: ${extractContent(message).slice(0, MAX_CHARS_PER_MESSAGE)}`
|
|
39
|
+
)
|
|
40
|
+
.join("\n\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The parent may already have a principle, in which case there is nothing to ask. */
|
|
44
|
+
export function needsInference(pending: PendingFailure): boolean {
|
|
45
|
+
return !pending.principle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function principleRequest(pending: PendingFailure, recent: string) {
|
|
49
|
+
return {
|
|
50
|
+
system: `Analyze this failed AI interaction (rated ${pending.rating}/10). Return JSON: {"principle": "<verb-first actionable rule, 10-20 words — write a full sentence, not a fragment>", "detailed_context": "<root cause and what to do differently, 50-150 words>"}.`,
|
|
51
|
+
user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
|
|
52
|
+
maxTokens: 400,
|
|
53
|
+
timeout: 90_000,
|
|
54
|
+
jsonSchema: {
|
|
55
|
+
type: "object" as const,
|
|
56
|
+
properties: {
|
|
57
|
+
principle: { type: "string" as const },
|
|
58
|
+
detailed_context: { type: "string" as const },
|
|
59
|
+
},
|
|
60
|
+
required: ["principle", "detailed_context"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
caller: "failure-principle",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseInferred(output: string | null): InferredPrinciple {
|
|
68
|
+
if (!output) return {};
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(output) as {
|
|
71
|
+
principle?: string;
|
|
72
|
+
detailed_context?: string;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
principle: parsed.principle || undefined,
|
|
76
|
+
detailedContext: parsed.detailed_context || undefined,
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The two fields resolve differently on purpose. A principle the parent already
|
|
85
|
+
* had means inference never ran, so there is nothing to lose to; a detailed
|
|
86
|
+
* context it already had was written from the full session and outranks one
|
|
87
|
+
* inferred from ten messages.
|
|
88
|
+
*/
|
|
89
|
+
export function mergeInferredPrinciple(
|
|
90
|
+
pending: PendingFailure,
|
|
91
|
+
output: string | null
|
|
92
|
+
): InferredPrinciple {
|
|
93
|
+
const inferred = parseInferred(output);
|
|
94
|
+
return {
|
|
95
|
+
principle: pending.principle || inferred.principle,
|
|
96
|
+
detailedContext: pending.detailedContext ?? inferred.detailedContext,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
claimPending,
|
|
14
14
|
type LedgerEntry,
|
|
15
15
|
type LedgerOutcome,
|
|
16
|
+
type PendingSnapshot,
|
|
16
17
|
reapStalePending,
|
|
17
18
|
recordAction,
|
|
18
19
|
savePending,
|
|
@@ -245,6 +246,40 @@ export function commitApplied(call: LedgeredCall): LedgerEntry | null {
|
|
|
245
246
|
return entry;
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The snapshot is the trustworthy source, but its absence is recoverable here in
|
|
251
|
+
* a way it never is after a successful edit: nothing landed, so whatever is on
|
|
252
|
+
* disk now is still the before-state.
|
|
253
|
+
*/
|
|
254
|
+
export function unappliedBefore(
|
|
255
|
+
pending: PendingSnapshot | null,
|
|
256
|
+
target: string
|
|
257
|
+
): string | null {
|
|
258
|
+
if (pending) return pending.before;
|
|
259
|
+
return contentsOf(target);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Record a call that did not land. Unlike the applied half this writes with or
|
|
264
|
+
* without a parked snapshot, because a missing snapshot here is recoverable and
|
|
265
|
+
* dropping the entry would lose the only record that the attempt happened.
|
|
266
|
+
*/
|
|
267
|
+
export function commitUnapplied(
|
|
268
|
+
call: LedgeredCall,
|
|
269
|
+
verdict: UnappliedVerdict
|
|
270
|
+
): LedgerEntry {
|
|
271
|
+
return recordAction({
|
|
272
|
+
tool: call.tool,
|
|
273
|
+
target: call.target,
|
|
274
|
+
outcome: verdict.outcome,
|
|
275
|
+
before: unappliedBefore(claimPending(call.toolUseId), call.target),
|
|
276
|
+
// Nothing landed. That is what this event means, and it is the difference
|
|
277
|
+
// between this entry and an applied one.
|
|
278
|
+
after: null,
|
|
279
|
+
reason: verdict.reason,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
248
283
|
export function toolUseIdOf(payload: Record<string, unknown>): string | null {
|
|
249
284
|
for (const key of ["tool_use_id", "toolUseId", "tool_call_id"]) {
|
|
250
285
|
const value = payload[key];
|
package/src/hooks/lib/ledger.ts
CHANGED
|
@@ -46,8 +46,14 @@ import { isSensitivePath } from "./sensitive-path";
|
|
|
46
46
|
* lose the only signal in the record that says where the boundary was drawn,
|
|
47
47
|
* and "what did I try that was refused" is a question worth being able to ask
|
|
48
48
|
* separately from "what did I try that broke".
|
|
49
|
+
*
|
|
50
|
+
* `blocked` is a rule refusing, on the same reasoning: a person can be asked to
|
|
51
|
+
* reconsider and a rule cannot, so "PAL would not let me" and "you would not
|
|
52
|
+
* let me" are different facts about where the boundary sits. It is also the
|
|
53
|
+
* only outcome PAL itself decides, which is what makes the declare-enforce-record
|
|
54
|
+
* triad demonstrable rather than merely wired.
|
|
49
55
|
*/
|
|
50
|
-
export type LedgerOutcome = "applied" | "failed" | "denied";
|
|
56
|
+
export type LedgerOutcome = "applied" | "failed" | "denied" | "blocked";
|
|
51
57
|
|
|
52
58
|
/**
|
|
53
59
|
* One side of a change, identified rather than reproduced. The hash ties the
|
|
@@ -107,6 +113,12 @@ export interface LedgerEntry extends RecordAttribution {
|
|
|
107
113
|
delta?: LedgerDelta;
|
|
108
114
|
/** Why the action did not land. Absent on an applied one. */
|
|
109
115
|
reason?: string;
|
|
116
|
+
/**
|
|
117
|
+
* The shell command a rule refused. Only a blocked shell action carries one:
|
|
118
|
+
* it has no file to name, so without this the entry could say a command was
|
|
119
|
+
* refused but not which.
|
|
120
|
+
*/
|
|
121
|
+
command?: string;
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
export interface RecordActionInput {
|
|
@@ -121,6 +133,7 @@ export interface RecordActionInput {
|
|
|
121
133
|
/** Resulting content; null when nothing landed. */
|
|
122
134
|
after: string | null;
|
|
123
135
|
reason?: string;
|
|
136
|
+
command?: string;
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
/**
|
|
@@ -254,6 +267,7 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
|
254
267
|
after: stateOf(input.after),
|
|
255
268
|
...(delta ? { delta } : {}),
|
|
256
269
|
...(input.reason ? { reason: input.reason } : {}),
|
|
270
|
+
...(input.command ? { command: input.command } : {}),
|
|
257
271
|
};
|
|
258
272
|
|
|
259
273
|
const file = ledgerPath();
|
|
@@ -262,6 +276,39 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
|
262
276
|
return entry;
|
|
263
277
|
}
|
|
264
278
|
|
|
279
|
+
/** A refused command is quoted back, not stored whole. */
|
|
280
|
+
const MAX_COMMAND_CHARS = 500;
|
|
281
|
+
|
|
282
|
+
export interface RecordBlockedInput {
|
|
283
|
+
tool: string;
|
|
284
|
+
/** Absolute path of the file, or of the directory a refused command ran in. */
|
|
285
|
+
target: string;
|
|
286
|
+
/** The refused command, when the tool was a shell rather than an editor. */
|
|
287
|
+
command?: string;
|
|
288
|
+
/** What the rule told the agent — the same words, so both records agree. */
|
|
289
|
+
reason: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A rule refused this before it ran. Written by whoever enforces the rule, at
|
|
294
|
+
* the moment it fires, because a refusal produces no other event: nothing runs,
|
|
295
|
+
* so no post-tool hook reports it and nothing downstream can infer it happened.
|
|
296
|
+
*
|
|
297
|
+
* There is no before or after. The file is untouched, and a delta claiming
|
|
298
|
+
* otherwise would be the ledger describing a change that never occurred.
|
|
299
|
+
*/
|
|
300
|
+
export function recordBlocked(input: RecordBlockedInput): LedgerEntry {
|
|
301
|
+
return recordAction({
|
|
302
|
+
tool: input.tool,
|
|
303
|
+
target: input.target,
|
|
304
|
+
outcome: "blocked",
|
|
305
|
+
before: null,
|
|
306
|
+
after: null,
|
|
307
|
+
reason: input.reason,
|
|
308
|
+
...(input.command ? { command: input.command.slice(0, MAX_COMMAND_CHARS) } : {}),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
265
312
|
/**
|
|
266
313
|
* The before-state, held between the two halves of one tool call.
|
|
267
314
|
*
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -88,7 +88,6 @@ export const assets = {
|
|
|
88
88
|
copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
|
|
89
89
|
codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
|
|
90
90
|
codexRulesTemplate: () => pkg("assets", "templates", "rules.codex.rules"),
|
|
91
|
-
ledgerPageTemplate: () => pkg("assets", "templates", "ledger-page.html"),
|
|
92
91
|
statuslineScriptBash: () => pkg("assets", "statusline.sh"),
|
|
93
92
|
statuslineScriptPs1: () => pkg("assets", "statusline.ps1"),
|
|
94
93
|
agentTools: () => pkg("src", "tools", "agent"),
|
|
@@ -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
|
|