portable-agent-layer 0.70.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 (81) hide show
  1. package/README.md +5 -1
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/onboarding/SKILL.md +109 -0
  4. package/assets/skills/projects/SKILL.md +11 -2
  5. package/assets/templates/pal-settings.json +1 -0
  6. package/package.json +5 -1
  7. package/src/cli/index.ts +39 -12
  8. package/src/cli/migrate.ts +1 -1
  9. package/src/cli/personal-context.ts +67 -0
  10. package/src/cli/server.ts +13 -7
  11. package/src/cli/setup-identity.ts +13 -1
  12. package/src/cli/skill.ts +1 -1
  13. package/src/hooks/CompactRecover.ts +28 -86
  14. package/src/hooks/LedgerUnapplied.ts +3 -28
  15. package/src/hooks/LoadContext.ts +33 -60
  16. package/src/hooks/SecurityValidator.ts +16 -109
  17. package/src/hooks/handlers/agenda.ts +223 -0
  18. package/src/hooks/handlers/failure-principle.ts +19 -44
  19. package/src/hooks/handlers/inject-retrieval.ts +6 -2
  20. package/src/hooks/handlers/session-intelligence.ts +13 -70
  21. package/src/hooks/lib/agenda-store.ts +41 -0
  22. package/src/hooks/lib/capture-store.ts +103 -0
  23. package/src/hooks/lib/compact-recall.ts +89 -0
  24. package/src/hooks/lib/failure-principle.ts +98 -0
  25. package/src/hooks/lib/ledger-hook.ts +35 -0
  26. package/src/hooks/lib/ledger.ts +48 -1
  27. package/src/hooks/lib/paths.ts +0 -1
  28. package/src/hooks/lib/projects.ts +16 -1
  29. package/src/hooks/lib/security-gate.ts +159 -0
  30. package/src/hooks/lib/serves.ts +60 -0
  31. package/src/hooks/lib/session-context.ts +74 -0
  32. package/src/hooks/lib/stop.ts +14 -0
  33. package/src/hooks/lib/telos-goals.ts +144 -0
  34. package/src/hooks/lib/telos-topics.ts +68 -0
  35. package/src/hooks/lib/token-usage.ts +3 -1
  36. package/src/hooks/lib/wall-clock.ts +58 -0
  37. package/src/tools/agent/algorithm-reflect.ts +28 -97
  38. package/src/tools/agent/analyze.ts +19 -120
  39. package/src/tools/agent/handoff-note.ts +40 -70
  40. package/src/tools/agent/project.ts +47 -136
  41. package/src/tools/agent/relationship-note.ts +27 -46
  42. package/src/tools/agent/synthesize.ts +1 -1
  43. package/src/tools/agent/thread.ts +43 -123
  44. package/src/tools/control-room/data.ts +332 -0
  45. package/src/tools/control-room/matrix.ts +182 -0
  46. package/src/tools/control-room/server.ts +150 -0
  47. package/src/tools/control-room/ui/agenda.tsx +43 -0
  48. package/src/tools/control-room/ui/agents.tsx +67 -0
  49. package/src/tools/control-room/ui/app.css +857 -0
  50. package/src/tools/control-room/ui/app.tsx +74 -0
  51. package/src/tools/control-room/ui/board.tsx +82 -0
  52. package/src/tools/control-room/ui/format.ts +31 -0
  53. package/src/tools/control-room/ui/handoffs.tsx +37 -0
  54. package/src/tools/control-room/ui/index.html +19 -0
  55. package/src/tools/control-room/ui/ledger.tsx +137 -0
  56. package/src/tools/control-room/ui/matrix.tsx +117 -0
  57. package/src/tools/control-room/ui/panel.tsx +60 -0
  58. package/src/tools/control-room/ui/signal.tsx +161 -0
  59. package/src/tools/ledger/view.ts +3 -0
  60. package/src/tools/lib/algorithm-reflect.ts +84 -0
  61. package/src/tools/lib/analyze-report.ts +120 -0
  62. package/src/tools/lib/handoff-note.ts +88 -0
  63. package/src/tools/lib/note-flags.ts +59 -0
  64. package/src/tools/lib/project-isc.ts +151 -0
  65. package/src/tools/lib/relationship-reflect.ts +402 -0
  66. package/src/tools/lib/self-model.ts +499 -0
  67. package/src/tools/lib/session-usage.ts +216 -0
  68. package/src/tools/lib/skill-doctor.ts +457 -0
  69. package/src/tools/lib/thread.ts +119 -0
  70. package/src/tools/lib/token-report.ts +173 -0
  71. package/src/tools/lib/transcript-usage.ts +42 -0
  72. package/src/tools/lib/usage-buckets.ts +329 -0
  73. package/src/tools/relationship-reflect.ts +48 -412
  74. package/src/tools/self-model.ts +76 -558
  75. package/src/tools/session-summary.ts +8 -215
  76. package/src/tools/skill-doctor.ts +9 -444
  77. package/src/tools/token-cost.ts +18 -428
  78. package/assets/templates/ledger-page.html +0 -213
  79. package/src/cli/setup-telos.ts +0 -52
  80. package/src/hooks/lib/setup.ts +0 -60
  81. package/src/tools/ledger/server.ts +0 -111
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Usage:
9
9
  * bun ~/.pal/tools/project.ts list
10
- * bun ~/.pal/tools/project.ts create [name] [--path PATH] [--objectives "..."]
10
+ * bun ~/.pal/tools/project.ts create [name] [--path PATH] [--objectives "..."] [--serves goal|revenue|fun]
11
+ * bun ~/.pal/tools/project.ts serves <name> <goal|revenue|fun> [note]
11
12
  * bun ~/.pal/tools/project.ts resume <name>
12
13
  * bun ~/.pal/tools/project.ts complete | archive | pause | unpause <name>
13
14
  * bun ~/.pal/tools/project.ts add-next <name> "text"
@@ -38,6 +39,19 @@ import {
38
39
  readProject,
39
40
  writeProject,
40
41
  } from "../../hooks/lib/projects";
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";
41
55
 
42
56
  function now(): string {
43
57
  return new Date().toISOString();
@@ -83,6 +97,8 @@ function cmdCreate(args: string[]): void {
83
97
  path: { type: "string" },
84
98
  name: { type: "string" },
85
99
  objectives: { type: "string" },
100
+ serves: { type: "string" },
101
+ "serves-note": { type: "string" },
86
102
  },
87
103
  allowPositionals: true,
88
104
  });
@@ -111,6 +127,10 @@ function cmdCreate(args: string[]): void {
111
127
  .join("\n")
112
128
  : undefined;
113
129
 
130
+ if (values.serves !== undefined && !isServesKind(values.serves)) {
131
+ fail(`--serves must be one of: ${SERVES_KINDS.join(", ")}`);
132
+ }
133
+
114
134
  const project: ProjectProgress = {
115
135
  name,
116
136
  path,
@@ -118,11 +138,31 @@ function cmdCreate(args: string[]): void {
118
138
  created: now(),
119
139
  updated: now(),
120
140
  ...(goalLines ? { goal: goalLines } : {}),
141
+ ...(isServesKind(values.serves)
142
+ ? { serves: values.serves, serves_by: "user" as const }
143
+ : {}),
144
+ ...(values["serves-note"] ? { serves_note: values["serves-note"] } : {}),
121
145
  };
122
146
  writeProject(project);
123
147
  ok({ created: true, project });
124
148
  }
125
149
 
150
+ /** The answer the scaffolder asks for, and the one place importance can be corrected. */
151
+ function cmdServes(args: string[]): void {
152
+ const [name, kind, ...note] = args;
153
+ if (!name || !kind) fail("Usage: serves <name> <goal|revenue|fun> [note]");
154
+ if (!isServesKind(kind)) fail(`serves must be one of: ${SERVES_KINDS.join(", ")}`);
155
+
156
+ const outcome = setServes({
157
+ name,
158
+ kind,
159
+ note: note.join(" ").trim() || undefined,
160
+ by: "user",
161
+ });
162
+ if (outcome === "missing") fail(`No project named "${name}".`);
163
+ ok({ project: name, serves: kind, by: "user" });
164
+ }
165
+
126
166
  // ── resume ────────────────────────────────────────────────────────
127
167
 
128
168
  // resume returns a lean orientation view: all narrative sections, but the
@@ -372,130 +412,13 @@ function cmdRm(args: string[]): void {
372
412
  // Three states, not two: a retired ISC is one that stopped being valid, which the
373
413
  // record must not report as completed work. The box character is the storage form
374
414
  // 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
-
389
- interface Isc {
390
- id: number;
391
- text: string;
392
- status: IscStatus;
393
- }
394
-
395
- /**
396
- * An ISC is one markdown line, so a newline in its text would end the record
397
- * and strand every paragraph after it as unparseable debris. Backslashes are
398
- * escaped first so that decoding a literal "\n" in a regex cannot be mistaken
399
- * for the separator.
400
- */
401
- function encodeIscText(text: string): string {
402
- return text
403
- .replaceAll("\\", "\\\\")
404
- .replaceAll("\r\n", "\n")
405
- .replaceAll("\r", "\n")
406
- .replaceAll("\n", "\\n");
407
- }
408
-
409
- const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
410
-
411
- function decodeIscText(stored: string): string {
412
- return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
413
- }
414
-
415
- function parseIscs(criteria: string): Isc[] {
416
- const out: Isc[] = [];
417
- for (const line of criteria.split("\n")) {
418
- const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
419
- if (m)
420
- out.push({
421
- id: Number(m[2]),
422
- text: decodeIscText(m[3].trim()),
423
- status: statusFromBox(m[1]),
424
- });
425
- }
426
- return out;
427
- }
428
-
429
- // Collapse a full ISC line to a glanceable title for resume: cut at the first
430
- // clause boundary, then hard-cap length. Full text stays reachable via show-isc.
431
- function iscTitle(text: string): string {
432
- const boundary = text.search(/; | — | \(|\. /);
433
- const clause = (boundary > 0 ? text.slice(0, boundary) : text).trim();
434
- return clause.length > 80 ? `${clause.slice(0, 79).trimEnd()}…` : clause;
435
- }
436
-
437
- // Scans Criteria AND Changelog so an archived id can never be handed out again.
438
- function nextIscId(p: ProjectProgress): number {
439
- const ids = [...parseIscs(p.criteria ?? ""), ...parseIscs(p.changelog ?? "")].map(
440
- (i) => i.id
441
- );
442
- return ids.length > 0 ? Math.max(...ids) + 1 : 1;
443
- }
444
-
445
- function removeIscLine(
446
- section: string,
447
- id: number
448
- ): { line: string | null; rest: string } {
449
- const lines = section.split("\n");
450
- const idx = lines.findIndex((l) =>
451
- new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
452
- );
453
- if (idx === -1) return { line: null, rest: section };
454
- const [line] = lines.splice(idx, 1);
455
- return {
456
- line,
457
- rest: lines
458
- .join("\n")
459
- .replace(/\n{3,}/g, "\n\n")
460
- .trim(),
461
- };
462
- }
463
-
464
- // Drops any "### Archived <date>" heading whose block has no content left —
465
- // e.g. after every ISC filed under that date has been reopened.
466
- function dropEmptyArchiveHeadings(changelog: string): string {
467
- const lines = changelog.split("\n");
468
- const blockHasContent = (headingIdx: number): boolean => {
469
- for (let j = headingIdx + 1; j < lines.length && !lines[j].startsWith("### "); j++) {
470
- if (lines[j].trim() !== "") return true;
471
- }
472
- return false;
473
- };
474
- return lines
475
- .filter((l, i) => !(/^### Archived /.test(l) && !blockHasContent(i)))
476
- .join("\n")
477
- .replace(/\n{3,}/g, "\n\n")
478
- .trim();
479
- }
480
-
481
- function archiveLine(
482
- changelog: string | undefined,
483
- doneLine: string,
484
- kind: "Archived" | "Retired" = "Archived"
485
- ): string {
486
- const heading = `### ${kind} ${new Date().toISOString().slice(0, 10)}`;
487
- const base = (changelog ?? "").trim();
488
- if (base.includes(heading)) return `${base}\n${doneLine}`;
489
- return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
490
- }
491
-
492
415
  function cmdAddIsc(args: string[]): void {
493
416
  const name = args[0] ?? fail("Usage: add-isc <name> <title>");
494
417
  const title = args.slice(1).join(" ").trim();
495
418
  if (!title) fail("Usage: add-isc <name> <title>");
496
419
  const p = requireProject(name);
497
420
  const current = p.criteria ?? "";
498
- const id = nextIscId(p);
421
+ const id = nextIscId(current, p.changelog ?? "");
499
422
  const newLine = `- [ ] ISC-${id}: ${encodeIscText(title)}`;
500
423
  p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
501
424
  p.updated = now();
@@ -556,13 +479,6 @@ function cmdReopenIsc(args: string[]): void {
556
479
  ok({ checked: false, id });
557
480
  }
558
481
 
559
- function selectIscs(open: Isc[], done: Isc[], retired: Isc[], flags: Set<string>): Isc[] {
560
- if (flags.has("--all")) return [...open, ...done, ...retired];
561
- if (flags.has("--closed")) return done;
562
- if (flags.has("--retired")) return retired;
563
- return open;
564
- }
565
-
566
482
  function cmdListIsc(args: string[]): void {
567
483
  const flags = new Set(args.filter((a) => a.startsWith("--")));
568
484
  const name =
@@ -693,15 +609,6 @@ function cmdPruneIsc(args: string[]): void {
693
609
 
694
610
  // ── Task ISA (work/) ──────────────────────────────────────────────
695
611
 
696
- function taskSlug(title: string): string {
697
- const sanitized = title
698
- .toLowerCase()
699
- .replace(/[^a-z0-9]+/g, "-")
700
- .replace(/^-+|-+$/g, "")
701
- .slice(0, 40);
702
- return `${sanitized}-${Date.now().toString(36)}`;
703
- }
704
-
705
612
  function taskIsaPath(slug: string): string {
706
613
  const dir = resolve(paths.work(), slug);
707
614
  mkdirSync(dir, { recursive: true });
@@ -753,7 +660,8 @@ function help(): void {
753
660
 
754
661
  Commands:
755
662
  list show all registered projects
756
- create [name] [--path PATH] [--objectives X] register a project
663
+ create [name] [--path PATH] [--objectives X] [--serves KIND] register a project
664
+ serves <name> <goal|revenue|fun> [note] say what it is for — outranks PAL's guess
757
665
  resume <name> print lean project view (open-ISC titles; full text via show-isc)
758
666
  complete <name> mark complete
759
667
  archive <name> mark archived
@@ -796,6 +704,9 @@ function run(): void {
796
704
  case "create":
797
705
  cmdCreate(rest);
798
706
  return;
707
+ case "serves":
708
+ cmdServes(rest);
709
+ return;
799
710
  case "resume":
800
711
  cmdResume(rest);
801
712
  return;
@@ -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
  }