codecartographer-pi 0.20.0 → 0.21.0

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.
Files changed (39) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +7 -3
  3. package/.codecarto/broadside/config.yaml +17 -9
  4. package/.codecarto/findings/contracts/SKILL.md +4 -1
  5. package/.codecarto/findings/defect-scan/SKILL.md +10 -0
  6. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +6 -0
  7. package/.codecarto/findings/defect-scan-semantic/SKILL.md +8 -1
  8. package/.codecarto/findings/porting/SKILL.md +4 -0
  9. package/.codecarto/findings/protocols/SKILL.md +4 -0
  10. package/.codecarto/templates/mechanical-defects.md +15 -0
  11. package/.codecarto/templates/reimplementation-spec.md +5 -3
  12. package/.codecarto/templates/reverse-engineering-bundle.md +10 -1
  13. package/.codecarto/templates/semantic-defects.md +15 -0
  14. package/.codecarto/workflow/VALIDATE.md +1 -1
  15. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  16. package/README.md +3 -3
  17. package/dist/core/amendment.js +9 -4
  18. package/dist/core/broadside.d.ts +49 -0
  19. package/dist/core/broadside.js +131 -30
  20. package/dist/core/completion.js +7 -1
  21. package/dist/core/library.js +5 -3
  22. package/dist/core/pipeline.d.ts +39 -3
  23. package/dist/core/pipeline.js +61 -7
  24. package/dist/core/prompts.js +10 -3
  25. package/dist/core/status.d.ts +8 -0
  26. package/dist/core/status.js +39 -17
  27. package/dist/core/utils.d.ts +7 -0
  28. package/dist/core/utils.js +7 -0
  29. package/dist/core/workspace.js +2 -2
  30. package/dist/core/yaml.js +8 -1
  31. package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
  32. package/dist/extensions/codecarto/auto-runner.js +18 -3
  33. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  34. package/dist/extensions/codecarto/broadside-flags.js +12 -0
  35. package/dist/extensions/codecarto/index.js +95 -31
  36. package/dist/extensions/codecarto/phase-compaction.js +5 -1
  37. package/dist/mcp-server/server.d.ts +3 -1
  38. package/dist/mcp-server/server.js +99 -29
  39. package/package.json +1 -1
@@ -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 { createHash } from "node:crypto";
34
35
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
35
36
  import { execFile } from "node:child_process";
36
37
  import { promisify } from "node:util";
37
38
  import { join, relative } from "node:path";
38
- import { atomicWriteFile, pathExists, sleep } from "./utils.js";
39
+ import { atomicWriteFile, GIT_TIMEOUT_MS, pathExists, sleep } from "./utils.js";
39
40
  import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
40
41
  import { acquireLock } from "./status.js";
41
42
  import { loadYamlFile } from "./yaml.js";
@@ -75,6 +76,16 @@ export const BROADSIDE_LENS_IDS = [
75
76
  ];
76
77
  export const BROADSIDE_POLL_INTERVAL_MS = 15_000;
77
78
  export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
79
+ /**
80
+ * The run expense limit in USD a repository gets before it configures one.
81
+ * Pi asks a human before submitting over the estimate; the MCP surface cannot,
82
+ * and shipped with no limit at all, so a host calling submit with the stock
83
+ * config spent whatever the estimate came to (#231). One dollar covers a
84
+ * six-lens run of a repository this size with room to spare; a larger one
85
+ * raises `max_cost` in config.yaml, passes `max_cost` on the call, or sets it
86
+ * to 0 for no limit.
87
+ */
88
+ export const BROADSIDE_DEFAULT_MAX_COST = 1;
78
89
  /**
79
90
  * The share of a lens's output budget reasoning may spend.
80
91
  *
@@ -120,6 +131,37 @@ export class BroadsideAuthError extends Error {
120
131
  this.detail = detail;
121
132
  }
122
133
  }
134
+ /**
135
+ * `broadside/config.yaml` exists but cannot be used. A file that failed to
136
+ * parse used to be treated exactly like an absent one — defaults, including
137
+ * no spend cap and no lens routing, with no message — so a typo removed the
138
+ * user's own guard (#232). Only an absent file yields defaults now.
139
+ */
140
+ export class BroadsideConfigError extends Error {
141
+ path;
142
+ constructor(path, detail) {
143
+ super(`Broad-Side config ${path} ${detail}. Fix or remove the file; nothing runs on defaults while it is unreadable.`);
144
+ this.name = "BroadsideConfigError";
145
+ this.path = path;
146
+ }
147
+ }
148
+ /**
149
+ * `broadside/state.json` exists but cannot be read. It used to be read as
150
+ * empty and the next checkpoint wrote that empty state over it, losing the
151
+ * batch ids of every in-flight, already-paid run (#233). The corrupt file is
152
+ * preserved beside itself and nothing writes over it until someone looks.
153
+ */
154
+ export class BroadsideStateError extends Error {
155
+ path;
156
+ backupPath;
157
+ constructor(path, backupPath, detail) {
158
+ super(`Broad-Side state ${path} ${detail}. A copy is preserved at ${backupPath}; the file is not overwritten. ` +
159
+ "Repair state.json from the copy (each run's batch ids are what collect needs), or move it aside to start fresh.");
160
+ this.name = "BroadsideStateError";
161
+ this.path = path;
162
+ this.backupPath = backupPath;
163
+ }
164
+ }
123
165
  /** Thrown when a confirm hook declines a run. Nothing was submitted. */
124
166
  export class BroadsideCancelledError extends Error {
125
167
  constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
@@ -851,9 +893,10 @@ const SOURCE_SPECS = {
851
893
  */
852
894
  async function listRepoFiles(targetDir) {
853
895
  try {
854
- const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
896
+ const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
855
897
  const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
856
898
  maxBuffer: 64 * 1024 * 1024,
899
+ timeout: GIT_TIMEOUT_MS,
857
900
  });
858
901
  const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
859
902
  const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
@@ -865,7 +908,7 @@ async function listRepoFiles(targetDir) {
865
908
  }
866
909
  async function gitHead(targetDir) {
867
910
  try {
868
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024 });
911
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
869
912
  return stdout.trim() || null;
870
913
  }
871
914
  catch {
@@ -874,7 +917,7 @@ async function gitHead(targetDir) {
874
917
  }
875
918
  async function gitDirty(targetDir) {
876
919
  try {
877
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024 });
920
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
878
921
  return stdout.trim().length > 0;
879
922
  }
880
923
  catch {
@@ -890,7 +933,7 @@ async function changedFilesSince(targetDir, baseHead) {
890
933
  if (!baseHead)
891
934
  return null;
892
935
  try {
893
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
936
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
894
937
  return new Set(stdout.split("\n").filter(Boolean));
895
938
  }
896
939
  catch {
@@ -909,7 +952,7 @@ async function walkFiles(rootDir, dir, depth, remaining) {
909
952
  return out;
910
953
  }
911
954
  for (const entry of entries) {
912
- if (entry.name.startsWith(".") && entry.name !== ".github")
955
+ if (entry.name.startsWith("."))
913
956
  continue;
914
957
  if (entry.isDirectory()) {
915
958
  if (SKIP_DIR_NAMES.has(entry.name))
@@ -1371,15 +1414,30 @@ export async function loadBroadsideState(broadsideDir) {
1371
1414
  const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
1372
1415
  if (!(await pathExists(statePath)))
1373
1416
  return defaultBroadsideState();
1417
+ const text = await readFile(statePath, "utf8");
1418
+ let raw;
1374
1419
  try {
1375
- const raw = JSON.parse(await readFile(statePath, "utf8"));
1376
- if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs))
1377
- return defaultBroadsideState();
1378
- return raw;
1420
+ raw = JSON.parse(text);
1379
1421
  }
1380
- catch {
1381
- return defaultBroadsideState();
1422
+ catch (error) {
1423
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
1424
+ }
1425
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
1426
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
1382
1427
  }
1428
+ return raw;
1429
+ }
1430
+ /**
1431
+ * Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
1432
+ * named by content so repeated loads do not multiply copies. Returns the
1433
+ * copy's path (the existing one, when the same content was preserved before).
1434
+ */
1435
+ async function preserveCorruptState(statePath, text) {
1436
+ const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
1437
+ const backupPath = `${statePath}.corrupt-${digest}`;
1438
+ if (!(await pathExists(backupPath)))
1439
+ await writeFile(backupPath, text, "utf8");
1440
+ return backupPath;
1383
1441
  }
1384
1442
  /**
1385
1443
  * Overwrite `state.json` wholesale with `state`.
@@ -1472,13 +1530,26 @@ export async function loadBroadsideConfig(broadsideDir) {
1472
1530
  const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
1473
1531
  let raw = {};
1474
1532
  if (await pathExists(configPath)) {
1533
+ let parsed;
1475
1534
  try {
1476
- raw = (await loadYamlFile(configPath)) ?? {};
1535
+ parsed = await loadYamlFile(configPath);
1477
1536
  }
1478
- catch {
1479
- raw = {};
1537
+ catch (error) {
1538
+ throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
1539
+ }
1540
+ if (parsed !== null && parsed !== undefined) {
1541
+ if (typeof parsed !== "object" || Array.isArray(parsed))
1542
+ throw new BroadsideConfigError(configPath, "is not a YAML mapping");
1543
+ raw = parsed;
1480
1544
  }
1481
1545
  }
1546
+ return buildBroadsideConfig(raw);
1547
+ }
1548
+ /** The shipped defaults: what an absent config.yaml means. */
1549
+ export function defaultBroadsideConfig() {
1550
+ return buildBroadsideConfig({});
1551
+ }
1552
+ function buildBroadsideConfig(raw) {
1482
1553
  const lenses = Array.isArray(raw.default_lenses)
1483
1554
  ? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
1484
1555
  : [];
@@ -1503,7 +1574,9 @@ export async function loadBroadsideConfig(broadsideDir) {
1503
1574
  model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
1504
1575
  apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
1505
1576
  defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
1506
- maxCost: typeof raw.max_cost === "number" && raw.max_cost > 0 ? raw.max_cost : 0,
1577
+ // Absent: the shipped default. An explicit 0 is "no limit", spelled out
1578
+ // on purpose; a negative or non-numeric value is not a limit at all.
1579
+ maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
1507
1580
  pricing: inputOverride !== undefined && outputOverride !== undefined
1508
1581
  ? { inputPerM: inputOverride, outputPerM: outputOverride }
1509
1582
  : null,
@@ -1520,13 +1593,18 @@ export async function loadBroadsideConfig(broadsideDir) {
1520
1593
  redactSecrets: flag("redact_secrets", true),
1521
1594
  };
1522
1595
  }
1596
+ // ---------- model catalog, pricing, benchmarks ----------
1597
+ /** The catalog cache schema this build writes; a file from another is not read. */
1598
+ export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
1523
1599
  async function readCatalogCache(broadsideDir) {
1524
1600
  const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
1525
1601
  if (!(await pathExists(cachePath)))
1526
1602
  return null;
1527
1603
  try {
1528
1604
  const parsed = JSON.parse(await readFile(cachePath, "utf8"));
1529
- if (!parsed || typeof parsed !== "object" || typeof parsed.models !== "object")
1605
+ if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
1606
+ return null;
1607
+ if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
1530
1608
  return null;
1531
1609
  return parsed;
1532
1610
  }
@@ -1534,6 +1612,11 @@ async function readCatalogCache(broadsideDir) {
1534
1612
  return null;
1535
1613
  }
1536
1614
  }
1615
+ /** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
1616
+ function catalogEntryFetchedAt(cache, model) {
1617
+ const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
1618
+ return new Date(stamp).getTime();
1619
+ }
1537
1620
  async function writeCatalogCache(broadsideDir, cache) {
1538
1621
  await mkdir(broadsideDir, { recursive: true });
1539
1622
  await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
@@ -1613,7 +1696,7 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1613
1696
  // constants below are what we fall back to when the network is unavailable.
1614
1697
  const cache = await readCatalogCache(broadsideDir);
1615
1698
  const cached = cache?.models[model];
1616
- if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1699
+ if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1617
1700
  return { model, source: "cache", entry: cached };
1618
1701
  }
1619
1702
  // What went wrong when the live lookup produced nothing, for the error
@@ -1649,12 +1732,15 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1649
1732
  catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
1650
1733
  }
1651
1734
  if (live) {
1735
+ const now = new Date().toISOString();
1652
1736
  const updated = {
1653
- schema_version: 2,
1654
- fetched_at: new Date().toISOString(),
1655
- models: { ...(cache?.models ?? {}) },
1737
+ schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
1738
+ fetched_at: now,
1739
+ // Other entries keep their own stamps (a schema-2 file's entries
1740
+ // inherit the file's, once, on this upgrade); only this model is fresh.
1741
+ models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
1656
1742
  };
1657
- updated.models[model] = live;
1743
+ updated.models[model] = { ...live, fetched_at: now };
1658
1744
  await writeCatalogCache(broadsideDir, updated);
1659
1745
  return { model, source: "live", entry: live };
1660
1746
  }
@@ -1751,9 +1837,10 @@ export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
1751
1837
  }
1752
1838
  entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
1753
1839
  // Persist the catalog so the next submit's pricing resolution hits cache.
1754
- const cache = { schema_version: 2, fetched_at: new Date().toISOString(), models: {} };
1840
+ const fetchedAt = new Date().toISOString();
1841
+ const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
1755
1842
  for (const entry of entries)
1756
- cache.models[entry.id] = entry;
1843
+ cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
1757
1844
  await writeCatalogCache(broadsideDir, cache);
1758
1845
  const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
1759
1846
  return { entries, source: "live", benchmarks, defaultModel: config.model };
@@ -1811,6 +1898,14 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
1811
1898
  * charged, so callers must come back for it rather than retire it.
1812
1899
  */
1813
1900
  export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
1901
+ /**
1902
+ * Batch entry statuses collect never polls again: the dead ones above, plus
1903
+ * `completed`, plus the two a submit assigns without a batch (`skipped`: no
1904
+ * matching files; `rejected`: the provider refused it). The 0.19.1 changelog
1905
+ * called the dead set "a named constant rather than two hand-maintained
1906
+ * lists"; this set was still three literal copies (self-audit sem 5.8).
1907
+ */
1908
+ export const BROADSIDE_TERMINAL_ENTRY_STATUSES = ["completed", ...BROADSIDE_DEAD_BATCH_STATUSES, "skipped", "rejected"];
1814
1909
  export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
1815
1910
  const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
1816
1911
  const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
@@ -2042,7 +2137,9 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2042
2137
  `$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
2043
2138
  `Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
2044
2139
  }
2045
- const state = await loadBroadsideState(broadsideDir);
2140
+ // Read before anything is posted: a state.json that cannot be read refuses
2141
+ // the run here (#233), while persistBroadsideRun below merges by run id.
2142
+ await loadBroadsideState(broadsideDir);
2046
2143
  const runId = new Date().toISOString().replace(/[:.]/g, "-");
2047
2144
  const run = {
2048
2145
  id: runId,
@@ -2069,7 +2166,6 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2069
2166
  skippedFiles: info.secretFilesSkipped.length,
2070
2167
  },
2071
2168
  };
2072
- state.runs.push(run);
2073
2169
  await persistBroadsideRun(broadsideDir, run);
2074
2170
  const requestsByCustomId = {};
2075
2171
  const submissions = [];
@@ -2364,8 +2460,13 @@ function parseTriageItems(content) {
2364
2460
  export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2365
2461
  const broadsideDir = broadsideDirFor(cwd);
2366
2462
  const state = await loadBroadsideState(broadsideDir);
2367
- const run = state.runs[state.runs.length - 1];
2463
+ const run = opts.runId ? state.runs.find((candidate) => candidate.id === opts.runId) : state.runs[state.runs.length - 1];
2368
2464
  if (!run) {
2465
+ if (opts.runId) {
2466
+ const known = state.runs.map((candidate) => candidate.id);
2467
+ throw new Error(`No Broad-Side run with id ${opts.runId}. ` +
2468
+ (known.length > 0 ? `Recorded runs: ${known.join(", ")}.` : "No runs are recorded; call codecarto_broadside with action 'submit' first."));
2469
+ }
2369
2470
  throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
2370
2471
  }
2371
2472
  const runDir = join(broadsideDir, run.outputDir);
@@ -2386,7 +2487,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2386
2487
  lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
2387
2488
  continue;
2388
2489
  }
2389
- if (["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status)) {
2490
+ if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status)) {
2390
2491
  totalCost += entry.cost ?? 0;
2391
2492
  resultCount += entry.resultCount ?? 0;
2392
2493
  lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
@@ -2526,7 +2627,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2526
2627
  if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
2527
2628
  const allTerminal = run.lenses.every((lensId) => {
2528
2629
  const entry = run.batches[lensId];
2529
- return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
2630
+ return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2530
2631
  });
2531
2632
  if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
2532
2633
  const findingsText = allLensResults
@@ -2638,7 +2739,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2638
2739
  }
2639
2740
  const terminal = run.lenses.every((lensId) => {
2640
2741
  const entry = run.batches[lensId];
2641
- return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
2742
+ return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2642
2743
  });
2643
2744
  run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
2644
2745
  run.totalCost = totalCost;
@@ -110,7 +110,13 @@ async function appendDecisionLog(workspaceDir, phaseId, closeoutFile, decisions)
110
110
  const number = String(nextNumber + index).padStart(3, "0");
111
111
  return `D${number} | ${decision.trim()} | ${source} | closeouts/${closeoutFile} §Decisions Beyond Prompt (${phaseId})`;
112
112
  });
113
- content += `${content.endsWith("\n") ? "" : "\n"}${rows.join("\n")}\n`;
113
+ // A row appended straight after the section's explanatory paragraph is
114
+ // rendered as part of that paragraph by most Markdown renderers; a blank
115
+ // line makes the rows their own block (self-audit F4). Rows already
116
+ // present stay contiguous with the new ones.
117
+ const trailing = content.replace(/\n+$/, "").split("\n").pop() ?? "";
118
+ const separator = /^D\d+\s*\|/.test(trailing) || trailing.trim() === "" ? "" : "\n";
119
+ content += `${content.endsWith("\n") ? "" : "\n"}${separator}${rows.join("\n")}\n`;
114
120
  await writeFile(join(workspaceDir, "DECISIONS.md"), content, "utf8");
115
121
  return fresh.length;
116
122
  }
@@ -33,7 +33,7 @@ import { spawn } from "node:child_process";
33
33
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
34
34
  import { basename, join, resolve } from "node:path";
35
35
  import { acquireLock } from "./status.js";
36
- import { atomicWriteFile, canonicalPath, isPlainObject, normalizeForComparison, pathExists, uniqueTempSuffix } from "./utils.js";
36
+ import { atomicWriteFile, canonicalPath, GIT_TIMEOUT_MS, isPlainObject, normalizeForComparison, pathExists, uniqueTempSuffix } from "./utils.js";
37
37
  import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
38
38
  // ─── Constants ──────────────────────────────────────────────────────────────
39
39
  export const LIBRARY_MARKER_FILE = ".codecarto-library";
@@ -830,7 +830,7 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
830
830
  const lines = [];
831
831
  lines.push(`# ${escapeMd(marker.name)} — Library Index`);
832
832
  lines.push("");
833
- lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with \`codecarto library-reindex\`._`);
833
+ lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with the \`codecarto_library_reindex\` MCP tool._`);
834
834
  lines.push("");
835
835
  // A single-tenant library has no namespaces but is still one namespace's
836
836
  // worth of entries; count once so the noun agrees with the number shown.
@@ -1041,7 +1041,9 @@ export async function commitPublish(libraryRoot, message, opts = {}) {
1041
1041
  }
1042
1042
  function runGit(cwd, args) {
1043
1043
  return new Promise((resolvePromise) => {
1044
- const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1044
+ // Bounded like every fetch: a credential helper waiting on a prompt
1045
+ // used to hang publish or source-repo resolution for good (sem 3.12).
1046
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
1045
1047
  let stdout = "";
1046
1048
  let stderr = "";
1047
1049
  child.stdout.on("data", (b) => {
@@ -4,11 +4,47 @@ export declare const DEFAULT_PIPELINE_PATH = "workflow/pipeline-full-with-deep-a
4
4
  export declare function getPhaseMap(pipeline: PipelineFile): Map<string, PipelinePhase>;
5
5
  export declare function getPipelineLabel(pipelinePath: string): string;
6
6
  export declare function getNextEligiblePhase(state: WorkspaceState): PipelinePhase | null;
7
+ /** One phase the pipeline cannot reach, and the dependencies keeping it there. */
8
+ export interface BlockedPhase {
9
+ phaseId: string;
10
+ /** Each unmet `depends_on` entry, with why it will not clear on its own. */
11
+ missing: Array<{
12
+ dependencyId: string;
13
+ reason: "not-in-pipeline" | "blocked";
14
+ }>;
15
+ }
16
+ /**
17
+ * What the pipeline can do next. `getNextEligiblePhase` returned null both
18
+ * when every phase was complete and when the remaining phases waited on a
19
+ * dependency that would never clear, and every consumer read null as
20
+ * complete: a DAG with an unmet dependency reported 1/2 complete and unlocked
21
+ * the post-pipeline skills (#228). The third outcome is the difference.
22
+ */
23
+ export type PipelineOutcome = {
24
+ kind: "eligible";
25
+ phase: PipelinePhase;
26
+ } | {
27
+ kind: "complete";
28
+ } | {
29
+ kind: "stuck";
30
+ blocked: BlockedPhase[];
31
+ };
32
+ export declare function resolvePipelineOutcome(state: WorkspaceState): PipelineOutcome;
33
+ /** True when every phase in the active pipeline is complete. */
34
+ export declare function isPipelineComplete(state: WorkspaceState): boolean;
35
+ /**
36
+ * One sentence both surfaces print for a stuck pipeline, naming each blocked
37
+ * phase and the dependency keeping it there. The pipeline file is the thing to
38
+ * fix — or switch away from — so the sentence says so.
39
+ */
40
+ export declare function describeStuckPipeline(blocked: BlockedPhase[]): string;
7
41
  /**
8
42
  * Point `current_phase` and `next_actions` at whatever the engine finds
9
- * eligible now, or at the terminal routing when nothing is. Completion and a
10
- * pipeline switch both derive the cursor this way (#236), so status.yaml never
11
- * disagrees with the phase records it sits beside. Returns the eligible phase.
43
+ * eligible now, at the terminal routing when every phase is complete, or at
44
+ * the first blocked phase with the stuck sentence when nothing can run.
45
+ * Completion and a pipeline switch both derive the cursor this way (#236), so
46
+ * status.yaml never disagrees with the phase records it sits beside. Returns
47
+ * the eligible phase, or null.
12
48
  */
13
49
  export declare function recomputeCursor(state: WorkspaceState): PipelinePhase | null;
14
50
  /**
@@ -40,17 +40,71 @@ export function getNextEligiblePhase(state) {
40
40
  }
41
41
  return null;
42
42
  }
43
+ export function resolvePipelineOutcome(state) {
44
+ const phase = getNextEligiblePhase(state);
45
+ if (phase)
46
+ return { kind: "eligible", phase };
47
+ const phaseMap = getPhaseMap(state.pipeline);
48
+ const isComplete = (phaseId) => state.status.phases[phaseId]?.status === "complete";
49
+ const incomplete = state.pipeline.phase_order.filter((phaseId) => !isComplete(phaseId));
50
+ if (incomplete.length === 0)
51
+ return { kind: "complete" };
52
+ // Nothing is eligible and something is incomplete, so every incomplete
53
+ // phase has an unmet dependency. Each one is either a phase this pipeline
54
+ // does not declare, or one of the blocked phases themselves (a cycle, or a
55
+ // chain back to one).
56
+ const blocked = incomplete.map((phaseId) => ({
57
+ phaseId,
58
+ missing: (phaseMap.get(phaseId)?.depends_on ?? [])
59
+ .filter((dependencyId) => !isComplete(dependencyId))
60
+ .map((dependencyId) => ({
61
+ dependencyId,
62
+ reason: state.pipeline.phase_order.includes(dependencyId) ? "blocked" : "not-in-pipeline",
63
+ })),
64
+ }));
65
+ return { kind: "stuck", blocked };
66
+ }
67
+ /** True when every phase in the active pipeline is complete. */
68
+ export function isPipelineComplete(state) {
69
+ return resolvePipelineOutcome(state).kind === "complete";
70
+ }
71
+ /**
72
+ * One sentence both surfaces print for a stuck pipeline, naming each blocked
73
+ * phase and the dependency keeping it there. The pipeline file is the thing to
74
+ * fix — or switch away from — so the sentence says so.
75
+ */
76
+ export function describeStuckPipeline(blocked) {
77
+ const parts = blocked.map((entry) => {
78
+ const deps = entry.missing.map((m) => m.reason === "not-in-pipeline" ? `${m.dependencyId}, which is not in this pipeline` : `${m.dependencyId}, which is itself blocked`);
79
+ return `${entry.phaseId} depends on ${deps.join(" and ") || "nothing it can reach"}`;
80
+ });
81
+ return `Pipeline is stuck: ${parts.join("; ")}. No phase can run until the pipeline file's depends_on is fixed (or switch pipelines with codecarto_switch_pipeline / /codecarto-switch-pipeline).`;
82
+ }
43
83
  /**
44
84
  * Point `current_phase` and `next_actions` at whatever the engine finds
45
- * eligible now, or at the terminal routing when nothing is. Completion and a
46
- * pipeline switch both derive the cursor this way (#236), so status.yaml never
47
- * disagrees with the phase records it sits beside. Returns the eligible phase.
85
+ * eligible now, at the terminal routing when every phase is complete, or at
86
+ * the first blocked phase with the stuck sentence when nothing can run.
87
+ * Completion and a pipeline switch both derive the cursor this way (#236), so
88
+ * status.yaml never disagrees with the phase records it sits beside. Returns
89
+ * the eligible phase, or null.
48
90
  */
49
91
  export function recomputeCursor(state) {
50
- const next = getNextEligiblePhase(state);
51
- state.status.current_phase = next?.id ?? "complete";
52
- state.status.next_actions = next ? [beginPhaseAction(next)] : buildTerminalNextActions(state.status);
53
- return next;
92
+ const outcome = resolvePipelineOutcome(state);
93
+ if (outcome.kind === "eligible") {
94
+ state.status.current_phase = outcome.phase.id;
95
+ state.status.next_actions = [beginPhaseAction(outcome.phase)];
96
+ return outcome.phase;
97
+ }
98
+ if (outcome.kind === "stuck") {
99
+ // The cursor stays on the first phase that cannot run; "complete" is
100
+ // reserved for the state where nothing is left (#228).
101
+ state.status.current_phase = outcome.blocked[0]?.phaseId ?? "complete";
102
+ state.status.next_actions = [describeStuckPipeline(outcome.blocked)];
103
+ return null;
104
+ }
105
+ state.status.current_phase = "complete";
106
+ state.status.next_actions = buildTerminalNextActions(state.status);
107
+ return null;
54
108
  }
55
109
  /**
56
110
  * Phases status.yaml records as complete whose primary output is not on disk.
@@ -120,6 +120,13 @@ export async function buildPhasePrompt(state, phase, forced, options = {}) {
120
120
  const preflight = options.preflight ?? await runPhasePreflight(state, phase);
121
121
  const synthesisWorkflow = state.pipeline.workflow_name === "evidence-backed-project-synthesis";
122
122
  const handoffTemplateExists = await pathExists(join(state.workspaceDir, "templates", "phase-handoff.yaml"));
123
+ // The framework's own three files are the first reads on every phase, and
124
+ // after the first phase they are the same three files. Say so, so a host
125
+ // that carries context across phases spends it on the phase's inputs
126
+ // rather than re-reading the guide (self-audit F5). status.yaml is the
127
+ // exception: completion rewrote it, and it is the cursor.
128
+ const laterPhase = Object.values(state.status.phases).some((phaseState) => phaseState.status === "complete");
129
+ const unchangedNote = laterPhase ? " (framework-owned; unchanged since your last phase unless the scaffold was refreshed — skim rather than re-read if you still hold it)" : "";
123
130
  const lines = [
124
131
  `Read .codecarto/GUIDE.md and continue the CodeCartographer workflow for the phase \`${phase.id}\`.`,
125
132
  synthesisWorkflow
@@ -127,11 +134,11 @@ export async function buildPhasePrompt(state, phase, forced, options = {}) {
127
134
  : "Work on this phase only. The analyzed source code is the repository outside .codecarto/.",
128
135
  "",
129
136
  "Required reads before analysis:",
130
- "- .codecarto/GUIDE.md",
131
- "- .codecarto/workflow/status.yaml",
137
+ `- .codecarto/GUIDE.md${unchangedNote}`,
138
+ `- .codecarto/workflow/status.yaml${laterPhase ? " (rewritten by the last completion; read it)" : ""}`,
132
139
  ];
133
140
  if (handoffTemplateExists) {
134
- lines.push("- .codecarto/templates/phase-handoff.yaml");
141
+ lines.push(`- .codecarto/templates/phase-handoff.yaml${unchangedNote}`);
135
142
  }
136
143
  const primaryOutput = phase.primary_output ? `.codecarto/${phase.primary_output}` : undefined;
137
144
  if (primaryOutput) {
@@ -3,6 +3,14 @@ export declare const LOCK_RETRY_MS = 125;
3
3
  export declare const LOCK_TIMEOUT_MS = 5000;
4
4
  export declare const STALE_LOCK_MS = 60000;
5
5
  export declare function assertSafePhaseId(phaseId: string): void;
6
+ /**
7
+ * A YAML scalar as text: strings as written, numbers and booleans spelled
8
+ * back out. Files written before #225 hold owner notes such as `2048` or
9
+ * `true` bare, which the reader returns as a number or a boolean; dropping
10
+ * or crashing on those would lose real state, so they are read as the text
11
+ * they were. Anything else (null, arrays, objects) has no text.
12
+ */
13
+ export declare function textOf(value: unknown): string | null;
6
14
  export declare function ensureArray(value: unknown): string[];
7
15
  /**
8
16
  * Normalize a handoff's `open_question_closures` (#122, #186). Accepts both
@@ -13,8 +13,28 @@ export function assertSafePhaseId(phaseId) {
13
13
  throw new Error(`Invalid phase id: ${phaseId}`);
14
14
  }
15
15
  }
16
+ /**
17
+ * A YAML scalar as text: strings as written, numbers and booleans spelled
18
+ * back out. Files written before #225 hold owner notes such as `2048` or
19
+ * `true` bare, which the reader returns as a number or a boolean; dropping
20
+ * or crashing on those would lose real state, so they are read as the text
21
+ * they were. Anything else (null, arrays, objects) has no text.
22
+ */
23
+ export function textOf(value) {
24
+ if (typeof value === "string")
25
+ return value;
26
+ if (typeof value === "number" || typeof value === "boolean")
27
+ return String(value);
28
+ return null;
29
+ }
30
+ /** Like {@link textOf}, trimmed, and "" for a value that has no text. */
31
+ function trimmedText(value) {
32
+ return (textOf(value) ?? "").trim();
33
+ }
16
34
  export function ensureArray(value) {
17
- return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
35
+ if (!Array.isArray(value))
36
+ return [];
37
+ return value.map(textOf).filter((entry) => entry !== null);
18
38
  }
19
39
  function coerceEntry(value, allowTargetPhase) {
20
40
  if (typeof value === "string") {
@@ -27,22 +47,22 @@ function coerceEntry(value, allowTargetPhase) {
27
47
  return null;
28
48
  const raw = value;
29
49
  const entry = {};
30
- if (typeof raw.id === "string" && raw.id.trim())
31
- entry.id = raw.id.trim();
32
- if (typeof raw.kind === "string" && raw.kind.trim())
33
- entry.kind = raw.kind.trim();
34
- if (typeof raw.description === "string" && raw.description.trim())
35
- entry.description = raw.description.trim();
36
- if (typeof raw.deferred_reason === "string" && raw.deferred_reason.trim())
37
- entry.deferred_reason = raw.deferred_reason.trim();
38
- if (allowTargetPhase && typeof raw.target_phase === "string" && raw.target_phase.trim())
39
- entry.target_phase = raw.target_phase.trim();
50
+ if (trimmedText(raw.id))
51
+ entry.id = trimmedText(raw.id);
52
+ if (trimmedText(raw.kind))
53
+ entry.kind = trimmedText(raw.kind);
54
+ if (trimmedText(raw.description))
55
+ entry.description = trimmedText(raw.description);
56
+ if (trimmedText(raw.deferred_reason))
57
+ entry.deferred_reason = trimmedText(raw.deferred_reason);
58
+ if (allowTargetPhase && trimmedText(raw.target_phase))
59
+ entry.target_phase = trimmedText(raw.target_phase);
40
60
  // derives_from rides the same flag as target_phase: it is a carry-forward
41
61
  // concept only — the id of the open question this routed item answers one
42
62
  // candidate of (#122, #186). An open_questions entry has nothing to derive
43
63
  // from, so the field is dropped there rather than silently carried.
44
- if (allowTargetPhase && typeof raw.derives_from === "string" && raw.derives_from.trim())
45
- entry.derives_from = raw.derives_from.trim();
64
+ if (allowTargetPhase && trimmedText(raw.derives_from))
65
+ entry.derives_from = trimmedText(raw.derives_from);
46
66
  return Object.keys(entry).length > 0 ? entry : null;
47
67
  }
48
68
  /**
@@ -218,11 +238,13 @@ export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
218
238
  };
219
239
  }
220
240
  }
241
+ // Coerced, not assumed: a status.yaml written before #225 spells a
242
+ // digit-named project bare, and the reader returns a number for it.
221
243
  return {
222
- project_name: status.project_name?.trim() || basename(cwd),
223
- pipeline: status.pipeline?.trim() || pipelinePath,
224
- current_phase: status.current_phase?.trim() || pipeline.phase_order[0] || "complete",
225
- last_updated: status.last_updated?.trim() || "",
244
+ project_name: trimmedText(status.project_name) || basename(cwd),
245
+ pipeline: trimmedText(status.pipeline) || pipelinePath,
246
+ current_phase: trimmedText(status.current_phase) || pipeline.phase_order[0] || "complete",
247
+ last_updated: trimmedText(status.last_updated) || "",
226
248
  schema_version: typeof status.schema_version === "number" ? status.schema_version : 1,
227
249
  phases,
228
250
  next_actions: ensureArray(status.next_actions),