codecartographer-pi 0.16.0 → 0.17.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 (40) hide show
  1. package/.codecarto/GUIDE.md +15 -2
  2. package/.codecarto/README.md +3 -0
  3. package/.codecarto/broadside/SKILL.md +143 -0
  4. package/.codecarto/broadside/config.yaml +104 -0
  5. package/.codecarto/findings/broadside-scout/README.md +20 -0
  6. package/.codecarto/findings/broadside-scout/SKILL.md +101 -0
  7. package/.codecarto/skills/spec-delta-application/SKILL.md +3 -1
  8. package/.codecarto/templates/backlog-project.md +51 -0
  9. package/.codecarto/templates/broadside-scout-brief.md +97 -0
  10. package/.codecarto/{THREAD_LOG.md → templates/thread-log.md} +2 -5
  11. package/.codecarto/workflow/pipeline-scout-first.yaml +271 -0
  12. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  13. package/README.md +47 -2
  14. package/agent-skill/codecartographer/SKILL.md +3 -1
  15. package/agent-skill/codecartographer/references/broadside.md +115 -0
  16. package/agent-skill/codecartographer/references/library.md +1 -1
  17. package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
  18. package/dist/core/broadside.d.ts +421 -0
  19. package/dist/core/broadside.js +2349 -0
  20. package/dist/core/completion.js +20 -4
  21. package/dist/core/index.d.ts +1 -0
  22. package/dist/core/index.js +1 -0
  23. package/dist/core/library.d.ts +22 -0
  24. package/dist/core/library.js +101 -1
  25. package/dist/core/orchestrator-config.js +5 -2
  26. package/dist/core/pipeline.js +1 -0
  27. package/dist/core/status.js +9 -1
  28. package/dist/core/utils.js +7 -1
  29. package/dist/core/workspace.d.ts +17 -0
  30. package/dist/core/workspace.js +68 -2
  31. package/dist/extensions/codecarto/agent-runner.js +6 -0
  32. package/dist/extensions/codecarto/broadside-flags.d.ts +21 -0
  33. package/dist/extensions/codecarto/broadside-flags.js +116 -0
  34. package/dist/extensions/codecarto/index.js +232 -4
  35. package/dist/mcp-server/server.d.ts +22 -0
  36. package/dist/mcp-server/server.js +218 -11
  37. package/package.json +10 -1
  38. package/.codecarto/BACKLOG.md +0 -184
  39. package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +0 -118
  40. package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +0 -111
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
4
  import { autoCompletePhase, buildAutoSummary, isPhaseRunning, runAuto, runSinglePhase } from "./auto-runner.js";
@@ -6,12 +6,16 @@ import { disposeAgentsWidget } from "./agent-widget.js";
6
6
  import { parseDashboardFlags } from "./dashboard-flags.js";
7
7
  import { narrateDashboard } from "./dashboard-narrator.js";
8
8
  import { writeDashboard } from "./dashboard-writer.js";
9
+ import { parseBroadsideFlags, KNOWN_BROADSIDE_TOKENS } from "./broadside-flags.js";
9
10
  import { parseNextFlags } from "./next-flags.js";
10
11
  import { phaseCompactionExtension } from "./phase-compaction.js";
11
- import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, canonicalPath, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPathResolved, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, runPhasePreflight, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, } from "../../core/index.js";
12
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, 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
13
  import { initLibrary } from "../../core/library.js";
13
14
  import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
14
15
  const STATUS_WIDGET_ID = "codecarto-widget";
16
+ // Broad-Side gets its own widget id: a scout run is legal on a repository with
17
+ // no workspace, where the phase widget has nothing to render.
18
+ const BROADSIDE_WIDGET_ID = "codecarto-broadside";
15
19
  const STATUS_LINE_ID = "codecarto-status";
16
20
  const SAFE_TOOL_NAMES = ["read", "grep", "find", "ls", "edit", "write"];
17
21
  function derivePublishHeadline(spec, cwd) {
@@ -92,6 +96,47 @@ function setUiState(ctx, state, extraLines = []) {
92
96
  ctx.ui.setStatus(STATUS_LINE_ID, `${theme.fg("accent", "CC")} ${theme.fg("dim", currentPhase)}`);
93
97
  ctx.ui.setWidget(STATUS_WIDGET_ID, buildStatusLines(state, extraLines));
94
98
  }
99
+ /**
100
+ * Resolve the OpenRouter key for a Broad-Side run. Deliberately no slash-command
101
+ * parameter: a key typed as a command argument lands in the session transcript.
102
+ */
103
+ function resolveBroadsideKey(configuredKey) {
104
+ const fromEnv = process.env.OPENROUTER_API_KEY?.trim();
105
+ if (fromEnv)
106
+ return fromEnv;
107
+ return configuredKey.trim() || null;
108
+ }
109
+ /** The spend decision, rendered for a human about to approve it. */
110
+ function describeBroadsideEstimate(estimate) {
111
+ const lines = [
112
+ `Model: ${estimate.model}${estimate.mixedModels ? " (some lenses overridden — see below)" : ""} (pricing: ${estimate.pricing.source})`,
113
+ `Rates: $${estimate.pricing.inputPerM.toFixed(4)}/M in · $${estimate.pricing.outputPerM.toFixed(4)}/M out`,
114
+ "",
115
+ "Per lens:",
116
+ ...estimate.lenses.map(({ name, slices, cost, model }) => {
117
+ // Naming the model only when it differs keeps the common case quiet
118
+ // and makes a mixed-model run impossible to approve without noticing.
119
+ const override = estimate.mixedModels && model !== estimate.model ? ` on ${model}` : "";
120
+ return ` ${name}: ${slices} slice${slices === 1 ? "" : "s"} — ~$${cost.toFixed(4)}${override}`;
121
+ }),
122
+ "",
123
+ `Estimated total: ~$${estimate.totalCost.toFixed(4)} ` +
124
+ `(~${Math.round(estimate.inputTokens / 1000)}k in, ~${Math.round(estimate.outputTokens / 1000)}k out)`,
125
+ ];
126
+ if (estimate.maxCost > 0) {
127
+ lines.push(estimate.exceedsLimit
128
+ ? `This EXCEEDS the configured max_cost of $${estimate.maxCost.toFixed(2)}. Approving here overrides it for this run.`
129
+ : `Within the configured max_cost of $${estimate.maxCost.toFixed(2)}.`);
130
+ }
131
+ if (estimate.baseHead) {
132
+ lines.push(`Incremental: only modules changed since ${estimate.baseHead.slice(0, 8)} are included.`);
133
+ }
134
+ else if (estimate.sourceDirty) {
135
+ lines.push("Incremental was requested but the tree is dirty — this is a full scan.");
136
+ }
137
+ lines.push("", "The estimate is a pre-flight prediction from file sizes; OpenRouter bills actual usage.");
138
+ return lines.join("\n");
139
+ }
95
140
  export default function codeCartographerExtension(pi) {
96
141
  phaseCompactionExtension(pi);
97
142
  let lastFeedbackLines = [];
@@ -268,7 +313,7 @@ export default function codeCartographerExtension(pi) {
268
313
  }
269
314
  if (!(await pathExists(targetWorkspaceDir))) {
270
315
  await mkdir(ctx.cwd, { recursive: true });
271
- await cp(sourceWorkspaceDir, targetWorkspaceDir, { recursive: true });
316
+ await copyPackagedWorkspace(targetWorkspaceDir);
272
317
  }
273
318
  const rawStatusPath = join(targetWorkspaceDir, "workflow", "status.yaml");
274
319
  const rawStatus = (await loadYamlFile(rawStatusPath)) ?? {};
@@ -597,6 +642,30 @@ export default function codeCartographerExtension(pi) {
597
642
  ctx.ui.notify(`Usage: /codecarto-skill <name>${hint}`, "warning");
598
643
  return;
599
644
  }
645
+ // Broad-Side is a reading guide for batch reconnaissance output, not a
646
+ // post-pipeline skill: it is read before or during the pipeline and works
647
+ // on a repository with scout state and no workspace. Same exemption the
648
+ // MCP surface makes in handleSkill.
649
+ if (skillName === BROADSIDE_SKILL_NAME) {
650
+ const skill = await readBroadsideSkill(ctx.cwd).catch(() => null);
651
+ if (!skill) {
652
+ ctx.ui.notify("Broad-Side reading guide not found. Reinstall codecartographer-pi.", "error");
653
+ return;
654
+ }
655
+ const message = [
656
+ "Read the Broad-Side reading guide below and apply it to the batch reconnaissance results in .codecarto/broadside/.",
657
+ "",
658
+ skill.content,
659
+ ].join("\n");
660
+ if (ctx.isIdle()) {
661
+ pi.sendUserMessage(message);
662
+ }
663
+ else {
664
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
665
+ }
666
+ ctx.ui.notify("Queued the Broad-Side reading guide", "info");
667
+ return;
668
+ }
600
669
  const state = await ensureWorkspaceState(ctx);
601
670
  if (!state)
602
671
  return;
@@ -609,7 +678,7 @@ export default function codeCartographerExtension(pi) {
609
678
  if (!(await pathExists(skillFile))) {
610
679
  const available = await listSkillNames(state.workspaceDir);
611
680
  const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : " (no skills installed)";
612
- ctx.ui.notify(`Unknown skill: ${skillName}${hint}`, "error");
681
+ ctx.ui.notify(`Unknown skill: ${skillName}${hint}. The Broad-Side reading guide is served as \`${BROADSIDE_SKILL_NAME}\` and is not pipeline-gated.`, "error");
613
682
  return;
614
683
  }
615
684
  const prompt = await buildSkillPrompt(state, skillName);
@@ -624,6 +693,165 @@ export default function codeCartographerExtension(pi) {
624
693
  ctx.ui.notify(`Queued CodeCartographer skill: ${skillName}`, "info");
625
694
  },
626
695
  });
696
+ pi.registerCommand("codecarto-broadside", {
697
+ description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models] [lenses…] [flags]",
698
+ getArgumentCompletions: (prefix) => {
699
+ const items = KNOWN_BROADSIDE_TOKENS
700
+ .filter((value) => value.startsWith(prefix))
701
+ .map((value) => ({ value, label: value }));
702
+ return items.length > 0 ? items : null;
703
+ },
704
+ handler: async (args, ctx) => {
705
+ const flags = parseBroadsideFlags(args);
706
+ if (flags.unknown.length > 0) {
707
+ ctx.ui.notify(`Unknown /codecarto-broadside argument: ${flags.unknown.join(" ")}. ` +
708
+ `Actions: submit, collect, status, models. Lenses: ${BROADSIDE_LENS_IDS.join(", ")}.`, "error");
709
+ return;
710
+ }
711
+ if (flags.error) {
712
+ ctx.ui.notify(flags.error, "error");
713
+ return;
714
+ }
715
+ // Broad-Side runs on any git repository, with or without a workspace —
716
+ // so this command never goes through ensureWorkspaceState.
717
+ const broadsideDir = broadsideDirFor(ctx.cwd);
718
+ const config = await loadBroadsideConfig(broadsideDir);
719
+ const finish = (lines, notice, level = "info") => {
720
+ lastFeedbackLines = lines;
721
+ if (codecartoModeActive) {
722
+ // A workspace session already has a widget; fold the result into it.
723
+ if (ctx.hasUI)
724
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
725
+ void refreshWorkspaceUi(ctx, lines);
726
+ }
727
+ else if (ctx.hasUI) {
728
+ // Scout-only repository: the Broad-Side widget is the only place
729
+ // the result can live, so it holds it instead of being cleared.
730
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, ["Broad-Side", ...lines]);
731
+ }
732
+ ctx.ui.notify(notice, level);
733
+ };
734
+ if (flags.action === "status") {
735
+ const { state } = await runBroadsideStatus(ctx.cwd);
736
+ const runs = state.runs.length;
737
+ finish(statusText(state).split("\n"), runs > 0 ? `Broad-Side: ${runs} recorded run${runs === 1 ? "" : "s"}` : "Broad-Side: no runs recorded yet");
738
+ return;
739
+ }
740
+ const apiKey = resolveBroadsideKey(config.apiKey);
741
+ if (!apiKey) {
742
+ ctx.ui.notify("No OpenRouter API key. Set OPENROUTER_API_KEY in the environment, or add api_key to " +
743
+ ".codecarto/broadside/config.yaml. (A slash command takes no key: it would land in the transcript.)", "error");
744
+ return;
745
+ }
746
+ if (flags.action === "models") {
747
+ ctx.ui.notify("Fetching the OpenRouter batch-model catalog…", "info");
748
+ try {
749
+ const { entries, benchmarks } = await listBatchModels(broadsideDir, config, apiKey, {
750
+ includeBenchmarks: flags.benchmarks,
751
+ });
752
+ finish(modelsText(entries, { benchmarks, defaultModel: config.model }).split("\n"), `Broad-Side: ${entries.length} batch model${entries.length === 1 ? "" : "s"} listed`);
753
+ }
754
+ catch (error) {
755
+ ctx.ui.notify(`Model catalog lookup failed: ${error instanceof Error ? error.message : String(error)}`, "error");
756
+ }
757
+ return;
758
+ }
759
+ // Live per-lens progress. Poll callbacks fire often, so they render
760
+ // into a widget rather than a notification stream.
761
+ const progress = new Map();
762
+ const renderProgress = (heading) => {
763
+ if (!ctx.hasUI)
764
+ return;
765
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, [
766
+ "Broad-Side",
767
+ heading,
768
+ ...[...progress.entries()].map(([lensId, line]) => ` ${lensId}: ${line}`),
769
+ ]);
770
+ };
771
+ const onStatus = (lensId, status, counts) => {
772
+ progress.set(lensId, `${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`);
773
+ renderProgress("Polling batches…");
774
+ };
775
+ const waitSeconds = flags.waitSeconds ?? config.waitSeconds;
776
+ const waitMs = waitSeconds > 0 ? waitSeconds * 1000 : undefined;
777
+ const includeSynthesis = flags.includeSynthesis ?? config.includeSynthesis;
778
+ const includeTriage = flags.includeTriage ?? config.includeTriage;
779
+ const retryTruncated = flags.retryTruncated ?? config.retryTruncated;
780
+ if (flags.action === "submit") {
781
+ const lenses = flags.lenses.length > 0 ? flags.lenses : config.defaultLenses;
782
+ renderProgress("Slicing the repository and pricing the run…");
783
+ let submit;
784
+ try {
785
+ submit = await runBroadsideSubmit(ctx.cwd, apiKey, {
786
+ lenses,
787
+ model: config.model,
788
+ maxCost: flags.maxCost ?? config.maxCost,
789
+ incremental: flags.incremental || config.incremental,
790
+ // Pi can ask, so it asks instead of refusing over max_cost the
791
+ // way MCP has to. An approval here IS the force flag.
792
+ confirm: (estimate) => ctx.ui.confirm(`Broad-Side will spend about $${estimate.totalCost.toFixed(4)}`, describeBroadsideEstimate(estimate)),
793
+ });
794
+ }
795
+ catch (error) {
796
+ if (ctx.hasUI)
797
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
798
+ if (error instanceof BroadsideCancelledError) {
799
+ ctx.ui.notify("Broad-Side cancelled. Nothing was submitted.", "info");
800
+ return;
801
+ }
802
+ ctx.ui.notify(`Broad-Side submit failed: ${error instanceof Error ? error.message : String(error)}`, "error");
803
+ return;
804
+ }
805
+ const lines = estimateSubmitText(submit, lenses.map(getLens)).split("\n");
806
+ if (!waitMs) {
807
+ lines.push("", "Batches are in flight. Run /codecarto-broadside collect when they finish.");
808
+ finish(lines, `Broad-Side submitted: run ${submit.runId} (~$${submit.estimatedTotalCost.toFixed(4)})`);
809
+ return;
810
+ }
811
+ ctx.ui.notify(`Broad-Side submitted run ${submit.runId}; polling for up to ${waitSeconds}s…`, "info");
812
+ try {
813
+ const collect = await runBroadsideCollect(ctx.cwd, apiKey, {
814
+ waitMs,
815
+ includeSynthesis,
816
+ includeTriage,
817
+ retryTruncated,
818
+ onStatus,
819
+ });
820
+ finish([...lines, "", ...collectResultText(collect).split("\n")], `Broad-Side ${collect.status}: run ${collect.runId}`);
821
+ }
822
+ catch (error) {
823
+ if (ctx.hasUI)
824
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
825
+ // The batches are submitted and paid for either way — say so, so
826
+ // nobody re-submits a run that is already in flight.
827
+ ctx.ui.notify(`Broad-Side submitted run ${submit.runId}, but collect failed: ` +
828
+ `${error instanceof Error ? error.message : String(error)}. Retry with /codecarto-broadside collect.`, "error");
829
+ }
830
+ return;
831
+ }
832
+ // action === "collect"
833
+ renderProgress("Polling batches…");
834
+ try {
835
+ const collect = await runBroadsideCollect(ctx.cwd, apiKey, {
836
+ waitMs,
837
+ includeSynthesis,
838
+ includeTriage,
839
+ retryTruncated,
840
+ onStatus,
841
+ });
842
+ const lines = collectResultText(collect).split("\n");
843
+ const done = collect.status === "completed";
844
+ if (!done)
845
+ lines.push("", "Still in flight. Run /codecarto-broadside collect again to resume.");
846
+ finish(lines, `Broad-Side ${collect.status}: ${collect.resultCount} result${collect.resultCount === 1 ? "" : "s"} saved`, done ? "info" : "warning");
847
+ }
848
+ catch (error) {
849
+ if (ctx.hasUI)
850
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
851
+ ctx.ui.notify(`Broad-Side collect failed: ${error instanceof Error ? error.message : String(error)}`, "error");
852
+ }
853
+ },
854
+ });
627
855
  pi.registerCommand("codecarto-publish", {
628
856
  description: "Publish the completed reimplementation spec to the configured CodeCartographer library",
629
857
  handler: async (_args, ctx) => {
@@ -230,6 +230,28 @@ export declare function handleAmend(args: {
230
230
  text: string;
231
231
  };
232
232
  }>;
233
+ export declare function handleBroadside(args: {
234
+ cwd: string;
235
+ action: "submit" | "collect" | "status" | "models";
236
+ lenses?: string[];
237
+ api_key?: string;
238
+ wait_seconds?: number;
239
+ include_synthesis?: boolean;
240
+ include_triage?: boolean;
241
+ retry_truncated?: boolean;
242
+ max_cost?: number;
243
+ force?: boolean;
244
+ include_benchmarks?: boolean;
245
+ incremental?: boolean;
246
+ }): Promise<{
247
+ content: {
248
+ type: "text";
249
+ text: string;
250
+ }[];
251
+ structuredContent: {
252
+ text: string;
253
+ };
254
+ }>;
233
255
  export declare function handleGuide(args: {
234
256
  topic?: string;
235
257
  }): Promise<{
@@ -1,5 +1,7 @@
1
- // CodeCartographer MCP server. Exposes the framework as seven JSON-RPC tools
2
- // equivalent to the seven /codecarto-* commands the Pi extension registers.
1
+ // CodeCartographer MCP server. Exposes the framework's workflow, library, and
2
+ // Broad-Side operations as JSON-RPC tools (the TOOLS array below is the
3
+ // authoritative list; the README's tool table maps each tool to its Pi
4
+ // equivalent or marks it MCP-only).
3
5
  // Both wrappers import their primitives from ../core/index.ts so phase prompts,
4
6
  // status normalization, validation, and atomic completion are byte-identical
5
7
  // across surfaces.
@@ -13,9 +15,9 @@
13
15
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
17
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
16
- import { cp, mkdir, readFile, rename, writeFile } from "node:fs/promises";
18
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
17
19
  import { basename, isAbsolute, join } from "node:path";
18
- import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, canonicalPath, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listEntries, listGuideTopics, readGuide, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, seedOrchestratorFiles, 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, 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";
19
21
  import { applyAmendment } from "../core/amendment.js";
20
22
  import { appendUsageRun } from "../core/usage.js";
21
23
  import { initLibrary } from "../core/library.js";
@@ -84,7 +86,16 @@ export async function handleInit(args) {
84
86
  normalizeForComparison(await canonicalPath(targetWorkspaceDir)) ===
85
87
  normalizeForComparison(await canonicalPath(packagedWorkspaceDir));
86
88
  }
89
+ // Broad-Side (batch reconnaissance) creates .codecarto/broadside/ on any
90
+ // repo, workspace or not. A .codecarto/ holding only that directory is not
91
+ // an existing workspace — init must proceed and merge the template into it
92
+ // rather than demanding force and a backup of pure scout state.
93
+ let broadsideOnly = false;
87
94
  if (targetExists && !sameWorkspace) {
95
+ const entries = (await readdir(targetWorkspaceDir)).filter((entry) => entry !== BROADSIDE_DIR);
96
+ broadsideOnly = entries.length === 0 && (await pathExists(join(targetWorkspaceDir, BROADSIDE_DIR)));
97
+ }
98
+ if (targetExists && !sameWorkspace && !broadsideOnly) {
88
99
  if (!args.force) {
89
100
  throw new McpError(ErrorCode.InvalidRequest, `A .codecarto/ directory already exists at ${targetWorkspaceDir}. Pass force: true to back it up and reinitialize. Warning: this moves all existing findings, handoffs, usage data, closeouts, and phase progress to a .codecarto-backup-TIMESTAMP/ directory.`);
90
101
  }
@@ -93,7 +104,12 @@ export async function handleInit(args) {
93
104
  }
94
105
  if (!(await pathExists(targetWorkspaceDir))) {
95
106
  await mkdir(cwd, { recursive: true });
96
- await cp(packagedWorkspaceDir, targetWorkspaceDir, { recursive: true });
107
+ await copyPackagedWorkspace(targetWorkspaceDir);
108
+ }
109
+ else if (broadsideOnly) {
110
+ // Merge the template into the scout-only .codecarto/, preserving the
111
+ // broadside state and results already on disk.
112
+ await copyPackagedWorkspace(targetWorkspaceDir);
97
113
  }
98
114
  const statusPath = join(targetWorkspaceDir, "workflow", "status.yaml");
99
115
  const rawStatus = (await loadYamlFile(statusPath)) ?? {};
@@ -301,6 +317,15 @@ export async function handleSkill(args) {
301
317
  throw new McpError(ErrorCode.InvalidParams, "name is required");
302
318
  }
303
319
  const cwd = await validateCwd(args.cwd);
320
+ // Broad-Side is a reading guide for batch reconnaissance output, not a
321
+ // post-pipeline skill: it is useful before the pipeline starts and on a
322
+ // repository with no workspace at all, so it is served ahead of both gates.
323
+ if (args.name.trim() === BROADSIDE_SKILL_NAME) {
324
+ const skill = await readBroadsideSkill(cwd).catch((error) => {
325
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
326
+ });
327
+ return textResult(skill.content, { skill: BROADSIDE_SKILL_NAME, path: skill.path, postPipeline: false });
328
+ }
304
329
  const state = await requireWorkspace(cwd);
305
330
  const nextPhase = getNextEligiblePhase(state);
306
331
  if (nextPhase) {
@@ -310,7 +335,7 @@ export async function handleSkill(args) {
310
335
  if (!(await pathExists(skillFile))) {
311
336
  const available = await listSkillNames(state.workspaceDir);
312
337
  const hint = available.length > 0 ? ` Available: ${available.join(", ")}.` : " No skills installed.";
313
- throw new McpError(ErrorCode.InvalidParams, `Unknown skill: ${args.name}.${hint}`);
338
+ throw new McpError(ErrorCode.InvalidParams, `Unknown skill: ${args.name}.${hint} The Broad-Side reading guide is served as \`${BROADSIDE_SKILL_NAME}\` and is not pipeline-gated.`);
314
339
  }
315
340
  const prompt = await buildSkillPrompt(state, args.name);
316
341
  return textResult(prompt, { skill: args.name });
@@ -508,7 +533,10 @@ export async function handlePublish(args) {
508
533
  capabilities,
509
534
  confidentiality,
510
535
  generation,
511
- }, { forceNewVersion: args.force_new_version === true });
536
+ }, {
537
+ forceNewVersion: args.force_new_version === true,
538
+ allowSourceRepoChange: args.allow_source_repo_change === true,
539
+ });
512
540
  const lines = [
513
541
  `Published ${result.namespace ? `${result.namespace}/` : ""}${result.slug} v${result.version} to ${libraryPath}`,
514
542
  result.isNewVersion ? `New version: v${result.version}` : `Metadata-only update (content hash matched v${result.version}).`,
@@ -709,7 +737,13 @@ export async function handleListSkills(args) {
709
737
  const lines = skills.length > 0
710
738
  ? [`Available skills (${skills.length}):`, ...skills.map((s) => ` - ${s}`)]
711
739
  : ["No skills installed."];
712
- return textResult(lines.join("\n"), { skills });
740
+ // Broad-Side is listed apart from the post-pipeline set because it answers
741
+ // to codecarto_skill without the completion gate.
742
+ const broadsideAvailable = await readBroadsideSkill(cwd).then(() => true, () => false);
743
+ if (broadsideAvailable) {
744
+ lines.push("", `Also served by codecarto_skill (not pipeline-gated): ${BROADSIDE_SKILL_NAME} — how to read a Broad-Side batch reconnaissance run.`);
745
+ }
746
+ return textResult(lines.join("\n"), { skills, broadside: broadsideAvailable });
713
747
  }
714
748
  export async function handleRefreshScaffold(args) {
715
749
  const cwd = await validateCwd(args.cwd);
@@ -761,6 +795,117 @@ export async function handleAmend(args) {
761
795
  dashboardPath,
762
796
  });
763
797
  }
798
+ // ---------- broadside (batch reconnaissance) ----------
799
+ function resolveBroadsideApiKey(explicit, config) {
800
+ if (explicit && explicit.trim())
801
+ return explicit.trim();
802
+ const fromEnv = process.env.OPENROUTER_API_KEY?.trim();
803
+ if (fromEnv)
804
+ return fromEnv;
805
+ if (config.apiKey)
806
+ return config.apiKey;
807
+ throw new McpError(ErrorCode.InvalidParams, "No OpenRouter API key found. Pass api_key, set the OPENROUTER_API_KEY environment variable, or add api_key to .codecarto/broadside/config.yaml.");
808
+ }
809
+ export async function handleBroadside(args) {
810
+ const cwd = await validateCwd(args.cwd);
811
+ const action = args.action ?? "submit";
812
+ if (!["submit", "collect", "status", "models"].includes(action)) {
813
+ throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${action}. Valid actions: submit, collect, status, models.`);
814
+ }
815
+ const config = await loadBroadsideConfig(broadsideDirFor(cwd));
816
+ if (action === "status") {
817
+ const { state } = await runBroadsideStatus(cwd);
818
+ return textResult(statusText(state), { state });
819
+ }
820
+ const apiKey = resolveBroadsideApiKey(args.api_key, config);
821
+ // Every run knob resolves the same way: explicit parameter, else the repo's
822
+ // config.yaml default, else the shipped default baked into loadBroadsideConfig.
823
+ const waitSeconds = typeof args.wait_seconds === "number" && args.wait_seconds > 0
824
+ ? args.wait_seconds
825
+ : config.waitSeconds;
826
+ const waitMs = waitSeconds > 0 ? waitSeconds * 1000 : undefined;
827
+ const includeSynthesis = args.include_synthesis ?? config.includeSynthesis;
828
+ const includeTriage = args.include_triage ?? config.includeTriage;
829
+ const retryTruncated = args.retry_truncated ?? config.retryTruncated;
830
+ const incremental = args.incremental ?? config.incremental;
831
+ if (action === "models") {
832
+ const { entries, benchmarks } = await listBatchModels(broadsideDirFor(cwd), config, apiKey, {
833
+ includeBenchmarks: args.include_benchmarks === true,
834
+ }).catch((error) => {
835
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
836
+ });
837
+ return textResult(modelsText(entries, { benchmarks, defaultModel: config.model }), {
838
+ models: entries,
839
+ defaultModel: config.model,
840
+ benchmarkMeta: benchmarks?.meta ?? null,
841
+ });
842
+ }
843
+ if (action === "submit") {
844
+ let lenses;
845
+ if (args.lenses && args.lenses.length > 0) {
846
+ const unknown = args.lenses.filter((l) => !BROADSIDE_LENS_IDS.includes(l));
847
+ if (unknown.length > 0) {
848
+ throw new McpError(ErrorCode.InvalidParams, `Unknown lens(es): ${unknown.join(", ")}. Valid: ${BROADSIDE_LENS_IDS.join(", ")}`);
849
+ }
850
+ lenses = args.lenses;
851
+ }
852
+ else {
853
+ lenses = config.defaultLenses;
854
+ }
855
+ const maxCost = typeof args.max_cost === "number" && args.max_cost > 0 ? args.max_cost : config.maxCost;
856
+ const result = await runBroadsideSubmit(cwd, apiKey, {
857
+ lenses,
858
+ model: config.model,
859
+ maxCost,
860
+ force: args.force === true,
861
+ incremental,
862
+ }).catch((error) => {
863
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
864
+ });
865
+ const lines = [estimateSubmitText(result, lenses.map(getLens))];
866
+ if (waitMs) {
867
+ lines.push("", "Waiting for batches to complete...");
868
+ const collect = await runBroadsideCollect(cwd, apiKey, {
869
+ waitMs,
870
+ includeSynthesis,
871
+ includeTriage,
872
+ retryTruncated,
873
+ onStatus: (lensId, status, counts) => lines.push(` ${lensId}: ${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`),
874
+ });
875
+ lines.push("", collectResultText(collect));
876
+ }
877
+ return textResult(lines.join("\n"), {
878
+ runId: result.runId,
879
+ outputDir: result.outputDir,
880
+ batches: result.batches,
881
+ estimatedTotalCost: result.estimatedTotalCost,
882
+ pricing: result.pricing,
883
+ maxCost: result.maxCost,
884
+ });
885
+ }
886
+ // action === "collect"
887
+ const collect = await runBroadsideCollect(cwd, apiKey, {
888
+ waitMs,
889
+ includeSynthesis,
890
+ includeTriage,
891
+ retryTruncated,
892
+ }).catch((error) => {
893
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
894
+ });
895
+ return textResult(collectResultText(collect), {
896
+ runId: collect.runId,
897
+ status: collect.status,
898
+ totalCost: collect.totalCost,
899
+ resultCount: collect.resultCount,
900
+ truncatedCount: collect.truncatedCount,
901
+ retriedCount: collect.retriedCount,
902
+ lensOutcomes: collect.lensOutcomes,
903
+ synthesis: collect.synthesis,
904
+ triage: collect.triage,
905
+ topFindings: collect.topFindings,
906
+ topTriageItems: collect.topTriageItems,
907
+ });
908
+ }
764
909
  // ---------- tool registry ----------
765
910
  const TOOLS = [
766
911
  {
@@ -853,12 +998,12 @@ const TOOLS = [
853
998
  },
854
999
  {
855
1000
  name: "codecarto_skill",
856
- description: "Return the prompt text for a post-pipeline skill (only callable after all phases are complete). Use codecarto_status to confirm completion first.",
1001
+ description: "Return the prompt text for a post-pipeline skill (only callable after all phases are complete). Use codecarto_status to confirm completion first. One name is exempt from the completion gate: \"broadside\" returns the reading guide for a Broad-Side batch reconnaissance run, which is meant to be read before or during the pipeline and works without a workspace.",
857
1002
  inputSchema: {
858
1003
  type: "object",
859
1004
  properties: {
860
1005
  cwd: { type: "string", description: "Absolute path to the target repository." },
861
- name: { type: "string", description: "Skill name (a directory under .codecarto/skills/)." },
1006
+ name: { type: "string", description: "Skill name (a directory under .codecarto/skills/), or \"broadside\" for the Broad-Side reading guide." },
862
1007
  },
863
1008
  required: ["cwd", "name"],
864
1009
  },
@@ -897,6 +1042,10 @@ const TOOLS = [
897
1042
  },
898
1043
  },
899
1044
  force_new_version: { type: "boolean" },
1045
+ allow_source_repo_change: {
1046
+ type: "boolean",
1047
+ description: "Permit publishing when the target entry already records a different source_repo. Off by default, because a mismatch usually means two projects derived the same slug and the spec would land in the wrong version history. Set only when the repository itself moved.",
1048
+ },
900
1049
  },
901
1050
  required: ["source_repo", "headline"],
902
1051
  },
@@ -1004,7 +1153,7 @@ const TOOLS = [
1004
1153
  },
1005
1154
  {
1006
1155
  name: "codecarto_list_skills",
1007
- description: "List available post-pipeline skills installed in the workspace.",
1156
+ description: "List available post-pipeline skills installed in the workspace, plus the Broad-Side reading guide when it is present (that one is not pipeline-gated).",
1008
1157
  inputSchema: {
1009
1158
  type: "object",
1010
1159
  properties: { cwd: { type: "string", description: "Absolute path to the target repository." } },
@@ -1032,6 +1181,63 @@ const TOOLS = [
1032
1181
  required: ["cwd"],
1033
1182
  },
1034
1183
  },
1184
+ {
1185
+ name: "codecarto_broadside",
1186
+ description: "Broad-Side: fire a cheap batch reconnaissance scan at a repository via the OpenRouter Batch API. Six lenses (architecture, api, security, defect, conventions, porting) run as asynchronous single-turn prompts with structured JSON schemas; results land in .codecarto/broadside/<run>/ as JSON plus markdown, with an optional cross-lens synthesis report. Works on any git repository — no CodeCartographer workspace required. Requires an OpenRouter API key (api_key param, OPENROUTER_API_KEY env var, or .codecarto/broadside/config.yaml). Findings are unverified scouting signals from a batch model, not validated claims — they tell the interactive pipeline where to look. Actions: submit (fire batches, returns batch ids and cost estimate), collect (poll to completion, save results, optionally synthesize), status (show recorded runs), models (list batch-capable models with pricing, context, output caps, structured-output support, and optional coding benchmarks).",
1187
+ inputSchema: {
1188
+ type: "object",
1189
+ properties: {
1190
+ cwd: { type: "string", description: "Absolute path to the target repository." },
1191
+ action: {
1192
+ type: "string",
1193
+ enum: ["submit", "collect", "status", "models"],
1194
+ description: "submit fires all lens batches and returns batch ids; collect polls submitted batches, saves results, and optionally runs the synthesis pass; status shows recorded runs; models lists batch-capable models with pricing and capabilities.",
1195
+ },
1196
+ lenses: {
1197
+ type: "array",
1198
+ items: { type: "string", enum: [...BROADSIDE_LENS_IDS] },
1199
+ description: "Lenses to run (submit only). Defaults to all six.",
1200
+ },
1201
+ api_key: {
1202
+ type: "string",
1203
+ description: "OpenRouter API key. Prefer the OPENROUTER_API_KEY environment variable or .codecarto/broadside/config.yaml.",
1204
+ },
1205
+ wait_seconds: {
1206
+ type: "number",
1207
+ description: "For submit: after submitting, poll up to this many seconds before returning. For collect: poll up to this many seconds before returning with partial state. Falls back to wait_seconds in .codecarto/broadside/config.yaml.",
1208
+ },
1209
+ include_synthesis: {
1210
+ type: "boolean",
1211
+ description: "Run the cross-lens synthesis pass once all lens batches complete. Falls back to include_synthesis in .codecarto/broadside/config.yaml (default true).",
1212
+ },
1213
+ include_triage: {
1214
+ type: "boolean",
1215
+ description: "Run the triage pass once all lens batches complete: turns the findings into a prioritized work order (impact × difficulty, P0-P3, effort estimates). Falls back to include_triage in .codecarto/broadside/config.yaml (default true).",
1216
+ },
1217
+ retry_truncated: {
1218
+ type: "boolean",
1219
+ description: "Re-submit lens results that came back truncated at the output token limit, once, with a doubled output cap. Falls back to retry_truncated in .codecarto/broadside/config.yaml (default true).",
1220
+ },
1221
+ max_cost: {
1222
+ type: "number",
1223
+ description: "Approximate run expense limit in USD. The submit action estimates the run cost from slice sizes and the configured model's per-token pricing (live OpenRouter lookup, cached 24h) and refuses to submit when the estimate exceeds the limit unless force is true. Falls back to max_cost in .codecarto/broadside/config.yaml.",
1224
+ },
1225
+ force: {
1226
+ type: "boolean",
1227
+ description: "Submit even when the cost estimate exceeds max_cost (default false).",
1228
+ },
1229
+ incremental: {
1230
+ type: "boolean",
1231
+ description: "Diff against the previous run's git HEAD and scan only the modules whose files changed (falls back to a full scan on a dirty tree or when no prior run exists). Falls back to incremental in .codecarto/broadside/config.yaml (default false).",
1232
+ },
1233
+ include_benchmarks: {
1234
+ type: "boolean",
1235
+ description: "For action 'models': annotate each model with its Artificial Analysis coding index (extra API call; default false).",
1236
+ },
1237
+ },
1238
+ required: ["cwd", "action"],
1239
+ },
1240
+ },
1035
1241
  ];
1036
1242
  const HANDLERS = {
1037
1243
  codecarto_amend: handleAmend,
@@ -1055,6 +1261,7 @@ const HANDLERS = {
1055
1261
  codecarto_dashboard: handleDashboard,
1056
1262
  codecarto_list_skills: handleListSkills,
1057
1263
  codecarto_guide: handleGuide,
1264
+ codecarto_broadside: handleBroadside,
1058
1265
  };
1059
1266
  export async function handleGuide(args) {
1060
1267
  const topics = await listGuideTopics();