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.
- package/README.md +1 -1
- 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/playwright/SKILL.md +2 -2
- package/assets/skills/playwright/tools/shot.ts +6 -7
- package/assets/skills/projects/SKILL.md +4 -1
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +15 -4
- package/src/cli/index.ts +93 -7
- package/src/cli/migrate.ts +69 -3
- 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/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/stop.ts +5 -2
- package/src/targets/lib.ts +79 -34
- 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/assets/skills/playwright/tools/shot-lib.mjs +0 -44
- package/assets/skills/playwright/tools/shot.mjs +0 -89
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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?.
|
|
211
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
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 &&
|
|
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)
|
|
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
|
-
//
|
|
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
|
|
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;
|
package/src/hooks/lib/signals.ts
CHANGED
|
@@ -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
|
}
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -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
|
-
|
|
193
|
-
|
|
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) {
|