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.
Files changed (39) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +14 -0
  3. package/.codecarto/broadside/config.yaml +18 -0
  4. package/.codecarto/workflow/VALIDATE.md +2 -1
  5. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  6. package/README.md +10 -6
  7. package/dist/core/broadside.d.ts +72 -2
  8. package/dist/core/broadside.js +348 -63
  9. package/dist/core/completion.js +81 -22
  10. package/dist/core/dashboard-writer.d.ts +8 -0
  11. package/dist/core/dashboard-writer.js +159 -0
  12. package/dist/core/index.d.ts +2 -0
  13. package/dist/core/index.js +2 -0
  14. package/dist/core/library.js +4 -1
  15. package/dist/core/orchestrator-config.d.ts +32 -7
  16. package/dist/core/orchestrator-config.js +124 -44
  17. package/dist/core/pipeline.d.ts +37 -0
  18. package/dist/core/pipeline.js +80 -10
  19. package/dist/core/prompts.d.ts +20 -0
  20. package/dist/core/prompts.js +43 -10
  21. package/dist/core/secrets.d.ts +16 -0
  22. package/dist/core/secrets.js +98 -0
  23. package/dist/core/status.d.ts +8 -0
  24. package/dist/core/status.js +8 -3
  25. package/dist/core/synthesis.js +5 -2
  26. package/dist/core/workspace.d.ts +55 -8
  27. package/dist/core/workspace.js +115 -7
  28. package/dist/core/yaml.js +173 -14
  29. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  30. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  31. package/dist/extensions/codecarto/agent-runner.js +27 -9
  32. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  33. package/dist/extensions/codecarto/auto-runner.js +10 -6
  34. package/dist/extensions/codecarto/dashboard-narrator.js +9 -2
  35. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  36. package/dist/extensions/codecarto/dashboard-writer.js +5 -154
  37. package/dist/extensions/codecarto/index.js +65 -18
  38. package/dist/mcp-server/server.js +107 -49
  39. package/package.json +3 -2
@@ -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 { getNextEligiblePhase, resolvePhase, validatePhaseOutput } from "./pipeline.js";
4
- import { applyHandoff, autoAssignIds, buildTerminalNextActions, loadHandoffFile, normalizeStatus } from "./status.js";
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: mergedOpenQuestions,
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
- const nextEligible = getNextEligiblePhase(nextWorkspace);
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
+ }
@@ -16,3 +16,5 @@ export * from "./dashboard.ts";
16
16
  export * from "./library.ts";
17
17
  export * from "./synthesis.ts";
18
18
  export * from "./broadside.ts";
19
+ export * from "./secrets.ts";
20
+ export * from "./dashboard-writer.ts";
@@ -19,3 +19,5 @@ export * from "./dashboard.js";
19
19
  export * from "./library.js";
20
20
  export * from "./synthesis.js";
21
21
  export * from "./broadside.js";
22
+ export * from "./secrets.js";
23
+ export * from "./dashboard-writer.js";
@@ -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
- if (filter.source_repo !== undefined && e.source_repo !== filter.source_repo)
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 an existing config. Public so tests can
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 mergeConfig(raw: RawConfig | null | undefined): CodecartoConfig;
84
+ export declare function describeConfigProblems(config: CodecartoConfig): string[];
64
85
  /**
65
- * Write a `library:` block into a config file (user-global or workspace).
66
- * Creates the file and parent directories if needed. Preserves any existing
67
- * `orchestrator:` block. Overwrites the `library:` block if present.
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, publishConfirm?: boolean): Promise<void>;
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
- // Missing files at either layer fall back to defaults; malformed YAML
12
- // at either layer is non-fatal (drops the layer, logs nothing).
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 so consumers
15
- // don't have to expand themselves.
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 userRaw = await loadRawIfExists(resolveUserConfigPath());
46
- const workspaceRaw = await loadRawIfExists(join(workspaceDir, CONFIG_RELATIVE_PATH));
47
- return mergeLayered([userRaw, workspaceRaw]);
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
- const userRaw = await loadRawIfExists(resolveUserConfigPath());
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
- return await loadYamlFile(path);
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
- return null;
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
- merged = applyRaw(merged, layer);
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 an existing config. Public so tests can
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
- function applyRaw(base, raw) {
83
- if (!raw || typeof raw !== "object")
84
- return base;
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 && typeof o === "object") {
91
- if (typeof o.llm_steer_next_phase === "boolean") {
92
- out.orchestrator.llm_steer_next_phase = o.llm_steer_next_phase;
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 && typeof l === "object") {
97
- if (typeof l.path === "string" && l.path.trim() !== "") {
98
- out.library.path = resolve(expandTilde(l.path.trim()));
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
- if (typeof l.namespace === "string" && l.namespace.trim() !== "") {
101
- out.library.namespace = l.namespace.trim();
102
- }
103
- if (typeof l.publish_confirm === "boolean") {
104
- out.library.publish_confirm = l.publish_confirm;
105
- out.library.publish_confirm_configured = true;
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
- * Write a `library:` block into a config file (user-global or workspace).
118
- * Creates the file and parent directories if needed. Preserves any existing
119
- * `orchestrator:` block. Overwrites the `library:` block if present.
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, publishConfirm = true) {
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
- const raw = await readFile(configPath, "utf8");
126
- existing = parseSimpleYaml(raw);
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
- catch {
129
- // Malformed file — start fresh
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 library = { path: libraryPath, publish_confirm: publishConfirm };
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 };