portable-agent-layer 0.71.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/skill.ts +1 -1
  4. package/src/hooks/CompactRecover.ts +28 -86
  5. package/src/hooks/LedgerUnapplied.ts +3 -28
  6. package/src/hooks/LoadContext.ts +33 -60
  7. package/src/hooks/SecurityValidator.ts +16 -109
  8. package/src/hooks/handlers/failure-principle.ts +19 -44
  9. package/src/hooks/handlers/session-intelligence.ts +13 -70
  10. package/src/hooks/lib/capture-store.ts +103 -0
  11. package/src/hooks/lib/compact-recall.ts +89 -0
  12. package/src/hooks/lib/failure-principle.ts +98 -0
  13. package/src/hooks/lib/ledger-hook.ts +35 -0
  14. package/src/hooks/lib/ledger.ts +48 -1
  15. package/src/hooks/lib/security-gate.ts +159 -0
  16. package/src/hooks/lib/session-context.ts +74 -0
  17. package/src/tools/agent/algorithm-reflect.ts +28 -97
  18. package/src/tools/agent/analyze.ts +19 -120
  19. package/src/tools/agent/handoff-note.ts +29 -77
  20. package/src/tools/agent/project.ts +13 -134
  21. package/src/tools/agent/relationship-note.ts +27 -46
  22. package/src/tools/agent/synthesize.ts +1 -1
  23. package/src/tools/agent/thread.ts +43 -123
  24. package/src/tools/control-room/data.ts +2 -2
  25. package/src/tools/control-room/matrix.ts +1 -1
  26. package/src/tools/control-room/ui/ledger.tsx +2 -1
  27. package/src/tools/ledger/view.ts +3 -0
  28. package/src/tools/lib/algorithm-reflect.ts +84 -0
  29. package/src/tools/lib/analyze-report.ts +120 -0
  30. package/src/tools/lib/handoff-note.ts +88 -0
  31. package/src/tools/lib/note-flags.ts +59 -0
  32. package/src/tools/lib/project-isc.ts +151 -0
  33. package/src/tools/lib/relationship-reflect.ts +402 -0
  34. package/src/tools/lib/self-model.ts +499 -0
  35. package/src/tools/lib/session-usage.ts +216 -0
  36. package/src/tools/lib/skill-doctor.ts +457 -0
  37. package/src/tools/lib/thread.ts +119 -0
  38. package/src/tools/lib/token-report.ts +173 -0
  39. package/src/tools/lib/transcript-usage.ts +42 -0
  40. package/src/tools/lib/usage-buckets.ts +329 -0
  41. package/src/tools/relationship-reflect.ts +48 -412
  42. package/src/tools/self-model.ts +76 -558
  43. package/src/tools/session-summary.ts +8 -215
  44. package/src/tools/skill-doctor.ts +9 -444
  45. package/src/tools/token-cost.ts +18 -428
@@ -10,70 +10,38 @@
10
10
  * bun ~/.pal/tools/handoff-note.ts --done # mark completed, suppress next-session injection
11
11
  */
12
12
 
13
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
- import { resolve } from "node:path";
13
+ import { writeFileSync } from "node:fs";
15
14
  import { parseArgs } from "node:util";
16
- import { ensureDir, paths } from "../../hooks/lib/paths";
17
15
  import { emit } from "../lib/emit";
16
+ import {
17
+ handoffFile,
18
+ type NoteInput,
19
+ readHandoffs,
20
+ recordNote,
21
+ statusOf,
22
+ } from "../lib/handoff-note";
23
+
24
+ const HELP = `
25
+ HandoffNote — Write a handoff note for the current project
18
26
 
19
- export interface HandoffEntry {
20
- timestamp: string;
21
- title: string;
22
- status: "in-progress" | "completed";
23
- handoff: string;
24
- artifacts: string[];
25
- source: "deliberate" | "auto";
26
- /** What the work needs from the human before it can move — the one thing an agent cannot unblock. */
27
- waitingOn?: string;
28
- }
29
-
30
- function handoffPath(): string {
31
- return resolve(ensureDir(paths.state()), "last-handoff.json");
32
- }
33
-
34
- export function readHandoffs(): Record<string, HandoffEntry> {
35
- const p = handoffPath();
36
- if (!existsSync(p)) return {};
37
- try {
38
- return JSON.parse(readFileSync(p, "utf-8"));
39
- } catch {
40
- return {};
41
- }
42
- }
27
+ Usage:
28
+ bun ~/.pal/tools/handoff-note.ts --title "what we were doing" --text "what remains"
29
+ bun ~/.pal/tools/handoff-note.ts --done # mark session completed
43
30
 
44
- /** Returns how many entries survived the trim, which is what the receipt reports. */
45
- function writeHandoffs(handoffs: Record<string, HandoffEntry>): number {
46
- const entries = Object.entries(handoffs);
47
- const trimmed = entries.length > 20 ? Object.fromEntries(entries.slice(-20)) : handoffs;
48
- writeFileSync(handoffPath(), JSON.stringify(trimmed, null, 2), "utf-8");
49
- return Object.keys(trimmed).length;
50
- }
31
+ Arguments:
32
+ --title Brief title of what was being worked on (5-10 words)
33
+ --text What remains unfinished — decisions made, next steps, blockers
34
+ --waiting What this needs from you before it can move (a decision, an answer, access)
35
+ --done Mark as completed; suppresses "pick up where you left off" injection
51
36
 
52
- interface NoteInput {
53
- cwd: string;
54
- title: string;
55
- text: string;
56
- done: boolean;
57
- waitingOn?: string;
58
- }
37
+ Output: writes to memory/state/last-handoff.json keyed by cwd
38
+ `;
59
39
 
60
- function writeHandoffNote(note: NoteInput): {
61
- file: string;
62
- status: HandoffEntry["status"];
63
- kept: number;
64
- } {
65
- const handoffs = readHandoffs();
66
- handoffs[note.cwd] = {
67
- timestamp: new Date().toISOString(),
68
- title: note.title,
69
- status: note.done ? "completed" : "in-progress",
70
- handoff: note.text,
71
- artifacts: [],
72
- source: "deliberate",
73
- ...(note.waitingOn ? { waitingOn: note.waitingOn } : {}),
74
- };
75
- const kept = writeHandoffs(handoffs);
76
- return { file: handoffPath(), status: handoffs[note.cwd].status, kept };
40
+ function saveNote(note: NoteInput): void {
41
+ const file = handoffFile();
42
+ const store = recordNote(readHandoffs(file), note, new Date());
43
+ writeFileSync(file, JSON.stringify(store, null, 2), "utf-8");
44
+ emit.receipt(file, { status: statusOf(note), entries: Object.keys(store).length });
77
45
  }
78
46
 
79
47
  function run() {
@@ -89,32 +57,17 @@ function run() {
89
57
  });
90
58
 
91
59
  if (values.help) {
92
- console.log(`
93
- HandoffNote — Write a handoff note for the current project
94
-
95
- Usage:
96
- bun ~/.pal/tools/handoff-note.ts --title "what we were doing" --text "what remains"
97
- bun ~/.pal/tools/handoff-note.ts --done # mark session completed
98
-
99
- Arguments:
100
- --title Brief title of what was being worked on (5-10 words)
101
- --text What remains unfinished — decisions made, next steps, blockers
102
- --waiting What this needs from you before it can move (a decision, an answer, access)
103
- --done Mark as completed; suppresses "pick up where you left off" injection
104
-
105
- Output: writes to memory/state/last-handoff.json keyed by cwd
106
- `);
60
+ console.log(HELP);
107
61
  process.exit(0);
108
62
  }
109
63
 
110
64
  if (values.done) {
111
- const result = writeHandoffNote({
65
+ saveNote({
112
66
  cwd: process.cwd(),
113
67
  title: values.title || "session",
114
68
  text: values.text || "",
115
69
  done: true,
116
70
  });
117
- emit.receipt(result.file, { status: result.status, entries: result.kept });
118
71
  process.exit(0);
119
72
  }
120
73
 
@@ -123,14 +76,13 @@ Output: writes to memory/state/last-handoff.json keyed by cwd
123
76
  process.exit(1);
124
77
  }
125
78
 
126
- const result = writeHandoffNote({
79
+ saveNote({
127
80
  cwd: process.cwd(),
128
81
  title: values.title,
129
82
  text: values.text,
130
83
  done: false,
131
84
  waitingOn: values.waiting,
132
85
  });
133
- emit.receipt(result.file, { status: result.status, entries: result.kept });
134
86
  }
135
87
 
136
88
  if (import.meta.main) run();
@@ -40,6 +40,18 @@ import {
40
40
  writeProject,
41
41
  } from "../../hooks/lib/projects";
42
42
  import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
43
+ import {
44
+ archiveLine,
45
+ dropEmptyArchiveHeadings,
46
+ encodeIscText,
47
+ ISC_BOX,
48
+ iscTitle,
49
+ nextIscId,
50
+ parseIscs,
51
+ removeIscLine,
52
+ selectIscs,
53
+ taskSlug,
54
+ } from "../lib/project-isc";
43
55
 
44
56
  function now(): string {
45
57
  return new Date().toISOString();
@@ -400,130 +412,13 @@ function cmdRm(args: string[]): void {
400
412
  // Three states, not two: a retired ISC is one that stopped being valid, which the
401
413
  // record must not report as completed work. The box character is the storage form
402
414
  // and the id stays in it, so a retired line keeps reserving its id in nextIscId.
403
- type IscStatus = "open" | "done" | "retired";
404
-
405
- const ISC_BOX: Record<IscStatus, string> = {
406
- open: "[ ]",
407
- done: "[x]",
408
- retired: "[~]",
409
- };
410
-
411
- function statusFromBox(box: string): IscStatus {
412
- if (box.toLowerCase() === "x") return "done";
413
- if (box === "~") return "retired";
414
- return "open";
415
- }
416
-
417
- export interface Isc {
418
- id: number;
419
- text: string;
420
- status: IscStatus;
421
- }
422
-
423
- /**
424
- * An ISC is one markdown line, so a newline in its text would end the record
425
- * and strand every paragraph after it as unparseable debris. Backslashes are
426
- * escaped first so that decoding a literal "\n" in a regex cannot be mistaken
427
- * for the separator.
428
- */
429
- function encodeIscText(text: string): string {
430
- return text
431
- .replaceAll("\\", "\\\\")
432
- .replaceAll("\r\n", "\n")
433
- .replaceAll("\r", "\n")
434
- .replaceAll("\n", "\\n");
435
- }
436
-
437
- const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
438
-
439
- function decodeIscText(stored: string): string {
440
- return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
441
- }
442
-
443
- export function parseIscs(criteria: string): Isc[] {
444
- const out: Isc[] = [];
445
- for (const line of criteria.split("\n")) {
446
- const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
447
- if (m)
448
- out.push({
449
- id: Number(m[2]),
450
- text: decodeIscText(m[3].trim()),
451
- status: statusFromBox(m[1]),
452
- });
453
- }
454
- return out;
455
- }
456
-
457
- // Collapse a full ISC line to a glanceable title for resume: cut at the first
458
- // clause boundary, then hard-cap length. Full text stays reachable via show-isc.
459
- function iscTitle(text: string): string {
460
- const boundary = text.search(/; | — | \(|\. /);
461
- const clause = (boundary > 0 ? text.slice(0, boundary) : text).trim();
462
- return clause.length > 80 ? `${clause.slice(0, 79).trimEnd()}…` : clause;
463
- }
464
-
465
- // Scans Criteria AND Changelog so an archived id can never be handed out again.
466
- function nextIscId(p: ProjectProgress): number {
467
- const ids = [...parseIscs(p.criteria ?? ""), ...parseIscs(p.changelog ?? "")].map(
468
- (i) => i.id
469
- );
470
- return ids.length > 0 ? Math.max(...ids) + 1 : 1;
471
- }
472
-
473
- function removeIscLine(
474
- section: string,
475
- id: number
476
- ): { line: string | null; rest: string } {
477
- const lines = section.split("\n");
478
- const idx = lines.findIndex((l) =>
479
- new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
480
- );
481
- if (idx === -1) return { line: null, rest: section };
482
- const [line] = lines.splice(idx, 1);
483
- return {
484
- line,
485
- rest: lines
486
- .join("\n")
487
- .replace(/\n{3,}/g, "\n\n")
488
- .trim(),
489
- };
490
- }
491
-
492
- // Drops any "### Archived <date>" heading whose block has no content left —
493
- // e.g. after every ISC filed under that date has been reopened.
494
- function dropEmptyArchiveHeadings(changelog: string): string {
495
- const lines = changelog.split("\n");
496
- const blockHasContent = (headingIdx: number): boolean => {
497
- for (let j = headingIdx + 1; j < lines.length && !lines[j].startsWith("### "); j++) {
498
- if (lines[j].trim() !== "") return true;
499
- }
500
- return false;
501
- };
502
- return lines
503
- .filter((l, i) => !(/^### Archived /.test(l) && !blockHasContent(i)))
504
- .join("\n")
505
- .replace(/\n{3,}/g, "\n\n")
506
- .trim();
507
- }
508
-
509
- function archiveLine(
510
- changelog: string | undefined,
511
- doneLine: string,
512
- kind: "Archived" | "Retired" = "Archived"
513
- ): string {
514
- const heading = `### ${kind} ${new Date().toISOString().slice(0, 10)}`;
515
- const base = (changelog ?? "").trim();
516
- if (base.includes(heading)) return `${base}\n${doneLine}`;
517
- return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
518
- }
519
-
520
415
  function cmdAddIsc(args: string[]): void {
521
416
  const name = args[0] ?? fail("Usage: add-isc <name> <title>");
522
417
  const title = args.slice(1).join(" ").trim();
523
418
  if (!title) fail("Usage: add-isc <name> <title>");
524
419
  const p = requireProject(name);
525
420
  const current = p.criteria ?? "";
526
- const id = nextIscId(p);
421
+ const id = nextIscId(current, p.changelog ?? "");
527
422
  const newLine = `- [ ] ISC-${id}: ${encodeIscText(title)}`;
528
423
  p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
529
424
  p.updated = now();
@@ -584,13 +479,6 @@ function cmdReopenIsc(args: string[]): void {
584
479
  ok({ checked: false, id });
585
480
  }
586
481
 
587
- function selectIscs(open: Isc[], done: Isc[], retired: Isc[], flags: Set<string>): Isc[] {
588
- if (flags.has("--all")) return [...open, ...done, ...retired];
589
- if (flags.has("--closed")) return done;
590
- if (flags.has("--retired")) return retired;
591
- return open;
592
- }
593
-
594
482
  function cmdListIsc(args: string[]): void {
595
483
  const flags = new Set(args.filter((a) => a.startsWith("--")));
596
484
  const name =
@@ -721,15 +609,6 @@ function cmdPruneIsc(args: string[]): void {
721
609
 
722
610
  // ── Task ISA (work/) ──────────────────────────────────────────────
723
611
 
724
- function taskSlug(title: string): string {
725
- const sanitized = title
726
- .toLowerCase()
727
- .replace(/[^a-z0-9]+/g, "-")
728
- .replace(/^-+|-+$/g, "")
729
- .slice(0, 40);
730
- return `${sanitized}-${Date.now().toString(36)}`;
731
- }
732
-
733
612
  function taskIsaPath(slug: string): string {
734
613
  const dir = resolve(paths.work(), slug);
735
614
  mkdirSync(dir, { recursive: true });
@@ -13,27 +13,17 @@
13
13
  * Note types:
14
14
  * --o Opinion/behavioral observation about the user (requires --confidence)
15
15
  * --w World fact about the user's situation (objective, observable)
16
- * --b Session diary — what Jarvis did this session (first-person, specific)
16
+ * --b Session diary — what the assistant did this session (first-person)
17
+ *
18
+ * Which flags make which notes is in lib/note-flags.ts.
17
19
  */
18
20
 
19
21
  import { parseArgs } from "node:util";
20
22
  import { appendNotes } from "../../hooks/lib/relationship";
21
23
  import { emit } from "../lib/emit";
24
+ import { notesFromFlags } from "../lib/note-flags";
22
25
 
23
- function run() {
24
- const { values } = parseArgs({
25
- args: Bun.argv.slice(2),
26
- options: {
27
- o: { type: "string", multiple: true },
28
- w: { type: "string", multiple: true },
29
- b: { type: "string" },
30
- confidence: { type: "string" },
31
- help: { type: "boolean", short: "h" },
32
- },
33
- });
34
-
35
- if (values.help) {
36
- console.log(`
26
+ const HELP = `
37
27
  RelationshipNote — Append W/O/Session entries to today's relationship log
38
28
 
39
29
  Usage:
@@ -45,45 +35,36 @@ Flags:
45
35
  --o TEXT Opinion/behavioral observation about the user
46
36
  --confidence N Confidence for --o (0.0–1.0, default 0.75)
47
37
  --w TEXT World fact about the user's situation
48
- --b TEXT Session diary — what Jarvis did (first-person, specific)
38
+ --b TEXT Session diary — what the assistant did (first-person)
49
39
 
50
40
  Multiple flags may be combined in one call. At least one of --o, --w, --b is required.
51
41
 
52
42
  Output: appends to memory/relationship/YYYY-MM/YYYY-MM-DD.md
53
- `);
54
- process.exit(0);
55
- }
56
-
57
- if (!values.o && !values.w && !values.b) {
58
- console.error("Required: at least one of --o, --w, --b");
59
- process.exit(1);
60
- }
61
-
62
- const notes = [];
43
+ `;
63
44
 
64
- if (values.o && values.o.length > 0) {
65
- const confidence = values.confidence ? Number.parseFloat(values.confidence) : 0.75;
66
- if (Number.isNaN(confidence) || confidence < 0 || confidence > 1) {
67
- console.error("--confidence must be a number between 0.0 and 1.0");
68
- process.exit(1);
69
- }
70
- for (const text of values.o) {
71
- notes.push({ type: "O" as const, text, confidence });
72
- }
73
- }
45
+ if (import.meta.main) {
46
+ const { values } = parseArgs({
47
+ args: Bun.argv.slice(2),
48
+ options: {
49
+ o: { type: "string", multiple: true },
50
+ w: { type: "string", multiple: true },
51
+ b: { type: "string" },
52
+ confidence: { type: "string" },
53
+ help: { type: "boolean", short: "h" },
54
+ },
55
+ });
74
56
 
75
- if (values.w && values.w.length > 0) {
76
- for (const text of values.w) {
77
- notes.push({ type: "W" as const, text });
78
- }
57
+ if (values.help) {
58
+ console.log(HELP);
59
+ process.exit(0);
79
60
  }
80
61
 
81
- if (values.b) {
82
- notes.push({ type: "Session" as const, text: values.b });
62
+ const result = notesFromFlags(values);
63
+ if ("error" in result) {
64
+ console.error(result.error);
65
+ process.exit(1);
83
66
  }
84
67
 
85
- const { file, written } = appendNotes(notes);
86
- emit.receipt(file, { written, deduped: notes.length - written });
68
+ const { file, written } = appendNotes(result.notes);
69
+ emit.receipt(file, { written, deduped: result.notes.length - written });
87
70
  }
88
-
89
- if (import.meta.main) run();
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { resolve } from "node:path";
18
18
  import { parseArgs } from "node:util";
19
19
  import { ensureDir, paths } from "../../hooks/lib/paths";
20
- import { readJsonl } from "../self-model";
20
+ import { readJsonl } from "../lib/self-model";
21
21
 
22
22
  // ── Config ──
23
23
 
@@ -11,99 +11,54 @@
11
11
  * bun ~/.pal/tools/thread.ts --list [--all]
12
12
  */
13
13
 
14
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
15
- import { resolve } from "node:path";
16
14
  import { parseArgs } from "node:util";
17
- import { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
18
- import { encodeAnchor } from "../../hooks/lib/anchor";
19
- import { ensureDir, paths } from "../../hooks/lib/paths";
20
15
  import { emit } from "../lib/emit";
16
+ import {
17
+ addThread,
18
+ readThreads,
19
+ resolveThreadIn,
20
+ threadsFile,
21
+ visibleThreads,
22
+ writeThreads,
23
+ } from "../lib/thread";
24
+
25
+ const HELP = `
26
+ Thread — Manage open threads across sessions
21
27
 
22
- // ── Types ──
23
-
24
- export interface Thread extends RecordAttribution {
25
- id: string;
26
- cwd: string;
27
- title: string;
28
- context: string;
29
- status: "open" | "resolved";
30
- created: string;
31
- resolved: string | null;
32
- }
33
-
34
- // ── Storage ──
35
-
36
- function threadsPath(): string {
37
- return resolve(ensureDir(paths.state()), "threads.jsonl");
38
- }
39
-
40
- function generateId(): string {
41
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
42
- }
28
+ Usage:
29
+ thread.ts --add --title "..." [--context "..."]
30
+ thread.ts --resolve --id <id>
31
+ thread.ts --list [--all]
32
+ `;
43
33
 
44
- export function readThreads(): Thread[] {
45
- const p = threadsPath();
46
- if (!existsSync(p)) return [];
47
- try {
48
- return readFileSync(p, "utf-8")
49
- .split("\n")
50
- .filter((l) => l.trim())
51
- .map((l) => JSON.parse(l) as Thread);
52
- } catch {
53
- return [];
34
+ function add(title: string | undefined, context: string | undefined) {
35
+ if (!title) {
36
+ console.error("--title required");
37
+ process.exit(1);
54
38
  }
39
+ const thread = addThread(title, context ?? "");
40
+ emit.receipt(threadsFile(), {
41
+ id: thread.id,
42
+ title: thread.title,
43
+ status: thread.status,
44
+ });
55
45
  }
56
46
 
57
- export function writeThreads(threads: Thread[]): void {
58
- writeFileSync(
59
- threadsPath(),
60
- `${threads.map((t) => JSON.stringify(t)).join("\n")}\n`,
61
- "utf-8"
62
- );
63
- }
64
-
65
- // ── Operations ──
66
-
67
- /** Exported so the cwd-anchor and origin-stamp wiring is directly testable. */
68
- export function addThread(title: string, context: string): Thread {
69
- const thread: Thread = {
70
- id: generateId(),
71
- cwd: encodeAnchor(process.cwd()),
72
- ...currentAttribution(),
73
- title,
74
- context,
75
- status: "open",
76
- created: new Date().toISOString(),
77
- resolved: null,
78
- };
79
- appendFileSync(threadsPath(), `${JSON.stringify(thread)}\n`, "utf-8");
80
- return thread;
81
- }
82
-
83
- function resolveThread(id: string): {
84
- success: boolean;
85
- thread?: Thread;
86
- message: string;
87
- } {
88
- const threads = readThreads();
89
- const idx = threads.findIndex((t) => t.id === id);
90
- if (idx === -1) return { success: false, message: `Thread not found: ${id}` };
91
- threads[idx].status = "resolved";
92
- threads[idx].resolved = new Date().toISOString();
93
- writeThreads(threads);
94
- return {
95
- success: true,
96
- thread: threads[idx],
97
- message: `Resolved: ${threads[idx].title}`,
98
- };
99
- }
100
-
101
- function listThreads(all: boolean): Thread[] {
102
- return all ? readThreads() : readThreads().filter((t) => t.status === "open");
47
+ function markResolved(id: string | undefined) {
48
+ if (!id) {
49
+ console.error("--id required");
50
+ process.exit(1);
51
+ }
52
+ const file = threadsFile();
53
+ const resolution = resolveThreadIn(readThreads(file), id, new Date());
54
+ if (!resolution) {
55
+ console.error(`Thread not found: ${id}`);
56
+ process.exit(1);
57
+ }
58
+ writeThreads(resolution.threads, file);
59
+ emit.receipt(file, { id, status: "resolved", title: resolution.thread.title });
103
60
  }
104
61
 
105
- // ── CLI ──
106
-
107
62
  function run() {
108
63
  const { values } = parseArgs({
109
64
  args: Bun.argv.slice(2),
@@ -125,49 +80,14 @@ function run() {
125
80
  else if (values.list) cmd = "list";
126
81
 
127
82
  if (values.help || !cmd) {
128
- console.log(`
129
- Thread — Manage open threads across sessions
130
-
131
- Usage:
132
- thread.ts --add --title "..." [--context "..."]
133
- thread.ts --resolve --id <id>
134
- thread.ts --list [--all]
135
- `);
83
+ console.log(HELP);
136
84
  process.exit(cmd ? 0 : 1);
137
85
  }
138
86
 
139
- if (cmd === "add") {
140
- if (!values.title) {
141
- console.error("--title required");
142
- process.exit(1);
143
- }
144
- const thread = addThread(values.title, values.context ?? "");
145
- emit.receipt(threadsPath(), {
146
- id: thread.id,
147
- title: thread.title,
148
- status: thread.status,
149
- });
150
- }
151
-
152
- if (cmd === "resolve") {
153
- if (!values.id) {
154
- console.error("--id required");
155
- process.exit(1);
156
- }
157
- const resolved = resolveThread(values.id);
158
- if (!resolved.success) {
159
- console.error(resolved.message);
160
- process.exit(1);
161
- }
162
- emit.receipt(threadsPath(), {
163
- id: values.id,
164
- status: "resolved",
165
- title: resolved.thread?.title,
166
- });
167
- }
168
-
87
+ if (cmd === "add") add(values.title, values.context);
88
+ if (cmd === "resolve") markResolved(values.id);
169
89
  if (cmd === "list") {
170
- const threads = listThreads(values.all ?? false);
90
+ const threads = visibleThreads(readThreads(), values.all ?? false);
171
91
  emit.data(JSON.stringify({ count: threads.length, threads }, null, 2));
172
92
  }
173
93
  }
@@ -16,9 +16,9 @@ import { loadAnalyzeNudge } from "../../hooks/lib/analyze-nudge";
16
16
  import { paths } from "../../hooks/lib/paths";
17
17
  import { isStale, type ProjectProgress, readAllProjects } from "../../hooks/lib/projects";
18
18
  import { readProjectHistory } from "../../hooks/lib/work-tracking";
19
- import { type HandoffEntry, readHandoffs } from "../agent/handoff-note";
20
- import { parseIscs } from "../agent/project";
21
19
  import { anchorSlugOf, type LedgerFilter, queryLedger } from "../ledger/query";
20
+ import { type HandoffEntry, readHandoffs } from "../lib/handoff-note";
21
+ import { parseIscs } from "../lib/project-isc";
22
22
 
23
23
  const DAY_MS = 86_400_000;
24
24
  const HANDOFF_FRESH_DAYS = 7;
@@ -20,7 +20,7 @@ import {
20
20
  } from "../../hooks/lib/projects";
21
21
  import { isImportant, SERVES_MEANING } from "../../hooks/lib/serves";
22
22
  import { dueFrom, readTelosGoals, type TelosGoal } from "../../hooks/lib/telos-goals";
23
- import type { HandoffEntry } from "../agent/handoff-note";
23
+ import type { HandoffEntry } from "../lib/handoff-note";
24
24
  import { freshHandoffs } from "./data";
25
25
 
26
26
  const URGENT_WITHIN_DAYS = 14;
@@ -28,8 +28,9 @@ function Row({ r }: { r: LedgerViewRow }) {
28
28
  </span>
29
29
  </td>
30
30
  <td>{r.tool}</td>
31
- <td className="target" title={r.target}>
31
+ <td className="target" title={r.command ?? r.target}>
32
32
  <b>{slug}</b> {rest.join(" ")}
33
+ {r.command && <span className="reason">{r.command}</span>}
33
34
  </td>
34
35
  <td>{r.change}</td>
35
36
  <td>
@@ -40,6 +40,8 @@ export interface LedgerViewRow {
40
40
  change: string;
41
41
  outcome: string;
42
42
  reason?: string;
43
+ /** Present only on a blocked shell action, where the target is a directory. */
44
+ command?: string;
43
45
  }
44
46
 
45
47
  export interface LedgerView {
@@ -100,6 +102,7 @@ function toRow(entry: LedgerEntry, registry: ActorRegistryEntry[]): LedgerViewRo
100
102
  outcome: entry.outcome,
101
103
  };
102
104
  if (entry.reason) row.reason = entry.reason;
105
+ if (entry.command) row.command = entry.command;
103
106
  return row;
104
107
  }
105
108