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
@@ -20,27 +20,11 @@
20
20
  * Silent and fail-open, for the same reason as the other halves.
21
21
  */
22
22
 
23
- import { existsSync, readFileSync } from "node:fs";
24
- import {
25
- claimPending,
26
- type PendingSnapshot,
27
- reapStalePending,
28
- recordAction,
29
- } from "./lib/ledger";
30
- import { ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
23
+ import { reapStalePending } from "./lib/ledger";
24
+ import { commitUnapplied, ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
31
25
  import { logDebug } from "./lib/log";
32
26
  import { readStdinJSON } from "./lib/stdin";
33
27
 
34
- /**
35
- * The snapshot is the trustworthy source, but its absence is recoverable here
36
- * in a way it never is after a successful edit: nothing landed, so whatever is
37
- * on disk now is still the before-state.
38
- */
39
- function beforeState(pending: PendingSnapshot | null, target: string): string | null {
40
- if (pending) return pending.before;
41
- return existsSync(target) ? readFileSync(target, "utf-8") : null;
42
- }
43
-
44
28
  try {
45
29
  const input = await readStdinJSON<Record<string, unknown>>();
46
30
  if (!input) process.exit(0);
@@ -50,16 +34,7 @@ try {
50
34
  if (!verdict) process.exit(0);
51
35
 
52
36
  for (const call of calls) {
53
- const entry = recordAction({
54
- tool: call.tool,
55
- target: call.target,
56
- outcome: verdict.outcome,
57
- before: beforeState(claimPending(call.toolUseId), call.target),
58
- // Nothing landed. That is what this event means, and it is the difference
59
- // between this entry and an applied one.
60
- after: null,
61
- reason: verdict.reason,
62
- });
37
+ const entry = commitUnapplied(call, verdict);
63
38
  logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
64
39
  }
65
40
 
@@ -1,97 +1,70 @@
1
1
  /**
2
2
  * Hook: SessionStart — Injects dynamic context + regenerates AGENTS.md if stale.
3
3
  *
4
- * Static context (TELOS, setup prompt) is loaded natively from AGENTS.md / CLAUDE.md.
5
- * This hook injects dynamic context only: wisdom principles, relationship notes,
6
- * learning digest, signal trends, failure patterns, active work state.
4
+ * Static context (TELOS, setup prompt) is loaded natively from AGENTS.md /
5
+ * CLAUDE.md. This hook injects dynamic context only: wisdom principles,
6
+ * relationship notes, learning digest, signal trends, failure patterns, work state.
7
7
  *
8
- * Copilot: the CLI reads additionalContext from this hook's stdout, while
9
- * ~/.copilot/instructions/ is read only by the VS Code extension. The merged
10
- * context goes to both so either surface picks it up.
8
+ * Which agent gets what, in which envelope, is in lib/session-context.ts.
11
9
  */
12
10
 
13
11
  import { mkdirSync, writeFileSync } from "node:fs";
14
12
  import { resolve } from "node:path";
15
- import { getActiveAgent, isCodex, isCopilot, isCursor } from "./lib/agent";
13
+ import { getActiveAgent } from "./lib/agent";
16
14
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
17
15
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
18
16
  import { logContextSnapshot, logDebug, logError } from "./lib/log";
19
17
  import { platform } from "./lib/paths";
18
+ import {
19
+ contextEnvelope,
20
+ copilotInstructions,
21
+ isSubagentSession,
22
+ needsAgentsMd,
23
+ } from "./lib/session-context";
20
24
  import { isPalSpawnedInference } from "./lib/spawn-guard";
21
25
 
22
26
  // Recursion guard — when this process is a PAL-spawned inference subprocess,
23
27
  // skip all context loading so we don't trigger another inference call.
24
28
  if (isPalSpawnedInference()) process.exit(0);
25
29
 
26
- // --- Skip heavy context for subagents ---
27
- const isSubagent =
28
- process.env.CLAUDE_PROJECT_DIR?.includes("/.claude/Agents/") ||
29
- process.env.CLAUDE_AGENT_TYPE !== undefined;
30
-
31
- if (isSubagent) {
30
+ if (isSubagentSession(process.env)) {
32
31
  logDebug("LoadContext", "Subagent session — skipping context loading");
33
32
  process.exit(0);
34
33
  }
35
34
 
36
- // --- Regenerate CLAUDE.md if telos or setup changed ---
37
35
  try {
38
- const rebuilt = regenerateIfNeeded();
39
- if (rebuilt) logDebug("LoadContext", "AGENTS.md regenerated");
36
+ if (regenerateIfNeeded()) logDebug("LoadContext", "AGENTS.md regenerated");
40
37
  } catch (err) {
41
38
  logError("LoadContext:regenerate", err);
42
39
  }
43
40
 
44
- // --- Context to stdout (or file for Copilot) ---
45
41
  try {
46
- // Determine agent target — controls which sections are skipped (loaded natively instead).
47
42
  const active = getActiveAgent();
48
- const agent: AgentTarget =
43
+ // The reminder is built for one of three targets; every other agent reads the
44
+ // same shape Claude Code does.
45
+ const target: AgentTarget =
49
46
  active === "copilot" || active === "cursor" ? active : "claude";
50
- const reminder = buildSystemReminder({ agent });
47
+ const reminder = buildSystemReminder({ agent: target });
51
48
  if (!reminder) process.exit(0);
52
49
  logContextSnapshot(reminder);
53
50
 
54
- if (isCopilot()) {
55
- // Copilot: semi-static in ~/.copilot/instructions/pal-*.instructions.md (written at stop).
56
- // Write AGENTS.md + dynamic context to pal-session.instructions.md on each session start.
57
- const instructionsDir = resolve(platform.copilotDir(), "instructions");
58
- mkdirSync(instructionsDir, { recursive: true });
59
- const agentsMd = buildClaudeMd();
60
- const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
61
- if (context) {
62
- writeFileSync(
63
- resolve(instructionsDir, "pal-session.instructions.md"),
64
- `---\napplyTo: "**"\n---\n\n${context}`,
65
- "utf-8"
66
- );
67
- process.stdout.write(JSON.stringify({ additionalContext: context }));
68
- }
69
- logDebug(
70
- "LoadContext",
71
- `Copilot session instructions written: ${context.length} chars`
72
- );
73
- } else if (isCursor()) {
74
- // Cursor: semi-static in ~/.cursor/rules/pal-context.mdc; inject AGENTS.md + dynamic here
75
- const agentsMd = buildClaudeMd();
76
- const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
77
- process.stdout.write(JSON.stringify({ additional_context: context }));
78
- logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
79
- } else if (isCodex()) {
80
- // Codex: AGENTS.md already loaded via symlink; inject only dynamic context
81
- process.stdout.write(
82
- JSON.stringify({
83
- hookSpecificOutput: {
84
- hookEventName: "SessionStart",
85
- additionalContext: reminder,
86
- },
87
- })
88
- );
89
- logDebug("LoadContext", `Codex reminder injected: ${reminder.length} chars`);
90
- } else {
91
- // Claude Code (and opencode, which uses the plugin path not this hook): raw text
92
- console.log(reminder);
93
- logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
51
+ const envelope = contextEnvelope(
52
+ active,
53
+ reminder,
54
+ needsAgentsMd(active) ? buildClaudeMd() : ""
55
+ );
56
+ if (!envelope) process.exit(0);
57
+
58
+ if (envelope.file) {
59
+ const dir = resolve(platform.copilotDir(), "instructions");
60
+ mkdirSync(dir, { recursive: true });
61
+ const path = resolve(dir, "pal-session.instructions.md");
62
+ writeFileSync(path, copilotInstructions(envelope.file), "utf-8");
94
63
  }
64
+
65
+ if (envelope.kind === "text") console.log(envelope.payload);
66
+ else process.stdout.write(envelope.payload);
67
+ logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
95
68
  } catch (err) {
96
69
  logError("LoadContext:reminder", err);
97
70
  }
@@ -3,125 +3,32 @@
3
3
  * Emits the current agent's deny response to block, or exits silently to allow.
4
4
  *
5
5
  * Fail-open design: if anything goes wrong, the command is allowed through.
6
+ *
7
+ * The decision itself is in lib/security-gate.ts, where a test can import it.
6
8
  */
7
9
 
8
- import { blockResponse, normalizeToolUse } from "./lib/agent";
9
- import { logDebug } from "./lib/log";
10
- import { checkBashCommand, checkFilePath } from "./lib/security";
10
+ import { blockResponse } from "./lib/agent";
11
+ import { recordBlocked } from "./lib/ledger";
12
+ import { logError } from "./lib/log";
13
+ import { decideRefusal, type SecurityInput } from "./lib/security-gate";
11
14
  import { readStdinJSON } from "./lib/stdin";
12
15
 
13
- // beforeShellExecution shape (Cursor only) — flat, no tool-name wrapper
14
- interface ShellExecInput {
15
- command: string;
16
- sandbox?: boolean;
17
- }
18
-
19
- type SecurityInput = Record<string, unknown> | ShellExecInput;
20
-
21
- function isShellExec(input: SecurityInput): input is ShellExecInput {
22
- return !("tool_name" in input) && !("toolName" in input) && "command" in input;
23
- }
24
-
25
- // A name this list misses is a command this hook waves through, so both sets mirror
26
- // the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
27
- const SHELL_TOOLS = [
28
- "bash",
29
- "shell",
30
- "powershell",
31
- "local_shell",
32
- "runinterminal",
33
- "run_in_terminal",
34
- "terminal",
35
- "execute_command",
36
- ];
37
-
38
- const FILE_WRITE_TOOLS = [
39
- "write",
40
- "edit",
41
- "multiedit",
42
- "write_file",
43
- "apply_patch",
44
- "applypatch",
45
- "create",
46
- "create_file",
47
- "createfile",
48
- "str_replace",
49
- "str_replace_editor",
50
- "insert",
51
- "insert_edit_into_file",
52
- "replace_string_in_file",
53
- "multi_replace_string_in_file",
54
- "replacestring",
55
- "edit_notebook_file",
56
- "notebookedit",
57
- ];
58
-
59
- /** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
60
- function firstStringArg(
61
- args: Record<string, unknown>,
62
- keys: string[]
63
- ): string | undefined {
64
- for (const key of keys) {
65
- const value = args[key];
66
- if (typeof value === "string" && value.length > 0) return value;
67
- }
68
- return undefined;
69
- }
70
-
71
- /** Tool names that run a shell command, across every agent's naming. */
72
- function runsShellCommand(toolName: string): boolean {
73
- return SHELL_TOOLS.includes(toolName.toLowerCase());
74
- }
75
-
76
- /** Tool names that write to a file, across every agent's naming. */
77
- function writesFile(toolName: string): boolean {
78
- return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
79
- }
80
-
81
16
  try {
82
17
  const input = await readStdinJSON<SecurityInput>();
83
18
  if (!input) process.exit(0);
84
19
 
85
- if (isShellExec(input)) {
86
- // beforeShellExecution — command is always a shell command
87
- const reason = checkBashCommand(input.command);
88
- if (reason) {
89
- process.stdout.write(blockResponse(`Blocked: ${reason}`));
90
- }
91
- process.exit(0);
92
- }
93
-
94
- const toolUse = normalizeToolUse(input);
95
- if (!toolUse) process.exit(0);
20
+ const refusal = decideRefusal(input, process.cwd());
21
+ if (!refusal) process.exit(0);
96
22
 
97
- // Each agent names its shell/write tools differently; log the real name so an
98
- // unrecognized one shows up here instead of silently skipping the check.
99
- logDebug(
100
- "SecurityValidator",
101
- `toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
102
- );
103
-
104
- const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
105
- if (runsShellCommand(toolUse.toolName) && typeof command === "string") {
106
- const reason = checkBashCommand(command);
107
- const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
108
- // "No output" from a downstream tool is indistinguishable between "denied,
109
- // never ran" and "ran, produced nothing" — logging the verdict here, next
110
- // to the literal command, is what actually tells the two apart.
111
- logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
112
- if (reason) {
113
- process.stdout.write(blockResponse(`Blocked: ${reason}`, toolUse.hookEventName));
114
- process.exit(0);
115
- }
116
- }
23
+ process.stdout.write(blockResponse(refusal.message, refusal.hookEventName));
117
24
 
118
- const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
119
- if (writesFile(toolUse.toolName) && typeof filePath === "string") {
120
- const reason = checkFilePath(filePath);
121
- if (reason) {
122
- process.stdout.write(blockResponse(reason, toolUse.hookEventName));
123
- process.exit(0);
124
- }
25
+ // After the deny has gone out, in its own try/catch: this hook is fail-open,
26
+ // and a ledger that threw on its way to recording a block would turn the
27
+ // block into an allow.
28
+ try {
29
+ recordBlocked(refusal);
30
+ } catch (err) {
31
+ logError("SecurityValidator:ledger", err);
125
32
  }
126
33
  } catch {
127
34
  // Fail open
@@ -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
+ }
@@ -11,24 +11,22 @@
11
11
 
12
12
  import { existsSync } from "node:fs";
13
13
  import { readFile, unlink } from "node:fs/promises";
14
- import { extractContent, parseMessages } from "../lib/transcript";
14
+ import {
15
+ mergeInferredPrinciple,
16
+ needsInference,
17
+ type PendingFailure,
18
+ principleRequest,
19
+ recentExchange,
20
+ } from "../lib/failure-principle";
15
21
  import { captureFailure } from "./failure";
16
22
 
17
- interface PendingFailure {
18
- rating: number;
19
- context: string;
20
- detailedContext?: string;
21
- principle?: string;
22
- responsePreview?: string;
23
- userPreview?: string;
24
- cwd?: string;
25
- }
26
-
27
23
  /**
28
24
  * Inference the principle (if missing) and persist the failure record.
29
25
  * Reads pending data + transcript from the provided tmp paths and unlinks them.
26
+ *
27
+ * @lintignore exercised directly by test/failure-principle.test.ts
30
28
  */
31
- async function processFailurePrinciple(
29
+ export async function processFailurePrinciple(
32
30
  pendingPath: string,
33
31
  transcriptPath: string
34
32
  ): Promise<void> {
@@ -47,42 +45,19 @@ async function processFailurePrinciple(
47
45
  logDebug("failure-principle", `processing rating=${pending.rating}`);
48
46
 
49
47
  let { principle, detailedContext } = pending;
50
- if (!principle) {
48
+ if (needsInference(pending)) {
51
49
  try {
52
50
  const { inference } = await import("../lib/inference");
53
- const msgs = parseMessages(transcript);
54
- const recent = msgs
55
- .slice(-10)
56
- .map((m) => `${m.role.toUpperCase()}: ${extractContent(m).slice(0, 300)}`)
57
- .join("\n\n");
58
-
59
- const result = await inference({
60
- 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>"}.`,
61
- user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
62
- maxTokens: 400,
63
- timeout: 90000,
64
- jsonSchema: {
65
- type: "object" as const,
66
- properties: {
67
- principle: { type: "string" as const },
68
- detailed_context: { type: "string" as const },
69
- },
70
- required: ["principle", "detailed_context"],
71
- additionalProperties: false,
72
- },
73
- caller: "failure-principle",
74
- });
75
-
76
- if (result.success && result.output) {
77
- const parsed = JSON.parse(result.output) as {
78
- principle?: string;
79
- detailed_context?: string;
80
- };
81
- principle = parsed.principle || undefined;
82
- detailedContext ??= parsed.detailed_context || undefined;
83
- } else {
51
+ const result = await inference(
52
+ principleRequest(pending, recentExchange(transcript))
53
+ );
54
+ if (!result.success || !result.output) {
84
55
  logError("failure-principle", `inference failed (no output)`);
85
56
  }
57
+ ({ principle, detailedContext } = mergeInferredPrinciple(
58
+ pending,
59
+ result.output ?? null
60
+ ));
86
61
  } catch (err) {
87
62
  logError("failure-principle:inference", err);
88
63
  }
@@ -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 matches, prior-lesson
83
- * retrieval — into one payload, or null when none of them produced anything.
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),