portable-agent-layer 0.71.0 → 0.73.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 (104) hide show
  1. package/package.json +10 -7
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/server.ts +7 -0
  4. package/src/cli/skill.ts +1 -1
  5. package/src/hooks/CompactRecover.ts +28 -86
  6. package/src/hooks/LedgerUnapplied.ts +3 -28
  7. package/src/hooks/LoadContext.ts +33 -60
  8. package/src/hooks/SecurityValidator.ts +16 -109
  9. package/src/hooks/handlers/agenda.ts +195 -7
  10. package/src/hooks/handlers/failure-principle.ts +19 -44
  11. package/src/hooks/handlers/session-intelligence.ts +13 -70
  12. package/src/hooks/lib/agenda-store.ts +2 -0
  13. package/src/hooks/lib/capture-store.ts +103 -0
  14. package/src/hooks/lib/compact-recall.ts +89 -0
  15. package/src/hooks/lib/failure-principle.ts +98 -0
  16. package/src/hooks/lib/goal-links.ts +83 -0
  17. package/src/hooks/lib/ledger-hook.ts +35 -0
  18. package/src/hooks/lib/ledger.ts +48 -1
  19. package/src/hooks/lib/models.ts +1 -1
  20. package/src/hooks/lib/projects.ts +5 -0
  21. package/src/hooks/lib/security-gate.ts +159 -0
  22. package/src/hooks/lib/session-context.ts +74 -0
  23. package/src/hooks/lib/settings.ts +2 -0
  24. package/src/hooks/lib/telos-goals.ts +8 -2
  25. package/src/hooks/lib/token-usage.ts +2 -0
  26. package/src/tools/agent/algorithm-reflect.ts +28 -97
  27. package/src/tools/agent/analyze.ts +19 -120
  28. package/src/tools/agent/handoff-note.ts +29 -77
  29. package/src/tools/agent/project.ts +34 -153
  30. package/src/tools/agent/relationship-note.ts +27 -46
  31. package/src/tools/agent/synthesize.ts +1 -1
  32. package/src/tools/agent/thread.ts +43 -123
  33. package/src/tools/control-room/attention.ts +146 -0
  34. package/src/tools/control-room/data.ts +78 -7
  35. package/src/tools/control-room/detail.ts +158 -0
  36. package/src/tools/control-room/matrix.ts +92 -20
  37. package/src/tools/control-room/prefs.ts +96 -0
  38. package/src/tools/control-room/server-config.ts +8 -0
  39. package/src/tools/control-room/server.ts +196 -11
  40. package/src/tools/control-room/snooze.ts +65 -0
  41. package/src/tools/control-room/static.ts +68 -0
  42. package/src/tools/control-room/ui/app.tsx +196 -50
  43. package/src/tools/control-room/ui/attention-bell.tsx +118 -0
  44. package/src/tools/control-room/ui/components/README.md +18 -0
  45. package/src/tools/control-room/ui/components/badge.tsx +38 -0
  46. package/src/tools/control-room/ui/components/button.tsx +43 -0
  47. package/src/tools/control-room/ui/components/card.tsx +45 -0
  48. package/src/tools/control-room/ui/components/input.tsx +24 -0
  49. package/src/tools/control-room/ui/components/label.tsx +18 -0
  50. package/src/tools/control-room/ui/components/popover.tsx +37 -0
  51. package/src/tools/control-room/ui/components/separator.tsx +25 -0
  52. package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
  53. package/src/tools/control-room/ui/components/switch.tsx +27 -0
  54. package/src/tools/control-room/ui/components/table.tsx +60 -0
  55. package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
  56. package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
  57. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
  58. package/src/tools/control-room/ui/dist/index.html +19 -0
  59. package/src/tools/control-room/ui/format.ts +0 -12
  60. package/src/tools/control-room/ui/frame.tsx +125 -0
  61. package/src/tools/control-room/ui/index.html +2 -2
  62. package/src/tools/control-room/ui/lib/api.ts +27 -0
  63. package/src/tools/control-room/ui/lib/cn.ts +6 -0
  64. package/src/tools/control-room/ui/lib/write.ts +43 -0
  65. package/src/tools/control-room/ui/package.json +28 -0
  66. package/src/tools/control-room/ui/parts.tsx +235 -0
  67. package/src/tools/control-room/ui/screens/log.tsx +274 -0
  68. package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
  69. package/src/tools/control-room/ui/screens/projects.tsx +128 -0
  70. package/src/tools/control-room/ui/screens/settings.tsx +205 -0
  71. package/src/tools/control-room/ui/screens/today.tsx +391 -0
  72. package/src/tools/control-room/ui/theme.css +127 -0
  73. package/src/tools/control-room/ui/vite.config.ts +36 -0
  74. package/src/tools/control-room/writes.ts +106 -0
  75. package/src/tools/ledger/outcomes.ts +9 -0
  76. package/src/tools/ledger/query.ts +2 -0
  77. package/src/tools/ledger/view.ts +34 -10
  78. package/src/tools/lib/algorithm-reflect.ts +84 -0
  79. package/src/tools/lib/analyze-report.ts +120 -0
  80. package/src/tools/lib/handoff-note.ts +102 -0
  81. package/src/tools/lib/note-flags.ts +59 -0
  82. package/src/tools/lib/project-isc.ts +212 -0
  83. package/src/tools/lib/relationship-reflect.ts +402 -0
  84. package/src/tools/lib/self-model.ts +499 -0
  85. package/src/tools/lib/session-usage.ts +216 -0
  86. package/src/tools/lib/skill-doctor.ts +457 -0
  87. package/src/tools/lib/thread.ts +119 -0
  88. package/src/tools/lib/token-report.ts +173 -0
  89. package/src/tools/lib/transcript-usage.ts +42 -0
  90. package/src/tools/lib/usage-buckets.ts +329 -0
  91. package/src/tools/relationship-reflect.ts +48 -412
  92. package/src/tools/self-model.ts +76 -558
  93. package/src/tools/session-summary.ts +8 -215
  94. package/src/tools/skill-doctor.ts +9 -444
  95. package/src/tools/token-cost.ts +18 -428
  96. package/src/tools/control-room/ui/agenda.tsx +0 -43
  97. package/src/tools/control-room/ui/agents.tsx +0 -67
  98. package/src/tools/control-room/ui/app.css +0 -857
  99. package/src/tools/control-room/ui/board.tsx +0 -82
  100. package/src/tools/control-room/ui/handoffs.tsx +0 -37
  101. package/src/tools/control-room/ui/ledger.tsx +0 -136
  102. package/src/tools/control-room/ui/matrix.tsx +0 -117
  103. package/src/tools/control-room/ui/panel.tsx +0 -60
  104. package/src/tools/control-room/ui/signal.tsx +0 -161
@@ -40,6 +40,21 @@ 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
+ completeIsc,
46
+ encodeIscText,
47
+ ISC_BOX,
48
+ type IscMove,
49
+ type IscSections,
50
+ iscTitle,
51
+ nextIscId,
52
+ parseIscs,
53
+ removeIscLine,
54
+ reopenIsc,
55
+ selectIscs,
56
+ taskSlug,
57
+ } from "../lib/project-isc";
43
58
 
44
59
  function now(): string {
45
60
  return new Date().toISOString();
@@ -400,130 +415,13 @@ function cmdRm(args: string[]): void {
400
415
  // Three states, not two: a retired ISC is one that stopped being valid, which the
401
416
  // record must not report as completed work. The box character is the storage form
402
417
  // 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
418
  function cmdAddIsc(args: string[]): void {
521
419
  const name = args[0] ?? fail("Usage: add-isc <name> <title>");
522
420
  const title = args.slice(1).join(" ").trim();
523
421
  if (!title) fail("Usage: add-isc <name> <title>");
524
422
  const p = requireProject(name);
525
423
  const current = p.criteria ?? "";
526
- const id = nextIscId(p);
424
+ const id = nextIscId(current, p.changelog ?? "");
527
425
  const newLine = `- [ ] ISC-${id}: ${encodeIscText(title)}`;
528
426
  p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
529
427
  p.updated = now();
@@ -545,17 +443,25 @@ function cmdCompleteIsc(args: string[]): void {
545
443
  const id = Number(args[1] ?? fail("Usage: complete-isc <name> <id>"));
546
444
  if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
547
445
  const p = requireProject(name);
548
- if (parseIscs(p.changelog ?? "").some((i) => i.id === id)) {
446
+ const move = completeIsc(sectionsOf(p), id);
447
+ if (!move.ok) fail(`${move.reason} in project "${name}"`);
448
+ if (move.already) {
549
449
  ok({ checked: true, id, alreadyDone: true });
550
450
  return;
551
451
  }
552
- const { line, rest } = removeIscLine(p.criteria ?? "", id);
553
- if (!line) fail(`ISC-${id} not found in project "${name}"`);
554
- p.criteria = rest;
555
- p.changelog = archiveLine(p.changelog, line.replace("[ ]", "[x]"));
452
+ applyIscMove(p, move);
453
+ ok({ checked: true, id, archived: true });
454
+ }
455
+
456
+ function sectionsOf(p: ProjectProgress): IscSections {
457
+ return { criteria: p.criteria ?? "", changelog: p.changelog ?? "" };
458
+ }
459
+
460
+ function applyIscMove(p: ProjectProgress, move: IscMove & { ok: true }): void {
461
+ p.criteria = move.criteria;
462
+ p.changelog = move.changelog;
556
463
  p.updated = now();
557
464
  writeProject(p);
558
- ok({ checked: true, id, archived: true });
559
465
  }
560
466
 
561
467
  // Reopening pulls the line back out of the Changelog (or legacy Criteria) into
@@ -565,32 +471,16 @@ function cmdReopenIsc(args: string[]): void {
565
471
  const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
566
472
  if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
567
473
  const p = requireProject(name);
568
- if (parseIscs(p.criteria ?? "").some((i) => i.id === id && i.status === "open")) {
474
+ const move = reopenIsc(sectionsOf(p), id);
475
+ if (!move.ok) fail(`${move.reason} in project "${name}"`);
476
+ if (move.already) {
569
477
  ok({ checked: false, id, alreadyOpen: true });
570
478
  return;
571
479
  }
572
- let removed = removeIscLine(p.changelog ?? "", id);
573
- if (removed.line) {
574
- p.changelog = dropEmptyArchiveHeadings(removed.rest);
575
- } else {
576
- removed = removeIscLine(p.criteria ?? "", id);
577
- if (removed.line) p.criteria = removed.rest;
578
- }
579
- if (!removed.line) fail(`ISC-${id} not found in project "${name}"`);
580
- const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
581
- p.criteria = p.criteria ? `${p.criteria.trimEnd()}\n${openLine}` : openLine;
582
- p.updated = now();
583
- writeProject(p);
480
+ applyIscMove(p, move);
584
481
  ok({ checked: false, id });
585
482
  }
586
483
 
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
484
  function cmdListIsc(args: string[]): void {
595
485
  const flags = new Set(args.filter((a) => a.startsWith("--")));
596
486
  const name =
@@ -721,15 +611,6 @@ function cmdPruneIsc(args: string[]): void {
721
611
 
722
612
  // ── Task ISA (work/) ──────────────────────────────────────────────
723
613
 
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
614
  function taskIsaPath(slug: string): string {
734
615
  const dir = resolve(paths.work(), slug);
735
616
  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
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What is waiting on you, and what you have already seen.
3
+ *
4
+ * Every item here is a template over a fact PAL already holds — a refused tool
5
+ * call, an unanswered handoff, a project with no purpose on record. Nothing is
6
+ * inferred: the bell is the one surface that must be instant and always right,
7
+ * and a model in that path would fail silently exactly when it mattered.
8
+ */
9
+
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { ensureDir, paths } from "../../hooks/lib/paths";
13
+ import { readAllProjects } from "../../hooks/lib/projects";
14
+ import { queryLedger } from "../ledger/query";
15
+ import { displayTarget } from "../ledger/view";
16
+ import { freshHandoffs } from "./data";
17
+ import { type AttentionSource, readPrefs } from "./prefs";
18
+
19
+ const DAY_MS = 86_400_000;
20
+ const REFUSAL_WINDOW_DAYS = 7;
21
+ const MAX_ITEMS = 40;
22
+
23
+ export interface AttentionItem {
24
+ id: string;
25
+ source: AttentionSource;
26
+ severity: "alarm" | "waiting" | "note";
27
+ title: string;
28
+ detail: string;
29
+ at: string;
30
+ project: string | null;
31
+ /** Where clicking it should land. */
32
+ href: string;
33
+ read: boolean;
34
+ }
35
+
36
+ export interface AttentionView {
37
+ unread: number;
38
+ items: AttentionItem[];
39
+ }
40
+
41
+ type ReadState = Record<string, string>;
42
+
43
+ function readFile(): string {
44
+ return resolve(ensureDir(paths.state()), "attention-read.json");
45
+ }
46
+
47
+ function readMarks(): ReadState {
48
+ const file = readFile();
49
+ if (!existsSync(file)) return {};
50
+ try {
51
+ return JSON.parse(readFileSync(file, "utf-8")) as ReadState;
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ function writeMarks(marks: ReadState): void {
58
+ writeFileSync(readFile(), `${JSON.stringify(marks, null, 2)}\n`, "utf-8");
59
+ }
60
+
61
+ export function markRead(ids: string[], now: Date = new Date()): number {
62
+ const marks = readMarks();
63
+ for (const id of ids) marks[id] = now.toISOString();
64
+ writeMarks(marks);
65
+ return ids.length;
66
+ }
67
+
68
+ function refusals(now: Date): AttentionItem[] {
69
+ const since = new Date(now.getTime() - REFUSAL_WINDOW_DAYS * DAY_MS);
70
+ return queryLedger({ since })
71
+ .filter((e) => e.outcome === "denied" || e.outcome === "blocked")
72
+ .map((e) => ({
73
+ id: `refusal:${e.id}`,
74
+ source: "refusals" as const,
75
+ severity: "alarm" as const,
76
+ title: `${e.outcome === "blocked" ? "Blocked" : "Denied"}: ${e.tool}`,
77
+ detail: e.reason ?? e.command ?? displayTarget(e.target),
78
+ at: e.ts,
79
+ project: anchorProject(e.target),
80
+ href: `/log?outcome=${e.outcome}`,
81
+ read: false,
82
+ }));
83
+ }
84
+
85
+ function anchorProject(target: string): string | null {
86
+ const match = /^\{proj:([^}]+)\}/.exec(target);
87
+ return match ? match[1] : null;
88
+ }
89
+
90
+ function waiting(now: Date): AttentionItem[] {
91
+ const slugByPath = new Map(
92
+ readAllProjects()
93
+ .filter((p) => p.path)
94
+ .map((p) => [p.path as string, p.name])
95
+ );
96
+ return freshHandoffs(now)
97
+ .filter(([, h]) => h.waitingOn)
98
+ .map(([cwd, h]) => {
99
+ const slug = slugByPath.get(cwd) ?? null;
100
+ return {
101
+ id: `waiting:${cwd}:${h.timestamp}`,
102
+ source: "waiting" as const,
103
+ severity: "waiting" as const,
104
+ title: `${slug ?? cwd} is waiting on you`,
105
+ detail: h.waitingOn as string,
106
+ at: h.timestamp,
107
+ project: slug,
108
+ href: slug ? `/projects/${slug}` : "/",
109
+ read: false,
110
+ };
111
+ });
112
+ }
113
+
114
+ function unranked(): AttentionItem[] {
115
+ return readAllProjects()
116
+ .filter((p) => p.status === "active" && !p.serves)
117
+ .map((p) => ({
118
+ id: `unranked:${p.name}`,
119
+ source: "unranked" as const,
120
+ severity: "note" as const,
121
+ title: `${p.name} has no purpose on record`,
122
+ detail: "It ranks as unimportant until you say what it serves.",
123
+ at: p.updated,
124
+ project: p.name,
125
+ href: `/projects/${p.name}`,
126
+ read: false,
127
+ }));
128
+ }
129
+
130
+ const SOURCES: Record<AttentionSource, (now: Date) => AttentionItem[]> = {
131
+ refusals,
132
+ waiting,
133
+ unranked: () => unranked(),
134
+ };
135
+
136
+ export function attention(now: Date = new Date()): AttentionView {
137
+ const prefs = readPrefs();
138
+ const marks = readMarks();
139
+ const items = (Object.keys(SOURCES) as AttentionSource[])
140
+ .filter((source) => prefs.attention[source])
141
+ .flatMap((source) => SOURCES[source](now))
142
+ .map((item) => ({ ...item, read: marks[item.id] !== undefined }))
143
+ .sort((a, b) => b.at.localeCompare(a.at))
144
+ .slice(0, MAX_ITEMS);
145
+ return { unread: items.filter((i) => !i.read).length, items };
146
+ }