portable-agent-layer 0.76.0 → 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 +1 -1
- package/assets/skills/consulting-report/tools/generate-pdf.ts +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +2 -0
- package/src/cli/migrate.ts +135 -1
- package/src/cli/setup-auto-update.ts +45 -0
- package/src/hooks/AutoUpdate.ts +16 -0
- package/src/hooks/LoadContext.ts +7 -0
- package/src/hooks/handlers/agenda.ts +1 -1
- package/src/hooks/handlers/update-check.ts +15 -16
- package/src/hooks/lib/auto-update.ts +184 -0
- package/src/hooks/lib/goal-links.ts +2 -2
- package/src/hooks/lib/paths.ts +1 -0
- package/src/hooks/lib/security.ts +1 -1
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/work-tracking.ts +19 -14
- package/src/tools/control-room/server.ts +26 -0
- package/src/tools/control-room/ui/dist/assets/{index-BoQjFpzV.js → index-ZQVmA4BJ.js} +1 -1
- package/src/tools/control-room/ui/dist/index.html +1 -1
- package/src/tools/control-room/ui/lib/write.ts +9 -1
- package/src/tools/control-room/ui/screens/settings.tsx +103 -1
- package/src/tools/control-room/writes.ts +11 -0
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/
|
|
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/
|
|
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
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.
|
package/src/cli/migrate.ts
CHANGED
|
@@ -12,16 +12,22 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
existsSync,
|
|
15
|
+
mkdirSync,
|
|
15
16
|
readdirSync,
|
|
16
17
|
readFileSync,
|
|
17
18
|
renameSync,
|
|
19
|
+
rmdirSync,
|
|
20
|
+
unlinkSync,
|
|
18
21
|
writeFileSync,
|
|
19
22
|
} from "node:fs";
|
|
20
|
-
import { resolve } from "node:path";
|
|
23
|
+
import { basename, dirname, resolve } from "node:path";
|
|
24
|
+
import { readBindings } from "../hooks/lib/bindings";
|
|
25
|
+
import { mergeJsonlLines } from "../hooks/lib/import-merge";
|
|
21
26
|
import { palHome, paths } from "../hooks/lib/paths";
|
|
22
27
|
import {
|
|
23
28
|
legacyJsonToProgress,
|
|
24
29
|
type ProjectProgress,
|
|
30
|
+
projectPathOnThisMachine,
|
|
25
31
|
readAllProjects,
|
|
26
32
|
readProject,
|
|
27
33
|
writeProject,
|
|
@@ -516,12 +522,140 @@ const v5AttributionKeys: Migration = {
|
|
|
516
522
|
},
|
|
517
523
|
};
|
|
518
524
|
|
|
525
|
+
// ── v6-history-slugs: history filed by cwd name → its owning project ──
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Session history used to be keyed on the last segment of the cwd, so a session
|
|
529
|
+
* in an unregistered directory minted a project-shaped folder, and a project
|
|
530
|
+
* checked out under a differently-named directory had its history filed under
|
|
531
|
+
* that directory instead. Both now route through `historyFileFor`; this brings
|
|
532
|
+
* the records already on disk onto the same rule.
|
|
533
|
+
*/
|
|
534
|
+
interface StrandedHistory {
|
|
535
|
+
slug: string;
|
|
536
|
+
file: string;
|
|
537
|
+
owner: string | null;
|
|
538
|
+
parked: boolean;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function orphanHistoryFolders(): { slug: string; file: string }[] {
|
|
542
|
+
const base = paths.projectHistory();
|
|
543
|
+
if (!existsSync(base)) return [];
|
|
544
|
+
const out: { slug: string; file: string }[] = [];
|
|
545
|
+
for (const slug of readdirSync(base)) {
|
|
546
|
+
const dir = resolve(base, slug);
|
|
547
|
+
const file = resolve(dir, "history.jsonl");
|
|
548
|
+
if (!existsSync(file) || existsSync(resolve(dir, "ISA.md"))) continue;
|
|
549
|
+
out.push({ slug, file });
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function parkedHistoryFiles(): { slug: string; file: string }[] {
|
|
555
|
+
const base = paths.unboundHistory();
|
|
556
|
+
if (!existsSync(base)) return [];
|
|
557
|
+
return readdirSync(base)
|
|
558
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
559
|
+
.map((f) => ({ slug: f.slice(0, -".jsonl".length), file: resolve(base, f) }));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The project checked out in a directory of this name, when exactly one is. */
|
|
563
|
+
function projectOwningDirectoryNamed(slug: string): string | null {
|
|
564
|
+
const bindings = readBindings();
|
|
565
|
+
const owners = readAllProjects().filter((p) => {
|
|
566
|
+
const path = projectPathOnThisMachine(p, bindings);
|
|
567
|
+
return path !== null && basename(path) === slug;
|
|
568
|
+
});
|
|
569
|
+
return owners.length === 1 ? owners[0].name : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Orphans always move; parked history moves only once its project appears. */
|
|
573
|
+
function strandedHistory(): StrandedHistory[] {
|
|
574
|
+
const out: StrandedHistory[] = [];
|
|
575
|
+
for (const { slug, file } of orphanHistoryFolders()) {
|
|
576
|
+
out.push({ slug, file, owner: projectOwningDirectoryNamed(slug), parked: false });
|
|
577
|
+
}
|
|
578
|
+
for (const { slug, file } of parkedHistoryFiles()) {
|
|
579
|
+
const owner = projectOwningDirectoryNamed(slug);
|
|
580
|
+
if (owner) out.push({ slug, file, owner, parked: true });
|
|
581
|
+
}
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function destinationFor(item: StrandedHistory): string {
|
|
586
|
+
return item.owner
|
|
587
|
+
? resolve(paths.projectHistory(), item.owner, "history.jsonl")
|
|
588
|
+
: resolve(paths.unboundHistory(), `${item.slug}.jsonl`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Union both sides so a destination that already has history keeps it. */
|
|
592
|
+
function foldHistoryInto(target: string, source: string): number {
|
|
593
|
+
const local = existsSync(target) ? readFileSync(target, "utf-8") : "";
|
|
594
|
+
const { text, added } = mergeJsonlLines(local, readFileSync(source, "utf-8"));
|
|
595
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
596
|
+
writeFileSync(target, text, "utf-8");
|
|
597
|
+
return added;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function discardEmptyFolder(dir: string): void {
|
|
601
|
+
try {
|
|
602
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
603
|
+
} catch {
|
|
604
|
+
/* left in place — never worth failing a migration over */
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const v6HistorySlugs: Migration = {
|
|
609
|
+
id: "v6-history-slugs",
|
|
610
|
+
description: "Re-file session history keyed on a cwd name onto its owning project",
|
|
611
|
+
|
|
612
|
+
check() {
|
|
613
|
+
const stranded = strandedHistory();
|
|
614
|
+
return {
|
|
615
|
+
pending: stranded.length > 0,
|
|
616
|
+
detail:
|
|
617
|
+
stranded.length > 0
|
|
618
|
+
? `${stranded.length} history file(s) filed under a directory name`
|
|
619
|
+
: undefined,
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
run(dryRun = false): MigrationResult {
|
|
624
|
+
const results: string[] = [];
|
|
625
|
+
let migrated = 0;
|
|
626
|
+
let skipped = 0;
|
|
627
|
+
|
|
628
|
+
for (const item of strandedHistory()) {
|
|
629
|
+
const target = destinationFor(item);
|
|
630
|
+
const where = item.owner ? `project ${item.owner}` : "unbound-history";
|
|
631
|
+
if (dryRun) {
|
|
632
|
+
migrated++;
|
|
633
|
+
results.push(`${item.slug}: would move to ${where}`);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const added = foldHistoryInto(target, item.file);
|
|
638
|
+
unlinkSync(item.file);
|
|
639
|
+
if (!item.parked) discardEmptyFolder(dirname(item.file));
|
|
640
|
+
migrated++;
|
|
641
|
+
results.push(`${item.slug}: ${added} entr(ies) moved to ${where}`);
|
|
642
|
+
} catch (e) {
|
|
643
|
+
skipped++;
|
|
644
|
+
results.push(`${item.slug}: skipped (${(e as Error).message})`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return { migrated, skipped, results };
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
|
|
519
652
|
const MIGRATIONS: Migration[] = [
|
|
520
653
|
v1Projects,
|
|
521
654
|
v2ThreadsToIsc,
|
|
522
655
|
v3EntitiesToKnowledge,
|
|
523
656
|
v4PathsToBindings,
|
|
524
657
|
v5AttributionKeys,
|
|
658
|
+
v6HistorySlugs,
|
|
525
659
|
];
|
|
526
660
|
|
|
527
661
|
// ── Public API ────────────────────────────────────────────────────
|
|
@@ -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
|
+
}
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 — "
|
|
5
|
-
* serves "
|
|
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.
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -97,6 +97,7 @@ export const paths = {
|
|
|
97
97
|
ledger: () => ensureDir(home("memory", "ledger")),
|
|
98
98
|
progress: () => ensureDir(home("memory", "state", "progress")),
|
|
99
99
|
projectHistory: () => ensureDir(home("memory", "projects")),
|
|
100
|
+
unboundHistory: () => ensureDir(home("memory", "state", "unbound-history")),
|
|
100
101
|
sessionLearning: () => ensureDir(home("memory", "learning", "session")),
|
|
101
102
|
synthesis: () => ensureDir(home("memory", "learning", "synthesis")),
|
|
102
103
|
work: () => ensureDir(home("memory", "work")),
|
|
@@ -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\
|
|
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.
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { resolve } from "node:path";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
8
|
import { ensureDir, paths } from "./paths";
|
|
9
|
+
import { defaultSlug, readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
9
10
|
|
|
10
11
|
// ── Session Records ──────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -146,25 +147,29 @@ interface ProjectHistoryEntry {
|
|
|
146
147
|
insights: string;
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
/**
|
|
151
|
+
* A directory under `memory/projects/` is what `list`, `resume` and the export
|
|
152
|
+
* read as a project, so only a registered one may own a folder there. Writer and
|
|
153
|
+
* readers share this resolver: keying them apart is what filed a project's
|
|
154
|
+
* history under its parent directory's name.
|
|
155
|
+
*/
|
|
156
|
+
function historyFileFor(cwd: string): string {
|
|
157
|
+
const project = resolveProjectFromCwd(cwd, readAllProjects());
|
|
158
|
+
return project
|
|
159
|
+
? resolve(paths.projectHistory(), project.name, "history.jsonl")
|
|
160
|
+
: resolve(paths.unboundHistory(), `${defaultSlug(cwd)}.jsonl`);
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
/** Append a learning entry to the
|
|
163
|
+
/** Append a learning entry to the history file owning this cwd */
|
|
156
164
|
export function appendProjectHistory(cwd: string, entry: ProjectHistoryEntry): void {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const line = `${JSON.stringify(entry)}\n`;
|
|
161
|
-
appendFileSync(historyPath, line, "utf-8");
|
|
165
|
+
const historyPath = historyFileFor(cwd);
|
|
166
|
+
ensureDir(dirname(historyPath));
|
|
167
|
+
appendFileSync(historyPath, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
/** Read the
|
|
170
|
+
/** Read the session history for a given cwd */
|
|
165
171
|
export function readProjectHistory(cwd: string, limit = 15): ProjectHistoryEntry[] {
|
|
166
|
-
const
|
|
167
|
-
const historyPath = resolve(paths.projectHistory(), slug, "history.jsonl");
|
|
172
|
+
const historyPath = historyFileFor(cwd);
|
|
168
173
|
if (!existsSync(historyPath)) return [];
|
|
169
174
|
try {
|
|
170
175
|
const lines = readFileSync(historyPath, "utf-8").trim().split("\n").filter(Boolean);
|