@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 +26 -1
- package/dist/src/dream/cli.js +117 -22
- package/dist/src/dream/doctor.js +138 -0
- package/dist/src/dream/gates.js +11 -7
- package/dist/src/dream/git.js +55 -12
- package/dist/src/dream/lock.js +78 -37
- package/dist/src/dream/runner.js +12 -2
- package/dist/src/notes/address.js +4 -4
- package/dist/src/notes/frontmatter.js +2 -2
- package/dist/src/notes/paths.js +2 -2
- package/dist/src/notes/store.js +2 -2
- package/dist/src/notes/tools.js +1 -1
- package/dist/src/prompts.js +18 -11
- package/dist/src/protocol.js +7 -6
- package/dist/src/thresholds.js +29 -2
- package/dist/test/doctor.test.js +44 -0
- package/dist/test/dream.test.js +320 -35
- package/dist/test/integration.test.js +35 -25
- package/dist/test/notes.test.js +45 -45
- package/package.json +1 -1
- package/playbook.md +4 -4
- package/src/dream/cli.ts +101 -14
- package/src/dream/doctor.ts +97 -0
- package/src/dream/gates.ts +12 -6
- package/src/dream/git.ts +59 -13
- package/src/dream/lock.ts +67 -24
- package/src/dream/runner.ts +12 -3
- package/src/notes/address.ts +4 -4
- package/src/notes/frontmatter.ts +2 -2
- package/src/notes/paths.ts +2 -2
- package/src/notes/store.ts +2 -2
- package/src/notes/tools.ts +1 -1
- package/src/prompts.ts +19 -11
- package/src/protocol.ts +7 -6
- package/src/thresholds.ts +34 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertVirtualPath } from "./model.js";
|
|
2
|
-
const ADDRESS_FORMS = "legal prefixes are @project/ and @
|
|
2
|
+
const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
|
|
3
3
|
/**
|
|
4
4
|
* Decode the one public note address into its physical home and virtual path. This is a
|
|
5
5
|
* tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
|
|
@@ -13,9 +13,9 @@ export function assertAddress(value) {
|
|
|
13
13
|
scope = "project";
|
|
14
14
|
path = value.slice("@project/".length);
|
|
15
15
|
}
|
|
16
|
-
else if (value.startsWith("@
|
|
17
|
-
scope = "
|
|
18
|
-
path = value.slice("@
|
|
16
|
+
else if (value.startsWith("@personal/")) {
|
|
17
|
+
scope = "personal";
|
|
18
|
+
path = value.slice("@personal/".length);
|
|
19
19
|
}
|
|
20
20
|
else if (value.startsWith("@")) {
|
|
21
21
|
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { localIso } from "./model.js";
|
|
2
|
-
const SCOPES = ["session", "project", "
|
|
2
|
+
const SCOPES = ["session", "project", "personal"];
|
|
3
3
|
const ORIGINS = ["user", "self", "external"];
|
|
4
4
|
const STATUSES = ["active", "superseded", "pending", "archived"];
|
|
5
5
|
const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
|
|
@@ -89,7 +89,7 @@ function parseFrontmatter(raw) {
|
|
|
89
89
|
export function parseNote(raw, now = Date.now()) {
|
|
90
90
|
const { fields, body } = parseFrontmatter(raw);
|
|
91
91
|
const meta = { ...fields };
|
|
92
|
-
meta.scope = isScope(meta.scope) ? meta.scope : "
|
|
92
|
+
meta.scope = isScope(meta.scope) ? meta.scope : "personal";
|
|
93
93
|
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
94
94
|
meta.status = isStatus(meta.status) ? meta.status : "active";
|
|
95
95
|
meta.stale = meta.stale === true;
|
package/dist/src/notes/paths.js
CHANGED
|
@@ -39,8 +39,8 @@ function sessionId(ctx) {
|
|
|
39
39
|
}
|
|
40
40
|
/** Absolute directory holding every note of one scope. */
|
|
41
41
|
export function scopeDir(scope, ctx) {
|
|
42
|
-
if (scope === "
|
|
43
|
-
return join(notesRoot(), "
|
|
42
|
+
if (scope === "personal")
|
|
43
|
+
return join(notesRoot(), "personal");
|
|
44
44
|
if (scope === "project")
|
|
45
45
|
return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
46
46
|
return join(sessionHomesRoot(), sessionId(ctx));
|
package/dist/src/notes/store.js
CHANGED
|
@@ -21,10 +21,10 @@ export class NoteError extends Error {
|
|
|
21
21
|
this.edit_index = extra.edit_index;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
const SCOPE_ORDER = ["session", "project", "
|
|
24
|
+
const SCOPE_ORDER = ["session", "project", "personal"];
|
|
25
25
|
function assertScope(value) {
|
|
26
26
|
if (!isScope(value))
|
|
27
|
-
throw new NoteError("invalid_scope", `scope must be one of session, project,
|
|
27
|
+
throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
|
|
28
28
|
return value;
|
|
29
29
|
}
|
|
30
30
|
function assertOrigin(value) {
|
package/dist/src/notes/tools.js
CHANGED
|
@@ -9,7 +9,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
|
|
|
9
9
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
10
10
|
description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
|
|
11
11
|
}));
|
|
12
|
-
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@
|
|
12
|
+
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
|
|
13
13
|
function wireMeta(meta) {
|
|
14
14
|
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
15
15
|
}
|
package/dist/src/prompts.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { historyFromSession } from "./history.js";
|
|
2
|
-
import { localIso } from "./notes/model.js";
|
|
3
2
|
import { listNotes } from "./notes/store.js";
|
|
4
|
-
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG,
|
|
3
|
+
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_PERSONAL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
|
|
5
4
|
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
6
5
|
function identityBlock(agentName, firstWindowId, currentWindowId, previousWindowId) {
|
|
7
6
|
const lines = [
|
|
@@ -13,20 +12,27 @@ function identityBlock(agentName, firstWindowId, currentWindowId, previousWindow
|
|
|
13
12
|
lines.push(`Previous context window id: ${previousWindowId}`);
|
|
14
13
|
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
15
14
|
}
|
|
15
|
+
function relativeTime(timestamp, now) {
|
|
16
|
+
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
17
|
+
const [unit, size] = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]]
|
|
18
|
+
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s");
|
|
19
|
+
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
20
|
+
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
21
|
+
}
|
|
16
22
|
/**
|
|
17
|
-
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the
|
|
23
|
+
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the personal and
|
|
18
24
|
* project homes are both injected, broadest first; stale maps are skipped per home, and the
|
|
19
25
|
* session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
|
|
20
26
|
* recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
|
|
21
|
-
*
|
|
22
|
-
* each: address, line count, UTF-8 byte count,
|
|
27
|
+
* POCKET_PERSONAL_LIMIT), most-recently-updated first within each home, one metadata line
|
|
28
|
+
* each: address, line count, UTF-8 byte count, relative update time at window open. Bodies never render
|
|
23
29
|
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
24
30
|
*/
|
|
25
31
|
function notesIndex(ctx) {
|
|
26
32
|
const sections = [];
|
|
27
33
|
// Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
|
|
28
34
|
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
29
|
-
for (const scope of ["
|
|
35
|
+
for (const scope of ["personal", "project"]) {
|
|
30
36
|
const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
|
|
31
37
|
if (toc && !toc.meta.stale) {
|
|
32
38
|
if (toc.body.length > 0)
|
|
@@ -34,23 +40,24 @@ function notesIndex(ctx) {
|
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
37
|
-
// churn from evicting project or
|
|
43
|
+
// churn from evicting project or personal notes; maps never take pocket seats.
|
|
38
44
|
const recentNotes = [
|
|
39
45
|
...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
40
46
|
...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
41
|
-
...listNotes(ctx, { scope: "
|
|
47
|
+
...listNotes(ctx, { scope: "personal" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PERSONAL_LIMIT),
|
|
42
48
|
];
|
|
43
49
|
if (recentNotes.length > 0) {
|
|
44
|
-
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${
|
|
50
|
+
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${POCKET_PERSONAL_LIMIT} from personal). A note's content never appears here, so its name has to say what the note is about:`];
|
|
51
|
+
const now = Date.now();
|
|
45
52
|
for (const row of recentNotes) {
|
|
46
|
-
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${
|
|
53
|
+
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, now)})`);
|
|
47
54
|
}
|
|
48
55
|
sections.push(lines.join("\n"));
|
|
49
56
|
}
|
|
50
57
|
return sections.join("\n\n");
|
|
51
58
|
}
|
|
52
59
|
function notesHomeBlock() {
|
|
53
|
-
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @
|
|
60
|
+
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @personal/<vpath> is the human's cross-project home. @ means leaving home; there is no cross-home fallback. Any other note is a plain file — use the file tools.";
|
|
54
61
|
}
|
|
55
62
|
/**
|
|
56
63
|
* Assemble the static, once-per-window boot block: the reset line for resets, the
|
package/dist/src/protocol.js
CHANGED
|
@@ -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
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@
|
|
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 project — architecture, 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.";
|
package/dist/src/thresholds.js
CHANGED
|
@@ -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
|
|
@@ -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
|
+
});
|