portable-agent-layer 0.64.0 → 0.65.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.
@@ -24,6 +24,7 @@
24
24
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { resolve } from "node:path";
26
26
  import { parseArgs } from "node:util";
27
+ import { writeBinding } from "../../hooks/lib/bindings";
27
28
  import { paths } from "../../hooks/lib/paths";
28
29
  import {
29
30
  defaultSlug,
@@ -32,6 +33,7 @@ import {
32
33
  legacyJsonToProgress,
33
34
  type ProjectProgress,
34
35
  type ProjectStatus,
36
+ proposeBinding,
35
37
  readAllProjects,
36
38
  readProject,
37
39
  writeProject,
@@ -132,14 +134,28 @@ function cmdResume(args: string[]): void {
132
134
  if (!name) fail("Usage: resume <name>");
133
135
  const { criteria, changelog, ...project } = requireProject(name);
134
136
  const iscs = parseIscs(criteria ?? "");
135
- const openIscs = iscs.filter((i) => !i.checked);
136
- const done = iscs.filter((i) => i.checked).length + parseIscs(changelog ?? "").length;
137
+ const archived = parseIscs(changelog ?? "");
138
+ const openIscs = iscs.filter((i) => i.status === "open");
139
+ const all = [...iscs, ...archived];
140
+ const done = all.filter((i) => i.status === "done").length;
141
+ const retired = all.filter((i) => i.status === "retired").length;
142
+ // Resuming a project PAL cannot locate is the natural moment to offer a
143
+ // binding: the user just named this project, so the suggestion is wanted rather
144
+ // than volunteered. It is only ever a command — nothing binds on its own.
145
+ const unlocatable = !project.path || !existsSync(project.path);
146
+ const binding = unlocatable
147
+ ? proposeBinding({ ...project, criteria, changelog })
148
+ : null;
149
+
137
150
  ok({
138
151
  project: {
139
152
  ...project,
140
153
  open_iscs: openIscs.map((i) => ({ id: i.id, title: iscTitle(i.text) })),
141
- isc_summary: { open: openIscs.length, done },
154
+ isc_summary: { open: openIscs.length, done, retired },
142
155
  },
156
+ ...(unlocatable
157
+ ? { binding: binding ?? { state: "unbound", confidence: "none" } }
158
+ : {}),
143
159
  });
144
160
  }
145
161
 
@@ -206,12 +222,15 @@ function addHandoff(name: string, text: string): void {
206
222
 
207
223
  // ── set-path ──────────────────────────────────────────────────────
208
224
 
225
+ // Where a project lives is machine-local, so this writes a binding rather than a
226
+ // field on the record. Unlike the save path it does not require the directory to
227
+ // exist yet: naming where a repo is about to be cloned is a legitimate use.
209
228
  function cmdSetPath(args: string[]): void {
210
229
  const [name, ...rest] = args;
211
230
  if (!name || rest.length === 0) fail("Usage: set-path <name> <new-path>");
212
231
  const newPath = resolve(rest.join(" ").trim());
213
232
  const p = requireProject(name);
214
- p.path = newPath;
233
+ writeBinding(p.name, newPath);
215
234
  p.updated = now();
216
235
  writeProject(p);
217
236
  ok({ updated: true, name, path: newPath });
@@ -350,17 +369,34 @@ function cmdRm(args: string[]): void {
350
369
 
351
370
  // ── ISC helpers ──────────────────────────────────────────────────
352
371
 
372
+ // Three states, not two: a retired ISC is one that stopped being valid, which the
373
+ // record must not report as completed work. The box character is the storage form
374
+ // and the id stays in it, so a retired line keeps reserving its id in nextIscId.
375
+ type IscStatus = "open" | "done" | "retired";
376
+
377
+ const ISC_BOX: Record<IscStatus, string> = {
378
+ open: "[ ]",
379
+ done: "[x]",
380
+ retired: "[~]",
381
+ };
382
+
383
+ function statusFromBox(box: string): IscStatus {
384
+ if (box.toLowerCase() === "x") return "done";
385
+ if (box === "~") return "retired";
386
+ return "open";
387
+ }
388
+
353
389
  interface Isc {
354
390
  id: number;
355
391
  text: string;
356
- checked: boolean;
392
+ status: IscStatus;
357
393
  }
358
394
 
359
395
  function parseIscs(criteria: string): Isc[] {
360
396
  const out: Isc[] = [];
361
397
  for (const line of criteria.split("\n")) {
362
- const m = new RegExp(/^-\s+\[( |x)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
363
- if (m) out.push({ id: Number(m[2]), text: m[3].trim(), checked: m[1] === "x" });
398
+ const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
399
+ if (m) out.push({ id: Number(m[2]), text: m[3].trim(), status: statusFromBox(m[1]) });
364
400
  }
365
401
  return out;
366
402
  }
@@ -387,7 +423,7 @@ function removeIscLine(
387
423
  ): { line: string | null; rest: string } {
388
424
  const lines = section.split("\n");
389
425
  const idx = lines.findIndex((l) =>
390
- new RegExp(String.raw`^-\s+\[[ x]\]\s+ISC-${id}:`).test(l)
426
+ new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
391
427
  );
392
428
  if (idx === -1) return { line: null, rest: section };
393
429
  const [line] = lines.splice(idx, 1);
@@ -417,8 +453,12 @@ function dropEmptyArchiveHeadings(changelog: string): string {
417
453
  .trim();
418
454
  }
419
455
 
420
- function archiveLine(changelog: string | undefined, doneLine: string): string {
421
- const heading = `### Archived ${new Date().toISOString().slice(0, 10)}`;
456
+ function archiveLine(
457
+ changelog: string | undefined,
458
+ doneLine: string,
459
+ kind: "Archived" | "Retired" = "Archived"
460
+ ): string {
461
+ const heading = `### ${kind} ${new Date().toISOString().slice(0, 10)}`;
422
462
  const base = (changelog ?? "").trim();
423
463
  if (base.includes(heading)) return `${base}\n${doneLine}`;
424
464
  return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
@@ -472,7 +512,7 @@ function cmdReopenIsc(args: string[]): void {
472
512
  const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
473
513
  if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
474
514
  const p = requireProject(name);
475
- if (parseIscs(p.criteria ?? "").some((i) => i.id === id && !i.checked)) {
515
+ if (parseIscs(p.criteria ?? "").some((i) => i.id === id && i.status === "open")) {
476
516
  ok({ checked: false, id, alreadyOpen: true });
477
517
  return;
478
518
  }
@@ -484,16 +524,17 @@ function cmdReopenIsc(args: string[]): void {
484
524
  if (removed.line) p.criteria = removed.rest;
485
525
  }
486
526
  if (!removed.line) fail(`ISC-${id} not found in project "${name}"`);
487
- const openLine = removed.line.replace(/\[x\]/i, "[ ]");
527
+ const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
488
528
  p.criteria = p.criteria ? `${p.criteria.trimEnd()}\n${openLine}` : openLine;
489
529
  p.updated = now();
490
530
  writeProject(p);
491
531
  ok({ checked: false, id });
492
532
  }
493
533
 
494
- function selectIscs(open: Isc[], done: Isc[], flags: Set<string>): Isc[] {
495
- if (flags.has("--all")) return [...open, ...done];
534
+ function selectIscs(open: Isc[], done: Isc[], retired: Isc[], flags: Set<string>): Isc[] {
535
+ if (flags.has("--all")) return [...open, ...done, ...retired];
496
536
  if (flags.has("--closed")) return done;
537
+ if (flags.has("--retired")) return retired;
497
538
  return open;
498
539
  }
499
540
 
@@ -501,17 +542,20 @@ function cmdListIsc(args: string[]): void {
501
542
  const flags = new Set(args.filter((a) => a.startsWith("--")));
502
543
  const name =
503
544
  args.find((a) => !a.startsWith("--")) ??
504
- fail("Usage: list-isc <name> [--all | --closed]");
545
+ fail("Usage: list-isc <name> [--all | --closed | --retired]");
505
546
  const p = requireProject(name);
506
547
  const criteria = parseIscs(p.criteria ?? "");
507
- const open = criteria.filter((i) => !i.checked);
508
- const done = [...criteria.filter((i) => i.checked), ...parseIscs(p.changelog ?? "")];
548
+ const all = [...criteria, ...parseIscs(p.changelog ?? "")];
549
+ const open = all.filter((i) => i.status === "open");
550
+ const done = all.filter((i) => i.status === "done");
551
+ const retired = all.filter((i) => i.status === "retired");
509
552
  ok({
510
553
  name,
511
- total: open.length + done.length,
554
+ total: open.length + done.length + retired.length,
512
555
  open: open.length,
513
556
  done: done.length,
514
- iscs: selectIscs(open, done, flags),
557
+ retired: retired.length,
558
+ iscs: selectIscs(open, done, retired, flags),
515
559
  });
516
560
  }
517
561
 
@@ -526,7 +570,80 @@ function cmdShowIsc(args: string[]): void {
526
570
  (i) => i.id === id
527
571
  );
528
572
  if (!isc) fail(`ISC-${id} not found in project "${name}".`);
529
- ok({ name, id: isc.id, status: isc.checked ? "closed" : "open", text: isc.text });
573
+ ok({ name, id: isc.id, status: isc.status, text: isc.text });
574
+ }
575
+
576
+ // retire-isc closes an ISC that stopped being valid, which complete-isc cannot say:
577
+ // completing files it as done work. The line moves to the Changelog under its own
578
+ // heading as [~], so it still reserves its id and never reads as finished.
579
+ function cmdRetireIsc(args: string[]): void {
580
+ const positional = args.filter((a) => !a.startsWith("--"));
581
+ const name = positional[0];
582
+ const id = Number(positional[1]);
583
+ if (!name || !Number.isInteger(id) || id < 1) {
584
+ fail("Usage: retire-isc <name> <id> [--by <supersedingId>]");
585
+ }
586
+ const byIndex = args.indexOf("--by");
587
+ const by = byIndex === -1 ? null : Number(args[byIndex + 1]);
588
+ if (byIndex !== -1 && (!Number.isInteger(by) || (by ?? 0) < 1)) {
589
+ fail("--by expects a positive ISC id");
590
+ }
591
+ const p = requireProject(name);
592
+ if (parseIscs(p.changelog ?? "").some((i) => i.id === id && i.status === "retired")) {
593
+ ok({ retired: true, id, alreadyRetired: true });
594
+ return;
595
+ }
596
+ const { line, rest } = removeIscLine(p.criteria ?? "", id);
597
+ if (!line) fail(`ISC-${id} not found in project "${name}"`);
598
+ const suffix = by ? ` (superseded by ISC-${by})` : "";
599
+ p.criteria = rest;
600
+ p.changelog = archiveLine(
601
+ p.changelog,
602
+ `${line.replace(/\[[ x]\]/i, "[~]")}${suffix}`,
603
+ "Retired"
604
+ );
605
+ p.updated = now();
606
+ writeProject(p);
607
+ ok({ retired: true, id, supersededBy: by, archived: true });
608
+ }
609
+
610
+ // edit-isc rewrites one ISC's text in place, keeping its id and open/done state.
611
+ // The id never leaves the record, so nextIscId still reserves it. Returns the
612
+ // previous text because the ISA files carry no version history of their own.
613
+ function cmdEditIsc(args: string[]): void {
614
+ const name = args[0];
615
+ const id = Number(args[1]);
616
+ const text = args.slice(2).join(" ").trim();
617
+ if (!name || !Number.isInteger(id) || id < 1 || !text) {
618
+ fail('Usage: edit-isc <name> <id> "new text"');
619
+ }
620
+ const p = requireProject(name);
621
+ const inCriteria = parseIscs(p.criteria ?? "").find((i) => i.id === id);
622
+ const isc = inCriteria ?? parseIscs(p.changelog ?? "").find((i) => i.id === id);
623
+ if (!isc) fail(`ISC-${id} not found in project "${name}".`);
624
+
625
+ const box = ISC_BOX[isc.status];
626
+ const rewrite = (section: string) =>
627
+ section
628
+ .split("\n")
629
+ .map((l) =>
630
+ new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`, "i").test(l)
631
+ ? `- ${box} ISC-${id}: ${text}`
632
+ : l
633
+ )
634
+ .join("\n");
635
+
636
+ if (inCriteria) p.criteria = rewrite(p.criteria ?? "");
637
+ else p.changelog = rewrite(p.changelog ?? "");
638
+ p.updated = now();
639
+ writeProject(p);
640
+ ok({
641
+ edited: true,
642
+ id,
643
+ status: isc.status,
644
+ previous: isc.text,
645
+ text,
646
+ });
530
647
  }
531
648
 
532
649
  // Backfill: sweep any done ISCs still sitting in Criteria (legacy projects, or
@@ -534,7 +651,7 @@ function cmdShowIsc(args: string[]): void {
534
651
  function cmdPruneIsc(args: string[]): void {
535
652
  const name = args[0] ?? fail("Usage: prune-isc <name>");
536
653
  const p = requireProject(name);
537
- const done = parseIscs(p.criteria ?? "").filter((i) => i.checked);
654
+ const done = parseIscs(p.criteria ?? "").filter((i) => i.status !== "open");
538
655
  for (const isc of done) {
539
656
  const { line, rest } = removeIscLine(p.criteria ?? "", isc.id);
540
657
  if (!line) continue;
@@ -545,7 +662,7 @@ function cmdPruneIsc(args: string[]): void {
545
662
  p.updated = now();
546
663
  writeProject(p);
547
664
  }
548
- const openLeft = parseIscs(p.criteria ?? "").filter((i) => !i.checked).length;
665
+ const openLeft = parseIscs(p.criteria ?? "").filter((i) => i.status === "open").length;
549
666
  ok({ pruned: done.length, name, remaining_open: openLeft });
550
667
  }
551
668
 
@@ -628,8 +745,10 @@ Commands:
628
745
  add-isc <name> "title" append a new open ISC to Criteria
629
746
  complete-isc <name> <id> mark ISC-N as done
630
747
  reopen-isc <name> <id> reopen ISC-N (mark not done)
631
- list-isc <name> [--all | --closed] list open ISCs (default); --all or --closed for done
748
+ list-isc <name> [--all | --closed | --retired] list open ISCs (default); --all, --closed, or --retired
632
749
  show-isc <name> <id> print one ISC's full text
750
+ edit-isc <name> <id> "new text" rewrite ISC-N's text, keeping its id and state
751
+ retire-isc <name> <id> [--by <id>] close ISC-N as no longer valid, not as done
633
752
  prune-isc <name> archive done ISCs from Criteria into the Changelog
634
753
  isa-init <name> mark project as ISA-initialized
635
754
  scaffold-task-isa <title> create a one-shot task ISA in memory/work/
@@ -725,6 +844,12 @@ function run(): void {
725
844
  case "show-isc":
726
845
  cmdShowIsc(rest);
727
846
  return;
847
+ case "retire-isc":
848
+ cmdRetireIsc(rest);
849
+ return;
850
+ case "edit-isc":
851
+ cmdEditIsc(rest);
852
+ return;
728
853
  case "prune-isc":
729
854
  cmdPruneIsc(rest);
730
855
  return;
@@ -14,6 +14,8 @@
14
14
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
16
  import { parseArgs } from "node:util";
17
+ import { encodeAnchor } from "../../hooks/lib/anchor";
18
+ import { loadMachine } from "../../hooks/lib/machine";
17
19
  import { ensureDir, paths } from "../../hooks/lib/paths";
18
20
  import { emit } from "../lib/emit";
19
21
 
@@ -22,6 +24,7 @@ import { emit } from "../lib/emit";
22
24
  export interface Thread {
23
25
  id: string;
24
26
  cwd: string;
27
+ m: string;
25
28
  title: string;
26
29
  context: string;
27
30
  status: "open" | "resolved";
@@ -62,10 +65,12 @@ export function writeThreads(threads: Thread[]): void {
62
65
 
63
66
  // ── Operations ──
64
67
 
65
- function addThread(title: string, context: string): Thread {
68
+ /** Exported so the cwd-anchor and origin-stamp wiring is directly testable. */
69
+ export function addThread(title: string, context: string): Thread {
66
70
  const thread: Thread = {
67
71
  id: generateId(),
68
- cwd: process.cwd(),
72
+ cwd: encodeAnchor(process.cwd()),
73
+ m: loadMachine().id,
69
74
  title,
70
75
  context,
71
76
  status: "open",
@@ -1,44 +0,0 @@
1
- const USAGE = "usage: shot.ts <url> [-o <file>] [--viewport WxH] [--full-page] [--selector <css>] [--wait <ms>]";
2
- export function parseArgs(argv) {
3
- let url = "";
4
- let out = "";
5
- let viewport;
6
- let fullPage = false;
7
- let selector;
8
- let waitMs;
9
- for (let i = 0;i < argv.length; i++) {
10
- const a = argv[i];
11
- if (a === "-o" || a === "--out")
12
- out = argv[++i] ?? "";
13
- else if (a === "--viewport") {
14
- const m = /^(\d+)[x,](\d+)$/.exec(argv[++i] ?? "");
15
- if (!m)
16
- throw new Error("--viewport expects WxH, e.g. 1440x900");
17
- viewport = { width: Number(m[1]), height: Number(m[2]) };
18
- } else if (a === "--full-page")
19
- fullPage = true;
20
- else if (a === "--selector")
21
- selector = argv[++i];
22
- else if (a === "--wait") {
23
- const n = Number(argv[++i]);
24
- if (!Number.isFinite(n))
25
- throw new Error("--wait expects a number of milliseconds");
26
- waitMs = n;
27
- } else if (!a.startsWith("-") && !url)
28
- url = a;
29
- else
30
- throw new Error(`unknown argument: ${a}
31
- ${USAGE}`);
32
- }
33
- if (!url)
34
- throw new Error(`a URL is required
35
- ${USAGE}`);
36
- return { url, out, viewport, fullPage, selector, waitMs };
37
- }
38
- export function chooseTier(opts) {
39
- if (!opts.cliAvailable)
40
- return "node";
41
- if (opts.viewport || opts.fullPage)
42
- return "node";
43
- return "cli";
44
- }
@@ -1,89 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
5
- import { chooseTier, parseArgs } from "./shot-lib.mjs";
6
- function playwrightCliAvailable() {
7
- try {
8
- return spawnSync("playwright-cli", ["--version"], { stdio: "ignore" }).status === 0;
9
- } catch {
10
- return false;
11
- }
12
- }
13
- function runViaCli(opts, out) {
14
- const work = mkdtempSync(join(tmpdir(), "pal-pwcli-"));
15
- const run = (args, quiet = false) => spawnSync("playwright-cli", args, { stdio: quiet ? "ignore" : "inherit", cwd: work });
16
- try {
17
- if (run(["open", opts.url]).status !== 0)
18
- return false;
19
- const args = ["screenshot", `--filename=${out}`];
20
- if (opts.selector)
21
- args.push(opts.selector);
22
- const shot = run(args);
23
- run(["close"], true);
24
- return shot.status === 0 && existsSync(out);
25
- } finally {
26
- rmSync(work, { recursive: true, force: true });
27
- }
28
- }
29
- async function runViaNode(opts, out) {
30
- let chromium;
31
- try {
32
- ({ chromium } = await import("playwright"));
33
- } catch {
34
- return "unavailable";
35
- }
36
- let browser;
37
- try {
38
- browser = await chromium.launch();
39
- } catch (e) {
40
- console.error(`chromium launch failed: ${e.message}`);
41
- return "unavailable";
42
- }
43
- try {
44
- const page = await browser.newPage(opts.viewport ? { viewport: opts.viewport } : {});
45
- await page.goto(opts.url, { waitUntil: "networkidle" });
46
- if (opts.waitMs)
47
- await page.waitForTimeout(opts.waitMs);
48
- if (opts.selector)
49
- await page.locator(opts.selector).screenshot({ path: out });
50
- else
51
- await page.screenshot({ path: out, fullPage: opts.fullPage });
52
- return existsSync(out) ? "ok" : "error";
53
- } catch (e) {
54
- console.error(`screenshot failed: ${e.message}`);
55
- return "error";
56
- } finally {
57
- await browser.close();
58
- }
59
- }
60
- async function main() {
61
- let opts;
62
- try {
63
- opts = parseArgs(process.argv.slice(2));
64
- } catch (e) {
65
- console.error(e.message);
66
- process.exit(2);
67
- }
68
- const out = opts.out ? resolve(opts.out) : join(tmpdir(), `pal-shot-${Date.now()}.png`);
69
- const tier = chooseTier({
70
- cliAvailable: playwrightCliAvailable(),
71
- viewport: opts.viewport,
72
- fullPage: opts.fullPage
73
- });
74
- if (tier === "cli" && runViaCli(opts, out)) {
75
- console.log(out);
76
- return;
77
- }
78
- const result = await runViaNode(opts, out);
79
- if (result === "ok") {
80
- console.log(out);
81
- return;
82
- }
83
- if (result === "unavailable") {
84
- console.error("NO_PLAYWRIGHT_CLI");
85
- process.exit(3);
86
- }
87
- process.exit(1);
88
- }
89
- await main();