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
@@ -0,0 +1,161 @@
1
+ import type { DueBadge, RatingPoint, SignalView } from "../data";
2
+ import { percent, tenths } from "./format";
3
+ import { Panel, Pending, useLoaded } from "./panel";
4
+
5
+ const LOW_RATING = 3;
6
+ const W = 320;
7
+ const H = 72;
8
+ const PAD = 4;
9
+
10
+ function sparkPath(points: RatingPoint[]): {
11
+ line: string;
12
+ area: string;
13
+ xy: [number, number][];
14
+ } {
15
+ if (points.length === 0) return { line: "", area: "", xy: [] };
16
+ const step = points.length > 1 ? (W - PAD * 2) / (points.length - 1) : 0;
17
+ const xy = points.map<[number, number]>((p, i) => [
18
+ PAD + i * step,
19
+ H - PAD - ((p.rating - 1) / 9) * (H - PAD * 2),
20
+ ]);
21
+ const line = xy
22
+ .map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`)
23
+ .join(" ");
24
+ const area = `${line} L${xy.at(-1)?.[0].toFixed(1)} ${H} L${xy[0][0].toFixed(1)} ${H} Z`;
25
+ return { line, area, xy };
26
+ }
27
+
28
+ function Sparkline({ points }: { points: RatingPoint[] }) {
29
+ const { line, area, xy } = sparkPath(points);
30
+ const last = xy.at(-1);
31
+ const midY = H - PAD - (4 / 9) * (H - PAD * 2);
32
+ return (
33
+ <svg
34
+ className="spark"
35
+ viewBox={`0 0 ${W} ${H}`}
36
+ preserveAspectRatio="none"
37
+ role="img"
38
+ aria-label="ratings"
39
+ >
40
+ <title>ratings, oldest to newest</title>
41
+ <defs>
42
+ <linearGradient id="sparkfill" x1="0" x2="0" y1="0" y2="1">
43
+ <stop offset="0" stopColor="#f0b14a" stopOpacity="0.28" />
44
+ <stop offset="1" stopColor="#f0b14a" stopOpacity="0" />
45
+ </linearGradient>
46
+ </defs>
47
+ <line className="rule" x1={PAD} x2={W - PAD} y1={midY} y2={midY} />
48
+ <path className="area" d={area} />
49
+ <path className="line" d={line} />
50
+ {points.map((p, i) =>
51
+ p.rating <= LOW_RATING ? (
52
+ <circle key={p.ts} className="low" cx={xy[i][0]} cy={xy[i][1]} r="2" />
53
+ ) : null
54
+ )}
55
+ {last && <circle className="last" cx={last[0]} cy={last[1]} r="3" />}
56
+ </svg>
57
+ );
58
+ }
59
+
60
+ function Figure({ value, label, tone }: { value: string; label: string; tone?: string }) {
61
+ return (
62
+ <div className="figure">
63
+ <div className={`value ${tone ?? ""}`}>{value}</div>
64
+ <div className="label">{label}</div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ const BADGE_LOOK: Record<DueBadge["state"], { row: string; tag: string }> = {
70
+ due: { row: "due", tag: "amber" },
71
+ clear: { row: "clear", tag: "good" },
72
+ "n/a": { row: "na", tag: "ghost" },
73
+ };
74
+
75
+ function Due({ name, badge }: { name: string; badge: DueBadge }) {
76
+ const look = BADGE_LOOK[badge.state];
77
+ return (
78
+ <div className={`due-row ${look.row}`}>
79
+ <span className={`tag ${look.tag}`}>{badge.state}</span>
80
+ <span className="detail">
81
+ {name}
82
+ {badge.detail ? ` — ${badge.detail}` : ""}
83
+ </span>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ function ratingTone(avg: number): string {
89
+ if (avg < 5) return "bad";
90
+ if (avg >= 7) return "good";
91
+ return "";
92
+ }
93
+
94
+ export function Signal() {
95
+ const view = useLoaded<SignalView>("/api/signal");
96
+ return (
97
+ <Panel
98
+ index="02 · feedback"
99
+ title="Signal"
100
+ span={4}
101
+ order={1}
102
+ aside={
103
+ view.state === "ready" && view.data.synthesizedAt
104
+ ? `synthesised ${view.data.synthesizedAt.slice(0, 10)}`
105
+ : ""
106
+ }
107
+ >
108
+ <Pending value={view} />
109
+ {view.state === "ready" && (
110
+ <>
111
+ <div className="figures">
112
+ <Figure
113
+ value={view.data.ratings ? tenths(view.data.ratings.recentAvg) : "–"}
114
+ label="last 10"
115
+ tone={view.data.ratings ? ratingTone(view.data.ratings.recentAvg) : ""}
116
+ />
117
+ <Figure
118
+ value={view.data.ratings ? tenths(view.data.ratings.avg) : "–"}
119
+ label={`avg of ${view.data.ratings?.count ?? 0}`}
120
+ />
121
+ <Figure
122
+ value={view.data.ratings ? String(view.data.ratings.lowCount) : "–"}
123
+ label="low (≤3)"
124
+ tone={view.data.ratings && view.data.ratings.lowCount > 5 ? "bad" : ""}
125
+ />
126
+ </div>
127
+ {view.data.series.length > 0 ? (
128
+ <Sparkline points={view.data.series} />
129
+ ) : (
130
+ <div className="empty">No ratings yet.</div>
131
+ )}
132
+ <div className="spark-caption">
133
+ <span>last {view.data.series.length} ratings</span>
134
+ <span>{view.data.ratings?.trend ?? ""}</span>
135
+ </div>
136
+ <div className="figures" style={{ marginTop: 16 }}>
137
+ <Figure
138
+ value={
139
+ view.data.algorithm ? String(view.data.algorithm.reflectionCount) : "–"
140
+ }
141
+ label="reflections"
142
+ />
143
+ <Figure
144
+ value={view.data.algorithm ? percent(view.data.algorithm.passRate) : "–"}
145
+ label="criteria pass"
146
+ />
147
+ <Figure
148
+ value={view.data.algorithm ? tenths(view.data.algorithm.avgSentiment) : "–"}
149
+ label="sentiment"
150
+ />
151
+ </div>
152
+ <div className="due">
153
+ <Due name="learning analysis" badge={view.data.due.analysis} />
154
+ <Due name="algorithm review" badge={view.data.due.algorithmReview} />
155
+ <Due name="relationship reflect" badge={view.data.due.relationshipReflect} />
156
+ </div>
157
+ </>
158
+ )}
159
+ </Panel>
160
+ );
161
+ }
@@ -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
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The shape of an algorithm reflection: what a LEARN phase records, and the
3
+ * defaults and clamps applied to whatever the CLI was handed.
4
+ *
5
+ * The tool around this is only ever spawned, so the stamping, the clamp and the
6
+ * scope default were reachable only through argv. They take their clock and
7
+ * their directory as parameters here, which is what makes them assertable.
8
+ */
9
+
10
+ import { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
11
+ import { encodeAnchor } from "../../hooks/lib/anchor";
12
+
13
+ const MIN_SENTIMENT = 1;
14
+ const MAX_SENTIMENT = 10;
15
+ const DEFAULT_SENTIMENT = 5;
16
+
17
+ export interface AlgorithmReflection extends RecordAttribution {
18
+ timestamp: string;
19
+ cwd: string;
20
+ task: string;
21
+ criteria_count: number;
22
+ criteria_passed: number;
23
+ criteria_failed: number;
24
+ sentiment: number;
25
+ q1: string;
26
+ q2: string;
27
+ q3: string;
28
+ /** general = the improvement ideas generalize to the algorithm; task-specific = bound to this task. */
29
+ scope: "general" | "task-specific";
30
+ }
31
+
32
+ export interface ReflectionInput {
33
+ task: string;
34
+ q1: string;
35
+ q2: string;
36
+ q3: string;
37
+ criteria_count?: number;
38
+ criteria_passed?: number;
39
+ criteria_failed?: number;
40
+ sentiment?: number;
41
+ scope?: string;
42
+ }
43
+
44
+ /** An absent flag reads as its default; a non-numeric one reads as NaN, not zero. */
45
+ export function intOr(value: string | undefined, fallback: number): number {
46
+ return Number.parseInt(value || String(fallback), 10);
47
+ }
48
+
49
+ export function clampSentiment(sentiment: number | undefined): number {
50
+ return Math.max(MIN_SENTIMENT, Math.min(MAX_SENTIMENT, sentiment ?? DEFAULT_SENTIMENT));
51
+ }
52
+
53
+ /**
54
+ * General is the ~94% case, so only an explicit "task-specific" opts out of
55
+ * algorithm-update's clustering — anything else, including a typo, stays general.
56
+ */
57
+ export function scopeOf(scope: string | undefined): AlgorithmReflection["scope"] {
58
+ return scope === "task-specific" ? "task-specific" : "general";
59
+ }
60
+
61
+ export function buildReflection(
62
+ input: ReflectionInput,
63
+ now: Date = new Date(),
64
+ cwd: string = process.cwd()
65
+ ): AlgorithmReflection {
66
+ return {
67
+ timestamp: now.toISOString(),
68
+ cwd: encodeAnchor(cwd),
69
+ ...currentAttribution(),
70
+ task: input.task,
71
+ criteria_count: input.criteria_count ?? 0,
72
+ criteria_passed: input.criteria_passed ?? 0,
73
+ criteria_failed: input.criteria_failed ?? 0,
74
+ sentiment: clampSentiment(input.sentiment),
75
+ q1: input.q1,
76
+ q2: input.q2,
77
+ q3: input.q3,
78
+ scope: scopeOf(input.scope),
79
+ };
80
+ }
81
+
82
+ export function reflectionLine(reflection: AlgorithmReflection): string {
83
+ return `${JSON.stringify(reflection)}\n`;
84
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The learning-analysis report, as lines rather than as console output.
3
+ *
4
+ * The tool around it is spawned, so every judgement in here — which colour a
5
+ * rating average earns, whether an entry came from a failure or a learning,
6
+ * what is shown when there is nothing to show — was written straight into
7
+ * console.log and could not be read back by a test.
8
+ */
9
+
10
+ import type { AnalysisResult } from "../../hooks/lib/graduation";
11
+
12
+ type PatternGroup = AnalysisResult["candidates"][number];
13
+ type AnalysisEntry = PatternGroup["entries"][number];
14
+ type RatingsSummary = NonNullable<AnalysisResult["ratings"]>;
15
+
16
+ const c = {
17
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
18
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
19
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
20
+ yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
21
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
22
+ red: (s: string) => `\x1b[31m${s}\x1b[0m`,
23
+ magenta: (s: string) => `\x1b[35m${s}\x1b[0m`,
24
+ };
25
+
26
+ const RULE = c.dim("─────────────────────────────────────────────────");
27
+
28
+ /** Green from 7, red at 4 and below, amber for the band between them. */
29
+ function averageColour(average: number) {
30
+ if (average >= 7) return c.green;
31
+ return average <= 4 ? c.red : c.yellow;
32
+ }
33
+
34
+ function ratingsLines(r: RatingsSummary): string[] {
35
+ const average = averageColour(r.average)(`${r.average.toFixed(1)}/10`);
36
+ const low = c.red(`Low (≤4): ${r.low.count}`);
37
+ const high = c.green(`High (≥7): ${r.high.count}`);
38
+ return [
39
+ `\n ${c.bold("Ratings:")} ${average} avg (${r.total} total)`,
40
+ ` ${low} | ${high}`,
41
+ ];
42
+ }
43
+
44
+ /** The source string carries where the entry came from; the tag says which. */
45
+ function entryLine(entry: AnalysisEntry, maxChars: number): string {
46
+ const kind = entry.source.startsWith("failure:") ? "failure" : "learning";
47
+ const tag = kind === "failure" ? c.red(`[${kind}]`) : c.yellow(`[${kind}]`);
48
+ return ` ${c.dim(entry.date || "unknown")} ${tag} ${entry.text.slice(0, maxChars)}`;
49
+ }
50
+
51
+ function headingOf(group: PatternGroup): string {
52
+ const domain = c.cyan(`[${group.domain}]`);
53
+ const count = c.bold(`${group.entries.length}x`);
54
+ return ` ${domain} ${count}`;
55
+ }
56
+
57
+ function fileLines(group: PatternGroup): string[] {
58
+ return group.entries.map((entry) => ` ${c.dim(entry.path)}`);
59
+ }
60
+
61
+ function candidateLines(candidate: PatternGroup): string[] {
62
+ const framePath = c.magenta(`memory/wisdom/frames/${candidate.domain}.md`);
63
+ return [
64
+ `${headingOf(candidate)} occurrences`,
65
+ "",
66
+ ...candidate.entries.map((entry) => entryLine(entry, 100)),
67
+ `\n ${c.dim("Files:")}`,
68
+ ...fileLines(candidate),
69
+ "",
70
+ ` Target frame: ${framePath}`,
71
+ ` ${RULE}\n`,
72
+ ];
73
+ }
74
+
75
+ function emergingLines(group: PatternGroup): string[] {
76
+ return [
77
+ headingOf(group),
78
+ ...group.entries.map((entry) => entryLine(entry, 80)),
79
+ " Files:",
80
+ ...fileLines(group),
81
+ "",
82
+ ];
83
+ }
84
+
85
+ export function reportLines(result: AnalysisResult): string[] {
86
+ const hasPatterns = result.candidates.length > 0 || result.emerging.length > 0;
87
+ if (!hasPatterns && result.ratings === null) {
88
+ return ["\n No patterns or ratings data found.\n"];
89
+ }
90
+
91
+ const lines: string[] = [];
92
+
93
+ if (result.ratings) lines.push(...ratingsLines(result.ratings));
94
+
95
+ if (result.candidates.length > 0) {
96
+ const header = `Graduation Report — ${result.candidates.length} pattern(s) detected`;
97
+ lines.push(`\n ${c.bold(c.green(header))}\n`, ` ${RULE}\n`);
98
+ for (const candidate of result.candidates) lines.push(...candidateLines(candidate));
99
+ }
100
+
101
+ if (result.emerging.length > 0) {
102
+ lines.push(` ${c.bold(c.yellow("Emerging (2x — one more to graduate)"))}\n`);
103
+ for (const group of result.emerging) lines.push(...emergingLines(group));
104
+ }
105
+
106
+ if (result.recommendations.length > 0) {
107
+ lines.push(` ${c.bold("Recommendations:")}\n`);
108
+ for (const rec of result.recommendations) lines.push(` ${rec}`);
109
+ lines.push("");
110
+ }
111
+
112
+ if (result.candidates.length > 0) {
113
+ lines.push(
114
+ " To crystallize: add a line to the wisdom frame file.",
115
+ ` Format: ${c.green("- Your principle here [CRYSTAL: 85%]")}\n`
116
+ );
117
+ }
118
+
119
+ return lines;
120
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The handoff store: what an unfinished session leaves behind for the next one,
3
+ * keyed by the directory the work happened in.
4
+ *
5
+ * The tool around this is only ever spawned, so the shape of an entry and the
6
+ * trim that keeps the file a working set were reachable only by running the CLI
7
+ * and reading the file back. Everything here takes the store it operates on and
8
+ * the clock it stamps with.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { ensureDir, paths } from "../../hooks/lib/paths";
14
+
15
+ export interface HandoffEntry {
16
+ timestamp: string;
17
+ title: string;
18
+ status: "in-progress" | "completed";
19
+ handoff: string;
20
+ artifacts: string[];
21
+ source: "deliberate" | "auto";
22
+ /** What the work needs from the human before it can move — the one thing an agent cannot unblock. */
23
+ waitingOn?: string;
24
+ }
25
+
26
+ export interface NoteInput {
27
+ cwd: string;
28
+ title: string;
29
+ text: string;
30
+ done: boolean;
31
+ waitingOn?: string;
32
+ }
33
+
34
+ export type HandoffStore = Record<string, HandoffEntry>;
35
+
36
+ /** The file is a working set, not an archive: the oldest keys fall off first. */
37
+ const MAX_ENTRIES = 20;
38
+
39
+ export function handoffFile(): string {
40
+ return resolve(ensureDir(paths.state()), "last-handoff.json");
41
+ }
42
+
43
+ export function parseHandoffs(content: string): HandoffStore {
44
+ try {
45
+ return JSON.parse(content) as HandoffStore;
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ export function readHandoffs(file: string = handoffFile()): HandoffStore {
52
+ if (!existsSync(file)) return {};
53
+ try {
54
+ return parseHandoffs(readFileSync(file, "utf-8"));
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ export function trimHandoffs(handoffs: HandoffStore): HandoffStore {
61
+ const entries = Object.entries(handoffs);
62
+ if (entries.length <= MAX_ENTRIES) return handoffs;
63
+ return Object.fromEntries(entries.slice(-MAX_ENTRIES));
64
+ }
65
+
66
+ export const statusOf = (note: NoteInput): HandoffEntry["status"] =>
67
+ note.done ? "completed" : "in-progress";
68
+
69
+ /** A closed session carries no waiting line, so the key is absent rather than empty. */
70
+ export function entryFor(note: NoteInput, now: Date): HandoffEntry {
71
+ return {
72
+ timestamp: now.toISOString(),
73
+ title: note.title,
74
+ status: statusOf(note),
75
+ handoff: note.text,
76
+ artifacts: [],
77
+ source: "deliberate",
78
+ ...(note.waitingOn ? { waitingOn: note.waitingOn } : {}),
79
+ };
80
+ }
81
+
82
+ export function recordNote(
83
+ store: HandoffStore,
84
+ note: NoteInput,
85
+ now: Date
86
+ ): HandoffStore {
87
+ return trimHandoffs({ ...store, [note.cwd]: entryFor(note, now) });
88
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The flags the relationship-note tool takes, turned into notes to append.
3
+ *
4
+ * The tool is only ever spawned, so the one judgement here — what counts as a
5
+ * usable confidence, and what an empty invocation should do — could not be
6
+ * asserted from a test. A bad confidence is a typo, and writing the note anyway
7
+ * under a default would record a claim nobody made.
8
+ */
9
+
10
+ type NoteType = "O" | "W" | "Session";
11
+
12
+ interface NoteDraft {
13
+ type: NoteType;
14
+ text: string;
15
+ confidence?: number;
16
+ }
17
+
18
+ export interface NoteFlags {
19
+ o?: string[];
20
+ w?: string[];
21
+ b?: string;
22
+ confidence?: string;
23
+ }
24
+
25
+ /** What an opinion is worth when the caller did not say. */
26
+ export const DEFAULT_CONFIDENCE = 0.75;
27
+
28
+ export type NoteFlagsResult = { notes: NoteDraft[] } | { error: string };
29
+
30
+ /** parseArgs leaves it a string; anything outside 0–1 is a typo, not a value. */
31
+ export function parseConfidence(raw: string | undefined): number | null {
32
+ if (raw === undefined) return DEFAULT_CONFIDENCE;
33
+ const value = Number.parseFloat(raw);
34
+ if (Number.isNaN(value) || value < 0 || value > 1) return null;
35
+ return value;
36
+ }
37
+
38
+ export function notesFromFlags(flags: NoteFlags): NoteFlagsResult {
39
+ const opinions = flags.o ?? [];
40
+ const facts = flags.w ?? [];
41
+ if (opinions.length === 0 && facts.length === 0 && !flags.b) {
42
+ return { error: "Required: at least one of --o, --w, --b" };
43
+ }
44
+
45
+ const notes: NoteDraft[] = [];
46
+
47
+ if (opinions.length > 0) {
48
+ const confidence = parseConfidence(flags.confidence);
49
+ if (confidence === null) {
50
+ return { error: "--confidence must be a number between 0.0 and 1.0" };
51
+ }
52
+ for (const text of opinions) notes.push({ type: "O", text, confidence });
53
+ }
54
+
55
+ for (const text of facts) notes.push({ type: "W", text });
56
+ if (flags.b) notes.push({ type: "Session", text: flags.b });
57
+
58
+ return { notes };
59
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * ISCs — the numbered criteria a project is judged against, stored as markdown
3
+ * lines inside a project's ISA.md.
4
+ *
5
+ * The tool that edits them is only ever spawned, so the line format, the
6
+ * escaping that keeps a multi-paragraph criterion on one line, and the archive
7
+ * bookkeeping were reachable only by running the CLI. Each function here takes
8
+ * the text it operates on, plus the clock it stamps with.
9
+ */
10
+
11
+ export type IscStatus = "open" | "done" | "retired";
12
+
13
+ export const ISC_BOX: Record<IscStatus, string> = {
14
+ open: "[ ]",
15
+ done: "[x]",
16
+ retired: "[~]",
17
+ };
18
+
19
+ export function statusFromBox(box: string): IscStatus {
20
+ if (box.toLowerCase() === "x") return "done";
21
+ if (box === "~") return "retired";
22
+ return "open";
23
+ }
24
+
25
+ export interface Isc {
26
+ id: number;
27
+ text: string;
28
+ status: IscStatus;
29
+ }
30
+
31
+ /**
32
+ * An ISC is one markdown line, so a newline in its text would end the record
33
+ * and strand every paragraph after it as unparseable debris. Backslashes are
34
+ * escaped first so that decoding a literal "\n" in a regex cannot be mistaken
35
+ * for the separator.
36
+ */
37
+ export function encodeIscText(text: string): string {
38
+ return text
39
+ .replaceAll("\\", "\\\\")
40
+ .replaceAll("\r\n", "\n")
41
+ .replaceAll("\r", "\n")
42
+ .replaceAll("\n", "\\n");
43
+ }
44
+
45
+ const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
46
+
47
+ export function decodeIscText(stored: string): string {
48
+ return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
49
+ }
50
+
51
+ export function parseIscs(criteria: string): Isc[] {
52
+ const out: Isc[] = [];
53
+ for (const line of criteria.split("\n")) {
54
+ const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
55
+ if (m)
56
+ out.push({
57
+ id: Number(m[2]),
58
+ text: decodeIscText(m[3].trim()),
59
+ status: statusFromBox(m[1]),
60
+ });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * A full ISC line collapsed to a glanceable title for resume: cut at the first
67
+ * clause boundary, then hard-cap length. Full text stays reachable via show-isc.
68
+ */
69
+ export function iscTitle(text: string): string {
70
+ const boundary = text.search(/; | — | \(|\. /);
71
+ const clause = (boundary > 0 ? text.slice(0, boundary) : text).trim();
72
+ return clause.length > 80 ? `${clause.slice(0, 79).trimEnd()}…` : clause;
73
+ }
74
+
75
+ /** Scans Criteria AND Changelog so an archived id can never be handed out again. */
76
+ export function nextIscId(criteria: string, changelog: string): number {
77
+ const ids = [...parseIscs(criteria), ...parseIscs(changelog)].map((i) => i.id);
78
+ return ids.length > 0 ? Math.max(...ids) + 1 : 1;
79
+ }
80
+
81
+ export function removeIscLine(
82
+ section: string,
83
+ id: number
84
+ ): { line: string | null; rest: string } {
85
+ const lines = section.split("\n");
86
+ const idx = lines.findIndex((l) =>
87
+ new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
88
+ );
89
+ if (idx === -1) return { line: null, rest: section };
90
+ const [line] = lines.splice(idx, 1);
91
+ return {
92
+ line,
93
+ rest: lines
94
+ .join("\n")
95
+ .replace(/\n{3,}/g, "\n\n")
96
+ .trim(),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Drops any "### Archived <date>" heading whose block has no content left —
102
+ * e.g. after every ISC filed under that date has been reopened.
103
+ */
104
+ export function dropEmptyArchiveHeadings(changelog: string): string {
105
+ const lines = changelog.split("\n");
106
+ const blockHasContent = (headingIdx: number): boolean => {
107
+ for (let j = headingIdx + 1; j < lines.length && !lines[j].startsWith("### "); j++) {
108
+ if (lines[j].trim() !== "") return true;
109
+ }
110
+ return false;
111
+ };
112
+ return lines
113
+ .filter((l, i) => !(/^### Archived /.test(l) && !blockHasContent(i)))
114
+ .join("\n")
115
+ .replace(/\n{3,}/g, "\n\n")
116
+ .trim();
117
+ }
118
+
119
+ export function archiveLine(
120
+ changelog: string | undefined,
121
+ doneLine: string,
122
+ kind: "Archived" | "Retired" = "Archived",
123
+ now: Date = new Date()
124
+ ): string {
125
+ const heading = `### ${kind} ${now.toISOString().slice(0, 10)}`;
126
+ const base = (changelog ?? "").trim();
127
+ if (base.includes(heading)) return `${base}\n${doneLine}`;
128
+ return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
129
+ }
130
+
131
+ export function selectIscs(
132
+ open: Isc[],
133
+ done: Isc[],
134
+ retired: Isc[],
135
+ flags: Set<string>
136
+ ): Isc[] {
137
+ if (flags.has("--all")) return [...open, ...done, ...retired];
138
+ if (flags.has("--closed")) return done;
139
+ if (flags.has("--retired")) return retired;
140
+ return open;
141
+ }
142
+
143
+ /** A filesystem-safe stem for a task's own ISA, kept unique by the clock. */
144
+ export function taskSlug(title: string, now: number = Date.now()): string {
145
+ const sanitized = title
146
+ .toLowerCase()
147
+ .replace(/[^a-z0-9]+/g, "-")
148
+ .replace(/^-+|-+$/g, "")
149
+ .slice(0, 40);
150
+ return `${sanitized}-${now.toString(36)}`;
151
+ }