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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.65.0",
3
+ "version": "0.66.1",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.
@@ -10,7 +10,13 @@
10
10
  * pending work without running anything.
11
11
  */
12
12
 
13
- import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
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
+ }
@@ -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
+ }
@@ -1,44 +1,42 @@
1
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.
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 { parse, stringify } from "./frontmatter";
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
- const SHORT_ID_LENGTH = 4;
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
- 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);
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
- 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;
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
- 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;
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
- 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;
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
- 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;
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
- 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;
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
- /** Append notes to today's relationship file */
51
- export function appendNotes(notes: RelationshipNote[], sessionId?: string): void {
52
- if (notes.length === 0) return;
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 */
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { loadMachine } from "./machine";
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(), m: loadMachine().id, ...data };
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
- m: loadMachine().id,
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.ok(result.message);
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
- function writeHandoffs(handoffs: Record<string, HandoffEntry>): void {
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
- ): { success: boolean; message: string } {
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.ok(result.message);
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.ok(result.message);
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
- m: loadMachine().id,
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.data(`Added with id ${thread.id}`);
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) emit.ok(resolved.message ?? "Thread resolved");
156
- else emit.data(JSON.stringify(resolved, null, 2));
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.ok(result.message);
237
+ emit.receipt(result.framePath, { domain: result.domain, type: result.type });
238
238
  }
239
239
 
240
240
  if (import.meta.main) run();
@@ -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() requested payload — ALWAYS emitted (list output, reports, results)
8
- * ok() success confirmation / progress — only at a TTY or under PAL_VERBOSE
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.ok(`\nCreated reflection report: ${filepath}`);
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);