@astrosheep/pi-context 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ export const RESET_V2 = "reset-v2";
9
9
  export const MAX_NOTE_BYTES = 1_000_000;
10
10
  export const POCKET_SESSION_LIMIT = 5;
11
11
  export const POCKET_PROJECT_LIMIT = 2;
12
- export const POCKET_GLOBAL_LIMIT = 2;
12
+ export const POCKET_PERSONAL_LIMIT = 2;
13
13
  // Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
14
14
  // notesFromSession replays already-persisted operations, which must keep loading sessions
15
15
  // that contain a longer legacy path. Reads and replay stay un-capped.
@@ -21,6 +21,8 @@ export const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
21
21
  export const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
22
22
  export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
23
23
  export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
24
+ /** Nested under "pi-context": the default dreamer model pattern, overridden by CLI --dreamer. */
25
+ export const PI_CONTEXT_DREAMER_KEY = "dreamer";
24
26
  export const DEFAULT_RESERVE_TOKENS = 16_384;
25
27
  export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
26
28
  /**
@@ -47,10 +49,9 @@ Use get_context_remaining to see how much of the window is left. When it runs ou
47
49
 
48
50
  If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read directly when you know the window and item IDs, history_list or history_search to find them when you don't.
49
51
 
50
- Your notes live in three homes: this session (bare names), this repo (@project/<vpath>), everywhere you go (@global/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
51
- Notes carry what exists nowhere elsewhat the human told you, what you discovered, where you stand.
52
- Session notes belong to this tripthe goal, the progress, the loose ends, packed for the road. The next window of THIS trip wakes to them; once the trip is over, nobody does.
53
- @project notes hold what you learned by working here the things you only know because you were here for whoever works here next.
54
- @global notes travel with you. Every window. Every conversation. Every trip. So before you drop anything in there, ask yourself: does this deserve to stare you in the face every single time you talk to the human? No? Then keep your weird junk OUT.
52
+ Your notes live in three homes: this session (bare names), this project (@project/<vpath>), the human across projects (@personal/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
53
+ Session notes belong to this trip — the goal, the progress, the loose ends. The next window of THIS trip wakes to them; once the trip is over, nobody does.
54
+ @project notes hold facts about this projectarchitecture, conventions, workflows, deployment and environment details for whoever works here next.
55
+ @personal notes hold the human's durable preferences and standing rules, plus lessons that apply across projects. Duration does not make a note personal; its stated scope must already be broader than the project or conversation at hand. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
55
56
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
56
57
  export const WARNING_PROMPT = "Your memory is about to be erased. Write the note. NOW. If it already exists, revise it with notes_edit (or rewrite it whole): the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Do not continue any task. Then call new_context IMMEDIATELY — anything not in the note dies with the window.";
@@ -1,5 +1,5 @@
1
1
  import { SettingsManager } from "@earendil-works/pi-coding-agent";
2
- import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
2
+ import { PI_CONTEXT_SETTINGS_KEY, PI_CONTEXT_DREAMER_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
3
3
  function isSettingsObject(value) {
4
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
5
  }
@@ -13,7 +13,7 @@ function piContextSettings(settings) {
13
13
  /** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
14
14
  export function mergePiContextSettings(globalSettings, projectSettings) {
15
15
  const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
16
- return { reminderMarginTokens: merged.reminderMarginTokens };
16
+ return { reminderMarginTokens: merged.reminderMarginTokens, dreamer: merged.dreamer };
17
17
  }
18
18
  /** A margin is usable only as a positive integer; anything else is ignored. */
19
19
  function validMargin(raw) {
@@ -44,6 +44,33 @@ export function deriveThresholds(reserveTokens, margins) {
44
44
  }
45
45
  return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
46
46
  }
47
+ /**
48
+ * `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
49
+ * with one warning; absent means no configured pattern, so the automatic model applies.
50
+ */
51
+ export function deriveDreamer(settings) {
52
+ const raw = settings.dreamer;
53
+ if (raw === undefined)
54
+ return { warnings: [] };
55
+ if (typeof raw !== "string" || raw.trim().length === 0) {
56
+ return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
57
+ }
58
+ return { pattern: raw.trim(), warnings: [] };
59
+ }
60
+ /**
61
+ * Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
62
+ * `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
63
+ * values winning per key. A settings read failure degrades to no pattern with one warning.
64
+ */
65
+ export function readDreamerSettings(cwd = process.cwd()) {
66
+ try {
67
+ const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
68
+ return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
69
+ }
70
+ catch (error) {
71
+ return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
72
+ }
73
+ }
47
74
  let cached;
48
75
  /**
49
76
  * Session-level threshold resolution: Pi's compaction reserve plus the settings.json
@@ -1,48 +1,167 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { existsSync, linkSync, mkdirSync, readFileSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { execFile, execFileSync } from "node:child_process";
4
+ import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
4
5
  import { mkdtempSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join } from "node:path";
7
- import { acquireLock, failLock } from "../src/dream/lock.js";
8
+ import { acquireLock, failLock, lastRunPath, releaseLock } from "../src/dream/lock.js";
8
9
  import { materialGate, timeGate } from "../src/dream/gates.js";
9
10
  import { defaultDreamerSessionFactory, dreamerWriteToolDefinitions, runDreamer, DREAMER_TOOLS } from "../src/dream/runner.js";
10
11
  import { gitCommit } from "../src/dream/git.js";
11
- import { execFileSync } from "node:child_process";
12
+ import { main } from "../src/dream/cli.js";
13
+ import { deriveDreamer, mergePiContextSettings, readDreamerSettings } from "../src/thresholds.js";
14
+ import { PI_CONTEXT_DREAMER_KEY, PI_CONTEXT_SETTINGS_KEY } from "../src/protocol.js";
12
15
  import { contentText } from "../src/history.js";
13
16
  function fixture() { return mkdtempSync(join(tmpdir(), "dream-")); }
14
17
  function old(path) { const d = new Date(Date.now() - 48 * 3600_000); utimesSync(path, d, d); }
18
+ /** A session whose prompt runs a scripted interaction with the recorded event handler. */
19
+ function scriptedSession(run) {
20
+ return async ({ cwd }) => {
21
+ let handler = () => { };
22
+ const session = {
23
+ subscribe(next) { handler = next; return () => { }; },
24
+ async prompt() { run(handler, cwd); },
25
+ dispose() { },
26
+ };
27
+ return session;
28
+ };
29
+ }
30
+ function successSession() {
31
+ return scriptedSession((handler) => handler({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } }));
32
+ }
33
+ /** Capture console.error lines without letting CLI chatter pollute the test output. */
34
+ async function captureErrors(run) {
35
+ const errors = [];
36
+ const original = console.error;
37
+ console.error = (...parts) => { errors.push(parts.map(String).join(" ")); };
38
+ try {
39
+ return { code: await run(), errors };
40
+ }
41
+ finally {
42
+ console.error = original;
43
+ }
44
+ }
15
45
  test("time and material gates preserve skip decisions and reasons", () => {
16
46
  const home = fixture();
17
47
  const lock = join(home, ".dream.lock");
18
- writeFileSync(lock, "999999");
19
- const fresh = timeGate(lock, 24);
48
+ const stamp = lastRunPath(lock);
49
+ assert.deepEqual(timeGate(stamp, 24).ok, true, "no prior dream passes");
50
+ writeFileSync(stamp, new Date().toISOString());
51
+ const fresh = timeGate(stamp, 24);
20
52
  assert.equal(fresh.ok, false);
21
- assert.equal(fresh.reason, "time gate: lock is too fresh");
22
- old(lock);
23
- assert.deepEqual(timeGate(lock, 24).ok, true);
53
+ assert.equal(fresh.reason, "time gate: last dream is too recent");
54
+ old(stamp);
55
+ assert.equal(timeGate(stamp, 24).ok, true);
24
56
  mkdirSync(join(home, "pi/session/one"), { recursive: true });
25
57
  writeFileSync(join(home, "pi/session/one/a.md"), "a");
26
- const material = materialGate(home, statSync(lock).mtimeMs, 1);
58
+ const material = materialGate(home, statSync(stamp).mtimeMs, 1);
27
59
  assert.equal(material.ok, true);
28
60
  assert.match(material.reason, /material gate: 1 changed sessions/);
29
61
  assert.equal(materialGate(home, Date.now(), 2).ok, false);
62
+ // The lock file's own mtime no longer carries the scheduler timestamp.
63
+ writeFileSync(lock, "1");
64
+ old(lock);
65
+ assert.equal(timeGate(stamp, 24).ok, true, "the sidecar survives an old lock file");
30
66
  });
31
- test("live lock is excluded, dead lock is reclaimed, and failures restore mtime", () => {
67
+ test("failure restores the last-run timestamp and cleanup is idempotent", () => {
32
68
  const home = fixture();
33
69
  const lock = join(home, ".dream.lock");
34
- writeFileSync(lock, String(process.pid));
35
- const live = acquireLock(lock);
36
- assert.equal(live.held, false);
37
- assert.equal(live.reason, "lock gate: live process holds the lock");
38
- writeFileSync(lock, "999999");
39
- old(lock);
40
- const prior = statSync(lock).mtimeMs;
41
- const reclaimed = acquireLock(lock);
42
- assert.equal(reclaimed.held, true);
43
- utimesSync(lock, new Date(), new Date());
44
- failLock(reclaimed);
45
- assert.ok(Math.abs(statSync(lock).mtimeMs - prior) < 2000);
70
+ const stamp = lastRunPath(lock);
71
+ writeFileSync(stamp, new Date(Date.now() - 48 * 3600_000).toISOString());
72
+ old(stamp);
73
+ const prior = statSync(stamp).mtimeMs;
74
+ const held = acquireLock(lock);
75
+ assert.equal(held.held, true);
76
+ assert.equal(readFileSync(lock, "utf8").trim(), `${process.pid} ${held.token}`, "the lock records its owner and token");
77
+ assert.ok(statSync(stamp).mtimeMs > prior + 1000, "a held lock advances the last-run sidecar, not itself");
78
+ failLock(held);
79
+ assert.equal(held.held, false, "failure marks the state released");
80
+ assert.ok(Math.abs(statSync(stamp).mtimeMs - prior) < 2000, "a failed run restores the last-run timestamp");
81
+ assert.equal(existsSync(lock), false, "a failed run releases its lock");
82
+ // Repeated cleanup on an already-released state is harmless.
83
+ const settled = statSync(stamp).mtimeMs;
84
+ failLock(held);
85
+ releaseLock(held);
86
+ assert.ok(Math.abs(statSync(stamp).mtimeMs - settled) < 1000, "repeated failure cleanup changes nothing");
87
+ const released = acquireLock(lock);
88
+ assert.equal(released.held, true);
89
+ releaseLock(released);
90
+ releaseLock(released);
91
+ assert.equal(released.held, false, "release marks the state released");
92
+ assert.equal(existsSync(lock), false, "release removes the PID marker");
93
+ assert.equal(existsSync(stamp), true, "the timestamp sidecar survives release");
94
+ });
95
+ test("any existing lock refuses acquisition and is left byte-identical", () => {
96
+ const home = fixture();
97
+ const lock = join(home, ".dream.lock");
98
+ const stamp = lastRunPath(lock);
99
+ const markers = [
100
+ `${process.pid} live-owner`, // a live owner's marker
101
+ "999999", // a dead PID
102
+ "999999 dead-token", // a dead PID with a token
103
+ "", // empty
104
+ "not-a-pid", // malformed
105
+ ];
106
+ for (const marker of markers) {
107
+ writeFileSync(lock, marker);
108
+ old(lock);
109
+ const result = acquireLock(lock);
110
+ assert.equal(result.held, false, `refuses an existing marker ${JSON.stringify(marker)}`);
111
+ assert.equal(result.reason, "lock gate: lock already exists");
112
+ assert.equal(readFileSync(lock, "utf8"), marker, "the existing marker is byte-identical");
113
+ }
114
+ assert.equal(existsSync(stamp), false, "a refused acquisition writes no timestamp");
115
+ });
116
+ test("an acquired lock blocks every later contender until released", () => {
117
+ const home = fixture();
118
+ const lock = join(home, ".dream.lock");
119
+ const first = acquireLock(lock);
120
+ assert.equal(first.held, true);
121
+ const second = acquireLock(lock);
122
+ assert.equal(second.held, false);
123
+ assert.equal(second.reason, "lock gate: lock already exists");
124
+ assert.equal(readFileSync(lock, "utf8").trim(), `${process.pid} ${first.token}`, "the holder's marker is untouched");
125
+ });
126
+ test("cleanup never removes a successor's lock, even repeated", () => {
127
+ const home = fixture();
128
+ const lock = join(home, ".dream.lock");
129
+ const stamp = lastRunPath(lock);
130
+ const mine = acquireLock(lock);
131
+ assert.equal(mine.held, true);
132
+ // A successor acquires after ours is externally gone (human/supported protocol).
133
+ unlinkSync(lock);
134
+ const successor = acquireLock(lock);
135
+ assert.equal(successor.held, true);
136
+ const successorStamp = Date.now() - 5000;
137
+ utimesSync(stamp, new Date(), new Date(successorStamp));
138
+ failLock(mine);
139
+ releaseLock(mine);
140
+ assert.equal(mine.held, false, "the old state is marked released by the first cleanup");
141
+ assert.equal(readFileSync(lock, "utf8").trim(), `${process.pid} ${successor.token}`, "the successor's lock survives");
142
+ assert.ok(Math.abs(statSync(stamp).mtimeMs - successorStamp) < 1000, "the successor's timestamp survives");
143
+ });
144
+ /** Each child makes one actual acquisition attempt; a holder stays alive so later attempts see it. */
145
+ async function acquireRaceOutcomes(lock, contenders) {
146
+ const moduleUrl = new URL("../src/dream/lock.js", import.meta.url).href;
147
+ const script = [
148
+ `import { acquireLock, releaseLock } from ${JSON.stringify(moduleUrl)};`,
149
+ `const result = acquireLock(${JSON.stringify(lock)});`,
150
+ `if (result.held) await new Promise((resolve) => setTimeout(resolve, 1200));`,
151
+ `process.stdout.write(result.held ? "held" : "lost");`,
152
+ `if (result.held) releaseLock(result);`,
153
+ ].join("\n");
154
+ const contender = () => new Promise((resolve, reject) => {
155
+ execFile(process.execPath, ["--input-type=module", "-e", script], (error, stdout) => error ? reject(error) : resolve(stdout.trim()));
156
+ });
157
+ return Promise.all(Array.from({ length: contenders }, contender));
158
+ }
159
+ test("concurrent acquisitions of an absent lock yield exactly one held:true", async () => {
160
+ const home = fixture();
161
+ const lock = join(home, ".dream.lock");
162
+ assert.equal(existsSync(lock), false, "the lock starts absent");
163
+ const outcomes = await acquireRaceOutcomes(lock, 8);
164
+ assert.equal(outcomes.filter((outcome) => outcome === "held").length, 1, "exactly one actual held:true return");
46
165
  });
47
166
  test("dreamer write jail accepts home files and refuses escapes", async () => {
48
167
  const home = fixture();
@@ -76,6 +195,9 @@ test("dreamer write jail accepts home files and refuses escapes", async () => {
76
195
  symlinkSync(insideFile, join(home, "global/inside-link.md"));
77
196
  await tools.get("write").execute("write", { path: "global/inside-link.md", content: "updated" }, undefined, undefined, ctx);
78
197
  assert.equal(readFileSync(insideFile, "utf8"), "updated", "the jail permits a symlinked file that resolves inside home");
198
+ // The jail has no `.git` special case: an in-home `.git` path is written like any other.
199
+ await tools.get("write").execute("write", { path: ".git/config", content: "not protected" }, undefined, undefined, ctx);
200
+ assert.equal(readFileSync(join(home, ".git/config"), "utf8"), "not protected", "the dream write jail does not protect .git");
79
201
  });
80
202
  test("dreamer allowlist contains only the file tools and reports their writes", async () => {
81
203
  let configured = [];
@@ -92,6 +214,7 @@ test("dreamer allowlist contains only the file tools and reports their writes",
92
214
  assert.deepEqual(result.writes, [{ tool: "write", path: "global/a.md" }, { tool: "edit", path: "project/p.md" }]);
93
215
  assert.equal(result.report, "done\nagain");
94
216
  assert.equal(result.report, contentText([{ type: "text", text: "done" }, { type: "text", text: "again" }]), "dream and history share the text projection");
217
+ assert.equal(result.error, undefined);
95
218
  });
96
219
  test("dreamer session has exactly the jailed file-tool allowlist", async () => {
97
220
  const session = await defaultDreamerSessionFactory({ cwd: fixture(), tools: DREAMER_TOOLS });
@@ -110,14 +233,14 @@ test("playbook describes plain files and the retained frontmatter", () => {
110
233
  assert.equal(/^scope:/m.test(playbook), false, "scope is derived from the address rather than persisted");
111
234
  assert.match(playbook, /Nothing is physically deleted/);
112
235
  });
113
- test("provider errors propagate without parsing a response", async () => {
114
- const session = {
115
- subscribe(handler) { this.handler = handler; return () => { }; },
116
- handler: (_event) => { },
117
- async prompt(_text) { this.handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } }); },
118
- dispose() { },
119
- };
120
- await assert.rejects(() => runDreamer("playbook", "/tmp/notes", { sessionFactory: async () => session }), /Insufficient Balance/);
236
+ test("provider errors are reported with partial writes instead of parsing a response", async () => {
237
+ const factory = scriptedSession((handler) => {
238
+ handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
239
+ handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
240
+ });
241
+ const result = await runDreamer("playbook", "/tmp/notes", { sessionFactory: factory });
242
+ assert.match(result.error ?? "", /Insufficient Balance/);
243
+ assert.deepEqual(result.writes, [{ tool: "write", path: "global/partial.md" }]);
121
244
  });
122
245
  test("default dreamer rejects an unresolvable model pattern", async () => {
123
246
  await assert.rejects(() => defaultDreamerSessionFactory({ cwd: "/tmp/notes", modelPattern: "definitely-not-a-real-model", tools: DREAMER_TOOLS }), /definitely-not-a-real-model/);
@@ -125,18 +248,180 @@ test("default dreamer rejects an unresolvable model pattern", async () => {
125
248
  test("git audit layer commits baseline and dream, stays silent when clean, keeps file content", () => {
126
249
  const home = fixture();
127
250
  writeFileSync(join(home, "a.md"), "one");
128
- gitCommit(home, "baseline t");
129
- gitCommit(home, "dream t"); // clean tree — no empty commit
251
+ const baseline = gitCommit(home, "baseline t");
252
+ assert.equal(baseline.ok, true);
253
+ const clean = gitCommit(home, "dream t"); // clean tree — no empty commit
254
+ assert.equal(clean.ok, true);
255
+ assert.equal(clean.empty, true);
130
256
  const log1 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
131
257
  assert.equal(log1, "baseline t");
132
258
  writeFileSync(join(home, "a.md"), "two");
133
- gitCommit(home, "dream t2");
259
+ const second = gitCommit(home, "dream t2");
260
+ assert.equal(second.ok, true);
261
+ assert.equal(second.empty, false);
134
262
  const log2 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
135
263
  assert.equal(log2, "dream t2\nbaseline t");
136
264
  assert.equal(readFileSync(join(home, "a.md"), "utf8"), "two"); // notes themselves untouched by the layer
137
265
  const before = execFileSync("git", ["show", "HEAD~1:a.md"], { cwd: home, encoding: "utf8" }).trim();
138
266
  assert.equal(before, "one"); // rollback information actually recorded
139
267
  });
140
- test("git audit layer never breaks the run when git itself fails", () => {
141
- gitCommit(join(fixture(), "missing", "home"), "x"); // init on a missing cwd throws inside — swallowed
268
+ test("git audit layer gives a newly initialized empty repository a real baseline snapshot", () => {
269
+ const home = fixture();
270
+ const baseline = gitCommit(home, "baseline empty");
271
+ assert.equal(baseline.ok, true);
272
+ if (baseline.ok) {
273
+ assert.notEqual(baseline.commit, "", "the baseline carries a real commit, not an empty marker");
274
+ assert.equal(baseline.empty, true, "no files changed");
275
+ }
276
+ assert.equal(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), "baseline empty");
277
+ });
278
+ test("git audit layer reports its failure instead of swallowing it", () => {
279
+ const missing = gitCommit(join(fixture(), "missing", "home"), "x");
280
+ assert.equal(missing.ok, false);
281
+ const broken = fixture();
282
+ writeFileSync(join(broken, ".git"), "not a git directory");
283
+ const result = gitCommit(broken, "x");
284
+ assert.equal(result.ok, false);
285
+ if (!result.ok)
286
+ assert.ok(result.error.length > 0, "the audit failure names its cause");
287
+ });
288
+ test("CLI: a failed final audit is recorded in the report and in the exit status", async () => {
289
+ const home = fixture();
290
+ const sessionFactory = scriptedSession((_handler, cwd) => {
291
+ mkdirSync(join(cwd, "global"), { recursive: true });
292
+ writeFileSync(join(cwd, "global/ok.md"), "ok");
293
+ // Break the repository after the baseline so only the final audit fails.
294
+ rmSync(join(cwd, ".git"), { recursive: true, force: true });
295
+ writeFileSync(join(cwd, ".git"), "broken");
296
+ });
297
+ const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
298
+ assert.notEqual(code, 0, "a dream with no final snapshot is not a success");
299
+ assert.ok(errors.some((line) => /final audit failed/.test(line)), "the audit failure is named");
300
+ const reports = readdirSync(join(home, "dreams"));
301
+ assert.equal(reports.length, 1);
302
+ assert.match(readFileSync(join(home, "dreams", reports[0]), "utf8"), /Final audit failed/, "the success report records the audit failure");
303
+ });
304
+ test("CLI: a report-write failure does not prevent the final failure audit", async () => {
305
+ const home = fixture();
306
+ writeFileSync(join(home, "dreams"), "not a directory");
307
+ const sessionFactory = scriptedSession((handler, cwd) => {
308
+ mkdirSync(join(cwd, "global"), { recursive: true });
309
+ writeFileSync(join(cwd, "global/partial.md"), "half");
310
+ handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
311
+ handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
312
+ });
313
+ const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
314
+ assert.notEqual(code, 0);
315
+ assert.ok(errors.some((line) => /could not write report/.test(line)), "the report failure is surfaced");
316
+ assert.match(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), /\(failed\)/, "the final failure audit still ran");
317
+ assert.equal(execFileSync("git", ["show", "HEAD:global/partial.md"], { cwd: home, encoding: "utf8" }), "half", "the partial write is still committed");
318
+ });
319
+ test("CLI: an unwritable report is a failure even when the dreamer succeeds", async () => {
320
+ const home = fixture();
321
+ writeFileSync(join(home, "dreams"), "not a directory");
322
+ const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory: successSession(), dreamerSettings: () => ({ warnings: [] }) }));
323
+ assert.notEqual(code, 0, "a missing report is not a success");
324
+ assert.ok(errors.some((line) => /could not write report/.test(line)), "the report failure is surfaced");
325
+ assert.notEqual(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), "", "the final audit still ran");
326
+ });
327
+ test("CLI: a failed baseline audit aborts before the dreamer and preserves the notes bytes", async () => {
328
+ const home = fixture();
329
+ writeFileSync(join(home, "keep.md"), "keep me");
330
+ writeFileSync(join(home, ".git"), "not a git directory"); // every git command fails
331
+ let started = false;
332
+ const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], {
333
+ sessionFactory: async () => { started = true; return {}; },
334
+ dreamerSettings: () => ({ warnings: [] }),
335
+ }));
336
+ assert.notEqual(code, 0, "a missing baseline is a failed run");
337
+ assert.equal(started, false, "the dreamer never starts without a baseline");
338
+ assert.equal(readFileSync(join(home, "keep.md"), "utf8"), "keep me", "the notes bytes survive");
339
+ assert.equal(existsSync(join(home, "dreams")), false, "no dream report is written");
340
+ });
341
+ test("CLI: a dreamer failure records the failure and the partial write in a final commit", async () => {
342
+ const home = fixture();
343
+ const sessionFactory = scriptedSession((handler, cwd) => {
344
+ mkdirSync(join(cwd, "global"), { recursive: true });
345
+ writeFileSync(join(cwd, "global/partial.md"), "half");
346
+ handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
347
+ handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
348
+ });
349
+ const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
350
+ assert.notEqual(code, 0);
351
+ const reports = readdirSync(join(home, "dreams"));
352
+ assert.equal(reports.length, 1, "one failure report");
353
+ const report = readFileSync(join(home, "dreams", reports[0]), "utf8");
354
+ assert.match(report, /Insufficient Balance/, "the report contains the failure");
355
+ assert.match(report, /global\/partial\.md/, "the report names the partial write");
356
+ const log = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
357
+ assert.match(log, /\(failed\)/, "the partial state is committed as a failure");
358
+ assert.equal(execFileSync("git", ["show", "HEAD:global/partial.md"], { cwd: home, encoding: "utf8" }), "half");
359
+ });
360
+ test("CLI: a successful run commits its report and leaves the audited tree clean", async () => {
361
+ const home = fixture();
362
+ writeFileSync(join(home, "seed.md"), "seed");
363
+ const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory: successSession(), dreamerSettings: () => ({ warnings: [] }) }));
364
+ assert.equal(code, 0);
365
+ assert.equal(execFileSync("git", ["status", "--porcelain"], { cwd: home, encoding: "utf8" }).trim(), "", "the audited tree is clean after the lock is released");
366
+ const tracked = execFileSync("git", ["ls-files"], { cwd: home, encoding: "utf8" }).trim().split("\n");
367
+ assert.equal(tracked.some((file) => file.startsWith(".dream.lock")), false, "lock artifacts stay out of the snapshot");
368
+ assert.equal(tracked.includes("seed.md"), true);
369
+ });
370
+ test("CLI: --force does not bypass an existing lock", async () => {
371
+ const home = fixture();
372
+ writeFileSync(join(home, ".dream.lock"), "999999 dead-owner");
373
+ let started = false;
374
+ const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], {
375
+ sessionFactory: async () => { started = true; return {}; },
376
+ dreamerSettings: () => ({ warnings: [] }),
377
+ }));
378
+ assert.equal(code, 0, "an existing lock skips the run rather than failing");
379
+ assert.equal(started, false, "--force never reaches the dreamer while the lock exists");
380
+ assert.equal(readFileSync(join(home, ".dream.lock"), "utf8"), "999999 dead-owner", "the existing lock is byte-identical");
381
+ assert.equal(existsSync(join(home, "dreams")), false, "no dream report is written");
382
+ });
383
+ test("dreamer setting is a non-empty string; project overrides global; invalid values warn and fall back", () => {
384
+ assert.deepEqual(deriveDreamer({}), { warnings: [] }, "absent setting falls back silently");
385
+ assert.deepEqual(deriveDreamer({ dreamer: "openai/gpt-x" }), { pattern: "openai/gpt-x", warnings: [] });
386
+ const merged = mergePiContextSettings({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "global/model" } }, { [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "project/model" } });
387
+ assert.equal(deriveDreamer(merged).pattern, "project/model", "project wins per key");
388
+ for (const invalid of ["", " ", 42, null]) {
389
+ const result = deriveDreamer({ dreamer: invalid });
390
+ assert.equal(result.pattern, undefined, `invalid ${JSON.stringify(invalid)} is ignored`);
391
+ assert.equal(result.warnings.length, 1, "one warning per invalid value");
392
+ assert.match(result.warnings[0], /dreamer/);
393
+ }
394
+ });
395
+ test("readDreamerSettings reads the project setting over the global one through SettingsManager", () => {
396
+ const cwd = mkdtempSync(join(tmpdir(), "dream-cwd-"));
397
+ const agentDir = mkdtempSync(join(tmpdir(), "dream-agent-"));
398
+ const prior = process.env.PI_CODING_AGENT_DIR;
399
+ process.env.PI_CODING_AGENT_DIR = agentDir;
400
+ try {
401
+ writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "global/model" } }));
402
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
403
+ writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "project/model" } }));
404
+ assert.equal(readDreamerSettings(cwd).pattern, "project/model");
405
+ writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({}));
406
+ assert.equal(readDreamerSettings(cwd).pattern, "global/model", "global applies when the project does not set the key");
407
+ }
408
+ finally {
409
+ if (prior === undefined)
410
+ delete process.env.PI_CODING_AGENT_DIR;
411
+ else
412
+ process.env.PI_CODING_AGENT_DIR = prior;
413
+ }
414
+ });
415
+ test("CLI: --dreamer wins over settings, settings win over the automatic fallback", async () => {
416
+ const home = fixture();
417
+ const seen = [];
418
+ const success = successSession();
419
+ const run = (argv, settings) => main(["--notes-home", home, "--force", ...argv], {
420
+ sessionFactory: async (options) => { seen.push(options.modelPattern); return success({ cwd: home, tools: DREAMER_TOOLS }); },
421
+ dreamerSettings: () => settings,
422
+ });
423
+ await captureErrors(() => run([], { pattern: "settings/model", warnings: [] }));
424
+ await captureErrors(() => run(["--dreamer", "cli/model"], { pattern: "settings/model", warnings: [] }));
425
+ await captureErrors(() => run([], { warnings: [] }));
426
+ assert.deepEqual(seen, ["settings/model", "cli/model", undefined]);
142
427
  });