codecartographer-pi 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codecarto/GUIDE.md +7 -0
- package/.codecarto/broadside/SKILL.md +7 -0
- package/.codecarto/broadside/config.yaml +2 -2
- package/.codecarto/templates/phase-handoff.yaml +20 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/agent-skill/codecartographer/references/handoff-contract.md +30 -2
- package/dist/core/broadside.d.ts +99 -4
- package/dist/core/broadside.js +284 -30
- package/dist/core/completion.d.ts +15 -0
- package/dist/core/completion.js +80 -3
- package/dist/core/coverage.d.ts +45 -0
- package/dist/core/coverage.js +131 -0
- package/dist/core/dashboard.d.ts +13 -0
- package/dist/core/dashboard.js +13 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/prompts.js +19 -1
- package/dist/core/status.d.ts +8 -1
- package/dist/core/status.js +37 -2
- package/dist/core/types.d.ts +24 -1
- package/dist/core/yaml.js +27 -7
- package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
- package/dist/extensions/codecarto/auto-runner.js +11 -4
- package/dist/extensions/codecarto/index.js +70 -23
- package/dist/mcp-server/server.js +42 -8
- package/package.json +1 -1
package/dist/core/broadside.js
CHANGED
|
@@ -31,11 +31,12 @@
|
|
|
31
31
|
// executable surfaces (Pi and MCP), not the pure template. What the template does
|
|
32
32
|
// carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
|
|
33
33
|
// served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
|
|
34
|
-
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
34
|
+
import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
35
35
|
import { execFile } from "node:child_process";
|
|
36
36
|
import { promisify } from "node:util";
|
|
37
|
-
import { join } from "node:path";
|
|
37
|
+
import { join, relative } from "node:path";
|
|
38
38
|
import { pathExists, sleep } from "./utils.js";
|
|
39
|
+
import { acquireLock } from "./status.js";
|
|
39
40
|
import { loadYamlFile } from "./yaml.js";
|
|
40
41
|
import { packagedWorkspaceDir } from "./workspace.js";
|
|
41
42
|
const execFileAsync = promisify(execFile);
|
|
@@ -49,8 +50,14 @@ export const BROADSIDE_STATE_FILE = "state.json";
|
|
|
49
50
|
export const BROADSIDE_CONFIG_FILE = "config.yaml";
|
|
50
51
|
export const BROADSIDE_STATE_SCHEMA_VERSION = 1;
|
|
51
52
|
// Per-token pricing in USD (OpenRouter, google/gemini-3.7-flash:batch).
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
// OpenRouter's listed rates for the `:batch` variant, which already carry the
|
|
54
|
+
// batch discount — the sync model is $0.75/$3.75. These were half these values
|
|
55
|
+
// until a live run compared them against the catalog: the batch discount had
|
|
56
|
+
// been applied a second time by hand, so every estimate for the default model
|
|
57
|
+
// came out at half its true cost and `max_cost` bound at twice what the user
|
|
58
|
+
// asked for. They are the offline fallback only; the live catalog wins.
|
|
59
|
+
export const BROADSIDE_INPUT_PRICE_PER_M = 0.375;
|
|
60
|
+
export const BROADSIDE_OUTPUT_PRICE_PER_M = 1.875;
|
|
54
61
|
// OpenRouter's public model catalog; pricing, context, and capabilities live
|
|
55
62
|
// per model id. The benchmarks endpoint adds coding/intelligence indices.
|
|
56
63
|
export const BROADSIDE_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
@@ -848,7 +855,10 @@ async function walkFiles(rootDir, dir, depth, remaining) {
|
|
|
848
855
|
out = out.concat(children);
|
|
849
856
|
}
|
|
850
857
|
else if (entry.isFile()) {
|
|
851
|
-
|
|
858
|
+
// relative() rather than slice(rootDir.length + 1): the hand-rolled
|
|
859
|
+
// slice cut one character too many whenever rootDir carried a trailing
|
|
860
|
+
// separator, and mangled every path outright when rootDir was "/".
|
|
861
|
+
const rel = relative(rootDir, join(dir, entry.name)).split("\\").join("/");
|
|
852
862
|
out.push(rel);
|
|
853
863
|
}
|
|
854
864
|
}
|
|
@@ -1142,9 +1152,30 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
|
|
|
1142
1152
|
},
|
|
1143
1153
|
};
|
|
1144
1154
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1155
|
+
/**
|
|
1156
|
+
* Pre-flight cost estimate for one lens.
|
|
1157
|
+
*
|
|
1158
|
+
* Every slice is its own batch request, so both halves scale with the slice
|
|
1159
|
+
* count. The output half used to be a single `maxTokens * 0.75` for the whole
|
|
1160
|
+
* lens no matter how many requests it sent — on a repository that sliced into
|
|
1161
|
+
* 13 modules that budgeted one request's output and shipped thirteen, and a
|
|
1162
|
+
* live run came in at roughly 3x its estimate. Since this number is what
|
|
1163
|
+
* `max_cost` binds against, under-counting it lets a run outspend the cap the
|
|
1164
|
+
* user set.
|
|
1165
|
+
*
|
|
1166
|
+
* @param info - Repo info, when the caller has it: lets the estimate include
|
|
1167
|
+
* the system prompt and JSON schema each request carries. Omitted, the
|
|
1168
|
+
* estimate covers slice content only, which is what the old signature did.
|
|
1169
|
+
*/
|
|
1170
|
+
export function estimateCost(lens, slices, pricing, maxTokensOverride, info) {
|
|
1171
|
+
const sliceChars = slices.reduce((sum, s) => sum + (lens.maxChars === 0 ? 6000 : s.chars), 0);
|
|
1172
|
+
// The system prompt and the response schema ride on every request, so they
|
|
1173
|
+
// are paid once per slice rather than once per lens.
|
|
1174
|
+
const perRequestOverhead = info
|
|
1175
|
+
? (lens.systemPrompt(info)?.length ?? 0) + JSON.stringify(SCHEMAS[lens.schemaName] ?? {}).length
|
|
1176
|
+
: 0;
|
|
1177
|
+
const inputTokens = Math.ceil((sliceChars + perRequestOverhead * slices.length) / 4);
|
|
1178
|
+
const outputTokens = slices.length * Math.ceil((maxTokensOverride ?? lens.maxTokens) * 0.75);
|
|
1148
1179
|
const cost = (inputTokens / 1_000_000) * pricing.inputPerM +
|
|
1149
1180
|
(outputTokens / 1_000_000) * pricing.outputPerM;
|
|
1150
1181
|
return { inputTokens, outputTokens, cost };
|
|
@@ -1195,9 +1226,76 @@ export async function loadBroadsideState(broadsideDir) {
|
|
|
1195
1226
|
return defaultBroadsideState();
|
|
1196
1227
|
}
|
|
1197
1228
|
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Overwrite `state.json` wholesale with `state`.
|
|
1231
|
+
*
|
|
1232
|
+
* Prefer {@link persistBroadsideRun} anywhere a live operation is recording its
|
|
1233
|
+
* own progress — this entry point replaces the file, so any run a concurrent
|
|
1234
|
+
* process recorded in the meantime is erased. It remains the right call for
|
|
1235
|
+
* seeding a fresh workspace and for test fixtures, where "make the file exactly
|
|
1236
|
+
* this" is the intent.
|
|
1237
|
+
*/
|
|
1198
1238
|
export async function saveBroadsideState(broadsideDir, state) {
|
|
1199
1239
|
await mkdir(broadsideDir, { recursive: true });
|
|
1200
|
-
|
|
1240
|
+
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1241
|
+
const lock = await acquireLock(`${statePath}.lock`);
|
|
1242
|
+
try {
|
|
1243
|
+
await writeBroadsideStateFile(statePath, state);
|
|
1244
|
+
}
|
|
1245
|
+
finally {
|
|
1246
|
+
await lock.release();
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/** Serialize through a temp file so a crash mid-write cannot truncate state.json. */
|
|
1250
|
+
async function writeBroadsideStateFile(statePath, state) {
|
|
1251
|
+
const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
1252
|
+
await writeFile(tempPath, `${JSON.stringify(state, null, "\t")}\n`, "utf8");
|
|
1253
|
+
await rename(tempPath, statePath);
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Read-modify-write `state.json` under a lock.
|
|
1257
|
+
*
|
|
1258
|
+
* The lock is held only for the read-modify-write, never for the surrounding
|
|
1259
|
+
* operation: a `collect` can poll for the better part of an hour, and holding
|
|
1260
|
+
* the lock across that would push every concurrent caller past the 5s lock
|
|
1261
|
+
* timeout.
|
|
1262
|
+
*/
|
|
1263
|
+
export async function updateBroadsideStateAtomically(broadsideDir, mutate) {
|
|
1264
|
+
await mkdir(broadsideDir, { recursive: true });
|
|
1265
|
+
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1266
|
+
const lock = await acquireLock(`${statePath}.lock`);
|
|
1267
|
+
try {
|
|
1268
|
+
const state = await loadBroadsideState(broadsideDir);
|
|
1269
|
+
await mutate(state);
|
|
1270
|
+
await writeBroadsideStateFile(statePath, state);
|
|
1271
|
+
return state;
|
|
1272
|
+
}
|
|
1273
|
+
finally {
|
|
1274
|
+
await lock.release();
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Record one run's current shape, merged into whatever is on disk *now*.
|
|
1279
|
+
*
|
|
1280
|
+
* Broad-Side operations are long-lived and hold their state in memory while
|
|
1281
|
+
* they poll. Writing that snapshot back wholesale silently erased any run a
|
|
1282
|
+
* concurrent operation had recorded since it was loaded, orphaning that run's
|
|
1283
|
+
* paid results on disk — present as files, invisible to `list`, and unreachable
|
|
1284
|
+
* by `collect`, which finds its run by position in `state.runs`. Observed live:
|
|
1285
|
+
* a submit at 23:35 was erased by a collect that had loaded state before it and
|
|
1286
|
+
* wrote back at 00:08.
|
|
1287
|
+
*
|
|
1288
|
+
* Merging by run id also self-heals: a run erased by an older writer is
|
|
1289
|
+
* restored the next time its own operation checkpoints.
|
|
1290
|
+
*/
|
|
1291
|
+
export async function persistBroadsideRun(broadsideDir, run) {
|
|
1292
|
+
return updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
1293
|
+
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
1294
|
+
if (index === -1)
|
|
1295
|
+
state.runs.push(run);
|
|
1296
|
+
else
|
|
1297
|
+
state.runs[index] = run;
|
|
1298
|
+
});
|
|
1201
1299
|
}
|
|
1202
1300
|
export async function loadBroadsideConfig(broadsideDir) {
|
|
1203
1301
|
const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
|
|
@@ -1332,10 +1430,11 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1332
1430
|
},
|
|
1333
1431
|
};
|
|
1334
1432
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
//
|
|
1433
|
+
// On-disk cache first, then the live catalog — for the default model too.
|
|
1434
|
+
// Hardcoded rates used to short-circuit here, which meant a stale constant
|
|
1435
|
+
// could never self-correct even though the catalog was already being
|
|
1436
|
+
// fetched for every other model. The authoritative source wins; the
|
|
1437
|
+
// constants below are what we fall back to when the network is unavailable.
|
|
1339
1438
|
const cache = await readCatalogCache(broadsideDir);
|
|
1340
1439
|
const cached = cache?.models[model];
|
|
1341
1440
|
if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
|
|
@@ -1366,6 +1465,11 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1366
1465
|
await writeCatalogCache(broadsideDir, updated);
|
|
1367
1466
|
return { model, source: "live", entry: live };
|
|
1368
1467
|
}
|
|
1468
|
+
// Offline fallback: the default model's rates and capabilities are known at
|
|
1469
|
+
// compile time, so a network failure does not have to stop a run.
|
|
1470
|
+
const builtIn = builtInCatalogEntry(model);
|
|
1471
|
+
if (builtIn)
|
|
1472
|
+
return { model, source: "built-in", entry: builtIn };
|
|
1369
1473
|
throw new Error(`Could not resolve per-token pricing for batch model "${model}". ` +
|
|
1370
1474
|
"Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
|
|
1371
1475
|
"(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
|
|
@@ -1469,6 +1573,14 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
|
1469
1573
|
data.http_status = resp.status;
|
|
1470
1574
|
return data;
|
|
1471
1575
|
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Batch statuses that will never produce a result.
|
|
1578
|
+
*
|
|
1579
|
+
* Deliberately excludes the synthetic `timeout` this module returns when a poll
|
|
1580
|
+
* budget expires: that batch is still running server-side and has already been
|
|
1581
|
+
* charged, so callers must come back for it rather than retire it.
|
|
1582
|
+
*/
|
|
1583
|
+
export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
|
|
1472
1584
|
export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
1473
1585
|
const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
1474
1586
|
const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
|
|
@@ -1491,7 +1603,7 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
1491
1603
|
const status = String(batch.status ?? "unknown");
|
|
1492
1604
|
const counts = (batch.request_counts ?? {});
|
|
1493
1605
|
opts.onStatus?.(status, counts);
|
|
1494
|
-
if (
|
|
1606
|
+
if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
|
|
1495
1607
|
return batch;
|
|
1496
1608
|
if (Date.now() >= deadline)
|
|
1497
1609
|
return { id: batchId, status: "timeout" };
|
|
@@ -1563,14 +1675,30 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1563
1675
|
const sourceDirty = await gitDirty(cwd);
|
|
1564
1676
|
let baseHead = null;
|
|
1565
1677
|
let changed = null;
|
|
1678
|
+
const incrementalOutcome = {
|
|
1679
|
+
requested: opts.incremental === true,
|
|
1680
|
+
applied: false,
|
|
1681
|
+
baseHead: null,
|
|
1682
|
+
};
|
|
1566
1683
|
if (opts.incremental) {
|
|
1567
1684
|
const state = await loadBroadsideState(broadsideDir);
|
|
1568
1685
|
// The baseline is the most recent run that recorded a HEAD — a
|
|
1569
1686
|
// submit-only run (never collected) is still a valid committed base.
|
|
1570
1687
|
const previous = [...state.runs].reverse().find((r) => r.sourceHead);
|
|
1571
|
-
if (
|
|
1688
|
+
if (sourceDirty) {
|
|
1689
|
+
incrementalOutcome.reason = "dirty-worktree";
|
|
1690
|
+
}
|
|
1691
|
+
else if (!previous?.sourceHead) {
|
|
1692
|
+
incrementalOutcome.reason = "no-baseline";
|
|
1693
|
+
}
|
|
1694
|
+
else {
|
|
1572
1695
|
baseHead = previous.sourceHead;
|
|
1573
1696
|
changed = await changedFilesSince(cwd, baseHead);
|
|
1697
|
+
incrementalOutcome.baseHead = baseHead;
|
|
1698
|
+
if (changed)
|
|
1699
|
+
incrementalOutcome.applied = true;
|
|
1700
|
+
else
|
|
1701
|
+
incrementalOutcome.reason = "diff-failed";
|
|
1574
1702
|
}
|
|
1575
1703
|
}
|
|
1576
1704
|
// Slice offline first so the estimate covers every request we would send.
|
|
@@ -1591,7 +1719,7 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1591
1719
|
const lensModel = modelForLens(lensId);
|
|
1592
1720
|
const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
|
|
1593
1721
|
const maxTokens = lensOutputCap ? Math.min(lens.maxTokens, lensOutputCap) : lens.maxTokens;
|
|
1594
|
-
const estimate = estimateCost(lens, slices, lensPricing, maxTokens);
|
|
1722
|
+
const estimate = estimateCost(lens, slices, lensPricing, maxTokens, info);
|
|
1595
1723
|
estimatedInputTokens += estimate.inputTokens;
|
|
1596
1724
|
estimatedOutputTokens += estimate.outputTokens;
|
|
1597
1725
|
estimatedTotalCost += estimate.cost;
|
|
@@ -1626,6 +1754,7 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1626
1754
|
exceedsLimit,
|
|
1627
1755
|
baseHead,
|
|
1628
1756
|
sourceDirty,
|
|
1757
|
+
incremental: incrementalOutcome,
|
|
1629
1758
|
...(outputCap !== undefined && { outputCap }),
|
|
1630
1759
|
});
|
|
1631
1760
|
if (!approved)
|
|
@@ -1659,7 +1788,7 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1659
1788
|
baseHead,
|
|
1660
1789
|
};
|
|
1661
1790
|
state.runs.push(run);
|
|
1662
|
-
await
|
|
1791
|
+
await persistBroadsideRun(broadsideDir, run);
|
|
1663
1792
|
const requestsByCustomId = {};
|
|
1664
1793
|
const submissions = [];
|
|
1665
1794
|
// Submit from the estimate rather than recomputing: the user approved that
|
|
@@ -1707,7 +1836,7 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1707
1836
|
})());
|
|
1708
1837
|
}
|
|
1709
1838
|
await Promise.allSettled(submissions);
|
|
1710
|
-
await
|
|
1839
|
+
await persistBroadsideRun(broadsideDir, run);
|
|
1711
1840
|
// Persist the exact request bodies so collect can re-submit a truncated
|
|
1712
1841
|
// slice (bumped output cap) without re-walking the repo (#133). The run
|
|
1713
1842
|
// dir is created here rather than waiting for collect so a crash between
|
|
@@ -1734,6 +1863,7 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1734
1863
|
: resolved.get(model).supportsStructuredOutputs,
|
|
1735
1864
|
expirationDate: defaultEntry.expirationDate ?? null,
|
|
1736
1865
|
},
|
|
1866
|
+
incremental: incrementalOutcome,
|
|
1737
1867
|
};
|
|
1738
1868
|
}
|
|
1739
1869
|
function extractContent(result) {
|
|
@@ -1799,6 +1929,46 @@ export async function saveLensResults(runDir, lensId, batch) {
|
|
|
1799
1929
|
}
|
|
1800
1930
|
return out;
|
|
1801
1931
|
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Rebuild lens results from what a previous collect already wrote to disk.
|
|
1934
|
+
*
|
|
1935
|
+
* The post-passes are gated on having lens findings in hand, and a collect
|
|
1936
|
+
* only holds the ones *it* polled. When an earlier collect saved every lens
|
|
1937
|
+
* and then died before synthesis and triage ran — the batch window is long
|
|
1938
|
+
* and a poll can easily be interrupted — the next collect finds every lens
|
|
1939
|
+
* already terminal, skips them all, and would otherwise reach the post-pass
|
|
1940
|
+
* gate with nothing to hand it. Reading the saved results back is what makes
|
|
1941
|
+
* "a resumed collect can finish whichever is still pending" true.
|
|
1942
|
+
*/
|
|
1943
|
+
export async function loadSavedLensResults(runDir, lenses) {
|
|
1944
|
+
if (!(await pathExists(runDir)))
|
|
1945
|
+
return [];
|
|
1946
|
+
const reserved = new Set(["requests.json", "run-meta.json", "synthesis.json", "triage.json"]);
|
|
1947
|
+
const out = [];
|
|
1948
|
+
// Longest lens id first: no id is a prefix of another today, but ordering
|
|
1949
|
+
// keeps that from becoming a silent misattribution if one ever is.
|
|
1950
|
+
const ordered = [...lenses].sort((a, b) => b.length - a.length);
|
|
1951
|
+
for (const name of (await readdir(runDir)).sort()) {
|
|
1952
|
+
if (!name.endsWith(".json") || name.endsWith(".error.json") || reserved.has(name) || name.startsWith("raw-"))
|
|
1953
|
+
continue;
|
|
1954
|
+
const customId = name.slice(0, -".json".length);
|
|
1955
|
+
const lensId = ordered.find((id) => customId === id || customId.startsWith(`${id}-`));
|
|
1956
|
+
if (!lensId)
|
|
1957
|
+
continue;
|
|
1958
|
+
const content = await readFile(join(runDir, name), "utf8").catch(() => null);
|
|
1959
|
+
if (content === null)
|
|
1960
|
+
continue;
|
|
1961
|
+
out.push({
|
|
1962
|
+
lensId,
|
|
1963
|
+
customId,
|
|
1964
|
+
moduleName: customId.replace(/^[a-z]+-/, ""),
|
|
1965
|
+
content,
|
|
1966
|
+
raw: {},
|
|
1967
|
+
truncated: parseLensJson(content) === null,
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
return out;
|
|
1971
|
+
}
|
|
1802
1972
|
async function loadStoredRequests(runDir) {
|
|
1803
1973
|
const path = join(runDir, "requests.json");
|
|
1804
1974
|
if (!(await pathExists(path)))
|
|
@@ -1956,11 +2126,19 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
1956
2126
|
await writeFile(join(runDir, `raw-${lensId}.json`), `${JSON.stringify(batch, null, "\t")}\n`, "utf8");
|
|
1957
2127
|
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, truncated };
|
|
1958
2128
|
}
|
|
1959
|
-
else
|
|
1960
|
-
|
|
2129
|
+
else {
|
|
2130
|
+
// Every non-completed outcome still has to reach the report.
|
|
2131
|
+
// `lensOutcomes` is what the caller renders, and this branch used to
|
|
2132
|
+
// require `batch.error` — but the commonest failure here is the
|
|
2133
|
+
// synthetic `{ status: "timeout" }` the poll returns when its budget
|
|
2134
|
+
// expires with the batch still in flight, and that carries no error.
|
|
2135
|
+
// A lens that never came back was therefore omitted entirely,
|
|
2136
|
+
// indistinguishable in the output from one that was never requested.
|
|
2137
|
+
if (batch.error)
|
|
2138
|
+
entry.error = batch.error;
|
|
1961
2139
|
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount };
|
|
1962
2140
|
}
|
|
1963
|
-
await
|
|
2141
|
+
await persistBroadsideRun(broadsideDir, run);
|
|
1964
2142
|
}
|
|
1965
2143
|
// #133: re-submit truncated slices once with a bumped output cap. Batch
|
|
1966
2144
|
// requests are pure, so re-running is always safe; the aim is to recover
|
|
@@ -1993,7 +2171,11 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
1993
2171
|
if (error)
|
|
1994
2172
|
continue;
|
|
1995
2173
|
const batch = await pollBatchUntilTerminal(batchId, apiKey, {
|
|
1996
|
-
|
|
2174
|
+
// Share the caller's deadline. Each of these polls used to
|
|
2175
|
+
// start a fresh 25-minute budget, so `wait_seconds` bounded
|
|
2176
|
+
// only the lens poll and a collect could run for the caller's
|
|
2177
|
+
// budget plus fifty minutes.
|
|
2178
|
+
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
1997
2179
|
onStatus: (status, counts) => opts.onStatus?.(`${stored.lensId}:retry`, status, counts),
|
|
1998
2180
|
fetcher: opts.fetcher,
|
|
1999
2181
|
});
|
|
@@ -2023,7 +2205,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2023
2205
|
outcome.truncated = allLensResults.filter((s) => s.lensId === lensId && s.truncated).length;
|
|
2024
2206
|
}
|
|
2025
2207
|
}
|
|
2026
|
-
await
|
|
2208
|
+
await persistBroadsideRun(broadsideDir, run);
|
|
2027
2209
|
}
|
|
2028
2210
|
// Synthesis + triage: cross-lens post-passes, only after every lens batch
|
|
2029
2211
|
// is terminal. Triage turns the leads into a prioritized work order.
|
|
@@ -2032,12 +2214,25 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2032
2214
|
let topTriageItems = [];
|
|
2033
2215
|
const wantSynthesis = opts.includeSynthesis !== false;
|
|
2034
2216
|
const wantTriage = opts.includeTriage !== false;
|
|
2217
|
+
// A resumed collect polls nothing — every lens is already terminal — so the
|
|
2218
|
+
// findings the post-passes need have to come back off disk, or a run whose
|
|
2219
|
+
// first collect was interrupted could never produce its executive report
|
|
2220
|
+
// and work order, however many times it was re-run.
|
|
2221
|
+
const postPassUnfinished = (entry) => entry.status === "pending" || entry.status === "submitted";
|
|
2222
|
+
if ((wantSynthesis || wantTriage) && allLensResults.length === 0
|
|
2223
|
+
&& (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
|
|
2224
|
+
const restored = await loadSavedLensResults(runDir, run.lenses);
|
|
2225
|
+
if (restored.length > 0) {
|
|
2226
|
+
allLensResults.push(...restored);
|
|
2227
|
+
truncatedCount = restored.filter((s) => s.truncated).length;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2035
2230
|
if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
|
|
2036
2231
|
const allTerminal = run.lenses.every((lensId) => {
|
|
2037
2232
|
const entry = run.batches[lensId];
|
|
2038
2233
|
return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
|
|
2039
2234
|
});
|
|
2040
|
-
if (allTerminal && (run.synthesis
|
|
2235
|
+
if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
|
|
2041
2236
|
const findingsText = allLensResults
|
|
2042
2237
|
.map((r) => `## ${r.lensId} — ${r.customId}\n\n${r.content}\n`)
|
|
2043
2238
|
.join("\n");
|
|
@@ -2066,6 +2261,24 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2066
2261
|
: []),
|
|
2067
2262
|
];
|
|
2068
2263
|
const submitted = new Map();
|
|
2264
|
+
// A pass can be left at "submitted" when an earlier collect returned
|
|
2265
|
+
// before its batch reached a terminal status — the batch still runs
|
|
2266
|
+
// and is still charged, so the result exists and is simply unclaimed.
|
|
2267
|
+
// Nothing above would ever look at it again: the pass list is built
|
|
2268
|
+
// from "pending" entries only. Poll those regardless of the want
|
|
2269
|
+
// flags, because the spend already happened and discarding a
|
|
2270
|
+
// finished result is worse than saving one the caller opted out of.
|
|
2271
|
+
for (const kind of ["synthesis", "triage"]) {
|
|
2272
|
+
const entry = kind === "synthesis" ? run.synthesis : run.triage;
|
|
2273
|
+
if (entry.status !== "submitted" || !entry.batchId)
|
|
2274
|
+
continue;
|
|
2275
|
+
if (submitted.has(entry.batchId))
|
|
2276
|
+
continue;
|
|
2277
|
+
submitted.set(entry.batchId, {
|
|
2278
|
+
batchId: entry.batchId,
|
|
2279
|
+
pass: { kind, request: undefined, entry },
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2069
2282
|
await Promise.allSettled(passes.map(async (pass) => {
|
|
2070
2283
|
pass.entry.status = "submitted";
|
|
2071
2284
|
try {
|
|
@@ -2081,10 +2294,13 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2081
2294
|
pass.entry.status = "failed";
|
|
2082
2295
|
}
|
|
2083
2296
|
}));
|
|
2084
|
-
await
|
|
2297
|
+
await persistBroadsideRun(broadsideDir, run);
|
|
2085
2298
|
for (const { batchId, pass } of submitted.values()) {
|
|
2086
2299
|
const batch = await pollBatchUntilTerminal(batchId, apiKey, {
|
|
2087
|
-
|
|
2300
|
+
// Shares the caller's deadline, as the retry poll above does.
|
|
2301
|
+
// A pass whose poll runs out stays `submitted`, so the batch
|
|
2302
|
+
// is already paid for and a later collect claims its result.
|
|
2303
|
+
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
2088
2304
|
onStatus: (status, counts) => opts.onStatus?.(pass.kind, status, counts),
|
|
2089
2305
|
fetcher: opts.fetcher,
|
|
2090
2306
|
});
|
|
@@ -2107,10 +2323,20 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2107
2323
|
}
|
|
2108
2324
|
}
|
|
2109
2325
|
}
|
|
2110
|
-
else if (batch.
|
|
2326
|
+
else if (BROADSIDE_DEAD_BATCH_STATUSES.includes(String(batch.status))) {
|
|
2327
|
+
// The batch will never produce a result, so retire the pass.
|
|
2328
|
+
// This used to require `batch.error`, leaving an expired or
|
|
2329
|
+
// cancelled batch parked at "submitted" forever — and since a
|
|
2330
|
+
// resumed collect re-polls anything still "submitted", it
|
|
2331
|
+
// would re-poll a dead batch on every future run.
|
|
2111
2332
|
pass.entry.status = "failed";
|
|
2333
|
+
if (batch.error)
|
|
2334
|
+
pass.entry.error = batch.error instanceof Error ? batch.error.message : String(batch.error);
|
|
2112
2335
|
}
|
|
2113
|
-
|
|
2336
|
+
// A "timeout" is deliberately left at "submitted": the batch is
|
|
2337
|
+
// still running server-side and has already been paid for, so a
|
|
2338
|
+
// later collect should claim its result rather than discard it.
|
|
2339
|
+
await persistBroadsideRun(broadsideDir, run);
|
|
2114
2340
|
}
|
|
2115
2341
|
}
|
|
2116
2342
|
}
|
|
@@ -2120,7 +2346,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2120
2346
|
});
|
|
2121
2347
|
run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
|
|
2122
2348
|
run.totalCost = totalCost;
|
|
2123
|
-
await
|
|
2349
|
+
await persistBroadsideRun(broadsideDir, run);
|
|
2124
2350
|
await writeFile(join(runDir, "run-meta.json"), `${JSON.stringify({
|
|
2125
2351
|
experimental: true,
|
|
2126
2352
|
method: "Broad-Side (OpenRouter Batch API)",
|
|
@@ -2223,9 +2449,31 @@ function parseSynthesisTopFindings(content) {
|
|
|
2223
2449
|
}
|
|
2224
2450
|
}
|
|
2225
2451
|
// ---------- formatting helpers for tool output ----------
|
|
2452
|
+
function describeIncrementalFallback(reason) {
|
|
2453
|
+
switch (reason) {
|
|
2454
|
+
case "dirty-worktree":
|
|
2455
|
+
return "the working tree has uncommitted changes, so there is no committed state to diff against";
|
|
2456
|
+
case "no-baseline":
|
|
2457
|
+
return "no earlier run recorded a commit to diff against";
|
|
2458
|
+
case "diff-failed":
|
|
2459
|
+
return "the diff against the previous run's commit could not be read";
|
|
2460
|
+
default:
|
|
2461
|
+
return "no baseline was available";
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2226
2464
|
export function estimateSubmitText(result, lenses) {
|
|
2465
|
+
// Count the lenses that actually got a batch, not every lens considered. A
|
|
2466
|
+
// lens with nothing to scan is reported as `skipped (0 request(s))` two
|
|
2467
|
+
// lines below, so counting it here made the header contradict its own body:
|
|
2468
|
+
// a Rust CLI with no server surface reported "submitted 6 batch(es)" over a
|
|
2469
|
+
// list showing four batches and two skips.
|
|
2470
|
+
const entries = Object.values(result.batches ?? {});
|
|
2471
|
+
const submittedCount = entries.filter((entry) => entry.batchId).length;
|
|
2472
|
+
const withoutBatch = entries.length - submittedCount;
|
|
2227
2473
|
const lines = [
|
|
2228
|
-
|
|
2474
|
+
withoutBatch > 0
|
|
2475
|
+
? `Broad-Side submitted ${submittedCount} batch(es); ${withoutBatch} lens(es) produced none (see below).`
|
|
2476
|
+
: `Broad-Side submitted ${submittedCount} batch(es).`,
|
|
2229
2477
|
];
|
|
2230
2478
|
for (const lens of lenses) {
|
|
2231
2479
|
const entry = result.batches[lens.id];
|
|
@@ -2235,6 +2483,12 @@ export function estimateSubmitText(result, lenses) {
|
|
|
2235
2483
|
const override = entry.model ? ` on ${entry.model}` : "";
|
|
2236
2484
|
lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
|
|
2237
2485
|
}
|
|
2486
|
+
const incremental = result.incremental;
|
|
2487
|
+
if (incremental?.requested) {
|
|
2488
|
+
lines.push(incremental.applied
|
|
2489
|
+
? `Incremental: scanning only what changed since ${(incremental.baseHead ?? "").slice(0, 8)}.`
|
|
2490
|
+
: `Incremental: requested but NOT applied — ${describeIncrementalFallback(incremental.reason)}. Every module was scanned, at full cost.`);
|
|
2491
|
+
}
|
|
2238
2492
|
lines.push(`Estimated total: ~$${result.estimatedTotalCost.toFixed(4)}`, `Pricing: $${result.pricing.inputPerM.toFixed(4)}/M in, $${result.pricing.outputPerM.toFixed(4)}/M out (${result.pricing.source})`);
|
|
2239
2493
|
if (result.modelInfo.contextLength) {
|
|
2240
2494
|
lines.push(`Model: ${result.modelInfo.contextLength.toLocaleString()} context, ${result.modelInfo.maxCompletionTokens?.toLocaleString() ?? "?"} max output`);
|
|
@@ -24,4 +24,19 @@ export declare const CONVENTIONS_PENDING_HEADING = "## Pending proposals";
|
|
|
24
24
|
* @param content - the file content, or null to read from disk (null when the file is absent).
|
|
25
25
|
*/
|
|
26
26
|
export declare function countPendingProposals(workspaceDir: string, content?: string): Promise<number>;
|
|
27
|
+
/**
|
|
28
|
+
* First scaffold version whose handoff template documents the `{id, evidence}`
|
|
29
|
+
* closure shape and the runtime-evidence rule (#122, #186).
|
|
30
|
+
*
|
|
31
|
+
* A workspace scaffolded before it was never told that closing a
|
|
32
|
+
* `needs-runtime-test` question requires evidence, so refusing its completion
|
|
33
|
+
* would apply a rule its own templates do not carry — and could stop an
|
|
34
|
+
* in-flight `--auto` run on a handoff written against the older contract.
|
|
35
|
+
* Those workspaces get a warning instead; refreshing the scaffold
|
|
36
|
+
* (`codecarto_refresh_scaffold` on MCP, `/codecarto-refresh-scaffold` on Pi)
|
|
37
|
+
* opts them in. Same treatment Stage 2's findings pairing check uses.
|
|
38
|
+
*/
|
|
39
|
+
export declare const CLOSURE_EVIDENCE_GATE_SCAFFOLD_VERSION = "0.19.0";
|
|
40
|
+
/** Whether the runtime-evidence requirement refuses (current scaffold) or warns (older). */
|
|
41
|
+
export declare function closureEvidenceGateActive(scaffoldVersion: string | undefined | null): boolean;
|
|
27
42
|
export declare function completeValidatedPhase(cwd: string, validation: ValidationResult, sourceLabel: string): Promise<CompletionResult>;
|
package/dist/core/completion.js
CHANGED
|
@@ -2,7 +2,7 @@ import { appendFile, copyFile, mkdir, readFile, readdir, writeFile } from "node:
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getNextEligiblePhase, resolvePhase, validatePhaseOutput } from "./pipeline.js";
|
|
4
4
|
import { applyHandoff, autoAssignIds, buildTerminalNextActions, loadHandoffFile, normalizeStatus } from "./status.js";
|
|
5
|
-
import { dateOnly, newlineIfUnterminated, pathExists, uniqueStrings } from "./utils.js";
|
|
5
|
+
import { compareDottedVersions, dateOnly, newlineIfUnterminated, pathExists, uniqueStrings } from "./utils.js";
|
|
6
6
|
import { getWorkspaceState, updateStatusAtomically } from "./workspace.js";
|
|
7
7
|
/**
|
|
8
8
|
* The Markdown a reader sees: content inside `<!-- -->` blocks removed by a
|
|
@@ -233,6 +233,26 @@ async function writeCompletionArtifacts(workspaceDir, phaseId, validation, times
|
|
|
233
233
|
const { totalPending: totalPendingProposals } = await stageProposedConventions(workspaceDir, phaseId, timestamp, handoff?.proposed_conventions ?? []);
|
|
234
234
|
return { closeoutPath: `.codecarto/closeouts/${closeoutFile}`, decisionsAppended, totalPendingProposals };
|
|
235
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* First scaffold version whose handoff template documents the `{id, evidence}`
|
|
238
|
+
* closure shape and the runtime-evidence rule (#122, #186).
|
|
239
|
+
*
|
|
240
|
+
* A workspace scaffolded before it was never told that closing a
|
|
241
|
+
* `needs-runtime-test` question requires evidence, so refusing its completion
|
|
242
|
+
* would apply a rule its own templates do not carry — and could stop an
|
|
243
|
+
* in-flight `--auto` run on a handoff written against the older contract.
|
|
244
|
+
* Those workspaces get a warning instead; refreshing the scaffold
|
|
245
|
+
* (`codecarto_refresh_scaffold` on MCP, `/codecarto-refresh-scaffold` on Pi)
|
|
246
|
+
* opts them in. Same treatment Stage 2's findings pairing check uses.
|
|
247
|
+
*/
|
|
248
|
+
export const CLOSURE_EVIDENCE_GATE_SCAFFOLD_VERSION = "0.19.0";
|
|
249
|
+
/** Whether the runtime-evidence requirement refuses (current scaffold) or warns (older). */
|
|
250
|
+
export function closureEvidenceGateActive(scaffoldVersion) {
|
|
251
|
+
if (!scaffoldVersion)
|
|
252
|
+
return false;
|
|
253
|
+
const comparison = compareDottedVersions(scaffoldVersion, CLOSURE_EVIDENCE_GATE_SCAFFOLD_VERSION);
|
|
254
|
+
return comparison !== null && comparison >= 0;
|
|
255
|
+
}
|
|
236
256
|
export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
237
257
|
const initialState = await getWorkspaceState(cwd);
|
|
238
258
|
if (!initialState)
|
|
@@ -254,6 +274,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
254
274
|
+ `plus closeout_summary and optional closeout_content. Then re-run completion.`);
|
|
255
275
|
}
|
|
256
276
|
}
|
|
277
|
+
const warnings = [];
|
|
257
278
|
if (handoff) {
|
|
258
279
|
const activePhases = new Set(initialState.pipeline.phase_order);
|
|
259
280
|
const sourceIndex = initialState.pipeline.phase_order.indexOf(validation.phaseId);
|
|
@@ -267,14 +288,70 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
267
288
|
if (!entry.id?.trim())
|
|
268
289
|
throw new Error("Invalid handoff: post_pipeline entries require a canonical id");
|
|
269
290
|
}
|
|
291
|
+
// Closure integrity, gating (#122, #186). Both checks are deterministic
|
|
292
|
+
// reads of ids the model wrote itself, so neither can wedge an --auto run
|
|
293
|
+
// on a heuristic; both sit here, before the lock, alongside the
|
|
294
|
+
// target_phase check, so a refusal mutates nothing.
|
|
295
|
+
const questionsById = new Map();
|
|
296
|
+
const derivesFromById = new Map();
|
|
297
|
+
for (const phaseState of Object.values(initialState.status.phases)) {
|
|
298
|
+
for (const entry of phaseState.open_questions ?? []) {
|
|
299
|
+
if (entry.id)
|
|
300
|
+
questionsById.set(entry.id, entry);
|
|
301
|
+
}
|
|
302
|
+
for (const entry of phaseState.carry_forward ?? []) {
|
|
303
|
+
if (entry.id && entry.derives_from)
|
|
304
|
+
derivesFromById.set(entry.id, entry.derives_from);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const closingQuestionIds = new Set(handoff.open_question_closures.map((closure) => closure.id).filter(Boolean));
|
|
308
|
+
// D1: a routed item that declares `derives_from` is one candidate answer
|
|
309
|
+
// to that question. Closing it while the question stands is exactly the
|
|
310
|
+
// contradiction #122 reported — the routed candidate shipped as settled
|
|
311
|
+
// while the question that said "source alone cannot determine which" was
|
|
312
|
+
// still open. A derives_from naming an id that no longer exists is fine:
|
|
313
|
+
// the question was already resolved.
|
|
314
|
+
for (const closureId of handoff.carry_forward_closures) {
|
|
315
|
+
const questionId = derivesFromById.get(closureId);
|
|
316
|
+
if (!questionId || !questionsById.has(questionId))
|
|
317
|
+
continue;
|
|
318
|
+
if (closingQuestionIds.has(questionId))
|
|
319
|
+
continue;
|
|
320
|
+
throw new Error(`Refusing to complete ${validation.phaseId}: the handoff closes carry_forward ${closureId}, which derives_from open question ${questionId} — and ${questionId} is still unresolved and is not in this handoff's open_question_closures. `
|
|
321
|
+
+ `A routed item is one candidate answer to the question it came from; closing it does not settle the question. `
|
|
322
|
+
+ `Either close ${questionId} in this same handoff with the evidence that settles it, or leave ${closureId} routed and give the finding an unsettled action ("verify at runtime") instead.`);
|
|
323
|
+
}
|
|
324
|
+
// D3: a `needs-runtime-test` question closes on runtime evidence, not on
|
|
325
|
+
// another source read. Requiring the evidence string to be non-empty is
|
|
326
|
+
// the whole gate — judging what it says stays prose guidance.
|
|
327
|
+
//
|
|
328
|
+
// Unlike D1, this one can fire on a handoff that uses none of the new
|
|
329
|
+
// fields: a bare-string closure was the only shape before this release.
|
|
330
|
+
// Gating it unconditionally would apply a rule to workspaces whose own
|
|
331
|
+
// templates never state it, so it is version-gated exactly like Stage
|
|
332
|
+
// 2's pairing check — refuse on a scaffold that documents the rule, warn
|
|
333
|
+
// on one that predates it.
|
|
334
|
+
const evidenceGateActive = closureEvidenceGateActive(initialState.scaffoldVersion);
|
|
335
|
+
for (const closure of handoff.open_question_closures) {
|
|
336
|
+
if (questionsById.get(closure.id)?.kind !== "needs-runtime-test")
|
|
337
|
+
continue;
|
|
338
|
+
if (closure.evidence?.trim())
|
|
339
|
+
continue;
|
|
340
|
+
const detail = `open_question_closures closes ${closure.id}, whose kind is needs-runtime-test, without evidence. `
|
|
341
|
+
+ `A runtime question closes on runtime evidence — a spike report or an observation against the running system — not on a source read. `
|
|
342
|
+
+ `Write the closure as an object: { id: ${closure.id}, evidence: <where that evidence lives> }. If you do not have it, leave the question open.`;
|
|
343
|
+
if (evidenceGateActive)
|
|
344
|
+
throw new Error(`Refusing to complete ${validation.phaseId}: ${detail}`);
|
|
345
|
+
warnings.push(`${detail} Warning only: this workspace's scaffold predates the requirement — refresh it `
|
|
346
|
+
+ `(codecarto_refresh_scaffold on MCP, /codecarto-refresh-scaffold on Pi) to make this gating.`);
|
|
347
|
+
}
|
|
270
348
|
}
|
|
271
349
|
// Closure integrity (#122, warning only): a handoff can close a carry-forward
|
|
272
350
|
// or open question the report never addressed — "closed in the handoff,
|
|
273
351
|
// resolved nowhere." The id of every claimed closure should appear somewhere
|
|
274
352
|
// in the primary output that claims to resolve it.
|
|
275
|
-
const warnings = [];
|
|
276
353
|
if (handoff && validation.outputPath) {
|
|
277
|
-
const closures = [...handoff.carry_forward_closures, ...handoff.open_question_closures].filter((id) => id?.trim());
|
|
354
|
+
const closures = [...handoff.carry_forward_closures, ...handoff.open_question_closures.map((closure) => closure.id)].filter((id) => id?.trim());
|
|
278
355
|
if (closures.length > 0) {
|
|
279
356
|
const output = await readFile(validation.outputPath, "utf8").catch(() => "");
|
|
280
357
|
const unmentioned = closures.filter((id) => !output.includes(id));
|