portable-agent-layer 0.63.3 → 0.65.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.
- package/README.md +8 -4
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/analyze-pdf/SKILL.md +11 -0
- package/assets/skills/analyze-youtube/SKILL.md +12 -0
- package/assets/skills/consulting-report/SKILL.md +9 -0
- package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
- package/assets/skills/council/SKILL.md +32 -0
- package/assets/skills/create-pdf/SKILL.md +13 -0
- package/assets/skills/create-skill/SKILL.md +14 -2
- package/assets/skills/create-skill/authoring-guide.md +10 -1
- package/assets/skills/create-subagent/SKILL.md +22 -4
- package/assets/skills/{research → deep-research}/SKILL.md +32 -1
- package/assets/skills/entities/SKILL.md +10 -0
- package/assets/skills/extract-wisdom/SKILL.md +12 -0
- package/assets/skills/first-principles/SKILL.md +8 -0
- package/assets/skills/frontend-design/SKILL.md +14 -0
- package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
- package/assets/skills/humanize/SKILL.md +13 -1
- package/assets/skills/opinion/SKILL.md +11 -0
- package/assets/skills/pal-analyze/SKILL.md +11 -0
- package/assets/skills/pal-reflect/SKILL.md +10 -0
- package/assets/skills/playwright/SKILL.md +15 -2
- package/assets/skills/playwright/tools/shot.ts +6 -7
- package/assets/skills/presentation/SKILL.md +12 -0
- package/assets/skills/projects/SKILL.md +20 -1
- package/assets/skills/reflect/SKILL.md +13 -0
- package/assets/skills/telos/SKILL.md +12 -0
- package/assets/skills/think/SKILL.md +9 -0
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
- package/assets/templates/pal-settings.json +1 -0
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +15 -4
- package/src/cli/index.ts +95 -9
- package/src/cli/migrate.ts +69 -3
- package/src/cli/skill.ts +47 -3
- package/src/hooks/handlers/inject-retrieval.ts +20 -10
- package/src/hooks/lib/anchor.ts +90 -0
- package/src/hooks/lib/bindings.ts +117 -0
- package/src/hooks/lib/export.ts +38 -1
- package/src/hooks/lib/import-merge.ts +220 -0
- package/src/hooks/lib/inference.ts +113 -72
- package/src/hooks/lib/machine.ts +176 -0
- package/src/hooks/lib/projects.ts +223 -15
- package/src/hooks/lib/readme-sync.ts +30 -10
- package/src/hooks/lib/relationship.ts +3 -1
- package/src/hooks/lib/remote.ts +58 -0
- package/src/hooks/lib/retrieval.ts +8 -2
- package/src/hooks/lib/signals.ts +2 -1
- package/src/hooks/lib/skill-match.ts +129 -0
- package/src/hooks/lib/skill-triggers.ts +82 -0
- package/src/hooks/lib/stop.ts +5 -2
- package/src/targets/lib.ts +137 -35
- package/src/targets/opencode/plugin.ts +2 -6
- package/src/tools/agent/algorithm-reflect.ts +45 -11
- package/src/tools/agent/project.ts +148 -23
- package/src/tools/agent/thread.ts +7 -2
- package/src/tools/skill-doctor.ts +130 -5
- package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
- package/assets/skills/playwright/tools/shot.mjs +0 -89
- package/assets/skills/review/SKILL.md +0 -20
- package/assets/skills/summarize/SKILL.md +0 -16
- /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { resolve } from "node:path";
|
|
26
26
|
import { parseArgs } from "node:util";
|
|
27
|
+
import { writeBinding } from "../../hooks/lib/bindings";
|
|
27
28
|
import { paths } from "../../hooks/lib/paths";
|
|
28
29
|
import {
|
|
29
30
|
defaultSlug,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
legacyJsonToProgress,
|
|
33
34
|
type ProjectProgress,
|
|
34
35
|
type ProjectStatus,
|
|
36
|
+
proposeBinding,
|
|
35
37
|
readAllProjects,
|
|
36
38
|
readProject,
|
|
37
39
|
writeProject,
|
|
@@ -132,14 +134,28 @@ function cmdResume(args: string[]): void {
|
|
|
132
134
|
if (!name) fail("Usage: resume <name>");
|
|
133
135
|
const { criteria, changelog, ...project } = requireProject(name);
|
|
134
136
|
const iscs = parseIscs(criteria ?? "");
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
+
const archived = parseIscs(changelog ?? "");
|
|
138
|
+
const openIscs = iscs.filter((i) => i.status === "open");
|
|
139
|
+
const all = [...iscs, ...archived];
|
|
140
|
+
const done = all.filter((i) => i.status === "done").length;
|
|
141
|
+
const retired = all.filter((i) => i.status === "retired").length;
|
|
142
|
+
// Resuming a project PAL cannot locate is the natural moment to offer a
|
|
143
|
+
// binding: the user just named this project, so the suggestion is wanted rather
|
|
144
|
+
// than volunteered. It is only ever a command — nothing binds on its own.
|
|
145
|
+
const unlocatable = !project.path || !existsSync(project.path);
|
|
146
|
+
const binding = unlocatable
|
|
147
|
+
? proposeBinding({ ...project, criteria, changelog })
|
|
148
|
+
: null;
|
|
149
|
+
|
|
137
150
|
ok({
|
|
138
151
|
project: {
|
|
139
152
|
...project,
|
|
140
153
|
open_iscs: openIscs.map((i) => ({ id: i.id, title: iscTitle(i.text) })),
|
|
141
|
-
isc_summary: { open: openIscs.length, done },
|
|
154
|
+
isc_summary: { open: openIscs.length, done, retired },
|
|
142
155
|
},
|
|
156
|
+
...(unlocatable
|
|
157
|
+
? { binding: binding ?? { state: "unbound", confidence: "none" } }
|
|
158
|
+
: {}),
|
|
143
159
|
});
|
|
144
160
|
}
|
|
145
161
|
|
|
@@ -206,12 +222,15 @@ function addHandoff(name: string, text: string): void {
|
|
|
206
222
|
|
|
207
223
|
// ── set-path ──────────────────────────────────────────────────────
|
|
208
224
|
|
|
225
|
+
// Where a project lives is machine-local, so this writes a binding rather than a
|
|
226
|
+
// field on the record. Unlike the save path it does not require the directory to
|
|
227
|
+
// exist yet: naming where a repo is about to be cloned is a legitimate use.
|
|
209
228
|
function cmdSetPath(args: string[]): void {
|
|
210
229
|
const [name, ...rest] = args;
|
|
211
230
|
if (!name || rest.length === 0) fail("Usage: set-path <name> <new-path>");
|
|
212
231
|
const newPath = resolve(rest.join(" ").trim());
|
|
213
232
|
const p = requireProject(name);
|
|
214
|
-
p.
|
|
233
|
+
writeBinding(p.name, newPath);
|
|
215
234
|
p.updated = now();
|
|
216
235
|
writeProject(p);
|
|
217
236
|
ok({ updated: true, name, path: newPath });
|
|
@@ -350,17 +369,34 @@ function cmdRm(args: string[]): void {
|
|
|
350
369
|
|
|
351
370
|
// ── ISC helpers ──────────────────────────────────────────────────
|
|
352
371
|
|
|
372
|
+
// Three states, not two: a retired ISC is one that stopped being valid, which the
|
|
373
|
+
// record must not report as completed work. The box character is the storage form
|
|
374
|
+
// 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
|
+
|
|
353
389
|
interface Isc {
|
|
354
390
|
id: number;
|
|
355
391
|
text: string;
|
|
356
|
-
|
|
392
|
+
status: IscStatus;
|
|
357
393
|
}
|
|
358
394
|
|
|
359
395
|
function parseIscs(criteria: string): Isc[] {
|
|
360
396
|
const out: Isc[] = [];
|
|
361
397
|
for (const line of criteria.split("\n")) {
|
|
362
|
-
const m = new RegExp(/^-\s+\[( |x)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
363
|
-
if (m) out.push({ id: Number(m[2]), text: m[3].trim(),
|
|
398
|
+
const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
399
|
+
if (m) out.push({ id: Number(m[2]), text: m[3].trim(), status: statusFromBox(m[1]) });
|
|
364
400
|
}
|
|
365
401
|
return out;
|
|
366
402
|
}
|
|
@@ -387,7 +423,7 @@ function removeIscLine(
|
|
|
387
423
|
): { line: string | null; rest: string } {
|
|
388
424
|
const lines = section.split("\n");
|
|
389
425
|
const idx = lines.findIndex((l) =>
|
|
390
|
-
new RegExp(String.raw`^-\s+\[[ x]\]\s+ISC-${id}:`).test(l)
|
|
426
|
+
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
|
|
391
427
|
);
|
|
392
428
|
if (idx === -1) return { line: null, rest: section };
|
|
393
429
|
const [line] = lines.splice(idx, 1);
|
|
@@ -417,8 +453,12 @@ function dropEmptyArchiveHeadings(changelog: string): string {
|
|
|
417
453
|
.trim();
|
|
418
454
|
}
|
|
419
455
|
|
|
420
|
-
function archiveLine(
|
|
421
|
-
|
|
456
|
+
function archiveLine(
|
|
457
|
+
changelog: string | undefined,
|
|
458
|
+
doneLine: string,
|
|
459
|
+
kind: "Archived" | "Retired" = "Archived"
|
|
460
|
+
): string {
|
|
461
|
+
const heading = `### ${kind} ${new Date().toISOString().slice(0, 10)}`;
|
|
422
462
|
const base = (changelog ?? "").trim();
|
|
423
463
|
if (base.includes(heading)) return `${base}\n${doneLine}`;
|
|
424
464
|
return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
|
|
@@ -472,7 +512,7 @@ function cmdReopenIsc(args: string[]): void {
|
|
|
472
512
|
const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
|
|
473
513
|
if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
|
|
474
514
|
const p = requireProject(name);
|
|
475
|
-
if (parseIscs(p.criteria ?? "").some((i) => i.id === id &&
|
|
515
|
+
if (parseIscs(p.criteria ?? "").some((i) => i.id === id && i.status === "open")) {
|
|
476
516
|
ok({ checked: false, id, alreadyOpen: true });
|
|
477
517
|
return;
|
|
478
518
|
}
|
|
@@ -484,16 +524,17 @@ function cmdReopenIsc(args: string[]): void {
|
|
|
484
524
|
if (removed.line) p.criteria = removed.rest;
|
|
485
525
|
}
|
|
486
526
|
if (!removed.line) fail(`ISC-${id} not found in project "${name}"`);
|
|
487
|
-
const openLine = removed.line.replace(/\[x\]/i, "[ ]");
|
|
527
|
+
const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
|
|
488
528
|
p.criteria = p.criteria ? `${p.criteria.trimEnd()}\n${openLine}` : openLine;
|
|
489
529
|
p.updated = now();
|
|
490
530
|
writeProject(p);
|
|
491
531
|
ok({ checked: false, id });
|
|
492
532
|
}
|
|
493
533
|
|
|
494
|
-
function selectIscs(open: Isc[], done: Isc[], flags: Set<string>): Isc[] {
|
|
495
|
-
if (flags.has("--all")) return [...open, ...done];
|
|
534
|
+
function selectIscs(open: Isc[], done: Isc[], retired: Isc[], flags: Set<string>): Isc[] {
|
|
535
|
+
if (flags.has("--all")) return [...open, ...done, ...retired];
|
|
496
536
|
if (flags.has("--closed")) return done;
|
|
537
|
+
if (flags.has("--retired")) return retired;
|
|
497
538
|
return open;
|
|
498
539
|
}
|
|
499
540
|
|
|
@@ -501,17 +542,20 @@ function cmdListIsc(args: string[]): void {
|
|
|
501
542
|
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
502
543
|
const name =
|
|
503
544
|
args.find((a) => !a.startsWith("--")) ??
|
|
504
|
-
fail("Usage: list-isc <name> [--all | --closed]");
|
|
545
|
+
fail("Usage: list-isc <name> [--all | --closed | --retired]");
|
|
505
546
|
const p = requireProject(name);
|
|
506
547
|
const criteria = parseIscs(p.criteria ?? "");
|
|
507
|
-
const
|
|
508
|
-
const
|
|
548
|
+
const all = [...criteria, ...parseIscs(p.changelog ?? "")];
|
|
549
|
+
const open = all.filter((i) => i.status === "open");
|
|
550
|
+
const done = all.filter((i) => i.status === "done");
|
|
551
|
+
const retired = all.filter((i) => i.status === "retired");
|
|
509
552
|
ok({
|
|
510
553
|
name,
|
|
511
|
-
total: open.length + done.length,
|
|
554
|
+
total: open.length + done.length + retired.length,
|
|
512
555
|
open: open.length,
|
|
513
556
|
done: done.length,
|
|
514
|
-
|
|
557
|
+
retired: retired.length,
|
|
558
|
+
iscs: selectIscs(open, done, retired, flags),
|
|
515
559
|
});
|
|
516
560
|
}
|
|
517
561
|
|
|
@@ -526,7 +570,80 @@ function cmdShowIsc(args: string[]): void {
|
|
|
526
570
|
(i) => i.id === id
|
|
527
571
|
);
|
|
528
572
|
if (!isc) fail(`ISC-${id} not found in project "${name}".`);
|
|
529
|
-
ok({ name, id: isc.id, status: isc.
|
|
573
|
+
ok({ name, id: isc.id, status: isc.status, text: isc.text });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// retire-isc closes an ISC that stopped being valid, which complete-isc cannot say:
|
|
577
|
+
// completing files it as done work. The line moves to the Changelog under its own
|
|
578
|
+
// heading as [~], so it still reserves its id and never reads as finished.
|
|
579
|
+
function cmdRetireIsc(args: string[]): void {
|
|
580
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
581
|
+
const name = positional[0];
|
|
582
|
+
const id = Number(positional[1]);
|
|
583
|
+
if (!name || !Number.isInteger(id) || id < 1) {
|
|
584
|
+
fail("Usage: retire-isc <name> <id> [--by <supersedingId>]");
|
|
585
|
+
}
|
|
586
|
+
const byIndex = args.indexOf("--by");
|
|
587
|
+
const by = byIndex === -1 ? null : Number(args[byIndex + 1]);
|
|
588
|
+
if (byIndex !== -1 && (!Number.isInteger(by) || (by ?? 0) < 1)) {
|
|
589
|
+
fail("--by expects a positive ISC id");
|
|
590
|
+
}
|
|
591
|
+
const p = requireProject(name);
|
|
592
|
+
if (parseIscs(p.changelog ?? "").some((i) => i.id === id && i.status === "retired")) {
|
|
593
|
+
ok({ retired: true, id, alreadyRetired: true });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const { line, rest } = removeIscLine(p.criteria ?? "", id);
|
|
597
|
+
if (!line) fail(`ISC-${id} not found in project "${name}"`);
|
|
598
|
+
const suffix = by ? ` (superseded by ISC-${by})` : "";
|
|
599
|
+
p.criteria = rest;
|
|
600
|
+
p.changelog = archiveLine(
|
|
601
|
+
p.changelog,
|
|
602
|
+
`${line.replace(/\[[ x]\]/i, "[~]")}${suffix}`,
|
|
603
|
+
"Retired"
|
|
604
|
+
);
|
|
605
|
+
p.updated = now();
|
|
606
|
+
writeProject(p);
|
|
607
|
+
ok({ retired: true, id, supersededBy: by, archived: true });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// edit-isc rewrites one ISC's text in place, keeping its id and open/done state.
|
|
611
|
+
// The id never leaves the record, so nextIscId still reserves it. Returns the
|
|
612
|
+
// previous text because the ISA files carry no version history of their own.
|
|
613
|
+
function cmdEditIsc(args: string[]): void {
|
|
614
|
+
const name = args[0];
|
|
615
|
+
const id = Number(args[1]);
|
|
616
|
+
const text = args.slice(2).join(" ").trim();
|
|
617
|
+
if (!name || !Number.isInteger(id) || id < 1 || !text) {
|
|
618
|
+
fail('Usage: edit-isc <name> <id> "new text"');
|
|
619
|
+
}
|
|
620
|
+
const p = requireProject(name);
|
|
621
|
+
const inCriteria = parseIscs(p.criteria ?? "").find((i) => i.id === id);
|
|
622
|
+
const isc = inCriteria ?? parseIscs(p.changelog ?? "").find((i) => i.id === id);
|
|
623
|
+
if (!isc) fail(`ISC-${id} not found in project "${name}".`);
|
|
624
|
+
|
|
625
|
+
const box = ISC_BOX[isc.status];
|
|
626
|
+
const rewrite = (section: string) =>
|
|
627
|
+
section
|
|
628
|
+
.split("\n")
|
|
629
|
+
.map((l) =>
|
|
630
|
+
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`, "i").test(l)
|
|
631
|
+
? `- ${box} ISC-${id}: ${text}`
|
|
632
|
+
: l
|
|
633
|
+
)
|
|
634
|
+
.join("\n");
|
|
635
|
+
|
|
636
|
+
if (inCriteria) p.criteria = rewrite(p.criteria ?? "");
|
|
637
|
+
else p.changelog = rewrite(p.changelog ?? "");
|
|
638
|
+
p.updated = now();
|
|
639
|
+
writeProject(p);
|
|
640
|
+
ok({
|
|
641
|
+
edited: true,
|
|
642
|
+
id,
|
|
643
|
+
status: isc.status,
|
|
644
|
+
previous: isc.text,
|
|
645
|
+
text,
|
|
646
|
+
});
|
|
530
647
|
}
|
|
531
648
|
|
|
532
649
|
// Backfill: sweep any done ISCs still sitting in Criteria (legacy projects, or
|
|
@@ -534,7 +651,7 @@ function cmdShowIsc(args: string[]): void {
|
|
|
534
651
|
function cmdPruneIsc(args: string[]): void {
|
|
535
652
|
const name = args[0] ?? fail("Usage: prune-isc <name>");
|
|
536
653
|
const p = requireProject(name);
|
|
537
|
-
const done = parseIscs(p.criteria ?? "").filter((i) => i.
|
|
654
|
+
const done = parseIscs(p.criteria ?? "").filter((i) => i.status !== "open");
|
|
538
655
|
for (const isc of done) {
|
|
539
656
|
const { line, rest } = removeIscLine(p.criteria ?? "", isc.id);
|
|
540
657
|
if (!line) continue;
|
|
@@ -545,7 +662,7 @@ function cmdPruneIsc(args: string[]): void {
|
|
|
545
662
|
p.updated = now();
|
|
546
663
|
writeProject(p);
|
|
547
664
|
}
|
|
548
|
-
const openLeft = parseIscs(p.criteria ?? "").filter((i) =>
|
|
665
|
+
const openLeft = parseIscs(p.criteria ?? "").filter((i) => i.status === "open").length;
|
|
549
666
|
ok({ pruned: done.length, name, remaining_open: openLeft });
|
|
550
667
|
}
|
|
551
668
|
|
|
@@ -628,8 +745,10 @@ Commands:
|
|
|
628
745
|
add-isc <name> "title" append a new open ISC to Criteria
|
|
629
746
|
complete-isc <name> <id> mark ISC-N as done
|
|
630
747
|
reopen-isc <name> <id> reopen ISC-N (mark not done)
|
|
631
|
-
list-isc <name> [--all | --closed]
|
|
748
|
+
list-isc <name> [--all | --closed | --retired] list open ISCs (default); --all, --closed, or --retired
|
|
632
749
|
show-isc <name> <id> print one ISC's full text
|
|
750
|
+
edit-isc <name> <id> "new text" rewrite ISC-N's text, keeping its id and state
|
|
751
|
+
retire-isc <name> <id> [--by <id>] close ISC-N as no longer valid, not as done
|
|
633
752
|
prune-isc <name> archive done ISCs from Criteria into the Changelog
|
|
634
753
|
isa-init <name> mark project as ISA-initialized
|
|
635
754
|
scaffold-task-isa <title> create a one-shot task ISA in memory/work/
|
|
@@ -725,6 +844,12 @@ function run(): void {
|
|
|
725
844
|
case "show-isc":
|
|
726
845
|
cmdShowIsc(rest);
|
|
727
846
|
return;
|
|
847
|
+
case "retire-isc":
|
|
848
|
+
cmdRetireIsc(rest);
|
|
849
|
+
return;
|
|
850
|
+
case "edit-isc":
|
|
851
|
+
cmdEditIsc(rest);
|
|
852
|
+
return;
|
|
728
853
|
case "prune-isc":
|
|
729
854
|
cmdPruneIsc(rest);
|
|
730
855
|
return;
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { parseArgs } from "node:util";
|
|
17
|
+
import { encodeAnchor } from "../../hooks/lib/anchor";
|
|
18
|
+
import { loadMachine } from "../../hooks/lib/machine";
|
|
17
19
|
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
18
20
|
import { emit } from "../lib/emit";
|
|
19
21
|
|
|
@@ -22,6 +24,7 @@ import { emit } from "../lib/emit";
|
|
|
22
24
|
export interface Thread {
|
|
23
25
|
id: string;
|
|
24
26
|
cwd: string;
|
|
27
|
+
m: string;
|
|
25
28
|
title: string;
|
|
26
29
|
context: string;
|
|
27
30
|
status: "open" | "resolved";
|
|
@@ -62,10 +65,12 @@ export function writeThreads(threads: Thread[]): void {
|
|
|
62
65
|
|
|
63
66
|
// ── Operations ──
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
/** Exported so the cwd-anchor and origin-stamp wiring is directly testable. */
|
|
69
|
+
export function addThread(title: string, context: string): Thread {
|
|
66
70
|
const thread: Thread = {
|
|
67
71
|
id: generateId(),
|
|
68
|
-
cwd: process.cwd(),
|
|
72
|
+
cwd: encodeAnchor(process.cwd()),
|
|
73
|
+
m: loadMachine().id,
|
|
69
74
|
title,
|
|
70
75
|
context,
|
|
71
76
|
status: "open",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
13
|
import { basename, extname, relative, resolve } from "node:path";
|
|
14
14
|
import { palHome } from "../hooks/lib/paths";
|
|
15
|
+
import { declaredTriggers } from "../hooks/lib/skill-triggers";
|
|
15
16
|
|
|
16
17
|
type Level = "pass" | "warn" | "error";
|
|
17
18
|
|
|
@@ -33,13 +34,20 @@ interface ParsedSkill {
|
|
|
33
34
|
name: string | null;
|
|
34
35
|
description: string | null;
|
|
35
36
|
descriptionQuoted: boolean;
|
|
37
|
+
triggers: string[];
|
|
38
|
+
shipped: boolean;
|
|
39
|
+
license: string | null;
|
|
40
|
+
derivedFrom: string | null;
|
|
36
41
|
body: string;
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
const SHIPPED_SOURCE = "portable-agent-layer";
|
|
45
|
+
|
|
39
46
|
const RESERVED_WORDS = ["anthropic", "claude"];
|
|
40
47
|
const MAX_NAME = 64;
|
|
41
48
|
const MAX_DESCRIPTION = 1024;
|
|
42
49
|
const MAX_BODY_LINES = 500;
|
|
50
|
+
const MIN_TRIGGERS = 3;
|
|
43
51
|
|
|
44
52
|
/** File extensions worth scanning for hardcoded paths (SKILL.md + its scripts). */
|
|
45
53
|
const SCANNABLE_EXT = new Set([".md", ".ts", ".js", ".mjs", ".cjs", ".sh", ".py"]);
|
|
@@ -83,7 +91,16 @@ function findAbsolutePaths(skillDir: string): string[] {
|
|
|
83
91
|
function parseSkill(content: string): ParsedSkill {
|
|
84
92
|
const parts = content.split(/^---\s*$/m);
|
|
85
93
|
if (parts.length < 3) {
|
|
86
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
name: null,
|
|
96
|
+
description: null,
|
|
97
|
+
descriptionQuoted: false,
|
|
98
|
+
triggers: [],
|
|
99
|
+
shipped: false,
|
|
100
|
+
license: null,
|
|
101
|
+
derivedFrom: null,
|
|
102
|
+
body: content,
|
|
103
|
+
};
|
|
87
104
|
}
|
|
88
105
|
const frontmatter = parts[1];
|
|
89
106
|
const body = parts.slice(2).join("---");
|
|
@@ -95,7 +112,46 @@ function parseSkill(content: string): ParsedSkill {
|
|
|
95
112
|
rawDescription.startsWith('"') &&
|
|
96
113
|
rawDescription.endsWith('"');
|
|
97
114
|
const description = descriptionQuoted ? rawDescription.slice(1, -1) : rawDescription;
|
|
98
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
description,
|
|
118
|
+
descriptionQuoted,
|
|
119
|
+
triggers: declaredTriggers(frontmatter),
|
|
120
|
+
shipped: metadataField(frontmatter, "source") === SHIPPED_SOURCE,
|
|
121
|
+
license: topLevelField(frontmatter, "license"),
|
|
122
|
+
derivedFrom: metadataField(frontmatter, "derived-from"),
|
|
123
|
+
body,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Value of a top-level `key:` line in the frontmatter, unquoted. */
|
|
128
|
+
function topLevelField(frontmatter: string, key: string): string | null {
|
|
129
|
+
return (
|
|
130
|
+
new RegExp(String.raw`^${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ?? null
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Value of an indented `key:` line under the `metadata:` block, unquoted. */
|
|
135
|
+
function metadataField(frontmatter: string, key: string): string | null {
|
|
136
|
+
return (
|
|
137
|
+
new RegExp(String.raw`^[ \t]+${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ??
|
|
138
|
+
null
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Render triggers for a report line: `"a", "b"` or `"a" then "b"`. */
|
|
143
|
+
function quoteList(triggers: string[], separator: string): string {
|
|
144
|
+
return triggers.map((trigger) => `"${trigger}"`).join(separator);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The triggers every skill must declare first: its own name, then the
|
|
149
|
+
* de-hyphenated form a user would actually type. A single-word name has only
|
|
150
|
+
* the one form, and the parser dedupes anyway, so it requires just itself.
|
|
151
|
+
*/
|
|
152
|
+
function leadTriggers(name: string): string[] {
|
|
153
|
+
const spaced = name.replaceAll("-", " ");
|
|
154
|
+
return spaced === name ? [name] : [name, spaced];
|
|
99
155
|
}
|
|
100
156
|
|
|
101
157
|
/** Remove fenced and inline code so prose checks don't trip on examples. */
|
|
@@ -134,9 +190,29 @@ export function lintSkill(skillDir: string): DoctorReport {
|
|
|
134
190
|
`skill file is "${skillFile}" — must be exactly "SKILL.md" or the skill is silently ignored`
|
|
135
191
|
);
|
|
136
192
|
|
|
137
|
-
const {
|
|
138
|
-
|
|
139
|
-
|
|
193
|
+
const {
|
|
194
|
+
name,
|
|
195
|
+
description,
|
|
196
|
+
descriptionQuoted,
|
|
197
|
+
triggers,
|
|
198
|
+
shipped,
|
|
199
|
+
license,
|
|
200
|
+
derivedFrom,
|
|
201
|
+
body,
|
|
202
|
+
} = parseSkill(readFileSync(resolve(skillDir, skillFile), "utf-8"));
|
|
203
|
+
|
|
204
|
+
// ── provenance (shipped skills only) ──
|
|
205
|
+
if (shipped) {
|
|
206
|
+
if (license) add("pass", "license", `licensed ${license}`);
|
|
207
|
+
else if (derivedFrom)
|
|
208
|
+
add("pass", "license", `unlicensed by design — derived from ${derivedFrom}`);
|
|
209
|
+
else
|
|
210
|
+
add(
|
|
211
|
+
"warn",
|
|
212
|
+
"license",
|
|
213
|
+
"shipped skill declares no license — add `license: MIT`, or `metadata.derived-from: <origin>` when the idea comes from another project"
|
|
214
|
+
);
|
|
215
|
+
}
|
|
140
216
|
|
|
141
217
|
// The runtime keys a skill by its folder name; a mismatched frontmatter `name`
|
|
142
218
|
// makes the skill silently fail to load.
|
|
@@ -218,6 +294,37 @@ export function lintSkill(skillDir: string): DoctorReport {
|
|
|
218
294
|
: add("pass", "description.pov", "third person");
|
|
219
295
|
}
|
|
220
296
|
|
|
297
|
+
// ── triggers ──
|
|
298
|
+
if (triggers.length === 0) {
|
|
299
|
+
add(
|
|
300
|
+
"warn",
|
|
301
|
+
"metadata.triggers",
|
|
302
|
+
"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"
|
|
303
|
+
);
|
|
304
|
+
} else if (triggers.length < MIN_TRIGGERS) {
|
|
305
|
+
add(
|
|
306
|
+
"warn",
|
|
307
|
+
"metadata.triggers",
|
|
308
|
+
`only ${triggers.length} trigger(s) declared — aim for at least ${MIN_TRIGGERS}, mostly multi-word phrases`
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
add("pass", "metadata.triggers", `${triggers.length} triggers declared`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (name && triggers.length > 0) {
|
|
315
|
+
const lead = leadTriggers(name);
|
|
316
|
+
const actual = triggers.slice(0, lead.length);
|
|
317
|
+
const wanted = quoteList(lead, " then ");
|
|
318
|
+
const found = quoteList(actual, ", ") || "nothing";
|
|
319
|
+
actual.join("\u0000") === lead.join("\u0000")
|
|
320
|
+
? add("pass", "metadata.triggers.lead", `leads with ${wanted}`)
|
|
321
|
+
: add(
|
|
322
|
+
"warn",
|
|
323
|
+
"metadata.triggers.lead",
|
|
324
|
+
`triggers must lead with ${wanted} — found ${found}`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
221
328
|
// ── body ──
|
|
222
329
|
const bodyLines = body.split("\n").length;
|
|
223
330
|
bodyLines <= MAX_BODY_LINES
|
|
@@ -299,6 +406,24 @@ export function lintSkill(skillDir: string): DoctorReport {
|
|
|
299
406
|
}
|
|
300
407
|
|
|
301
408
|
/** Render a report as a human-readable string. */
|
|
409
|
+
/** One scannable line per skill for a whole-store run: verdict plus the checks that fired. */
|
|
410
|
+
export function formatSummary(r: DoctorReport): string {
|
|
411
|
+
const fired = (level: Level) =>
|
|
412
|
+
r.findings.filter((f) => f.level === level).map((f) => f.check);
|
|
413
|
+
// The folder name, not the frontmatter name — the folder is what the reader
|
|
414
|
+
// passes back to `pal cli skill doctor <name>`, and a mismatch between the two
|
|
415
|
+
// is itself one of the errors this line reports.
|
|
416
|
+
const name = basename(r.dir).padEnd(20);
|
|
417
|
+
|
|
418
|
+
if (r.errors > 0) {
|
|
419
|
+
return `✗ ${name} ${r.errors} error(s): ${fired("error").join(", ")}`;
|
|
420
|
+
}
|
|
421
|
+
if (r.warnings > 0) {
|
|
422
|
+
return `⚠ ${name} ${r.warnings} warning(s): ${fired("warn").join(", ")}`;
|
|
423
|
+
}
|
|
424
|
+
return `✓ ${name} clean`;
|
|
425
|
+
}
|
|
426
|
+
|
|
302
427
|
export function formatReport(r: DoctorReport): string {
|
|
303
428
|
const icon = { pass: "✓", warn: "⚠", error: "✗" } as const;
|
|
304
429
|
const lines = [`skill-doctor: ${r.name ?? "(unparsed)"} — ${r.dir}`];
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
const USAGE = "usage: shot.ts <url> [-o <file>] [--viewport WxH] [--full-page] [--selector <css>] [--wait <ms>]";
|
|
2
|
-
export function parseArgs(argv) {
|
|
3
|
-
let url = "";
|
|
4
|
-
let out = "";
|
|
5
|
-
let viewport;
|
|
6
|
-
let fullPage = false;
|
|
7
|
-
let selector;
|
|
8
|
-
let waitMs;
|
|
9
|
-
for (let i = 0;i < argv.length; i++) {
|
|
10
|
-
const a = argv[i];
|
|
11
|
-
if (a === "-o" || a === "--out")
|
|
12
|
-
out = argv[++i] ?? "";
|
|
13
|
-
else if (a === "--viewport") {
|
|
14
|
-
const m = /^(\d+)[x,](\d+)$/.exec(argv[++i] ?? "");
|
|
15
|
-
if (!m)
|
|
16
|
-
throw new Error("--viewport expects WxH, e.g. 1440x900");
|
|
17
|
-
viewport = { width: Number(m[1]), height: Number(m[2]) };
|
|
18
|
-
} else if (a === "--full-page")
|
|
19
|
-
fullPage = true;
|
|
20
|
-
else if (a === "--selector")
|
|
21
|
-
selector = argv[++i];
|
|
22
|
-
else if (a === "--wait") {
|
|
23
|
-
const n = Number(argv[++i]);
|
|
24
|
-
if (!Number.isFinite(n))
|
|
25
|
-
throw new Error("--wait expects a number of milliseconds");
|
|
26
|
-
waitMs = n;
|
|
27
|
-
} else if (!a.startsWith("-") && !url)
|
|
28
|
-
url = a;
|
|
29
|
-
else
|
|
30
|
-
throw new Error(`unknown argument: ${a}
|
|
31
|
-
${USAGE}`);
|
|
32
|
-
}
|
|
33
|
-
if (!url)
|
|
34
|
-
throw new Error(`a URL is required
|
|
35
|
-
${USAGE}`);
|
|
36
|
-
return { url, out, viewport, fullPage, selector, waitMs };
|
|
37
|
-
}
|
|
38
|
-
export function chooseTier(opts) {
|
|
39
|
-
if (!opts.cliAvailable)
|
|
40
|
-
return "node";
|
|
41
|
-
if (opts.viewport || opts.fullPage)
|
|
42
|
-
return "node";
|
|
43
|
-
return "cli";
|
|
44
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
-
import { chooseTier, parseArgs } from "./shot-lib.mjs";
|
|
6
|
-
function playwrightCliAvailable() {
|
|
7
|
-
try {
|
|
8
|
-
return spawnSync("playwright-cli", ["--version"], { stdio: "ignore" }).status === 0;
|
|
9
|
-
} catch {
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
function runViaCli(opts, out) {
|
|
14
|
-
const work = mkdtempSync(join(tmpdir(), "pal-pwcli-"));
|
|
15
|
-
const run = (args, quiet = false) => spawnSync("playwright-cli", args, { stdio: quiet ? "ignore" : "inherit", cwd: work });
|
|
16
|
-
try {
|
|
17
|
-
if (run(["open", opts.url]).status !== 0)
|
|
18
|
-
return false;
|
|
19
|
-
const args = ["screenshot", `--filename=${out}`];
|
|
20
|
-
if (opts.selector)
|
|
21
|
-
args.push(opts.selector);
|
|
22
|
-
const shot = run(args);
|
|
23
|
-
run(["close"], true);
|
|
24
|
-
return shot.status === 0 && existsSync(out);
|
|
25
|
-
} finally {
|
|
26
|
-
rmSync(work, { recursive: true, force: true });
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async function runViaNode(opts, out) {
|
|
30
|
-
let chromium;
|
|
31
|
-
try {
|
|
32
|
-
({ chromium } = await import("playwright"));
|
|
33
|
-
} catch {
|
|
34
|
-
return "unavailable";
|
|
35
|
-
}
|
|
36
|
-
let browser;
|
|
37
|
-
try {
|
|
38
|
-
browser = await chromium.launch();
|
|
39
|
-
} catch (e) {
|
|
40
|
-
console.error(`chromium launch failed: ${e.message}`);
|
|
41
|
-
return "unavailable";
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
const page = await browser.newPage(opts.viewport ? { viewport: opts.viewport } : {});
|
|
45
|
-
await page.goto(opts.url, { waitUntil: "networkidle" });
|
|
46
|
-
if (opts.waitMs)
|
|
47
|
-
await page.waitForTimeout(opts.waitMs);
|
|
48
|
-
if (opts.selector)
|
|
49
|
-
await page.locator(opts.selector).screenshot({ path: out });
|
|
50
|
-
else
|
|
51
|
-
await page.screenshot({ path: out, fullPage: opts.fullPage });
|
|
52
|
-
return existsSync(out) ? "ok" : "error";
|
|
53
|
-
} catch (e) {
|
|
54
|
-
console.error(`screenshot failed: ${e.message}`);
|
|
55
|
-
return "error";
|
|
56
|
-
} finally {
|
|
57
|
-
await browser.close();
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
async function main() {
|
|
61
|
-
let opts;
|
|
62
|
-
try {
|
|
63
|
-
opts = parseArgs(process.argv.slice(2));
|
|
64
|
-
} catch (e) {
|
|
65
|
-
console.error(e.message);
|
|
66
|
-
process.exit(2);
|
|
67
|
-
}
|
|
68
|
-
const out = opts.out ? resolve(opts.out) : join(tmpdir(), `pal-shot-${Date.now()}.png`);
|
|
69
|
-
const tier = chooseTier({
|
|
70
|
-
cliAvailable: playwrightCliAvailable(),
|
|
71
|
-
viewport: opts.viewport,
|
|
72
|
-
fullPage: opts.fullPage
|
|
73
|
-
});
|
|
74
|
-
if (tier === "cli" && runViaCli(opts, out)) {
|
|
75
|
-
console.log(out);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const result = await runViaNode(opts, out);
|
|
79
|
-
if (result === "ok") {
|
|
80
|
-
console.log(out);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (result === "unavailable") {
|
|
84
|
-
console.error("NO_PLAYWRIGHT_CLI");
|
|
85
|
-
process.exit(3);
|
|
86
|
-
}
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
89
|
-
await main();
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: review
|
|
3
|
-
description: "Security-focused code review with severity ratings. Use when reviewing code for security issues, vulnerabilities, or OWASP concerns."
|
|
4
|
-
argument-hint: [file or directory]
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
When the user invokes /review <file, diff, or PR>:
|
|
8
|
-
|
|
9
|
-
1. Read the target code or diff in full
|
|
10
|
-
2. Analyze for:
|
|
11
|
-
- **Security** — OWASP top 10, injection, auth, data exposure
|
|
12
|
-
- **Logic** — edge cases, off-by-one, null handling, race conditions
|
|
13
|
-
- **Performance** — N+1 queries, unnecessary allocations, blocking calls
|
|
14
|
-
- **Style** — consistency with surrounding code (not your preferences)
|
|
15
|
-
3. Output findings grouped by severity:
|
|
16
|
-
- CRITICAL — must fix before merge
|
|
17
|
-
- WARNING — should fix, creates risk
|
|
18
|
-
- SUGGESTION — nice to have
|
|
19
|
-
4. Each finding includes: file:line, what's wrong, concrete fix
|
|
20
|
-
5. End with a one-line verdict: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|