@astrosheep/pi-context 0.22.0 → 0.22.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
@@ -59,6 +59,10 @@ The dreamer model is configured under the same key. `--dreamer <model pattern>`
59
59
  }
60
60
  ```
61
61
 
62
+ ## Check the notes store
63
+
64
+ Run `dream doctor` (or `dream doctor --notes-home <dir>`) to check home layout, note frontmatter, concrete backtick-quoted note addresses, MAP entries, and lock presence/format. It is read-only: no model, git commits, directory creation, or repairs. Exit status is 0 when clean and 1 when issues are found. References needing an unavailable project context are reported as unresolved; prose and example/glob addresses are not validated. A present lock is reported without inferring process liveness.
65
+
62
66
  ## The dream lock
63
67
 
64
68
  The `dream` CLI takes an exclusive `.dream.lock` in the notes home with a single O_CREAT|O_EXCL creation. The lock is Git-style existence locking: an existing lock refuses a new run regardless of its contents, PID, or age, and `--force` bypasses only the scheduling and material gates, never the lock. A lock is released only by the run that acquired it (and repeated cleanup is harmless), so a live dream is never displaced.
@@ -7,6 +7,7 @@ import { materialGate, timeGate } from "./gates.js";
7
7
  import { loadPlaybook, runDreamer } from "./runner.js";
8
8
  import { gitCommit } from "./git.js";
9
9
  import { readDreamerSettings } from "../thresholds.js";
10
+ import { doctor } from "./doctor.js";
10
11
  import { notesRoot } from "../notes/paths.js";
11
12
  function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
12
13
  const a = argv[i];
@@ -67,9 +68,20 @@ function finishDream(home, stamp, reportPath, failed, body, writes) {
67
68
  return failed || !audit.ok || reportError !== undefined ? 1 : 0;
68
69
  }
69
70
  export async function main(argv = process.argv.slice(2), deps = {}) {
71
+ if (argv[0] === "doctor") {
72
+ const options = args(argv.slice(1));
73
+ if (options.help) {
74
+ console.log("dream doctor [--notes-home <dir>] — read-only diagnostics; no model or repairs");
75
+ return 0;
76
+ }
77
+ const home = resolve(String(options["notes-home"] ?? notesRoot()));
78
+ const issues = doctor(home);
79
+ console.log(issues.length ? issues.join("\n") : `dream doctor: OK (${home})`);
80
+ return issues.length ? 1 : 0;
81
+ }
70
82
  const a = args(argv);
71
83
  if (a.help) {
72
- console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
84
+ console.log("dream doctor [--notes-home <dir>] — read-only diagnostics\ndream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
73
85
  return 0;
74
86
  }
75
87
  const home = resolve(String(a["notes-home"] ?? notesRoot()));
@@ -0,0 +1,138 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join, relative } from "node:path";
3
+ import { assertAddress } from "../notes/address.js";
4
+ /** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
5
+ export function doctor(home) {
6
+ const issues = [];
7
+ const report = (path, message) => issues.push(`${relative(home, path) || "."}: ${message}`);
8
+ const inspect = (path, action) => {
9
+ try {
10
+ action();
11
+ }
12
+ catch (error) {
13
+ report(path, `cannot inspect: ${error instanceof Error ? error.message : String(error)}`);
14
+ }
15
+ };
16
+ const directory = (path) => {
17
+ const stat = lstatSync(path);
18
+ if (stat.isDirectory())
19
+ return true;
20
+ report(path, "expected a directory (symlinks are not followed); check its location/type");
21
+ return false;
22
+ };
23
+ const checkNote = (path, root, project) => {
24
+ const raw = readFileSync(path, "utf8");
25
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
26
+ if (!match) {
27
+ report(path, "missing or unclosed frontmatter; add a valid metadata block");
28
+ return;
29
+ }
30
+ const fields = new Map();
31
+ for (const line of match[1].split(/\r?\n/)) {
32
+ const field = /^([\w]+):\s*(.*?)\s*$/.exec(line);
33
+ if (!field)
34
+ continue;
35
+ if (fields.has(field[1]))
36
+ report(path, `duplicate metadata key ${field[1]}; keep one value`);
37
+ fields.set(field[1], field[2].replace(/^(["'])(.*)\1$/, "$2"));
38
+ }
39
+ for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
40
+ if (!valid.test(fields.get(key) ?? ""))
41
+ report(path, `missing/invalid ${key}; repair frontmatter`);
42
+ }
43
+ for (const key of ["created_at", "updated_at", "last_accessed"]) {
44
+ const value = fields.get(key);
45
+ if (!value || !Number.isFinite(Date.parse(value)))
46
+ report(path, `missing/invalid ${key}; use an ISO timestamp`);
47
+ }
48
+ if (fields.has("scope"))
49
+ report(path, "obsolete scope field; remove it (home determines scope)");
50
+ // Check concrete, code-formatted addresses; examples/globs and prose are not links.
51
+ for (const link of raw.slice(match[0].length).matchAll(/`([^`\n]+)`/g)) {
52
+ const address = link[1];
53
+ if (!address.endsWith(".md") || /[<>*?\s]/.test(address))
54
+ continue;
55
+ if (!address.startsWith("@") && basename(path) !== "MAP.md")
56
+ continue;
57
+ try {
58
+ const parsed = assertAddress(address);
59
+ const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
60
+ if (!targetHome) {
61
+ report(path, `${address}: project context unavailable; use a resolvable reference`);
62
+ continue;
63
+ }
64
+ if (!existsSync(join(targetHome, parsed.path)))
65
+ report(path, `${address}: target missing; update or remove the reference`);
66
+ }
67
+ catch {
68
+ report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`);
69
+ }
70
+ }
71
+ };
72
+ const walk = (dir, root, project) => {
73
+ for (const name of readdirSync(dir)) {
74
+ const path = join(dir, name);
75
+ inspect(path, () => {
76
+ const stat = lstatSync(path);
77
+ if (stat.isSymbolicLink())
78
+ report(path, "symlink not inspected; replace with a regular note/directory");
79
+ else if (stat.isDirectory())
80
+ walk(path, root, project);
81
+ else if (stat.isFile() && name.endsWith(".md"))
82
+ checkNote(path, root, project);
83
+ else
84
+ report(path, "unexpected file in note home; inspect and relocate it");
85
+ });
86
+ }
87
+ };
88
+ inspect(home, () => {
89
+ if (!directory(home))
90
+ return;
91
+ for (const name of readdirSync(home)) {
92
+ const path = join(home, name);
93
+ inspect(path, () => {
94
+ if (name === "global") {
95
+ report(path, "legacy home; manually migrate to personal/ without overwriting existing files");
96
+ return;
97
+ }
98
+ if (name === ".dream.lock") {
99
+ const valid = lstatSync(path).isFile() && /^[1-9]\d* [\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\s*$/i.test(readFileSync(path, "utf8"));
100
+ report(path, `${valid ? "lock present" : "malformed lock"}; verify no dream is running before manual removal; liveness not inferred`);
101
+ return;
102
+ }
103
+ if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name))
104
+ return;
105
+ if (name === "personal") {
106
+ if (directory(path))
107
+ walk(path, path);
108
+ return;
109
+ }
110
+ if (name === "project" || name === "pi") {
111
+ if (!directory(path))
112
+ return;
113
+ const homes = name === "pi" ? join(path, "session") : path;
114
+ if (name === "pi") {
115
+ for (const entry of readdirSync(path))
116
+ if (entry !== "session")
117
+ report(join(path, entry), "unexpected directory; expected pi/session/<id>/");
118
+ if (!existsSync(homes) || !directory(homes))
119
+ return;
120
+ }
121
+ for (const id of readdirSync(homes)) {
122
+ const root = join(homes, id);
123
+ inspect(root, () => {
124
+ if (!directory(root))
125
+ return;
126
+ if (name === "project" && !/^.+-[\da-f]{8}$/.test(id))
127
+ report(root, "invalid project key; expected <name>-<8 hex>");
128
+ walk(root, root, name === "project" ? root : undefined);
129
+ });
130
+ }
131
+ return;
132
+ }
133
+ report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
134
+ });
135
+ }
136
+ });
137
+ return issues;
138
+ }
@@ -0,0 +1,44 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, readdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { doctor } from "../src/dream/doctor.js";
7
+ import { main } from "../src/dream/cli.js";
8
+ const note = (body = "") => `---\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nlast_accessed: 2026-01-01T00:00:00Z\naccess_count: 0\n---\n\n${body}`;
9
+ const home = () => mkdtempSync(join(tmpdir(), "dream-doctor-"));
10
+ test("doctor validates note homes and references without changing files", async () => {
11
+ const root = home();
12
+ mkdirSync(join(root, "personal"));
13
+ writeFileSync(join(root, "personal/a.md"), note());
14
+ writeFileSync(join(root, "personal/MAP.md"), note("- `a.md`\n- `@personal/a.md`"));
15
+ const before = readFileSync(join(root, "personal/a.md"));
16
+ assert.deepEqual(doctor(root), []);
17
+ assert.equal(await main(["doctor", "--notes-home", root], { runDreamer: async () => { throw new Error("must not run"); } }), 0);
18
+ assert.deepEqual(readFileSync(join(root, "personal/a.md")), before);
19
+ assert.deepEqual(readdirSync(root), ["personal"]);
20
+ });
21
+ test("doctor reports layout, metadata, links and locks; never repairs", () => {
22
+ const root = home();
23
+ mkdirSync(join(root, "global"));
24
+ mkdirSync(join(root, "project/bad"), { recursive: true });
25
+ writeFileSync(join(root, "project/bad/MAP.md"), note("`missing.md` `@global/old.md`"));
26
+ writeFileSync(join(root, "project/bad/broken.md"), "---\norigin: nope\n---\n");
27
+ writeFileSync(join(root, ".dream.lock"), "garbage");
28
+ symlinkSync(join(root, "project"), join(root, "project/bad/link"));
29
+ const output = doctor(root).join("\n");
30
+ for (const expected of ["legacy home", "invalid project key", "target missing", "invalid address", "invalid origin", "invalid created_at", "malformed lock", "symlink"])
31
+ assert.ok(output.includes(expected), expected);
32
+ assert.equal(readFileSync(join(root, ".dream.lock"), "utf8"), "garbage");
33
+ assert.ok(existsSync(join(root, "global")));
34
+ });
35
+ test("doctor reports missing homes without creating them, including CLI", async () => {
36
+ const missing = join(home(), "absent");
37
+ assert.equal(await main(["doctor", "--notes-home", missing]), 1);
38
+ assert.equal(existsSync(missing), false);
39
+ });
40
+ test("doctor reports valid lock presence without claiming it is stale", () => {
41
+ const root = home();
42
+ writeFileSync(join(root, ".dream.lock"), "123 12345678-1234-1234-1234-123456789abc");
43
+ assert.match(doctor(root).join("\n"), /lock present.*liveness not inferred/);
44
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
6
6
  "license": "MIT",
package/src/dream/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { materialGate, timeGate } from "./gates.js";
7
7
  import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult, type DreamWrite } from "./runner.js";
8
8
  import { gitCommit } from "./git.js";
9
9
  import { readDreamerSettings, type DreamerSetting } from "../thresholds.js";
10
+ import { doctor } from "./doctor.js";
10
11
  import { notesRoot } from "../notes/paths.js";
11
12
 
12
13
  function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
@@ -55,7 +56,15 @@ function finishDream(home: string, stamp: string, reportPath: string, failed: bo
55
56
  }
56
57
 
57
58
  export async function main(argv = process.argv.slice(2), deps: DreamDependencies = {}): Promise<number> {
58
- const a = args(argv); if (a.help) { console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it."); return 0; }
59
+ if (argv[0] === "doctor") {
60
+ const options = args(argv.slice(1));
61
+ if (options.help) { console.log("dream doctor [--notes-home <dir>] — read-only diagnostics; no model or repairs"); return 0; }
62
+ const home = resolve(String(options["notes-home"] ?? notesRoot()));
63
+ const issues = doctor(home);
64
+ console.log(issues.length ? issues.join("\n") : `dream doctor: OK (${home})`);
65
+ return issues.length ? 1 : 0;
66
+ }
67
+ const a = args(argv); if (a.help) { console.log("dream doctor [--notes-home <dir>] — read-only diagnostics\ndream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it."); return 0; }
59
68
  const home = resolve(String(a["notes-home"] ?? notesRoot())); process.env.PI_NOTES_HOME = home; mkdirSync(home, { recursive: true });
60
69
  const lockPath = join(home, ".dream.lock"); const stampPath = lastRunPath(lockPath);
61
70
  const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
@@ -0,0 +1,97 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join, relative } from "node:path";
3
+ import { assertAddress } from "../notes/address.js";
4
+
5
+ /** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
6
+ export function doctor(home: string): string[] {
7
+ const issues: string[] = [];
8
+ const report = (path: string, message: string) => issues.push(`${relative(home, path) || "."}: ${message}`);
9
+ const inspect = (path: string, action: () => void) => {
10
+ try { action(); } catch (error) { report(path, `cannot inspect: ${error instanceof Error ? error.message : String(error)}`); }
11
+ };
12
+ const directory = (path: string): boolean => {
13
+ const stat = lstatSync(path);
14
+ if (stat.isDirectory()) return true;
15
+ report(path, "expected a directory (symlinks are not followed); check its location/type");
16
+ return false;
17
+ };
18
+ const checkNote = (path: string, root: string, project?: string) => {
19
+ const raw = readFileSync(path, "utf8");
20
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
21
+ if (!match) { report(path, "missing or unclosed frontmatter; add a valid metadata block"); return; }
22
+ const fields = new Map<string, string>();
23
+ for (const line of match[1]!.split(/\r?\n/)) {
24
+ const field = /^([\w]+):\s*(.*?)\s*$/.exec(line);
25
+ if (!field) continue;
26
+ if (fields.has(field[1]!)) report(path, `duplicate metadata key ${field[1]}; keep one value`);
27
+ fields.set(field[1]!, field[2]!.replace(/^(["'])(.*)\1$/, "$2"));
28
+ }
29
+ for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
30
+ if (!valid.test(fields.get(key) ?? "")) report(path, `missing/invalid ${key}; repair frontmatter`);
31
+ }
32
+ for (const key of ["created_at", "updated_at", "last_accessed"]) {
33
+ const value = fields.get(key);
34
+ if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
35
+ }
36
+ if (fields.has("scope")) report(path, "obsolete scope field; remove it (home determines scope)");
37
+ // Check concrete, code-formatted addresses; examples/globs and prose are not links.
38
+ for (const link of raw.slice(match[0].length).matchAll(/`([^`\n]+)`/g)) {
39
+ const address = link[1]!;
40
+ if (!address.endsWith(".md") || /[<>*?\s]/.test(address)) continue;
41
+ if (!address.startsWith("@") && basename(path) !== "MAP.md") continue;
42
+ try {
43
+ const parsed = assertAddress(address);
44
+ const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
45
+ if (!targetHome) { report(path, `${address}: project context unavailable; use a resolvable reference`); continue; }
46
+ if (!existsSync(join(targetHome, parsed.path))) report(path, `${address}: target missing; update or remove the reference`);
47
+ } catch { report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`); }
48
+ }
49
+ };
50
+ const walk = (dir: string, root: string, project?: string) => {
51
+ for (const name of readdirSync(dir)) {
52
+ const path = join(dir, name);
53
+ inspect(path, () => {
54
+ const stat = lstatSync(path);
55
+ if (stat.isSymbolicLink()) report(path, "symlink not inspected; replace with a regular note/directory");
56
+ else if (stat.isDirectory()) walk(path, root, project);
57
+ else if (stat.isFile() && name.endsWith(".md")) checkNote(path, root, project);
58
+ else report(path, "unexpected file in note home; inspect and relocate it");
59
+ });
60
+ }
61
+ };
62
+ inspect(home, () => {
63
+ if (!directory(home)) return;
64
+ for (const name of readdirSync(home)) {
65
+ const path = join(home, name);
66
+ inspect(path, () => {
67
+ if (name === "global") { report(path, "legacy home; manually migrate to personal/ without overwriting existing files"); return; }
68
+ if (name === ".dream.lock") {
69
+ const valid = lstatSync(path).isFile() && /^[1-9]\d* [\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\s*$/i.test(readFileSync(path, "utf8"));
70
+ report(path, `${valid ? "lock present" : "malformed lock"}; verify no dream is running before manual removal; liveness not inferred`);
71
+ return;
72
+ }
73
+ if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name)) return;
74
+ if (name === "personal") { if (directory(path)) walk(path, path); return; }
75
+ if (name === "project" || name === "pi") {
76
+ if (!directory(path)) return;
77
+ const homes = name === "pi" ? join(path, "session") : path;
78
+ if (name === "pi") {
79
+ for (const entry of readdirSync(path)) if (entry !== "session") report(join(path, entry), "unexpected directory; expected pi/session/<id>/");
80
+ if (!existsSync(homes) || !directory(homes)) return;
81
+ }
82
+ for (const id of readdirSync(homes)) {
83
+ const root = join(homes, id);
84
+ inspect(root, () => {
85
+ if (!directory(root)) return;
86
+ if (name === "project" && !/^.+-[\da-f]{8}$/.test(id)) report(root, "invalid project key; expected <name>-<8 hex>");
87
+ walk(root, root, name === "project" ? root : undefined);
88
+ });
89
+ }
90
+ return;
91
+ }
92
+ report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
93
+ });
94
+ }
95
+ });
96
+ return issues;
97
+ }