codecartographer-pi 0.19.5 → 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 (46) 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/templates/gitignore +55 -0
  5. package/.codecarto/workflow/VALIDATE.md +2 -1
  6. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  7. package/README.md +10 -6
  8. package/dist/core/amendment.js +28 -23
  9. package/dist/core/broadside.d.ts +72 -2
  10. package/dist/core/broadside.js +351 -68
  11. package/dist/core/completion.js +95 -26
  12. package/dist/core/dashboard-writer.d.ts +8 -0
  13. package/dist/core/dashboard-writer.js +159 -0
  14. package/dist/core/index.d.ts +2 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/library.js +115 -107
  17. package/dist/core/orchestrator-config.d.ts +32 -7
  18. package/dist/core/orchestrator-config.js +124 -44
  19. package/dist/core/pipeline.d.ts +37 -0
  20. package/dist/core/pipeline.js +80 -10
  21. package/dist/core/prompts.d.ts +20 -0
  22. package/dist/core/prompts.js +43 -10
  23. package/dist/core/secrets.d.ts +16 -0
  24. package/dist/core/secrets.js +98 -0
  25. package/dist/core/status.d.ts +30 -2
  26. package/dist/core/status.js +54 -8
  27. package/dist/core/synthesis.js +5 -2
  28. package/dist/core/usage.d.ts +8 -0
  29. package/dist/core/usage.js +35 -7
  30. package/dist/core/utils.d.ts +32 -5
  31. package/dist/core/utils.js +81 -19
  32. package/dist/core/workspace.d.ts +99 -18
  33. package/dist/core/workspace.js +275 -36
  34. package/dist/core/yaml.js +173 -14
  35. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  36. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  37. package/dist/extensions/codecarto/agent-runner.js +27 -9
  38. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  39. package/dist/extensions/codecarto/auto-runner.js +10 -6
  40. package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
  41. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  42. package/dist/extensions/codecarto/dashboard-writer.js +5 -157
  43. package/dist/extensions/codecarto/index.js +73 -21
  44. package/dist/extensions/codecarto/phase-compaction.js +7 -7
  45. package/dist/mcp-server/server.js +111 -50
  46. package/package.json +3 -2
@@ -1,157 +1,5 @@
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
- import { readdir, readFile, rename, writeFile } from "node:fs/promises";
6
- import { join } from "node:path";
7
- import { DASHBOARD_RELATIVE_PATH, getWorkspaceState, loadUsage, NARRATION_CACHE_RELATIVE_PATH, parseSimpleYaml, pathExists, renderDashboard, } from "../../core/index.js";
8
- const CLOSEOUT_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/;
9
- /**
10
- * Render and atomically replace `.codecarto/dashboard.html`.
11
- * @returns true when a fresh dashboard landed on disk; false when the
12
- * workspace is missing or any gather/render/write step failed (swallowed —
13
- * lifecycle callers must never fail on a dashboard problem, but they may
14
- * report truthfully whether a refresh happened).
15
- */
16
- export async function writeDashboard(cwd, packageVersion) {
17
- try {
18
- const state = await getWorkspaceState(cwd);
19
- if (!state)
20
- return false;
21
- const workspaceDir = state.workspaceDir;
22
- const [usage, closeouts, outputsPresent, narration] = await Promise.all([
23
- loadUsage(workspaceDir),
24
- listCloseouts(workspaceDir),
25
- buildOutputsPresent(state.workspaceDir, state.pipeline),
26
- loadNarration(workspaceDir),
27
- ]);
28
- const inputs = {
29
- status: state.status,
30
- pipeline: state.pipeline,
31
- usage,
32
- closeouts,
33
- outputsPresent,
34
- packageVersion,
35
- generatedAt: new Date().toISOString(),
36
- narration,
37
- };
38
- const html = renderDashboard(inputs);
39
- const path = join(workspaceDir, DASHBOARD_RELATIVE_PATH);
40
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
41
- await writeFile(tempPath, html, "utf8");
42
- await rename(tempPath, path);
43
- return true;
44
- }
45
- catch {
46
- // Best-effort: a failed dashboard write must not surface as a phase
47
- // error. The user's pipeline state is unaffected; the next state
48
- // change will trigger another render attempt.
49
- return false;
50
- }
51
- }
52
- async function listCloseouts(workspaceDir) {
53
- const dir = join(workspaceDir, "closeouts");
54
- if (!(await pathExists(dir)))
55
- return [];
56
- let entries;
57
- try {
58
- entries = await readdir(dir);
59
- }
60
- catch {
61
- return [];
62
- }
63
- const out = [];
64
- for (const name of entries) {
65
- const m = CLOSEOUT_FILENAME_RE.exec(name);
66
- if (!m)
67
- continue;
68
- out.push({ date: m[1], phaseOrModule: m[2], fileName: name, summary: await readCloseoutSummary(join(dir, name)) });
69
- }
70
- return out;
71
- }
72
- async function readCloseoutSummary(path) {
73
- try {
74
- const raw = await readFile(path, "utf8");
75
- const lines = raw.split(/\r?\n/);
76
- const summaryStart = lines.findIndex((line) => /^##\s+Summary\s*$/i.test(line.trim()));
77
- if (summaryStart === -1)
78
- return undefined;
79
- const body = [];
80
- for (const line of lines.slice(summaryStart + 1)) {
81
- if (/^##\s+/.test(line.trim()))
82
- break;
83
- const trimmed = line.trim();
84
- if (!trimmed || trimmed === "-")
85
- continue;
86
- body.push(trimmed.replace(/^[-*]\s+/, ""));
87
- if (body.join(" ").length > 280)
88
- break;
89
- }
90
- const summary = body.join(" ").trim();
91
- return summary ? `${summary.slice(0, 280)}${summary.length > 280 ? "…" : ""}` : undefined;
92
- }
93
- catch {
94
- return undefined;
95
- }
96
- }
97
- async function buildOutputsPresent(workspaceDir, pipeline) {
98
- const out = new Map();
99
- for (const phaseId of pipeline.phase_order) {
100
- const phaseDef = pipeline.phases.find((p) => p.id === phaseId);
101
- if (!phaseDef)
102
- continue;
103
- const entry = { secondary: [] };
104
- if (phaseDef.primary_output) {
105
- entry.primary = {
106
- path: phaseDef.primary_output,
107
- exists: await pathExists(join(workspaceDir, phaseDef.primary_output)),
108
- };
109
- }
110
- for (const sec of phaseDef.secondary_outputs ?? []) {
111
- entry.secondary.push({
112
- path: sec.path,
113
- exists: await pathExists(join(workspaceDir, sec.path)),
114
- });
115
- }
116
- out.set(phaseId, entry);
117
- }
118
- return out;
119
- }
120
- async function loadNarration(workspaceDir) {
121
- const path = join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH);
122
- if (!(await pathExists(path)))
123
- return undefined;
124
- try {
125
- const raw = await readFile(path, "utf8");
126
- const { frontmatter, body } = splitFrontmatter(raw);
127
- if (!frontmatter)
128
- return undefined;
129
- const generatedAt = typeof frontmatter.generatedAt === "string" ? frontmatter.generatedAt : "";
130
- const phaseCountAtGeneration = typeof frontmatter.phaseCountAtGeneration === "number" ? frontmatter.phaseCountAtGeneration : 0;
131
- if (!generatedAt)
132
- return undefined;
133
- return { content: body.trim(), generatedAt, phaseCountAtGeneration };
134
- }
135
- catch {
136
- return undefined;
137
- }
138
- }
139
- function splitFrontmatter(raw) {
140
- if (!raw.startsWith("---\n"))
141
- return { frontmatter: null, body: raw };
142
- const end = raw.indexOf("\n---\n", 4);
143
- if (end === -1)
144
- return { frontmatter: null, body: raw };
145
- const yamlText = raw.slice(4, end);
146
- const body = raw.slice(end + 5);
147
- try {
148
- const parsed = parseSimpleYaml(yamlText);
149
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
150
- return { frontmatter: parsed, body };
151
- }
152
- }
153
- catch {
154
- // fall through
155
- }
156
- return { frontmatter: null, body };
157
- }
1
+ // Moved to core/dashboard-writer.ts (#254): both surfaces render the dashboard
2
+ // at the same lifecycle points, so it is a shared primitive. This re-export
3
+ // keeps the old import path — scripts/build-demo-dashboard.mjs among them —
4
+ // working.
5
+ export { writeDashboard } from "../../core/dashboard-writer.js";
@@ -5,13 +5,12 @@ import { autoCompletePhase, buildAutoSummary, isPhaseRunning, runAuto, runSingle
5
5
  import { disposeAgentsWidget } from "./agent-widget.js";
6
6
  import { parseDashboardFlags } from "./dashboard-flags.js";
7
7
  import { narrateDashboard } from "./dashboard-narrator.js";
8
- import { writeDashboard } from "./dashboard-writer.js";
9
8
  import { parseBroadsideFlags, KNOWN_BROADSIDE_TOKENS } from "./broadside-flags.js";
10
9
  import { parseNextFlags } from "./next-flags.js";
11
10
  import { buildPiGuideMessage } from "./guide-framing.js";
12
11
  import { isCtxLive, notifyCtx } from "./notify.js";
13
12
  import { phaseCompactionExtension } from "./phase-compaction.js";
14
- import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPathResolved, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listSkillNames, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, } from "../../core/index.js";
13
+ import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, describeConfigProblems, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
15
14
  import { initLibrary } from "../../core/library.js";
16
15
  import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
17
16
  const STATUS_WIDGET_ID = "codecarto-widget";
@@ -343,13 +342,17 @@ export default function codeCartographerExtension(pi) {
343
342
  if (event.toolName === "edit" || event.toolName === "write") {
344
343
  const inputPath = typeof event.input.path === "string" ? event.input.path : "";
345
344
  const strippedPath = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
346
- const targetPath = await canonicalPath(resolve(ctx.cwd, strippedPath));
345
+ // Resolve through whatever already exists on disk — symlinks included —
346
+ // before appending the unborn tail. `resolve()` would collapse
347
+ // `link/..` lexically and `realpath()` throws on a file that is not
348
+ // there yet, and either gap let a write escape the workspace (#223).
349
+ const targetPath = await resolveExistingPrefix(strippedPath, ctx.cwd);
347
350
  const allowedRoots = [await canonicalPath(workspaceDir)];
348
351
  const config = await loadCodecartoConfig(workspaceDir);
349
352
  if (config.library.path && await discoverLibrary(config.library.path)) {
350
353
  allowedRoots.push(await canonicalPath(config.library.path));
351
354
  }
352
- const withinAllowed = await Promise.all(allowedRoots.map((allowedRoot) => isWithinPathResolved(targetPath, allowedRoot)));
355
+ const withinAllowed = allowedRoots.map((allowedRoot) => isWithinPath(targetPath, allowedRoot));
353
356
  if (!withinAllowed.some((result) => result)) {
354
357
  notifyCtx(ctx, `Blocked ${event.toolName} outside .codecarto/ or configured library: ${inputPath}`, "warning");
355
358
  return { block: true, reason: `CodeCartographer mode only allows ${event.toolName} within .codecarto/ or the configured CodeCartographer library.` };
@@ -433,11 +436,21 @@ export default function codeCartographerExtension(pi) {
433
436
  const targetExists = await pathExists(targetWorkspaceDir);
434
437
  if (targetExists) {
435
438
  const sameWorkspace = normalizeForComparison(await canonicalPath(targetWorkspaceDir)) === normalizeForComparison(await canonicalPath(sourceWorkspaceDir));
436
- if (!sameWorkspace) {
437
- const overwrite = await ctx.ui.confirm("CodeCartographer already exists — data will be lost", "A .codecarto/ directory already exists in this repository. Re-initializing will back up the existing workspace to .codecarto-backup-TIMESTAMP/ and create a fresh one. All phase findings, handoffs, usage data, closeouts, and progress will be moved to the backup. Consider /codecarto-open to reattach without resetting. Continue?");
438
- if (!overwrite)
439
- return;
440
- const backupDir = join(ctx.cwd, `.codecarto-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`);
439
+ // The packaged template itself (a checkout install) is an existing
440
+ // workspace like any other: ask, and back up. It cannot be renamed
441
+ // away — it is what init copies from — so its session state is
442
+ // moved out file by file instead (#245).
443
+ const overwrite = await ctx.ui.confirm("CodeCartographer already exists — data will be lost", sameWorkspace
444
+ ? "This .codecarto/ is CodeCartographer's own packaged template (a checkout install), and it holds workspace state. Re-initializing will move that state — status, findings, handoffs, usage data, closeouts, dashboard — to .codecarto-backup-TIMESTAMP/ and reset; the framework files stay in place. Consider /codecarto-open to reattach without resetting. Continue?"
445
+ : "A .codecarto/ directory already exists in this repository. Re-initializing will back up the existing workspace to .codecarto-backup-TIMESTAMP/ and create a fresh one. All phase findings, handoffs, usage data, closeouts, and progress will be moved to the backup. Consider /codecarto-open to reattach without resetting. Continue?");
446
+ if (!overwrite)
447
+ return;
448
+ const backupDir = join(ctx.cwd, `.codecarto-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`);
449
+ if (sameWorkspace) {
450
+ const moved = await backupWorkspaceState(targetWorkspaceDir, backupDir);
451
+ notifyCtx(ctx, `Moved ${moved.length} workspace state file${moved.length === 1 ? "" : "s"} to ${basename(backupDir)}/`, "info");
452
+ }
453
+ else {
441
454
  await rename(targetWorkspaceDir, backupDir);
442
455
  notifyCtx(ctx, `Backed up existing workspace to ${basename(backupDir)}/`, "info");
443
456
  }
@@ -447,7 +460,8 @@ export default function codeCartographerExtension(pi) {
447
460
  await copyPackagedWorkspace(targetWorkspaceDir);
448
461
  }
449
462
  const rawStatusPath = join(targetWorkspaceDir, "workflow", "status.yaml");
450
- const rawStatus = (await loadYamlFile(rawStatusPath)) ?? {};
463
+ // Absent on a fresh copy: the template ships no status.yaml.
464
+ const rawStatus = (await pathExists(rawStatusPath)) ? ((await loadYamlFile(rawStatusPath)) ?? {}) : {};
451
465
  const selectedPipelinePath = pipelineChoice ?? rawStatus.pipeline?.trim() ?? DEFAULT_PIPELINE_PATH;
452
466
  const resolvedPipelinePath = join(targetWorkspaceDir, selectedPipelinePath);
453
467
  if (!(await pathExists(resolvedPipelinePath))) {
@@ -485,9 +499,17 @@ export default function codeCartographerExtension(pi) {
485
499
  if (!state)
486
500
  return;
487
501
  const nextPhase = getNextEligiblePhase(state)?.id ?? "complete";
488
- lastFeedbackLines = [`Current phase: ${nextPhase}`, `Pipeline: ${getPipelineLabel(state.status.pipeline)}`];
502
+ // Same check codecarto_status makes: a phase complete in status.yaml
503
+ // whose report is not on disk (#259).
504
+ const missingOutputs = describeMissingCompletedOutputs(await listMissingCompletedOutputs(state));
505
+ lastFeedbackLines = [`Current phase: ${nextPhase}`, `Pipeline: ${getPipelineLabel(state.status.pipeline)}`, ...missingOutputs];
489
506
  setUiState(ctx, state, lastFeedbackLines);
490
- notifyCtx(ctx, `CodeCartographer phase: ${nextPhase}`, "info");
507
+ if (missingOutputs.length > 0) {
508
+ notifyCtx(ctx, `CodeCartographer phase: ${nextPhase}. ${missingOutputs[0]}`, "warning");
509
+ }
510
+ else {
511
+ notifyCtx(ctx, `CodeCartographer phase: ${nextPhase}`, "info");
512
+ }
491
513
  },
492
514
  });
493
515
  pi.registerCommand("codecarto-switch-pipeline", {
@@ -528,6 +550,7 @@ export default function codeCartographerExtension(pi) {
528
550
  lines.push(`New phases: ${result.newPhases.join(", ")}`);
529
551
  if (result.dropped.length > 0)
530
552
  lines.push(`Phases not in new pipeline: ${result.dropped.join(", ")} (findings remain on disk)`);
553
+ lines.push(...describeDanglingCarryForward(result.dangling));
531
554
  lastFeedbackLines = lines;
532
555
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
533
556
  notifyCtx(ctx, `Switched to pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
@@ -626,6 +649,10 @@ export default function codeCartographerExtension(pi) {
626
649
  return;
627
650
  }
628
651
  const config = await loadCodecartoConfig(state.workspaceDir);
652
+ // A bad config file must not block a phase run, but it must not be
653
+ // silent either: the toggle read here may be the one it dropped.
654
+ if (config.problems.length > 0)
655
+ notifyCtx(ctx, describeConfigProblems(config).join("\n"), "warning");
629
656
  const llmSteerEnabled = flags.llmSteerOverride ?? config.orchestrator.llm_steer_next_phase;
630
657
  lastFeedbackLines = [`Running ${phase.id} phase as sub-agent`];
631
658
  setUiState(ctx, state, lastFeedbackLines);
@@ -846,23 +873,27 @@ export default function codeCartographerExtension(pi) {
846
873
  notifyCtx(ctx, `Cannot run skill: pipeline is not complete (next phase: ${nextPhase.id}). Finish the pipeline before running post-pipeline skills.`, "error");
847
874
  return;
848
875
  }
849
- const skillFile = join(state.workspaceDir, "skills", skillName, "SKILL.md");
850
- if (!(await pathExists(skillFile))) {
876
+ // Resolve against the installed list only: the name is never joined
877
+ // onto a path, so a traversal like `../findings/architecture` cannot
878
+ // splice a phase SKILL.md into the post-pipeline prompt. Same rule as
879
+ // the MCP surface's handleSkill.
880
+ const resolved = await resolveSkillName(state.workspaceDir, skillName);
881
+ if (!resolved) {
851
882
  const available = await listSkillNames(state.workspaceDir);
852
883
  const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : " (no skills installed)";
853
884
  notifyCtx(ctx, `Unknown skill: ${skillName}${hint}. The Broad-Side reading guide is served as \`${BROADSIDE_SKILL_NAME}\` and is not pipeline-gated.`, "error");
854
885
  return;
855
886
  }
856
- const prompt = await buildSkillPrompt(state, skillName);
887
+ const prompt = await buildSkillPrompt(state, resolved);
857
888
  if (ctx.isIdle()) {
858
889
  pi.sendUserMessage(prompt);
859
890
  }
860
891
  else {
861
892
  pi.sendUserMessage(prompt, { deliverAs: "followUp" });
862
893
  }
863
- lastFeedbackLines = [`Queued post-pipeline skill: ${skillName}`];
894
+ lastFeedbackLines = [`Queued post-pipeline skill: ${resolved}`];
864
895
  setUiState(ctx, state, lastFeedbackLines);
865
- notifyCtx(ctx, `Queued CodeCartographer skill: ${skillName}`, "info");
896
+ notifyCtx(ctx, `Queued CodeCartographer skill: ${resolved}`, "info");
866
897
  },
867
898
  });
868
899
  pi.registerCommand("codecarto-list-skills", {
@@ -1103,6 +1134,13 @@ export default function codeCartographerExtension(pi) {
1103
1134
  if (!state)
1104
1135
  return;
1105
1136
  const config = await loadCodecartoConfig(state.workspaceDir);
1137
+ // The library path, namespace, and confirm gate all come from the
1138
+ // config, so a file that could not be used is a refusal, not a silent
1139
+ // fallback to the other layer (#242). Same rule as codecarto_publish.
1140
+ if (config.problems.length > 0) {
1141
+ notifyCtx(ctx, ["Publish refused: the configuration has problems. Fix or remove the offending file, then retry.", ...describeConfigProblems(config)].join("\n"), "error");
1142
+ return;
1143
+ }
1106
1144
  if (!config.library.path) {
1107
1145
  notifyCtx(ctx, "No library.path is configured. Create a library directory with a .codecarto-library marker, then set library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml.", "error");
1108
1146
  return;
@@ -1223,15 +1261,21 @@ export default function codeCartographerExtension(pi) {
1223
1261
  namespaced: !!namespace,
1224
1262
  ...(namespace ? {} : {}),
1225
1263
  });
1226
- // Write the config to the user-global location
1264
+ // Write library.path (and library.namespace when given) to the
1265
+ // user-global config and nothing else — not publish_confirm, so
1266
+ // initializing a library does not change how publish behaves on
1267
+ // MCP (#244). A config file that cannot be parsed is left alone;
1268
+ // the error lands in the catch below.
1227
1269
  const configPath = resolveUserConfigPath();
1228
1270
  await writeLibraryConfig(configPath, libraryPath, namespace);
1271
+ const written = namespace ? "library.path and library.namespace" : "library.path";
1229
1272
  const msg = result.alreadyExisted
1230
1273
  ? `Library already exists at ${libraryPath} (marker preserved). Config updated.`
1231
1274
  : `Created library at ${libraryPath} with marker "${result.marker.name}".`;
1232
- lastFeedbackLines = [msg, `Config written to ${configPath}`];
1275
+ const configLine = `Wrote ${written} to ${configPath}; other keys untouched`;
1276
+ lastFeedbackLines = [msg, configLine];
1233
1277
  notifyCtx(ctx, msg, "info");
1234
- notifyCtx(ctx, `Config written to ${configPath}`, "info");
1278
+ notifyCtx(ctx, configLine, "info");
1235
1279
  }
1236
1280
  catch (error) {
1237
1281
  const message = error instanceof Error ? error.message : String(error);
@@ -1258,10 +1302,18 @@ export default function codeCartographerExtension(pi) {
1258
1302
  const marker = await discoverLibrary(config.library.path);
1259
1303
  lines.push(` Library marker: ${marker ? `found ("${marker.name}", namespaced: ${marker.namespaced})` : "MISSING — run /codecarto-library-init"}`);
1260
1304
  }
1305
+ // A file that could not be used is the one thing this command exists
1306
+ // to surface; /codecarto-publish refuses while any is listed (#242).
1307
+ lines.push(...describeConfigProblems(config));
1261
1308
  lastFeedbackLines = lines;
1262
1309
  if (state)
1263
1310
  setUiState(ctx, state, lastFeedbackLines);
1264
- notifyCtx(ctx, "Configuration shown in status widget.", "info");
1311
+ if (config.problems.length > 0) {
1312
+ notifyCtx(ctx, describeConfigProblems(config).join("\n"), "warning");
1313
+ }
1314
+ else {
1315
+ notifyCtx(ctx, "Configuration shown in status widget.", "info");
1316
+ }
1265
1317
  },
1266
1318
  });
1267
1319
  pi.registerCommand("codecarto-usage", {
@@ -1,7 +1,7 @@
1
- import { mkdir, rename, writeFile } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
3
  import { compact } from "@earendil-works/pi-coding-agent";
4
- import { canonicalPath, getWorkspaceState, isWithinPath } from "../../core/index.js";
4
+ import { atomicWriteFile, canonicalPath, getWorkspaceState, isWithinPath, resolveExistingPrefix } from "../../core/index.js";
5
5
  const PHASE_SESSION_PREFIX = "CodeCartographer phase: ";
6
6
  export function phaseIdFromSessionName(sessionName) {
7
7
  if (!sessionName?.startsWith(PHASE_SESSION_PREFIX))
@@ -29,7 +29,6 @@ export function buildPhaseCompactionInstructions(phaseId, primaryOutput) {
29
29
  export async function writePhaseCheckpoint(cwd, phaseId, summary, tokensBefore) {
30
30
  const dir = join(cwd, ".codecarto", "scratch", "checkpoints");
31
31
  const target = join(dir, `${phaseId}.md`);
32
- const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
33
32
  await mkdir(dir, { recursive: true });
34
33
  const content = [
35
34
  "---",
@@ -44,8 +43,7 @@ export async function writePhaseCheckpoint(cwd, phaseId, summary, tokensBefore)
44
43
  summary.trim(),
45
44
  "",
46
45
  ].join("\n");
47
- await writeFile(temp, content, "utf8");
48
- await rename(temp, target);
46
+ await atomicWriteFile(target, content);
49
47
  return target;
50
48
  }
51
49
  /**
@@ -64,7 +62,9 @@ export function phaseCompactionExtension(pi) {
64
62
  if (event.toolName === "edit" || event.toolName === "write") {
65
63
  const inputPath = typeof event.input.path === "string" ? event.input.path : "";
66
64
  const strippedPath = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
67
- const targetPath = await canonicalPath(resolve(ctx.cwd, strippedPath));
65
+ // Same containment as the parent extension's hook (#223): follow the
66
+ // existing prefix through symlinks, then append the unborn tail.
67
+ const targetPath = await resolveExistingPrefix(strippedPath, ctx.cwd);
68
68
  const allowedRoot = await canonicalPath(join(ctx.cwd, ".codecarto"));
69
69
  if (!isWithinPath(targetPath, allowedRoot)) {
70
70
  return {