portable-agent-layer 0.76.1 → 0.77.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 CHANGED
@@ -77,7 +77,7 @@ pal cli status # check your setup
77
77
  | `pal cli init` | Scaffold PAL home directory and install hooks |
78
78
  | `pal cli install` | Register hooks/skills for targets |
79
79
  | `pal cli uninstall` | Remove hooks/skills for targets |
80
- | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks |
80
+ | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this daily on its own; the switch lives in the control room under Settings → Updates. On a repo install a daily run waits while the clone has uncommitted changes |
81
81
  | `pal cli export` | Export user state (telos, memory) to a zip |
82
82
  | `pal cli import` | Import user state from a zip |
83
83
  | `pal cli status` | Show current PAL configuration |
@@ -28,9 +28,9 @@ interface ReportMeta {
28
28
  reportTitle: string;
29
29
  classification: string;
30
30
  consultancyName: string;
31
- /** Public path to consultancy logo (e.g. "/logos/konvert7.svg"). Used in the PDF footer. */
31
+ /** Public path to consultancy logo (e.g. "/logos/consultancy.svg"). Used in the PDF footer. */
32
32
  consultancyLogoSrc?: string;
33
- /** Public path to client logo (e.g. "/logos/transcend.svg"). Used in the PDF header. */
33
+ /** Public path to client logo (e.g. "/logos/client.svg"). Used in the PDF header. */
34
34
  clientLogoSrc?: string;
35
35
  }
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.76.1",
3
+ "version": "0.77.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -1245,10 +1245,12 @@ async function install(targets: Targets) {
1245
1245
  await import("../targets/lib");
1246
1246
  const { promptIdentity } = await import("./setup-identity");
1247
1247
  const { promptAttribution } = await import("./setup-attribution");
1248
+ const { promptAutoUpdate } = await import("./setup-auto-update");
1248
1249
  scaffoldTelos();
1249
1250
  scaffoldPalSettings();
1250
1251
  await promptIdentity();
1251
1252
  await promptAttribution();
1253
+ await promptAutoUpdate();
1252
1254
  pointAtOnboarding();
1253
1255
 
1254
1256
  // Registers the label loadActor derives, so it travels on the next export.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Daily unattended updates — one-time opt-in prompt.
3
+ *
4
+ * Asked once during `pal install`, the same seam git attribution uses, so a new
5
+ * user sees it at init and an existing one on their next update. The non-TTY
6
+ * guard is load-bearing twice over: it keeps CI silent, and it keeps the
7
+ * reinstall at the end of an unattended update from waiting on an answer nobody
8
+ * is there to give.
9
+ */
10
+
11
+ import * as clack from "@clack/prompts";
12
+ import { isRepoMode } from "../hooks/handlers/update-check";
13
+ import { raw as readSettings, write as writeSettings } from "../hooks/lib/settings";
14
+
15
+ /** Only a git clone can be mid-change; a global package install has no such state. */
16
+ function whatItDoes(): string {
17
+ const daily = "Once a day, at session start, PAL updates itself in the background.";
18
+ return isRepoMode()
19
+ ? `${daily}\nIt waits while this clone has uncommitted changes.`
20
+ : daily;
21
+ }
22
+
23
+ export async function promptAutoUpdate(): Promise<void> {
24
+ if (!process.stdin.isTTY) return;
25
+
26
+ const settings = { ...readSettings() };
27
+ if (settings.autoUpdate?.decided) return;
28
+
29
+ clack.intro("Automatic updates");
30
+ clack.note(whatItDoes(), "Keep PAL up to date on its own?");
31
+
32
+ const enabled = await clack.confirm({
33
+ message: "Turn on daily automatic updates?",
34
+ initialValue: false,
35
+ });
36
+ if (clack.isCancel(enabled)) {
37
+ clack.cancel("Skipped — will ask again next time");
38
+ return;
39
+ }
40
+
41
+ settings.autoUpdate = { enabled: enabled === true, decided: true };
42
+ writeSettings(settings);
43
+ const state = enabled ? "Daily updates on" : "Daily updates off";
44
+ clack.outro(`${state} ✓ · change later: control room → Settings → Updates`);
45
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Spawned, never awaited — runs the daily self-update in a process of its own.
3
+ *
4
+ * LoadContext decides whether today's update is due; the control room's button
5
+ * starts this directly. Either way the work happens here so nothing waits on a
6
+ * git pull and a reinstall.
7
+ */
8
+
9
+ import { runAutoUpdate } from "./lib/auto-update";
10
+ import { logError } from "./lib/log";
11
+
12
+ try {
13
+ runAutoUpdate();
14
+ } catch (err) {
15
+ logError("AutoUpdate", err);
16
+ }
@@ -11,6 +11,7 @@
11
11
  import { mkdirSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { getActiveAgent } from "./lib/agent";
14
+ import { shouldAutoUpdate, spawnAutoUpdate } from "./lib/auto-update";
14
15
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
15
16
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
16
17
  import { logContextSnapshot, logDebug, logError } from "./lib/log";
@@ -38,6 +39,12 @@ try {
38
39
  logError("LoadContext:regenerate", err);
39
40
  }
40
41
 
42
+ try {
43
+ if (shouldAutoUpdate()) spawnAutoUpdate();
44
+ } catch (err) {
45
+ logError("LoadContext:auto-update", err);
46
+ }
47
+
41
48
  try {
42
49
  const active = getActiveAgent();
43
50
  // The reminder is built for one of three targets; every other agent reads the
@@ -343,7 +343,7 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
343
343
  "You write the first three lines a person reads in the morning.",
344
344
  "You are given their goals and a ranked list of their projects and goals with the reason each was ranked.",
345
345
  "Write exactly three moves, most consequential first.",
346
- "A move is a sentence naming an action, not a project name: 'Send ACE the mapping one-pager' beats 'work on ontology'.",
346
+ "A move is a sentence naming an action, not a project name: 'Send the supplier the revised quote' beats 'work on billing'.",
347
347
  "Prefer what is blocked on the person themselves, then what serves a goal, then what is merely urgent.",
348
348
  "Name the project each move belongs to, copying its slug exactly from the list you were given. A move that belongs to no project takes an empty string.",
349
349
  "Never invent a fact that is not in what you were given.",
@@ -12,7 +12,7 @@ import { resolve } from "node:path";
12
12
  import { logDebug } from "../lib/log";
13
13
  import { ensureDir, palPkg, paths } from "../lib/paths";
14
14
 
15
- interface UpdateCache {
15
+ export interface UpdateCache {
16
16
  checkedAt: string;
17
17
  available: boolean;
18
18
  current: string;
@@ -26,18 +26,24 @@ function cachePath(): string {
26
26
  return resolve(ensureDir(paths.state()), "update-available.json");
27
27
  }
28
28
 
29
- function readCache(): UpdateCache | null {
29
+ /** The last check's result whatever its age — the notice and the page read this, not the TTL. */
30
+ export function cachedStatus(): UpdateCache | null {
30
31
  try {
31
32
  const fp = cachePath();
32
33
  if (!existsSync(fp)) return null;
33
- const cache = JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
34
- if (Date.now() - new Date(cache.checkedAt).getTime() < CACHE_TTL_MS) return cache;
35
- return null; // expired
34
+ return JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
36
35
  } catch {
37
36
  return null;
38
37
  }
39
38
  }
40
39
 
40
+ function readCache(): UpdateCache | null {
41
+ const cache = cachedStatus();
42
+ if (!cache) return null;
43
+ const fresh = Date.now() - new Date(cache.checkedAt).getTime() < CACHE_TTL_MS;
44
+ return fresh ? cache : null;
45
+ }
46
+
41
47
  function writeCache(cache: UpdateCache): void {
42
48
  try {
43
49
  writeFileSync(cachePath(), JSON.stringify(cache, null, 2), "utf-8");
@@ -50,7 +56,7 @@ export function isRepoMode(): boolean {
50
56
  return existsSync(resolve(palPkg(), ".git"));
51
57
  }
52
58
 
53
- function getInstalledVersion(): string {
59
+ export function getInstalledVersion(): string {
54
60
  try {
55
61
  const pkg = JSON.parse(readFileSync(resolve(palPkg(), "package.json"), "utf-8"));
56
62
  return pkg.version || "0.0.0";
@@ -193,14 +199,7 @@ export function clearUpdateCache(): void {
193
199
 
194
200
  /** Read cached update status for greeting display. Returns null if no update. */
195
201
  export function getUpdateNotice(): string | null {
196
- try {
197
- const fp = cachePath();
198
- if (!existsSync(fp)) return null;
199
- const cache = JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
200
- if (!cache.available) return null;
201
-
202
- return `📦 Update available: ${cache.current} → ${cache.latest} (pal cli update)`;
203
- } catch {
204
- return null;
205
- }
202
+ const cache = cachedStatus();
203
+ if (!cache?.available) return null;
204
+ return `📦 Update available: ${cache.current} → ${cache.latest} (pal cli update)`;
206
205
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The daily self-update: when it may run unattended, and what came of it.
3
+ *
4
+ * `pal cli update` still owns the pull and the reinstall — this module only
5
+ * decides whether to start it and records the outcome, so the command shape
6
+ * lives in exactly one place.
7
+ *
8
+ * The ledger in state/auto-update.json is deliberately not update-available.json:
9
+ * that one is an hourly detection cache, this one is the record of what was
10
+ * attempted. A skip is stamped separately from an attempt so that skipping today
11
+ * never consumes today's attempt.
12
+ */
13
+
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { cachedStatus, getInstalledVersion, isRepoMode } from "../handlers/update-check";
18
+ import { logDebug, logError } from "./log";
19
+ import { assets, palPkg, paths } from "./paths";
20
+ import { raw as rawSettings } from "./settings";
21
+
22
+ export interface AutoUpdateLedger {
23
+ attemptedAt?: string;
24
+ finishedAt?: string;
25
+ ok?: boolean;
26
+ from?: string;
27
+ to?: string;
28
+ error?: string;
29
+ skippedAt?: string;
30
+ skipped?: string;
31
+ }
32
+
33
+ export interface AutoUpdateStatus {
34
+ enabled: boolean;
35
+ decided: boolean;
36
+ current: string;
37
+ latest: string | null;
38
+ available: boolean;
39
+ checkedAt: string | null;
40
+ mode: "repo" | "package";
41
+ last: AutoUpdateLedger | null;
42
+ }
43
+
44
+ const DAY_MS = 24 * 60 * 60 * 1000;
45
+ const DIRTY_TREE = "uncommitted changes in the PAL repo";
46
+
47
+ function ledgerPath(): string {
48
+ return resolve(paths.state(), "auto-update.json");
49
+ }
50
+
51
+ function logPath(): string {
52
+ return resolve(paths.state(), "auto-update.log");
53
+ }
54
+
55
+ export function readLedger(): AutoUpdateLedger | null {
56
+ try {
57
+ const fp = ledgerPath();
58
+ if (!existsSync(fp)) return null;
59
+ return JSON.parse(readFileSync(fp, "utf-8")) as AutoUpdateLedger;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function writeLedger(entry: AutoUpdateLedger): AutoUpdateLedger {
66
+ try {
67
+ writeFileSync(ledgerPath(), `${JSON.stringify(entry, null, 2)}\n`, "utf-8");
68
+ } catch (err) {
69
+ logError("auto-update:ledger", err);
70
+ }
71
+ return entry;
72
+ }
73
+
74
+ function isAutoUpdateEnabled(): boolean {
75
+ return rawSettings().autoUpdate?.enabled === true;
76
+ }
77
+
78
+ export function autoUpdateStatus(): AutoUpdateStatus {
79
+ const settings = rawSettings().autoUpdate ?? {};
80
+ const cache = cachedStatus();
81
+ return {
82
+ enabled: settings.enabled === true,
83
+ decided: settings.decided === true,
84
+ current: cache?.current ?? getInstalledVersion(),
85
+ latest: cache?.latest ?? null,
86
+ available: cache?.available === true,
87
+ checkedAt: cache?.checkedAt ?? null,
88
+ mode: cache?.mode ?? (isRepoMode() ? "repo" : "package"),
89
+ last: readLedger(),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * A pull cannot fast-forward over uncommitted work, so an unattended update on a
95
+ * dirty clone would fail every night. It waits instead, and says so.
96
+ */
97
+ function hasUncommittedChanges(): boolean {
98
+ if (!isRepoMode()) return false;
99
+ const status = spawnSync("git", ["status", "--porcelain"], {
100
+ cwd: palPkg(),
101
+ encoding: "utf-8",
102
+ windowsHide: true,
103
+ });
104
+ if (status.status !== 0) return false;
105
+ return (status.stdout ?? "").trim().length > 0;
106
+ }
107
+
108
+ function recordSkip(reason: string): void {
109
+ writeLedger({ ...readLedger(), skippedAt: new Date().toISOString(), skipped: reason });
110
+ logDebug("auto-update", `skipped: ${reason}`);
111
+ }
112
+
113
+ function attemptedWithin(ledger: AutoUpdateLedger | null, now: number): boolean {
114
+ if (!ledger?.attemptedAt) return false;
115
+ return now - new Date(ledger.attemptedAt).getTime() < DAY_MS;
116
+ }
117
+
118
+ /** The unattended gate: opted in, not already tried today, and safe to pull. */
119
+ export function shouldAutoUpdate(now: number = Date.now()): boolean {
120
+ if (!isAutoUpdateEnabled()) return false;
121
+ if (attemptedWithin(readLedger(), now)) return false;
122
+ if (hasUncommittedChanges()) {
123
+ recordSkip(DIRTY_TREE);
124
+ return false;
125
+ }
126
+ return true;
127
+ }
128
+
129
+ function updateCommand(): string[] {
130
+ return [resolve(palPkg(), "src", "cli", "index.ts"), "cli", "update"];
131
+ }
132
+
133
+ /**
134
+ * Runs in the detached child, never in the hook that spawned it. The attempt is
135
+ * stamped before the command starts so a run that dies mid-flight still counts
136
+ * against today — a broken update retries tomorrow, not every session.
137
+ */
138
+ export function runAutoUpdate(): AutoUpdateLedger {
139
+ if (hasUncommittedChanges()) {
140
+ recordSkip(DIRTY_TREE);
141
+ return readLedger() ?? {};
142
+ }
143
+
144
+ const from = getInstalledVersion();
145
+ writeLedger({ attemptedAt: new Date().toISOString(), from });
146
+
147
+ const run = spawnSync("bun", updateCommand(), {
148
+ cwd: palPkg(),
149
+ encoding: "utf-8",
150
+ windowsHide: true,
151
+ });
152
+ const output = `${run.stdout ?? ""}${run.stderr ?? ""}`;
153
+ try {
154
+ writeFileSync(logPath(), output, "utf-8");
155
+ } catch (err) {
156
+ logError("auto-update:log", err);
157
+ }
158
+
159
+ const ok = run.status === 0;
160
+ logDebug("auto-update", `update exited ${run.status}`);
161
+ return writeLedger({
162
+ ...readLedger(),
163
+ finishedAt: new Date().toISOString(),
164
+ ok,
165
+ from,
166
+ to: getInstalledVersion(),
167
+ error: ok ? undefined : (run.error?.message ?? `exit ${run.status}`),
168
+ });
169
+ }
170
+
171
+ /** Hands the update to its own process so session start never waits on it. */
172
+ export function spawnAutoUpdate(): void {
173
+ try {
174
+ const child = spawn("bun", [resolve(assets.hooks(), "AutoUpdate.ts")], {
175
+ detached: true,
176
+ stdio: "ignore",
177
+ windowsHide: true,
178
+ });
179
+ child.unref();
180
+ logDebug("auto-update", "detached update spawned");
181
+ } catch (err) {
182
+ logError("auto-update:spawn", err);
183
+ }
184
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Which projects serve which stated goal, and how far along that makes it.
3
3
  *
4
- * The linkage needs judgement — "Catalyst is the starter that pays for the rest"
5
- * serves "land two retained clients" only if you know what both mean — so a model
4
+ * The linkage needs judgement — "the starter kit is what pays for the rest"
5
+ * serves "reach steady revenue" only if you know what both mean — so a model
6
6
  * draws it. The progress does not: it is criteria closed over criteria written,
7
7
  * counted here, because a model returning "64%" says something unfalsifiable
8
8
  * about a goal it cannot measure.
@@ -14,7 +14,7 @@ const WIN_RECURSE_FLAG = String.raw`(?:-(?:r(?:e(?:c(?:u(?:r(?:se?)?)?)?)?)?f?|f
14
14
 
15
15
  /**
16
16
  * A whole root, not a directory inside one. The trailing lookahead is the part
17
- * that matters: without it `C:\` prefix-matches `C:\Users\rico\dist` and every
17
+ * that matters: without it `C:\` prefix-matches `C:\Users\user\dist` and every
18
18
  * ordinary recursive delete on Windows gets blocked.
19
19
  */
20
20
  const WIN_ROOT_TARGET = String.raw`["']?(?:[a-z]:[\\/]?\*?|\\\\|~|\$home|\$env:userprofile|\$env:systemdrive)["']?(?=["'\s;,)]|$)`;
@@ -25,6 +25,8 @@ export interface PalSettingsData {
25
25
  dynamicContext?: Record<string, boolean>;
26
26
  /** Git co-author attribution opt-in. `decided` gates the one-time prompt. */
27
27
  attribution?: { enabled?: boolean; decided?: boolean };
28
+ /** Daily unattended self-update opt-in. `decided` gates the one-time prompt. */
29
+ autoUpdate?: { enabled?: boolean; decided?: boolean };
28
30
  /**
29
31
  * Action-ledger user extension. `redactPaths` adds to the built-in set of
30
32
  * paths whose contents are never stored; it cannot shrink it.
@@ -6,6 +6,7 @@
6
6
  * `pal cli server start|stop|status` owns the process; this file only serves.
7
7
  */
8
8
 
9
+ import { autoUpdateStatus, spawnAutoUpdate } from "../../hooks/lib/auto-update";
9
10
  import { loadMachine } from "../../hooks/lib/machine";
10
11
  import { readAllProjects } from "../../hooks/lib/projects";
11
12
  import { isServesKind, setServes } from "../../hooks/lib/serves";
@@ -25,6 +26,7 @@ import {
25
26
  isQuadrant,
26
27
  QUADRANTS,
27
28
  readInstallSettings,
29
+ setAutoUpdate,
28
30
  setIscStatus,
29
31
  setPlacement,
30
32
  type WriteOutcome,
@@ -255,6 +257,26 @@ async function readWrite(request: Request): Promise<Response> {
255
257
  return json({ marked: markRead(ids as string[]) });
256
258
  }
257
259
 
260
+ async function autoUpdateWrite(request: Request): Promise<Response> {
261
+ const body = await readBody(request);
262
+ if (!body) return json({ error: "expected a JSON body" }, 400);
263
+ const { enabled } = body;
264
+ if (typeof enabled !== "boolean") {
265
+ return json({ error: "enabled must be true or false" }, 400);
266
+ }
267
+ const outcome = setAutoUpdate(enabled);
268
+ return outcome.ok ? json(autoUpdateStatus()) : answer(outcome, {});
269
+ }
270
+
271
+ /**
272
+ * The button runs the update the daily gate would have run, bypassing the
273
+ * schedule but not the dirty-tree guard the child applies to itself.
274
+ */
275
+ function updateNow(): Promise<Response> {
276
+ spawnAutoUpdate();
277
+ return Promise.resolve(json({ started: true }, 202));
278
+ }
279
+
258
280
  async function prefsWrite(request: Request): Promise<Response> {
259
281
  const body = await readBody(request);
260
282
  if (!body) return json({ error: "expected a JSON body" }, 400);
@@ -270,6 +292,8 @@ const WRITES: Record<string, (request: Request) => Promise<Response>> = {
270
292
  "/api/snooze": snoozeWrite,
271
293
  "/api/attention/read": readWrite,
272
294
  "/api/prefs": prefsWrite,
295
+ "/api/update": autoUpdateWrite,
296
+ "/api/update/run": updateNow,
273
297
  };
274
298
 
275
299
  export function startControlRoom(port: number = DEFAULT_PORT) {
@@ -312,6 +336,8 @@ export function startControlRoom(port: number = DEFAULT_PORT) {
312
336
  return json(readInstallSettings());
313
337
  case "/api/prefs":
314
338
  return json(readPrefs());
339
+ case "/api/update":
340
+ return json(autoUpdateStatus());
315
341
  case "/api/attention":
316
342
  return json(attention());
317
343
  case "/api/status":