codecartographer-pi 0.20.0 → 0.22.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 +1 -1
  2. package/.codecarto/broadside/SKILL.md +15 -4
  3. package/.codecarto/broadside/config.yaml +26 -10
  4. package/.codecarto/findings/contracts/SKILL.md +4 -1
  5. package/.codecarto/findings/defect-scan/SKILL.md +10 -0
  6. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +6 -0
  7. package/.codecarto/findings/defect-scan-semantic/SKILL.md +8 -1
  8. package/.codecarto/findings/porting/SKILL.md +4 -0
  9. package/.codecarto/findings/protocols/SKILL.md +4 -0
  10. package/.codecarto/templates/mechanical-defects.md +15 -0
  11. package/.codecarto/templates/reimplementation-spec.md +5 -3
  12. package/.codecarto/templates/reverse-engineering-bundle.md +10 -1
  13. package/.codecarto/templates/semantic-defects.md +15 -0
  14. package/.codecarto/workflow/VALIDATE.md +1 -1
  15. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  16. package/README.md +5 -5
  17. package/agent-skill/codecartographer/references/broadside.md +10 -0
  18. package/dist/core/amendment.js +9 -4
  19. package/dist/core/broadside.d.ts +98 -0
  20. package/dist/core/broadside.js +330 -63
  21. package/dist/core/completion.js +7 -1
  22. package/dist/core/library.js +5 -3
  23. package/dist/core/pipeline.d.ts +39 -3
  24. package/dist/core/pipeline.js +61 -7
  25. package/dist/core/prompts.js +10 -3
  26. package/dist/core/status.d.ts +8 -0
  27. package/dist/core/status.js +39 -17
  28. package/dist/core/utils.d.ts +7 -0
  29. package/dist/core/utils.js +7 -0
  30. package/dist/core/workspace.js +2 -2
  31. package/dist/core/yaml.js +8 -1
  32. package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
  33. package/dist/extensions/codecarto/auto-runner.js +18 -3
  34. package/dist/extensions/codecarto/broadside-flags.d.ts +7 -1
  35. package/dist/extensions/codecarto/broadside-flags.js +51 -0
  36. package/dist/extensions/codecarto/index.js +103 -35
  37. package/dist/extensions/codecarto/phase-compaction.js +5 -1
  38. package/dist/mcp-server/server.d.ts +5 -1
  39. package/dist/mcp-server/server.js +140 -32
  40. package/package.json +1 -1
@@ -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, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolveSkillName, 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, writeDashboard, } from "../core/index.js";
20
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, 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, writeDashboard, } 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";
@@ -69,8 +69,12 @@ async function optionalCwd(cwd) {
69
69
  return validateCwd(cwd.trim());
70
70
  }
71
71
  async function requireWorkspace(cwd) {
72
+ // What getWorkspaceState throws is the user's to fix — a status.yaml that
73
+ // does not parse, a missing `pipeline:`, a pipeline file that is not there
74
+ // — so it is InvalidRequest, not the InternalError a host would retry or
75
+ // report as a server bug (self-audit mech 2.8).
72
76
  const state = await getWorkspaceState(cwd).catch((error) => {
73
- throw new McpError(ErrorCode.InternalError, error instanceof Error ? error.message : String(error));
77
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
74
78
  });
75
79
  if (!state) {
76
80
  throw new McpError(ErrorCode.InvalidRequest, `No CodeCartographer workspace at ${cwd}. Call codecarto_init first.`);
@@ -89,9 +93,9 @@ function textResult(text, structured) {
89
93
  structuredContent: { ...structured, text },
90
94
  };
91
95
  }
92
- async function buildMcpPhasePrompt(state, phase, forced) {
96
+ async function buildMcpPhasePrompt(state, phase, forced, auto = false) {
93
97
  try {
94
- return await buildPhasePrompt(state, phase, forced);
98
+ return await buildPhasePrompt(state, phase, forced, { auto });
95
99
  }
96
100
  catch (error) {
97
101
  if (error instanceof PhasePreflightError) {
@@ -188,8 +192,11 @@ export async function handleInit(args) {
188
192
  export async function handleStatus(args) {
189
193
  const cwd = await validateCwd(args.cwd);
190
194
  const state = await requireWorkspace(cwd);
191
- const nextPhase = getNextEligiblePhase(state);
192
- const currentPhase = nextPhase?.id ?? state.status.current_phase ?? "complete";
195
+ const outcome = resolvePipelineOutcome(state);
196
+ const nextPhase = outcome.kind === "eligible" ? outcome.phase : null;
197
+ // "complete" is the terminal marker; a stuck pipeline sits on its first
198
+ // blocked phase and says so below (#228).
199
+ const currentPhase = outcome.kind === "eligible" ? outcome.phase.id : outcome.kind === "stuck" ? outcome.blocked[0].phaseId : "complete";
193
200
  const completed = state.pipeline.phase_order.filter((id) => state.status.phases[id]?.status === "complete").length;
194
201
  const totalCarryForward = Object.values(state.status.phases).reduce((sum, phase) => sum + (phase.carry_forward?.length ?? 0), 0);
195
202
  const currentOpenQuestions = currentPhase === "complete" ? 0 : state.status.phases[currentPhase]?.open_questions?.length ?? 0;
@@ -199,7 +206,7 @@ export async function handleStatus(args) {
199
206
  const missingOutputs = await listMissingCompletedOutputs(state);
200
207
  const summaryLines = [
201
208
  `Phase: ${currentPhase}`,
202
- `Pipeline state: ${currentPhase === "complete" ? "complete" : "in progress"}`,
209
+ `Pipeline state: ${outcome.kind === "eligible" ? "in progress" : outcome.kind}`,
203
210
  `Pipeline: ${getPipelineLabel(state.status.pipeline)} (${state.status.pipeline})`,
204
211
  `Progress: ${completed}/${state.pipeline.phase_order.length} complete`,
205
212
  `Open questions (terminal unresolved): ${terminalOpenQuestions}`,
@@ -212,6 +219,8 @@ export async function handleStatus(args) {
212
219
  ? state.status.next_actions.map((action, index) => `${index === 0 ? "Next: " : " "}${action}`)
213
220
  : [`Next: ${nextPhase ? `Begin ${nextPhase.id}` : "All phases complete."}`]),
214
221
  ];
222
+ if (outcome.kind === "stuck")
223
+ summaryLines.push(describeStuckPipeline(outcome.blocked));
215
224
  if (scaffoldNotice)
216
225
  summaryLines.push(`Scaffold: ${scaffoldNotice}`);
217
226
  summaryLines.push(...describeMissingCompletedOutputs(missingOutputs));
@@ -219,6 +228,8 @@ export async function handleStatus(args) {
219
228
  return textResult(summary, {
220
229
  ...(scaffoldNotice ? { scaffoldNotice } : {}),
221
230
  ...(missingOutputs.length > 0 ? { missingOutputs } : {}),
231
+ ...(outcome.kind === "stuck" ? { stuck: outcome.blocked } : {}),
232
+ pipelineState: outcome.kind === "eligible" ? "in progress" : outcome.kind,
222
233
  currentPhase,
223
234
  pipeline: state.status.pipeline,
224
235
  pipelineLabel: getPipelineLabel(state.status.pipeline),
@@ -271,14 +282,24 @@ export async function handleSwitchPipeline(args) {
271
282
  export async function handleNext(args) {
272
283
  const cwd = await validateCwd(args.cwd);
273
284
  const state = await requireWorkspace(cwd);
274
- const phase = getNextEligiblePhase(state);
275
- if (!phase) {
285
+ const outcome = resolvePipelineOutcome(state);
286
+ if (outcome.kind === "stuck") {
287
+ // Not a result: a host looping on codecarto_next would read a text
288
+ // answer as "done" (#228). There is no prompt to hand out until the
289
+ // pipeline file is fixed.
290
+ throw new McpError(ErrorCode.InvalidRequest, describeStuckPipeline(outcome.blocked));
291
+ }
292
+ if (outcome.kind === "complete") {
276
293
  return textResult("All CodeCartographer phases are complete. Run codecarto_skill for post-pipeline work.", {
277
294
  complete: true,
278
295
  });
279
296
  }
280
- const prompt = await buildMcpPhasePrompt(state, phase, false);
281
- return textResult(prompt, { phase: phase.id, forced: false });
297
+ // `unattended` is the MCP spelling of Pi's --auto: nobody is there to
298
+ // answer the reimplementation-spec phase's Strategic Alignment Hook, so
299
+ // the prompt carries the auto-default instruction instead (#270).
300
+ const unattended = args.unattended === true;
301
+ const prompt = await buildMcpPhasePrompt(state, outcome.phase, false, unattended);
302
+ return textResult(prompt, { phase: outcome.phase.id, forced: false, unattended });
282
303
  }
283
304
  export async function handlePhase(args) {
284
305
  if (typeof args.phase !== "string" || !args.phase.trim()) {
@@ -290,8 +311,9 @@ export async function handlePhase(args) {
290
311
  if (!phase) {
291
312
  throw new McpError(ErrorCode.InvalidParams, `Unknown phase: ${args.phase}`);
292
313
  }
293
- const prompt = await buildMcpPhasePrompt(state, phase, true);
294
- return textResult(prompt, { phase: phase.id, forced: true });
314
+ const unattended = args.unattended === true;
315
+ const prompt = await buildMcpPhasePrompt(state, phase, true, unattended);
316
+ return textResult(prompt, { phase: phase.id, forced: true, unattended });
295
317
  }
296
318
  export async function handleValidate(args) {
297
319
  const cwd = await validateCwd(args.cwd);
@@ -391,9 +413,12 @@ export async function handleSkill(args) {
391
413
  return textResult(skill.content, { skill: BROADSIDE_SKILL_NAME, path: skill.path, postPipeline: false });
392
414
  }
393
415
  const state = await requireWorkspace(cwd);
394
- const nextPhase = getNextEligiblePhase(state);
395
- if (nextPhase) {
396
- throw new McpError(ErrorCode.InvalidRequest, `Cannot run skill: pipeline is not complete (next phase: ${nextPhase.id}). Finish the pipeline first.`);
416
+ const outcome = resolvePipelineOutcome(state);
417
+ if (outcome.kind === "eligible") {
418
+ throw new McpError(ErrorCode.InvalidRequest, `Cannot run skill: pipeline is not complete (next phase: ${outcome.phase.id}). Finish the pipeline first.`);
419
+ }
420
+ if (outcome.kind === "stuck") {
421
+ throw new McpError(ErrorCode.InvalidRequest, `Cannot run skill: the pipeline is not complete. ${describeStuckPipeline(outcome.blocked)}`);
397
422
  }
398
423
  // Resolve against the installed list only: the name is never joined onto a
399
424
  // path, so a traversal like `../findings/architecture` cannot splice a
@@ -883,8 +908,16 @@ export async function handleOpen(args) {
883
908
  throw new McpError(ErrorCode.InvalidRequest, "No existing CodeCartographer workspace found. Run codecarto_init first.");
884
909
  }
885
910
  const state = await requireWorkspace(cwd);
886
- const nextPhase = getNextEligiblePhase(state)?.id ?? "complete";
887
- return textResult(`Opened existing CodeCartographer workspace: ${getPipelineLabel(state.status.pipeline)}. Current phase: ${nextPhase}.`, { pipeline: getPipelineLabel(state.status.pipeline), currentPhase: nextPhase });
911
+ const outcome = resolvePipelineOutcome(state);
912
+ const nextPhase = outcome.kind === "eligible" ? outcome.phase.id : outcome.kind === "stuck" ? `${outcome.blocked[0].phaseId} (stuck)` : "complete";
913
+ const lines = [`Opened existing CodeCartographer workspace: ${getPipelineLabel(state.status.pipeline)}. Current phase: ${nextPhase}.`];
914
+ if (outcome.kind === "stuck")
915
+ lines.push(describeStuckPipeline(outcome.blocked));
916
+ return textResult(lines.join("\n"), {
917
+ pipeline: getPipelineLabel(state.status.pipeline),
918
+ currentPhase: nextPhase,
919
+ ...(outcome.kind === "stuck" ? { stuck: outcome.blocked } : {}),
920
+ });
888
921
  }
889
922
  export async function handleUsage(args) {
890
923
  const cwd = await validateCwd(args.cwd);
@@ -1007,32 +1040,58 @@ export async function handleBroadside(args) {
1007
1040
  if (!["submit", "collect", "status", "models"].includes(action)) {
1008
1041
  throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${action}. Valid actions: submit, collect, status, models.`);
1009
1042
  }
1010
- const config = await loadBroadsideConfig(broadsideDirFor(cwd));
1043
+ // A config.yaml that exists but cannot be read refuses every action that
1044
+ // would act on it (#232); status only reads recorded runs, so it answers
1045
+ // and says the file is unreadable. A corrupt state.json refuses even that:
1046
+ // there is nothing trustworthy to report and nothing may write over it (#233).
1047
+ let config;
1048
+ let configWarning = null;
1049
+ try {
1050
+ config = await loadBroadsideConfig(broadsideDirFor(cwd));
1051
+ }
1052
+ catch (error) {
1053
+ if (!(error instanceof BroadsideConfigError) || action !== "status") {
1054
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
1055
+ }
1056
+ config = defaultBroadsideConfig();
1057
+ configWarning = error.message;
1058
+ }
1011
1059
  if (action === "status") {
1012
- const { state } = await runBroadsideStatus(cwd);
1013
- return textResult(statusText(state), { state });
1060
+ const { state } = await runBroadsideStatus(cwd).catch((error) => {
1061
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
1062
+ });
1063
+ const lines = [statusText(state)];
1064
+ if (configWarning)
1065
+ lines.push(`Warning: ${configWarning}`);
1066
+ return textResult(lines.join("\n"), { state, ...(configWarning ? { configWarning } : {}) });
1014
1067
  }
1015
1068
  const apiKey = resolveBroadsideApiKey(args.api_key, config);
1016
1069
  // Every run knob resolves the same way: explicit parameter, else the repo's
1017
1070
  // config.yaml default, else the shipped default baked into loadBroadsideConfig.
1018
- const waitSeconds = typeof args.wait_seconds === "number" && args.wait_seconds > 0
1071
+ // An explicit 0 means "poll once and return": it used to fall through to
1072
+ // the config default and, as undefined, to the 25-minute budget (#230).
1073
+ const waitSeconds = typeof args.wait_seconds === "number" && args.wait_seconds >= 0
1019
1074
  ? args.wait_seconds
1020
1075
  : config.waitSeconds;
1021
- const waitMs = waitSeconds > 0 ? waitSeconds * 1000 : undefined;
1076
+ const waitMs = waitSeconds * 1000;
1022
1077
  const includeSynthesis = args.include_synthesis ?? config.includeSynthesis;
1023
1078
  const includeTriage = args.include_triage ?? config.includeTriage;
1024
1079
  const retryTruncated = args.retry_truncated ?? config.retryTruncated;
1025
1080
  const incremental = args.incremental ?? config.incremental;
1026
1081
  if (action === "models") {
1027
- const { entries, benchmarks } = await listBatchModels(broadsideDirFor(cwd), config, apiKey, {
1082
+ const { entries, benchmarks, endpoints } = await listBatchModels(broadsideDirFor(cwd), config, apiKey, {
1028
1083
  includeBenchmarks: args.include_benchmarks === true,
1029
1084
  }).catch((error) => {
1030
1085
  throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
1031
1086
  });
1032
- return textResult(modelsText(entries, { benchmarks, defaultModel: config.model }), {
1087
+ return textResult(modelsText(entries, { benchmarks, defaultModel: config.model, endpoints }), {
1033
1088
  models: entries,
1034
1089
  defaultModel: config.model,
1035
1090
  benchmarkMeta: benchmarks?.meta ?? null,
1091
+ // The catalog is advisory (#141): what this repository's submits
1092
+ // learned about each id's batch endpoint rides alongside it.
1093
+ catalogAdvisory: true,
1094
+ endpoints,
1036
1095
  });
1037
1096
  }
1038
1097
  if (action === "submit") {
@@ -1047,10 +1106,37 @@ export async function handleBroadside(args) {
1047
1106
  else {
1048
1107
  lenses = config.defaultLenses;
1049
1108
  }
1050
- const maxCost = typeof args.max_cost === "number" && args.max_cost > 0 ? args.max_cost : config.maxCost;
1109
+ // An explicit 0 is "no limit" (#231); absent falls back to config.yaml,
1110
+ // whose own default is BROADSIDE_DEFAULT_MAX_COST.
1111
+ const maxCost = typeof args.max_cost === "number" && args.max_cost >= 0 ? args.max_cost : config.maxCost;
1112
+ // Model selection for one run (#141): `model` replaces the run default,
1113
+ // `lens_models` layers per-lens overrides over config.yaml's. Both are
1114
+ // pre-flighted by core exactly like the file's values — priced from the
1115
+ // catalog, refused without structured-output support, clamped to the
1116
+ // model's ceiling — so a wrong id fails before anything is submitted.
1117
+ const model = typeof args.model === "string" && args.model.trim() ? args.model.trim() : config.model;
1118
+ if (args.model !== undefined && !(typeof args.model === "string" && args.model.trim())) {
1119
+ throw new McpError(ErrorCode.InvalidParams, "model must be a non-empty OpenRouter batch model id (see action 'models').");
1120
+ }
1121
+ const lensModels = {};
1122
+ if (args.lens_models !== undefined) {
1123
+ if (!args.lens_models || typeof args.lens_models !== "object" || Array.isArray(args.lens_models)) {
1124
+ throw new McpError(ErrorCode.InvalidParams, "lens_models must be an object mapping lens ids to batch model ids.");
1125
+ }
1126
+ for (const [lensId, value] of Object.entries(args.lens_models)) {
1127
+ if (!BROADSIDE_LENS_IDS.includes(lensId)) {
1128
+ throw new McpError(ErrorCode.InvalidParams, `lens_models: unknown lens "${lensId}". Valid: ${BROADSIDE_LENS_IDS.join(", ")}`);
1129
+ }
1130
+ if (typeof value !== "string" || !value.trim()) {
1131
+ throw new McpError(ErrorCode.InvalidParams, `lens_models.${lensId} must be a non-empty OpenRouter batch model id.`);
1132
+ }
1133
+ lensModels[lensId] = value.trim();
1134
+ }
1135
+ }
1051
1136
  const result = await runBroadsideSubmit(cwd, apiKey, {
1052
1137
  lenses,
1053
- model: config.model,
1138
+ model,
1139
+ lensModels,
1054
1140
  maxCost,
1055
1141
  force: args.force === true,
1056
1142
  incremental,
@@ -1084,11 +1170,13 @@ export async function handleBroadside(args) {
1084
1170
  });
1085
1171
  }
1086
1172
  // action === "collect"
1173
+ const runId = typeof args.run_id === "string" && args.run_id.trim() ? args.run_id.trim() : undefined;
1087
1174
  const collect = await runBroadsideCollect(cwd, apiKey, {
1088
1175
  waitMs,
1089
1176
  includeSynthesis,
1090
1177
  includeTriage,
1091
1178
  retryTruncated,
1179
+ ...(runId && { runId }),
1092
1180
  }).catch((error) => {
1093
1181
  throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
1094
1182
  });
@@ -1156,7 +1244,13 @@ const TOOLS = [
1156
1244
  description: "Return the prompt text for the next eligible CodeCartographer phase. The host should feed this prompt back to the agent or display it to the user.",
1157
1245
  inputSchema: {
1158
1246
  type: "object",
1159
- properties: { cwd: { type: "string", description: "Absolute path to the target repository." } },
1247
+ properties: {
1248
+ cwd: { type: "string", description: "Absolute path to the target repository." },
1249
+ unattended: {
1250
+ type: "boolean",
1251
+ description: "Set when no user is in the loop to answer the prompt's questions (an autonomous run). The reimplementation-spec prompt then defaults to the language-agnostic variant and records selection: auto-default instead of asking which variant to build, and every interactive hook is suppressed. Same as Pi's /codecarto-next --auto.",
1252
+ },
1253
+ },
1160
1254
  required: ["cwd"],
1161
1255
  },
1162
1256
  },
@@ -1168,6 +1262,7 @@ const TOOLS = [
1168
1262
  properties: {
1169
1263
  cwd: { type: "string", description: "Absolute path to the target repository." },
1170
1264
  phase: { type: "string", description: "Phase id from the active pipeline." },
1265
+ unattended: { type: "boolean", description: "As for codecarto_next: no user is in the loop, so interactive hooks default and record their choice instead of asking." },
1171
1266
  },
1172
1267
  required: ["cwd", "phase"],
1173
1268
  },
@@ -1186,7 +1281,7 @@ const TOOLS = [
1186
1281
  },
1187
1282
  {
1188
1283
  name: "codecarto_complete",
1189
- description: "Mark a phase complete. Requires the phase output's validation to be PASS or PASS WITH GAPS. Atomically updates status.yaml under a file lock, appends to THREAD_LOG.md, and creates a closeout stub from the template if one does not yet exist. If phase is omitted, completes the next eligible phase.",
1284
+ description: "Mark a phase complete. Requires the phase output's validation to be PASS or PASS WITH GAPS and applies the phase handoff at scratch/handoffs/<phase>.yaml. Atomically updates status.yaml under a file lock, appends one THREAD_LOG.md entry, and writes the phase's closeout: the handoff's closeout_content (plus its decisions) overwrites the canonical <date>-<phase>.md when supplied; otherwise a stub is copied from the template only if no closeout exists yet. If phase is omitted, completes the next eligible phase.",
1190
1285
  inputSchema: {
1191
1286
  type: "object",
1192
1287
  properties: {
@@ -1412,11 +1507,15 @@ const TOOLS = [
1412
1507
  },
1413
1508
  api_key: {
1414
1509
  type: "string",
1415
- description: "OpenRouter API key. Prefer the OPENROUTER_API_KEY environment variable or .codecarto/broadside/config.yaml.",
1510
+ description: "OpenRouter API key. A value passed here is recorded wherever the host logs tool calls; prefer the OPENROUTER_API_KEY environment variable of the server process. (api_key in .codecarto/broadside/config.yaml also works, but that file is tracked, so a key there is committed with it.)",
1511
+ },
1512
+ run_id: {
1513
+ type: "string",
1514
+ description: "For collect: the run to collect, as listed by the status action. Defaults to the most recent run; pass this to collect an older run that is still in flight after a newer submit.",
1416
1515
  },
1417
1516
  wait_seconds: {
1418
1517
  type: "number",
1419
- 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.",
1518
+ 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. 0 polls each in-flight batch once and returns without waiting. Falls back to wait_seconds in .codecarto/broadside/config.yaml (default 0).",
1420
1519
  },
1421
1520
  include_synthesis: {
1422
1521
  type: "boolean",
@@ -1432,7 +1531,7 @@ const TOOLS = [
1432
1531
  },
1433
1532
  max_cost: {
1434
1533
  type: "number",
1435
- 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.",
1534
+ 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, whose default is $1.00; pass 0 for no limit.",
1436
1535
  },
1437
1536
  force: {
1438
1537
  type: "boolean",
@@ -1446,6 +1545,15 @@ const TOOLS = [
1446
1545
  type: "boolean",
1447
1546
  description: "For action 'models': annotate each model with its Artificial Analysis coding index (extra API call; default false).",
1448
1547
  },
1548
+ model: {
1549
+ type: "string",
1550
+ description: "For submit: the OpenRouter batch model for this run (an id ending in :batch, as listed by action 'models'). Falls back to model in .codecarto/broadside/config.yaml, then the shipped default. Pre-flighted like the configured model: priced from the live catalog, refused without structured-output support, clamped to its completion ceiling. The models listing is advisory — some catalog ids have no batch endpoint and are refused at submit, at no cost; the listing tags ids this repository has already seen accepted or refused.",
1551
+ },
1552
+ lens_models: {
1553
+ type: "object",
1554
+ additionalProperties: { type: "string" },
1555
+ description: "For submit: per-lens model overrides for this run, e.g. {\"security\": \"deepseek/deepseek-v4-pro-0813:batch\"}. Keys are lens ids; a lens named here runs on that model, others on `model`. Layered over lens_models in .codecarto/broadside/config.yaml (a lens set in both takes the parameter's). Each override is priced, capability-checked, and clamped individually, and the estimate breaks cost out per lens.",
1556
+ },
1449
1557
  },
1450
1558
  required: ["cwd", "action"],
1451
1559
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.20.0",
3
+ "version": "0.22.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",