@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.
- package/dist/bin.js +297 -199
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +92 -22
- package/dist/data/mcp.mjs +62 -18
- package/harness/harness/epic.py +69 -1
- package/harness/harness/version.py +6 -1
- package/harness/test_work.py +75 -0
- package/package.json +3 -3
package/dist/data/backends.mjs
CHANGED
|
@@ -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 {
|
|
16
|
-
import { dirname, resolve
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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 =
|
|
204
|
-
const graphPath =
|
|
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
|
|
213
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 ?
|
|
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
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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 ? {
|
|
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) {
|
package/harness/harness/epic.py
CHANGED
|
@@ -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
|
-
|
|
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/)")
|
package/harness/test_work.py
CHANGED
|
@@ -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.
|
|
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",
|