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
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The gate's decision, separated from the process that carries it out.
3
+ *
4
+ * SecurityValidator is spawned, so nothing can import it and nothing measures
5
+ * it — every rule about which tool names run a shell, which argument spells the
6
+ * path, and what the agent is told was unreachable from a test. The decision
7
+ * lives here instead; the entrypoint is left with stdin, stdout and the ledger.
8
+ */
9
+
10
+ import { normalizeToolUse } from "./agent";
11
+ import { logDebug } from "./log";
12
+ import { checkBashCommand, checkFilePath } from "./security";
13
+
14
+ /** beforeShellExecution (Cursor only) — flat, no tool-name wrapper. */
15
+ interface ShellExecInput {
16
+ command: string;
17
+ sandbox?: boolean;
18
+ }
19
+
20
+ export type SecurityInput = Record<string, unknown> | ShellExecInput;
21
+
22
+ export interface GateRefusal {
23
+ tool: string;
24
+ /** The file, or the directory a refused command would have run in. */
25
+ target: string;
26
+ /** Set only when a shell was refused: it has no file to name. */
27
+ command?: string;
28
+ /** Why, for the ledger. */
29
+ reason: string;
30
+ /** Why, in the words the agent is given — the two differ only by framing. */
31
+ message: string;
32
+ hookEventName?: string;
33
+ }
34
+
35
+ // A name this list misses is a command this hook waves through, so both sets mirror
36
+ // the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
37
+ const SHELL_TOOLS = [
38
+ "bash",
39
+ "shell",
40
+ "powershell",
41
+ "local_shell",
42
+ "runinterminal",
43
+ "run_in_terminal",
44
+ "terminal",
45
+ "execute_command",
46
+ ];
47
+
48
+ const FILE_WRITE_TOOLS = [
49
+ "write",
50
+ "edit",
51
+ "multiedit",
52
+ "write_file",
53
+ "apply_patch",
54
+ "applypatch",
55
+ "create",
56
+ "create_file",
57
+ "createfile",
58
+ "str_replace",
59
+ "str_replace_editor",
60
+ "insert",
61
+ "insert_edit_into_file",
62
+ "replace_string_in_file",
63
+ "multi_replace_string_in_file",
64
+ "replacestring",
65
+ "edit_notebook_file",
66
+ "notebookedit",
67
+ ];
68
+
69
+ function isShellExec(input: SecurityInput): input is ShellExecInput {
70
+ return !("tool_name" in input) && !("toolName" in input) && "command" in input;
71
+ }
72
+
73
+ /** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
74
+ function firstStringArg(
75
+ args: Record<string, unknown>,
76
+ keys: string[]
77
+ ): string | undefined {
78
+ for (const key of keys) {
79
+ const value = args[key];
80
+ if (typeof value === "string" && value.length > 0) return value;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ /** Tool names that run a shell command, across every agent's naming. */
86
+ function runsShellCommand(toolName: string): boolean {
87
+ return SHELL_TOOLS.includes(toolName.toLowerCase());
88
+ }
89
+
90
+ /** Tool names that write to a file, across every agent's naming. */
91
+ function writesFile(toolName: string): boolean {
92
+ return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
93
+ }
94
+
95
+ /**
96
+ * What the gate would do with this call, or null to let it through.
97
+ *
98
+ * `cwd` is passed rather than read so a test can pin the target of a refused
99
+ * command, which has no file of its own to name.
100
+ */
101
+ export function decideRefusal(input: SecurityInput, cwd: string): GateRefusal | null {
102
+ if (isShellExec(input)) {
103
+ const reason = checkBashCommand(input.command);
104
+ if (!reason) return null;
105
+ return {
106
+ tool: "shell",
107
+ target: cwd,
108
+ command: input.command,
109
+ reason,
110
+ message: `Blocked: ${reason}`,
111
+ };
112
+ }
113
+
114
+ const toolUse = normalizeToolUse(input);
115
+ if (!toolUse) return null;
116
+
117
+ // Each agent names its shell/write tools differently; log the real name so an
118
+ // unrecognized one shows up here instead of silently skipping the check.
119
+ logDebug(
120
+ "SecurityValidator",
121
+ `toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
122
+ );
123
+
124
+ const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
125
+ if (runsShellCommand(toolUse.toolName) && command) {
126
+ const reason = checkBashCommand(command);
127
+ // "No output" from a downstream tool is indistinguishable between "denied,
128
+ // never ran" and "ran, produced nothing" — logging the verdict here, next
129
+ // to the literal command, is what actually tells the two apart.
130
+ const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
131
+ logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
132
+ if (reason) {
133
+ return {
134
+ tool: toolUse.toolName,
135
+ target: cwd,
136
+ command,
137
+ reason,
138
+ message: `Blocked: ${reason}`,
139
+ hookEventName: toolUse.hookEventName,
140
+ };
141
+ }
142
+ }
143
+
144
+ const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
145
+ if (writesFile(toolUse.toolName) && filePath) {
146
+ const reason = checkFilePath(filePath);
147
+ if (reason) {
148
+ return {
149
+ tool: toolUse.toolName,
150
+ target: filePath,
151
+ reason,
152
+ message: reason,
153
+ hookEventName: toolUse.hookEventName,
154
+ };
155
+ }
156
+ }
157
+
158
+ return null;
159
+ }
@@ -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
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What each agent is handed at session start, and in which envelope.
3
+ *
4
+ * The four runtimes disagree on all of it: whether AGENTS.md is already loaded,
5
+ * which JSON key carries injected context, and whether it is read from stdout at
6
+ * all. This has been wrong in production before — Copilot silently received no
7
+ * context for weeks — and none of it was reachable from a test.
8
+ */
9
+
10
+ export interface ContextEnvelope {
11
+ /** Whether the agent parses stdout as JSON or reads it as raw text. */
12
+ kind: "json" | "text";
13
+ payload: string;
14
+ /** Copilot only: the same context, also written where its extension reads. */
15
+ file?: string;
16
+ }
17
+
18
+ /**
19
+ * A subagent gets none of this. Its parent already carries the context, and
20
+ * paying for it again on every spawn is the whole cost of a cheap subagent.
21
+ */
22
+ export function isSubagentSession(env: NodeJS.ProcessEnv): boolean {
23
+ if (env.CLAUDE_AGENT_TYPE !== undefined) return true;
24
+ return env.CLAUDE_PROJECT_DIR?.includes("/.claude/Agents/") ?? false;
25
+ }
26
+
27
+ /**
28
+ * Copilot and Cursor read AGENTS.md natively from nothing, so it is prepended
29
+ * here; Codex reaches it through a symlink and Claude Code loads it itself.
30
+ */
31
+ export function needsAgentsMd(agent: string): boolean {
32
+ return agent === "copilot" || agent === "cursor";
33
+ }
34
+
35
+ /**
36
+ * Copilot also writes the same text to a file, because its CLI reads stdout
37
+ * while the VS Code extension reads only ~/.copilot/instructions/.
38
+ */
39
+ export function contextEnvelope(
40
+ agent: string,
41
+ reminder: string,
42
+ agentsMd: string
43
+ ): ContextEnvelope | null {
44
+ const merged = needsAgentsMd(agent)
45
+ ? [agentsMd, reminder].filter(Boolean).join("\n\n")
46
+ : reminder;
47
+ if (!merged) return null;
48
+
49
+ if (agent === "copilot") {
50
+ return {
51
+ kind: "json",
52
+ payload: JSON.stringify({ additionalContext: merged }),
53
+ file: merged,
54
+ };
55
+ }
56
+ if (agent === "cursor") {
57
+ return { kind: "json", payload: JSON.stringify({ additional_context: merged }) };
58
+ }
59
+ if (agent === "codex") {
60
+ return {
61
+ kind: "json",
62
+ payload: JSON.stringify({
63
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: merged },
64
+ }),
65
+ };
66
+ }
67
+ // Claude Code, and opencode which uses the plugin path rather than this hook.
68
+ return { kind: "text", payload: merged };
69
+ }
70
+
71
+ /** The file Copilot's VS Code extension reads, with the applyTo header it needs. */
72
+ export function copilotInstructions(context: string): string {
73
+ return `---\napplyTo: "**"\n---\n\n${context}`;
74
+ }
@@ -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
+ }
@@ -14,7 +14,9 @@ type TokenCaller =
14
14
  | "session-name"
15
15
  | "session-intelligence"
16
16
  | "relationship"
17
- | "self-model";
17
+ | "self-model"
18
+ | "agenda-serves"
19
+ | "agenda-moves";
18
20
 
19
21
  interface TokenUsageEntry {
20
22
  ts: string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one fact that goes stale inside a session: the time.
3
+ *
4
+ * The session-start reminder stamps the clock once, so a conversation picked up
5
+ * the next morning still believes it is yesterday. This line rides along with
6
+ * every prompt instead, in the principal's own timezone, because "is it morning"
7
+ * is the question the agent keeps getting wrong.
8
+ */
9
+
10
+ import { identity, isEnabled } from "./settings";
11
+
12
+ const FIELDS = {
13
+ weekday: "short",
14
+ year: "numeric",
15
+ month: "2-digit",
16
+ day: "2-digit",
17
+ hour: "2-digit",
18
+ minute: "2-digit",
19
+ hourCycle: "h23",
20
+ } as const;
21
+
22
+ function fieldsIn(now: Date, timeZone: string): Record<string, string> {
23
+ const parts = new Intl.DateTimeFormat("en-US", { ...FIELDS, timeZone }).formatToParts(
24
+ now
25
+ );
26
+ return Object.fromEntries(parts.map((p) => [p.type, p.value]));
27
+ }
28
+
29
+ /** The IANA name Intl settles on, or null when it does not recognise the input. */
30
+ export function canonicalTimeZone(timeZone: string): string | null {
31
+ try {
32
+ return new Intl.DateTimeFormat("en-US", { timeZone }).resolvedOptions().timeZone;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function isValidTimeZone(timeZone: string): boolean {
39
+ return canonicalTimeZone(timeZone) !== null;
40
+ }
41
+
42
+ /** A configured timezone is user input, and Intl throws on a bad one. UTC is always true. */
43
+ function zoneOrUtc(timeZone: string): string {
44
+ return timeZone && isValidTimeZone(timeZone) ? timeZone : "UTC";
45
+ }
46
+
47
+ /** @lintignore exercised directly by test/wall-clock.test.ts */
48
+ export function wallClockLine(now: Date, configuredZone: string): string {
49
+ const zone = zoneOrUtc(configuredZone);
50
+ const f = fieldsIn(now, zone);
51
+ return `Now: ${f.weekday} ${f.year}-${f.month}-${f.day} ${f.hour}:${f.minute} ${zone}`;
52
+ }
53
+
54
+ export function getWallClockReminder(now: Date = new Date()): string | null {
55
+ if (!isEnabled("wallClock")) return null;
56
+ const line = wallClockLine(now, identity().principal.timezone);
57
+ return `<system-reminder>${line}</system-reminder>`;
58
+ }