codecartographer-pi 0.17.1 → 0.18.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.
@@ -126,26 +126,40 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
126
126
  post_pipeline: [],
127
127
  };
128
128
  }
129
+ /**
130
+ * Spell a tool for both executable surfaces — the shape the scaffold
131
+ * staleness notice adopted (#177). next_actions is canonical state rendered
132
+ * by codecarto_status on MCP and as the Pi widget's "Next:" line alike, and a
133
+ * Pi user handed only the MCP tool name has nothing to run. Several tools
134
+ * join with "then" so a sequence reads as one per surface. No backticks:
135
+ * these lines render in a plain TUI line and in the HTML dashboard.
136
+ */
137
+ function onBothSurfaces(...tools) {
138
+ const mcp = tools.map((tool) => `codecarto_${tool}`).join(" then ");
139
+ const pi = tools.map((tool) => `/codecarto-${tool.replace(/_/g, "-")}`).join(" then ");
140
+ return `${mcp} on MCP, ${pi} on Pi`;
141
+ }
129
142
  /**
130
143
  * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
131
144
  * moment every phase completes is exactly when skills, amendments, publishing,
132
145
  * and the dashboard apply; the prior static sentence left them undiscovered —
133
146
  * the 0.15.0 field test finished two full runs with every one of them unused.
134
- * Amendment recomputes this list so closure counts never go stale.
147
+ * Amendment recomputes this list so closure counts never go stale. Every tool
148
+ * named here is spelled for both surfaces (see onBothSurfaces).
135
149
  */
136
150
  export function buildTerminalNextActions(status) {
137
151
  const openQuestions = Object.values(status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
138
152
  const postPipeline = status.post_pipeline.length;
139
153
  const actions = [
140
- "All phases complete. Review findings; post-pipeline skills: codecarto_list_skills / codecarto_skill.",
154
+ `All phases complete. Review findings; post-pipeline skills: ${onBothSurfaces("list_skills", "skill")}.`,
141
155
  ];
142
156
  if (openQuestions > 0 || postPipeline > 0) {
143
- actions.push(`${openQuestions} open question(s) and ${postPipeline} post-pipeline item(s) remain — apply resolutions with codecarto_amend (write scratch/amendments/<slug>.yaml from templates/amendment.yaml).`);
157
+ actions.push(`${openQuestions} open question(s) and ${postPipeline} post-pipeline item(s) remain — apply resolutions with ${onBothSurfaces("amend")} (write scratch/amendments/<slug>.yaml from templates/amendment.yaml).`);
144
158
  }
145
159
  if ("reimplementation-spec" in status.phases) {
146
- actions.push("Publish the finished spec to a library: codecarto_publish (create one with codecarto_library_init; see the library guide topic).");
160
+ actions.push(`Publish the finished spec to a library: ${onBothSurfaces("publish")} (create one with ${onBothSurfaces("library_init")}; see the library guide topic).`);
147
161
  }
148
- actions.push("Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; codecarto_dashboard re-renders on demand). Usage totals: codecarto_usage.");
162
+ actions.push(`Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; re-render on demand with ${onBothSurfaces("dashboard")}). Usage totals: ${onBothSurfaces("usage")}.`);
149
163
  return actions;
150
164
  }
151
165
  export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
@@ -38,6 +38,16 @@ export declare function copyPackagedWorkspace(targetWorkspaceDir: string, source
38
38
  * @returns the file names created, for the caller's report.
39
39
  */
40
40
  export declare function seedOrchestratorFiles(workspaceDir: string): Promise<string[]>;
41
+ /**
42
+ * What a scaffold refresh never touches, for a wrapper that asks before
43
+ * refreshing to show. The same sets drive {@link refreshScaffold}, so the
44
+ * preview and the write cannot disagree.
45
+ */
46
+ export declare const SCAFFOLD_REFRESH_PROTECTED: Readonly<{
47
+ topLevel: readonly string[];
48
+ dirs: readonly string[];
49
+ workflowFiles: readonly string[];
50
+ }>;
41
51
  /** One scaffold refresh's outcome. */
42
52
  export type RefreshScaffoldResult = {
43
53
  /** Workspace-relative paths written, sorted. */
@@ -47,6 +57,12 @@ export type RefreshScaffoldResult = {
47
57
  /** The running framework version the scaffold now matches. */
48
58
  scaffoldVersionAfter: string;
49
59
  };
60
+ /**
61
+ * The workspace-relative paths a scaffold refresh would write, sorted — the
62
+ * exact set {@link refreshScaffold} copies, computed without writing anything.
63
+ * A wrapper that asks before refreshing shows this.
64
+ */
65
+ export declare function listScaffoldRefreshFiles(): Promise<string[]>;
50
66
  /**
51
67
  * Refresh a workspace's framework-owned files from the packaged template
52
68
  * (issue #102): both staleness notices instruct exactly this, and the only
@@ -199,6 +199,16 @@ const REFRESH_EXCLUDED_TOP_LEVEL = new Set(["BACKLOG.md", "THREAD_LOG.md", "CONV
199
199
  // generated results) — refresh must never overwrite it.
200
200
  const REFRESH_EXCLUDED_DIRS = new Set(["scratch", "inputs", "closeouts", "broadside"]);
201
201
  const REFRESH_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", "config.yaml", ".usage.local.yaml"]);
202
+ /**
203
+ * What a scaffold refresh never touches, for a wrapper that asks before
204
+ * refreshing to show. The same sets drive {@link refreshScaffold}, so the
205
+ * preview and the write cannot disagree.
206
+ */
207
+ export const SCAFFOLD_REFRESH_PROTECTED = Object.freeze({
208
+ topLevel: Object.freeze([...REFRESH_EXCLUDED_TOP_LEVEL]),
209
+ dirs: Object.freeze([...REFRESH_EXCLUDED_DIRS]),
210
+ workflowFiles: Object.freeze([...REFRESH_EXCLUDED_WORKFLOW_FILES]),
211
+ });
202
212
  async function listTemplateFiles(dir, relativeDir = "") {
203
213
  const entries = await readdir(dir, { withFileTypes: true });
204
214
  const files = [];
@@ -218,6 +228,17 @@ async function listTemplateFiles(dir, relativeDir = "") {
218
228
  }
219
229
  return files;
220
230
  }
231
+ /**
232
+ * The workspace-relative paths a scaffold refresh would write, sorted — the
233
+ * exact set {@link refreshScaffold} copies, computed without writing anything.
234
+ * A wrapper that asks before refreshing shows this.
235
+ */
236
+ export async function listScaffoldRefreshFiles() {
237
+ if (!existsSync(packagedWorkspaceDir)) {
238
+ throw new Error("Packaged .codecarto template is missing. Reinstall codecartographer-pi.");
239
+ }
240
+ return (await listTemplateFiles(packagedWorkspaceDir)).sort();
241
+ }
221
242
  /**
222
243
  * Refresh a workspace's framework-owned files from the packaged template
223
244
  * (issue #102): both staleness notices instruct exactly this, and the only
@@ -238,7 +259,7 @@ export async function refreshScaffold(cwd) {
238
259
  throw new Error("Packaged .codecarto template is missing. Reinstall codecartographer-pi.");
239
260
  }
240
261
  const scaffoldVersionBefore = state.scaffoldVersion;
241
- const files = (await listTemplateFiles(packagedWorkspaceDir)).sort();
262
+ const files = await listScaffoldRefreshFiles();
242
263
  for (const relativePath of files) {
243
264
  const target = join(state.workspaceDir, relativePath);
244
265
  await mkdir(dirname(target), { recursive: true });
@@ -261,6 +282,12 @@ export async function refreshScaffold(cwd) {
261
282
  };
262
283
  }
263
284
  // Numeric x.y.z comparison; null when either side is not a plain dotted triple.
285
+ /**
286
+ * The remedy every staleness notice points at, spelled for both executable
287
+ * surfaces: this text renders inside Pi's widget and inside MCP's status
288
+ * result alike, and a Pi user handed only the MCP tool name has nothing to run.
289
+ */
290
+ const SCAFFOLD_REFRESH_REMEDY = "`codecarto_refresh_scaffold` on MCP, `/codecarto-refresh-scaffold` on Pi";
264
291
  /**
265
292
  * Human-readable staleness notice for the workspace's .codecarto/ scaffold,
266
293
  * or null when the scaffold matches the running framework. A missing marker
@@ -272,7 +299,7 @@ export async function refreshScaffold(cwd) {
272
299
  export function describeScaffoldStaleness(state) {
273
300
  const scaffold = state.scaffoldVersion;
274
301
  if (!scaffold) {
275
- return "This workspace's .codecarto/ scaffold has no workflow/scaffold-version.yaml marker (introduced after v0.12.11), so its framework-owned files (GUIDE.md, templates/, workflow/ pipelines and VALIDATE.md) may predate the v0.12.0 handoff contract. Refresh them from the packaged CodeCartographer template (codecarto_refresh_scaffold does exactly this without touching project state).";
302
+ return `This workspace's .codecarto/ scaffold has no workflow/scaffold-version.yaml marker (introduced after v0.12.11), so its framework-owned files (GUIDE.md, templates/, workflow/ pipelines and VALIDATE.md) may predate the v0.12.0 handoff contract. Refresh them from the packaged CodeCartographer template (${SCAFFOLD_REFRESH_REMEDY}) — that refresh does not touch project state.`;
276
303
  }
277
304
  const comparison = compareDottedVersions(scaffold, PACKAGE_VERSION);
278
305
  if (comparison === 0)
@@ -280,10 +307,10 @@ export function describeScaffoldStaleness(state) {
280
307
  if (comparison === null) {
281
308
  return scaffold === PACKAGE_VERSION
282
309
  ? null
283
- : `This workspace's scaffold version (${scaffold}) does not match the running framework (${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template — codecarto_refresh_scaffold does exactly this without touching project state.`;
310
+ : `This workspace's scaffold version (${scaffold}) does not match the running framework (${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template (${SCAFFOLD_REFRESH_REMEDY}) — that refresh does not touch project state.`;
284
311
  }
285
312
  if (comparison < 0) {
286
- return `This workspace's scaffold (v${scaffold}) is older than the running framework (v${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template to pick up pipeline and template fixes — codecarto_refresh_scaffold does exactly this without touching project state.`;
313
+ return `This workspace's scaffold (v${scaffold}) is older than the running framework (v${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template to pick up pipeline and template fixes (${SCAFFOLD_REFRESH_REMEDY}) — that refresh does not touch project state.`;
287
314
  }
288
315
  return `This workspace's scaffold (v${scaffold}) is newer than the running framework (v${PACKAGE_VERSION}). Upgrade CodeCartographer to at least v${scaffold}.`;
289
316
  }
@@ -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
  }