portable-agent-layer 0.65.0 → 0.66.1
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 +2 -0
- package/package.json +1 -1
- package/src/cli/identity.ts +104 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/migrate.ts +95 -1
- package/src/hooks/lib/actor.ts +171 -0
- package/src/hooks/lib/export.ts +2 -0
- package/src/hooks/lib/identity-store.ts +138 -0
- package/src/hooks/lib/machine.ts +30 -92
- package/src/hooks/lib/relationship.ts +15 -4
- package/src/hooks/lib/signals.ts +2 -2
- package/src/tools/agent/algorithm-reflect.ts +8 -5
- package/src/tools/agent/handoff-note.ts +8 -9
- package/src/tools/agent/relationship-note.ts +2 -3
- package/src/tools/agent/thread.ts +17 -7
- package/src/tools/agent/wisdom-frame.ts +1 -1
- package/src/tools/lib/emit.ts +31 -2
- package/src/tools/relationship-reflect.ts +6 -1
package/README.md
CHANGED
|
@@ -85,6 +85,8 @@ pal cli status # check your setup
|
|
|
85
85
|
| `pal cli migrate` | Run pending data migrations (non-destructive) |
|
|
86
86
|
| `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
|
|
87
87
|
| `pal cli usage` | Summarize token usage and estimated cost |
|
|
88
|
+
| `pal cli actor [label <name>]` | Show or rename the actor — who caused a record. Travels with an export, so a shared memory can tell two people apart |
|
|
89
|
+
| `pal cli machine [label <name>]` | Show or rename this install — where a record was written. Never leaves the machine |
|
|
88
90
|
| `pal cli knowledge` | Query & manage the knowledge store (search, graph, stats, hubs, find, show, add, ls, ingest) |
|
|
89
91
|
| `pal cli skill link <name>` | Link a personal `~/.pal/skills/<name>/` into every installed agent so it is discoverable |
|
|
90
92
|
| `pal cli skill doctor <name>` | Evaluate a skill against the authoring best practices (folder/file-name match, name, description, body length, point-of-view, reference depth) |
|
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pal cli actor / pal cli machine — read and rename the two identities PAL keeps.
|
|
3
|
+
*
|
|
4
|
+
* pal cli actor Show this actor's label and id
|
|
5
|
+
* pal cli actor label <name> Rename the actor — who caused a record
|
|
6
|
+
* pal cli machine Show this install's label and id
|
|
7
|
+
* pal cli machine label <name> Rename the machine — where a record was written
|
|
8
|
+
*
|
|
9
|
+
* Renaming touches no stored record: both subjects resolve a label on read, so
|
|
10
|
+
* an id already written into a thread or a reflection reads under the new name
|
|
11
|
+
* immediately. The registry entry is refreshed here so the change also travels
|
|
12
|
+
* on the next export.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
actorFilePath,
|
|
17
|
+
ensureActorRegistered,
|
|
18
|
+
loadActor,
|
|
19
|
+
setActorLabel,
|
|
20
|
+
} from "../hooks/lib/actor";
|
|
21
|
+
import { shortId } from "../hooks/lib/identity-store";
|
|
22
|
+
import {
|
|
23
|
+
ensureRegistered,
|
|
24
|
+
loadMachine,
|
|
25
|
+
machineFilePath,
|
|
26
|
+
setLabel,
|
|
27
|
+
} from "../hooks/lib/machine";
|
|
28
|
+
import { log } from "../targets/lib";
|
|
29
|
+
|
|
30
|
+
export type IdentitySubject = "actor" | "machine";
|
|
31
|
+
|
|
32
|
+
interface SubjectOps {
|
|
33
|
+
/** What the id names, for the one-line description. */
|
|
34
|
+
noun: string;
|
|
35
|
+
read(): { id: string; label: string };
|
|
36
|
+
rename(name: string): { id: string; label: string };
|
|
37
|
+
register(): void;
|
|
38
|
+
file(): string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SUBJECTS: Record<IdentitySubject, SubjectOps> = {
|
|
42
|
+
actor: {
|
|
43
|
+
noun: "who caused a record",
|
|
44
|
+
read: () => loadActor(),
|
|
45
|
+
rename: (name) => setActorLabel(name),
|
|
46
|
+
register: () => {
|
|
47
|
+
ensureActorRegistered();
|
|
48
|
+
},
|
|
49
|
+
file: () => actorFilePath(),
|
|
50
|
+
},
|
|
51
|
+
machine: {
|
|
52
|
+
noun: "where a record was written",
|
|
53
|
+
read: () => loadMachine(),
|
|
54
|
+
rename: (name) => {
|
|
55
|
+
const updated = setLabel(name);
|
|
56
|
+
return updated;
|
|
57
|
+
},
|
|
58
|
+
register: () => {
|
|
59
|
+
ensureRegistered();
|
|
60
|
+
},
|
|
61
|
+
file: () => machineFilePath(),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function show(subject: IdentitySubject): number {
|
|
66
|
+
const ops = SUBJECTS[subject];
|
|
67
|
+
const { id, label } = ops.read();
|
|
68
|
+
console.log(`${subject}: ${label} (${shortId(id)}) — ${ops.noun}`);
|
|
69
|
+
console.log(` id: ${id}`);
|
|
70
|
+
console.log(` file: ${ops.file()}`);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rename(subject: IdentitySubject, name: string): number {
|
|
75
|
+
const ops = SUBJECTS[subject];
|
|
76
|
+
const before = ops.read();
|
|
77
|
+
const after = ops.rename(name);
|
|
78
|
+
ops.register();
|
|
79
|
+
if (after.label === before.label) {
|
|
80
|
+
log.info(`${subject} is already named ${after.label}`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
log.success(`${subject} renamed: ${before.label} → ${after.label}`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function runIdentity(subject: IdentitySubject, args: string[]): number {
|
|
88
|
+
const [action, ...rest] = args;
|
|
89
|
+
|
|
90
|
+
if (!action) return show(subject);
|
|
91
|
+
|
|
92
|
+
if (action === "label") {
|
|
93
|
+
const name = rest.join(" ").trim();
|
|
94
|
+
if (!name) {
|
|
95
|
+
log.error(`Usage: pal cli ${subject} label <name>`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
return rename(subject, name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
log.error(`Unknown ${subject} action: ${action}`);
|
|
102
|
+
log.info(`Usage: pal cli ${subject} [label <name>]`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -244,6 +244,13 @@ async function runCli(command: string | undefined, args: string[]) {
|
|
|
244
244
|
if (code !== 0) process.exit(code);
|
|
245
245
|
break;
|
|
246
246
|
}
|
|
247
|
+
case "actor":
|
|
248
|
+
case "machine": {
|
|
249
|
+
const { runIdentity } = await import("./identity");
|
|
250
|
+
const code = runIdentity(command, args);
|
|
251
|
+
if (code !== 0) process.exit(code);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
247
254
|
case "debug":
|
|
248
255
|
cliDebug(args);
|
|
249
256
|
break;
|
|
@@ -288,6 +295,8 @@ function showHelp() {
|
|
|
288
295
|
pal cli migrate [--list] [--dry-run] Run pending data migrations
|
|
289
296
|
pal cli analyze [--actionable] Learning analysis: ratings, failure patterns, graduation candidates
|
|
290
297
|
pal cli usage Summarize token usage and cost
|
|
298
|
+
pal cli actor [label <name>] Show or rename this actor (who caused a record)
|
|
299
|
+
pal cli machine [label <name>] Show or rename this install (where it was written)
|
|
291
300
|
pal cli knowledge <sub> [args] Query & manage the knowledge store
|
|
292
301
|
(search · graph · stats · hubs · find · show · add · ls)
|
|
293
302
|
pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
|
|
@@ -1179,6 +1188,10 @@ async function install(targets: Targets) {
|
|
|
1179
1188
|
await promptTelos();
|
|
1180
1189
|
await promptAttribution();
|
|
1181
1190
|
|
|
1191
|
+
// Registers the label loadActor derives, so it travels on the next export.
|
|
1192
|
+
const { ensureActorRegistered } = await import("../hooks/lib/actor");
|
|
1193
|
+
ensureActorRegistered();
|
|
1194
|
+
|
|
1182
1195
|
// Shared, target-independent state. Every target installer used to repeat these
|
|
1183
1196
|
// identical calls; AGENTS.md in particular must exist before any target symlinks
|
|
1184
1197
|
// to it, so it runs once here rather than once per target.
|
package/src/cli/migrate.ts
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
* pending work without running anything.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
existsSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
14
20
|
import { resolve } from "node:path";
|
|
15
21
|
import { palHome, paths } from "../hooks/lib/paths";
|
|
16
22
|
import {
|
|
@@ -423,11 +429,99 @@ const v4PathsToBindings: Migration = {
|
|
|
423
429
|
},
|
|
424
430
|
};
|
|
425
431
|
|
|
432
|
+
// ── v5-attribution-keys: m → machine on stored records ─────────────
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Records stamped their origin as `m`, which nobody could decode without
|
|
436
|
+
* reading the writer. The stamp is now spelled out — machine, actor, runtime,
|
|
437
|
+
* authority — and this brings historical rows onto the same schema, because a
|
|
438
|
+
* mixed schema in an append-only store is exactly what makes it unauditable
|
|
439
|
+
* later. Nothing ever read `m`, so no consumer depends on the old spelling.
|
|
440
|
+
*/
|
|
441
|
+
function attributionFiles(): string[] {
|
|
442
|
+
const files = [paths.reflectionsFile(), resolve(paths.state(), "threads.jsonl")];
|
|
443
|
+
const signalsDir = paths.signals();
|
|
444
|
+
if (existsSync(signalsDir)) {
|
|
445
|
+
for (const name of readdirSync(signalsDir)) {
|
|
446
|
+
if (name.endsWith(".jsonl")) files.push(resolve(signalsDir, name));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return files.filter((f) => existsSync(f));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Lines carrying the old key. A line already using `machine` is left alone. */
|
|
453
|
+
function renameAttributionKey(raw: string): { text: string; changed: number } {
|
|
454
|
+
const lines = raw.split("\n");
|
|
455
|
+
let changed = 0;
|
|
456
|
+
|
|
457
|
+
const out = lines.map((line) => {
|
|
458
|
+
if (!line.trim()) return line;
|
|
459
|
+
let record: Record<string, unknown>;
|
|
460
|
+
try {
|
|
461
|
+
record = JSON.parse(line) as Record<string, unknown>;
|
|
462
|
+
} catch {
|
|
463
|
+
return line; // a malformed line is left exactly as found
|
|
464
|
+
}
|
|
465
|
+
if (!("m" in record)) return line;
|
|
466
|
+
|
|
467
|
+
const { m, ...rest } = record;
|
|
468
|
+
changed++;
|
|
469
|
+
// `rest` spreads last so a record already carrying `machine` keeps that
|
|
470
|
+
// value and still sheds the stale `m`.
|
|
471
|
+
return JSON.stringify({ machine: m, ...rest });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
return { text: out.join("\n"), changed };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function filesCarryingOldKey(): string[] {
|
|
478
|
+
return attributionFiles().filter(
|
|
479
|
+
(f) => renameAttributionKey(readFileSync(f, "utf-8")).changed > 0
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const v5AttributionKeys: Migration = {
|
|
484
|
+
id: "v5-attribution-keys",
|
|
485
|
+
description: "Rename the origin stamp `m` to `machine` on stored records",
|
|
486
|
+
|
|
487
|
+
check() {
|
|
488
|
+
const stale = filesCarryingOldKey();
|
|
489
|
+
return {
|
|
490
|
+
pending: stale.length > 0,
|
|
491
|
+
detail:
|
|
492
|
+
stale.length > 0
|
|
493
|
+
? `${stale.length} file(s) still stamp origin as \`m\``
|
|
494
|
+
: undefined,
|
|
495
|
+
};
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
run(dryRun = false): MigrationResult {
|
|
499
|
+
const results: string[] = [];
|
|
500
|
+
let migrated = 0;
|
|
501
|
+
|
|
502
|
+
for (const file of filesCarryingOldKey()) {
|
|
503
|
+
const { text, changed } = renameAttributionKey(readFileSync(file, "utf-8"));
|
|
504
|
+
const name = file.replace(palHome(), "").replaceAll("\\", "/");
|
|
505
|
+
if (dryRun) {
|
|
506
|
+
results.push(`${name}: would rewrite ${changed} record(s)`);
|
|
507
|
+
migrated++;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
writeFileSync(file, text, "utf-8");
|
|
511
|
+
results.push(`${name}: ${changed} record(s) rewritten`);
|
|
512
|
+
migrated++;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { migrated, skipped: 0, results };
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
|
|
426
519
|
const MIGRATIONS: Migration[] = [
|
|
427
520
|
v1Projects,
|
|
428
521
|
v2ThreadsToIsc,
|
|
429
522
|
v3EntitiesToKnowledge,
|
|
430
523
|
v4PathsToBindings,
|
|
524
|
+
v5AttributionKeys,
|
|
431
525
|
];
|
|
432
526
|
|
|
433
527
|
// ── Public API ────────────────────────────────────────────────────
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor identity — who caused a record, as distinct from where it was written.
|
|
3
|
+
*
|
|
4
|
+
* A machine id answers "which install"; it deliberately never crosses an export
|
|
5
|
+
* boundary, because two installs sharing one id breaks every origin-scoped
|
|
6
|
+
* read. An actor id answers "which person", and needs the opposite treatment:
|
|
7
|
+
* the same person on a laptop and a desktop is one actor, so `actor.json` lives
|
|
8
|
+
* under memory/ where the export picks it up and the import merge adopts it on
|
|
9
|
+
* an install that has none. An install that already has an actor keeps it — the
|
|
10
|
+
* merge treats a diverged file as a conflict and leaves the local side in place.
|
|
11
|
+
*
|
|
12
|
+
* Identity alone does not attribute an action. `currentAttribution()` is the
|
|
13
|
+
* stamp a record carries: the person, the install, the agent runtime they were
|
|
14
|
+
* driving, and whether a human turn was behind the call at all.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { type AgentType, getActiveAgent } from "./agent";
|
|
19
|
+
import {
|
|
20
|
+
type IdentityBase,
|
|
21
|
+
loadIdentity,
|
|
22
|
+
readRegistryEntries,
|
|
23
|
+
relabelIdentity,
|
|
24
|
+
resolveDisplayName,
|
|
25
|
+
shortId,
|
|
26
|
+
writeRegistryEntry as writeEntry,
|
|
27
|
+
} from "./identity-store";
|
|
28
|
+
import { loadMachine } from "./machine";
|
|
29
|
+
import { palHome, paths } from "./paths";
|
|
30
|
+
import { identity } from "./settings";
|
|
31
|
+
import { isPalSpawnedInference } from "./spawn-guard";
|
|
32
|
+
|
|
33
|
+
export type ActorIdentity = IdentityBase;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Under what authority an action ran. `user` means a human turn drove it;
|
|
37
|
+
* `agent` means PAL spawned the inference itself and no human saw the call.
|
|
38
|
+
* This is the authority PAL can observe today — an approval a PAL-owned gate
|
|
39
|
+
* granted is a further distinction that gate has to introduce.
|
|
40
|
+
*/
|
|
41
|
+
export type Authority = "user" | "agent";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The origin fields every attributable record carries. Spelled out rather than
|
|
45
|
+
* abbreviated: these rows are meant to be read by whoever is auditing them, and
|
|
46
|
+
* a key nobody can decode is the same as no key at all.
|
|
47
|
+
*/
|
|
48
|
+
export interface RecordAttribution {
|
|
49
|
+
/** Which install wrote it. */
|
|
50
|
+
machine: string;
|
|
51
|
+
/** Which person caused it. */
|
|
52
|
+
actor: string;
|
|
53
|
+
/** Which agent the actor was driving — claude, codex, cursor, copilot, opencode. */
|
|
54
|
+
runtime: AgentType;
|
|
55
|
+
/** Whether a human turn was behind the call. */
|
|
56
|
+
authority: Authority;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Unlike machine.json this sits inside memory/, so it is exported and can be
|
|
61
|
+
* adopted by a second install belonging to the same person.
|
|
62
|
+
*/
|
|
63
|
+
export function actorFilePath(home: string = palHome()): string {
|
|
64
|
+
return resolve(home, "memory", "actor.json");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function actorsDir(): string {
|
|
68
|
+
return resolve(paths.memory(), "actors");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Neutral default label, on the same reasoning as the machine default: the
|
|
73
|
+
* label travels in every exported registry entry, so a real name belongs there
|
|
74
|
+
* only once its owner has chosen to put it there.
|
|
75
|
+
*/
|
|
76
|
+
export function defaultActorLabel(id: string): string {
|
|
77
|
+
return `actor-${shortId(id)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function newActor(): ActorIdentity {
|
|
81
|
+
const id = crypto.randomUUID();
|
|
82
|
+
return { id, label: defaultActorLabel(id), createdAt: new Date().toISOString() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function repairActor(stored: Partial<ActorIdentity> & { id: string }): ActorIdentity {
|
|
86
|
+
return {
|
|
87
|
+
id: stored.id,
|
|
88
|
+
label: stored.label?.trim() || defaultActorLabel(stored.id),
|
|
89
|
+
createdAt: stored.createdAt || new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The name the principal already told PAL, or nothing. The settings default of
|
|
95
|
+
* "User" names nobody, so it never becomes a label.
|
|
96
|
+
*/
|
|
97
|
+
function principalName(): string {
|
|
98
|
+
const name = identity().principal.name.trim();
|
|
99
|
+
return !name || name === "User" ? "" : name;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* This person's identity, minted on first call and stable afterwards — across
|
|
104
|
+
* machines, once an export has carried it there.
|
|
105
|
+
*
|
|
106
|
+
* The label is derived, not stored-at-mint: while it is still the generated
|
|
107
|
+
* default, the principal's configured name wins. Seeding it once at install
|
|
108
|
+
* only held for installs that ran the seeding step, so an actor minted by an
|
|
109
|
+
* upgrade kept a neutral label forever while settings knew the name all along.
|
|
110
|
+
* Deriving on read makes the name hold on every install without a step anyone
|
|
111
|
+
* has to remember. A label the user chose is stored, and is never overridden.
|
|
112
|
+
*/
|
|
113
|
+
export function loadActor(home: string = palHome()): ActorIdentity {
|
|
114
|
+
const actor = loadIdentity(actorFilePath(home), newActor, repairActor);
|
|
115
|
+
if (actor.label !== defaultActorLabel(actor.id)) return actor;
|
|
116
|
+
const name = principalName();
|
|
117
|
+
return name ? { ...actor, label: name } : actor;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Rename this actor. No stored record is touched — labels resolve on read. */
|
|
121
|
+
export function setActorLabel(label: string, home: string = palHome()): ActorIdentity {
|
|
122
|
+
return relabelIdentity(actorFilePath(home), loadActor(home), label);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ActorRegistryEntry {
|
|
126
|
+
id: string;
|
|
127
|
+
label: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Write (or refresh) an actor's registry entry. Registry entries are exported. */
|
|
131
|
+
export function writeActorEntry(entry: ActorRegistryEntry, body = ""): string {
|
|
132
|
+
return writeEntry(actorsDir(), { ...entry }, body);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Every known actor, this one and any that arrived via import. */
|
|
136
|
+
export function readActorRegistry(): ActorRegistryEntry[] {
|
|
137
|
+
return readRegistryEntries(actorsDir()).map((meta) => ({
|
|
138
|
+
id: meta.id,
|
|
139
|
+
label: meta.label,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Name for a record's actor, falling back to the short id. */
|
|
144
|
+
export function actorDisplayName(id: string, registry: ActorRegistryEntry[]): string {
|
|
145
|
+
return resolveDisplayName(id, registry);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Register this actor so their label resolves on anyone else's install. */
|
|
149
|
+
export function ensureActorRegistered(home: string = palHome()): ActorIdentity {
|
|
150
|
+
const actor = loadActor(home);
|
|
151
|
+
writeActorEntry({ id: actor.id, label: actor.label });
|
|
152
|
+
return actor;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Authority behind the call currently executing. */
|
|
156
|
+
export function currentAuthority(): Authority {
|
|
157
|
+
return isPalSpawnedInference() ? "agent" : "user";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The origin stamp for a record written right now. Every attributable writer
|
|
162
|
+
* spreads this instead of stamping a machine id alone.
|
|
163
|
+
*/
|
|
164
|
+
export function currentAttribution(): RecordAttribution {
|
|
165
|
+
return {
|
|
166
|
+
machine: loadMachine().id,
|
|
167
|
+
actor: loadActor().id,
|
|
168
|
+
runtime: getActiveAgent(),
|
|
169
|
+
authority: currentAuthority(),
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/hooks/lib/export.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync, readdirSync } from "node:fs";
|
|
7
7
|
import { relative, resolve } from "node:path";
|
|
8
8
|
import AdmZip from "adm-zip";
|
|
9
|
+
import { ensureActorRegistered } from "./actor";
|
|
9
10
|
import { ensureRegistered } from "./machine";
|
|
10
11
|
import { palHome } from "./paths";
|
|
11
12
|
|
|
@@ -95,6 +96,7 @@ export function buildManifest(
|
|
|
95
96
|
export function exportZip(outputPath: string): number {
|
|
96
97
|
const root = palHome();
|
|
97
98
|
const identity = ensureRegistered(root);
|
|
99
|
+
ensureActorRegistered(root);
|
|
98
100
|
const files = collectExportFiles();
|
|
99
101
|
if (files.length === 0) return 0;
|
|
100
102
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity store — the mechanics shared by every kind of identity PAL keeps.
|
|
3
|
+
*
|
|
4
|
+
* PAL names two different subjects. A machine is the install a record came
|
|
5
|
+
* from; an actor is the person who caused it. They answer different questions
|
|
6
|
+
* and travel in opposite directions across an export boundary, but the storage
|
|
7
|
+
* problem is identical: a uuid that must never be regenerated, a label that
|
|
8
|
+
* resolves on read so a rename touches no stored record, and a registry of
|
|
9
|
+
* `<id>.md` entries so an id written on one install still reads as a name on
|
|
10
|
+
* another.
|
|
11
|
+
*
|
|
12
|
+
* Each subject supplies its own file location, its own extra fields, and its
|
|
13
|
+
* own default label. Everything below is the part that does not differ.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, resolve } from "node:path";
|
|
18
|
+
import { parse, stringify } from "./frontmatter";
|
|
19
|
+
|
|
20
|
+
export interface IdentityBase {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Frontmatter of one registry entry. Every value is a string on disk. */
|
|
27
|
+
export type RegistryFields = Record<string, string> & { id: string; label: string };
|
|
28
|
+
|
|
29
|
+
/** Anything the display resolver can name. Extra fields are ignored. */
|
|
30
|
+
export interface NameableEntry {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SHORT_ID_LENGTH = 4;
|
|
36
|
+
|
|
37
|
+
/** First segment of the uuid — enough to disambiguate two same-labelled subjects. */
|
|
38
|
+
export function shortId(id: string): string {
|
|
39
|
+
return id.replaceAll("-", "").slice(0, SHORT_ID_LENGTH);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasUsableId(value: unknown): value is { id: string } {
|
|
43
|
+
const v = value as { id?: unknown } | null;
|
|
44
|
+
return typeof v?.id === "string" && v.id.length > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Persist a record, creating its directory if this is the first write. */
|
|
48
|
+
function saveIdentity<T extends IdentityBase>(file: string, record: T): T {
|
|
49
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
50
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
51
|
+
return record;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read a stored identity, or mint one on first call.
|
|
56
|
+
*
|
|
57
|
+
* The id is the only irreplaceable field — discarding one orphans every record
|
|
58
|
+
* that referenced it — so a file carrying a usable id is repaired rather than
|
|
59
|
+
* regenerated, and a file that has lost its id is treated as absent.
|
|
60
|
+
*/
|
|
61
|
+
export function loadIdentity<T extends IdentityBase>(
|
|
62
|
+
file: string,
|
|
63
|
+
create: () => T,
|
|
64
|
+
repair: (stored: Partial<T> & { id: string }) => T
|
|
65
|
+
): T {
|
|
66
|
+
if (existsSync(file)) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown;
|
|
69
|
+
if (hasUsableId(parsed)) return repair(parsed as Partial<T> & { id: string });
|
|
70
|
+
} catch {
|
|
71
|
+
/* fall through to minting a fresh identity below */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return saveIdentity(file, create());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Change a label without touching any stored record — labels resolve on read. */
|
|
78
|
+
export function relabelIdentity<T extends IdentityBase>(
|
|
79
|
+
file: string,
|
|
80
|
+
current: T,
|
|
81
|
+
label: string
|
|
82
|
+
): T {
|
|
83
|
+
return saveIdentity(file, { ...current, label: label.trim() || current.label });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function entryPath(dir: string, id: string): string {
|
|
87
|
+
return resolve(dir, `${id}.md`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Write (or refresh) a registry entry. Registry entries are exported. */
|
|
91
|
+
export function writeRegistryEntry(
|
|
92
|
+
dir: string,
|
|
93
|
+
fields: RegistryFields,
|
|
94
|
+
body = ""
|
|
95
|
+
): string {
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
const file = entryPath(dir, fields.id);
|
|
98
|
+
const existingBody = existsSync(file) ? parse(readFileSync(file, "utf-8")).body : "";
|
|
99
|
+
writeFileSync(
|
|
100
|
+
file,
|
|
101
|
+
stringify({ ...fields, updated: new Date().toISOString() }, body || existingBody)
|
|
102
|
+
);
|
|
103
|
+
return file;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every entry in a registry directory. Only `.md` files count, and an entry
|
|
108
|
+
* missing an id or a label is dropped — a malformed one must not hide the rest.
|
|
109
|
+
*/
|
|
110
|
+
export function readRegistryEntries(dir: string): RegistryFields[] {
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
const entries: RegistryFields[] = [];
|
|
113
|
+
for (const name of readdirSync(dir)) {
|
|
114
|
+
if (!name.endsWith(".md")) continue;
|
|
115
|
+
try {
|
|
116
|
+
const { meta } = parse<Record<string, string>>(
|
|
117
|
+
readFileSync(resolve(dir, name), "utf-8")
|
|
118
|
+
);
|
|
119
|
+
if (meta.id && meta.label) entries.push(meta as RegistryFields);
|
|
120
|
+
} catch {
|
|
121
|
+
/* a malformed entry must not hide the rest of the registry */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Name for a record's id. Unknown ids fall back to the short id so a record
|
|
129
|
+
* whose registry entry has not arrived yet still reads sensibly. A label shared
|
|
130
|
+
* by two subjects is suffixed rather than deduplicated — the registry is not
|
|
131
|
+
* always reachable, so uniqueness can never be enforced.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveDisplayName(id: string, registry: NameableEntry[]): string {
|
|
134
|
+
const entry = registry.find((e) => e.id === id);
|
|
135
|
+
if (!entry) return shortId(id);
|
|
136
|
+
const sharesLabel = registry.some((e) => e.id !== id && e.label === entry.label);
|
|
137
|
+
return sharesLabel ? `${entry.label}·${shortId(id)}` : entry.label;
|
|
138
|
+
}
|
package/src/hooks/lib/machine.ts
CHANGED
|
@@ -1,44 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Machine identity —
|
|
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.
|
|
2
|
+
* Machine identity — which install a record came from.
|
|
8
3
|
*
|
|
9
4
|
* `machine.json` lives at the PAL_HOME root, outside every exported directory,
|
|
10
5
|
* because importing it would give two installs one id and silently break every
|
|
11
|
-
* origin-scoped read built on top of it.
|
|
6
|
+
* origin-scoped read built on top of it. Contrast with actor.ts, whose file
|
|
7
|
+
* lives under memory/ precisely so it does travel: one person on two machines
|
|
8
|
+
* is one actor, but never one machine.
|
|
9
|
+
*
|
|
10
|
+
* The storage mechanics live in identity-store.ts; this module supplies the
|
|
11
|
+
* machine-specific parts — where the file lives, the `os` field, and a label
|
|
12
|
+
* that is deliberately not the hostname.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { platform as osPlatform } from "node:os";
|
|
16
16
|
import { resolve } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type IdentityBase,
|
|
19
|
+
loadIdentity,
|
|
20
|
+
readRegistryEntries,
|
|
21
|
+
relabelIdentity,
|
|
22
|
+
resolveDisplayName,
|
|
23
|
+
shortId,
|
|
24
|
+
writeRegistryEntry as writeEntry,
|
|
25
|
+
} from "./identity-store";
|
|
18
26
|
import { palHome, paths } from "./paths";
|
|
19
27
|
|
|
20
|
-
export interface MachineIdentity {
|
|
21
|
-
id: string;
|
|
22
|
-
label: string;
|
|
28
|
+
export interface MachineIdentity extends IdentityBase {
|
|
23
29
|
os: string;
|
|
24
|
-
createdAt: string;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
export { shortId };
|
|
28
33
|
|
|
29
34
|
export function machineFilePath(home: string = palHome()): string {
|
|
30
35
|
return resolve(home, "machine.json");
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function machinesDir(): string {
|
|
34
|
-
|
|
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);
|
|
39
|
+
return resolve(paths.memory(), "machines");
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
/**
|
|
@@ -60,16 +58,6 @@ function newIdentity(): MachineIdentity {
|
|
|
60
58
|
};
|
|
61
59
|
}
|
|
62
60
|
|
|
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
61
|
function repair(stored: Partial<MachineIdentity> & { id: string }): MachineIdentity {
|
|
74
62
|
return {
|
|
75
63
|
id: stored.id,
|
|
@@ -85,27 +73,12 @@ function repair(stored: Partial<MachineIdentity> & { id: string }): MachineIdent
|
|
|
85
73
|
* that referenced the old one.
|
|
86
74
|
*/
|
|
87
75
|
export function loadMachine(home: string = palHome()): MachineIdentity {
|
|
88
|
-
|
|
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;
|
|
76
|
+
return loadIdentity(machineFilePath(home), newIdentity, repair);
|
|
101
77
|
}
|
|
102
78
|
|
|
103
79
|
/** Rename this machine. No stored record is touched — labels resolve on read. */
|
|
104
80
|
export function setLabel(label: string, home: string = palHome()): MachineIdentity {
|
|
105
|
-
|
|
106
|
-
const updated = { ...current, label: label.trim() || current.label };
|
|
107
|
-
writeFileSync(machineFilePath(home), `${JSON.stringify(updated, null, 2)}\n`);
|
|
108
|
-
return updated;
|
|
81
|
+
return relabelIdentity(machineFilePath(home), loadMachine(home), label);
|
|
109
82
|
}
|
|
110
83
|
|
|
111
84
|
export interface RegistryEntry {
|
|
@@ -114,58 +87,23 @@ export interface RegistryEntry {
|
|
|
114
87
|
os: string;
|
|
115
88
|
}
|
|
116
89
|
|
|
117
|
-
function registryPath(id: string): string {
|
|
118
|
-
return resolve(machinesDir(), `${id}.md`);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
90
|
/** Write (or refresh) a machine's registry entry. Registry entries are exported. */
|
|
122
91
|
export function writeRegistryEntry(entry: RegistryEntry, body = ""): string {
|
|
123
|
-
|
|
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;
|
|
92
|
+
return writeEntry(machinesDir(), { ...entry }, body);
|
|
136
93
|
}
|
|
137
94
|
|
|
138
95
|
/** Every known machine, this one and any that arrived via import. */
|
|
139
96
|
export function readRegistry(): RegistryEntry[] {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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;
|
|
97
|
+
return readRegistryEntries(machinesDir()).map((meta) => ({
|
|
98
|
+
id: meta.id,
|
|
99
|
+
label: meta.label,
|
|
100
|
+
os: meta.os ?? "",
|
|
101
|
+
}));
|
|
156
102
|
}
|
|
157
103
|
|
|
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
|
-
*/
|
|
104
|
+
/** Name for a record's origin machine, falling back to the short id. */
|
|
164
105
|
export function displayName(id: string, registry: RegistryEntry[]): string {
|
|
165
|
-
|
|
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;
|
|
106
|
+
return resolveDisplayName(id, registry);
|
|
169
107
|
}
|
|
170
108
|
|
|
171
109
|
/** Register this install so its label can be resolved on any machine. */
|
|
@@ -47,16 +47,26 @@ function dedup(notes: RelationshipNote[], filepath: string): RelationshipNote[]
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
export
|
|
52
|
-
|
|
50
|
+
/** What an append actually did — `written` is the count after deduplication. */
|
|
51
|
+
export interface AppendResult {
|
|
52
|
+
file: string;
|
|
53
|
+
written: number;
|
|
54
|
+
}
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Append notes to today's relationship file. Reports the file and the number of
|
|
58
|
+
* notes that survived deduplication, which is what a caller must report rather
|
|
59
|
+
* than the count it passed in.
|
|
60
|
+
*/
|
|
61
|
+
export function appendNotes(notes: RelationshipNote[], sessionId?: string): AppendResult {
|
|
54
62
|
const filepath = dailyFilePath(new Date());
|
|
63
|
+
if (notes.length === 0) return { file: filepath, written: 0 };
|
|
64
|
+
|
|
55
65
|
const today = new Date().toISOString().slice(0, 10);
|
|
56
66
|
|
|
57
67
|
// Deduplicate against existing content
|
|
58
68
|
const fresh = dedup(notes, filepath);
|
|
59
|
-
if (fresh.length === 0) return;
|
|
69
|
+
if (fresh.length === 0) return { file: filepath, written: 0 };
|
|
60
70
|
|
|
61
71
|
const lines: string[] = [];
|
|
62
72
|
|
|
@@ -81,6 +91,7 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): void
|
|
|
81
91
|
|
|
82
92
|
const existing = existsSync(filepath) ? readFileSync(filepath, "utf-8") : "";
|
|
83
93
|
writeFileSync(filepath, existing + lines.join("\n"), "utf-8");
|
|
94
|
+
return { file: filepath, written: fresh.length };
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/** Load notes from the last N days as a single string */
|
package/src/hooks/lib/signals.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { currentAttribution } from "./actor";
|
|
4
4
|
import { paths } from "./paths";
|
|
5
5
|
import { now } from "./time";
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ function emitSignal(
|
|
|
15
15
|
filename: string,
|
|
16
16
|
data: { type: string; [key: string]: unknown }
|
|
17
17
|
): void {
|
|
18
|
-
const signal: Signal = { ts: now(),
|
|
18
|
+
const signal: Signal = { ts: now(), ...currentAttribution(), ...data };
|
|
19
19
|
const filepath = resolve(paths.signals(), filename);
|
|
20
20
|
appendFileSync(filepath, `${JSON.stringify(signal)}\n`);
|
|
21
21
|
}
|
|
@@ -15,17 +15,16 @@
|
|
|
15
15
|
|
|
16
16
|
import { appendFileSync } from "node:fs";
|
|
17
17
|
import { parseArgs } from "node:util";
|
|
18
|
+
import { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
|
|
18
19
|
import { encodeAnchor } from "../../hooks/lib/anchor";
|
|
19
|
-
import { loadMachine } from "../../hooks/lib/machine";
|
|
20
20
|
import { paths } from "../../hooks/lib/paths";
|
|
21
21
|
import { emit } from "../lib/emit";
|
|
22
22
|
|
|
23
23
|
// ── Types ──
|
|
24
24
|
|
|
25
|
-
interface AlgorithmReflection {
|
|
25
|
+
interface AlgorithmReflection extends RecordAttribution {
|
|
26
26
|
timestamp: string;
|
|
27
27
|
cwd: string;
|
|
28
|
-
m: string;
|
|
29
28
|
task: string;
|
|
30
29
|
criteria_count: number;
|
|
31
30
|
criteria_passed: number;
|
|
@@ -64,7 +63,7 @@ export function buildReflection(input: {
|
|
|
64
63
|
return {
|
|
65
64
|
timestamp: new Date().toISOString(),
|
|
66
65
|
cwd: encodeAnchor(process.cwd()),
|
|
67
|
-
|
|
66
|
+
...currentAttribution(),
|
|
68
67
|
task: input.task,
|
|
69
68
|
criteria_count: input.criteria_count ?? 0,
|
|
70
69
|
criteria_passed: input.criteria_passed ?? 0,
|
|
@@ -156,7 +155,11 @@ Output: algorithm-reflections.jsonl in memory/learning/reflections/
|
|
|
156
155
|
});
|
|
157
156
|
|
|
158
157
|
const result = appendReflection(reflection);
|
|
159
|
-
emit.
|
|
158
|
+
emit.receipt(result.path, {
|
|
159
|
+
passed: reflection.criteria_passed,
|
|
160
|
+
of: reflection.criteria_count,
|
|
161
|
+
scope: reflection.scope,
|
|
162
|
+
});
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
if (import.meta.main) run();
|
|
@@ -39,10 +39,12 @@ function readHandoffs(): Record<string, HandoffEntry> {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/** Returns how many entries survived the trim, which is what the receipt reports. */
|
|
43
|
+
function writeHandoffs(handoffs: Record<string, HandoffEntry>): number {
|
|
43
44
|
const entries = Object.entries(handoffs);
|
|
44
45
|
const trimmed = entries.length > 20 ? Object.fromEntries(entries.slice(-20)) : handoffs;
|
|
45
46
|
writeFileSync(handoffPath(), JSON.stringify(trimmed, null, 2), "utf-8");
|
|
47
|
+
return Object.keys(trimmed).length;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
function writeHandoffNote(
|
|
@@ -50,7 +52,7 @@ function writeHandoffNote(
|
|
|
50
52
|
title: string,
|
|
51
53
|
text: string,
|
|
52
54
|
done: boolean
|
|
53
|
-
): {
|
|
55
|
+
): { file: string; status: HandoffEntry["status"]; kept: number } {
|
|
54
56
|
const handoffs = readHandoffs();
|
|
55
57
|
handoffs[cwd] = {
|
|
56
58
|
timestamp: new Date().toISOString(),
|
|
@@ -60,11 +62,8 @@ function writeHandoffNote(
|
|
|
60
62
|
artifacts: [],
|
|
61
63
|
source: "deliberate",
|
|
62
64
|
};
|
|
63
|
-
writeHandoffs(handoffs);
|
|
64
|
-
return {
|
|
65
|
-
success: true,
|
|
66
|
-
message: done ? "Handoff cleared (marked completed)" : "Handoff note written",
|
|
67
|
-
};
|
|
65
|
+
const kept = writeHandoffs(handoffs);
|
|
66
|
+
return { file: handoffPath(), status: handoffs[cwd].status, kept };
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
function run() {
|
|
@@ -103,7 +102,7 @@ Output: writes to memory/state/last-handoff.json keyed by cwd
|
|
|
103
102
|
values.text || "",
|
|
104
103
|
true
|
|
105
104
|
);
|
|
106
|
-
emit.
|
|
105
|
+
emit.receipt(result.file, { status: result.status, entries: result.kept });
|
|
107
106
|
process.exit(0);
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -113,7 +112,7 @@ Output: writes to memory/state/last-handoff.json keyed by cwd
|
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
const result = writeHandoffNote(process.cwd(), values.title, values.text, false);
|
|
116
|
-
emit.
|
|
115
|
+
emit.receipt(result.file, { status: result.status, entries: result.kept });
|
|
117
116
|
}
|
|
118
117
|
|
|
119
118
|
if (import.meta.main) run();
|
|
@@ -82,9 +82,8 @@ Output: appends to memory/relationship/YYYY-MM/YYYY-MM-DD.md
|
|
|
82
82
|
notes.push({ type: "Session" as const, text: values.b });
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
appendNotes(notes);
|
|
86
|
-
|
|
87
|
-
emit.ok(`Relationship note written (${notes.length})`);
|
|
85
|
+
const { file, written } = appendNotes(notes);
|
|
86
|
+
emit.receipt(file, { written, deduped: notes.length - written });
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
if (import.meta.main) run();
|
|
@@ -14,17 +14,16 @@
|
|
|
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 { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
|
|
17
18
|
import { encodeAnchor } from "../../hooks/lib/anchor";
|
|
18
|
-
import { loadMachine } from "../../hooks/lib/machine";
|
|
19
19
|
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
20
20
|
import { emit } from "../lib/emit";
|
|
21
21
|
|
|
22
22
|
// ── Types ──
|
|
23
23
|
|
|
24
|
-
export interface Thread {
|
|
24
|
+
export interface Thread extends RecordAttribution {
|
|
25
25
|
id: string;
|
|
26
26
|
cwd: string;
|
|
27
|
-
m: string;
|
|
28
27
|
title: string;
|
|
29
28
|
context: string;
|
|
30
29
|
status: "open" | "resolved";
|
|
@@ -70,7 +69,7 @@ export function addThread(title: string, context: string): Thread {
|
|
|
70
69
|
const thread: Thread = {
|
|
71
70
|
id: generateId(),
|
|
72
71
|
cwd: encodeAnchor(process.cwd()),
|
|
73
|
-
|
|
72
|
+
...currentAttribution(),
|
|
74
73
|
title,
|
|
75
74
|
context,
|
|
76
75
|
status: "open",
|
|
@@ -143,7 +142,11 @@ Usage:
|
|
|
143
142
|
process.exit(1);
|
|
144
143
|
}
|
|
145
144
|
const thread = addThread(values.title, values.context ?? "");
|
|
146
|
-
emit.
|
|
145
|
+
emit.receipt(threadsPath(), {
|
|
146
|
+
id: thread.id,
|
|
147
|
+
title: thread.title,
|
|
148
|
+
status: thread.status,
|
|
149
|
+
});
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
if (cmd === "resolve") {
|
|
@@ -152,8 +155,15 @@ Usage:
|
|
|
152
155
|
process.exit(1);
|
|
153
156
|
}
|
|
154
157
|
const resolved = resolveThread(values.id);
|
|
155
|
-
if (resolved.success)
|
|
156
|
-
|
|
158
|
+
if (!resolved.success) {
|
|
159
|
+
console.error(resolved.message);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
emit.receipt(threadsPath(), {
|
|
163
|
+
id: values.id,
|
|
164
|
+
status: "resolved",
|
|
165
|
+
title: resolved.thread?.title,
|
|
166
|
+
});
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
if (cmd === "list") {
|
|
@@ -234,7 +234,7 @@ Examples:
|
|
|
234
234
|
|
|
235
235
|
const cliType = (values.type || "evolution") as ObservationType;
|
|
236
236
|
const result = updateFrame(values.domain, values.observation, cliType);
|
|
237
|
-
emit.
|
|
237
|
+
emit.receipt(result.framePath, { domain: result.domain, type: result.type });
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
if (import.meta.main) run();
|
package/src/tools/lib/emit.ts
CHANGED
|
@@ -4,13 +4,25 @@
|
|
|
4
4
|
* confirmations are pure noise that costs context tokens; a human at a TTY wants
|
|
5
5
|
* them. Gate on the TTY signal, with PAL_VERBOSE / PAL_QUIET as explicit overrides.
|
|
6
6
|
*
|
|
7
|
-
* data()
|
|
8
|
-
*
|
|
7
|
+
* data() requested payload — ALWAYS emitted (list output, reports, results)
|
|
8
|
+
* receipt() proof a write landed — ALWAYS emitted (see below)
|
|
9
|
+
* ok() success confirmation / progress — only at a TTY or under PAL_VERBOSE
|
|
9
10
|
*
|
|
10
11
|
* Errors stay on console.error (stderr) + a non-zero exit — always surfaced,
|
|
11
12
|
* independent of this gate.
|
|
13
|
+
*
|
|
14
|
+
* Why receipt() is not gated: a state-changing call's confirmation is its
|
|
15
|
+
* payload, not progress chatter. Without it a caller cannot tell what landed or
|
|
16
|
+
* where, and re-reads the file to find out — which costs far more context than
|
|
17
|
+
* the one line the gate saved. The receipt reports what was actually written,
|
|
18
|
+
* so a writer that deduplicates or trims must report the real count rather than
|
|
19
|
+
* what it was handed. `no-silent-write` in klint.rules.ts enforces that every
|
|
20
|
+
* tool writing to disk emits one.
|
|
12
21
|
*/
|
|
13
22
|
|
|
23
|
+
import { relative } from "node:path";
|
|
24
|
+
import { palHome } from "../../hooks/lib/paths";
|
|
25
|
+
|
|
14
26
|
function isVerbose(): boolean {
|
|
15
27
|
if (process.env.PAL_QUIET === "1") return false;
|
|
16
28
|
if (process.env.PAL_VERBOSE === "1") return true;
|
|
@@ -21,6 +33,16 @@ function line(stream: { write: (s: string) => void }, text: string): void {
|
|
|
21
33
|
stream.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
22
34
|
}
|
|
23
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Paths are reported relative to PAL_HOME so a receipt reads the same on every
|
|
38
|
+
* machine, and never discloses an absolute home path. A file outside PAL_HOME
|
|
39
|
+
* keeps its own path — a "../.." chain would name nothing useful.
|
|
40
|
+
*/
|
|
41
|
+
function homeRelative(file: string): string {
|
|
42
|
+
const rel = relative(palHome(), file).replaceAll("\\", "/");
|
|
43
|
+
return rel && !rel.startsWith("..") ? rel : file.replaceAll("\\", "/");
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
export const emit = {
|
|
25
47
|
data(text: string): void {
|
|
26
48
|
line(process.stdout, text);
|
|
@@ -28,4 +50,11 @@ export const emit = {
|
|
|
28
50
|
ok(text: string): void {
|
|
29
51
|
if (isVerbose()) line(process.stdout, text);
|
|
30
52
|
},
|
|
53
|
+
/** Proof that a write landed: the file it went to, plus what the operation produced. */
|
|
54
|
+
receipt(file: string, extra: Record<string, unknown> = {}): void {
|
|
55
|
+
line(
|
|
56
|
+
process.stdout,
|
|
57
|
+
JSON.stringify({ ok: true, wrote: homeRelative(file), ...extra })
|
|
58
|
+
);
|
|
59
|
+
},
|
|
31
60
|
};
|
|
@@ -454,7 +454,12 @@ Output:
|
|
|
454
454
|
const report = formatReport(period, notes, ratings, opinionChanges);
|
|
455
455
|
const filepath = writeReport(report, period);
|
|
456
456
|
setLastReflectDate(new Date().toISOString().slice(0, 10));
|
|
457
|
-
emit.
|
|
457
|
+
emit.receipt(filepath, {
|
|
458
|
+
period,
|
|
459
|
+
notes: notes.length,
|
|
460
|
+
ratings: ratings.length,
|
|
461
|
+
opinionChanges: opinionChanges.length,
|
|
462
|
+
});
|
|
458
463
|
|
|
459
464
|
const opinions = readOpinions();
|
|
460
465
|
const high = opinions.filter((o) => o.confidence >= 0.85);
|