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.
- package/.codecarto/GUIDE.md +1 -1
- package/.codecarto/broadside/SKILL.md +7 -3
- package/.codecarto/broadside/config.yaml +17 -9
- package/.codecarto/findings/contracts/SKILL.md +4 -1
- package/.codecarto/findings/defect-scan/SKILL.md +10 -0
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +6 -0
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +8 -1
- package/.codecarto/findings/porting/SKILL.md +4 -0
- package/.codecarto/findings/protocols/SKILL.md +4 -0
- package/.codecarto/templates/mechanical-defects.md +15 -0
- package/.codecarto/templates/reimplementation-spec.md +5 -3
- package/.codecarto/templates/reverse-engineering-bundle.md +10 -1
- package/.codecarto/templates/semantic-defects.md +15 -0
- package/.codecarto/workflow/VALIDATE.md +1 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +3 -3
- package/dist/core/amendment.js +9 -4
- package/dist/core/broadside.d.ts +49 -0
- package/dist/core/broadside.js +131 -30
- package/dist/core/completion.js +7 -1
- package/dist/core/library.js +5 -3
- package/dist/core/pipeline.d.ts +39 -3
- package/dist/core/pipeline.js +61 -7
- package/dist/core/prompts.js +10 -3
- package/dist/core/status.d.ts +8 -0
- package/dist/core/status.js +39 -17
- package/dist/core/utils.d.ts +7 -0
- package/dist/core/utils.js +7 -0
- package/dist/core/workspace.js +2 -2
- package/dist/core/yaml.js +8 -1
- package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
- package/dist/extensions/codecarto/auto-runner.js +18 -3
- package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
- package/dist/extensions/codecarto/broadside-flags.js +12 -0
- package/dist/extensions/codecarto/index.js +95 -31
- package/dist/extensions/codecarto/phase-compaction.js +5 -1
- package/dist/mcp-server/server.d.ts +3 -1
- package/dist/mcp-server/server.js +99 -29
- 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 { 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(".")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1535
|
+
parsed = await loadYamlFile(configPath);
|
|
1477
1536
|
}
|
|
1478
|
-
catch {
|
|
1479
|
-
|
|
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
|
-
|
|
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() -
|
|
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:
|
|
1654
|
-
fetched_at:
|
|
1655
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 &&
|
|
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 &&
|
|
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;
|
package/dist/core/completion.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/core/library.js
CHANGED
|
@@ -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 \`
|
|
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
|
-
|
|
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) => {
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -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,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
/**
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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,
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
package/dist/core/prompts.js
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
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(
|
|
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) {
|
package/dist/core/status.d.ts
CHANGED
|
@@ -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
|
package/dist/core/status.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
31
|
-
entry.id = raw.id
|
|
32
|
-
if (
|
|
33
|
-
entry.kind = raw.kind
|
|
34
|
-
if (
|
|
35
|
-
entry.description = raw.description
|
|
36
|
-
if (
|
|
37
|
-
entry.deferred_reason = raw.deferred_reason
|
|
38
|
-
if (allowTargetPhase &&
|
|
39
|
-
entry.target_phase = raw.target_phase
|
|
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 &&
|
|
45
|
-
entry.derives_from = raw.derives_from
|
|
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
|
|
223
|
-
pipeline: status.pipeline
|
|
224
|
-
current_phase: status.current_phase
|
|
225
|
-
last_updated: status.last_updated
|
|
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),
|