@appchy/jarvis 0.1.95 → 0.1.97

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.
@@ -11,14 +11,72 @@ import {
11
11
  } from "./chunk-YWSWQEJN.mjs";
12
12
 
13
13
  // ../../packages/data/src/backends/graphify.ts
14
+ import { execFile as execFile2 } from "child_process";
15
+ import { access, readFile as readFile3, rename, rm, stat, utimes, writeFile as writeFile2 } from "fs/promises";
16
+ import { dirname as dirname2, resolve as resolve3 } from "path";
17
+ import { promisify as promisify2 } from "util";
18
+
19
+ // ../../packages/data/src/backends/changes.ts
14
20
  import { execFile } from "child_process";
15
- import { access, readFile as readFile2, rename, rm, stat, utimes, writeFile } from "fs/promises";
16
- import { dirname, resolve as resolve2 } from "path";
21
+ import { readFile, writeFile } from "fs/promises";
22
+ import { dirname, resolve } from "path";
17
23
  import { promisify } from "util";
24
+ var run = promisify(execFile);
25
+ var STAMP = "extracted-at";
26
+ async function git(root, argv) {
27
+ try {
28
+ const { stdout } = await run("git", argv, {
29
+ cwd: root,
30
+ timeout: 5e3,
31
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
32
+ });
33
+ return stdout.trim();
34
+ } catch {
35
+ return void 0;
36
+ }
37
+ }
38
+ function stampPath(graphPath) {
39
+ return resolve(dirname(graphPath), STAMP);
40
+ }
41
+ async function saveExtractedCommit(root, graphPath) {
42
+ const head = await git(root, ["rev-parse", "HEAD"]);
43
+ if (!head) return;
44
+ try {
45
+ await writeFile(stampPath(graphPath), `${head}
46
+ `, "utf8");
47
+ } catch {
48
+ }
49
+ }
50
+ async function loadExtractedCommit(graphPath) {
51
+ try {
52
+ const sha = (await readFile(stampPath(graphPath), "utf8")).trim();
53
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : void 0;
54
+ } catch {
55
+ return void 0;
56
+ }
57
+ }
58
+ async function changedSince(root, since) {
59
+ const committed = await git(root, ["diff", "--name-only", since, "HEAD"]);
60
+ if (committed === void 0) return void 0;
61
+ const working = await git(root, ["status", "--porcelain"]);
62
+ if (working === void 0) return void 0;
63
+ const staged = working.split("\n").filter((line) => line.trim().length > 0).flatMap((line) => line.slice(3).split(" -> "));
64
+ return [...committed.split("\n"), ...staged].map((path) => path.trim()).filter(Boolean);
65
+ }
66
+ async function indexedCodeChanged(root, graphPath, globs) {
67
+ if (!globs.include || globs.include.length === 0) return true;
68
+ const since = await loadExtractedCommit(graphPath);
69
+ if (!since) return true;
70
+ const changed = await changedSince(root, since);
71
+ if (changed === void 0) return true;
72
+ const included = matcher(globs.include);
73
+ const ignored = globs.ignore && globs.ignore.length > 0 ? matcher(globs.ignore) : () => false;
74
+ return changed.some((path) => included(path) && !ignored(path));
75
+ }
18
76
 
19
77
  // ../../packages/data/src/backends/imports.ts
20
- import { readFile } from "fs/promises";
21
- import { posix, resolve } from "path";
78
+ import { readFile as readFile2 } from "fs/promises";
79
+ import { posix, resolve as resolve2 } from "path";
22
80
  var IMPORT_RELATION_SET = new Set(IMPORT_RELATIONS);
23
81
  var asStr = (v) => typeof v === "string" ? v : void 0;
24
82
  var toPosix = (p) => p.replace(/\\/g, "/");
@@ -50,7 +108,7 @@ async function packageNameOf(cache, root, file) {
50
108
  return hit === NO_PACKAGE ? void 0 : hit;
51
109
  }
52
110
  pending.push(dir);
53
- const name = await readFile(resolve(root, dir, "package.json"), "utf8").then((text) => asStr(JSON.parse(text).name)).catch(() => void 0);
111
+ const name = await readFile2(resolve2(root, dir, "package.json"), "utf8").then((text) => asStr(JSON.parse(text).name)).catch(() => void 0);
54
112
  if (name !== void 0) {
55
113
  for (const d of pending) cache.set(d, name);
56
114
  return name;
@@ -65,7 +123,7 @@ async function packageNameOf(cache, root, file) {
65
123
  async function readLines(cache, absPath) {
66
124
  const hit = cache.get(absPath);
67
125
  if (hit !== void 0) return hit;
68
- const lines = await readFile(absPath, "utf8").then((text) => text.split(/\r?\n/)).catch(() => null);
126
+ const lines = await readFile2(absPath, "utf8").then((text) => text.split(/\r?\n/)).catch(() => null);
69
127
  cache.set(absPath, lines);
70
128
  return lines;
71
129
  }
@@ -83,7 +141,7 @@ async function resolveImportCollisions(raw, root) {
83
141
  const specsInFile = async (file) => {
84
142
  const hit = fileSpecCache.get(file);
85
143
  if (hit !== void 0 || fileSpecCache.has(file)) return hit;
86
- const lines = await readLines(lineCache, resolve(root, file));
144
+ const lines = await readLines(lineCache, resolve2(root, file));
87
145
  const specs = lines?.flatMap((l) => specifiersOf(l));
88
146
  fileSpecCache.set(file, specs);
89
147
  return specs;
@@ -125,7 +183,7 @@ async function resolveImportCollisions(raw, root) {
125
183
  const importer = toPosix(asStr(links[0]?.source_file) ?? "");
126
184
  const lineNo = lineNumberOf(asStr(links[0]?.source_location));
127
185
  if (!importer || lineNo === void 0) continue;
128
- const lines = await readLines(lineCache, resolve(root, importer));
186
+ const lines = await readLines(lineCache, resolve2(root, importer));
129
187
  const line = lines?.[lineNo - 1];
130
188
  if (!line) continue;
131
189
  const allSpecs = specifiersOf(line);
@@ -185,7 +243,7 @@ function communityLabelPrompt(communities) {
185
243
  }
186
244
 
187
245
  // ../../packages/data/src/backends/graphify.ts
188
- var run = promisify(execFile);
246
+ var run2 = promisify2(execFile2);
189
247
  var LABEL_TOP_K = 8;
190
248
  var LABEL_MAX_LEN = 80;
191
249
  var PLACEHOLDER_COMMUNITY = /^Community \d+$/;
@@ -200,8 +258,8 @@ function graphify(options = {}) {
200
258
  // saying so at the moment it is produced.
201
259
  ...options.staleness ? { staleness: options.staleness } : {},
202
260
  load: async (ctx) => {
203
- const root = resolve2(ctx.config.repoRoot, options.root ?? ".");
204
- const graphPath = resolve2(ctx.config.repoRoot, graphRel);
261
+ const root = resolve3(ctx.config.repoRoot, options.root ?? ".");
262
+ const graphPath = resolve3(ctx.config.repoRoot, graphRel);
205
263
  const bin = options.bin ?? "graphify";
206
264
  const forceExtract = process.env.JARVIS_DATA_REBUILD === "1";
207
265
  const refresh = process.env.JARVIS_DATA_REFRESH === "1" && options.extract !== false;
@@ -209,15 +267,27 @@ function graphify(options = {}) {
209
267
  const mode = forceExtract ? true : options.extract ?? "auto";
210
268
  const present = await exists(graphPath);
211
269
  const missing = options.missing !== void 0 ? { missing: options.missing } : {};
212
- const extracted = forceExtract && present ? await rebuildFresh(graphPath, () => extract(bin, root, ctx, graphRel, { ...missing })) : refresh ? await extract(bin, root, ctx, graphRel, { force: true, ...missing }) : mode === true || mode === "auto" && !present ? await extract(bin, root, ctx, graphRel, { ...missing }) : false;
213
- if (extracted) await stampFresh(graphPath);
270
+ const unmoved = refresh && present && !await indexedCodeChanged(root, graphPath, {
271
+ ...options.include ? { include: options.include } : {},
272
+ ...options.ignore ? { ignore: options.ignore } : {}
273
+ });
274
+ const extracted = forceExtract && present ? await rebuildFresh(graphPath, () => extract(bin, root, ctx, graphRel, { ...missing })) : unmoved ? false : refresh ? await extract(bin, root, ctx, graphRel, { force: true, ...missing }) : mode === true || mode === "auto" && !present ? await extract(bin, root, ctx, graphRel, { ...missing }) : false;
275
+ if (extracted) {
276
+ await stampFresh(graphPath);
277
+ await saveExtractedCommit(root, graphPath);
278
+ } else if (unmoved) {
279
+ ctx.logger.info(
280
+ "graphify: nothing it indexes changed \u2014 keeping the graph, skipping extract"
281
+ );
282
+ await stampFresh(graphPath);
283
+ }
214
284
  if (!await exists(graphPath)) {
215
285
  ctx.panic(
216
286
  `graphify graph not found at ${graphRel}. Run \`jarvis build graph\`, or set { extract: true } in the backend options.`
217
287
  );
218
288
  }
219
289
  if (options.staleness) await reportIfStale(ctx, graphPath, graphRel, options.staleness);
220
- const raw = JSON.parse(await readFile2(graphPath, "utf8"));
290
+ const raw = JSON.parse(await readFile3(graphPath, "utf8"));
221
291
  if (options.labeler && (forceLabel || extracted && !refresh)) {
222
292
  await labelCommunities(ctx, options.labeler, raw, graphPath, extracted);
223
293
  }
@@ -231,7 +301,7 @@ async function loadCommunityLabels(graphPath) {
231
301
  const labels = /* @__PURE__ */ new Map();
232
302
  const labelPath = communityLabelsPath(graphPath);
233
303
  try {
234
- const raw = JSON.parse(await readFile2(labelPath, "utf8"));
304
+ const raw = JSON.parse(await readFile3(labelPath, "utf8"));
235
305
  if (!raw || typeof raw !== "object") return labels;
236
306
  for (const [rawId, rawName] of Object.entries(raw)) {
237
307
  const name = asString(rawName);
@@ -289,7 +359,7 @@ function communityOf(n, labels) {
289
359
  return key ? `Community ${key}` : void 0;
290
360
  }
291
361
  function graphifyOptions(ctx, graphRel) {
292
- return { cwd: ctx.config.repoRoot, env: { ...process.env, GRAPHIFY_OUT: dirname(graphRel) } };
362
+ return { cwd: ctx.config.repoRoot, env: { ...process.env, GRAPHIFY_OUT: dirname2(graphRel) } };
293
363
  }
294
364
  async function extract(bin, root, ctx, graphRel, options = {}) {
295
365
  try {
@@ -297,7 +367,7 @@ async function extract(bin, root, ctx, graphRel, options = {}) {
297
367
  `graphify: extracting ${root}${options.force ? " (incremental \u2014 prune deletions)" : ""}`
298
368
  );
299
369
  const args = options.force ? ["update", "--force", root] : ["update", root];
300
- await run(bin, args, graphifyOptions(ctx, graphRel));
370
+ await run2(bin, args, graphifyOptions(ctx, graphRel));
301
371
  return true;
302
372
  } catch (err) {
303
373
  const code = err.code;
@@ -337,7 +407,7 @@ async function rebuildFresh(graphPath, extract2) {
337
407
  }
338
408
  async function parseable(path) {
339
409
  try {
340
- JSON.parse(await readFile2(path, "utf8"));
410
+ JSON.parse(await readFile3(path, "utf8"));
341
411
  return true;
342
412
  } catch {
343
413
  return false;
@@ -411,12 +481,12 @@ function sampleCommunities(raw) {
411
481
  return communities;
412
482
  }
413
483
  function communityLabelsPath(graphPath) {
414
- return resolve2(dirname(graphPath), "community-labels.json");
484
+ return resolve3(dirname2(graphPath), "community-labels.json");
415
485
  }
416
486
  async function readCommunityLabels(graphPath) {
417
487
  const out = /* @__PURE__ */ new Map();
418
488
  try {
419
- const existing = JSON.parse(await readFile2(communityLabelsPath(graphPath), "utf8"));
489
+ const existing = JSON.parse(await readFile3(communityLabelsPath(graphPath), "utf8"));
420
490
  for (const [id, value] of Object.entries(existing)) {
421
491
  const name = asString(value);
422
492
  if (name && !PLACEHOLDER_COMMUNITY.test(name)) out.set(id, name);
@@ -429,7 +499,7 @@ async function writeCommunityLabels(graphPath, named) {
429
499
  const merged = {};
430
500
  for (const [id, name] of await readCommunityLabels(graphPath)) merged[id] = name;
431
501
  for (const [id, name] of named) merged[id] = name;
432
- await writeFile(communityLabelsPath(graphPath), `${JSON.stringify(merged, null, 2)}
502
+ await writeFile2(communityLabelsPath(graphPath), `${JSON.stringify(merged, null, 2)}
433
503
  `, "utf8");
434
504
  }
435
505
  async function reportIfStale(ctx, graphPath, graphRel, severity) {
@@ -441,7 +511,7 @@ async function reportIfStale(ctx, graphPath, graphRel, severity) {
441
511
  }
442
512
  let headMs;
443
513
  try {
444
- const { stdout } = await run("git", ["show", "-s", "--format=%ct", "HEAD"], {
514
+ const { stdout } = await run2("git", ["show", "-s", "--format=%ct", "HEAD"], {
445
515
  cwd: ctx.config.repoRoot
446
516
  });
447
517
  const seconds = Number(stdout.trim());
package/dist/data/mcp.mjs CHANGED
@@ -215,6 +215,14 @@ async function freshness(ctx) {
215
215
  const builtMs = Date.parse(ctx.builtAt);
216
216
  let behindHead;
217
217
  if (head && mtimeMs) behindHead = mtimeMs < head.committedMs;
218
+ let behindHeadBy;
219
+ let behindHeadForMs;
220
+ if (behindHead && head && mtimeMs) {
221
+ behindHeadForMs = head.committedMs - mtimeMs;
222
+ const since = new Date(mtimeMs).toISOString();
223
+ const count = Number(await git(root, ["rev-list", "--count", `--since=${since}`, "HEAD"]));
224
+ if (Number.isFinite(count)) behindHeadBy = count;
225
+ }
218
226
  const replacedMs = Math.max(mtimeMs ?? 0, await snapshotMtimeMs(ctx) ?? 0);
219
227
  const rebuiltOnDisk = Boolean(
220
228
  replacedMs && Number.isFinite(builtMs) && replacedMs > builtMs + 1e3
@@ -223,27 +231,47 @@ async function freshness(ctx) {
223
231
  const behindOrigin = Boolean(origin?.reachable && (origin.behindBy ?? 0) > 0);
224
232
  const originUnknown = Boolean(origin && !origin.reachable);
225
233
  let reason;
226
- if (behindOrigin)
234
+ let verdict;
235
+ if (behindOrigin) {
236
+ verdict = "stale";
227
237
  reason = `this checkout is ${origin?.behindBy} commit(s) behind ${origin?.ref} \u2014 the map and the board both describe an older world than the origin's. Pull, then \`jarvis build graph\`.`;
228
- else if (originUnknown)
229
- reason = `cannot reach ${origin?.ref}, so I cannot tell whether this is current${origin?.lastSyncedIso ? ` \u2014 last synced at ${origin.lastSyncedIso}` : " and this clone has never fetched"}.`;
230
- else if (behindHead)
231
- reason = `${graphRel} is older than HEAD \u2014 the code graph is stale; run \`jarvis build graph\` (the incremental update \u2014 it re-extracts in place), then traces and impact are trustworthy again. \`--rebuild\` re-extracts from scratch and relabels; it is for a graph still wrong after a plain build, not for ordinary drift.`;
232
- else if (rebuiltOnDisk)
238
+ } else if (originUnknown) {
239
+ verdict = "unknown";
240
+ reason = `cannot reach ${origin?.ref}, so whether this is current is UNKNOWN rather than bad \u2014 rebuilding cannot answer it${origin?.lastSyncedIso ? `. Last heard from a remote at ${origin.lastSyncedIso}` : ", and this clone has never fetched"}.`;
241
+ } else if (behindHead) {
242
+ verdict = "stale";
243
+ reason = `${graphRel} is ${describeBehind(behindHeadBy, behindHeadForMs)} \u2014 run \`jarvis build graph\` (the incremental update \u2014 it re-extracts in place), then traces and impact are trustworthy again. \`--rebuild\` re-extracts from scratch and relabels; it is for a graph still wrong after a plain build, not for ordinary drift.`;
244
+ } else if (rebuiltOnDisk) {
245
+ verdict = "stale";
233
246
  reason = `${graphRel} was rebuilt after this MCP server loaded it \u2014 reloading automatically.`;
247
+ }
234
248
  return {
235
249
  graphPath: graphRel,
236
250
  ...mtimeIso ? { mtimeIso } : {},
237
251
  ...head ? { head: { commit: head.commit, dirty: head.dirty } } : {},
238
252
  ...behindHead !== void 0 ? { behindHead } : {},
253
+ ...behindHeadBy !== void 0 ? { behindHeadBy } : {},
254
+ ...behindHeadForMs !== void 0 ? { behindHeadForMs } : {},
239
255
  ...rebuiltOnDisk ? { rebuiltOnDisk } : {},
240
256
  ...origin ? { origin } : {},
241
257
  ...behindOrigin ? { behindOrigin } : {},
242
258
  ...originUnknown ? { originUnknown } : {},
243
259
  stale: Boolean(behindHead) || rebuiltOnDisk || behindOrigin || originUnknown,
260
+ ...verdict ? { verdict } : {},
244
261
  ...reason ? { reason } : {}
245
262
  };
246
263
  }
264
+ function describeBehind(commits, forMs) {
265
+ const age = forMs !== void 0 && forMs >= 60 * 60 * 1e3 ? describeAge(forMs) : void 0;
266
+ if (commits === void 0) return `older than HEAD${age ? ` by ${age}` : ""}`;
267
+ const plural = commits === 1 ? "commit" : "commits";
268
+ return `${commits} ${plural} behind HEAD${age ? `, built ${age} ago` : ""}`;
269
+ }
270
+ function describeAge(ms) {
271
+ const hours = Math.round(ms / (60 * 60 * 1e3));
272
+ if (hours < 48) return `${hours} hour(s)`;
273
+ return `${Math.round(hours / 24)} day(s)`;
274
+ }
247
275
  async function snapshotMtimeMs(ctx) {
248
276
  const path = ctx.config.persistence?.locate?.({ repoRoot: ctx.config.repoRoot }, "snapshot");
249
277
  if (!path) return void 0;
@@ -874,11 +902,13 @@ async function brief(ctx, req) {
874
902
  const areas = [...communityCounts.entries()].map(([name, size]) => ({ name, size })).sort((a, b) => b.size - a.size).slice(0, listCap);
875
903
  const inProgressCount = byCategory["in-progress"] ?? 0;
876
904
  return {
877
- summary: `${leaves.length} work item(s) \u2014 ${inProgressCount} in flight, ${byCategory["open"] ?? 0} open, ${byCategory["done"] ?? 0} done. ${artifacts} knowledge artifact(s); health: ${health.counts.error} error(s), ${health.counts.warn} warning(s)${f.stale ? " \u2014 GRAPH IS STALE, run `jarvis build graph` (incremental) before trusting details" : ""}${staleOrigins.length ? ` \u2014 ${staleOrigins.map((o) => o.origin).join(", ")} behind their own HEAD (re-run \`jarvis build graph\` there, then re-merge)` : ""}.`,
905
+ summary: `${leaves.length} work item(s) \u2014 ${inProgressCount} in flight, ${byCategory["open"] ?? 0} open, ${byCategory["done"] ?? 0} done. ${artifacts} knowledge artifact(s); health: ${health.counts.error} error(s), ${health.counts.warn} warning(s)${f.stale ? ` \u2014 ${describeFreshness(f)}` : ""}${staleOrigins.length ? ` \u2014 ${staleOrigins.map((o) => o.origin).join(", ")} behind their own HEAD (re-run \`jarvis build graph\` there, then re-merge)` : ""}.`,
878
906
  freshness: {
879
907
  builtAt: ctx.builtAt,
880
908
  ...f.head ? { head: { commit: f.head.commit, dirty: f.head.dirty } } : {},
881
909
  ...f.stale !== void 0 ? { stale: f.stale } : {},
910
+ ...f.verdict ? { verdict: f.verdict } : {},
911
+ ...f.behindHeadBy !== void 0 ? { behindHeadBy: f.behindHeadBy } : {},
882
912
  ...originsFresh.length ? { federated: originsFresh } : {}
883
913
  },
884
914
  graph: { nodes: ctx.graph.nodes().length, artifacts, edges: ctx.graph.edges().length },
@@ -920,6 +950,15 @@ function renderWorkItem(ctx, item, home) {
920
950
  ...home !== void 0 ? { belongsTo: home } : {}
921
951
  };
922
952
  }
953
+ function describeFreshness(f) {
954
+ if (f.verdict === "unknown")
955
+ return "FRESHNESS UNKNOWN \u2014 cannot reach the origin, so nothing below is confirmed current; rebuilding cannot answer it";
956
+ if (f.behindOrigin)
957
+ return `THIS CHECKOUT IS BEHIND ITS ORIGIN by ${f.origin?.behindBy} commit(s) \u2014 pull, then run \`jarvis build graph\``;
958
+ if (f.behindHead)
959
+ return `GRAPH IS STALE by ${f.behindHeadBy ?? "an unknown number of"} commit(s), run \`jarvis build graph\` (incremental) before trusting details`;
960
+ return "GRAPH IS STALE, run `jarvis build graph` (incremental) before trusting details";
961
+ }
923
962
 
924
963
  // ../../packages/data/src/resolve.ts
925
964
  function resolveTarget(ctx, input) {
@@ -2562,15 +2601,11 @@ function createServer(ctx, hosted = [], prompts = []) {
2562
2601
  );
2563
2602
  }
2564
2603
  for (const p of prompts) {
2565
- server.registerPrompt(
2566
- p.name,
2567
- { title: p.title, description: p.description },
2568
- async () => ({
2569
- messages: [
2570
- { role: "user", content: { type: "text", text: await p.read() } }
2571
- ]
2572
- })
2573
- );
2604
+ server.registerPrompt(p.name, { title: p.title, description: p.description }, async () => ({
2605
+ messages: [
2606
+ { role: "user", content: { type: "text", text: await p.read() } }
2607
+ ]
2608
+ }));
2574
2609
  }
2575
2610
  return server;
2576
2611
  }
@@ -2601,14 +2636,23 @@ function buildInstructions(req = {}) {
2601
2636
  }
2602
2637
  async function result(ctx, structured) {
2603
2638
  const f = await freshness(ctx);
2604
- const summary = f.stale && !/stale/i.test(structured.summary) ? `${STALE_PREFIX}${structured.summary}` : structured.summary;
2639
+ const summary = f.stale && !/stale/i.test(structured.summary) ? `${f.verdict === "unknown" ? UNKNOWN_PREFIX : STALE_PREFIX}${structured.summary}` : structured.summary;
2605
2640
  return {
2606
2641
  content: [{ type: "text", text: summary }],
2607
2642
  structuredContent: { ...structured, summary },
2608
- ...f.stale ? { _meta: { data: { stale: true, ...f.reason ? { reason: f.reason } : {} } } } : {}
2643
+ ...f.stale ? {
2644
+ _meta: {
2645
+ data: {
2646
+ stale: true,
2647
+ ...f.verdict ? { verdict: f.verdict } : {},
2648
+ ...f.reason ? { reason: f.reason } : {}
2649
+ }
2650
+ }
2651
+ } : {}
2609
2652
  };
2610
2653
  }
2611
2654
  var STALE_PREFIX = "\u26A0 STALE GRAPH \u2014 results may be wrong; run `jarvis build graph` (the incremental update \u2014 not --rebuild). ";
2655
+ var UNKNOWN_PREFIX = "\u26A0 FRESHNESS UNKNOWN \u2014 this clone cannot reach its origin, so it cannot tell whether these results are current. ";
2612
2656
 
2613
2657
  // ../../packages/data/src/mcp/snapshot.ts
2614
2658
  async function loadSnapshot(config) {
@@ -212,13 +212,81 @@ def cmd_epic_move(args) -> int:
212
212
  f"with {len(moved)} task(s)")
213
213
  _sync(root)
214
214
  return 0
215
+ #: Headings whose content is a STATEMENT ABOUT THE WORLD rather than a plan for one
216
+ #: piece of work — the parts of an epic that can outlive it, and therefore the parts
217
+ #: worth naming before they are deleted. Slice ordering, as-found and running logs are
218
+ #: deliberately absent: those are supposed to die with the epic.
219
+ _DURABLE_SECTIONS = ("## Governance this implies", "## Non-goals", "### Settled",
220
+ "### Forward-compat")
221
+
222
+
223
+ def _cost_of_removing(version, root) -> list:
224
+ """What this release is about to delete, as lines a person can act on.
225
+
226
+ **Said BEFORE the unlink, because the advice that follows a release is useless
227
+ after it.** `release` has always printed _"promote still-load-bearing decisions
228
+ to the domain that owns them"_ — and printed it after the files holding those
229
+ decisions had already left the tree. Measured 2026-09-11 on `01-one-board`: twelve
230
+ plans, 450,405 bytes, about a hundred durable statements with no owner file, and
231
+ the recovery commit had to be worked out by hand afterwards from `git log
232
+ --diff-filter=D`.
233
+
234
+ So this names the size, the sections that can outlive the plan, and the commit
235
+ where the text still lives. It does not judge whether a statement is durable —
236
+ that is the judgement a lint cannot make, and claiming it would be worse than
237
+ saying nothing.
238
+ """
239
+ plans = [e for e in version.epics if e.planned]
240
+ if not plans:
241
+ return []
242
+ out, total = [], 0
243
+ for e in plans:
244
+ try:
245
+ text = e.md.read_text()
246
+ except OSError: # pragma: no cover — defensive
247
+ continue
248
+ total += len(text.encode())
249
+ held = [h.split("## ")[-1].split("### ")[-1]
250
+ for h in _DURABLE_SECTIONS if h in text]
251
+ note = f" — holds §{', §'.join(held)}" if held else ""
252
+ out.append(f" {e.name} {len(text.encode()):,} bytes{note}")
253
+ head = _still_holds(root)
254
+ return ([f"\n DELETING {len(plans)} epic plan(s), {total:,} bytes. Anything "
255
+ f"durable in them must already be in the file that owns it — after this "
256
+ f"they are in git only, and nobody reads git for a decision:"]
257
+ + out
258
+ + ([f" the text stays recoverable at {head}"] if head else [])
259
+ + [" `git show <commit>:<path>` — and `jarvis work align` reports "
260
+ "what cites a file that is gone"])
261
+
262
+
263
+ def _still_holds(root) -> str:
264
+ """The commit whose tree still contains these plans, or empty when git cannot say.
265
+
266
+ HEAD is the right answer, and it is worth being precise about why: the unlink has
267
+ not happened yet and the board write commits after it, so at the moment this
268
+ prints, the newest commit in the history is one that still holds every file about
269
+ to go. Through the same seam every other git question goes through — a second way
270
+ of asking would eventually give a second answer.
271
+ """
272
+ from . import git
273
+ code, out, _ = git._git(root.parent, "rev-parse", "--short", "HEAD")
274
+ return out.strip() if code == 0 else ""
275
+
276
+
215
277
  def cmd_epic_release(root, version) -> int:
216
278
  """Remove every `epic.md` in a released version, and report it.
217
279
 
218
280
  An epic is TEMPORARY by definition: it is the plan-it-together doc, and how
219
281
  the work was planned stops being true the moment it ships. The folder stays
220
282
  as the grouping of what shipped — that record is worth keeping — and git
221
- holds the plan. Called from `cmd_release`, never on its own."""
283
+ holds the plan. Called from `cmd_release`, never on its own.
284
+
285
+ It says what it is deleting FIRST. The cost of a release is not the stamp, it is
286
+ the plans that go with it, and a person deciding needs that in front of them
287
+ while the files still exist."""
288
+ for line in _cost_of_removing(version, root):
289
+ print(line)
222
290
  removed = []
223
291
  for e in version.epics:
224
292
  # An epic with no plan doc is already in the shape this produces — a folder
@@ -198,7 +198,12 @@ def cmd_release(args) -> int:
198
198
  # how the work was planned stops being true once it ships.
199
199
  cmd_epic_release(root, version)
200
200
  print("\nDistill before archiving:")
201
- print(" 1. Promote still-load-bearing decisions to the domain or system that owns them")
201
+ # Points at the commit the deletion just named, because this instruction used to
202
+ # send a reader to files the same command had already removed — and the one time
203
+ # it mattered, the recovery commit had to be reconstructed afterwards with
204
+ # `git log --diff-filter=D`.
205
+ print(" 1. Promote still-load-bearing decisions to the domain or system that owns "
206
+ "them — read them from the commit named above; they are no longer in the tree")
202
207
  print(f" 2. Repoint any inbound deep-links to those {ids.LEDGER}-nn entries")
203
208
  print(f" 3. {cli()} archive {name} (strips each task to task.md, and "
204
209
  f"files a RELEASED cut under versions/complete/)")
@@ -7288,6 +7288,81 @@ def test_the_stop_hook_being_switched_off_silences_the_standing_too():
7288
7288
 
7289
7289
 
7290
7290
 
7291
+ def test_a_release_says_what_it_is_deleting_before_it_deletes_it():
7292
+ # `release` has always printed "promote still-load-bearing decisions to the
7293
+ # domain that owns them" — AFTER unlinking the files holding those decisions.
7294
+ # Measured on 01-one-board: twelve plans, 450,405 bytes, about a hundred durable
7295
+ # statements with no owner file, and the recovery commit reconstructed afterwards
7296
+ # by hand with `git log --diff-filter=D`. The cost of a release is the plans that
7297
+ # go with it, so it is named while they still exist.
7298
+ with tempfile.TemporaryDirectory() as tmp:
7299
+ try:
7300
+ repo = _git_repo(tmp, push=False)
7301
+ v = repo / "work" / "versions" / "09-a-cut"
7302
+ (v / "an-epic" / "complete" / "only-task").mkdir(parents=True)
7303
+ (v / "version.md").write_text(
7304
+ "---\ncreated: 2026-09-01\norder: 9\noutcome: a user can do it\n"
7305
+ "---\n\n# A cut\n")
7306
+ (v / "an-epic" / "epic.md").write_text(
7307
+ "---\ntype: epic\nowner: quality\n---\n\n# An epic\n\n## Plan\n\n"
7308
+ "Slice ordering, which is supposed to die with this file.\n\n"
7309
+ "## Governance this implies\n\n- quality/README.md — a rule is owed\n")
7310
+ (v / "an-epic" / "complete" / "only-task" / "task.md").write_text(
7311
+ "---\npriority: P0\ncompleted: 2026-09-02\n---\n\n# Only task\n\n"
7312
+ "## Acceptance criteria\n\n- [x] it works\n")
7313
+ _git(repo, "add", "-A")
7314
+ _git(repo, "commit", "-qm", "the board")
7315
+ head = _git(repo, "rev-parse", "--short", "HEAD").stdout.strip()
7316
+
7317
+ with _work_dir(str(repo / "work")):
7318
+ said = _capture_stdout(lambda: version.cmd_release({"name": "09-a-cut"}))
7319
+
7320
+ # The cost, in front of the person, in units they can weigh.
7321
+ assert "DELETING 1 epic plan(s)" in said, said
7322
+ assert "bytes" in said
7323
+ # WHICH sections can outlive the plan — and not the ones that cannot.
7324
+ assert "§Governance this implies" in said, said
7325
+ assert "§Plan" not in said, "slice ordering is supposed to die with the epic"
7326
+ # Where it went, named at the moment it goes rather than reconstructed later.
7327
+ assert head and head in said, f"expected the recovery commit {head}: {said}"
7328
+
7329
+ # And the claim is true: the plan is out of the tree and in that commit.
7330
+ assert not (v / "an-epic" / "epic.md").exists()
7331
+ kept = _git(repo, "show", f"{head}:work/versions/09-a-cut/an-epic/epic.md")
7332
+ assert "a rule is owed" in kept.stdout, kept.stderr
7333
+
7334
+ # The instruction that follows no longer sends a reader to a deleted file.
7335
+ assert "no longer in the tree" in said, said
7336
+ finally:
7337
+ events._PENDING.clear()
7338
+ config.apply(config.DEFAULTS)
7339
+
7340
+
7341
+ def test_a_release_with_no_plans_left_says_nothing_about_deleting_any():
7342
+ # Silence where there is no cost. An epic with no plan doc is already in the
7343
+ # shape release produces, and announcing a deletion of nothing would train the
7344
+ # reader to skip the announcement that matters.
7345
+ with tempfile.TemporaryDirectory() as tmp:
7346
+ try:
7347
+ repo = _git_repo(tmp, push=False)
7348
+ v = repo / "work" / "versions" / "09-a-cut"
7349
+ (v / "an-epic" / "complete" / "only-task").mkdir(parents=True)
7350
+ (v / "version.md").write_text(
7351
+ "---\ncreated: 2026-09-01\norder: 9\noutcome: a user can do it\n"
7352
+ "---\n\n# A cut\n")
7353
+ (v / "an-epic" / "complete" / "only-task" / "task.md").write_text(
7354
+ "---\npriority: P0\ncompleted: 2026-09-02\n---\n\n# Only task\n\n"
7355
+ "## Acceptance criteria\n\n- [x] it works\n")
7356
+ _git(repo, "add", "-A")
7357
+ _git(repo, "commit", "-qm", "the board")
7358
+ with _work_dir(str(repo / "work")):
7359
+ said = _capture_stdout(lambda: version.cmd_release({"name": "09-a-cut"}))
7360
+ assert "DELETING" not in said, said
7361
+ finally:
7362
+ events._PENDING.clear()
7363
+ config.apply(config.DEFAULTS)
7364
+
7365
+
7291
7366
  if __name__ == "__main__":
7292
7367
  tests = [v for k, v in sorted(globals().items())
7293
7368
  if k.startswith("test_") and callable(v)]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,12 +56,12 @@
56
56
  "tsup": "^8.5.1",
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
- "@jarvis/agents": "1.0.0",
60
59
  "@jarvis/anthropic": "1.0.0",
61
60
  "@jarvis/board": "0.1.0",
61
+ "@jarvis/agents": "1.0.0",
62
62
  "@jarvis/data": "0.1.0",
63
- "@jarvis/errors": "1.0.0",
64
63
  "@jarvis/logger": "1.0.0",
64
+ "@jarvis/errors": "1.0.0",
65
65
  "@jarvis/rpc": "1.0.0",
66
66
  "@jarvis/types": "1.0.0",
67
67
  "@jarvis/typescript-config": "1.0.0",