codecartographer-pi 0.19.6 → 0.20.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 +14 -0
- package/.codecarto/broadside/config.yaml +18 -0
- package/.codecarto/workflow/VALIDATE.md +2 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/dist/core/broadside.d.ts +72 -2
- package/dist/core/broadside.js +348 -63
- package/dist/core/completion.js +81 -22
- package/dist/core/dashboard-writer.d.ts +8 -0
- package/dist/core/dashboard-writer.js +159 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.js +4 -1
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +80 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +43 -10
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +8 -0
- package/dist/core/status.js +8 -3
- package/dist/core/synthesis.js +5 -2
- package/dist/core/workspace.d.ts +55 -8
- package/dist/core/workspace.js +115 -7
- package/dist/core/yaml.js +173 -14
- package/dist/extensions/codecarto/agent-rewriter.js +21 -14
- package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
- package/dist/extensions/codecarto/agent-runner.js +27 -9
- package/dist/extensions/codecarto/agent-state.d.ts +0 -2
- package/dist/extensions/codecarto/auto-runner.js +10 -6
- package/dist/extensions/codecarto/dashboard-narrator.js +9 -2
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
- package/dist/extensions/codecarto/dashboard-writer.js +5 -154
- package/dist/extensions/codecarto/index.js +65 -18
- package/dist/mcp-server/server.js +107 -49
- package/package.json +3 -2
package/dist/core/completion.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFile, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import { applyHandoff, autoAssignIds,
|
|
3
|
+
import { recomputeCursor, resolvePhase, validatePhaseOutput } from "./pipeline.js";
|
|
4
|
+
import { applyHandoff, autoAssignIds, loadHandoffFile, normalizeStatus } from "./status.js";
|
|
5
5
|
import { compareDottedVersions, dateOnly, newlineIfUnterminated, pathExists, uniqueStrings } from "./utils.js";
|
|
6
6
|
import { getWorkspaceState, updateStatusAtomically } from "./workspace.js";
|
|
7
7
|
/**
|
|
@@ -253,6 +253,80 @@ export function closureEvidenceGateActive(scaffoldVersion) {
|
|
|
253
253
|
const comparison = compareDottedVersions(scaffoldVersion, CLOSURE_EVIDENCE_GATE_SCAFFOLD_VERSION);
|
|
254
254
|
return comparison !== null && comparison >= 0;
|
|
255
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Whether `text` names one of `ids` as a whole token: the id must not run
|
|
258
|
+
* straight into another id character on either side, so `arch-CF2` matches
|
|
259
|
+
* "routed as `arch-CF2`" and not "arch-CF20".
|
|
260
|
+
*/
|
|
261
|
+
function mentionsAnyId(text, ids) {
|
|
262
|
+
for (const id of ids) {
|
|
263
|
+
let from = 0;
|
|
264
|
+
while (true) {
|
|
265
|
+
const at = text.indexOf(id, from);
|
|
266
|
+
if (at === -1)
|
|
267
|
+
break;
|
|
268
|
+
const before = at === 0 ? "" : text[at - 1];
|
|
269
|
+
const after = text[at + id.length] ?? "";
|
|
270
|
+
if (!/[A-Za-z0-9_-]/.test(before) && !/[A-Za-z0-9_-]/.test(after))
|
|
271
|
+
return true;
|
|
272
|
+
from = at + 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Turn each PARTIAL validation row into a `needs-maintainer-decision`
|
|
279
|
+
* question on the phase, unless the row's criterion or evidence cell names an
|
|
280
|
+
* entry the workspace already tracks: an open question, carry-forward, or
|
|
281
|
+
* post-pipeline item, from this handoff or an earlier phase. VALIDATE.md asks
|
|
282
|
+
* the phase to name the tracking entry in the evidence cell; honouring it is
|
|
283
|
+
* what stops a routed gap from also becoming a duplicate question that every
|
|
284
|
+
* later phase re-triages and someone must close (#239).
|
|
285
|
+
*
|
|
286
|
+
* Runs after the handoff has been applied. That is also what keeps the auto
|
|
287
|
+
* ids distinct from the handoff's: both `autoAssignIds` from `oq-<phase>-1`,
|
|
288
|
+
* and assigning the gaps first let a later id-less handoff question replace a
|
|
289
|
+
* gap under the same id.
|
|
290
|
+
*/
|
|
291
|
+
function addPartialRowQuestions(status, phaseId, rows) {
|
|
292
|
+
const phase = status.phases[phaseId];
|
|
293
|
+
if (!phase)
|
|
294
|
+
return;
|
|
295
|
+
const trackedIds = new Set();
|
|
296
|
+
for (const phaseState of Object.values(status.phases)) {
|
|
297
|
+
for (const entry of [...(phaseState.open_questions ?? []), ...(phaseState.carry_forward ?? [])]) {
|
|
298
|
+
if (entry.id)
|
|
299
|
+
trackedIds.add(entry.id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const entry of status.post_pipeline) {
|
|
303
|
+
if (entry.id)
|
|
304
|
+
trackedIds.add(entry.id);
|
|
305
|
+
}
|
|
306
|
+
const gapEntries = [];
|
|
307
|
+
for (const row of rows) {
|
|
308
|
+
if (!row.result.toUpperCase().includes("PARTIAL"))
|
|
309
|
+
continue;
|
|
310
|
+
if (mentionsAnyId(`${row.criterion} ${row.evidence}`, trackedIds))
|
|
311
|
+
continue;
|
|
312
|
+
const candidate = {
|
|
313
|
+
kind: "needs-maintainer-decision",
|
|
314
|
+
description: row.criterion || "Partial validation gap",
|
|
315
|
+
deferred_reason: row.evidence || "Marked PARTIAL by validation",
|
|
316
|
+
};
|
|
317
|
+
// Idempotent across re-runs of completion: the same gap is one question.
|
|
318
|
+
if (phase.open_questions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason))
|
|
319
|
+
continue;
|
|
320
|
+
gapEntries.push(candidate);
|
|
321
|
+
}
|
|
322
|
+
if (gapEntries.length === 0)
|
|
323
|
+
return;
|
|
324
|
+
// Reserve every id already on the phase so the new ones skip them; the
|
|
325
|
+
// placeholders keep autoAssignIds from touching the existing entries.
|
|
326
|
+
const reserved = phase.open_questions.filter((entry) => entry.id).map((entry) => ({ id: entry.id }));
|
|
327
|
+
autoAssignIds([...reserved, ...gapEntries], "oq", phaseId);
|
|
328
|
+
phase.open_questions.push(...gapEntries);
|
|
329
|
+
}
|
|
256
330
|
export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
257
331
|
const initialState = await getWorkspaceState(cwd);
|
|
258
332
|
if (!initialState)
|
|
@@ -391,20 +465,6 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
391
465
|
open_questions: [],
|
|
392
466
|
carry_forward: [],
|
|
393
467
|
};
|
|
394
|
-
const gapEntries = lockedValidation.rows
|
|
395
|
-
.filter((row) => row.result.toUpperCase().includes("PARTIAL"))
|
|
396
|
-
.map((row) => ({
|
|
397
|
-
kind: "needs-maintainer-decision",
|
|
398
|
-
description: row.criterion || "Partial validation gap",
|
|
399
|
-
deferred_reason: row.evidence || "Marked PARTIAL by validation",
|
|
400
|
-
}));
|
|
401
|
-
autoAssignIds(gapEntries, "oq", validation.phaseId);
|
|
402
|
-
const mergedOpenQuestions = [...existingPhase.open_questions];
|
|
403
|
-
for (const candidate of gapEntries) {
|
|
404
|
-
if (!mergedOpenQuestions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason)) {
|
|
405
|
-
mergedOpenQuestions.push(candidate);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
468
|
nextStatus.phases[validation.phaseId] = {
|
|
409
469
|
status: "complete",
|
|
410
470
|
owner_notes: uniqueStrings([
|
|
@@ -414,18 +474,17 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
414
474
|
`Validation: ${lockedValidation.overall}`,
|
|
415
475
|
]),
|
|
416
476
|
outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
|
|
417
|
-
open_questions:
|
|
477
|
+
open_questions: [...existingPhase.open_questions],
|
|
418
478
|
carry_forward: existingPhase.carry_forward ?? [],
|
|
419
479
|
};
|
|
420
480
|
if (handoff)
|
|
421
481
|
applyHandoff(nextStatus, handoff);
|
|
482
|
+
// After the handoff, so a gap the handoff routed is recognised as
|
|
483
|
+
// tracked and the ids the handoff introduced are known (#239).
|
|
484
|
+
addPartialRowQuestions(nextStatus, validation.phaseId, lockedValidation.rows);
|
|
422
485
|
nextStatus.last_updated = completionTimestamp;
|
|
423
486
|
const nextWorkspace = { ...lockedState, status: nextStatus };
|
|
424
|
-
|
|
425
|
-
nextStatus.current_phase = nextEligible?.id ?? "complete";
|
|
426
|
-
nextStatus.next_actions = nextEligible
|
|
427
|
-
? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
|
|
428
|
-
: buildTerminalNextActions(nextStatus);
|
|
487
|
+
recomputeCursor(nextWorkspace);
|
|
429
488
|
return {
|
|
430
489
|
state: { ...nextWorkspace, status: nextStatus },
|
|
431
490
|
// The closeout, THREAD_LOG line, decision rows, and staged proposals
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render and atomically replace `.codecarto/dashboard.html`.
|
|
3
|
+
* @returns true when a fresh dashboard landed on disk; false when the
|
|
4
|
+
* workspace is missing or any gather/render/write step failed (swallowed —
|
|
5
|
+
* lifecycle callers must never fail on a dashboard problem, but they may
|
|
6
|
+
* report truthfully whether a refresh happened).
|
|
7
|
+
*/
|
|
8
|
+
export declare function writeDashboard(cwd: string, packageVersion: string): Promise<boolean>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// I/O wrapper that gathers all dashboard inputs and writes the rendered
|
|
2
|
+
// HTML to `.codecarto/dashboard.html`. Best-effort — failures are swallowed
|
|
3
|
+
// and never escalate to a phase error the user sees, mirroring the
|
|
4
|
+
// recordUsage discipline at extensions/codecarto/index.ts.
|
|
5
|
+
//
|
|
6
|
+
// Lived in the Pi extension until #254, with the MCP server reaching across
|
|
7
|
+
// to import it; both surfaces refresh the dashboard at the same lifecycle
|
|
8
|
+
// points (init, completion, amendment, pipeline switch, on demand), so it is
|
|
9
|
+
// a core primitive like everything else they share.
|
|
10
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { atomicWriteFile, DASHBOARD_RELATIVE_PATH, getWorkspaceState, loadUsage, NARRATION_CACHE_RELATIVE_PATH, parseSimpleYaml, pathExists, renderDashboard, } from "./index.js";
|
|
13
|
+
const CLOSEOUT_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/;
|
|
14
|
+
/**
|
|
15
|
+
* Render and atomically replace `.codecarto/dashboard.html`.
|
|
16
|
+
* @returns true when a fresh dashboard landed on disk; false when the
|
|
17
|
+
* workspace is missing or any gather/render/write step failed (swallowed —
|
|
18
|
+
* lifecycle callers must never fail on a dashboard problem, but they may
|
|
19
|
+
* report truthfully whether a refresh happened).
|
|
20
|
+
*/
|
|
21
|
+
export async function writeDashboard(cwd, packageVersion) {
|
|
22
|
+
try {
|
|
23
|
+
const state = await getWorkspaceState(cwd);
|
|
24
|
+
if (!state)
|
|
25
|
+
return false;
|
|
26
|
+
const workspaceDir = state.workspaceDir;
|
|
27
|
+
const [usage, closeouts, outputsPresent, narration] = await Promise.all([
|
|
28
|
+
loadUsage(workspaceDir),
|
|
29
|
+
listCloseouts(workspaceDir),
|
|
30
|
+
buildOutputsPresent(state.workspaceDir, state.pipeline),
|
|
31
|
+
loadNarration(workspaceDir),
|
|
32
|
+
]);
|
|
33
|
+
const inputs = {
|
|
34
|
+
status: state.status,
|
|
35
|
+
pipeline: state.pipeline,
|
|
36
|
+
usage,
|
|
37
|
+
closeouts,
|
|
38
|
+
outputsPresent,
|
|
39
|
+
packageVersion,
|
|
40
|
+
generatedAt: new Date().toISOString(),
|
|
41
|
+
narration,
|
|
42
|
+
};
|
|
43
|
+
const html = renderDashboard(inputs);
|
|
44
|
+
await atomicWriteFile(join(workspaceDir, DASHBOARD_RELATIVE_PATH), html);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Best-effort: a failed dashboard write must not surface as a phase
|
|
49
|
+
// error. The user's pipeline state is unaffected; the next state
|
|
50
|
+
// change will trigger another render attempt.
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function listCloseouts(workspaceDir) {
|
|
55
|
+
const dir = join(workspaceDir, "closeouts");
|
|
56
|
+
if (!(await pathExists(dir)))
|
|
57
|
+
return [];
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(dir);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const name of entries) {
|
|
67
|
+
const m = CLOSEOUT_FILENAME_RE.exec(name);
|
|
68
|
+
if (!m)
|
|
69
|
+
continue;
|
|
70
|
+
out.push({ date: m[1], phaseOrModule: m[2], fileName: name, summary: await readCloseoutSummary(join(dir, name)) });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
async function readCloseoutSummary(path) {
|
|
75
|
+
try {
|
|
76
|
+
const raw = await readFile(path, "utf8");
|
|
77
|
+
const lines = raw.split(/\r?\n/);
|
|
78
|
+
const summaryStart = lines.findIndex((line) => /^##\s+Summary\s*$/i.test(line.trim()));
|
|
79
|
+
if (summaryStart === -1)
|
|
80
|
+
return undefined;
|
|
81
|
+
const body = [];
|
|
82
|
+
for (const line of lines.slice(summaryStart + 1)) {
|
|
83
|
+
if (/^##\s+/.test(line.trim()))
|
|
84
|
+
break;
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
if (!trimmed || trimmed === "-")
|
|
87
|
+
continue;
|
|
88
|
+
body.push(trimmed.replace(/^[-*]\s+/, ""));
|
|
89
|
+
if (body.join(" ").length > 280)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
const summary = body.join(" ").trim();
|
|
93
|
+
return summary ? `${summary.slice(0, 280)}${summary.length > 280 ? "…" : ""}` : undefined;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function buildOutputsPresent(workspaceDir, pipeline) {
|
|
100
|
+
const out = new Map();
|
|
101
|
+
for (const phaseId of pipeline.phase_order) {
|
|
102
|
+
const phaseDef = pipeline.phases.find((p) => p.id === phaseId);
|
|
103
|
+
if (!phaseDef)
|
|
104
|
+
continue;
|
|
105
|
+
const entry = { secondary: [] };
|
|
106
|
+
if (phaseDef.primary_output) {
|
|
107
|
+
entry.primary = {
|
|
108
|
+
path: phaseDef.primary_output,
|
|
109
|
+
exists: await pathExists(join(workspaceDir, phaseDef.primary_output)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
for (const sec of phaseDef.secondary_outputs ?? []) {
|
|
113
|
+
entry.secondary.push({
|
|
114
|
+
path: sec.path,
|
|
115
|
+
exists: await pathExists(join(workspaceDir, sec.path)),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
out.set(phaseId, entry);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
async function loadNarration(workspaceDir) {
|
|
123
|
+
const path = join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH);
|
|
124
|
+
if (!(await pathExists(path)))
|
|
125
|
+
return undefined;
|
|
126
|
+
try {
|
|
127
|
+
const raw = await readFile(path, "utf8");
|
|
128
|
+
const { frontmatter, body } = splitFrontmatter(raw);
|
|
129
|
+
if (!frontmatter)
|
|
130
|
+
return undefined;
|
|
131
|
+
const generatedAt = typeof frontmatter.generatedAt === "string" ? frontmatter.generatedAt : "";
|
|
132
|
+
const phaseCountAtGeneration = typeof frontmatter.phaseCountAtGeneration === "number" ? frontmatter.phaseCountAtGeneration : 0;
|
|
133
|
+
if (!generatedAt)
|
|
134
|
+
return undefined;
|
|
135
|
+
return { content: body.trim(), generatedAt, phaseCountAtGeneration };
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function splitFrontmatter(raw) {
|
|
142
|
+
if (!raw.startsWith("---\n"))
|
|
143
|
+
return { frontmatter: null, body: raw };
|
|
144
|
+
const end = raw.indexOf("\n---\n", 4);
|
|
145
|
+
if (end === -1)
|
|
146
|
+
return { frontmatter: null, body: raw };
|
|
147
|
+
const yamlText = raw.slice(4, end);
|
|
148
|
+
const body = raw.slice(end + 5);
|
|
149
|
+
try {
|
|
150
|
+
const parsed = parseSimpleYaml(yamlText);
|
|
151
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
152
|
+
return { frontmatter: parsed, body };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// fall through
|
|
157
|
+
}
|
|
158
|
+
return { frontmatter: null, body };
|
|
159
|
+
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
package/dist/core/library.js
CHANGED
|
@@ -676,7 +676,10 @@ export async function listEntries(libraryRoot, filter = {}) {
|
|
|
676
676
|
return false;
|
|
677
677
|
if (filter.slug !== undefined && e.slug !== filter.slug)
|
|
678
678
|
return false;
|
|
679
|
-
|
|
679
|
+
// Same equivalence the publish guard applies: `.git`, scheme, userinfo,
|
|
680
|
+
// default port, and forge-host case do not make two references two
|
|
681
|
+
// repositories, so they must not make a filter miss one either (#257).
|
|
682
|
+
if (filter.source_repo !== undefined && !sameSourceRepo(e.source_repo, filter.source_repo))
|
|
680
683
|
return false;
|
|
681
684
|
if (filter.tag !== undefined && !e.tags.includes(filter.tag))
|
|
682
685
|
return false;
|
|
@@ -24,9 +24,24 @@ export interface LibraryConfig {
|
|
|
24
24
|
* to hosts that actually configured the key. */
|
|
25
25
|
publish_confirm_configured: boolean;
|
|
26
26
|
}
|
|
27
|
+
/** One thing a config file said that the loader could not use. */
|
|
28
|
+
export interface ConfigProblem {
|
|
29
|
+
/** The config file the problem was found in. */
|
|
30
|
+
path: string;
|
|
31
|
+
/** What was wrong and what the loader did about it. */
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
27
34
|
export interface CodecartoConfig {
|
|
28
35
|
orchestrator: OrchestratorConfig;
|
|
29
36
|
library: LibraryConfig;
|
|
37
|
+
/**
|
|
38
|
+
* Faults in the config files that were read, in layer order (user-global
|
|
39
|
+
* first). Empty when every file that exists was used in full. The loader
|
|
40
|
+
* never throws on a bad file — a phase run should not be blocked by a
|
|
41
|
+
* typo in a toggle — but publish refuses while this is non-empty, since
|
|
42
|
+
* the library path and the confirm gate come from here.
|
|
43
|
+
*/
|
|
44
|
+
problems: ConfigProblem[];
|
|
30
45
|
}
|
|
31
46
|
export declare const CONFIG_RELATIVE_PATH = "workflow/config.yaml";
|
|
32
47
|
export declare const USER_CONFIG_DIR: string;
|
|
@@ -56,15 +71,25 @@ export declare function loadCodecartoConfig(workspaceDir: PathLike): Promise<Cod
|
|
|
56
71
|
*/
|
|
57
72
|
export declare function loadUserConfig(): Promise<CodecartoConfig>;
|
|
58
73
|
/**
|
|
59
|
-
* Apply one raw config layer over
|
|
74
|
+
* Apply one raw config layer over the defaults. Public so tests can
|
|
60
75
|
* exercise layering without filesystem fixtures, and so wrappers can mock
|
|
61
|
-
* a layer in memory (e.g. "what if library_path were X").
|
|
76
|
+
* a layer in memory (e.g. "what if library_path were X"). `sourcePath`
|
|
77
|
+
* names the layer in any problem it produces.
|
|
78
|
+
*/
|
|
79
|
+
export declare function mergeConfig(raw: RawConfig | null | undefined, sourcePath?: string): CodecartoConfig;
|
|
80
|
+
/**
|
|
81
|
+
* The lines both surfaces print for a config with problems: one header,
|
|
82
|
+
* then one line per fault naming its file. Empty when there are none.
|
|
62
83
|
*/
|
|
63
|
-
export declare function
|
|
84
|
+
export declare function describeConfigProblems(config: CodecartoConfig): string[];
|
|
64
85
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
86
|
+
* Record a library in a config file (user-global or workspace): set
|
|
87
|
+
* `library.path`, and `library.namespace` when one is given. Every other key
|
|
88
|
+
* in the file, `library.publish_confirm` included, is left exactly as it was
|
|
89
|
+
* — library-init writes only what it was asked for, so it cannot switch the
|
|
90
|
+
* MCP confirm gate on behind the user's back (#244). Creates the file and
|
|
91
|
+
* parent directories if needed. A file that exists but does not parse is
|
|
92
|
+
* never overwritten: the caller is told to fix it first.
|
|
68
93
|
*/
|
|
69
|
-
export declare function writeLibraryConfig(configPath: string, libraryPath: string, namespace?: string | null
|
|
94
|
+
export declare function writeLibraryConfig(configPath: string, libraryPath: string, namespace?: string | null): Promise<void>;
|
|
70
95
|
export {};
|
|
@@ -8,13 +8,19 @@
|
|
|
8
8
|
// workspace. Overrides individual keys from the user-global layer.
|
|
9
9
|
//
|
|
10
10
|
// Resolution order (top wins): per-workspace > user-global > defaults.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// A missing file at either layer falls back to defaults. A file that exists
|
|
12
|
+
// but cannot be used — unparseable YAML, a section that is not a mapping, a
|
|
13
|
+
// key of the wrong type, a relative `library.path` — is dropped at the
|
|
14
|
+
// granularity of the fault and the fault is recorded in `problems`, so the
|
|
15
|
+
// tools can say which file and which key rather than silently answering
|
|
16
|
+
// from the wrong settings (#242, #243).
|
|
13
17
|
//
|
|
14
|
-
// `library.path` is returned tilde-expanded and absolute
|
|
15
|
-
//
|
|
18
|
+
// `library.path` is returned tilde-expanded and absolute. A relative value
|
|
19
|
+
// is refused: it would resolve against wherever the MCP server or Pi was
|
|
20
|
+
// launched, not against the config file, so the library would move with the
|
|
21
|
+
// launch directory.
|
|
16
22
|
import { homedir } from "node:os";
|
|
17
|
-
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
18
24
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
19
25
|
import { expandTilde, pathExists } from "./utils.js";
|
|
20
26
|
import { loadYamlFile, parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
|
|
@@ -40,11 +46,12 @@ const DEFAULT_CONFIG = {
|
|
|
40
46
|
publish_confirm: true,
|
|
41
47
|
publish_confirm_configured: false,
|
|
42
48
|
},
|
|
49
|
+
problems: [],
|
|
43
50
|
};
|
|
44
51
|
export async function loadCodecartoConfig(workspaceDir) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
return mergeLayered([
|
|
52
|
+
const user = await loadRawIfExists(resolveUserConfigPath());
|
|
53
|
+
const workspace = await loadRawIfExists(join(workspaceDir, CONFIG_RELATIVE_PATH));
|
|
54
|
+
return mergeLayered([user, workspace]);
|
|
48
55
|
}
|
|
49
56
|
/**
|
|
50
57
|
* Read the user-global config directly. Exposed so wrappers can show
|
|
@@ -52,57 +59,113 @@ export async function loadCodecartoConfig(workspaceDir) {
|
|
|
52
59
|
* load a workspace first.
|
|
53
60
|
*/
|
|
54
61
|
export async function loadUserConfig() {
|
|
55
|
-
|
|
56
|
-
return mergeLayered([userRaw]);
|
|
62
|
+
return mergeLayered([await loadRawIfExists(resolveUserConfigPath())]);
|
|
57
63
|
}
|
|
58
64
|
async function loadRawIfExists(path) {
|
|
59
65
|
if (!(await pathExists(path)))
|
|
60
|
-
return null;
|
|
66
|
+
return { path, raw: null };
|
|
61
67
|
try {
|
|
62
|
-
|
|
68
|
+
const parsed = await loadYamlFile(path);
|
|
69
|
+
if (parsed === null || parsed === undefined)
|
|
70
|
+
return { path, raw: null };
|
|
71
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
72
|
+
return { path, raw: null, problem: { path, message: "the file is not a YAML mapping; the whole file was ignored" } };
|
|
73
|
+
}
|
|
74
|
+
return { path, raw: parsed };
|
|
63
75
|
}
|
|
64
|
-
catch {
|
|
65
|
-
|
|
76
|
+
catch (error) {
|
|
77
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
78
|
+
return { path, raw: null, problem: { path, message: `could not be parsed (${reason}); the whole file was ignored` } };
|
|
66
79
|
}
|
|
67
80
|
}
|
|
68
81
|
function mergeLayered(layers) {
|
|
69
82
|
let merged = cloneDefault();
|
|
70
|
-
for (const layer of layers)
|
|
71
|
-
|
|
83
|
+
for (const layer of layers) {
|
|
84
|
+
if (layer.problem) {
|
|
85
|
+
merged.problems.push(layer.problem);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
merged = applyRaw(merged, layer.raw, layer.path);
|
|
89
|
+
}
|
|
72
90
|
return merged;
|
|
73
91
|
}
|
|
74
92
|
/**
|
|
75
|
-
* Apply one raw config layer over
|
|
93
|
+
* Apply one raw config layer over the defaults. Public so tests can
|
|
76
94
|
* exercise layering without filesystem fixtures, and so wrappers can mock
|
|
77
|
-
* a layer in memory (e.g. "what if library_path were X").
|
|
95
|
+
* a layer in memory (e.g. "what if library_path were X"). `sourcePath`
|
|
96
|
+
* names the layer in any problem it produces.
|
|
78
97
|
*/
|
|
79
|
-
export function mergeConfig(raw) {
|
|
80
|
-
return applyRaw(cloneDefault(), raw);
|
|
98
|
+
export function mergeConfig(raw, sourcePath = "(in-memory config)") {
|
|
99
|
+
return applyRaw(cloneDefault(), raw, sourcePath);
|
|
81
100
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The lines both surfaces print for a config with problems: one header,
|
|
103
|
+
* then one line per fault naming its file. Empty when there are none.
|
|
104
|
+
*/
|
|
105
|
+
export function describeConfigProblems(config) {
|
|
106
|
+
if (config.problems.length === 0)
|
|
107
|
+
return [];
|
|
108
|
+
const noun = config.problems.length === 1 ? "problem" : "problems";
|
|
109
|
+
return [
|
|
110
|
+
`Config ${noun} (${config.problems.length}) — these settings are not in effect:`,
|
|
111
|
+
...config.problems.map((problem) => ` - ${problem.path}: ${problem.message}`),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
function applyRaw(base, raw, sourcePath) {
|
|
85
115
|
const out = {
|
|
86
116
|
orchestrator: { ...base.orchestrator },
|
|
87
117
|
library: { ...base.library },
|
|
118
|
+
problems: [...base.problems],
|
|
88
119
|
};
|
|
120
|
+
if (!raw || typeof raw !== "object")
|
|
121
|
+
return out;
|
|
122
|
+
const problem = (message) => out.problems.push({ path: sourcePath, message });
|
|
123
|
+
const describe = (value) => (typeof value === "string" ? JSON.stringify(value) : String(value));
|
|
89
124
|
const o = raw.orchestrator;
|
|
90
|
-
if (o
|
|
91
|
-
if (typeof o
|
|
92
|
-
|
|
125
|
+
if (o !== undefined) {
|
|
126
|
+
if (!o || typeof o !== "object" || Array.isArray(o)) {
|
|
127
|
+
problem("orchestrator must be a mapping; the section was ignored");
|
|
128
|
+
}
|
|
129
|
+
else if (o.llm_steer_next_phase !== undefined) {
|
|
130
|
+
if (typeof o.llm_steer_next_phase === "boolean") {
|
|
131
|
+
out.orchestrator.llm_steer_next_phase = o.llm_steer_next_phase;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
problem(`orchestrator.llm_steer_next_phase must be true or false, got ${describe(o.llm_steer_next_phase)}; the key was ignored`);
|
|
135
|
+
}
|
|
93
136
|
}
|
|
94
137
|
}
|
|
95
138
|
const l = raw.library;
|
|
96
|
-
if (l
|
|
97
|
-
if (typeof l
|
|
98
|
-
|
|
139
|
+
if (l !== undefined) {
|
|
140
|
+
if (!l || typeof l !== "object" || Array.isArray(l)) {
|
|
141
|
+
problem("library must be a mapping; the section was ignored");
|
|
99
142
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
143
|
+
else {
|
|
144
|
+
if (typeof l.path === "string" && l.path.trim() !== "") {
|
|
145
|
+
const expanded = expandTilde(l.path.trim());
|
|
146
|
+
if (isAbsolute(expanded)) {
|
|
147
|
+
out.library.path = resolve(expanded);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
problem(`library.path must be absolute or start with ~ (got ${describe(l.path.trim())}); the key was ignored`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if (l.path !== undefined && l.path !== null && typeof l.path !== "string") {
|
|
154
|
+
problem(`library.path must be a string, got ${describe(l.path)}; the key was ignored`);
|
|
155
|
+
}
|
|
156
|
+
if (typeof l.namespace === "string" && l.namespace.trim() !== "") {
|
|
157
|
+
out.library.namespace = l.namespace.trim();
|
|
158
|
+
}
|
|
159
|
+
else if (l.namespace !== undefined && l.namespace !== null && typeof l.namespace !== "string") {
|
|
160
|
+
problem(`library.namespace must be a string, got ${describe(l.namespace)}; the key was ignored`);
|
|
161
|
+
}
|
|
162
|
+
if (typeof l.publish_confirm === "boolean") {
|
|
163
|
+
out.library.publish_confirm = l.publish_confirm;
|
|
164
|
+
out.library.publish_confirm_configured = true;
|
|
165
|
+
}
|
|
166
|
+
else if (l.publish_confirm !== undefined) {
|
|
167
|
+
problem(`library.publish_confirm must be true or false, got ${describe(l.publish_confirm)}; the key was ignored`);
|
|
168
|
+
}
|
|
106
169
|
}
|
|
107
170
|
}
|
|
108
171
|
return out;
|
|
@@ -111,25 +174,42 @@ function cloneDefault() {
|
|
|
111
174
|
return {
|
|
112
175
|
orchestrator: { ...DEFAULT_CONFIG.orchestrator },
|
|
113
176
|
library: { ...DEFAULT_CONFIG.library },
|
|
177
|
+
problems: [],
|
|
114
178
|
};
|
|
115
179
|
}
|
|
116
180
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
181
|
+
* Record a library in a config file (user-global or workspace): set
|
|
182
|
+
* `library.path`, and `library.namespace` when one is given. Every other key
|
|
183
|
+
* in the file, `library.publish_confirm` included, is left exactly as it was
|
|
184
|
+
* — library-init writes only what it was asked for, so it cannot switch the
|
|
185
|
+
* MCP confirm gate on behind the user's back (#244). Creates the file and
|
|
186
|
+
* parent directories if needed. A file that exists but does not parse is
|
|
187
|
+
* never overwritten: the caller is told to fix it first.
|
|
120
188
|
*/
|
|
121
|
-
export async function writeLibraryConfig(configPath, libraryPath, namespace = null
|
|
189
|
+
export async function writeLibraryConfig(configPath, libraryPath, namespace = null) {
|
|
122
190
|
let existing = {};
|
|
123
191
|
if (await pathExists(configPath)) {
|
|
192
|
+
const raw = await readFile(configPath, "utf8");
|
|
193
|
+
let parsed;
|
|
124
194
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
195
|
+
parsed = parseSimpleYaml(raw);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
199
|
+
throw new Error(`Refusing to rewrite ${configPath}: it could not be parsed (${reason}). Fix or remove the file, then run library-init again.`);
|
|
127
200
|
}
|
|
128
|
-
|
|
129
|
-
|
|
201
|
+
if (parsed !== null && parsed !== undefined) {
|
|
202
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
203
|
+
throw new Error(`Refusing to rewrite ${configPath}: it is not a YAML mapping. Fix or remove the file, then run library-init again.`);
|
|
204
|
+
}
|
|
205
|
+
existing = parsed;
|
|
130
206
|
}
|
|
131
207
|
}
|
|
132
|
-
const
|
|
208
|
+
const previous = existing.library;
|
|
209
|
+
const library = previous && typeof previous === "object" && !Array.isArray(previous)
|
|
210
|
+
? { ...previous }
|
|
211
|
+
: {};
|
|
212
|
+
library.path = libraryPath;
|
|
133
213
|
if (namespace)
|
|
134
214
|
library.namespace = namespace;
|
|
135
215
|
const updated = { ...existing, library };
|