portable-agent-layer 0.39.0 → 0.41.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 +37 -16
- package/assets/templates/PAL/MEMORY_SYSTEM.md +63 -17
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +81 -8
- package/assets/templates/hooks.copilot.json +4 -4
- package/assets/templates/settings.claude.json +7 -7
- package/package.json +8 -5
- package/src/cli/index.ts +282 -22
- package/src/cli/migrate.ts +5 -48
- package/src/hooks/CompactRecover.ts +4 -0
- package/src/hooks/LoadContext.ts +13 -8
- package/src/hooks/PreCompactPersist.ts +4 -0
- package/src/hooks/StopOrchestrator.ts +18 -6
- package/src/hooks/UserPromptOrchestrator.ts +7 -1
- package/src/hooks/handlers/auto-graduate.ts +8 -0
- package/src/hooks/handlers/failure-principle.ts +122 -0
- package/src/hooks/handlers/rating.ts +57 -26
- package/src/hooks/handlers/session-intelligence.ts +26 -6
- package/src/hooks/handlers/session-name.ts +13 -21
- package/src/hooks/lib/agent.ts +28 -13
- package/src/hooks/lib/detached-inference.ts +39 -0
- package/src/hooks/lib/graduation.ts +1 -0
- package/src/hooks/lib/inference.ts +786 -5
- package/src/hooks/lib/log.ts +60 -12
- package/src/hooks/lib/notify.ts +1 -0
- package/src/hooks/lib/projects.ts +52 -0
- package/src/hooks/lib/security.ts +5 -0
- package/src/hooks/lib/spawn-guard.ts +68 -0
- package/src/hooks/lib/stop.ts +77 -79
- package/src/targets/opencode/plugin.ts +13 -0
- package/src/tools/agent/project.ts +4 -42
- package/src/tools/self-model.ts +1 -0
package/src/cli/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { spawnSync } from "node:child_process";
|
|
|
22
22
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { resolve } from "node:path";
|
|
25
|
+
import { inference, previewInferenceRoute } from "../hooks/lib/inference";
|
|
26
|
+
import { DEBUG_LOG_MAX_ROTATED } from "../hooks/lib/log";
|
|
25
27
|
import { palHome, palPkg, platform } from "../hooks/lib/paths";
|
|
26
28
|
import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
|
|
27
29
|
import { log } from "../targets/lib";
|
|
@@ -167,9 +169,13 @@ async function runCli(command: string | undefined, args: string[]) {
|
|
|
167
169
|
case "status":
|
|
168
170
|
await status();
|
|
169
171
|
break;
|
|
170
|
-
case "doctor":
|
|
172
|
+
case "doctor": {
|
|
171
173
|
doctor();
|
|
174
|
+
if (args.includes("--probe-inference") || args.includes("--probe")) {
|
|
175
|
+
await probeInference();
|
|
176
|
+
}
|
|
172
177
|
break;
|
|
178
|
+
}
|
|
173
179
|
case "migrate": {
|
|
174
180
|
const { runMigrate } = await import("./migrate");
|
|
175
181
|
runMigrate(args);
|
|
@@ -217,7 +223,7 @@ function showHelp() {
|
|
|
217
223
|
pal cli export [path] [--dry-run] Export state to zip
|
|
218
224
|
pal cli import [path] [--dry-run] Import state from zip
|
|
219
225
|
pal cli status Show PAL configuration
|
|
220
|
-
pal cli doctor
|
|
226
|
+
pal cli doctor [--probe-inference] Check prerequisites and health (--probe fires real inference per route)
|
|
221
227
|
pal cli migrate [--list] [--dry-run] Run pending data migrations
|
|
222
228
|
pal cli usage Summarize token usage and cost
|
|
223
229
|
|
|
@@ -385,6 +391,136 @@ function checkCopilotInstructionsPresent(): boolean {
|
|
|
385
391
|
return existsSync(resolve(platform.copilotDir(), "copilot-instructions.md"));
|
|
386
392
|
}
|
|
387
393
|
|
|
394
|
+
// ── Install integrity (Tier 2 doctor checks) ──
|
|
395
|
+
|
|
396
|
+
/** Recursively collect every `command` or `bash` field value in a hook-config JSON. */
|
|
397
|
+
function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
|
|
398
|
+
if (Array.isArray(obj)) {
|
|
399
|
+
for (const item of obj) extractAllHookCommands(item, out);
|
|
400
|
+
} else if (obj && typeof obj === "object") {
|
|
401
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
402
|
+
if ((k === "command" || k === "bash") && typeof v === "string") {
|
|
403
|
+
out.push(v);
|
|
404
|
+
} else {
|
|
405
|
+
extractAllHookCommands(v, out);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
interface HookPrefixCheck {
|
|
413
|
+
ok: boolean;
|
|
414
|
+
total: number;
|
|
415
|
+
missing: number;
|
|
416
|
+
firstMissing?: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Verify every command in an installed hook file starts with `PAL_AGENT=<agent>`. */
|
|
420
|
+
function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
|
|
421
|
+
if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
|
|
422
|
+
try {
|
|
423
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
424
|
+
const commands = extractAllHookCommands(data);
|
|
425
|
+
const prefix = `PAL_AGENT=${agentName} `;
|
|
426
|
+
const missing = commands.filter((c) => !c.startsWith(prefix));
|
|
427
|
+
return {
|
|
428
|
+
ok: commands.length > 0 && missing.length === 0,
|
|
429
|
+
total: commands.length,
|
|
430
|
+
missing: missing.length,
|
|
431
|
+
firstMissing: missing[0]?.slice(0, 80),
|
|
432
|
+
};
|
|
433
|
+
} catch {
|
|
434
|
+
return { ok: false, total: 0, missing: 0 };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface FreshnessCheck {
|
|
439
|
+
ok: boolean;
|
|
440
|
+
reason?: string;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Verify the installed opencode plugin is at least as new as the source. Catches
|
|
445
|
+
* the "stale install" failure mode we hit live — plugin file is a copy, not a
|
|
446
|
+
* symlink, so source edits don't reach the running opencode until reinstall.
|
|
447
|
+
*/
|
|
448
|
+
/**
|
|
449
|
+
* Probe every supported agent route with a tiny real inference call.
|
|
450
|
+
* Sequential (concurrent multi-CLI spawns trigger the empty-abort race we
|
|
451
|
+
* already mitigate but don't want to invite). Each probe temporarily sets
|
|
452
|
+
* PAL_AGENT then restores; doesn't pollute user shell.
|
|
453
|
+
*
|
|
454
|
+
* Triggered by `pal cli doctor --probe-inference` (opt-in: costs tokens).
|
|
455
|
+
*/
|
|
456
|
+
async function probeInference(): Promise<void> {
|
|
457
|
+
console.log("");
|
|
458
|
+
log.info("Inference probe (live calls, ~5-60s each)");
|
|
459
|
+
const green = "\x1b[32m";
|
|
460
|
+
const red = "\x1b[31m";
|
|
461
|
+
const yellow = "\x1b[33m";
|
|
462
|
+
const dim = "\x1b[90m";
|
|
463
|
+
const reset = "\x1b[0m";
|
|
464
|
+
const agents = ["claude", "codex", "opencode", "copilot", "cursor"] as const;
|
|
465
|
+
const savedAgent = process.env.PAL_AGENT;
|
|
466
|
+
try {
|
|
467
|
+
for (const agent of agents) {
|
|
468
|
+
process.env.PAL_AGENT = agent;
|
|
469
|
+
const preview = previewInferenceRoute();
|
|
470
|
+
const tag = `${agent.padEnd(10)} → ${preview.route.padEnd(15)}`;
|
|
471
|
+
if (preview.route === "none") {
|
|
472
|
+
console.log(` ${dim}-${reset} ${tag} ${dim}(skip: ${preview.reason})${reset}`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (preview.route === "disabled") {
|
|
476
|
+
console.log(` ${yellow}⚠${reset} ${tag} ${dim}${preview.reason}${reset}`);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const start = Date.now();
|
|
480
|
+
const r = await inference({
|
|
481
|
+
user: "Reply with exactly: OK",
|
|
482
|
+
system: "Reply in 3 words or fewer.",
|
|
483
|
+
caller: "doctor-probe",
|
|
484
|
+
timeout: 60_000,
|
|
485
|
+
});
|
|
486
|
+
const elapsedMs = Date.now() - start;
|
|
487
|
+
if (r.success) {
|
|
488
|
+
const bytes = r.output?.length ?? 0;
|
|
489
|
+
console.log(
|
|
490
|
+
` ${green}✓${reset} ${tag} ${String(elapsedMs).padStart(6)}ms bytes=${bytes}`
|
|
491
|
+
);
|
|
492
|
+
} else {
|
|
493
|
+
console.log(
|
|
494
|
+
` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/memory/state/debug.log${reset}`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
} finally {
|
|
499
|
+
if (savedAgent === undefined) delete process.env.PAL_AGENT;
|
|
500
|
+
else process.env.PAL_AGENT = savedAgent;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function checkOpencodePluginFresh(): FreshnessCheck {
|
|
505
|
+
const installedPath = resolve(platform.opencodeDir(), "plugins", "pal-plugin.ts");
|
|
506
|
+
const sourcePath = resolve(palPkg(), "src", "targets", "opencode", "plugin.ts");
|
|
507
|
+
if (!existsSync(installedPath))
|
|
508
|
+
return { ok: false, reason: "installed plugin missing" };
|
|
509
|
+
if (!existsSync(sourcePath)) return { ok: true }; // can't compare; assume installed is fine
|
|
510
|
+
try {
|
|
511
|
+
const installedMtime = statSync(installedPath).mtimeMs;
|
|
512
|
+
const sourceMtime = statSync(sourcePath).mtimeMs;
|
|
513
|
+
if (installedMtime >= sourceMtime) return { ok: true };
|
|
514
|
+
const ageMin = Math.round((sourceMtime - installedMtime) / 60000);
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
reason: `source is ${ageMin}m newer than installed — run 'pal cli install --opencode'`,
|
|
518
|
+
};
|
|
519
|
+
} catch {
|
|
520
|
+
return { ok: true };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
388
524
|
function playwrightBrowsersPath(): string {
|
|
389
525
|
if (process.env.PLAYWRIGHT_BROWSERS_PATH) return process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
390
526
|
const home = homedir();
|
|
@@ -437,12 +573,24 @@ function nodeInstallHint(): string {
|
|
|
437
573
|
}
|
|
438
574
|
|
|
439
575
|
function checkHookHealth(home: string): HookHealth {
|
|
440
|
-
const
|
|
576
|
+
const stateDir = resolve(home, "memory", "state");
|
|
577
|
+
// Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
|
|
578
|
+
// rotation doesn't make the 24h window appear empty.
|
|
579
|
+
const candidates = [
|
|
580
|
+
resolve(stateDir, "debug.log"),
|
|
581
|
+
resolve(stateDir, "debug.log.prev"),
|
|
582
|
+
...Array.from({ length: DEBUG_LOG_MAX_ROTATED }, (_, i) =>
|
|
583
|
+
resolve(stateDir, `debug.log.${i + 1}`)
|
|
584
|
+
),
|
|
585
|
+
];
|
|
441
586
|
|
|
442
587
|
try {
|
|
443
|
-
|
|
588
|
+
let content = "";
|
|
589
|
+
for (const path of candidates) {
|
|
590
|
+
if (existsSync(path)) content += `${readFileSync(path, "utf-8")}\n`;
|
|
591
|
+
}
|
|
592
|
+
if (!content) return { totalErrors: 0, lastError: null };
|
|
444
593
|
|
|
445
|
-
const content = readFileSync(logPath, "utf-8");
|
|
446
594
|
const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
|
|
447
595
|
|
|
448
596
|
// Filter to last 24h
|
|
@@ -516,9 +664,10 @@ function doctor(silent = false): DoctorResult {
|
|
|
516
664
|
const ok = (msg: string) => console.log(` \x1b[32m\u2713\x1b[0m ${msg}`);
|
|
517
665
|
const warn = (msg: string) => console.log(` \x1b[33m\u26A0\x1b[0m ${msg}`);
|
|
518
666
|
const fail = (msg: string) => console.log(` \x1b[31m\u2717\x1b[0m ${msg}`);
|
|
667
|
+
const info = (msg: string) => console.log(` \x1b[90m\u00B7\x1b[0m ${msg}`);
|
|
519
668
|
|
|
520
669
|
console.log("");
|
|
521
|
-
log.info("
|
|
670
|
+
log.info("Prerequisites");
|
|
522
671
|
ok(`Bun ${bun.version}`);
|
|
523
672
|
const node = checkNode();
|
|
524
673
|
if (!node.available) {
|
|
@@ -547,6 +696,14 @@ function doctor(silent = false): DoctorResult {
|
|
|
547
696
|
codex.available
|
|
548
697
|
? ok(`Codex ${codex.version || ""}`.trim())
|
|
549
698
|
: fail("Codex — not found");
|
|
699
|
+
checkPlaywrightChromium()
|
|
700
|
+
? ok("Playwright Chromium installed")
|
|
701
|
+
: fail(
|
|
702
|
+
"Playwright Chromium — not found (run 'pal cli install' or 'bunx playwright install chromium')"
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
console.log("");
|
|
706
|
+
log.info("PAL state");
|
|
550
707
|
ok(`PAL home: ${home}`);
|
|
551
708
|
telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
|
|
552
709
|
|
|
@@ -591,7 +748,15 @@ function doctor(silent = false): DoctorResult {
|
|
|
591
748
|
);
|
|
592
749
|
}
|
|
593
750
|
|
|
751
|
+
// Dependencies (PAL's own npm packages)
|
|
752
|
+
const nodeModulesPath = resolve(palPkg(), "node_modules");
|
|
753
|
+
existsSync(nodeModulesPath)
|
|
754
|
+
? ok("Dependencies installed")
|
|
755
|
+
: fail("Dependencies missing — run 'pal cli install'");
|
|
756
|
+
|
|
594
757
|
// Skills (per installed agent)
|
|
758
|
+
console.log("");
|
|
759
|
+
log.info("Skills");
|
|
595
760
|
const countSkillsIn = (dir: string) =>
|
|
596
761
|
existsSync(dir)
|
|
597
762
|
? readdirSync(dir).filter((f) => existsSync(resolve(dir, f, "SKILL.md"))).length
|
|
@@ -627,20 +792,9 @@ function doctor(silent = false): DoctorResult {
|
|
|
627
792
|
: warn("Codex skills — none found (run 'pal cli install --codex')");
|
|
628
793
|
}
|
|
629
794
|
|
|
630
|
-
// Dependencies
|
|
631
|
-
const nodeModulesPath = resolve(palPkg(), "node_modules");
|
|
632
|
-
existsSync(nodeModulesPath)
|
|
633
|
-
? ok("Dependencies installed")
|
|
634
|
-
: fail("Dependencies missing — run 'pal cli install'");
|
|
635
|
-
|
|
636
|
-
// Playwright Chromium (required by create-pdf + consulting-report skills)
|
|
637
|
-
checkPlaywrightChromium()
|
|
638
|
-
? ok("Playwright Chromium installed")
|
|
639
|
-
: fail(
|
|
640
|
-
"Playwright Chromium — not found (run 'pal cli install' or 'bunx playwright install chromium')"
|
|
641
|
-
);
|
|
642
|
-
|
|
643
795
|
// Hook registration (per installed agent)
|
|
796
|
+
console.log("");
|
|
797
|
+
log.info("Hooks");
|
|
644
798
|
if (claude.available) {
|
|
645
799
|
checkClaudeHooksRegistered()
|
|
646
800
|
? ok("Claude Code hooks registered")
|
|
@@ -670,10 +824,116 @@ function doctor(silent = false): DoctorResult {
|
|
|
670
824
|
: fail("Codex hooks — not registered (run 'pal cli install --codex')");
|
|
671
825
|
}
|
|
672
826
|
|
|
673
|
-
//
|
|
827
|
+
// Install integrity — verify PAL_AGENT prefix on every command in installed
|
|
828
|
+
// hook files. Catches stale installs after template changes (the exact bug
|
|
829
|
+
// we hit live for opencode + copilot). opencode uses a plugin file instead
|
|
830
|
+
// of a hooks.json, so it gets a separate freshness check.
|
|
831
|
+
console.log("");
|
|
832
|
+
log.info("Install integrity");
|
|
833
|
+
const prefixCheck = (
|
|
834
|
+
filePath: string,
|
|
835
|
+
agentName: string,
|
|
836
|
+
installCmd: string
|
|
837
|
+
): void => {
|
|
838
|
+
const r = checkAgentHookPrefix(filePath, agentName);
|
|
839
|
+
if (r.ok) {
|
|
840
|
+
ok(`${agentName}: PAL_AGENT=${agentName} on all ${r.total} hook commands`);
|
|
841
|
+
} else if (r.total === 0) {
|
|
842
|
+
fail(`${agentName}: hook file missing or unreadable at ${filePath}`);
|
|
843
|
+
} else {
|
|
844
|
+
fail(
|
|
845
|
+
`${agentName}: ${r.missing}/${r.total} hook commands missing PAL_AGENT=${agentName} prefix (run '${installCmd}')`
|
|
846
|
+
);
|
|
847
|
+
if (r.firstMissing) {
|
|
848
|
+
log.warn(` First offender: ${r.firstMissing}…`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
if (claude.available) {
|
|
853
|
+
prefixCheck(
|
|
854
|
+
resolve(platform.claudeDir(), "settings.json"),
|
|
855
|
+
"claude",
|
|
856
|
+
"pal cli install --claude"
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
if (cursor.available) {
|
|
860
|
+
prefixCheck(
|
|
861
|
+
resolve(platform.cursorDir(), "hooks.json"),
|
|
862
|
+
"cursor",
|
|
863
|
+
"pal cli install --cursor"
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
if (copilot.available) {
|
|
867
|
+
prefixCheck(
|
|
868
|
+
resolve(platform.copilotDir(), "hooks", "pal-hooks.json"),
|
|
869
|
+
"copilot",
|
|
870
|
+
"pal cli install --copilot"
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
if (codex.available) {
|
|
874
|
+
prefixCheck(
|
|
875
|
+
resolve(platform.codexDir(), "hooks.json"),
|
|
876
|
+
"codex",
|
|
877
|
+
"pal cli install --codex"
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (opencode.available) {
|
|
881
|
+
const fresh = checkOpencodePluginFresh();
|
|
882
|
+
fresh.ok
|
|
883
|
+
? ok("opencode plugin: source-and-installed in sync")
|
|
884
|
+
: fail(`opencode plugin: ${fresh.reason}`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Inference routing preview — what `inference()` would do RIGHT NOW
|
|
888
|
+
console.log("");
|
|
889
|
+
log.info("Inference");
|
|
890
|
+
{
|
|
891
|
+
const preview = previewInferenceRoute();
|
|
892
|
+
console.log(` → Active agent: ${preview.agent}`);
|
|
893
|
+
if (preview.route === "none") {
|
|
894
|
+
fail(`Would route to: NONE — ${preview.reason}`);
|
|
895
|
+
} else if (preview.route === "disabled") {
|
|
896
|
+
warn(`Would route to: DISABLED — ${preview.reason}`);
|
|
897
|
+
} else if (preview.route.endsWith("-api")) {
|
|
898
|
+
warn(`Would route to: ${preview.route} (${preview.reason})`);
|
|
899
|
+
} else {
|
|
900
|
+
ok(`Would route to: ${preview.route} (${preview.reason})`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (process.env.PAL_INFERENCE_DISABLED === "1") {
|
|
904
|
+
warn(
|
|
905
|
+
"PAL_INFERENCE_DISABLED=1 — test kill-switch leaked into prod env; every inference call will return failure"
|
|
906
|
+
);
|
|
907
|
+
} else {
|
|
908
|
+
ok("PAL_INFERENCE_DISABLED is not set (production-safe)");
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Spawn-guard env vars should never appear in the user's shell — they're
|
|
912
|
+
// set ONLY in PAL-spawned subprocesses. Leaks into the user shell cause
|
|
913
|
+
// every inference call to short-circuit silently.
|
|
914
|
+
if (process.env.PAL_SPAWNED_INFERENCE) {
|
|
915
|
+
fail(
|
|
916
|
+
`PAL_SPAWNED_INFERENCE=${process.env.PAL_SPAWNED_INFERENCE} leaked into shell — every inference call will refuse. Unset it.`
|
|
917
|
+
);
|
|
918
|
+
} else {
|
|
919
|
+
ok("PAL_SPAWNED_INFERENCE not leaked (recursion guard clean)");
|
|
920
|
+
}
|
|
921
|
+
if (process.env.PAL_INFERENCE_DEPTH) {
|
|
922
|
+
fail(
|
|
923
|
+
`PAL_INFERENCE_DEPTH=${process.env.PAL_INFERENCE_DEPTH} leaked into shell — depth circuit-breaker will fire. Unset it.`
|
|
924
|
+
);
|
|
925
|
+
} else {
|
|
926
|
+
ok("PAL_INFERENCE_DEPTH not leaked (depth counter clean)");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// API key checks — both are optional safety-net fallbacks. Unset is normal
|
|
930
|
+
// for CLI-only setups; only matters if your native CLI breaks.
|
|
674
931
|
process.env.PAL_ANTHROPIC_API_KEY
|
|
675
|
-
? ok("PAL_ANTHROPIC_API_KEY
|
|
676
|
-
:
|
|
932
|
+
? ok("PAL_ANTHROPIC_API_KEY set (anthropic-api fallback available)")
|
|
933
|
+
: info("PAL_ANTHROPIC_API_KEY unset (optional — anthropic-api fallback off)");
|
|
934
|
+
process.env.PAL_OPENAI_API_KEY
|
|
935
|
+
? ok("PAL_OPENAI_API_KEY set (openai-api fallback for codex available)")
|
|
936
|
+
: info("PAL_OPENAI_API_KEY unset (optional — openai-api fallback off)");
|
|
677
937
|
process.env.PAL_GEMINI_API_KEY
|
|
678
938
|
? ok("PAL_GEMINI_API_KEY is set")
|
|
679
939
|
: warn("PAL_GEMINI_API_KEY — not set (optional, for YouTube analysis)");
|
package/src/cli/migrate.ts
CHANGED
|
@@ -14,8 +14,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
15
|
import { paths } from "../hooks/lib/paths";
|
|
16
16
|
import {
|
|
17
|
+
legacyJsonToProgress,
|
|
17
18
|
type ProjectProgress,
|
|
18
|
-
type ProjectStatus,
|
|
19
19
|
readAllProjects,
|
|
20
20
|
readProject,
|
|
21
21
|
writeProject,
|
|
@@ -39,26 +39,6 @@ interface Migration {
|
|
|
39
39
|
|
|
40
40
|
// ── v1-projects: JSON progress files → ISA.md ─────────────────────
|
|
41
41
|
|
|
42
|
-
interface LegacyDecision {
|
|
43
|
-
ts: string;
|
|
44
|
-
decision: string;
|
|
45
|
-
rationale: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface LegacyProject {
|
|
49
|
-
name: string;
|
|
50
|
-
path: string;
|
|
51
|
-
status: ProjectStatus;
|
|
52
|
-
created: string;
|
|
53
|
-
updated: string;
|
|
54
|
-
facts?: string[];
|
|
55
|
-
objectives?: string[];
|
|
56
|
-
next_steps?: string[];
|
|
57
|
-
blockers?: string[];
|
|
58
|
-
handoff?: string;
|
|
59
|
-
decisions?: LegacyDecision[];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
function pendingJsonFiles(): string[] {
|
|
63
43
|
const progressDir = paths.progress();
|
|
64
44
|
if (!existsSync(progressDir)) return [];
|
|
@@ -92,37 +72,14 @@ const v1Projects: Migration = {
|
|
|
92
72
|
const filePath = resolve(progressDir, file);
|
|
93
73
|
|
|
94
74
|
try {
|
|
95
|
-
const raw = JSON.parse(readFileSync(filePath, "utf-8"))
|
|
96
|
-
|
|
75
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
76
|
+
const p = legacyJsonToProgress(raw);
|
|
77
|
+
if (!p) {
|
|
97
78
|
skipped++;
|
|
98
79
|
results.push(`${slug}: skipped (malformed JSON)`);
|
|
99
80
|
continue;
|
|
100
81
|
}
|
|
101
|
-
|
|
102
|
-
if (!dryRun) {
|
|
103
|
-
const p: ProjectProgress = {
|
|
104
|
-
name: raw.name,
|
|
105
|
-
path: raw.path,
|
|
106
|
-
status: raw.status,
|
|
107
|
-
created: raw.created ?? new Date().toISOString(),
|
|
108
|
-
updated: raw.updated ?? new Date().toISOString(),
|
|
109
|
-
...(raw.handoff ? { handoff: raw.handoff } : {}),
|
|
110
|
-
...(raw.next_steps?.length ? { next: raw.next_steps } : {}),
|
|
111
|
-
...(raw.blockers?.length ? { blockers: raw.blockers } : {}),
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
if (raw.facts?.length) p.context = raw.facts.join("\n");
|
|
115
|
-
if (raw.objectives?.length)
|
|
116
|
-
p.goal = raw.objectives.map((o) => `- ${o}`).join("\n");
|
|
117
|
-
if (raw.decisions?.length) {
|
|
118
|
-
p.decisions = raw.decisions
|
|
119
|
-
.map((d) => `- ${d.ts.slice(0, 10)}: ${d.decision} (${d.rationale})`)
|
|
120
|
-
.join("\n");
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
writeProject(p);
|
|
124
|
-
}
|
|
125
|
-
|
|
82
|
+
if (!dryRun) writeProject(p);
|
|
126
83
|
migrated++;
|
|
127
84
|
results.push(`${slug}: ${dryRun ? "would migrate" : "migrated"} (source kept)`);
|
|
128
85
|
} catch {
|
|
@@ -14,8 +14,12 @@ import { resolve } from "node:path";
|
|
|
14
14
|
import { isCursor } from "./lib/agent";
|
|
15
15
|
import { logDebug, logError } from "./lib/log";
|
|
16
16
|
import { paths } from "./lib/paths";
|
|
17
|
+
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
17
18
|
import { readStdinJSON } from "./lib/stdin";
|
|
18
19
|
|
|
20
|
+
// Recursion guard — spawned subprocesses don't compact, so nothing to recover.
|
|
21
|
+
if (isPalSpawnedInference()) process.exit(0);
|
|
22
|
+
|
|
19
23
|
interface SessionStartInput {
|
|
20
24
|
session_id?: string;
|
|
21
25
|
hook_event_name?: string;
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -11,10 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
|
+
import { getActiveAgent, isCodex, isCopilot, isCursor } from "./lib/agent";
|
|
14
15
|
import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
|
|
15
16
|
import { type AgentTarget, buildSystemReminder } from "./lib/context";
|
|
16
17
|
import { logDebug, logError } from "./lib/log";
|
|
17
18
|
import { platform } from "./lib/paths";
|
|
19
|
+
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
20
|
+
|
|
21
|
+
// Recursion guard — when this process is a PAL-spawned inference subprocess,
|
|
22
|
+
// skip all context loading so we don't trigger another inference call.
|
|
23
|
+
if (isPalSpawnedInference()) process.exit(0);
|
|
18
24
|
|
|
19
25
|
// --- Skip heavy context for subagents ---
|
|
20
26
|
const isSubagent =
|
|
@@ -37,14 +43,13 @@ try {
|
|
|
37
43
|
// --- Context to stdout (or file for Copilot) ---
|
|
38
44
|
try {
|
|
39
45
|
// Determine agent target — controls which sections are skipped (loaded natively instead).
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
agent = "cursor";
|
|
46
|
+
const active = getActiveAgent();
|
|
47
|
+
const agent: AgentTarget =
|
|
48
|
+
active === "copilot" || active === "cursor" ? active : "claude";
|
|
44
49
|
const reminder = buildSystemReminder({ agent });
|
|
45
50
|
if (!reminder) process.exit(0);
|
|
46
51
|
|
|
47
|
-
if (
|
|
52
|
+
if (isCopilot()) {
|
|
48
53
|
// Copilot: semi-static in ~/.copilot/instructions/pal-*.instructions.md (written at stop).
|
|
49
54
|
// Write AGENTS.md + dynamic context to pal-session.instructions.md on each session start.
|
|
50
55
|
const instructionsDir = resolve(platform.copilotDir(), "instructions");
|
|
@@ -62,13 +67,13 @@ try {
|
|
|
62
67
|
"LoadContext",
|
|
63
68
|
`Copilot session instructions written: ${context.length} chars`
|
|
64
69
|
);
|
|
65
|
-
} else if (
|
|
70
|
+
} else if (isCursor()) {
|
|
66
71
|
// Cursor: semi-static in ~/.cursor/rules/pal-context.mdc; inject AGENTS.md + dynamic here
|
|
67
72
|
const agentsMd = buildClaudeMd();
|
|
68
73
|
const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
|
|
69
74
|
process.stdout.write(JSON.stringify({ additional_context: context }));
|
|
70
75
|
logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
|
|
71
|
-
} else if (
|
|
76
|
+
} else if (isCodex()) {
|
|
72
77
|
// Codex: AGENTS.md already loaded via symlink; inject only dynamic context
|
|
73
78
|
process.stdout.write(
|
|
74
79
|
JSON.stringify({
|
|
@@ -80,7 +85,7 @@ try {
|
|
|
80
85
|
);
|
|
81
86
|
logDebug("LoadContext", `Codex reminder injected: ${reminder.length} chars`);
|
|
82
87
|
} else {
|
|
83
|
-
// Claude Code: raw text
|
|
88
|
+
// Claude Code (and opencode, which uses the plugin path not this hook): raw text
|
|
84
89
|
console.log(reminder);
|
|
85
90
|
logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
|
|
86
91
|
}
|
|
@@ -17,9 +17,13 @@ import { resolve } from "node:path";
|
|
|
17
17
|
import { persistLastExchange } from "./handlers/persist-last-exchange";
|
|
18
18
|
import { logDebug, logError } from "./lib/log";
|
|
19
19
|
import { paths } from "./lib/paths";
|
|
20
|
+
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
20
21
|
import { readStdinJSON } from "./lib/stdin";
|
|
21
22
|
import { readTranscriptFile } from "./lib/transcript";
|
|
22
23
|
|
|
24
|
+
// Recursion guard — spawned subprocesses don't compact, so nothing to persist.
|
|
25
|
+
if (isPalSpawnedInference()) process.exit(0);
|
|
26
|
+
|
|
23
27
|
interface PreCompactInput {
|
|
24
28
|
session_id?: string;
|
|
25
29
|
transcript_path?: string;
|
|
@@ -9,14 +9,22 @@
|
|
|
9
9
|
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
10
10
|
import { isCodex, isCursor } from "./lib/agent";
|
|
11
11
|
import { logError } from "./lib/log";
|
|
12
|
+
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
12
13
|
import { readStdinJSON } from "./lib/stdin";
|
|
13
14
|
import { runStopHandlers } from "./lib/stop";
|
|
14
15
|
import { readTranscriptFile } from "./lib/transcript";
|
|
15
16
|
|
|
17
|
+
// Recursion guard — spawned inference subprocesses must not record session
|
|
18
|
+
// learning, ratings, or handoffs from their throwaway transcript.
|
|
19
|
+
if (isPalSpawnedInference()) process.exit(0);
|
|
20
|
+
|
|
16
21
|
interface StopHookInput {
|
|
17
|
-
session_id
|
|
18
|
-
|
|
22
|
+
session_id?: string;
|
|
23
|
+
sessionId?: string; // Copilot uses camelCase
|
|
24
|
+
transcript_path?: string;
|
|
25
|
+
transcriptPath?: string; // Copilot uses camelCase
|
|
19
26
|
last_assistant_message?: string;
|
|
27
|
+
lastAssistantMessage?: string; // Copilot uses camelCase
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
// Check README sync before anything else — may block the session
|
|
@@ -40,18 +48,22 @@ try {
|
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
const input = await readStdinJSON<StopHookInput>();
|
|
43
|
-
|
|
51
|
+
const transcriptPath = input?.transcript_path ?? input?.transcriptPath;
|
|
52
|
+
const sessionId = input?.session_id ?? input?.sessionId;
|
|
53
|
+
const lastAssistant = input?.last_assistant_message ?? input?.lastAssistantMessage;
|
|
54
|
+
|
|
55
|
+
if (!transcriptPath) {
|
|
44
56
|
logError("StopOrchestrator", "No transcript_path in hook input");
|
|
45
57
|
process.exit(0);
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
// Read the actual transcript from the file on disk
|
|
49
|
-
const messages = readTranscriptFile(
|
|
61
|
+
const messages = readTranscriptFile(transcriptPath);
|
|
50
62
|
if (messages.length < 2) process.exit(0);
|
|
51
63
|
|
|
52
64
|
// Serialize and run handlers
|
|
53
65
|
const transcript = JSON.stringify(messages);
|
|
54
66
|
await runStopHandlers(transcript, {
|
|
55
|
-
lastAssistantMessage:
|
|
56
|
-
sessionId
|
|
67
|
+
lastAssistantMessage: lastAssistant,
|
|
68
|
+
sessionId,
|
|
57
69
|
});
|
|
@@ -11,11 +11,17 @@ import { injectRetrieval } from "./handlers/inject-retrieval";
|
|
|
11
11
|
import { captureRating } from "./handlers/rating";
|
|
12
12
|
import { captureSessionName } from "./handlers/session-name";
|
|
13
13
|
import { logDebug, logError } from "./lib/log";
|
|
14
|
+
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
14
15
|
import { readStdinJSON } from "./lib/stdin";
|
|
15
16
|
|
|
17
|
+
// Recursion guard — the "prompt" inside a spawned inference is the dispatcher's
|
|
18
|
+
// payload, not a real user message. Skip rating capture, session naming, etc.
|
|
19
|
+
if (isPalSpawnedInference()) process.exit(0);
|
|
20
|
+
|
|
16
21
|
interface PromptSubmitInput {
|
|
17
22
|
prompt: string;
|
|
18
23
|
session_id?: string;
|
|
24
|
+
sessionId?: string; // Copilot sends this (camelCase) instead of session_id
|
|
19
25
|
conversation_id?: string; // Cursor sends this instead of session_id
|
|
20
26
|
}
|
|
21
27
|
|
|
@@ -23,7 +29,7 @@ const input = await readStdinJSON<PromptSubmitInput>();
|
|
|
23
29
|
logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
|
|
24
30
|
if (!input?.prompt) process.exit(0);
|
|
25
31
|
|
|
26
|
-
const sessionId = input.session_id ?? input.conversation_id;
|
|
32
|
+
const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
|
|
27
33
|
const results = await Promise.allSettled([
|
|
28
34
|
captureRating(input.prompt, sessionId),
|
|
29
35
|
captureSessionName(input.prompt, sessionId ?? ""),
|
|
@@ -96,6 +96,7 @@ interface AutoGraduateResult {
|
|
|
96
96
|
* Returns a summary of what happened so callers (handler, tests) can reason
|
|
97
97
|
* about the run without re-reading state.
|
|
98
98
|
*/
|
|
99
|
+
/** @lintignore — consumed by test/auto-graduate.test.ts via dynamic import */
|
|
99
100
|
export async function autoGraduate(
|
|
100
101
|
opts: AutoGraduateOptions = {}
|
|
101
102
|
): Promise<AutoGraduateResult> {
|
|
@@ -167,3 +168,10 @@ export async function autoGraduate(
|
|
|
167
168
|
}
|
|
168
169
|
return result;
|
|
169
170
|
}
|
|
171
|
+
|
|
172
|
+
// Detached child entry point — runs the full autoGraduate cycle in isolation
|
|
173
|
+
// so the parent Stop hook does not block on the inference step.
|
|
174
|
+
if (process.argv[2] === "--run") {
|
|
175
|
+
await autoGraduate();
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|