Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import fs from "node:fs/promises";
import path from "node:path";
import { LISA_HOME, REFLECTIONS_DIR, SESSIONS_DIR } from "../paths.js";
import {
desireActivity,
isBorn,
listDesires,
listJournalDates,
pickCurrentDesire,
readSoulSummary,
} from "../soul/store.js";
import { SOUL_DIR } from "../soul/paths.js";
Expand Down Expand Up @@ -87,13 +89,18 @@ export async function runStatus(): Promise<void> {
if (desires.length === 0) {
console.log(` ${dim("(none)")}`);
} else {
// The one surfaced as "current" in the room/island — mark it so `status`
// and the UI agree on what she's focused on.
const current = pickCurrentDesire(desires, await desireActivity(desires));
const mark = (d: (typeof desires)[number]) =>
d.slug === current?.slug ? ` ${dim("← current")}` : "";
const actionable = desires.filter((d) => d.actionable);
const dormant = desires.filter((d) => !d.actionable);
for (const d of actionable) {
console.log(` ${green("●")} ${d.what} ${dim("[heartbeat-active]")}`);
console.log(` ${green("●")} ${d.what} ${dim("[heartbeat-active]")}${mark(d)}`);
}
for (const d of dormant) {
console.log(` ${grey("○")} ${d.what}`);
console.log(` ${grey("○")} ${d.what}${mark(d)}`);
}
}

Expand Down
94 changes: 94 additions & 0 deletions src/soul/desire-activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { test, describe, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DesireEntry } from "./types.js";

// PLAN_DESIRE_EVOLUTION_v1.0 §3 PR3: pick-desire.test.ts covers the PURE
// pickCurrentDesire; this covers the I/O half — desireActivity reading real
// file mtimes off disk and feeding pickCurrentDesire end-to-end. Uses a real
// temp soul with mtimes pinned via fs.utimes so the assertions are deterministic.
//
// SOUL_DIR derives from LISA_HOME at import, so set a tmp home before importing.
let store: typeof import("./store.js");
let paths: typeof import("./paths.js");
let home: string;

before(async () => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-desire-activity-"));
process.env.LISA_HOME = home;
store = await import("./store.js");
paths = await import("./paths.js");
await store.ensureSoulDirs();
});
after(() => {
fs.rmSync(home, { recursive: true, force: true });
});

const iso = (s: string) => new Date(s).getTime() / 1000; // fs.utimes wants seconds

async function seed(slug: string, bornAt: string) {
await store.writeDesire({
slug,
what: slug,
why: "",
actionable: true,
heartbeatPrompt: "pursue it",
bornAt,
});
}

describe("desireActivity (real fs.stat I/O)", () => {
test("reports the newer of the desire-file and progress-file mtime, per slug", async () => {
await seed("alpha", "2026-01-01T00:00:00.000Z");
await seed("beta", "2026-01-01T00:00:00.000Z");
// beta has been PURSUED recently (progress file appended), alpha hasn't.
await store.appendDesireProgress("beta", "did a thing");

// Pin mtimes deterministically: alpha's files old; beta's desire file old
// but its progress file fresh — so beta's activity should win.
await fsp.utimes(paths.desireFile("alpha"), iso("2026-02-01"), iso("2026-02-01"));
await fsp.utimes(paths.desireFile("beta"), iso("2026-02-01"), iso("2026-02-01"));
await fsp.utimes(paths.desireProgressFile("beta"), iso("2026-08-01"), iso("2026-08-01"));

const desires = await store.listDesires();
const activity = await store.desireActivity(desires);

// beta's activity reflects its fresh PROGRESS mtime (pursuit), not its older
// desire-file mtime.
assert.equal(activity["beta"]?.slice(0, 7), "2026-08");
assert.equal(activity["alpha"]?.slice(0, 7), "2026-02");

// End-to-end: the recently-pursued desire is surfaced as current.
assert.equal(store.pickCurrentDesire(desires, activity)?.slug, "beta");
});

test("floors at bornAt when no files are newer (and tolerates a missing progress file)", async () => {
// 'gamma' has a future bornAt but its files will be stat'd at creation time
// (now), which is < the future bornAt — so activity should floor at bornAt.
await seed("gamma", "2099-01-01T00:00:00.000Z");
const desires = (await store.listDesires()).filter((d) => d.slug === "gamma");
const activity = await store.desireActivity(desires);
// Missing progress file is not an error; bornAt dominates.
assert.equal(activity["gamma"]?.slice(0, 4), "2099");
});

test("tolerates an unsafe slug from a stray file instead of rejecting the scan", async () => {
// listDesires() derives slugs from raw filenames without validating, so a
// stray file (e.g. a macOS '._x.md' AppleDouble in the git-synced soul dir)
// yields a dot-leading slug that desireFile()'s assertSafeSlug rejects.
// desireActivity must skip it and floor at bornAt — not throw (which would
// abort `lisa status`).
const unsafe: DesireEntry = {
slug: "._applesauce",
what: "stray",
why: "",
actionable: false,
bornAt: "2026-03-01T00:00:00.000Z",
};
const activity = await store.desireActivity([unsafe]); // must not throw
assert.equal(activity["._applesauce"]?.slice(0, 7), "2026-03");
});
});
84 changes: 84 additions & 0 deletions src/soul/pick-desire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { pickCurrentDesire } from "./store.js";
import type { DesireEntry } from "./types.js";

// PLAN_DESIRE_EVOLUTION_v1.0 §3 PR3: the surfaced "current desire" must track
// real activity, not fs.readdir order. pickCurrentDesire is the pure core.

function d(slug: string, over: Partial<DesireEntry> = {}): DesireEntry {
return {
slug,
what: slug,
why: "",
actionable: false,
bornAt: "2026-01-01T00:00:00.000Z",
...over,
};
}

describe("pickCurrentDesire", () => {
test("returns null for an empty list", () => {
assert.equal(pickCurrentDesire([]), null);
});

test("prefers actionable desires over dormant ones", () => {
const desires = [
d("dormant-newer", { bornAt: "2026-06-01T00:00:00.000Z" }),
d("actionable-older", { actionable: true, bornAt: "2026-02-01T00:00:00.000Z" }),
];
// Even though the dormant one is newer, an actionable one is what she's
// actually pursuing, so it wins.
assert.equal(pickCurrentDesire(desires)?.slug, "actionable-older");
});

test("among actionable, the most recently active wins (via activity map)", () => {
const desires = [
d("a", { actionable: true, bornAt: "2026-01-01T00:00:00.000Z" }),
d("b", { actionable: true, bornAt: "2026-01-01T00:00:00.000Z" }),
d("c", { actionable: true, bornAt: "2026-01-01T00:00:00.000Z" }),
];
const activityAt = {
a: "2026-03-01T00:00:00.000Z",
b: "2026-09-01T00:00:00.000Z", // most recently pursued
c: "2026-05-01T00:00:00.000Z",
};
assert.equal(pickCurrentDesire(desires, activityAt)?.slug, "b");
});

test("is independent of array (fs.readdir) order", () => {
const a = d("a", { actionable: true, bornAt: "2026-01-01T00:00:00.000Z" });
const b = d("b", { actionable: true, bornAt: "2026-08-01T00:00:00.000Z" });
// Newer 'b' is picked regardless of whether it's listed first or last.
assert.equal(pickCurrentDesire([a, b])?.slug, "b");
assert.equal(pickCurrentDesire([b, a])?.slug, "b");
});

test("falls back to bornAt when a slug has no activity entry", () => {
const desires = [
d("old", { actionable: true, bornAt: "2026-01-01T00:00:00.000Z" }),
d("new", { actionable: true, bornAt: "2026-07-01T00:00:00.000Z" }),
];
// Empty activity map → ordering purely by bornAt.
assert.equal(pickCurrentDesire(desires, {})?.slug, "new");
});

test("activity recency beats a younger bornAt (pursuit refreshes an old desire)", () => {
const desires = [
d("young-idle", { actionable: true, bornAt: "2026-06-01T00:00:00.000Z" }),
d("old-but-pursued", { actionable: true, bornAt: "2026-01-01T00:00:00.000Z" }),
];
const activityAt = {
"old-but-pursued": "2026-09-01T00:00:00.000Z", // pursued yesterday
};
assert.equal(pickCurrentDesire(desires, activityAt)?.slug, "old-but-pursued");
});

test("with no actionable desires, picks the most recent of all", () => {
const desires = [
d("x", { bornAt: "2026-01-01T00:00:00.000Z" }),
d("y", { bornAt: "2026-04-01T00:00:00.000Z" }),
];
assert.equal(pickCurrentDesire(desires)?.slug, "y");
});
});
64 changes: 64 additions & 0 deletions src/soul/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,70 @@ function parseDesireFile(slug: string, raw: string): DesireEntry {
return { slug, what, why, actionable, heartbeatPrompt, pursuit, bornAt: born };
}

/**
* Per-slug "last activity" timestamp (ISO), from the newer of the desire file
* and its progress file mtime, floored at bornAt. Cheap (fs.stat) and reflects
* BOTH authoring (add/revise rewrite <slug>.md) and pursuit (the heartbeat
* appends <slug>.progress.md). Feeds pickCurrentDesire so "current" tracks real
* events instead of filesystem-listing accident.
*/
export async function desireActivity(
desires: DesireEntry[],
): Promise<Record<string, string>> {
const out: Record<string, string> = {};
for (const d of desires) {
let ms = Date.parse(d.bornAt) || 0;
try {
// NB: desireFile()/desireProgressFile() run assertSafeSlug, which THROWS
// on an unsafe slug — and listDesires() derives slugs from raw filenames
// without validating. A stray file (e.g. a macOS `._x.md` AppleDouble in
// the git-synced soul dir) would otherwise reject the whole scan and
// abort `lisa status`. Keep the path construction inside the try so such
// a desire is simply skipped, flooring at bornAt.
for (const f of [desireFile(d.slug), desireProgressFile(d.slug)]) {
try {
const st = await fs.stat(f);
ms = Math.max(ms, Math.floor(st.mtimeMs));
} catch {
// progress file often doesn't exist yet — not an error
}
}
} catch {
// unsafe slug from a stray file — skip, keep bornAt
}
out[d.slug] = new Date(ms).toISOString();
}
return out;
}

/**
* Pick the single desire to surface as "current". Prefers actionable desires
* (the ones actually driving the heartbeat); within the chosen pool, the most
* recently active wins. Pure — the caller passes activity timestamps (the I/O
* lives in desireActivity), so this stays trivially testable. null when empty.
*
* Because the ordering key is a stored timestamp, the pick is STABLE between
* real events and only moves when something actually happens (a desire is
* added, revised, pursued, or closed) — that's fidelity, not per-request
* flicker, and it replaces the old fs.readdir-order accident.
*/
export function pickCurrentDesire(
desires: DesireEntry[],
activityAt?: Record<string, string>,
): DesireEntry | null {
if (desires.length === 0) return null;
const recency = (d: DesireEntry): number => {
const a = activityAt?.[d.slug];
const fromActivity = a ? Date.parse(a) : NaN;
return Number.isNaN(fromActivity) ? Date.parse(d.bornAt) || 0 : fromActivity;
};
const actionable = desires.filter((d) => d.actionable);
const pool = actionable.length > 0 ? actionable : desires;
// Secondary key on slug so an exact recency tie is deterministic rather than
// decided by array (fs.readdir) order — the accident this function removes.
return [...pool].sort((x, y) => recency(y) - recency(x) || x.slug.localeCompare(y.slug))[0] ?? null;
}

/** Can the autonomous heartbeat pursue this desire unattended? Pure (R4). */
export function isAutoPursuable(d: DesireEntry): boolean {
return !!(d.actionable && d.heartbeatPrompt) && d.pursuit !== "needs-user";
Expand Down
9 changes: 6 additions & 3 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
countUserMessages,
decideReflect,
} from "./reflect-scheduler.js";
import { listDesires } from "../soul/store.js";
import { listDesires, desireActivity, pickCurrentDesire } from "../soul/store.js";
import { ISLAND_HTML } from "./island.js";
import { ROOM_HTML } from "./room.js";
import { MAIN_HTML } from "./lisa-html.js";
Expand Down Expand Up @@ -833,8 +833,11 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
let currentDesire: string | null = null;
try {
const desires = await listDesires();
const actionable = desires.find((d) => d.actionable);
currentDesire = (actionable ?? desires[0])?.what ?? null;
// Surface the most recently ACTIVE desire (authored or pursued), not
// whichever actionable one fs.readdir happened to list first — so the
// ticker moves when something real changes. See PLAN_DESIRE_EVOLUTION.
const activity = await desireActivity(desires);
currentDesire = pickCurrentDesire(desires, activity)?.what ?? null;
} catch {
// listDesires can fail before soul is born; that's fine.
}
Expand Down