portable-agent-layer 0.64.0 → 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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Machine identity — who this install is, and how a record's origin becomes a
3
+ * name at display time.
4
+ *
5
+ * Records store the id and never the label. Resolution happens on read, so
6
+ * renaming a machine is a one-file edit that no stored record notices, and two
7
+ * machines sharing a label is a display concern rather than a data collision.
8
+ *
9
+ * `machine.json` lives at the PAL_HOME root, outside every exported directory,
10
+ * because importing it would give two installs one id and silently break every
11
+ * origin-scoped read built on top of it.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { platform as osPlatform } from "node:os";
16
+ import { resolve } from "node:path";
17
+ import { parse, stringify } from "./frontmatter";
18
+ import { palHome, paths } from "./paths";
19
+
20
+ export interface MachineIdentity {
21
+ id: string;
22
+ label: string;
23
+ os: string;
24
+ createdAt: string;
25
+ }
26
+
27
+ const SHORT_ID_LENGTH = 4;
28
+
29
+ export function machineFilePath(home: string = palHome()): string {
30
+ return resolve(home, "machine.json");
31
+ }
32
+
33
+ function machinesDir(): string {
34
+ const dir = resolve(paths.memory(), "machines");
35
+ mkdirSync(dir, { recursive: true });
36
+ return dir;
37
+ }
38
+
39
+ /** First segment of the uuid — enough to disambiguate two same-labelled machines. */
40
+ export function shortId(id: string): string {
41
+ return id.replaceAll("-", "").slice(0, SHORT_ID_LENGTH);
42
+ }
43
+
44
+ /**
45
+ * Neutral default label. Deliberately not derived from the hostname: a hostname
46
+ * routinely carries the owner's real name, and the label travels in every
47
+ * exported registry entry.
48
+ */
49
+ export function defaultLabel(id: string): string {
50
+ return `machine-${shortId(id)}`;
51
+ }
52
+
53
+ function newIdentity(): MachineIdentity {
54
+ const id = crypto.randomUUID();
55
+ return {
56
+ id,
57
+ label: defaultLabel(id),
58
+ os: osPlatform(),
59
+ createdAt: new Date().toISOString(),
60
+ };
61
+ }
62
+
63
+ function hasUsableId(value: unknown): value is Partial<MachineIdentity> & { id: string } {
64
+ const v = value as Partial<MachineIdentity> | null;
65
+ return typeof v?.id === "string" && v.id.length > 0;
66
+ }
67
+
68
+ /**
69
+ * Fill in whatever a stored identity is missing. Only the id is irreplaceable —
70
+ * discarding one orphans every record that referenced it — so a file carrying a
71
+ * usable id is repaired rather than regenerated.
72
+ */
73
+ function repair(stored: Partial<MachineIdentity> & { id: string }): MachineIdentity {
74
+ return {
75
+ id: stored.id,
76
+ label: stored.label?.trim() || defaultLabel(stored.id),
77
+ os: stored.os || osPlatform(),
78
+ createdAt: stored.createdAt || new Date().toISOString(),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * This install's identity, created on first call and stable afterwards. The id
84
+ * is never regenerated once the file exists — a changed id orphans every record
85
+ * that referenced the old one.
86
+ */
87
+ export function loadMachine(home: string = palHome()): MachineIdentity {
88
+ const file = machineFilePath(home);
89
+ if (existsSync(file)) {
90
+ try {
91
+ const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown;
92
+ if (hasUsableId(parsed)) return repair(parsed);
93
+ } catch {
94
+ /* fall through to regeneration below */
95
+ }
96
+ }
97
+ const identity = newIdentity();
98
+ mkdirSync(home, { recursive: true });
99
+ writeFileSync(file, `${JSON.stringify(identity, null, 2)}\n`);
100
+ return identity;
101
+ }
102
+
103
+ /** Rename this machine. No stored record is touched — labels resolve on read. */
104
+ export function setLabel(label: string, home: string = palHome()): MachineIdentity {
105
+ const current = loadMachine(home);
106
+ const updated = { ...current, label: label.trim() || current.label };
107
+ writeFileSync(machineFilePath(home), `${JSON.stringify(updated, null, 2)}\n`);
108
+ return updated;
109
+ }
110
+
111
+ export interface RegistryEntry {
112
+ id: string;
113
+ label: string;
114
+ os: string;
115
+ }
116
+
117
+ function registryPath(id: string): string {
118
+ return resolve(machinesDir(), `${id}.md`);
119
+ }
120
+
121
+ /** Write (or refresh) a machine's registry entry. Registry entries are exported. */
122
+ export function writeRegistryEntry(entry: RegistryEntry, body = ""): string {
123
+ const file = registryPath(entry.id);
124
+ const existingBody = existsSync(file) ? parse(readFileSync(file, "utf-8")).body : "";
125
+ const content = stringify(
126
+ {
127
+ id: entry.id,
128
+ label: entry.label,
129
+ os: entry.os,
130
+ updated: new Date().toISOString(),
131
+ },
132
+ body || existingBody
133
+ );
134
+ writeFileSync(file, content);
135
+ return file;
136
+ }
137
+
138
+ /** Every known machine, this one and any that arrived via import. */
139
+ export function readRegistry(): RegistryEntry[] {
140
+ const dir = machinesDir();
141
+ const entries: RegistryEntry[] = [];
142
+ for (const name of readdirSync(dir)) {
143
+ if (!name.endsWith(".md")) continue;
144
+ try {
145
+ const meta = parse<Record<string, string>>(
146
+ readFileSync(resolve(dir, name), "utf-8")
147
+ ).meta;
148
+ if (meta.id && meta.label) {
149
+ entries.push({ id: meta.id, label: meta.label, os: meta.os ?? "" });
150
+ }
151
+ } catch {
152
+ /* a malformed entry must not hide the rest of the registry */
153
+ }
154
+ }
155
+ return entries;
156
+ }
157
+
158
+ /**
159
+ * Name for a record's origin id. Unknown ids fall back to the short id so a
160
+ * record from a machine whose entry has not arrived yet still reads sensibly.
161
+ * A label shared by two machines is suffixed rather than deduplicated — the
162
+ * registry is not always reachable, so uniqueness can never be enforced.
163
+ */
164
+ export function displayName(id: string, registry: RegistryEntry[]): string {
165
+ const entry = registry.find((e) => e.id === id);
166
+ if (!entry) return shortId(id);
167
+ const sharesLabel = registry.some((e) => e.id !== id && e.label === entry.label);
168
+ return sharesLabel ? `${entry.label}·${shortId(id)}` : entry.label;
169
+ }
170
+
171
+ /** Register this install so its label can be resolved on any machine. */
172
+ export function ensureRegistered(home: string = palHome()): MachineIdentity {
173
+ const identity = loadMachine(home);
174
+ writeRegistryEntry({ id: identity.id, label: identity.label, os: identity.os });
175
+ return identity;
176
+ }
@@ -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
+ }
@@ -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
  }
@@ -3,6 +3,7 @@
3
3
  * Used by StopOrchestrator.ts (Claude Code) and opencode plugin.
4
4
  */
5
5
 
6
+ import { randomUUID } from "node:crypto";
6
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import { mkdtemp, rename, writeFile } from "node:fs/promises";
8
9
  import { tmpdir } from "node:os";
@@ -189,8 +190,10 @@ async function detachFailurePrinciple(transcript: string): Promise<void> {
189
190
  // Rename to claim the pending file atomically — prevents two Stop hooks
190
191
  // racing on the same low rating (opencode notably fires session.idle AND
191
192
  // session.diff concurrently, so runStopHandlers runs twice in parallel).
192
- const claimedDir = await mkdtemp(resolve(tmpdir(), "pal-pending-"));
193
- const claimedPath = resolve(claimedDir, "pending.json");
193
+ // The claim stays inside the state directory: rename fails with EXDEV across
194
+ // devices, and the OS temp dir is on another volume often enough to matter.
195
+ const claimId: string = randomUUID();
196
+ const claimedPath = resolve(paths.state(), `pending-failure.${claimId}.json`);
194
197
  try {
195
198
  await rename(pendingPath, claimedPath);
196
199
  } catch (err) {