@astrosheep/pi-context 0.21.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
@@ -20,7 +20,7 @@ pi -e npm:@astrosheep/pi-context
20
20
  - **A boot block at every window head** — static once-per-window content (cache-stable) carrying the window identity, the recent-notes index, and a short protocol that teaches the model how to recover: notes for its own bookkeeping, history tools for everything before the reset.
21
21
  - **Low-budget guidance** — one persisted early warning per window when the estimated remaining budget crosses the reminder line, so the model checkpoints before the lights go out.
22
22
  - **`get_context_remaining`** — the live, reserve-adjusted estimate of the context budget left before Pi's compaction reserve.
23
- - **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace; notes are real markdown files under `~/.agents/notes` (`global/`, `project/`, `pi/session/`):
23
+ - **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace; notes are real markdown files under `~/.agents/notes` (`personal/`, `project/`, `pi/session/`):
24
24
 
25
25
  | Codex action | Pi tool |
26
26
  | --- | --- |
@@ -51,6 +51,31 @@ The reminder threshold is Pi's compaction reserve plus a margin, configured unde
51
51
 
52
52
  `reminder = reserveTokens + reminderMarginTokens`; with the defaults the early warning fires 24,576 tokens above Pi's reset line.
53
53
 
54
+ The dreamer model is configured under the same key. `--dreamer <model pattern>` on the `dream` CLI wins; otherwise a non-empty `pi-context.dreamer` string from settings applies; otherwise the automatic model is used. An invalid value (empty or not a string) is ignored with one warning.
55
+
56
+ ```json
57
+ {
58
+ "pi-context": { "reminderMarginTokens": 24576, "dreamer": "anthropic/claude-sonnet-4-5" }
59
+ }
60
+ ```
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
+
66
+ ## The dream lock
67
+
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.
69
+
70
+ If a dream process crashed, its lock remains and later runs refuse to start. There is no automatic recovery and no force-unlock command: after you have confirmed that no dream process is running, remove the stale lock by hand.
71
+
72
+ ```sh
73
+ # only when no dream is running
74
+ rm "${PI_NOTES_HOME:-$HOME/.agents/notes}/.dream.lock"
75
+ ```
76
+
77
+ Removing a lock while a holder is running is outside the supported cooperative protocol and can let two dreams run at once.
78
+
54
79
  ## Documentation
55
80
 
56
81
  Implementation architecture and the reset lifecycle live in [docs/](docs/).
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { acquireLock, failLock, releaseLock } from "./lock.js";
4
+ import { fileURLToPath } from "node:url";
5
+ import { acquireLock, failLock, lastRunPath, releaseLock } from "./lock.js";
5
6
  import { materialGate, timeGate } from "./gates.js";
6
7
  import { loadPlaybook, runDreamer } from "./runner.js";
7
8
  import { gitCommit } from "./git.js";
9
+ import { readDreamerSettings } from "../thresholds.js";
10
+ import { doctor } from "./doctor.js";
8
11
  import { notesRoot } from "../notes/paths.js";
9
12
  function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
10
13
  const a = argv[i];
@@ -24,23 +27,76 @@ function packageRoot() {
24
27
  dir = parent;
25
28
  }
26
29
  }
27
- export async function main(argv = process.argv.slice(2)) {
30
+ function writeList(writes) {
31
+ return writes.length ? writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
32
+ }
33
+ /** Best-effort text write; returns the failure message instead of throwing. */
34
+ function writeText(path, content) {
35
+ try {
36
+ mkdirSync(dirname(path), { recursive: true });
37
+ writeFileSync(path, content);
38
+ return undefined;
39
+ }
40
+ catch (error) {
41
+ return error instanceof Error ? error.message : String(error);
42
+ }
43
+ }
44
+ function appendText(path, content) {
45
+ try {
46
+ appendFileSync(path, content);
47
+ return undefined;
48
+ }
49
+ catch (error) {
50
+ return error instanceof Error ? error.message : String(error);
51
+ }
52
+ }
53
+ /**
54
+ * Close one dream: record the report, then run the final audit commit. The audit always
55
+ * runs even when the report cannot be written, and a failed audit is appended to the
56
+ * report (when it exists) as well as named on stderr, so neither failure hides the other.
57
+ */
58
+ function finishDream(home, stamp, reportPath, failed, body, writes) {
59
+ const header = failed ? `# Dream ${stamp} (failed)` : `# Dream ${stamp}`;
60
+ let reportError = writeText(reportPath, `${header}\n\n${body}\n\n${writeList(writes)}\n`);
61
+ const audit = gitCommit(home, `dream ${stamp}${failed ? " (failed)" : ""}`);
62
+ if (!audit.ok) {
63
+ console.error(`dream: final audit failed: ${audit.error}`);
64
+ reportError ??= appendText(reportPath, `\n## Final audit failed\n\n${audit.error}\n`);
65
+ }
66
+ if (reportError)
67
+ console.error(`dream: could not write report at ${reportPath}: ${reportError}`);
68
+ return failed || !audit.ok || reportError !== undefined ? 1 : 0;
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
+ }
28
82
  const a = args(argv);
29
83
  if (a.help) {
30
- console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDefault dreamer: in-process pi SDK session with jailed file tools. 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.");
31
85
  return 0;
32
86
  }
33
87
  const home = resolve(String(a["notes-home"] ?? notesRoot()));
34
88
  process.env.PI_NOTES_HOME = home;
35
89
  mkdirSync(home, { recursive: true });
36
90
  const lockPath = join(home, ".dream.lock");
91
+ const stampPath = lastRunPath(lockPath);
37
92
  const minHours = Number(a["min-hours"] ?? 24);
38
93
  const minSessions = Number(a["min-sessions"] ?? 3);
39
- const time = timeGate(lockPath, minHours);
94
+ const time = timeGate(stampPath, minHours);
40
95
  console.log(time.reason);
41
96
  if (!a.force && !time.ok)
42
97
  return 0;
43
- const material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions);
98
+ const since = existsSync(stampPath) ? statSync(stampPath).mtimeMs : 0;
99
+ const material = materialGate(home, since, minSessions);
44
100
  console.log(material.reason);
45
101
  if (!a.force && !material.ok)
46
102
  return 0;
@@ -58,26 +114,65 @@ export async function main(argv = process.argv.slice(2)) {
58
114
  }
59
115
  const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-");
60
116
  const reportPath = join(home, "dreams", `${stamp}.md`);
61
- gitCommit(home, `baseline ${stamp}`);
117
+ let succeeded = false;
62
118
  try {
119
+ // CLI --dreamer wins over settings; settings win over the automatic model fallback.
120
+ let modelPattern;
121
+ if (a.dreamer)
122
+ modelPattern = String(a.dreamer);
123
+ else {
124
+ const settings = (deps.dreamerSettings ?? readDreamerSettings)();
125
+ for (const warning of settings.warnings)
126
+ console.error(warning);
127
+ modelPattern = settings.pattern;
128
+ }
129
+ // The baseline snapshot is required: without it the human gate has nothing to inspect.
130
+ const baseline = gitCommit(home, `baseline ${stamp}`);
131
+ if (!baseline.ok) {
132
+ console.error(`dream: baseline audit failed: ${baseline.error}`);
133
+ return 1;
134
+ }
63
135
  const defaultBook = join(packageRoot(), "playbook.md");
64
136
  const playbookPath = String(a.playbook ?? defaultBook);
65
- const playbook = loadPlaybook(playbookPath);
66
- const result = await runDreamer(playbook, home, { modelPattern: a.dreamer ? String(a.dreamer) : undefined });
67
- const writes = result.writes.length ? result.writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
68
- mkdirSync(join(home, "dreams"), { recursive: true });
69
- writeFileSync(reportPath, `# Dream ${stamp}\n\n${result.report}\n\n${writes}\n`);
70
- gitCommit(home, `dream ${stamp}`);
71
- console.log(reportPath);
72
- return 0;
73
- }
74
- catch (e) {
75
- failLock(lock);
76
- console.error(e instanceof Error ? e.message : e);
77
- return 1;
137
+ let result;
138
+ try {
139
+ const playbook = loadPlaybook(playbookPath);
140
+ result = await (deps.runDreamer ?? runDreamer)(playbook, home, { modelPattern, sessionFactory: deps.sessionFactory });
141
+ }
142
+ catch (e) {
143
+ const message = e instanceof Error ? e.message : String(e);
144
+ console.error(message);
145
+ return finishDream(home, stamp, reportPath, true, message, []);
146
+ }
147
+ if (result.error) {
148
+ console.error(result.error);
149
+ return finishDream(home, stamp, reportPath, true, result.error, result.writes);
150
+ }
151
+ const code = finishDream(home, stamp, reportPath, false, result.report, result.writes);
152
+ if (code === 0) {
153
+ succeeded = true;
154
+ console.log(reportPath);
155
+ }
156
+ return code;
78
157
  }
79
158
  finally {
80
- releaseLock(lock);
159
+ // Only the holder's own lock is released; a successor's lock is never touched.
160
+ if (succeeded)
161
+ releaseLock(lock);
162
+ else
163
+ failLock(lock);
164
+ }
165
+ }
166
+ function isEntryPoint() {
167
+ const entry = process.argv[1];
168
+ if (!entry)
169
+ return false;
170
+ try {
171
+ return realpathSync(resolve(entry)) === realpathSync(fileURLToPath(import.meta.url));
172
+ }
173
+ catch {
174
+ return false;
81
175
  }
82
176
  }
83
- main().then((code) => { process.exitCode = code; });
177
+ if (isEntryPoint())
178
+ main().then((code) => { process.exitCode = code; });
@@ -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
+ }
@@ -1,13 +1,17 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { sessionHomesRoot } from "../notes/paths.js";
4
- export function timeGate(lockPath, minHours, now = Date.now()) {
5
- if (!existsSync(lockPath))
6
- return { ok: true, reason: "time gate: no prior lock" };
7
- const age = now - statSync(lockPath).mtimeMs;
8
- return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
4
+ /**
5
+ * The scheduler reads the last-run sidecar, not the lock: the lock's lifetime says
6
+ * nothing about when the last dream ran, while the sidecar records exactly that.
7
+ */
8
+ export function timeGate(stampPath, minHours, now = Date.now()) {
9
+ if (!existsSync(stampPath))
10
+ return { ok: true, reason: "time gate: no prior dream" };
11
+ const age = now - statSync(stampPath).mtimeMs;
12
+ return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: last dream is too recent" };
9
13
  }
10
- export function materialGate(home, lockMtime, minSessions) {
14
+ export function materialGate(home, sinceMtime, minSessions) {
11
15
  const root = sessionHomesRoot(home);
12
16
  let changed = 0;
13
17
  if (existsSync(root))
@@ -15,7 +19,7 @@ export function materialGate(home, lockMtime, minSessions) {
15
19
  if (!dir.isDirectory())
16
20
  continue;
17
21
  const files = readdirSync(join(root, dir.name), { withFileTypes: true });
18
- if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > lockMtime))
22
+ if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > sinceMtime))
19
23
  changed++;
20
24
  }
21
25
  return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
@@ -1,28 +1,71 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ /** Lock runtime artifacts are not notes and must not appear in snapshots or `git status`. */
5
+ const RUNTIME_IGNORE = ".dream.lock*";
6
+ /** Add the runtime-artifact pattern to the repository's local exclude, once. */
7
+ function ensureRuntimeIgnored(home) {
8
+ try {
9
+ const exclude = join(home, ".git", "info", "exclude");
10
+ const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
11
+ if (current.split(/\r?\n/).includes(RUNTIME_IGNORE))
12
+ return;
13
+ mkdirSync(dirname(exclude), { recursive: true });
14
+ const prefix = current.length > 0 && !current.endsWith("\n") ? `${current}\n` : current;
15
+ writeFileSync(exclude, `${prefix}${RUNTIME_IGNORE}\n`);
16
+ }
17
+ catch { /* best effort: an unignored lock only adds noise to the audit */ }
18
+ }
4
19
  /**
5
- * Git audit layer for a dream run: one commit before (baseline) and one after
6
- * (dream), so the human gate reviews `git show` instead of trusting a report,
7
- * and rollback is `git revert`. This layer is a garnish, never load-bearing:
8
- * every failure is logged and swallowed a notes home without git, or a
9
- * broken repo, still dreams. Nothing is committed when the tree is clean.
20
+ * Git audit layer for a dream run: one commit before (baseline) and one after (dream),
21
+ * so the human gate reviews `git show` instead of trusting a report, and rollback is
22
+ * `git revert`. The caller decides how loud a failure is; this function only reports it.
23
+ * A clean tree on an established repository commits nothing; a repository with no HEAD
24
+ * gets an empty baseline commit, because an audit run with no snapshot is not a success.
10
25
  */
11
26
  export function gitCommit(home, message) {
12
27
  try {
13
28
  if (!existsSync(join(home, ".git"))) {
14
29
  execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
15
30
  }
31
+ ensureRuntimeIgnored(home);
16
32
  execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
33
+ let clean = false;
17
34
  try {
18
35
  execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
19
- return; // clean tree — no empty commit
36
+ clean = true; // clean tree — no empty commit on an established repository
37
+ }
38
+ catch {
39
+ clean = false;
40
+ }
41
+ const before = headCommit(home);
42
+ if (!clean) {
43
+ execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
44
+ console.log(`git: committed "${message}"`);
20
45
  }
21
- catch { /* staged changes exist — fall through to commit */ }
22
- execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
23
- console.log(`git: committed "${message}"`);
46
+ else if (before === undefined) {
47
+ // A newly initialized repository has no snapshot at all; give the audit one.
48
+ execFileSync("git", ["commit", "-q", "--allow-empty", "-m", message], { cwd: home, stdio: "ignore" });
49
+ console.log(`git: committed "${message}"`);
50
+ }
51
+ const commit = headCommit(home);
52
+ if (commit === undefined)
53
+ return { ok: false, error: "audit commit produced no snapshot (no HEAD)" };
54
+ return { ok: true, commit, empty: clean };
24
55
  }
25
56
  catch (error) {
26
- console.log(`git audit layer skipped: ${error instanceof Error ? error.message : error}`);
57
+ const reason = error instanceof Error ? error.message : String(error);
58
+ console.log(`git audit layer failed: ${reason}`);
59
+ return { ok: false, error: reason };
60
+ }
61
+ }
62
+ /** HEAD sha, or undefined when the repository has no commit yet. */
63
+ function headCommit(home) {
64
+ try {
65
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: home, encoding: "utf8" }).trim();
66
+ return head.length > 0 ? head : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
27
70
  }
28
71
  }
@@ -1,58 +1,99 @@
1
- import { existsSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
- const HOUR = 60 * 60 * 1000;
3
- function live(pid) {
4
- if (!Number.isInteger(pid) || pid <= 0)
5
- return false;
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
3
+ /**
4
+ * The scheduler's last-run timestamp lives in a sidecar beside the lock, never in the
5
+ * lock file itself: acquiring, releasing or cleaning up the lock touches only the PID
6
+ * marker, so lock lifecycle does not destroy the timestamp the time gate reads.
7
+ */
8
+ export function lastRunPath(lockPath) {
9
+ return `${lockPath}.last-run`;
10
+ }
11
+ function readText(path) {
6
12
  try {
7
- process.kill(pid, 0);
8
- return true;
13
+ return readFileSync(path, "utf8");
9
14
  }
10
15
  catch {
11
- return false;
16
+ return undefined;
12
17
  }
13
18
  }
14
- export function acquireLock(path) {
15
- const now = Date.now();
16
- let priorMtime;
17
- if (existsSync(path)) {
18
- const stat = statSync(path);
19
- priorMtime = stat.mtimeMs;
20
- let pid = 0;
21
- try {
22
- pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
23
- }
24
- catch { /* reclaim */ }
25
- if (now - stat.mtimeMs <= HOUR && live(pid))
26
- return { path, held: false, reason: "lock gate: live process holds the lock", startedAt: now };
27
- try {
28
- unlinkSync(path);
29
- }
30
- catch {
31
- return { path, held: false, reason: "lock gate: lock could not be reclaimed", startedAt: now };
32
- }
19
+ function stampMtime(stampPath) {
20
+ try {
21
+ return statSync(stampPath).mtimeMs;
22
+ }
23
+ catch {
24
+ return undefined;
33
25
  }
34
- writeFileSync(path, String(process.pid), { flag: "wx" });
35
- return { path, held: true, startedAt: now, priorMtime };
36
26
  }
37
- export function releaseLock(lock) {
38
- // The lock is also the durable last-dream timestamp. Leave the PID marker in place;
39
- // the next acquisition reclaims it once the PID is dead or it is older than an hour.
27
+ /**
28
+ * Cleanup ownership: only the exact marker this run wrote may be removed. The token is
29
+ * diagnostic and guards cleanup; it never grants permission to take an existing lock.
30
+ */
31
+ function ownsLock(lock) {
32
+ if (!lock.held || !lock.token)
33
+ return false;
34
+ return readText(lock.path)?.trim() === `${process.pid} ${lock.token}`;
40
35
  }
41
- export function restoreMtime(path, mtimeMs) {
36
+ /**
37
+ * Acquire the dream lock with Git-style exclusive existence locking: one O_CREAT|O_EXCL
38
+ * creation. An existing path refuses acquisition regardless of its contents, PID, or age,
39
+ * and is never read for permission, replaced, or removed. There is no automatic stale
40
+ * recovery; a crash-left lock is human cleanup after confirming no dream is running.
41
+ */
42
+ export function acquireLock(path) {
43
+ const now = Date.now();
44
+ const priorStampMtime = stampMtime(lastRunPath(path));
45
+ const token = randomUUID();
46
+ try {
47
+ writeFileSync(path, `${process.pid} ${token}`, { flag: "wx" });
48
+ }
49
+ catch {
50
+ return { path, held: false, reason: "lock gate: lock already exists", startedAt: now };
51
+ }
52
+ // Only a held lock advances the scheduler timestamp.
42
53
  try {
43
- utimesSync(path, new Date(), new Date(mtimeMs));
54
+ writeFileSync(lastRunPath(path), new Date(now).toISOString());
44
55
  }
45
56
  catch { /* advisory */ }
57
+ return { path, held: true, startedAt: now, priorStampMtime, token };
58
+ }
59
+ /** Release only the lock this run acquired. Idempotent: repeated cleanup does nothing. */
60
+ export function releaseLock(lock) {
61
+ if (!lock.held)
62
+ return;
63
+ if (ownsLock(lock)) {
64
+ try {
65
+ unlinkSync(lock.path);
66
+ }
67
+ catch { /* best effort */ }
68
+ }
69
+ lock.held = false;
46
70
  }
71
+ /**
72
+ * A failed run must not advance the scheduler: restore the previous timestamp, or remove
73
+ * the one this run wrote when there was none. Only this run's own marker is removed, and
74
+ * the state is marked released so a later cleanup attempt is harmless.
75
+ */
47
76
  export function failLock(lock) {
48
77
  if (!lock.held)
49
78
  return;
50
- if (lock.priorMtime === undefined) {
79
+ if (ownsLock(lock)) {
80
+ const stampPath = lastRunPath(lock.path);
81
+ if (lock.priorStampMtime === undefined) {
82
+ try {
83
+ unlinkSync(stampPath);
84
+ }
85
+ catch { /* best effort */ }
86
+ }
87
+ else {
88
+ try {
89
+ utimesSync(stampPath, new Date(), new Date(lock.priorStampMtime));
90
+ }
91
+ catch { /* best effort */ }
92
+ }
51
93
  try {
52
94
  unlinkSync(lock.path);
53
95
  }
54
96
  catch { /* best effort */ }
55
97
  }
56
- else
57
- restoreMtime(lock.path, lock.priorMtime);
98
+ lock.held = false;
58
99
  }
@@ -83,6 +83,11 @@ export const defaultDreamerSessionFactory = async ({ cwd, modelPattern, tools })
83
83
  const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, customTools: dreamerWriteToolDefinitions(cwd), noTools: "all", model, thinkingLevel: "off" });
84
84
  return session;
85
85
  };
86
+ /**
87
+ * Run one dream turn. A dreamer failure is returned as `error` together with the partial
88
+ * writes observed so far, so the caller can record partial state instead of losing it;
89
+ * only a failure to even start the session throws.
90
+ */
86
91
  export async function runDreamer(playbook, cwd, options = {}) {
87
92
  const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
88
93
  let answer = "";
@@ -102,9 +107,14 @@ export async function runDreamer(playbook, cwd, options = {}) {
102
107
  answer = contentText(event.message.content);
103
108
  });
104
109
  try {
105
- await session.prompt(playbook);
110
+ try {
111
+ await session.prompt(playbook);
112
+ }
113
+ catch (error) {
114
+ return { report: answer, writes, error: error instanceof Error ? error.message : String(error) };
115
+ }
106
116
  if (providerError)
107
- throw new Error(`dreamer failed: ${providerError}`);
117
+ return { report: answer, writes, error: `dreamer failed: ${providerError}` };
108
118
  return { report: answer, writes };
109
119
  }
110
120
  finally {