sphica 0.6.3 → 0.6.5

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/dist/mcp.js CHANGED
@@ -45726,7 +45726,7 @@ async function liveSuccessors(db, id) {
45726
45726
  async function judgeUnits(db, rows, wanted, hits) {
45727
45727
  const ids = rows.map((r) => r.id);
45728
45728
  const [options, anchors, aliases] = ids.length ? await Promise.all([
45729
- db.selectFrom("unit_option").select(["unit_id", "text", "outcome", "why"]).where("unit_id", "in", ids).orderBy("position").execute(),
45729
+ db.selectFrom("unit_option").select(["unit_id", "text", "outcome", "why", "reconsider_when"]).where("unit_id", "in", ids).orderBy("position").execute(),
45730
45730
  db.selectFrom("unit_anchor").select(["unit_id", "path", "symbol", "role"]).where("unit_id", "in", ids).where("retired_at", "is", null).orderBy("id").execute(),
45731
45731
  db.selectFrom("unit_alias").select(["unit_id", "terms", "content_hash"]).where("unit_id", "in", ids).orderBy("id", "desc").execute()
45732
45732
  ]) : [[], [], []];
@@ -45740,7 +45740,7 @@ async function judgeUnits(db, rows, wanted, hits) {
45740
45740
  r.why,
45741
45741
  r.scope_note,
45742
45742
  r.revisit_when,
45743
- ...opts.flatMap((o) => [o.text, o.why]),
45743
+ ...opts.flatMap((o) => [o.text, o.why, o.reconsider_when]),
45744
45744
  ...anch.flatMap((a) => [a.path, a.symbol])
45745
45745
  ].filter(Boolean).join(`
45746
45746
  `)));
@@ -46000,7 +46000,7 @@ import os from "node:os";
46000
46000
  import path from "node:path";
46001
46001
  import { constants as C, DatabaseSync } from "node:sqlite";
46002
46002
  var SCHEMA_GENERATION = 2;
46003
- var SCHEMA_REVISION = 2;
46003
+ var SCHEMA_REVISION = 3;
46004
46004
  var sphicaHome = () => process.env.SPHICA_HOME || path.join(os.homedir(), ".sphica");
46005
46005
  var dbFile = () => process.env.SPHICA_DB || path.join(sphicaHome(), "sphica.db");
46006
46006
  function requireRuntime() {
@@ -46122,54 +46122,74 @@ var LIFECYCLES = ["candidate", "active", "superseded", "withdrawn"];
46122
46122
  var HOSTS = ["claude-code", "codex"];
46123
46123
  var sessionId = (projectId, host, externalId) => uuidFrom(String(projectId), host, externalId);
46124
46124
 
46125
- // server/src/plugin.ts
46126
- import fs2 from "node:fs";
46127
- import path2 from "node:path";
46128
- import { fileURLToPath } from "node:url";
46129
- var MANIFEST = path2.join(".claude-plugin", "plugin.json");
46130
- function versionAt(root) {
46131
- try {
46132
- const m = JSON.parse(fs2.readFileSync(path2.join(root, MANIFEST), "utf8"));
46133
- return m.name === "sphica" && typeof m.version === "string" ? m.version : null;
46134
- } catch {
46135
- return null;
46136
- }
46137
- }
46138
- var here = path2.dirname(fileURLToPath(import.meta.url));
46139
- var ROOT = [path2.join(here, ".."), path2.join(here, "..", "..", "plugin")].find((r) => versionAt(r) !== null) ?? path2.join(here, "..");
46140
- var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
46141
-
46142
- // server/src/project.ts
46143
- import { execFileSync } from "node:child_process";
46144
- import fs4 from "node:fs";
46125
+ // server/src/overview.ts
46145
46126
  import path4 from "node:path";
46146
46127
 
46147
46128
  // server/src/anchors.ts
46148
- import fs3 from "node:fs";
46149
- import path3 from "node:path";
46129
+ import fs2 from "node:fs";
46130
+ import path2 from "node:path";
46150
46131
  var MAX_BYTES = 2 * 1024 * 1024;
46151
- var leaves = (rel) => rel === ".." || rel.startsWith(`..${path3.sep}`) || path3.isAbsolute(rel);
46132
+ var leaves = (rel) => rel === ".." || rel.startsWith(`..${path2.sep}`) || path2.isAbsolute(rel);
46152
46133
  var literal3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46153
46134
  function readText(root, rel) {
46154
- const abs = path3.join(root, rel);
46155
- if (leaves(path3.relative(root, abs)))
46135
+ const abs = path2.join(root, rel);
46136
+ if (leaves(path2.relative(root, abs)))
46156
46137
  return;
46157
- const st = fs3.lstatSync(abs, { throwIfNoEntry: false });
46138
+ const st = fs2.lstatSync(abs, { throwIfNoEntry: false });
46158
46139
  if (!st)
46159
46140
  return null;
46160
46141
  if (!st.isFile() || st.size > MAX_BYTES)
46161
46142
  return;
46162
- const inside = path3.relative(fs3.realpathSync(root), fs3.realpathSync(abs));
46163
- if (leaves(inside))
46143
+ try {
46144
+ const inside = path2.relative(fs2.realpathSync(root), fs2.realpathSync(abs));
46145
+ if (leaves(inside))
46146
+ return;
46147
+ const buf = fs2.readFileSync(abs);
46148
+ return buf.includes(0) ? undefined : buf.toString("utf8");
46149
+ } catch {
46164
46150
  return;
46165
- const buf = fs3.readFileSync(abs);
46166
- return buf.includes(0) ? undefined : buf.toString("utf8");
46151
+ }
46167
46152
  }
46168
46153
  function findSymbol(text, symbol2) {
46169
46154
  const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`);
46170
46155
  const lines = text.split(/\r?\n/);
46171
46156
  return { lines, i: lines.findIndex((l) => re.test(l)) };
46172
46157
  }
46158
+ function fileState(root, rel) {
46159
+ if (!root)
46160
+ return "unknown";
46161
+ if (leaves(path2.relative(root, path2.join(root, rel))))
46162
+ return "unknown";
46163
+ let realRoot;
46164
+ try {
46165
+ realRoot = fs2.realpathSync(root);
46166
+ } catch {
46167
+ return "unknown";
46168
+ }
46169
+ let at = root;
46170
+ for (const part of rel.split("/")) {
46171
+ at = path2.join(at, part);
46172
+ let st;
46173
+ try {
46174
+ st = fs2.lstatSync(at, { throwIfNoEntry: false });
46175
+ } catch (e) {
46176
+ if (e.code === "ENOTDIR")
46177
+ return "gone";
46178
+ return "unknown";
46179
+ }
46180
+ if (!st)
46181
+ return "gone";
46182
+ if (st.isSymbolicLink()) {
46183
+ try {
46184
+ if (leaves(path2.relative(realRoot, fs2.realpathSync(at))))
46185
+ return "unknown";
46186
+ } catch {
46187
+ return "unknown";
46188
+ }
46189
+ }
46190
+ }
46191
+ return "present";
46192
+ }
46173
46193
  function checkAnchor(root, a) {
46174
46194
  if (!root)
46175
46195
  return { state: "unknown", line: null };
@@ -46189,91 +46209,21 @@ function checkAnchor(root, a) {
46189
46209
  };
46190
46210
  }
46191
46211
 
46192
- // server/src/project.ts
46193
- var localFile = () => path4.join(sphicaHome(), "projects.json");
46194
- var LOCAL_KEY = /^[a-z0-9][a-z0-9._-]*$/;
46195
- function normalizeRemote(url2) {
46196
- const raw = String(url2 ?? "").trim();
46197
- if (!raw)
46198
- return null;
46199
- const scp = raw.match(/^(?:[^@/]+@)?([^:/]+):(?!\/)(.+?)(?:\.git)?$/);
46200
- if (scp)
46201
- return `${scp[1]}/${scp[2]}`;
46202
- try {
46203
- const u = new URL(raw);
46204
- if (!u.hostname)
46205
- return null;
46206
- const p = u.pathname.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
46207
- return p ? `${u.hostname}/${p}` : u.hostname;
46208
- } catch {
46209
- return null;
46210
- }
46211
- }
46212
- var git = (dir, ...args) => {
46213
- try {
46214
- return execFileSync("git", ["-C", dir, ...args], {
46215
- encoding: "utf8",
46216
- stdio: ["ignore", "pipe", "ignore"],
46217
- timeout: 5000
46218
- }).trim();
46219
- } catch {
46220
- return null;
46221
- }
46222
- };
46223
- function localMap() {
46224
- let raw;
46225
- try {
46226
- raw = fs4.readFileSync(localFile(), "utf8");
46227
- } catch (e) {
46228
- if (e.code === "ENOENT")
46229
- return {};
46230
- throw e;
46231
- }
46232
- let m;
46233
- try {
46234
- m = JSON.parse(raw);
46235
- } catch {
46236
- m = null;
46237
- }
46238
- if (!m || typeof m !== "object" || Array.isArray(m) || Object.entries(m).some(([root, name]) => !path4.isAbsolute(root) || typeof name !== "string" || !LOCAL_KEY.test(name)))
46239
- throw new Error(`${localFile()} is not a valid JSON project table. Fix or delete it, then name the project again.`);
46240
- return m;
46241
- }
46242
- function identify(dir) {
46243
- const given = path4.resolve(dir);
46244
- const top = git(given, "rev-parse", "--show-toplevel");
46245
- const root = top || given;
46246
- const remote = top ? normalizeRemote(git(root, "remote", "get-url", "origin")) : null;
46247
- if (remote)
46248
- return { key: `git:${remote}`, root, name: remote.split("/").slice(1).join("/") || remote };
46249
- const map3 = localMap();
46250
- for (let d = root;; d = path4.dirname(d)) {
46251
- const local = map3[d];
46252
- if (local && LOCAL_KEY.test(local))
46253
- return { key: `local:${local}`, root: d, name: local };
46254
- if (top || path4.dirname(d) === d)
46255
- return null;
46256
- }
46257
- }
46258
- async function projectId(db, key) {
46259
- const r = await db.selectFrom("project").select("id").where("key", "=", key).executeTakeFirst();
46260
- return r?.id ?? null;
46261
- }
46262
-
46263
46212
  // server/src/read.ts
46213
+ var UNSUPPORTED = "unsupported: its owner quote was retracted or forgotten, so it is not the owner's condition";
46264
46214
  var cut = (text, start, end) => Buffer.from(text, "utf8").subarray(start, end).toString("utf8");
46265
46215
  var speaker = (s) => s.author_kind === "owner" ? "the owner" : s.author_kind === "assistant" ? "the assistant" : `${s.author_login ?? "someone"} (${s.author_association ?? s.author_kind})`;
46266
- async function readUnit(db, projectId2, ref, root, asOf) {
46216
+ async function readUnit(db, projectId, ref, root, asOf) {
46267
46217
  const byId = /^u([1-9][0-9]{0,15})$/.exec(ref);
46268
- const u = await db.selectFrom("unit").selectAll().where("project_id", "=", projectId2).where(byId ? "id" : "key", "=", byId ? Number(byId[1]) : ref).executeTakeFirst();
46269
- const bare = u ?? (byId ? undefined : await db.selectFrom("unit").selectAll().where("project_id", "=", projectId2).where("key", "in", ["trace:", "harvest:", "glean:"].map((o) => o + ref)).execute().then((rows) => rows.length === 1 ? rows[0] : undefined));
46218
+ const u = await db.selectFrom("unit").selectAll().where("project_id", "=", projectId).where(byId ? "id" : "key", "=", byId ? Number(byId[1]) : ref).executeTakeFirst();
46219
+ const bare = u ?? (byId ? undefined : await db.selectFrom("unit").selectAll().where("project_id", "=", projectId).where("key", "in", ["trace:", "harvest:", "glean:"].map((o) => o + ref)).execute().then((rows) => rows.length === 1 ? rows[0] : undefined));
46270
46220
  if (!bare || asOf && bare.created_at > asOf)
46271
46221
  return null;
46272
46222
  return describe3(db, bare, root, asOf);
46273
46223
  }
46274
46224
  async function describe3(db, u, root, asOf) {
46275
46225
  const [options, evidence, adoption, anchors, links, states] = await Promise.all([
46276
- db.selectFrom("unit_option").select(["id", "text", "outcome", "why"]).where("unit_id", "=", u.id).orderBy("position").execute(),
46226
+ db.selectFrom("unit_option").select(["id", "text", "outcome", "why", "reconsider_when"]).where("unit_id", "=", u.id).orderBy("position").execute(),
46277
46227
  db.selectFrom("unit_evidence as e").innerJoin("source as s", "s.id", "e.source_id").where("e.unit_id", "=", u.id).where("e.added_at", "<=", asOf ?? "9999").select([
46278
46228
  "e.option_id",
46279
46229
  "e.role",
@@ -46343,6 +46293,10 @@ async function describe3(db, u, root, asOf) {
46343
46293
  out.push("Options:");
46344
46294
  for (const o of options) {
46345
46295
  out.push(`- ${o.text}: ${o.outcome}${o.why ? `, because ${o.why}` : ""}`);
46296
+ if (o.reconsider_when) {
46297
+ const stands = evidence.some((e) => e.option_id === o.id && e.role === "reconsiders" && !e.retracted_at);
46298
+ out.push(` Reconsider when: ${inline(o.reconsider_when)}${stands ? " (the owner's words are quoted below)" : ` [${UNSUPPORTED}]`}`);
46299
+ }
46346
46300
  for (const e of evidence.filter((x) => x.option_id === o.id))
46347
46301
  out.push(quote(e));
46348
46302
  }
@@ -46375,11 +46329,11 @@ async function describe3(db, u, root, asOf) {
46375
46329
  return out.join(`
46376
46330
  `);
46377
46331
  }
46378
- async function readSource(db, projectId2, ref) {
46332
+ async function readSource(db, projectId, ref) {
46379
46333
  const m = /^s([1-9][0-9]{0,15})(?:@(\d{1,9}))?$/.exec(ref);
46380
46334
  if (!m)
46381
46335
  return null;
46382
- const s = await db.selectFrom("source").selectAll().where("project_id", "=", projectId2).where("id", "=", Number(m[1])).executeTakeFirst();
46336
+ const s = await db.selectFrom("source").selectAll().where("project_id", "=", projectId).where("id", "=", Number(m[1])).executeTakeFirst();
46383
46337
  if (!s)
46384
46338
  return null;
46385
46339
  return [
@@ -46399,6 +46353,405 @@ function part(id, text, from) {
46399
46353
  return end < all.length ? [shown, `(${all.length - end} more bytes; read s${id}@${end} for the rest)`] : [shown];
46400
46354
  }
46401
46355
 
46356
+ // server/src/rule-files.ts
46357
+ import fs3 from "node:fs";
46358
+ import path3 from "node:path";
46359
+
46360
+ // server/src/git.ts
46361
+ import { execFileSync } from "node:child_process";
46362
+ function cleanGit(root, args, max = 1024 * 1024) {
46363
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")));
46364
+ return execFileSync("git", ["-C", root, ...args], {
46365
+ env,
46366
+ maxBuffer: max,
46367
+ stdio: ["ignore", "pipe", "ignore"],
46368
+ timeout: 1e4
46369
+ });
46370
+ }
46371
+
46372
+ // server/src/rule-files.ts
46373
+ var RULE_LIMITS = { files: 200, bytes: 256 * 1024, depth: 8, entries: 5000 };
46374
+ var NAMES = new Set(["CLAUDE.md", "AGENTS.md", "AGENTS.override.md"]);
46375
+ var PATHSPECS = [...NAMES, ".claude/rules/**/*.md"].map((p) => `:(glob)**/${p}`);
46376
+ function isRuleFile(rel) {
46377
+ const parts = rel.split("/");
46378
+ const name = parts.at(-1) ?? "";
46379
+ if (NAMES.has(name))
46380
+ return true;
46381
+ const i = parts.findIndex((p, k) => p === ".claude" && parts[k + 1] === "rules");
46382
+ return i >= 0 && parts.length > i + 2 && name.endsWith(".md");
46383
+ }
46384
+ function ruleFiles(root) {
46385
+ const listed = gitList(root) ?? walk(root);
46386
+ const out = { files: [], skipped: 0, incomplete: listed.incomplete };
46387
+ const realRoot = fs3.realpathSync(root);
46388
+ for (const [i, rel] of [...new Set(listed.paths)].sort().entries()) {
46389
+ if (i >= RULE_LIMITS.files) {
46390
+ out.skipped++;
46391
+ continue;
46392
+ }
46393
+ const text = readBounded(root, realRoot, rel);
46394
+ if (text === null)
46395
+ continue;
46396
+ if (text === undefined)
46397
+ out.skipped++;
46398
+ else
46399
+ out.files.push({ path: rel, text });
46400
+ }
46401
+ return out;
46402
+ }
46403
+ function readBounded(root, realRoot, rel) {
46404
+ const abs = path3.join(root, rel);
46405
+ if (leaves(path3.relative(root, abs)))
46406
+ return;
46407
+ try {
46408
+ const st = fs3.lstatSync(abs, { throwIfNoEntry: false });
46409
+ if (!st)
46410
+ return null;
46411
+ if (!st.isFile() || st.size > RULE_LIMITS.bytes)
46412
+ return;
46413
+ if (leaves(path3.relative(realRoot, fs3.realpathSync(abs))))
46414
+ return;
46415
+ const buf = fs3.readFileSync(abs);
46416
+ return buf.length > RULE_LIMITS.bytes || buf.includes(0) ? undefined : buf.toString("utf8");
46417
+ } catch {
46418
+ return;
46419
+ }
46420
+ }
46421
+ function gitList(root) {
46422
+ try {
46423
+ if (cleanGit(root, ["rev-parse", "--is-inside-work-tree"]).toString("utf8").trim() !== "true")
46424
+ return null;
46425
+ } catch {
46426
+ return null;
46427
+ }
46428
+ try {
46429
+ const out = cleanGit(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...PATHSPECS], 16 * 1024 * 1024);
46430
+ return { paths: out.toString("utf8").split("\x00").filter(isRuleFile), incomplete: null };
46431
+ } catch {
46432
+ return { paths: [], incomplete: "git could not list the files" };
46433
+ }
46434
+ }
46435
+ function walk(root) {
46436
+ const paths = [];
46437
+ let entries = 0;
46438
+ let unread = 0;
46439
+ let tooDeep = false;
46440
+ let stopped = false;
46441
+ const visit2 = (rel, depth) => {
46442
+ if (stopped)
46443
+ return;
46444
+ let list;
46445
+ try {
46446
+ list = fs3.readdirSync(path3.join(root, rel), { withFileTypes: true });
46447
+ } catch {
46448
+ unread++;
46449
+ return;
46450
+ }
46451
+ for (const d of list) {
46452
+ if (++entries > RULE_LIMITS.entries) {
46453
+ stopped = true;
46454
+ return;
46455
+ }
46456
+ const child = rel ? `${rel}/${d.name}` : d.name;
46457
+ if ((d.isFile() || d.isSymbolicLink()) && isRuleFile(child))
46458
+ paths.push(child);
46459
+ else if (d.isDirectory() && d.name !== "node_modules" && (!d.name.startsWith(".") || d.name === ".claude")) {
46460
+ if (depth >= RULE_LIMITS.depth)
46461
+ tooDeep = true;
46462
+ else
46463
+ visit2(child, depth + 1);
46464
+ }
46465
+ }
46466
+ };
46467
+ visit2("", 1);
46468
+ const why = [
46469
+ ...stopped ? [`stopped after ${RULE_LIMITS.entries} directory entries`] : [],
46470
+ ...tooDeep ? [`did not look deeper than ${RULE_LIMITS.depth} directories`] : [],
46471
+ ...unread ? [`could not read ${unread} director${unread === 1 ? "y" : "ies"}`] : []
46472
+ ];
46473
+ return { paths, incomplete: why.length ? why.join("; ") : null };
46474
+ }
46475
+
46476
+ // server/src/overview.ts
46477
+ var OVERVIEW_LIMITS = { records: 50, key: 200, text: 300, paths: 520, heading: 120 };
46478
+ var PROJECT_WIDE = "Project-wide (no code location)";
46479
+ function pathList(paths) {
46480
+ const all = paths.map(inline);
46481
+ const out = [];
46482
+ let used = 0;
46483
+ for (const p of all) {
46484
+ if (used + Buffer.byteLength(p) + 2 > OVERVIEW_LIMITS.paths)
46485
+ break;
46486
+ out.push(p);
46487
+ used += Buffer.byteLength(p) + 2;
46488
+ }
46489
+ if (!out.length)
46490
+ out.push(head(all[0] ?? "", OVERVIEW_LIMITS.paths));
46491
+ return `${out.join(", ")}${all.length > out.length ? ` (+${all.length - out.length} more)` : ""}`;
46492
+ }
46493
+ async function liveOverview(db, projectId, after) {
46494
+ const live = db.selectFrom("unit").where("project_id", "=", projectId).where("lifecycle", "=", "active").where("kind", "in", ["decision", "constraint"]);
46495
+ const [total, rows] = await Promise.all([
46496
+ live.select((eb) => eb.fn.countAll().as("n")).executeTakeFirst(),
46497
+ live.select(["id", "key", "kind", "stance", "text"]).where("id", ">", after ?? 0).orderBy("id").limit(OVERVIEW_LIMITS.records + 1).execute()
46498
+ ]);
46499
+ const anchors = rows.length ? await db.selectFrom("unit_anchor").select(["unit_id", "path"]).where("unit_id", "in", rows.map((r) => r.id)).where("role", "=", "applies_to").where("retired_at", "is", null).orderBy("id").execute() : [];
46500
+ const shown = [];
46501
+ for (const r of rows.slice(0, OVERVIEW_LIMITS.records)) {
46502
+ const paths = [...new Set(anchors.filter((a) => a.unit_id === r.id).map((a) => a.path))];
46503
+ const first = paths[0];
46504
+ const dir = first === undefined ? null : path4.posix.dirname(first);
46505
+ const line = `- ${head(inline(r.key), OVERVIEW_LIMITS.key)} (u${r.id}, ${r.kind}${r.stance ? ` ${r.stance}` : ""}): ${head(inline(r.text), OVERVIEW_LIMITS.text)}${paths.length ? ` [${pathList(paths)}]` : ""}`;
46506
+ shown.push({
46507
+ id: r.id,
46508
+ group: dir === null ? PROJECT_WIDE : dir === "." ? "(repository root)" : `${dir}/`,
46509
+ line
46510
+ });
46511
+ }
46512
+ const n = Number(total?.n ?? 0);
46513
+ if (!shown.length)
46514
+ return after === null ? "No active decision or constraint is recorded for this project. status says whether sessions are still untraced." : `No active decision or constraint after id ${after}. ${n} in all.`;
46515
+ const groups = [...new Set(shown.map((s) => s.group))].sort((a, b) => a === PROJECT_WIDE ? 1 : b === PROJECT_WIDE ? -1 : a.localeCompare(b));
46516
+ const last = shown.at(-1)?.id ?? 0;
46517
+ const more = rows.length > shown.length;
46518
+ return [
46519
+ ...groups.flatMap((g) => [
46520
+ `## ${head(inline(g), OVERVIEW_LIMITS.heading)}`,
46521
+ ...shown.filter((s) => s.group === g).map((s) => s.line),
46522
+ ""
46523
+ ]),
46524
+ `${shown.length} shown of ${n} active decisions and constraints${after === null ? "" : ` (ids after ${after})`}.`,
46525
+ more ? `More follow: call overview again with after: ${last}. Pages are read at different times: a record that became active in between, with a lower id, is not on a later page.` : "That is the end of the list.",
46526
+ "Read a record by its key or u<id> before relying on it."
46527
+ ].join(`
46528
+ `);
46529
+ }
46530
+ var LOOK_LIMITS = { anchors: 2000, lines: 50, line: 2200, bytes: 56 * 1024 };
46531
+ var MARKER = /<!--\s*sphica:\s*((?:trace|harvest|glean):[^\s>]{1,1000})\s*-->/g;
46532
+ async function lookOverview(db, projectId, root) {
46533
+ const notChecked = [];
46534
+ const sections = [];
46535
+ let used = 0;
46536
+ const section = (title, lines, empty) => {
46537
+ const shown = [];
46538
+ for (const line of lines.slice(0, LOOK_LIMITS.lines).map((l) => head(l, LOOK_LIMITS.line))) {
46539
+ if (used + bytes(line) + 1 > LOOK_LIMITS.bytes)
46540
+ break;
46541
+ used += bytes(line) + 1;
46542
+ shown.push(line);
46543
+ }
46544
+ sections.push([
46545
+ `## ${title}`,
46546
+ ...shown.length || lines.length ? shown : [empty],
46547
+ ...lines.length > shown.length ? [`(${lines.length - shown.length} more not shown: deal with these first, then ask again)`] : []
46548
+ ].join(`
46549
+ `));
46550
+ };
46551
+ const live = db.selectFrom("unit_anchor as a").innerJoin("unit as u", "u.id", "a.unit_id").where("u.project_id", "=", projectId).where("u.lifecycle", "=", "active").where("a.retired_at", "is", null);
46552
+ const [anchors, total] = await Promise.all([
46553
+ live.select(["u.key", "u.kind", "a.path", "a.symbol", "a.line_start", "a.role"]).orderBy("a.id").limit(LOOK_LIMITS.anchors).execute(),
46554
+ live.select((eb) => eb.fn.countAll().as("n")).executeTakeFirst()
46555
+ ]);
46556
+ const gone = [];
46557
+ const lost = [];
46558
+ if (!root)
46559
+ notChecked.push("code locations: no working tree for this project here");
46560
+ else {
46561
+ let unknown3 = 0;
46562
+ let unscanned = 0;
46563
+ for (const a of anchors) {
46564
+ const where = `- ${inline(a.key)} (${a.kind}): ${inline(a.path)} (${a.role})`;
46565
+ const file2 = fileState(root, a.path);
46566
+ if (file2 === "gone")
46567
+ gone.push(where);
46568
+ else if (file2 === "unknown")
46569
+ unknown3++;
46570
+ else if (a.symbol) {
46571
+ const state = checkAnchor(root, a).state;
46572
+ if (state === "missing")
46573
+ lost.push(`- ${inline(a.key)} (${a.kind}): ${inline(a.symbol)} in ${inline(a.path)} (${a.role})`);
46574
+ else if (state === "unknown")
46575
+ unscanned++;
46576
+ }
46577
+ }
46578
+ if (unknown3)
46579
+ notChecked.push(`${unknown3} code locations that lead outside the repository or cannot be followed`);
46580
+ if (unscanned)
46581
+ notChecked.push(`${unscanned} code locations whose file could not be scanned for the symbol (too large, binary, or unreadable)`);
46582
+ const past = Number(total?.n ?? 0) - anchors.length;
46583
+ if (past > 0)
46584
+ notChecked.push(`${past} code locations past the first ${LOOK_LIMITS.anchors} (by age)`);
46585
+ }
46586
+ section("Files gone", gone, root ? "none" : "not checked");
46587
+ section("Symbol not found (the file is still there)", lost, root ? "none" : "not checked");
46588
+ const [options, deferred] = await Promise.all([
46589
+ db.selectFrom("unit_option as o").innerJoin("unit as u", "u.id", "o.unit_id").where("u.project_id", "=", projectId).where("u.lifecycle", "=", "active").where("o.reconsider_when", "is not", null).select((eb) => [
46590
+ "u.key",
46591
+ "o.text",
46592
+ "o.reconsider_when",
46593
+ eb.exists(eb.selectFrom("unit_evidence as e").innerJoin("source as s", "s.id", "e.source_id").whereRef("e.option_id", "=", "o.id").where("e.role", "=", "reconsiders").where("e.retracted_at", "is", null).where("s.author_kind", "=", "owner").select("e.id")).as("stands")
46594
+ ]).orderBy("o.id").execute(),
46595
+ db.selectFrom("unit").where("project_id", "=", projectId).where("lifecycle", "=", "active").where("revisit_when", "is not", null).select(["key", "text", "revisit_when"]).orderBy("id").execute()
46596
+ ]);
46597
+ section("Conditions to reconsider (judge whether one has come about; nothing here is decided)", [
46598
+ ...options.map((o) => `- ${inline(o.key)}: rejected option ${inline(head(o.text, 120))}, reconsider when: ${inline(head(o.reconsider_when ?? "", 300))}${o.stands ? "" : ` [${UNSUPPORTED}]`}`),
46599
+ ...deferred.map((d) => `- ${inline(d.key)}: deferred ${inline(head(d.text, 120))}, revisit when: ${inline(head(d.revisit_when ?? "", 300))}`)
46600
+ ], "none");
46601
+ const marked = [];
46602
+ if (!root)
46603
+ notChecked.push("instruction files: no working tree for this project here");
46604
+ else {
46605
+ const scan = ruleFiles(root);
46606
+ const found = [];
46607
+ for (const f of scan.files)
46608
+ for (const [i, text] of f.text.split(/\r?\n/).entries())
46609
+ for (const m of text.matchAll(MARKER))
46610
+ found.push({ file: f.path, line: i + 1, key: m[1] ?? "" });
46611
+ const keys = [...new Set(found.map((f) => f.key))];
46612
+ const units = new Map;
46613
+ for (let i = 0;i < keys.length; i += 500)
46614
+ for (const u of await db.selectFrom("unit").select(["id", "key", "lifecycle"]).where("project_id", "=", projectId).where("key", "in", keys.slice(i, i + 500)).execute())
46615
+ units.set(u.key, u);
46616
+ const chains = new Map;
46617
+ for (const f of found) {
46618
+ const u = units.get(f.key);
46619
+ const where = `- ${inline(f.file)}:${f.line}: ${inline(f.key)}`;
46620
+ if (!u)
46621
+ marked.push(`${where} is not a record of this project`);
46622
+ else if (u.lifecycle === "withdrawn")
46623
+ marked.push(`${where} was withdrawn`);
46624
+ else if (u.lifecycle === "superseded") {
46625
+ let next = chains.get(u.id);
46626
+ if (next === undefined) {
46627
+ next = await successor(db, u.id);
46628
+ chains.set(u.id, next);
46629
+ }
46630
+ marked.push(`${where} was superseded${next ? ` by ${inline(next.key)}${next.lifecycle === "active" ? "" : `, which is ${next.lifecycle} too`}` : ""}`);
46631
+ }
46632
+ }
46633
+ if (scan.skipped)
46634
+ notChecked.push(`${scan.skipped} instruction files not read (over the caps, not regular files, or outside the repository)`);
46635
+ if (scan.incomplete)
46636
+ notChecked.push(`instruction files: the listing ${scan.incomplete}`);
46637
+ }
46638
+ section("Rule markers whose record changed", marked, root ? "none" : "not checked");
46639
+ return [
46640
+ ...sections,
46641
+ `## Not checked
46642
+ ${notChecked.length ? notChecked.map((n) => `- ${n}`).join(`
46643
+ `) : "nothing: every place above was checked"}`,
46644
+ "Read a record by its key before acting on it. Change a record only through /sphica:trace, with the owner's words."
46645
+ ].join(`
46646
+
46647
+ `);
46648
+ }
46649
+ async function successor(db, id) {
46650
+ let end = null;
46651
+ let at = id;
46652
+ for (;; ) {
46653
+ const next = await db.selectFrom("unit_link as l").innerJoin("unit as u", "u.id", "l.from_unit").where("l.to_unit", "=", at).where("l.kind", "=", "supersedes").select(["u.id", "u.key", "u.lifecycle"]).executeTakeFirst();
46654
+ if (!next)
46655
+ break;
46656
+ end = { key: next.key, lifecycle: next.lifecycle };
46657
+ if (next.lifecycle !== "superseded")
46658
+ break;
46659
+ at = next.id;
46660
+ }
46661
+ return end;
46662
+ }
46663
+
46664
+ // server/src/plugin.ts
46665
+ import fs4 from "node:fs";
46666
+ import path5 from "node:path";
46667
+ import { fileURLToPath } from "node:url";
46668
+ var MANIFEST = path5.join(".claude-plugin", "plugin.json");
46669
+ function versionAt(root) {
46670
+ try {
46671
+ const m = JSON.parse(fs4.readFileSync(path5.join(root, MANIFEST), "utf8"));
46672
+ return m.name === "sphica" && typeof m.version === "string" ? m.version : null;
46673
+ } catch {
46674
+ return null;
46675
+ }
46676
+ }
46677
+ var here = path5.dirname(fileURLToPath(import.meta.url));
46678
+ var ROOT = [path5.join(here, ".."), path5.join(here, "..", "..", "plugin")].find((r) => versionAt(r) !== null) ?? path5.join(here, "..");
46679
+ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
46680
+
46681
+ // server/src/project.ts
46682
+ import { execFileSync as execFileSync2 } from "node:child_process";
46683
+ import fs5 from "node:fs";
46684
+ import path6 from "node:path";
46685
+ var localFile = () => path6.join(sphicaHome(), "projects.json");
46686
+ var LOCAL_KEY = /^[a-z0-9][a-z0-9._-]*$/;
46687
+ function normalizeRemote(url2) {
46688
+ const raw = String(url2 ?? "").trim();
46689
+ if (!raw)
46690
+ return null;
46691
+ const scp = raw.match(/^(?:[^@/]+@)?([^:/]+):(?!\/)(.+?)(?:\.git)?$/);
46692
+ if (scp)
46693
+ return `${scp[1]}/${scp[2]}`;
46694
+ try {
46695
+ const u = new URL(raw);
46696
+ if (!u.hostname)
46697
+ return null;
46698
+ const p = u.pathname.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
46699
+ return p ? `${u.hostname}/${p}` : u.hostname;
46700
+ } catch {
46701
+ return null;
46702
+ }
46703
+ }
46704
+ var git = (dir, ...args) => {
46705
+ try {
46706
+ return execFileSync2("git", ["-C", dir, ...args], {
46707
+ encoding: "utf8",
46708
+ stdio: ["ignore", "pipe", "ignore"],
46709
+ timeout: 5000
46710
+ }).trim();
46711
+ } catch {
46712
+ return null;
46713
+ }
46714
+ };
46715
+ function localMap() {
46716
+ let raw;
46717
+ try {
46718
+ raw = fs5.readFileSync(localFile(), "utf8");
46719
+ } catch (e) {
46720
+ if (e.code === "ENOENT")
46721
+ return {};
46722
+ throw e;
46723
+ }
46724
+ let m;
46725
+ try {
46726
+ m = JSON.parse(raw);
46727
+ } catch {
46728
+ m = null;
46729
+ }
46730
+ if (!m || typeof m !== "object" || Array.isArray(m) || Object.entries(m).some(([root, name]) => !path6.isAbsolute(root) || typeof name !== "string" || !LOCAL_KEY.test(name)))
46731
+ throw new Error(`${localFile()} is not a valid JSON project table. Fix or delete it, then name the project again.`);
46732
+ return m;
46733
+ }
46734
+ function identify(dir) {
46735
+ const given = path6.resolve(dir);
46736
+ const top = git(given, "rev-parse", "--show-toplevel");
46737
+ const root = top || given;
46738
+ const remote = top ? normalizeRemote(git(root, "remote", "get-url", "origin")) : null;
46739
+ if (remote)
46740
+ return { key: `git:${remote}`, root, name: remote.split("/").slice(1).join("/") || remote };
46741
+ const map3 = localMap();
46742
+ for (let d = root;; d = path6.dirname(d)) {
46743
+ const local = map3[d];
46744
+ if (local && LOCAL_KEY.test(local))
46745
+ return { key: `local:${local}`, root: d, name: local };
46746
+ if (top || path6.dirname(d) === d)
46747
+ return null;
46748
+ }
46749
+ }
46750
+ async function projectId(db, key) {
46751
+ const r = await db.selectFrom("project").select("id").where("key", "=", key).executeTakeFirst();
46752
+ return r?.id ?? null;
46753
+ }
46754
+
46402
46755
  // server/src/review.ts
46403
46756
  var ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
46404
46757
  function gitPath(token, prefix) {
@@ -46428,11 +46781,11 @@ function gitPath(token, prefix) {
46428
46781
  }
46429
46782
  function parseDiff(text) {
46430
46783
  const files = [];
46431
- const at = (path5) => {
46432
- const found = files.find((f2) => f2.path === path5);
46784
+ const at = (path7) => {
46785
+ const found = files.find((f2) => f2.path === path7);
46433
46786
  if (found)
46434
46787
  return found;
46435
- const f = { path: path5, added: [], lines: [] };
46788
+ const f = { path: path7, added: [], lines: [] };
46436
46789
  files.push(f);
46437
46790
  return f;
46438
46791
  };
@@ -46579,19 +46932,37 @@ async function checkFindings(db, projectId2, files, raw) {
46579
46932
  return problems;
46580
46933
  }
46581
46934
 
46935
+ // server/src/trace.ts
46936
+ var PENDING_DAYS = 30;
46937
+ var pendingCutoff = (now) => new Date(now.getTime() - PENDING_DAYS * 86400000).toISOString();
46938
+ var untracedOwner = (eb) => eb.selectFrom("source as m").whereRef("m.session_id", "=", "s.id").where("m.author_kind", "=", "owner").where(({ not, exists, selectFrom }) => not(exists(selectFrom("source_processing as p").whereRef("p.source_id", "=", "m.id").select(sql`1`.as("x")))));
46939
+ var untracedSessions = (db, projectId2) => db.selectFrom("session as s").where("s.project_id", "=", projectId2).where((eb) => eb.exists(untracedOwner(eb).select(sql`1`.as("x")))).select((eb) => [
46940
+ "s.id",
46941
+ "s.host",
46942
+ "s.started_at",
46943
+ "s.branch",
46944
+ eb.selectFrom("source as o").whereRef("o.session_id", "=", "s.id").where("o.author_kind", "=", "owner").select((o) => o.fn.max("o.created_at").as("last")).as("last"),
46945
+ untracedOwner(eb).select((m) => m.fn.countAll().as("waiting")).as("waiting"),
46946
+ untracedOwner(eb).select((m) => m.fn.min("m.id").as("first")).as("first")
46947
+ ]);
46948
+
46582
46949
  // server/src/status.ts
46583
- async function pendingCount(db, projectId2) {
46584
- const r = await db.selectFrom("session as s").where("s.project_id", "=", projectId2).where((eb) => eb.exists(eb.selectFrom("source as m").whereRef("m.session_id", "=", "s.id").where("m.author_kind", "=", "owner").where((eb2) => eb2.not(eb2.exists(eb2.selectFrom("source_processing as p").whereRef("p.source_id", "=", "m.id").select(sql`1`.as("x"))))).select(sql`1`.as("x")))).select((eb) => eb.fn.countAll().as("n")).executeTakeFirst();
46585
- return Number(r?.n ?? 0);
46586
- }
46587
- async function coverage(db, projectId2) {
46950
+ async function pendingCount(db, projectId2, now = new Date) {
46951
+ const cutoff = pendingCutoff(now);
46952
+ const r = await db.selectFrom(untracedSessions(db, projectId2).as("w")).select([
46953
+ sql`count(case when w.last >= ${cutoff} then 1 end)`.as("recent"),
46954
+ sql`count(case when w.last < ${cutoff} then 1 end)`.as("older")
46955
+ ]).executeTakeFirst();
46956
+ return { recent: Number(r?.recent ?? 0), older: Number(r?.older ?? 0) };
46957
+ }
46958
+ async function coverage(db, projectId2, now) {
46588
46959
  const count = async (q) => Number((await q)?.n ?? 0);
46589
46960
  const units = (where) => count(where(unitBase()).select((eb) => eb.fn.countAll().as("n")).executeTakeFirst());
46590
46961
  const unitBase = () => db.selectFrom("unit").where("project_id", "=", projectId2);
46591
46962
  const processed = (outcome) => db.selectFrom("source_processing as p").innerJoin("source as m", "m.id", "p.source_id").where("m.project_id", "=", projectId2).where("p.outcome", "=", outcome).where("m.session_id", "is not", null).select("m.session_id");
46592
- const [sessions, pendingSessions, emptySessions, sources, active, candidates, quarantined, work] = await Promise.all([
46963
+ const [sessions, pending, emptySessions, sources, active, candidates, quarantined, work] = await Promise.all([
46593
46964
  count(db.selectFrom("session").where("project_id", "=", projectId2).select((eb) => eb.fn.countAll().as("n")).executeTakeFirst()),
46594
- pendingCount(db, projectId2),
46965
+ pendingCount(db, projectId2, now),
46595
46966
  count(db.selectFrom("session").where("id", "in", processed("no_unit")).where("id", "not in", processed("units")).select((eb) => eb.fn.countAll().as("n")).executeTakeFirst()),
46596
46967
  count(db.selectFrom("source").where("project_id", "=", projectId2).select((eb) => eb.fn.countAll().as("n")).executeTakeFirst()),
46597
46968
  units((q) => q.where("lifecycle", "=", "active")),
@@ -46599,15 +46970,21 @@ async function coverage(db, projectId2) {
46599
46970
  units((q) => q.where("extraction", "=", "quarantined")),
46600
46971
  db.selectFrom("work").where("project_id", "=", projectId2).where("status", "in", ["active", "blocked", "paused"]).orderBy("updated_at", "desc").select(["title", "current", "status"]).limit(5).execute()
46601
46972
  ]);
46602
- return { sessions, pendingSessions, emptySessions, sources, active, candidates, quarantined, work };
46973
+ return { sessions, pending, emptySessions, sources, active, candidates, quarantined, work };
46603
46974
  }
46604
- async function status(db, projectId2, name) {
46605
- const c = await coverage(db, projectId2);
46975
+ async function status(db, projectId2, name, now = new Date) {
46976
+ const c = await coverage(db, projectId2, now);
46606
46977
  const lines = [
46607
46978
  `${name}`,
46608
46979
  `Captured: ${plural2(c.sessions, "session")}, ${plural2(c.sources, "source")}.`,
46609
46980
  `Extracted: ${plural2(c.active, "active record")}, ${plural2(c.candidates, "candidate")} not active yet (waiting for adoption or evidence), ${plural2(c.quarantined, "quarantined record")}.`,
46610
- c.pendingSessions ? `${plural2(c.pendingSessions, "session")} not traced yet: their decisions exist only as captured text (run /sphica:trace pending).` : "Every captured session has been traced.",
46981
+ ...c.pending.recent ? [
46982
+ `${plural2(c.pending.recent, "session")} not traced yet: their decisions exist only as captured text (run /sphica:trace pending).`
46983
+ ] : [],
46984
+ ...c.pending.older ? [
46985
+ `${c.pending.older} older ${c.pending.older === 1 ? "session" : "sessions"} (last owner message over ${PENDING_DAYS} days ago) not traced; /sphica:trace pending lists them.`
46986
+ ] : [],
46987
+ ...c.pending.recent || c.pending.older ? [] : ["Every captured session has been traced."],
46611
46988
  ...c.emptySessions ? [`${plural2(c.emptySessions, "session")} traced with nothing to record.`] : [],
46612
46989
  c.work.length ? `Work in progress:
46613
46990
  ${framed(c.work.map((w) => `- ${head(inline(w.title), 200)} (${w.status}): ${head(inline(w.current), 500)}`).join(`
@@ -46775,6 +47152,25 @@ server.registerTool("read", {
46775
47152
  });
46776
47153
  var DIFF = exports_external.string().min(1).max(2000000).describe("The change under review as a unified diff (git diff output)");
46777
47154
  var notChecked = (e) => text(`Decision lane: not checked. Sphica unavailable: ${head(reason(e), 300)}. Report the decision check as not run, not as passed.`, true);
47155
+ server.registerTool("overview", {
47156
+ title: "Live decisions, and records that need a look",
47157
+ description: "On request, not before every change. view live lists every active decision and constraint of the project, grouped by the directory it " + "applies to, a page at a time (pass after from the previous page). view look lists live records whose code file is gone or whose symbol " + "is not found, written conditions for reconsidering an option (for you to judge, never applied), and lines in CLAUDE.md, AGENTS.md, or " + ".claude/rules marked <!-- sphica: key --> whose record was replaced or withdrawn. Read a record by its key before relying on it.",
47158
+ inputSchema: {
47159
+ view: exports_external.enum(["live", "look"]).describe("live: every active decision and constraint; look: records that need a look"),
47160
+ after: exports_external.number().int().min(0).optional().describe("With live: the id the previous page said to continue after"),
47161
+ cwd: CWD
47162
+ },
47163
+ annotations: READ_ONLY
47164
+ }, async (a) => {
47165
+ try {
47166
+ const p = await projectOf(a.cwd);
47167
+ if (typeof p === "string")
47168
+ return text(p);
47169
+ return text(framed(a.view === "live" ? await liveOverview(db, p.id, a.after ?? null) : await lookOverview(db, p.id, p.root)));
47170
+ } catch (e) {
47171
+ return text(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47172
+ }
47173
+ });
46778
47174
  server.registerTool("review_select", {
46779
47175
  title: "Past decisions a change touches",
46780
47176
  description: "For a code review: the active decisions, constraints, and implementation records this diff touches (records anchored to a changed path, " + "and records with no code location that forbid or defer an option an added line names). Judge each against the diff, then check the verdicts with review_check.",