codecartographer-pi 0.17.1 → 0.19.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.
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { basename, join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { autoCompletePhase, buildAutoSummary, isPhaseRunning, runAuto, runSinglePhase } from "./auto-runner.js";
5
5
  import { disposeAgentsWidget } from "./agent-widget.js";
6
6
  import { parseDashboardFlags } from "./dashboard-flags.js";
@@ -9,7 +9,7 @@ import { writeDashboard } from "./dashboard-writer.js";
9
9
  import { parseBroadsideFlags, KNOWN_BROADSIDE_TOKENS } from "./broadside-flags.js";
10
10
  import { parseNextFlags } from "./next-flags.js";
11
11
  import { phaseCompactionExtension } from "./phase-compaction.js";
12
- import { 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, listBatchModels, listSkillNames, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, runPhasePreflight, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, } from "../../core/index.js";
12
+ 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
13
  import { initLibrary } from "../../core/library.js";
14
14
  import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
15
15
  const STATUS_WIDGET_ID = "codecarto-widget";
@@ -137,11 +137,130 @@ function describeBroadsideEstimate(estimate) {
137
137
  lines.push("", "The estimate is a pre-flight prediction from file sizes; OpenRouter bills actual usage.");
138
138
  return lines.join("\n");
139
139
  }
140
+ /**
141
+ * Resolve the argument of /codecarto-amend to the slug applyAmendment takes.
142
+ * Accepts the slug, `slug.yaml`, or a path to the file — but a path only when
143
+ * it lands inside .codecarto/scratch/amendments/, the one place an amendment
144
+ * is read from. The path names the file; the read always goes through the slug.
145
+ */
146
+ function resolveAmendmentName(rawArg, cwd) {
147
+ const trimmed = rawArg.trim().replace(/^@/, "");
148
+ if (!trimmed)
149
+ return null;
150
+ if (!/[\\/]/.test(trimmed))
151
+ return trimmed;
152
+ const amendmentsDir = resolve(cwd, ".codecarto", "scratch", "amendments");
153
+ for (const candidate of [resolve(cwd, trimmed), resolve(cwd, ".codecarto", trimmed)]) {
154
+ if (dirname(candidate) === amendmentsDir)
155
+ return basename(candidate);
156
+ }
157
+ return null;
158
+ }
159
+ function clipDescription(text, max = 140) {
160
+ if (!text)
161
+ return "";
162
+ const oneLine = text.replace(/\s+/g, " ").trim();
163
+ return `: ${oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine}`;
164
+ }
165
+ /**
166
+ * What an amendment will do to canonical state, rendered for a human about to
167
+ * approve it: each closure resolved against status.yaml so an id that matches
168
+ * nothing is visible before the write, not only in the result.
169
+ */
170
+ function describeAmendmentPreview(amendment, state) {
171
+ const openQuestions = new Map();
172
+ for (const [phaseId, phase] of Object.entries(state.status.phases)) {
173
+ for (const entry of phase.open_questions ?? []) {
174
+ if (!entry.id)
175
+ continue;
176
+ openQuestions.set(entry.id, [...(openQuestions.get(entry.id) ?? []), { phaseId, entry }]);
177
+ }
178
+ }
179
+ const unmatched = " — matches nothing (already closed or unknown; reported, not fatal)";
180
+ const lines = [`Amendment file: .codecarto/scratch/amendments/${amendment.slug}.yaml`];
181
+ if (amendment.open_question_closures.length > 0) {
182
+ lines.push("", `Closes ${amendment.open_question_closures.length} open question(s):`);
183
+ for (const id of amendment.open_question_closures) {
184
+ const matches = openQuestions.get(id);
185
+ if (!matches) {
186
+ lines.push(` - ${id}${unmatched}`);
187
+ continue;
188
+ }
189
+ const { entry } = matches[0];
190
+ const where = [matches.map((match) => match.phaseId).join(", "), entry.kind].filter(Boolean).join(", ");
191
+ lines.push(` - ${id} (${where})${clipDescription(entry.description)}`);
192
+ }
193
+ }
194
+ if (amendment.post_pipeline_closures.length > 0) {
195
+ lines.push("", `Retires ${amendment.post_pipeline_closures.length} post-pipeline item(s):`);
196
+ for (const id of amendment.post_pipeline_closures) {
197
+ const entry = state.status.post_pipeline.find((item) => item.id === id);
198
+ if (!entry) {
199
+ lines.push(` - ${id}${unmatched}`);
200
+ continue;
201
+ }
202
+ const where = [entry.kind, entry.source_phase ? `from ${entry.source_phase}` : ""].filter(Boolean).join(", ");
203
+ lines.push(` - ${id}${where ? ` (${where})` : ""}${clipDescription(entry.description)}`);
204
+ }
205
+ }
206
+ if (amendment.notes.length > 0) {
207
+ lines.push("", `Records ${amendment.notes.length} note(s) in the closeout:`, ...amendment.notes.map((note) => ` - ${note}`));
208
+ }
209
+ const summary = amendment.closeout_summary.trim();
210
+ lines.push("", `Writes .codecarto/closeouts/<date>-amendment-${amendment.slug}.md, appends one THREAD_LOG entry${summary ? ` ("${summary}")` : ""}, updates workflow/status.yaml under the completion lock, and refreshes the dashboard.`);
211
+ return lines.join("\n");
212
+ }
213
+ /**
214
+ * What a scaffold refresh will overwrite, rendered for a human about to approve
215
+ * it. The file set is the one refreshScaffold writes; the protected set is the
216
+ * one it skips — both come from core, so the preview cannot drift from the write.
217
+ */
218
+ function describeScaffoldRefreshPreview(files, scaffoldVersionBefore) {
219
+ const topLevel = [];
220
+ const byDir = new Map();
221
+ for (const file of files) {
222
+ const slash = file.indexOf("/");
223
+ if (slash === -1) {
224
+ topLevel.push(file);
225
+ continue;
226
+ }
227
+ const dir = file.slice(0, slash);
228
+ byDir.set(dir, [...(byDir.get(dir) ?? []), file.slice(slash + 1)]);
229
+ }
230
+ const from = scaffoldVersionBefore ?? "unversioned";
231
+ const lines = [
232
+ from === PACKAGE_VERSION
233
+ ? `Scaffold version: ${from} (already current — the files are re-copied from the packaged template byte-for-byte).`
234
+ : `Scaffold version: ${from} → ${PACKAGE_VERSION}.`,
235
+ "",
236
+ `Overwrites ${files.length} framework-owned file(s) in .codecarto/ with the packaged template:`,
237
+ ];
238
+ if (topLevel.length > 0)
239
+ lines.push(` ${topLevel.join(", ")}`);
240
+ for (const [dir, entries] of [...byDir.entries()].sort(([a], [b]) => a.localeCompare(b))) {
241
+ // workflow/ is where the pipelines live, and the version marker: name them.
242
+ lines.push(dir === "workflow" ? ` workflow/: ${entries.join(", ")}` : ` ${dir}/: ${entries.length} file(s)`);
243
+ }
244
+ if (byDir.has("findings")) {
245
+ lines.push(" (findings/ refreshes only the packaged SKILL.md, README.md, and pass files — the findings outputs beside them stay.)");
246
+ }
247
+ const protectedPaths = [
248
+ ...SCAFFOLD_REFRESH_PROTECTED.workflowFiles.map((file) => `workflow/${file}`),
249
+ ...SCAFFOLD_REFRESH_PROTECTED.topLevel,
250
+ ...SCAFFOLD_REFRESH_PROTECTED.dirs.map((dir) => `${dir}/`),
251
+ ];
252
+ lines.push("", `Never touched: ${protectedPaths.join(", ")}.`, "One THREAD_LOG entry records the refresh. Continue?");
253
+ return lines.join("\n");
254
+ }
140
255
  export default function codeCartographerExtension(pi) {
141
256
  phaseCompactionExtension(pi);
142
257
  let lastFeedbackLines = [];
143
258
  let codecartoModeActive = false;
259
+ // Argument completers receive only the prefix, so the session's cwd is
260
+ // remembered here for the completers that list files under .codecarto/.
261
+ let sessionCwd;
144
262
  const readWorkspaceState = async (ctx, notifyOnError = true) => {
263
+ sessionCwd = ctx.cwd;
145
264
  try {
146
265
  return await getWorkspaceState(ctx.cwd);
147
266
  }
@@ -185,6 +304,7 @@ export default function codeCartographerExtension(pi) {
185
304
  pi.on("session_start", async (_event, ctx) => {
186
305
  codecartoModeActive = false;
187
306
  lastFeedbackLines = [];
307
+ sessionCwd = ctx.cwd;
188
308
  setUiState(ctx, null);
189
309
  });
190
310
  pi.on("session_shutdown", async () => {
@@ -698,6 +818,76 @@ export default function codeCartographerExtension(pi) {
698
818
  ctx.ui.notify(`Queued CodeCartographer skill: ${skillName}`, "info");
699
819
  },
700
820
  });
821
+ pi.registerCommand("codecarto-list-skills", {
822
+ description: "List the post-pipeline skills installed in .codecarto/skills/ (and the ungated Broad-Side reading guide)",
823
+ handler: async (_args, ctx) => {
824
+ // Same gate as /codecarto-skill: the listing reads the workspace's
825
+ // skills directory, so it needs a workspace — mirrors handleListSkills.
826
+ const state = await ensureWorkspaceState(ctx);
827
+ if (!state)
828
+ return;
829
+ const skills = await listSkillNames(state.workspaceDir);
830
+ const lines = skills.length > 0
831
+ ? [`Available skills (${skills.length}):`, ...skills.map((name) => ` - ${name}`)]
832
+ : ["No skills installed."];
833
+ const nextPhase = getNextEligiblePhase(state);
834
+ if (skills.length > 0) {
835
+ lines.push(nextPhase
836
+ ? `Post-pipeline skills unlock when the pipeline completes (next phase: ${nextPhase.id}).`
837
+ : "Run one with /codecarto-skill <name>.");
838
+ }
839
+ // Broad-Side is listed apart from the post-pipeline set because it
840
+ // answers to /codecarto-skill without the completion gate.
841
+ const broadsideAvailable = await readBroadsideSkill(ctx.cwd).then(() => true, () => false);
842
+ if (broadsideAvailable) {
843
+ lines.push("", `Also served by /codecarto-skill (not pipeline-gated): ${BROADSIDE_SKILL_NAME} — how to read a Broad-Side batch reconnaissance run.`);
844
+ }
845
+ lastFeedbackLines = lines;
846
+ setUiState(ctx, state, lastFeedbackLines);
847
+ ctx.ui.notify(skills.length > 0
848
+ ? `${skills.length} post-pipeline skill${skills.length === 1 ? "" : "s"}: ${skills.join(", ")}`
849
+ : "No post-pipeline skills installed.", "info");
850
+ },
851
+ });
852
+ pi.registerCommand("codecarto-guide", {
853
+ description: "Read the packaged CodeCartographer agent guide into the session: /codecarto-guide [topic]",
854
+ getArgumentCompletions: async (prefix) => {
855
+ const topics = await listGuideTopics().catch(() => ["overview"]);
856
+ const items = topics
857
+ .filter((value) => value.startsWith(prefix))
858
+ .map((value) => ({ value, label: value }));
859
+ return items.length > 0 ? items : null;
860
+ },
861
+ handler: async (args, ctx) => {
862
+ // The guide is packaged with the extension, not copied into a
863
+ // workspace, so — like codecarto_guide — this needs no workspace.
864
+ let document;
865
+ let topics;
866
+ try {
867
+ topics = await listGuideTopics();
868
+ document = await readGuide(args.trim() || undefined);
869
+ }
870
+ catch (error) {
871
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
872
+ return;
873
+ }
874
+ const other = topics.filter((name) => name !== document.topic);
875
+ const footer = other.length > 0
876
+ ? `\n\n---\nOther guide topics: ${other.join(", ")} (run /codecarto-guide <topic>).`
877
+ : "";
878
+ const message = `${document.content}${footer}`;
879
+ if (ctx.isIdle()) {
880
+ pi.sendUserMessage(message);
881
+ }
882
+ else {
883
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
884
+ }
885
+ lastFeedbackLines = [`Queued the CodeCartographer guide: ${document.topic}`];
886
+ if (codecartoModeActive)
887
+ void refreshWorkspaceUi(ctx, lastFeedbackLines);
888
+ ctx.ui.notify(`Queued the CodeCartographer guide (${document.topic})`, "info");
889
+ },
890
+ });
701
891
  pi.registerCommand("codecarto-broadside", {
702
892
  description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models] [lenses…] [flags]",
703
893
  getArgumentCompletions: (prefix) => {
@@ -886,16 +1076,20 @@ export default function codeCartographerExtension(pi) {
886
1076
  return;
887
1077
  }
888
1078
  const spec = await readFile(specPath, "utf8");
889
- const slug = deriveSlug(ctx.cwd);
1079
+ // The git remote when there is one, the directory otherwise (#147).
1080
+ // Slug and source_repo derive from the same value so they agree.
1081
+ const source = await resolvePublishSourceRepo(ctx.cwd);
1082
+ const slug = deriveSlug(source.source_repo);
890
1083
  const headline = derivePublishHeadline(spec, ctx.cwd);
891
1084
  const namespace = marker.namespaced ? config.library.namespace ?? undefined : undefined;
892
1085
  if (marker.namespaced && !namespace) {
893
1086
  ctx.ui.notify("The configured library is namespaced; set library.namespace before publishing.", "error");
894
1087
  return;
895
1088
  }
1089
+ const label = `${namespace ? `${namespace}/` : ""}${slug}`;
896
1090
  const preview = [
897
- `Publish ${namespace ? `${namespace}/` : ""}${slug} to ${config.library.path}`,
898
- `Source: ${ctx.cwd}`,
1091
+ `Publish ${label} to ${config.library.path}`,
1092
+ `Source: ${source.source_repo}${source.remote ? ` (git remote ${source.remote})` : ""}`,
899
1093
  `Spec: .codecarto/${phase.primary_output}`,
900
1094
  `Headline: ${headline}`,
901
1095
  `Provenance: Pi / ${ctx.model?.provider ?? "unknown"} / ${ctx.model?.id ?? "unknown"}`,
@@ -905,7 +1099,7 @@ export default function codeCartographerExtension(pi) {
905
1099
  const input = {
906
1100
  slug,
907
1101
  namespace,
908
- source_repo: ctx.cwd,
1102
+ source_repo: source.source_repo,
909
1103
  analyzed_at: new Date().toISOString(),
910
1104
  pipeline: state.status.pipeline,
911
1105
  codecarto_version: PACKAGE_VERSION,
@@ -915,22 +1109,44 @@ export default function codeCartographerExtension(pi) {
915
1109
  generation: piGeneration(ctx),
916
1110
  };
917
1111
  try {
1112
+ // Both guards in publishEntry raise before anything is written, and
1113
+ // each asks a question only the user can answer, so the command has
1114
+ // no flags for them: a yes is the override. The options accumulate,
1115
+ // so a publish that trips both guards asks both questions in turn.
1116
+ const options = {};
918
1117
  let result;
919
- try {
920
- result = await publishEntry(config.library.path, spec, input);
921
- }
922
- catch (error) {
923
- if (!(error instanceof ConfidentialityMismatchError))
924
- throw error;
925
- // Nothing was written. Pi declares no confidentiality, so the entry
926
- // sits at the internal default; whether it may go into a wider
927
- // library is the user's call, and a yes is the override.
928
- const publishAnyway = await ctx.ui.confirm("Confidentiality mismatch — publish anyway?", `This spec's confidentiality is "${error.entryConfidentiality}" (CodeCartographer's default; /codecarto-publish declares none), but the library "${marker.name}" has visibility "${error.libraryVisibility}". Publishing would expose it to everyone that library reaches. Publish anyway?`);
929
- if (!publishAnyway) {
930
- ctx.ui.notify("Publish cancelled. Nothing was written.", "info");
931
- return;
1118
+ while (!result) {
1119
+ try {
1120
+ result = await publishEntry(config.library.path, spec, input, options);
1121
+ }
1122
+ catch (error) {
1123
+ if (error instanceof SourceRepoMismatchError && !options.allowSourceRepoChange) {
1124
+ // The entry's history belongs to whatever the newest version
1125
+ // records. Appending is right only if that repository and this
1126
+ // one are the same project under a new address (#146) — which
1127
+ // includes the first publish after upgrading from a Pi that
1128
+ // recorded the directory to one that records the git remote.
1129
+ const moved = await ctx.ui.confirm("Source repository changed — did it move?", `Library entry ${label} records source_repo "${error.recorded}", but this publish carries "${error.incoming}". If the repository genuinely moved (rename, org transfer, host change — or this is the first publish since CodeCartographer began recording the git remote instead of the local directory), answer yes and this spec is appended as the entry's next version. If these are two different projects that share a directory name, answer no: nothing is written, and the second project needs a distinct slug (codecarto_publish on MCP accepts one). Did the repository move?`);
1130
+ if (!moved) {
1131
+ ctx.ui.notify("Publish cancelled. Nothing was written.", "info");
1132
+ return;
1133
+ }
1134
+ options.allowSourceRepoChange = true;
1135
+ }
1136
+ else if (error instanceof ConfidentialityMismatchError && !options.allowConfidentialityMismatch) {
1137
+ // Pi declares no confidentiality, so the entry sits at the internal
1138
+ // default; whether it may go into a wider library is the user's call.
1139
+ const publishAnyway = await ctx.ui.confirm("Confidentiality mismatch — publish anyway?", `This spec's confidentiality is "${error.entryConfidentiality}" (CodeCartographer's default; /codecarto-publish declares none), but the library "${marker.name}" has visibility "${error.libraryVisibility}". Publishing would expose it to everyone that library reaches. Publish anyway?`);
1140
+ if (!publishAnyway) {
1141
+ ctx.ui.notify("Publish cancelled. Nothing was written.", "info");
1142
+ return;
1143
+ }
1144
+ options.allowConfidentialityMismatch = true;
1145
+ }
1146
+ else {
1147
+ throw error;
1148
+ }
932
1149
  }
933
- result = await publishEntry(config.library.path, spec, input, { allowConfidentialityMismatch: true });
934
1150
  }
935
1151
  lastFeedbackLines = [`Published ${result.namespace ? `${result.namespace}/` : ""}${result.slug} v${result.version}`, result.isNewVersion ? "New content version." : "Metadata-only update (content unchanged)."];
936
1152
  await writeDashboard(ctx.cwd, PACKAGE_VERSION);
@@ -1069,4 +1285,123 @@ export default function codeCartographerExtension(pi) {
1069
1285
  ctx.ui.notify("Dashboard regenerated: .codecarto/dashboard.html", "info");
1070
1286
  },
1071
1287
  });
1288
+ pi.registerCommand("codecarto-refresh-scaffold", {
1289
+ description: "Refresh the framework-owned .codecarto/ files (GUIDE.md, templates/, workflow/ pipelines and VALIDATE.md) from the packaged template, after confirming; project state is untouched",
1290
+ handler: async (_args, ctx) => {
1291
+ const state = await ensureWorkspaceState(ctx);
1292
+ if (!state)
1293
+ return;
1294
+ // Pi can ask, so it shows the exact file set before overwriting
1295
+ // anything — MCP's codecarto_refresh_scaffold writes on call.
1296
+ let files;
1297
+ try {
1298
+ files = await listScaffoldRefreshFiles();
1299
+ }
1300
+ catch (error) {
1301
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1302
+ return;
1303
+ }
1304
+ const approved = await ctx.ui.confirm("Refresh the .codecarto/ scaffold from the packaged template?", describeScaffoldRefreshPreview(files, state.scaffoldVersion));
1305
+ if (!approved) {
1306
+ ctx.ui.notify("Scaffold refresh cancelled. Nothing was written.", "info");
1307
+ return;
1308
+ }
1309
+ try {
1310
+ const result = await refreshScaffold(ctx.cwd);
1311
+ const transition = `${result.scaffoldVersionBefore ?? "unversioned"} → ${result.scaffoldVersionAfter}`;
1312
+ lastFeedbackLines = [
1313
+ `Refreshed ${result.written.length} framework-owned file(s) from the packaged template (${transition}).`,
1314
+ "Project state, user config, findings outputs, scratch, closeouts, and orchestrator files were not touched.",
1315
+ "THREAD_LOG.md: one scaffold-refresh entry appended.",
1316
+ ];
1317
+ // Re-read state so the widget's staleness line clears with the marker.
1318
+ await refreshWorkspaceUi(ctx, lastFeedbackLines);
1319
+ ctx.ui.notify(`Refreshed ${result.written.length} framework-owned file(s) (${transition}).`, "info");
1320
+ }
1321
+ catch (error) {
1322
+ const message = error instanceof Error ? error.message : String(error);
1323
+ lastFeedbackLines = [message];
1324
+ setUiState(ctx, state, lastFeedbackLines);
1325
+ ctx.ui.notify(`Scaffold refresh failed: ${message}`, "error");
1326
+ }
1327
+ },
1328
+ });
1329
+ pi.registerCommand("codecarto-amend", {
1330
+ description: "Apply a post-pipeline amendment from .codecarto/scratch/amendments/, after a preview: /codecarto-amend <name | scratch/amendments/name.yaml>",
1331
+ getArgumentCompletions: async (prefix) => {
1332
+ const names = await listAmendmentNames(join(sessionCwd ?? process.cwd(), ".codecarto"));
1333
+ const items = names
1334
+ .filter((value) => value.startsWith(prefix))
1335
+ .map((value) => ({ value, label: value }));
1336
+ return items.length > 0 ? items : null;
1337
+ },
1338
+ handler: async (args, ctx) => {
1339
+ const state = await ensureWorkspaceState(ctx);
1340
+ if (!state)
1341
+ return;
1342
+ if (!args.trim()) {
1343
+ const staged = await listAmendmentNames(state.workspaceDir);
1344
+ const hint = staged.length > 0
1345
+ ? ` (staged: ${staged.join(", ")})`
1346
+ : " — write .codecarto/scratch/amendments/<name>.yaml first (see templates/amendment.yaml)";
1347
+ ctx.ui.notify(`Usage: /codecarto-amend <name>${hint}`, "warning");
1348
+ return;
1349
+ }
1350
+ const name = resolveAmendmentName(args, ctx.cwd);
1351
+ if (!name) {
1352
+ ctx.ui.notify(`Amendments are read from .codecarto/scratch/amendments/ only; pass the amendment name or a path inside that directory, not ${args.trim()}.`, "error");
1353
+ return;
1354
+ }
1355
+ // The same refusals codecarto_amend surfaces, raised before the
1356
+ // confirmation so nobody approves an amendment that cannot apply.
1357
+ let amendment;
1358
+ try {
1359
+ amendment = await loadAmendmentFile(name, state.workspaceDir);
1360
+ }
1361
+ catch (error) {
1362
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1363
+ return;
1364
+ }
1365
+ const nextPhase = getNextEligiblePhase(state);
1366
+ if (nextPhase) {
1367
+ ctx.ui.notify(`Cannot amend: the pipeline is not complete (next phase: ${nextPhase.id}). `
1368
+ + "Resolve open questions and routed items through that phase's handoff (open_question_closures / carry_forward_closures) instead.", "error");
1369
+ return;
1370
+ }
1371
+ // Pi can ask, so the amendment is previewed against status.yaml
1372
+ // before anything is written — MCP's codecarto_amend applies on call.
1373
+ const approved = await ctx.ui.confirm(`Apply amendment "${amendment.slug}"?`, describeAmendmentPreview(amendment, state));
1374
+ if (!approved) {
1375
+ ctx.ui.notify(`Amendment ${amendment.slug} cancelled. Nothing was written.`, "info");
1376
+ return;
1377
+ }
1378
+ try {
1379
+ const { applied, closeoutNotice } = await applyAmendment(ctx.cwd, name);
1380
+ // An amendment exists precisely to change the numbers the dashboard
1381
+ // shows; refresh it, reporting only a render that actually landed.
1382
+ const dashboardWritten = await writeDashboard(ctx.cwd, PACKAGE_VERSION);
1383
+ const lines = [
1384
+ `Amendment applied: ${amendment.slug}`,
1385
+ `Open questions closed: ${applied.openQuestionsClosed.length > 0 ? applied.openQuestionsClosed.join(", ") : "none"}`,
1386
+ `Post-pipeline items closed: ${applied.postPipelineClosed.length > 0 ? applied.postPipelineClosed.join(", ") : "none"}`,
1387
+ ];
1388
+ if (applied.unknownIds.length > 0)
1389
+ lines.push(`Ids that matched nothing (already closed or unknown): ${applied.unknownIds.join(", ")}`);
1390
+ lines.push(closeoutNotice);
1391
+ if (dashboardWritten)
1392
+ lines.push("Dashboard refreshed: .codecarto/dashboard.html");
1393
+ lastFeedbackLines = lines;
1394
+ await refreshWorkspaceUi(ctx, lastFeedbackLines);
1395
+ const closed = applied.openQuestionsClosed.length + applied.postPipelineClosed.length;
1396
+ ctx.ui.notify(`Amendment ${amendment.slug} applied: ${applied.openQuestionsClosed.length} open question(s) and ${applied.postPipelineClosed.length} post-pipeline item(s) closed`
1397
+ + `${applied.unknownIds.length > 0 ? `; ${applied.unknownIds.length} id(s) matched nothing` : ""}.`, closed === 0 || applied.unknownIds.length > 0 ? "warning" : "info");
1398
+ }
1399
+ catch (error) {
1400
+ const message = error instanceof Error ? error.message : String(error);
1401
+ lastFeedbackLines = [message];
1402
+ setUiState(ctx, state, lastFeedbackLines);
1403
+ ctx.ui.notify(`Amendment failed: ${message}`, "error");
1404
+ }
1405
+ },
1406
+ });
1072
1407
  }
@@ -17,7 +17,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
17
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
18
18
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
19
19
  import { basename, isAbsolute, join } from "node:path";
20
- import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, seedOrchestratorFiles, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, } from "../core/index.js";
20
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, seedOrchestratorFiles, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, } from "../core/index.js";
21
21
  import { applyAmendment } from "../core/amendment.js";
22
22
  import { appendUsageRun } from "../core/usage.js";
23
23
  import { initLibrary } from "../core/library.js";
@@ -370,6 +370,17 @@ async function resolveLibraryPath(args) {
370
370
  }
371
371
  throw new McpError(ErrorCode.InvalidParams, "library_path is required (pass it explicitly, or pass cwd and configure library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml).");
372
372
  }
373
+ /**
374
+ * The configuration `codecarto_config` reports for the same `cwd`: workspace
375
+ * over user-global when a cwd is given, user-global alone otherwise. Publish
376
+ * reads its gate from here so the value the config tool displays is the value
377
+ * the publish tool enforces.
378
+ */
379
+ async function loadEffectiveConfig(cwd) {
380
+ return typeof cwd === "string" && cwd.trim() !== ""
381
+ ? loadCodecartoConfig(join(cwd.trim(), ".codecarto"))
382
+ : loadUserConfig();
383
+ }
373
384
  function asStringArray(value, fieldName) {
374
385
  if (!Array.isArray(value)) {
375
386
  throw new McpError(ErrorCode.InvalidParams, `${fieldName} must be an array of strings`);
@@ -550,6 +561,50 @@ export async function handlePublish(args) {
550
561
  const analyzedAt = typeof args.analyzed_at === "string" && args.analyzed_at !== ""
551
562
  ? args.analyzed_at
552
563
  : new Date().toISOString();
564
+ const headline = args.headline.trim();
565
+ const forceNewVersion = args.force_new_version === true;
566
+ // publish_confirm gate (#162). Pi shows a preview and asks before writing.
567
+ // An MCP server has no one to ask, so — as codecarto_broadside does for its
568
+ // spend limit — it refuses with the preview and lets the caller re-invoke
569
+ // with confirm: true. The gate applies only when a config layer actually
570
+ // set the key: the loader's default (true) exists to make Pi's dialog
571
+ // opt-out, and a host that never configured the key keeps the behavior it
572
+ // had. Runs after every argument check so the preview names the resolved
573
+ // slug and namespace, and before publishEntry so nothing is written.
574
+ const config = await loadEffectiveConfig(args.cwd);
575
+ if (config.library.publish_confirm && config.library.publish_confirm_configured && args.confirm !== true) {
576
+ const preview = await previewPublishVersion(libraryPath, spec, { slug, namespace }, { forceNewVersion });
577
+ const label = `${namespace ? `${namespace}/` : ""}${slug}`;
578
+ const versionLine = preview.latestVersion === 0
579
+ ? "v1 (first version of a new entry)"
580
+ : preview.isNewVersion
581
+ ? `v${preview.version} (new content version; the newest is v${preview.latestVersion})`
582
+ : `v${preview.version} (metadata-only update; the content hash matches the newest version)`;
583
+ const specSource = typeof args.spec_path === "string" && args.spec_path.trim() !== ""
584
+ ? args.spec_path.trim()
585
+ : `inline (${spec.length} characters)`;
586
+ throw new McpError(ErrorCode.InvalidRequest, [
587
+ "Publish not performed: library.publish_confirm is set and this call did not carry confirm: true. Nothing was written.",
588
+ `Would publish ${label} to ${libraryPath}`,
589
+ ` Version: ${versionLine}`,
590
+ ` Source repo: ${sourceRepo}`,
591
+ ` Headline: ${headline}`,
592
+ ` Confidentiality: ${confidentiality ?? "internal (the default; none declared)"}`,
593
+ ` Spec: ${specSource}`,
594
+ "Re-invoke codecarto_publish with the same arguments plus confirm: true to publish, or set library.publish_confirm: false in config to drop this gate.",
595
+ ].join("\n"), {
596
+ refused: "publish_confirm",
597
+ libraryPath,
598
+ namespace: namespace ?? null,
599
+ slug,
600
+ version: preview.version,
601
+ isNewVersion: preview.isNewVersion,
602
+ latestVersion: preview.latestVersion,
603
+ source_repo: sourceRepo,
604
+ headline,
605
+ confidentiality: confidentiality ?? null,
606
+ });
607
+ }
553
608
  const result = await publishEntry(libraryPath, spec, {
554
609
  slug,
555
610
  namespace: namespace ?? undefined,
@@ -560,13 +615,13 @@ export async function handlePublish(args) {
560
615
  analyzed_at: analyzedAt,
561
616
  pipeline: defaults.pipeline,
562
617
  codecarto_version: PACKAGE_VERSION,
563
- headline: args.headline.trim(),
618
+ headline,
564
619
  tags,
565
620
  capabilities,
566
621
  confidentiality,
567
622
  generation,
568
623
  }, {
569
- forceNewVersion: args.force_new_version === true,
624
+ forceNewVersion,
570
625
  allowSourceRepoChange: args.allow_source_repo_change === true,
571
626
  allowConfidentialityMismatch: args.allow_confidentiality_mismatch === true,
572
627
  });
@@ -1060,7 +1115,7 @@ const TOOLS = [
1060
1115
  },
1061
1116
  {
1062
1117
  name: "codecarto_publish",
1063
- description: "Publish a reimplementation-spec to a CodeCartographer library. Identified by library_path (absolute) or cwd's config.yaml. Content-hash idempotent — re-publishing identical spec bytes updates metadata in place rather than bumping the version. Required: source_repo, headline, and either spec (inline) or spec_path (absolute file). Slug derives from source_repo if not provided. If the library is namespaced, namespace is required (or pass cwd to inherit from config). Generation context (agent, model, vendor, reasoning) is passed via model_metadata so the host can record provenance; omitted fields default to 'unknown'.",
1118
+ description: "Publish a reimplementation-spec to a CodeCartographer library. Identified by library_path (absolute) or cwd's config.yaml. Content-hash idempotent — re-publishing identical spec bytes updates metadata in place rather than bumping the version. Required: source_repo, headline, and either spec (inline) or spec_path (absolute file). Slug derives from source_repo if not provided. If the library is namespaced, namespace is required (or pass cwd to inherit from config). Generation context (agent, model, vendor, reasoning) is passed via model_metadata so the host can record provenance; omitted fields default to 'unknown'. When library.publish_confirm is set in config, a call without confirm: true is refused with a preview of what would be published (nothing is written); re-invoke with confirm: true to publish.",
1064
1119
  inputSchema: {
1065
1120
  type: "object",
1066
1121
  properties: {
@@ -1104,6 +1159,10 @@ const TOOLS = [
1104
1159
  type: "boolean",
1105
1160
  description: "Permit publishing when the entry's confidentiality is more restricted than the library's visibility (internal < shared < public: an internal entry into a shared or public library, a shared entry into a public one). Off by default, because that direction exposes the spec to everyone the library reaches. An omitted confidentiality counts as internal. Set only when the exposure is intended; the recorded confidentiality is not changed.",
1106
1161
  },
1162
+ confirm: {
1163
+ type: "boolean",
1164
+ description: "Acknowledge the publish_confirm gate. When library.publish_confirm is set in config (user-global ~/.codecarto/config.yaml, or the workspace's .codecarto/workflow/config.yaml when cwd is passed), a call without confirm: true is refused and the refusal previews what would be published — library, entry, whether a new version or a metadata-only update, source_repo, headline, confidentiality — so the caller can show it before committing to the write. Pass true to publish. No effect when publish_confirm is unset or false.",
1165
+ },
1107
1166
  },
1108
1167
  required: ["source_repo", "headline"],
1109
1168
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "mcpName": "io.github.HuginnIndustries/codecartographer",
5
5
  "description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
6
6
  "type": "module",