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,457 @@
1
+ /**
2
+ * skill-doctor — static evaluator for a SKILL.md against Anthropic's
3
+ * skill-authoring best practices. Checks only what is mechanically verifiable:
4
+ * name/description constraints, body length, point-of-view, and reference depth.
5
+ *
6
+ * Every check is a pure function of a skill's text or its directory listing, so
7
+ * each one is exercised directly rather than through a whole lint run.
8
+ *
9
+ * Script: bun src/tools/skill-doctor.ts <skill-dir-or-name>
10
+ * CLI: pal cli skill doctor <name> (see src/cli/skill.ts)
11
+ */
12
+
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { basename, extname, relative, resolve } from "node:path";
15
+ import { palHome } from "../../hooks/lib/paths";
16
+ import { declaredTriggers } from "../../hooks/lib/skill-triggers";
17
+
18
+ type Level = "pass" | "warn" | "error";
19
+
20
+ interface DoctorFinding {
21
+ level: Level;
22
+ check: string;
23
+ message: string;
24
+ }
25
+
26
+ export interface DoctorReport {
27
+ dir: string;
28
+ name: string | null;
29
+ findings: DoctorFinding[];
30
+ errors: number;
31
+ warnings: number;
32
+ }
33
+
34
+ export interface ParsedSkill {
35
+ name: string | null;
36
+ description: string | null;
37
+ descriptionQuoted: boolean;
38
+ triggers: string[];
39
+ shipped: boolean;
40
+ license: string | null;
41
+ derivedFrom: string | null;
42
+ body: string;
43
+ }
44
+
45
+ const SHIPPED_SOURCE = "portable-agent-layer";
46
+
47
+ const RESERVED_WORDS = ["anthropic", "claude"];
48
+ const MAX_NAME = 64;
49
+ const MAX_DESCRIPTION = 1024;
50
+ const MAX_BODY_LINES = 500;
51
+ const MIN_TRIGGERS = 3;
52
+
53
+ /** File extensions worth scanning for hardcoded paths (SKILL.md + its scripts). */
54
+ const SCANNABLE_EXT = new Set([".md", ".ts", ".js", ".mjs", ".cjs", ".sh", ".py"]);
55
+
56
+ /** Machine/user-specific absolute paths that will not survive an export to
57
+ * another machine or user: POSIX home dirs and Windows user profiles. Portable
58
+ * forms ($HOME, ~, %USERPROFILE%, env vars) are deliberately not matched. */
59
+ const ABSOLUTE_PATH_RE =
60
+ /(?:\/(?:Users|home)\/[A-Za-z0-9._-]+|\/root\/[A-Za-z0-9._-]|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)/;
61
+
62
+ /** Collect SKILL.md and sibling script files, skipping vendored/VCS trees. */
63
+ export function collectSkillFiles(skillDir: string): string[] {
64
+ const out: string[] = [];
65
+ const walk = (dir: string) => {
66
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
67
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
68
+ const full = resolve(dir, entry.name);
69
+ if (entry.isDirectory()) walk(full);
70
+ else if (entry.isFile() && SCANNABLE_EXT.has(extname(entry.name))) out.push(full);
71
+ }
72
+ };
73
+ walk(skillDir);
74
+ return out;
75
+ }
76
+
77
+ /** Find machine-specific absolute paths across a skill's files. */
78
+ export function findAbsolutePaths(skillDir: string): string[] {
79
+ const hits: string[] = [];
80
+ for (const file of collectSkillFiles(skillDir)) {
81
+ const rel = relative(skillDir, file).replaceAll("\\", "/");
82
+ const lines = readFileSync(file, "utf-8").split("\n");
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const m = ABSOLUTE_PATH_RE.exec(lines[i]);
85
+ if (m) hits.push(`${rel}:${i + 1} → ${m[0]}`);
86
+ }
87
+ }
88
+ return hits;
89
+ }
90
+
91
+ /** Split a SKILL.md into frontmatter fields and body. */
92
+ export function parseSkill(content: string): ParsedSkill {
93
+ const parts = content.split(/^---\s*$/m);
94
+ if (parts.length < 3) {
95
+ return {
96
+ name: null,
97
+ description: null,
98
+ descriptionQuoted: false,
99
+ triggers: [],
100
+ shipped: false,
101
+ license: null,
102
+ derivedFrom: null,
103
+ body: content,
104
+ };
105
+ }
106
+ const frontmatter = parts[1];
107
+ const body = parts.slice(2).join("---");
108
+ const name = /^name:\s*"?(.+?)"?\s*$/m.exec(frontmatter)?.[1] ?? null;
109
+ const rawDescription = /^description:[ \t]*(.*?)\s*$/m.exec(frontmatter)?.[1] ?? null;
110
+ const descriptionQuoted =
111
+ rawDescription !== null &&
112
+ rawDescription.length >= 2 &&
113
+ rawDescription.startsWith('"') &&
114
+ rawDescription.endsWith('"');
115
+ const description = descriptionQuoted ? rawDescription.slice(1, -1) : rawDescription;
116
+ return {
117
+ name,
118
+ description,
119
+ descriptionQuoted,
120
+ triggers: declaredTriggers(frontmatter),
121
+ shipped: metadataField(frontmatter, "source") === SHIPPED_SOURCE,
122
+ license: topLevelField(frontmatter, "license"),
123
+ derivedFrom: metadataField(frontmatter, "derived-from"),
124
+ body,
125
+ };
126
+ }
127
+
128
+ /** Value of a top-level `key:` line in the frontmatter, unquoted. */
129
+ export function topLevelField(frontmatter: string, key: string): string | null {
130
+ return (
131
+ new RegExp(String.raw`^${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ?? null
132
+ );
133
+ }
134
+
135
+ /** Value of an indented `key:` line under the `metadata:` block, unquoted. */
136
+ export function metadataField(frontmatter: string, key: string): string | null {
137
+ return (
138
+ new RegExp(String.raw`^[ \t]+${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ??
139
+ null
140
+ );
141
+ }
142
+
143
+ /** Render triggers for a report line: `"a", "b"` or `"a" then "b"`. */
144
+ export function quoteList(triggers: string[], separator: string): string {
145
+ return triggers.map((trigger) => `"${trigger}"`).join(separator);
146
+ }
147
+
148
+ /**
149
+ * The triggers every skill must declare first: its own name, then the
150
+ * de-hyphenated form a user would actually type. A single-word name has only
151
+ * the one form, and the parser dedupes anyway, so it requires just itself.
152
+ */
153
+ export function leadTriggers(name: string): string[] {
154
+ const spaced = name.replaceAll("-", " ");
155
+ return spaced === name ? [name] : [name, spaced];
156
+ }
157
+
158
+ /** Remove fenced and inline code so prose checks don't trip on examples. */
159
+ export function stripCode(s: string): string {
160
+ return s.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
161
+ }
162
+
163
+ /**
164
+ * Evaluate the skill at `skillDir` (a folder containing SKILL.md) and return a
165
+ * structured report. Hard rules from Anthropic's validation surface as errors;
166
+ * best-practice nudges surface as warnings.
167
+ */
168
+ export function lintSkill(skillDir: string): DoctorReport {
169
+ const findings: DoctorFinding[] = [];
170
+ const add = (level: Level, check: string, message: string) =>
171
+ findings.push({ level, check, message });
172
+
173
+ if (!existsSync(skillDir)) {
174
+ add("error", "structure", `No skill directory at ${skillDir}`);
175
+ return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
176
+ }
177
+
178
+ // The runtime only loads a file named exactly `SKILL.md`. On case-insensitive
179
+ // filesystems (macOS, Windows) `existsSync` would accept `skill.md`, so match
180
+ // the real on-disk entry, not a case-folded path.
181
+ const skillFile = readdirSync(skillDir).find((e) => e.toLowerCase() === "skill.md");
182
+ if (!skillFile) {
183
+ add("error", "structure", `No SKILL.md found in ${skillDir}`);
184
+ return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
185
+ }
186
+ skillFile === "SKILL.md"
187
+ ? add("pass", "file.name", "skill file is named SKILL.md")
188
+ : add(
189
+ "error",
190
+ "file.name",
191
+ `skill file is "${skillFile}" — must be exactly "SKILL.md" or the skill is silently ignored`
192
+ );
193
+
194
+ const {
195
+ name,
196
+ description,
197
+ descriptionQuoted,
198
+ triggers,
199
+ shipped,
200
+ license,
201
+ derivedFrom,
202
+ body,
203
+ } = parseSkill(readFileSync(resolve(skillDir, skillFile), "utf-8"));
204
+
205
+ // ── provenance (shipped skills only) ──
206
+ if (shipped) {
207
+ if (license) add("pass", "license", `licensed ${license}`);
208
+ else if (derivedFrom)
209
+ add("pass", "license", `unlicensed by design — derived from ${derivedFrom}`);
210
+ else
211
+ add(
212
+ "warn",
213
+ "license",
214
+ "shipped skill declares no license — add `license: MIT`, or `metadata.derived-from: <origin>` when the idea comes from another project"
215
+ );
216
+ }
217
+
218
+ // The runtime keys a skill by its folder name; a mismatched frontmatter `name`
219
+ // makes the skill silently fail to load.
220
+ const folder = basename(skillDir);
221
+ if (name) {
222
+ name === folder
223
+ ? add("pass", "name.folder", `matches folder "${folder}"`)
224
+ : add(
225
+ "error",
226
+ "name.folder",
227
+ `name "${name}" must equal the folder name "${folder}" verbatim — otherwise the skill is silently ignored`
228
+ );
229
+ }
230
+
231
+ // ── name ──
232
+ if (!name) {
233
+ add("error", "name", "Missing `name` in frontmatter");
234
+ } else {
235
+ name.length <= MAX_NAME
236
+ ? add("pass", "name.length", `${name.length}/${MAX_NAME} chars`)
237
+ : add("error", "name.length", `${name.length} chars exceeds ${MAX_NAME}`);
238
+ /^[a-z0-9-]+$/.test(name)
239
+ ? add("pass", "name.charset", "lowercase letters, numbers, hyphens only")
240
+ : add(
241
+ "error",
242
+ "name.charset",
243
+ `"${name}" must be lowercase a-z, 0-9, hyphens only`
244
+ );
245
+ const reserved = RESERVED_WORDS.find((w) => name.toLowerCase().includes(w));
246
+ reserved
247
+ ? add("error", "name.reserved", `contains reserved word "${reserved}"`)
248
+ : add("pass", "name.reserved", "no reserved words");
249
+ }
250
+
251
+ // ── description ──
252
+ if (!description) {
253
+ add("error", "description", "Missing `description` in frontmatter");
254
+ } else {
255
+ description.length <= MAX_DESCRIPTION
256
+ ? add(
257
+ "pass",
258
+ "description.length",
259
+ `${description.length}/${MAX_DESCRIPTION} chars`
260
+ )
261
+ : add(
262
+ "error",
263
+ "description.length",
264
+ `${description.length} chars exceeds ${MAX_DESCRIPTION}`
265
+ );
266
+ descriptionQuoted
267
+ ? add("pass", "description.quoted", "value is wrapped in double quotes")
268
+ : add(
269
+ "warn",
270
+ "description.quoted",
271
+ 'value is not wrapped in double quotes — unquoted YAML mis-parses on colons, commas, and quotes; wrap it in "..." (escaping any inner " as \\")'
272
+ );
273
+ /<[^>]+>/.test(description)
274
+ ? add(
275
+ "warn",
276
+ "description.xml",
277
+ "contains angle-bracket content — Anthropic disallows XML tags; rephrase placeholders like <x> in prose"
278
+ )
279
+ : add("pass", "description.xml", "no XML tags");
280
+ /\bwhen/i.test(description)
281
+ ? add("pass", "description.trigger", "states when to use the skill")
282
+ : add(
283
+ "warn",
284
+ "description.trigger",
285
+ "no 'when to use' trigger — add 'Use when …' so the dispatcher can match it"
286
+ );
287
+ /\b(I can|I['’]ll|I will|I help|you can|you will|you could|you['’]ll)\b/i.test(
288
+ description
289
+ )
290
+ ? add(
291
+ "warn",
292
+ "description.pov",
293
+ "reads first/second person — write descriptions in third person (e.g. 'Processes…', 'Generates…')"
294
+ )
295
+ : add("pass", "description.pov", "third person");
296
+ }
297
+
298
+ // ── triggers ──
299
+ if (triggers.length === 0) {
300
+ add(
301
+ "warn",
302
+ "metadata.triggers",
303
+ "no metadata.triggers declared — add the words and phrases a prompt would contain so the prompt-time matcher can surface this skill; without them it falls back to keywords mined from the description"
304
+ );
305
+ } else if (triggers.length < MIN_TRIGGERS) {
306
+ add(
307
+ "warn",
308
+ "metadata.triggers",
309
+ `only ${triggers.length} trigger(s) declared — aim for at least ${MIN_TRIGGERS}, mostly multi-word phrases`
310
+ );
311
+ } else {
312
+ add("pass", "metadata.triggers", `${triggers.length} triggers declared`);
313
+ }
314
+
315
+ if (name && triggers.length > 0) {
316
+ const lead = leadTriggers(name);
317
+ const actual = triggers.slice(0, lead.length);
318
+ const wanted = quoteList(lead, " then ");
319
+ const found = quoteList(actual, ", ") || "nothing";
320
+ actual.join("\u0000") === lead.join("\u0000")
321
+ ? add("pass", "metadata.triggers.lead", `leads with ${wanted}`)
322
+ : add(
323
+ "warn",
324
+ "metadata.triggers.lead",
325
+ `triggers must lead with ${wanted} — found ${found}`
326
+ );
327
+ }
328
+
329
+ // ── body ──
330
+ const bodyLines = body.split("\n").length;
331
+ bodyLines <= MAX_BODY_LINES
332
+ ? add("pass", "body.length", `${bodyLines}/${MAX_BODY_LINES} lines`)
333
+ : add(
334
+ "warn",
335
+ "body.length",
336
+ `${bodyLines} lines exceeds ${MAX_BODY_LINES} — split into reference files`
337
+ );
338
+
339
+ const prose = stripCode(body);
340
+ /\b(I['’]m|I will|I['’]ll|in my experience|my workflow|I wrote|I created)\b/i.test(
341
+ prose
342
+ )
343
+ ? add(
344
+ "warn",
345
+ "body.pov",
346
+ "body uses first-person author voice — write it as second-person instructions to the assistant"
347
+ )
348
+ : add("pass", "body.pov", "instructional voice");
349
+
350
+ // ── reference depth (one level deep) ──
351
+ const linkRe = /\[[^\]]+\]\(([^)]+\.md)\)/g;
352
+ const skillFilePath = resolve(skillDir, skillFile);
353
+ // A reference back to the entry SKILL.md is a return-link, not a deeper chain.
354
+ const isDeeperRef = (target: string) =>
355
+ !/^https?:/.test(target) && resolve(skillDir, target) !== skillFilePath;
356
+ let nested = false;
357
+ for (const m of body.matchAll(linkRe)) {
358
+ const target = m[1];
359
+ if (!isDeeperRef(target)) continue;
360
+ const targetPath = resolve(skillDir, target);
361
+ if (!existsSync(targetPath)) continue;
362
+ const refContent = readFileSync(targetPath, "utf-8");
363
+ const onward = [...refContent.matchAll(linkRe)]
364
+ .map((mm) => mm[1])
365
+ .filter(isDeeperRef);
366
+ if (onward.length > 0) {
367
+ nested = true;
368
+ add(
369
+ "warn",
370
+ "references.depth",
371
+ `${target} links to further .md files — keep references one level deep from SKILL.md`
372
+ );
373
+ }
374
+ }
375
+ if (!nested) add("pass", "references.depth", "references are one level deep");
376
+
377
+ // ── windows-style paths ──
378
+ // Skip lines that are deliberate Windows examples (cmd.exe / %ENV% paths) —
379
+ // a cross-platform skill may legitimately document the Windows invocation.
380
+ const winPathRe = /\b[\w.-]+\\[\w.-]+\.(py|ts|js|sh|md|json)\b/;
381
+ const isIntentionalWindows = (line: string) =>
382
+ /%[A-Z_]+%/.test(line) || /cmd\.exe/i.test(line);
383
+ body.split("\n").some((l) => winPathRe.test(l) && !isIntentionalWindows(l))
384
+ ? add("warn", "paths", "Windows-style backslash path found — use forward slashes")
385
+ : add("pass", "paths", "forward-slash paths");
386
+
387
+ // ── machine-specific absolute paths (portability) ──
388
+ // A personal skill MAY legitimately hardcode a machine-specific path (e.g. a
389
+ // cloud-mount vault), so this is a warning, never an error — it flags paths
390
+ // that will not survive being exported to another machine or user.
391
+ const absHits = findAbsolutePaths(skillDir);
392
+ if (absHits.length > 0) {
393
+ const shown = absHits.slice(0, 3).join("; ");
394
+ const more = absHits.length > 3 ? ` (+${absHits.length - 3} more)` : "";
395
+ add(
396
+ "warn",
397
+ "paths.absolute",
398
+ `hardcoded absolute path(s) that won't be portable across machines: ${shown}${more} — prefer $HOME/~ or an env var, or ignore if this is an intentional machine-specific mount`
399
+ );
400
+ } else {
401
+ add("pass", "paths.absolute", "no machine-specific absolute paths");
402
+ }
403
+
404
+ const errors = findings.filter((f) => f.level === "error").length;
405
+ const warnings = findings.filter((f) => f.level === "warn").length;
406
+ return { dir: skillDir, name, findings, errors, warnings };
407
+ }
408
+
409
+ /** Render a report as a human-readable string. */
410
+ /** One scannable line per skill for a whole-store run: verdict plus the checks that fired. */
411
+ export function formatSummary(r: DoctorReport): string {
412
+ const fired = (level: Level) =>
413
+ r.findings.filter((f) => f.level === level).map((f) => f.check);
414
+ // The folder name, not the frontmatter name — the folder is what the reader
415
+ // passes back to `pal cli skill doctor <name>`, and a mismatch between the two
416
+ // is itself one of the errors this line reports.
417
+ const name = basename(r.dir).padEnd(20);
418
+
419
+ if (r.errors > 0) {
420
+ return `✗ ${name} ${r.errors} error(s): ${fired("error").join(", ")}`;
421
+ }
422
+ if (r.warnings > 0) {
423
+ return `⚠ ${name} ${r.warnings} warning(s): ${fired("warn").join(", ")}`;
424
+ }
425
+ return `✓ ${name} clean`;
426
+ }
427
+
428
+ export function formatReport(r: DoctorReport): string {
429
+ const icon = { pass: "✓", warn: "⚠", error: "✗" } as const;
430
+ const lines = [`skill-doctor: ${r.name ?? "(unparsed)"} — ${r.dir}`];
431
+ for (const f of r.findings) {
432
+ lines.push(` ${icon[f.level]} ${f.check}: ${f.message}`);
433
+ }
434
+ let verdict: string;
435
+ if (r.errors > 0) {
436
+ verdict = `FAIL — ${r.errors} error(s), ${r.warnings} warning(s)`;
437
+ } else if (r.warnings > 0) {
438
+ verdict = `OK with ${r.warnings} warning(s)`;
439
+ } else {
440
+ verdict = "PASS — all checks clean";
441
+ }
442
+ lines.push(` ${verdict}`);
443
+ return lines.join("\n");
444
+ }
445
+
446
+ /**
447
+ * A path when the argument names one, otherwise the skill of that name under
448
+ * ~/.pal/skills. An argument matching neither still resolves as a path, so the
449
+ * report names the directory the caller meant rather than a guess.
450
+ */
451
+ export function resolveSkillDir(arg: string): string {
452
+ const asPath = resolve(arg);
453
+ if (existsSync(resolve(asPath, "SKILL.md"))) return asPath;
454
+ const byName = resolve(palHome(), "skills", arg);
455
+ if (existsSync(resolve(byName, "SKILL.md"))) return byName;
456
+ return asPath;
457
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Open threads — the questions and decisions that outlive a session.
3
+ *
4
+ * The tool around this is only ever spawned, so the record's shape, the resolve
5
+ * that has to leave every other thread untouched, and the store's line format
6
+ * were reachable only by running the CLI. Each function here takes the store it
7
+ * operates on, plus the clock and directory it stamps with.
8
+ */
9
+
10
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
13
+ import { encodeAnchor } from "../../hooks/lib/anchor";
14
+ import { ensureDir, paths } from "../../hooks/lib/paths";
15
+
16
+ export interface Thread extends RecordAttribution {
17
+ id: string;
18
+ cwd: string;
19
+ title: string;
20
+ context: string;
21
+ status: "open" | "resolved";
22
+ created: string;
23
+ resolved: string | null;
24
+ }
25
+
26
+ export function threadsFile(): string {
27
+ return resolve(ensureDir(paths.state()), "threads.jsonl");
28
+ }
29
+
30
+ /** Time gives the id its order; the random tail keeps two threads in one millisecond apart. */
31
+ export function newId(
32
+ now: number = Date.now(),
33
+ random: () => number = Math.random
34
+ ): string {
35
+ return now.toString(36) + random().toString(36).slice(2, 5);
36
+ }
37
+
38
+ export function parseThreads(content: string): Thread[] {
39
+ try {
40
+ return content
41
+ .split("\n")
42
+ .filter((line) => line.trim())
43
+ .map((line) => JSON.parse(line) as Thread);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ export function readThreads(file: string = threadsFile()): Thread[] {
50
+ if (!existsSync(file)) return [];
51
+ try {
52
+ return parseThreads(readFileSync(file, "utf-8"));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ export function serializeThreads(threads: Thread[]): string {
59
+ return `${threads.map((thread) => JSON.stringify(thread)).join("\n")}\n`;
60
+ }
61
+
62
+ export function writeThreads(threads: Thread[], file: string = threadsFile()): void {
63
+ writeFileSync(file, serializeThreads(threads), "utf-8");
64
+ }
65
+
66
+ export function newThread(
67
+ title: string,
68
+ context: string,
69
+ now: Date = new Date(),
70
+ cwd: string = process.cwd()
71
+ ): Thread {
72
+ return {
73
+ id: newId(now.getTime()),
74
+ cwd: encodeAnchor(cwd),
75
+ ...currentAttribution(),
76
+ title,
77
+ context,
78
+ status: "open",
79
+ created: now.toISOString(),
80
+ resolved: null,
81
+ };
82
+ }
83
+
84
+ export function addThread(
85
+ title: string,
86
+ context: string,
87
+ file: string = threadsFile(),
88
+ now: Date = new Date(),
89
+ cwd: string = process.cwd()
90
+ ): Thread {
91
+ const thread = newThread(title, context, now, cwd);
92
+ appendFileSync(file, `${JSON.stringify(thread)}\n`, "utf-8");
93
+ return thread;
94
+ }
95
+
96
+ export interface Resolution {
97
+ threads: Thread[];
98
+ thread: Thread;
99
+ }
100
+
101
+ /** Returns a new store rather than editing one, so an unknown id changes nothing. */
102
+ export function resolveThreadIn(
103
+ threads: Thread[],
104
+ id: string,
105
+ now: Date
106
+ ): Resolution | null {
107
+ const index = threads.findIndex((t) => t.id === id);
108
+ if (index === -1) return null;
109
+ const thread: Thread = {
110
+ ...threads[index],
111
+ status: "resolved",
112
+ resolved: now.toISOString(),
113
+ };
114
+ return { threads: threads.map((t, i) => (i === index ? thread : t)), thread };
115
+ }
116
+
117
+ export function visibleThreads(threads: Thread[], all: boolean): Thread[] {
118
+ return all ? threads : threads.filter((t) => t.status === "open");
119
+ }