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.
Files changed (65) hide show
  1. package/README.md +8 -4
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/analyze-pdf/SKILL.md +11 -0
  4. package/assets/skills/analyze-youtube/SKILL.md +12 -0
  5. package/assets/skills/consulting-report/SKILL.md +9 -0
  6. package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
  7. package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
  8. package/assets/skills/council/SKILL.md +32 -0
  9. package/assets/skills/create-pdf/SKILL.md +13 -0
  10. package/assets/skills/create-skill/SKILL.md +14 -2
  11. package/assets/skills/create-skill/authoring-guide.md +10 -1
  12. package/assets/skills/create-subagent/SKILL.md +22 -4
  13. package/assets/skills/{research → deep-research}/SKILL.md +32 -1
  14. package/assets/skills/entities/SKILL.md +10 -0
  15. package/assets/skills/extract-wisdom/SKILL.md +12 -0
  16. package/assets/skills/first-principles/SKILL.md +8 -0
  17. package/assets/skills/frontend-design/SKILL.md +14 -0
  18. package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
  19. package/assets/skills/humanize/SKILL.md +13 -1
  20. package/assets/skills/opinion/SKILL.md +11 -0
  21. package/assets/skills/pal-analyze/SKILL.md +11 -0
  22. package/assets/skills/pal-reflect/SKILL.md +10 -0
  23. package/assets/skills/playwright/SKILL.md +15 -2
  24. package/assets/skills/playwright/tools/shot.ts +6 -7
  25. package/assets/skills/presentation/SKILL.md +12 -0
  26. package/assets/skills/projects/SKILL.md +20 -1
  27. package/assets/skills/reflect/SKILL.md +13 -0
  28. package/assets/skills/telos/SKILL.md +12 -0
  29. package/assets/skills/think/SKILL.md +9 -0
  30. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
  31. package/assets/templates/pal-settings.json +1 -0
  32. package/assets/templates/settings.claude.json +2 -1
  33. package/package.json +15 -4
  34. package/src/cli/index.ts +95 -9
  35. package/src/cli/migrate.ts +69 -3
  36. package/src/cli/skill.ts +47 -3
  37. package/src/hooks/handlers/inject-retrieval.ts +20 -10
  38. package/src/hooks/lib/anchor.ts +90 -0
  39. package/src/hooks/lib/bindings.ts +117 -0
  40. package/src/hooks/lib/export.ts +38 -1
  41. package/src/hooks/lib/import-merge.ts +220 -0
  42. package/src/hooks/lib/inference.ts +113 -72
  43. package/src/hooks/lib/machine.ts +176 -0
  44. package/src/hooks/lib/projects.ts +223 -15
  45. package/src/hooks/lib/readme-sync.ts +30 -10
  46. package/src/hooks/lib/relationship.ts +3 -1
  47. package/src/hooks/lib/remote.ts +58 -0
  48. package/src/hooks/lib/retrieval.ts +8 -2
  49. package/src/hooks/lib/signals.ts +2 -1
  50. package/src/hooks/lib/skill-match.ts +129 -0
  51. package/src/hooks/lib/skill-triggers.ts +82 -0
  52. package/src/hooks/lib/stop.ts +5 -2
  53. package/src/targets/lib.ts +137 -35
  54. package/src/targets/opencode/plugin.ts +2 -6
  55. package/src/tools/agent/algorithm-reflect.ts +45 -11
  56. package/src/tools/agent/project.ts +148 -23
  57. package/src/tools/agent/thread.ts +7 -2
  58. package/src/tools/skill-doctor.ts +130 -5
  59. package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
  60. package/assets/skills/playwright/tools/shot.mjs +0 -89
  61. package/assets/skills/review/SKILL.md +0 -20
  62. package/assets/skills/summarize/SKILL.md +0 -16
  63. /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
  64. /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
  65. /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
@@ -17,14 +17,19 @@ import {
17
17
  writeFileSync,
18
18
  } from "node:fs";
19
19
  import { basename, dirname, parse as parsePath, resolve, sep } from "node:path";
20
+ import { type Bindings, readBindings, writeBinding, writeBindings } from "./bindings";
20
21
  import { parse, stringify } from "./frontmatter";
21
- import { paths } from "./paths";
22
+ import { palHome, paths } from "./paths";
23
+ import { detectRemote } from "./remote";
22
24
 
23
25
  export type ProjectStatus = "active" | "paused" | "complete" | "archived";
24
26
 
25
27
  export interface ProjectProgress {
26
28
  name: string;
27
- path: string;
29
+ /** Resolved for this machine at read time; absent when not checked out here. */
30
+ path?: string;
31
+ /** Normalized git origin — the same on every machine, so this one does travel. */
32
+ remote?: string;
28
33
  status: ProjectStatus;
29
34
  created: string;
30
35
  updated: string;
@@ -109,7 +114,9 @@ const PROJECT_MARKERS = [
109
114
 
110
115
  type IsaMeta = {
111
116
  name: string;
112
- path: string;
117
+ /** Legacy only — records written since bindings landed carry no path. */
118
+ path?: string;
119
+ remote?: string;
113
120
  status: ProjectStatus;
114
121
  created: string;
115
122
  updated: string;
@@ -188,40 +195,77 @@ export function looksLikeProjectRoot(cwd: string): boolean {
188
195
  return PROJECT_MARKERS.some((marker) => existsSync(resolve(cwdAbs, marker)));
189
196
  }
190
197
 
198
+ /**
199
+ * Every project record, with `path` resolved to this machine.
200
+ *
201
+ * Seeds bindings for any project not yet bound here, which is what makes the
202
+ * binding file self-maintaining: no install step, no migration, no command to
203
+ * remember. Seeding is idempotent and writes only when it actually binds
204
+ * something, so the steady-state cost is the one `bindings.json` read below.
205
+ */
191
206
  export function readAllProjects(): ProjectProgress[] {
192
207
  const base = paths.projectHistory();
193
208
  if (!existsSync(base)) return [];
209
+ const bindings = readBindings();
194
210
  const out: ProjectProgress[] = [];
195
211
  for (const slug of readdirSync(base)) {
196
212
  const file = resolve(base, slug, "ISA.md");
197
213
  if (!existsSync(file)) continue;
198
- const p = readProject(slug);
214
+ const p = readProject(slug, bindings);
199
215
  if (p) out.push(p);
200
216
  }
217
+ seedBindings(out);
201
218
  return out;
202
219
  }
203
220
 
204
- export function readProject(name: string): ProjectProgress | null {
221
+ export function readProject(
222
+ name: string,
223
+ bindings: Bindings = readBindings()
224
+ ): ProjectProgress | null {
205
225
  const file = isaFilePath(name);
206
226
  if (!existsSync(file)) return null;
207
227
  try {
208
228
  const content = readFileSync(file, "utf-8");
209
229
  const { meta, body } = parse<IsaMeta>(content);
210
- if (!meta?.name || !meta?.path || !meta?.status) return null;
211
- return { ...meta, ...extractSections(body) };
230
+ if (!meta?.name || !meta?.status) return null;
231
+ // `path` is machine-local: the binding is the real source, and meta.path is
232
+ // only still read so records written before this change keep resolving.
233
+ const bound = bindings[meta.name] ?? meta.path;
234
+ const path = bound ? resolve(bound) : undefined;
235
+ return { ...meta, path, ...extractSections(body) };
212
236
  } catch {
213
237
  return null;
214
238
  }
215
239
  }
216
240
 
241
+ /**
242
+ * Persist a project. The `path` is deliberately NOT written into the record:
243
+ * records travel in an export, and one machine's disk layout is meaningless — or
244
+ * actively wrong — on another. It is recorded as a binding instead, which stays
245
+ * on this machine. Callers keep setting `p.path` as before; only where it lands
246
+ * has changed.
247
+ */
217
248
  export function writeProject(p: ProjectProgress): void {
249
+ // Detected once and then kept: the remote is stable, and re-running git on
250
+ // every save would spawn a subprocess per project write for no new information.
251
+ if (!p.remote && p.path && existsSync(p.path)) {
252
+ const detected = detectRemote(p.path);
253
+ if (detected) p.remote = detected;
254
+ }
255
+ // Only a path that exists here may be bound. Saving a record is not a claim
256
+ // about this machine's disk — `path` may have arrived from an imported record
257
+ // written elsewhere, and binding it would recreate the very leak bindings exist
258
+ // to prevent. An explicit `writeBinding` from the user stays unguarded.
259
+ if (p.path && existsSync(p.path)) writeBinding(p.name, p.path);
218
260
  const meta: Record<string, unknown> = {
219
261
  name: p.name,
220
- path: p.path,
221
262
  status: p.status,
222
263
  created: p.created,
223
264
  updated: p.updated,
224
265
  };
266
+ // Unlike `path`, this one is written into the record on purpose — it identifies
267
+ // the repository rather than one machine's copy of it.
268
+ if (p.remote) meta.remote = p.remote;
225
269
  if (p.next?.length) meta.next = p.next;
226
270
  if (p.blockers?.length) meta.blockers = p.blockers;
227
271
  if (p.handoff) meta.handoff = p.handoff;
@@ -246,18 +290,41 @@ export function deleteProject(name: string): boolean {
246
290
  * Parent-dir browse mode (cwd is an ancestor of a registered project) → null.
247
291
  * Multiple nested projects → longest registered path wins.
248
292
  */
293
+ /**
294
+ * Where a project sits on THIS machine.
295
+ *
296
+ * A record's `path` is written by whichever machine last touched it and travels
297
+ * with the corpus, so on any other machine it is a claim rather than a fact. A
298
+ * binding is local by construction, so it wins; the record's path remains the
299
+ * fallback until this machine has bound the project.
300
+ *
301
+ * Note the fallback still trusts a foreign path — closing that is ISC-48 task 4
302
+ * proper, which requires seeding to be wired first.
303
+ */
304
+ export function projectPathOnThisMachine(
305
+ project: ProjectProgress,
306
+ bindings: Bindings = readBindings()
307
+ ): string | null {
308
+ const bound = bindings[project.name] ?? project.path;
309
+ return bound ? resolve(bound) : null;
310
+ }
311
+
249
312
  export function resolveProjectFromCwd(
250
313
  cwd: string,
251
- projects: ProjectProgress[]
314
+ projects: ProjectProgress[],
315
+ bindings: Bindings = readBindings()
252
316
  ): ProjectProgress | null {
253
317
  const cwdAbs = resolve(cwd);
254
- const matches = projects.filter((p) => {
255
- const projAbs = resolve(p.path);
256
- return cwdAbs === projAbs || cwdAbs.startsWith(projAbs + sep);
257
- });
318
+ const matches: { project: ProjectProgress; path: string }[] = [];
319
+ for (const project of projects) {
320
+ const projAbs = projectPathOnThisMachine(project, bindings);
321
+ if (!projAbs) continue;
322
+ if (cwdAbs === projAbs || cwdAbs.startsWith(projAbs + sep))
323
+ matches.push({ project, path: projAbs });
324
+ }
258
325
  if (matches.length === 0) return null;
259
326
  matches.sort((a, b) => b.path.length - a.path.length);
260
- return matches[0];
327
+ return matches[0].project;
261
328
  }
262
329
 
263
330
  export function isStale(
@@ -312,7 +379,8 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
312
379
  const resolved = resolveProjectFromCwd(cwd, visible);
313
380
  const projectRoot = findProjectRoot(cwd);
314
381
  const alreadyRegistered =
315
- projectRoot !== null && all.some((p) => resolve(p.path) === projectRoot);
382
+ projectRoot !== null &&
383
+ all.some((p) => p.path !== undefined && resolve(p.path) === projectRoot);
316
384
  const showHint = resolved === null && projectRoot !== null && !alreadyRegistered;
317
385
 
318
386
  if (visible.length === 0 && !showHint) return "";
@@ -377,3 +445,143 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
377
445
 
378
446
  return lines.join("\n");
379
447
  }
448
+
449
+ /**
450
+ * Adopt the `path` already stored on each project record, for projects not yet
451
+ * bound here.
452
+ *
453
+ * Two guards keep this from importing another machine's filesystem. An existing
454
+ * binding always wins, because a binding is what THIS machine knows while a
455
+ * record's path may belong to any machine that ever wrote it. And a path is
456
+ * adopted only if it exists locally — that is what makes seeding safe to run on
457
+ * a machine that just imported someone else's corpus: their paths are simply not
458
+ * here, so nothing binds and those projects stay correctly unbound.
459
+ *
460
+ * Seeding is inference, so it is conservative. `writeBinding` is a statement by
461
+ * the user and is trusted without an existence check — binding a path you are
462
+ * about to clone into has to work.
463
+ *
464
+ * Returns the names newly bound, so a caller can stay silent on the common no-op.
465
+ */
466
+ export function seedBindings(
467
+ projects: ProjectProgress[],
468
+ home: string = palHome()
469
+ ): string[] {
470
+ const bindings = readBindings(home);
471
+ const seeded: string[] = [];
472
+ for (const project of projects) {
473
+ if (!project.name || !project.path) continue;
474
+ if (project.name in bindings) continue;
475
+ if (!existsSync(project.path)) continue;
476
+ bindings[project.name] = resolve(project.path);
477
+ seeded.push(project.name);
478
+ }
479
+ if (seeded.length === 0) return [];
480
+ writeBindings(bindings, home);
481
+ return seeded;
482
+ }
483
+
484
+ export type BindingIssue =
485
+ | { kind: "unlocatable"; project: string }
486
+ | { kind: "missing"; project: string; path: string }
487
+ | { kind: "shared"; path: string; projects: string[] };
488
+
489
+ /**
490
+ * Health of this machine's project bindings.
491
+ *
492
+ * Three things can go wrong once a path is machine-local. A project can become
493
+ * unlocatable, which is what losing bindings.json looks like from the outside.
494
+ * A binding can outlive the directory it names. And two projects can end up on
495
+ * one directory — the shape that appears when a name is reused for a second
496
+ * checkout, where binding by name alone would silently repoint the first.
497
+ *
498
+ * Read-only by construction: it reports, it never repairs.
499
+ */
500
+ export function auditBindings(
501
+ projects: ProjectProgress[] = readAllProjects(),
502
+ bindings: Bindings = readBindings()
503
+ ): BindingIssue[] {
504
+ const issues: BindingIssue[] = [];
505
+ const byPath = new Map<string, string[]>();
506
+
507
+ for (const project of projects) {
508
+ const path = projectPathOnThisMachine(project, bindings);
509
+ if (!path) {
510
+ issues.push({ kind: "unlocatable", project: project.name });
511
+ continue;
512
+ }
513
+ if (!existsSync(path)) {
514
+ issues.push({ kind: "missing", project: project.name, path });
515
+ continue;
516
+ }
517
+ byPath.set(path, [...(byPath.get(path) ?? []), project.name]);
518
+ }
519
+
520
+ for (const [path, names] of byPath) {
521
+ if (names.length > 1) issues.push({ kind: "shared", path, projects: names.sort() });
522
+ }
523
+
524
+ return issues;
525
+ }
526
+
527
+ /**
528
+ * Every issue names the command that fixes it. PAL runs inside agents, where a
529
+ * hook cannot ask a question — so a suggestion is always a command the user can
530
+ * choose to run, never something applied on their behalf.
531
+ */
532
+ export function describeBindingIssue(issue: BindingIssue): string {
533
+ const fix = (name: string) => `run 'project set-path ${name} <path>'`;
534
+ if (issue.kind === "unlocatable")
535
+ return `${issue.project} — not checked out here (${fix(issue.project)})`;
536
+ // "points at" rather than "bound to": the path may equally have come from a
537
+ // legacy record's own field, which is not a binding.
538
+ if (issue.kind === "missing")
539
+ return `${issue.project} — points at ${issue.path}, which does not exist here (${fix(issue.project)})`;
540
+ return `${issue.projects.join(" and ")} — both point at ${issue.path}; rebind whichever is wrong (${fix(issue.projects[0])})`;
541
+ }
542
+
543
+ export type BindingProposal = {
544
+ state: "unbound";
545
+ confidence: "strong" | "weak";
546
+ reason: string;
547
+ candidate: string;
548
+ command: string;
549
+ };
550
+
551
+ /**
552
+ * What PAL would suggest for a project it cannot locate, given where the user
553
+ * currently is. A matching git remote is strong evidence — two checkouts of one
554
+ * repository — while a matching directory name is only weak, because a name can
555
+ * be reused for an unrelated copy. Returns null when there is nothing worth
556
+ * saying, which is the common case.
557
+ */
558
+ export function proposeBinding(
559
+ project: ProjectProgress,
560
+ cwd: string = process.cwd()
561
+ ): BindingProposal | null {
562
+ const command = `pal cli project set-path ${project.name} ${cwd}`;
563
+ const cwdRemote = detectRemote(cwd);
564
+
565
+ if (project.remote && cwdRemote === project.remote) {
566
+ return {
567
+ state: "unbound",
568
+ confidence: "strong",
569
+ reason: `the repository here is ${cwdRemote}, which is this project's recorded remote`,
570
+ candidate: cwd,
571
+ command,
572
+ };
573
+ }
574
+
575
+ if (basename(cwd) === project.name) {
576
+ return {
577
+ state: "unbound",
578
+ confidence: "weak",
579
+ reason:
580
+ "this directory shares the project's name, but nothing confirms it is the same one — a name can belong to more than one checkout",
581
+ candidate: cwd,
582
+ command,
583
+ };
584
+ }
585
+
586
+ return null;
587
+ }
@@ -79,15 +79,30 @@ function extractEnvVars(): string[] {
79
79
  return [...vars];
80
80
  }
81
81
 
82
- /** Extract skill names from assets/skills/ */
83
- function extractSkillNames(): string[] {
84
- const pkg = palPkg();
85
- const skillsDir = resolve(pkg, "assets", "skills");
82
+ /**
83
+ * Names of the skills PAL ships — one directory per skill, each holding a
84
+ * SKILL.md. Reading the directory rather than loose `.md` files matters: the
85
+ * folder-per-skill layout is what the runtime loads, and matching on files
86
+ * silently yields nothing, which makes every skill check pass vacuously.
87
+ */
88
+ export function shippedSkillNames(): string[] {
89
+ const skillsDir = resolve(palPkg(), "assets", "skills");
86
90
  if (!existsSync(skillsDir)) return [];
87
91
 
88
- return readdirSync(skillsDir)
89
- .filter((f) => f.endsWith(".md"))
90
- .map((f) => f.replace(/\.md$/, ""));
92
+ return readdirSync(skillsDir, { withFileTypes: true })
93
+ .filter((e) => e.isDirectory() && existsSync(resolve(skillsDir, e.name, "SKILL.md")))
94
+ .map((e) => e.name)
95
+ .sort();
96
+ }
97
+
98
+ /**
99
+ * Skill names the README's "## Skills" table claims PAL ships. Scoped to that
100
+ * one section: other tables list commands and agents in the same row shape, and
101
+ * matching them would report every command as a retired skill.
102
+ */
103
+ function documentedSkillNames(readme: string): string[] {
104
+ const section = /^## Skills$([\s\S]*?)(?=^## )/m.exec(readme)?.[1] ?? "";
105
+ return Array.from(section.matchAll(/^\|\s*`([a-z0-9-]+)`\s*\|/gm), (m) => m[1]);
91
106
  }
92
107
 
93
108
  /** Validate that README.md documents all code surfaces. */
@@ -118,12 +133,17 @@ export function validateReadmeSync(): SyncResult {
118
133
  }
119
134
  }
120
135
 
121
- // Check skills — just verify the count is mentioned or each name appears
122
- const skills = extractSkillNames();
123
- const undocumentedSkills = skills.filter((name) => !readme.includes(name));
136
+ // Check skills — every shipped skill has a row, and no row outlives its skill
137
+ const shipped = shippedSkillNames();
138
+ const undocumentedSkills = shipped.filter((name) => !readme.includes(name));
124
139
  if (undocumentedSkills.length > 0) {
125
140
  issues.push(`Skills not documented in README: ${undocumentedSkills.join(", ")}`);
126
141
  }
127
142
 
143
+ const retired = documentedSkillNames(readme).filter((name) => !shipped.includes(name));
144
+ if (retired.length > 0) {
145
+ issues.push(`README documents skills that no longer ship: ${retired.join(", ")}`);
146
+ }
147
+
128
148
  return { ok: issues.length === 0, issues };
129
149
  }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
+ import { encodeAnchor } from "./anchor";
16
17
  import { ensureDir, paths } from "./paths";
17
18
 
18
19
  type NoteType = "W" | "O" | "Session";
@@ -65,7 +66,8 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): void
65
66
 
66
67
  const timestamp = new Date().toTimeString().slice(0, 5);
67
68
  lines.push(`## ${timestamp}`);
68
- if (sessionId) lines.push(`<!-- session:${sessionId} cwd:${process.cwd()} -->`);
69
+ if (sessionId)
70
+ lines.push(`<!-- session:${sessionId} cwd:${encodeAnchor(process.cwd())} -->`);
69
71
 
70
72
  for (const note of fresh) {
71
73
  if (note.type === "O" && note.confidence !== undefined) {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Repository identity — the one thing about a project that means the same on
3
+ * every machine.
4
+ *
5
+ * A path answers "where is it here", which is why it cannot travel. A git remote
6
+ * answers "which repository is this", which is true everywhere the repo is
7
+ * cloned. That makes it the portable half of a project's identity, and the
8
+ * evidence that lets PAL suggest a binding without guessing from a directory
9
+ * name — two unrelated checkouts can share a name, but not a remote.
10
+ *
11
+ * Nothing has to be committed into the repo for this to work: the remote is
12
+ * already there.
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+
17
+ const GIT_TIMEOUT_MS = 2000;
18
+
19
+ /**
20
+ * SSH and HTTPS clone URLs of one repository both normalize to the same value —
21
+ * `git@github.com:owner/repo.git` becomes `github.com/owner/repo` — so the same
22
+ * repository matches however it was cloned. Any credential embedded before the
23
+ * host is stripped rather than stored: this value lives in a record that travels
24
+ * in exports.
25
+ */
26
+ export function normalizeRemote(url: string): string | null {
27
+ const trimmed = url.trim();
28
+ if (!trimmed) return null;
29
+
30
+ const scp = /^[^@/]+@([^:]+):(.+)$/.exec(trimmed);
31
+ const withoutScheme = scp
32
+ ? `${scp[1]}/${scp[2]}`
33
+ : trimmed.replace(/^[a-z+]+:\/\//i, "");
34
+
35
+ const withoutCredentials = withoutScheme.replace(/^[^@/]*@/, "");
36
+ const cleaned = withoutCredentials
37
+ .replace(/\.git$/, "")
38
+ .replace(/\/+$/, "")
39
+ .toLowerCase();
40
+
41
+ // A remote on the local filesystem identifies nothing portable — it is just
42
+ // another path — so only a real host earns the right to be identity.
43
+ const [hostWithPort, ...rest] = cleaned.split("/");
44
+ const host = hostWithPort.split(":")[0];
45
+ if (rest.length === 0 || rest.join("/").length === 0) return null;
46
+ if (!/^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(host)) return null;
47
+ return cleaned;
48
+ }
49
+
50
+ /** The normalized origin remote of the repo at `dir`, or null if there is none. */
51
+ export function detectRemote(dir: string): string | null {
52
+ const res = spawnSync("git", ["-C", dir, "remote", "get-url", "origin"], {
53
+ encoding: "utf-8",
54
+ timeout: GIT_TIMEOUT_MS,
55
+ });
56
+ if (res.status !== 0 || !res.stdout) return null;
57
+ return normalizeRemote(res.stdout);
58
+ }
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { basename } from "node:path";
10
+ import { anchorMatchesCwd } from "./anchor";
11
+ import { readAllProjects } from "./projects";
10
12
  import type { IndexedDoc, RetrievalIndex } from "./retrieval-index";
11
13
  import { extractKeywords } from "./text-similarity";
12
14
 
@@ -87,14 +89,18 @@ function rank(query: string, index: RetrievalIndex, cwd: string): ScoredDoc[] {
87
89
  .toLowerCase()
88
90
  .replace(/[^a-z0-9-]/g, "");
89
91
  const scopeTokens = scopeKey ? extractKeywords(scopeKey) : new Set<string>();
92
+ // Loaded once per rank() call, not per doc — the registry rarely changes
93
+ // within a single retrieval pass.
94
+ const projects = readAllProjects();
90
95
 
91
96
  const scored: ScoredDoc[] = [];
92
97
  for (const doc of index.docs) {
93
98
  const raw = scoreDoc(queryTerms, doc, index.df, N);
94
99
  if (raw === 0) continue;
95
- // Exact cwd match when available; fingerprint heuristic for older captures.
100
+ // Anchored or plain cwd resolved against the local registry when
101
+ // available; fingerprint heuristic for captures with no cwd at all.
96
102
  const scopeMatch = doc.cwd
97
- ? doc.cwd === cwd
103
+ ? anchorMatchesCwd(doc.cwd, cwd, projects)
98
104
  : [...scopeTokens].some((t) => scopeMatches(doc, t));
99
105
  const boosted = raw * (scopeMatch ? SCOPE_BOOST : 1) * ageDecay(doc.ts);
100
106
  const confidence = boosted / self;
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { loadMachine } from "./machine";
3
4
  import { paths } from "./paths";
4
5
  import { now } from "./time";
5
6
 
@@ -14,7 +15,7 @@ function emitSignal(
14
15
  filename: string,
15
16
  data: { type: string; [key: string]: unknown }
16
17
  ): void {
17
- const signal: Signal = { ts: now(), ...data };
18
+ const signal: Signal = { ts: now(), m: loadMachine().id, ...data };
18
19
  const filepath = resolve(paths.signals(), filename);
19
20
  appendFileSync(filepath, `${JSON.stringify(signal)}\n`);
20
21
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Skill matching — deterministic trigger lookup that names the skills a prompt
3
+ * probably wants, injected at prompt time.
4
+ *
5
+ * A skill declares its own `metadata.triggers` in SKILL.md; `generateSkillIndex`
6
+ * copies them into skill-index.json. Here the prompt and every trigger are
7
+ * normalized the same way (lowercase, punctuation to spaces, space-padded), so a
8
+ * plain substring test is already a whole-word test and multi-word phrases work
9
+ * without a regex. Pure + fail-open, like the steering classifier it rides with.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { paths } from "./paths";
15
+ import { isEnabled } from "./settings";
16
+
17
+ interface SkillIndexEntry {
18
+ name: string;
19
+ description: string;
20
+ triggers?: string[];
21
+ }
22
+
23
+ export interface SkillIndex {
24
+ skills: Record<string, SkillIndexEntry>;
25
+ }
26
+
27
+ export interface SkillMatch {
28
+ name: string;
29
+ description: string;
30
+ score: number;
31
+ matched: string[];
32
+ }
33
+
34
+ const MAX_MATCHES = 3;
35
+ const MAX_SKILL_BYTES = 700;
36
+ const MAX_DESCRIPTION_CHARS = 100;
37
+ const PHRASE_WEIGHT = 3;
38
+ const WORD_WEIGHT = 1;
39
+
40
+ /** Lowercase, punctuation to spaces, space-padded — so `includes` tests whole words. */
41
+ function normalize(text: string): string {
42
+ return ` ${text
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, " ")
45
+ .trim()} `;
46
+ }
47
+
48
+ function readSkillIndex(): SkillIndex | null {
49
+ const path = resolve(paths.state(), "skill-index.json");
50
+ if (!existsSync(path)) return null;
51
+ try {
52
+ return JSON.parse(readFileSync(path, "utf-8")) as SkillIndex;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function weightOf(trigger: string): number {
59
+ return trigger.trim().includes(" ") ? PHRASE_WEIGHT : WORD_WEIGHT;
60
+ }
61
+
62
+ /** Rank the indexed skills whose triggers appear in the prompt, best first. */
63
+ export function matchSkills(prompt: string, index: SkillIndex): SkillMatch[] {
64
+ const haystack = normalize(prompt);
65
+ if (haystack.trim() === "") return [];
66
+
67
+ const matches: SkillMatch[] = [];
68
+ for (const entry of Object.values(index.skills ?? {})) {
69
+ let score = 0;
70
+ const matched: string[] = [];
71
+ for (const trigger of entry.triggers ?? []) {
72
+ const needle = normalize(trigger);
73
+ if (needle.trim() === "" || !haystack.includes(needle)) continue;
74
+ score += weightOf(needle);
75
+ matched.push(trigger);
76
+ }
77
+ if (score > 0)
78
+ matches.push({ name: entry.name, description: entry.description, score, matched });
79
+ }
80
+
81
+ return matches
82
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
83
+ .slice(0, MAX_MATCHES);
84
+ }
85
+
86
+ function summarize(description: string): string {
87
+ if (description.length <= MAX_DESCRIPTION_CHARS) return description;
88
+ return `${description.slice(0, MAX_DESCRIPTION_CHARS).trimEnd()}…`;
89
+ }
90
+
91
+ function line(match: SkillMatch): string {
92
+ const matched = match.matched.map((trigger) => `"${trigger}"`).join(", ");
93
+ return `- ${match.name} — ${summarize(match.description)} (matched: ${matched})`;
94
+ }
95
+
96
+ /** Build the skill-match <system-reminder> for a prompt, or null if nothing matches. */
97
+ export function getSkillReminder(prompt: string): string | null {
98
+ if (!isEnabled("skillMatching")) return null;
99
+ if (!prompt?.trim()) return null;
100
+
101
+ const index = readSkillIndex();
102
+ if (!index) return null;
103
+
104
+ let matches: SkillMatch[];
105
+ try {
106
+ matches = matchSkills(prompt, index);
107
+ } catch {
108
+ return null; // fail-open: never block a prompt on a matcher error
109
+ }
110
+ if (matches.length === 0) return null;
111
+
112
+ const lines: string[] = [];
113
+ let budget = MAX_SKILL_BYTES;
114
+ for (const match of matches) {
115
+ const rendered = line(match);
116
+ const cost = Buffer.byteLength(rendered);
117
+ if (cost > budget) break; // byte-cap: drop the overflow tail, keep top matches
118
+ lines.push(rendered);
119
+ budget -= cost;
120
+ }
121
+ if (lines.length === 0) return null;
122
+
123
+ return [
124
+ "<system-reminder>",
125
+ "Potential matching skills: these matched trigger words in your prompt. Invoke one with the Skill tool if it fits the request; ignore them if none do.",
126
+ ...lines,
127
+ "</system-reminder>",
128
+ ].join("\n");
129
+ }