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.
Files changed (81) hide show
  1. package/README.md +5 -1
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/onboarding/SKILL.md +109 -0
  4. package/assets/skills/projects/SKILL.md +11 -2
  5. package/assets/templates/pal-settings.json +1 -0
  6. package/package.json +5 -1
  7. package/src/cli/index.ts +39 -12
  8. package/src/cli/migrate.ts +1 -1
  9. package/src/cli/personal-context.ts +67 -0
  10. package/src/cli/server.ts +13 -7
  11. package/src/cli/setup-identity.ts +13 -1
  12. package/src/cli/skill.ts +1 -1
  13. package/src/hooks/CompactRecover.ts +28 -86
  14. package/src/hooks/LedgerUnapplied.ts +3 -28
  15. package/src/hooks/LoadContext.ts +33 -60
  16. package/src/hooks/SecurityValidator.ts +16 -109
  17. package/src/hooks/handlers/agenda.ts +223 -0
  18. package/src/hooks/handlers/failure-principle.ts +19 -44
  19. package/src/hooks/handlers/inject-retrieval.ts +6 -2
  20. package/src/hooks/handlers/session-intelligence.ts +13 -70
  21. package/src/hooks/lib/agenda-store.ts +41 -0
  22. package/src/hooks/lib/capture-store.ts +103 -0
  23. package/src/hooks/lib/compact-recall.ts +89 -0
  24. package/src/hooks/lib/failure-principle.ts +98 -0
  25. package/src/hooks/lib/ledger-hook.ts +35 -0
  26. package/src/hooks/lib/ledger.ts +48 -1
  27. package/src/hooks/lib/paths.ts +0 -1
  28. package/src/hooks/lib/projects.ts +16 -1
  29. package/src/hooks/lib/security-gate.ts +159 -0
  30. package/src/hooks/lib/serves.ts +60 -0
  31. package/src/hooks/lib/session-context.ts +74 -0
  32. package/src/hooks/lib/stop.ts +14 -0
  33. package/src/hooks/lib/telos-goals.ts +144 -0
  34. package/src/hooks/lib/telos-topics.ts +68 -0
  35. package/src/hooks/lib/token-usage.ts +3 -1
  36. package/src/hooks/lib/wall-clock.ts +58 -0
  37. package/src/tools/agent/algorithm-reflect.ts +28 -97
  38. package/src/tools/agent/analyze.ts +19 -120
  39. package/src/tools/agent/handoff-note.ts +40 -70
  40. package/src/tools/agent/project.ts +47 -136
  41. package/src/tools/agent/relationship-note.ts +27 -46
  42. package/src/tools/agent/synthesize.ts +1 -1
  43. package/src/tools/agent/thread.ts +43 -123
  44. package/src/tools/control-room/data.ts +332 -0
  45. package/src/tools/control-room/matrix.ts +182 -0
  46. package/src/tools/control-room/server.ts +150 -0
  47. package/src/tools/control-room/ui/agenda.tsx +43 -0
  48. package/src/tools/control-room/ui/agents.tsx +67 -0
  49. package/src/tools/control-room/ui/app.css +857 -0
  50. package/src/tools/control-room/ui/app.tsx +74 -0
  51. package/src/tools/control-room/ui/board.tsx +82 -0
  52. package/src/tools/control-room/ui/format.ts +31 -0
  53. package/src/tools/control-room/ui/handoffs.tsx +37 -0
  54. package/src/tools/control-room/ui/index.html +19 -0
  55. package/src/tools/control-room/ui/ledger.tsx +137 -0
  56. package/src/tools/control-room/ui/matrix.tsx +117 -0
  57. package/src/tools/control-room/ui/panel.tsx +60 -0
  58. package/src/tools/control-room/ui/signal.tsx +161 -0
  59. package/src/tools/ledger/view.ts +3 -0
  60. package/src/tools/lib/algorithm-reflect.ts +84 -0
  61. package/src/tools/lib/analyze-report.ts +120 -0
  62. package/src/tools/lib/handoff-note.ts +88 -0
  63. package/src/tools/lib/note-flags.ts +59 -0
  64. package/src/tools/lib/project-isc.ts +151 -0
  65. package/src/tools/lib/relationship-reflect.ts +402 -0
  66. package/src/tools/lib/self-model.ts +499 -0
  67. package/src/tools/lib/session-usage.ts +216 -0
  68. package/src/tools/lib/skill-doctor.ts +457 -0
  69. package/src/tools/lib/thread.ts +119 -0
  70. package/src/tools/lib/token-report.ts +173 -0
  71. package/src/tools/lib/transcript-usage.ts +42 -0
  72. package/src/tools/lib/usage-buckets.ts +329 -0
  73. package/src/tools/relationship-reflect.ts +48 -412
  74. package/src/tools/self-model.ts +76 -558
  75. package/src/tools/session-summary.ts +8 -215
  76. package/src/tools/skill-doctor.ts +9 -444
  77. package/src/tools/token-cost.ts +18 -428
  78. package/assets/templates/ledger-page.html +0 -213
  79. package/src/cli/setup-telos.ts +0 -52
  80. package/src/hooks/lib/setup.ts +0 -60
  81. package/src/tools/ledger/server.ts +0 -111
@@ -9,9 +9,15 @@
9
9
  *
10
10
  */
11
11
 
12
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
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
- async function captureSessionIntelligence(
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
- // Dedup check
135
- if (sessionId) {
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 = slugify(title);
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 = getPreviousCapture(sessionId);
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];
@@ -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
  *
@@ -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