codecartographer-pi 0.19.6 → 0.21.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 (57) hide show
  1. package/.codecarto/GUIDE.md +2 -2
  2. package/.codecarto/broadside/SKILL.md +21 -3
  3. package/.codecarto/broadside/config.yaml +35 -9
  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 +3 -2
  15. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  16. package/README.md +13 -9
  17. package/dist/core/amendment.js +9 -4
  18. package/dist/core/broadside.d.ts +121 -2
  19. package/dist/core/broadside.js +478 -92
  20. package/dist/core/completion.js +88 -23
  21. package/dist/core/dashboard-writer.d.ts +8 -0
  22. package/dist/core/dashboard-writer.js +159 -0
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/library.js +9 -4
  26. package/dist/core/orchestrator-config.d.ts +32 -7
  27. package/dist/core/orchestrator-config.js +124 -44
  28. package/dist/core/pipeline.d.ts +73 -0
  29. package/dist/core/pipeline.js +134 -10
  30. package/dist/core/prompts.d.ts +20 -0
  31. package/dist/core/prompts.js +53 -13
  32. package/dist/core/secrets.d.ts +16 -0
  33. package/dist/core/secrets.js +98 -0
  34. package/dist/core/status.d.ts +16 -0
  35. package/dist/core/status.js +47 -20
  36. package/dist/core/synthesis.js +5 -2
  37. package/dist/core/utils.d.ts +7 -0
  38. package/dist/core/utils.js +7 -0
  39. package/dist/core/workspace.d.ts +55 -8
  40. package/dist/core/workspace.js +116 -8
  41. package/dist/core/yaml.js +181 -15
  42. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  43. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  44. package/dist/extensions/codecarto/agent-runner.js +27 -9
  45. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  46. package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
  47. package/dist/extensions/codecarto/auto-runner.js +27 -8
  48. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  49. package/dist/extensions/codecarto/broadside-flags.js +12 -0
  50. package/dist/extensions/codecarto/dashboard-narrator.js +9 -2
  51. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  52. package/dist/extensions/codecarto/dashboard-writer.js +5 -154
  53. package/dist/extensions/codecarto/index.js +158 -47
  54. package/dist/extensions/codecarto/phase-compaction.js +5 -1
  55. package/dist/mcp-server/server.d.ts +3 -1
  56. package/dist/mcp-server/server.js +205 -77
  57. package/package.json +3 -2
@@ -17,12 +17,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
17
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
18
18
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
19
19
  import { basename, isAbsolute, join } from "node:path";
20
- import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, seedOrchestratorFiles, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, } from "../core/index.js";
20
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, 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";
24
- import { loadUserConfig, resolveUserConfigPath } from "../core/orchestrator-config.js";
25
- import { writeDashboard } from "../extensions/codecarto/dashboard-writer.js";
24
+ import { describeConfigProblems, loadUserConfig, resolveUserConfigPath } from "../core/orchestrator-config.js";
26
25
  // ---------- input helpers ----------
27
26
  /**
28
27
  * Normalize an optional `phase` argument. A client can send any JSON, and
@@ -50,9 +49,32 @@ async function validateCwd(cwd) {
50
49
  }
51
50
  return cwd;
52
51
  }
52
+ /**
53
+ * The library tools take `cwd` optionally: absent means "no workspace, use
54
+ * the user-global config alone". When it is given it becomes a containment
55
+ * root for spec_path and the source of the workspace config that gates the
56
+ * write, so it is validated exactly as a required cwd is — absolute and
57
+ * existing — before anything reads through it (#241). A relative path would
58
+ * resolve against the MCP server process's working directory, not the
59
+ * caller's, and widen the root to whatever `.codecarto/` sits there.
60
+ */
61
+ async function optionalCwd(cwd) {
62
+ if (cwd === undefined || cwd === null)
63
+ return null;
64
+ if (typeof cwd !== "string") {
65
+ throw new McpError(ErrorCode.InvalidParams, "cwd must be a string when given");
66
+ }
67
+ if (cwd.trim() === "")
68
+ return null;
69
+ return validateCwd(cwd.trim());
70
+ }
53
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).
54
76
  const state = await getWorkspaceState(cwd).catch((error) => {
55
- 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));
56
78
  });
57
79
  if (!state) {
58
80
  throw new McpError(ErrorCode.InvalidRequest, `No CodeCartographer workspace at ${cwd}. Call codecarto_init first.`);
@@ -71,9 +93,9 @@ function textResult(text, structured) {
71
93
  structuredContent: { ...structured, text },
72
94
  };
73
95
  }
74
- async function buildMcpPhasePrompt(state, phase, forced) {
96
+ async function buildMcpPhasePrompt(state, phase, forced, auto = false) {
75
97
  try {
76
- return await buildPhasePrompt(state, phase, forced);
98
+ return await buildPhasePrompt(state, phase, forced, { auto });
77
99
  }
78
100
  catch (error) {
79
101
  if (error instanceof PhasePreflightError) {
@@ -109,12 +131,21 @@ export async function handleInit(args) {
109
131
  const entries = (await readdir(targetWorkspaceDir)).filter((entry) => entry !== BROADSIDE_DIR);
110
132
  broadsideOnly = entries.length === 0 && (await pathExists(join(targetWorkspaceDir, BROADSIDE_DIR)));
111
133
  }
112
- if (targetExists && !sameWorkspace && !broadsideOnly) {
134
+ if (targetExists && !broadsideOnly) {
113
135
  if (!args.force) {
114
- 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.`);
136
+ throw new McpError(ErrorCode.InvalidRequest, sameWorkspace
137
+ ? `The .codecarto/ at ${targetWorkspaceDir} is CodeCartographer's own packaged template (a checkout install), and it holds workspace state. Pass force: true to move that state — status, findings, handoffs, usage data, closeouts, dashboard — to a .codecarto-backup-TIMESTAMP/ directory and reinitialize; the framework files stay in place. Consider codecarto_open to reattach without resetting.`
138
+ : `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.`);
115
139
  }
116
140
  const backupDir = join(cwd, `.codecarto-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`);
117
- await rename(targetWorkspaceDir, backupDir);
141
+ if (sameWorkspace) {
142
+ // The template cannot be renamed away — it is what init copies from —
143
+ // so its session state is moved out file by file instead (#245).
144
+ await backupWorkspaceState(targetWorkspaceDir, backupDir);
145
+ }
146
+ else {
147
+ await rename(targetWorkspaceDir, backupDir);
148
+ }
118
149
  }
119
150
  if (!(await pathExists(targetWorkspaceDir))) {
120
151
  await mkdir(cwd, { recursive: true });
@@ -161,17 +192,21 @@ export async function handleInit(args) {
161
192
  export async function handleStatus(args) {
162
193
  const cwd = await validateCwd(args.cwd);
163
194
  const state = await requireWorkspace(cwd);
164
- const nextPhase = getNextEligiblePhase(state);
165
- 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";
166
200
  const completed = state.pipeline.phase_order.filter((id) => state.status.phases[id]?.status === "complete").length;
167
201
  const totalCarryForward = Object.values(state.status.phases).reduce((sum, phase) => sum + (phase.carry_forward?.length ?? 0), 0);
168
202
  const currentOpenQuestions = currentPhase === "complete" ? 0 : state.status.phases[currentPhase]?.open_questions?.length ?? 0;
169
203
  const terminalOpenQuestions = Object.values(state.status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
170
204
  const postPipelinePending = state.status.post_pipeline.filter((entry) => entry.status !== "resolved").length;
171
205
  const scaffoldNotice = describeScaffoldStaleness(state);
206
+ const missingOutputs = await listMissingCompletedOutputs(state);
172
207
  const summaryLines = [
173
208
  `Phase: ${currentPhase}`,
174
- `Pipeline state: ${currentPhase === "complete" ? "complete" : "in progress"}`,
209
+ `Pipeline state: ${outcome.kind === "eligible" ? "in progress" : outcome.kind}`,
175
210
  `Pipeline: ${getPipelineLabel(state.status.pipeline)} (${state.status.pipeline})`,
176
211
  `Progress: ${completed}/${state.pipeline.phase_order.length} complete`,
177
212
  `Open questions (terminal unresolved): ${terminalOpenQuestions}`,
@@ -184,11 +219,17 @@ export async function handleStatus(args) {
184
219
  ? state.status.next_actions.map((action, index) => `${index === 0 ? "Next: " : " "}${action}`)
185
220
  : [`Next: ${nextPhase ? `Begin ${nextPhase.id}` : "All phases complete."}`]),
186
221
  ];
222
+ if (outcome.kind === "stuck")
223
+ summaryLines.push(describeStuckPipeline(outcome.blocked));
187
224
  if (scaffoldNotice)
188
225
  summaryLines.push(`Scaffold: ${scaffoldNotice}`);
226
+ summaryLines.push(...describeMissingCompletedOutputs(missingOutputs));
189
227
  const summary = summaryLines.join("\n");
190
228
  return textResult(summary, {
191
229
  ...(scaffoldNotice ? { scaffoldNotice } : {}),
230
+ ...(missingOutputs.length > 0 ? { missingOutputs } : {}),
231
+ ...(outcome.kind === "stuck" ? { stuck: outcome.blocked } : {}),
232
+ pipelineState: outcome.kind === "eligible" ? "in progress" : outcome.kind,
192
233
  currentPhase,
193
234
  pipeline: state.status.pipeline,
194
235
  pipelineLabel: getPipelineLabel(state.status.pipeline),
@@ -212,6 +253,11 @@ export async function handleSwitchPipeline(args) {
212
253
  return textResult(`Already on pipeline: ${getPipelineLabel(pipelineChoice)}`, { pipeline: getPipelineLabel(pipelineChoice) });
213
254
  }
214
255
  const result = await switchPipeline(cwd, pipelineChoice);
256
+ // The dashboard is rendered from the pipeline the switch just replaced;
257
+ // Pi re-rendered here and MCP did not, so the file showed the old phase
258
+ // list until the next completion (#254). Same best-effort call as
259
+ // completion and amendment make.
260
+ const dashboardPath = (await writeDashboard(cwd, PACKAGE_VERSION)) ? ".codecarto/dashboard.html" : undefined;
215
261
  const lines = [`Switched pipeline: ${getPipelineLabel(pipelineChoice)}`];
216
262
  if (result.carried.length > 0)
217
263
  lines.push(`Phases preserved (completed): ${result.carried.join(", ")}`);
@@ -219,24 +265,41 @@ export async function handleSwitchPipeline(args) {
219
265
  lines.push(`New phases: ${result.newPhases.join(", ")}`);
220
266
  if (result.dropped.length > 0)
221
267
  lines.push(`Phases not in new pipeline: ${result.dropped.join(", ")} (findings remain on disk)`);
268
+ lines.push(...describeDanglingCarryForward(result.dangling));
269
+ lines.push(`Current phase: ${result.state.status.current_phase}`);
270
+ if (dashboardPath)
271
+ lines.push(`Dashboard refreshed: ${dashboardPath}`);
222
272
  return textResult(lines.join("\n"), {
223
273
  pipeline: getPipelineLabel(pipelineChoice),
274
+ currentPhase: result.state.status.current_phase,
224
275
  carried: result.carried,
225
276
  newPhases: result.newPhases,
226
277
  dropped: result.dropped,
278
+ dangling: result.dangling,
279
+ dashboardPath,
227
280
  });
228
281
  }
229
282
  export async function handleNext(args) {
230
283
  const cwd = await validateCwd(args.cwd);
231
284
  const state = await requireWorkspace(cwd);
232
- const phase = getNextEligiblePhase(state);
233
- 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") {
234
293
  return textResult("All CodeCartographer phases are complete. Run codecarto_skill for post-pipeline work.", {
235
294
  complete: true,
236
295
  });
237
296
  }
238
- const prompt = await buildMcpPhasePrompt(state, phase, false);
239
- 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 });
240
303
  }
241
304
  export async function handlePhase(args) {
242
305
  if (typeof args.phase !== "string" || !args.phase.trim()) {
@@ -248,8 +311,9 @@ export async function handlePhase(args) {
248
311
  if (!phase) {
249
312
  throw new McpError(ErrorCode.InvalidParams, `Unknown phase: ${args.phase}`);
250
313
  }
251
- const prompt = await buildMcpPhasePrompt(state, phase, true);
252
- 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 });
253
317
  }
254
318
  export async function handleValidate(args) {
255
319
  const cwd = await validateCwd(args.cwd);
@@ -349,21 +413,27 @@ export async function handleSkill(args) {
349
413
  return textResult(skill.content, { skill: BROADSIDE_SKILL_NAME, path: skill.path, postPipeline: false });
350
414
  }
351
415
  const state = await requireWorkspace(cwd);
352
- const nextPhase = getNextEligiblePhase(state);
353
- if (nextPhase) {
354
- 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.`);
355
419
  }
356
- const skillFile = join(state.workspaceDir, "skills", args.name, "SKILL.md");
357
- if (!(await pathExists(skillFile))) {
420
+ if (outcome.kind === "stuck") {
421
+ throw new McpError(ErrorCode.InvalidRequest, `Cannot run skill: the pipeline is not complete. ${describeStuckPipeline(outcome.blocked)}`);
422
+ }
423
+ // Resolve against the installed list only: the name is never joined onto a
424
+ // path, so a traversal like `../findings/architecture` cannot splice a
425
+ // phase SKILL.md (or anything else) into the post-pipeline prompt.
426
+ const skillName = await resolveSkillName(state.workspaceDir, args.name);
427
+ if (!skillName) {
358
428
  const available = await listSkillNames(state.workspaceDir);
359
429
  const hint = available.length > 0 ? ` Available: ${available.join(", ")}.` : " No skills installed.";
360
- 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.`);
430
+ throw new McpError(ErrorCode.InvalidParams, `Unknown skill: ${args.name.trim()}.${hint} The Broad-Side reading guide is served as \`${BROADSIDE_SKILL_NAME}\` and is not pipeline-gated.`);
361
431
  }
362
- const prompt = await buildSkillPrompt(state, args.name);
363
- return textResult(prompt, { skill: args.name });
432
+ const prompt = await buildSkillPrompt(state, skillName);
433
+ return textResult(prompt, { skill: skillName });
364
434
  }
365
435
  // ---------- library helpers ----------
366
- async function resolveLibraryPath(args) {
436
+ function resolveLibraryPath(args, config) {
367
437
  const explicit = typeof args.library_path === "string" && args.library_path.trim() !== ""
368
438
  ? args.library_path.trim()
369
439
  : null;
@@ -373,18 +443,8 @@ async function resolveLibraryPath(args) {
373
443
  }
374
444
  return explicit;
375
445
  }
376
- if (typeof args.cwd === "string" && args.cwd.trim() !== "") {
377
- const cwd = args.cwd.trim();
378
- if (!isAbsolute(cwd)) {
379
- throw new McpError(ErrorCode.InvalidParams, `cwd must be absolute, got: ${cwd}`);
380
- }
381
- // loadCodecartoConfig merges user-global under per-workspace and tolerates
382
- // a missing workspace file, so a single call covers both cases.
383
- const workspaceDir = join(cwd, ".codecarto");
384
- const config = await loadCodecartoConfig(workspaceDir);
385
- if (config.library.path)
386
- return config.library.path;
387
- }
446
+ if (config.library.path)
447
+ return config.library.path;
388
448
  throw new McpError(ErrorCode.InvalidParams, "library_path is required (pass it explicitly, or pass cwd and configure library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml).");
389
449
  }
390
450
  /**
@@ -394,17 +454,22 @@ async function resolveLibraryPath(args) {
394
454
  * the publish tool enforces.
395
455
  */
396
456
  async function loadEffectiveConfig(cwd) {
397
- if (typeof cwd !== "string" || cwd.trim() === "")
457
+ // This config decides whether publish_confirm gates the write, so the cwd
458
+ // it is read through is the one optionalCwd validated, never a raw argument.
459
+ if (!cwd)
398
460
  return loadUserConfig();
399
- // A relative path here resolves against the server process's working
400
- // directory, not the caller's, so it would quietly read some other
401
- // workspace's config — and this config decides whether publish_confirm
402
- // gates the write. Refuse rather than answer from the wrong file.
403
- const trimmed = cwd.trim();
404
- if (!isAbsolute(trimmed)) {
405
- throw new McpError(ErrorCode.InvalidParams, `cwd must be an absolute path, got: ${trimmed}`);
406
- }
407
- return loadCodecartoConfig(join(trimmed, ".codecarto"));
461
+ return loadCodecartoConfig(join(cwd, ".codecarto"));
462
+ }
463
+ /**
464
+ * The library tools answer from the config — its path, its namespace, its
465
+ * confirm gate — so a config file that could not be used in full is a
466
+ * refusal, not a silent fallback to whatever the other layer says (#242).
467
+ * The message names each file and key; codecarto_config shows the same list.
468
+ */
469
+ function refuseOnConfigProblems(config, tool) {
470
+ if (config.problems.length === 0)
471
+ return;
472
+ throw new McpError(ErrorCode.InvalidRequest, [`${tool} refused: the configuration has problems. Fix or remove the offending file, then retry.`, ...describeConfigProblems(config)].join("\n"));
408
473
  }
409
474
  function asStringArray(value, fieldName) {
410
475
  if (!Array.isArray(value)) {
@@ -491,12 +556,12 @@ async function resolveDefaultsFromWorkspace(cwd, overrides) {
491
556
  let namespace = typeof overrides.namespace === "string" && overrides.namespace.trim() !== ""
492
557
  ? overrides.namespace.trim()
493
558
  : null;
494
- if (typeof cwd === "string" && cwd.trim() !== "" && isAbsolute(cwd)) {
495
- const workspaceDir = join(cwd.trim(), ".codecarto");
559
+ if (cwd) {
560
+ const workspaceDir = join(cwd, ".codecarto");
496
561
  if (await pathExists(workspaceDir)) {
497
562
  if (pipeline === "unknown") {
498
563
  try {
499
- const state = await getWorkspaceState(cwd.trim());
564
+ const state = await getWorkspaceState(cwd);
500
565
  if (state?.status.pipeline)
501
566
  pipeline = state.status.pipeline;
502
567
  }
@@ -542,16 +607,20 @@ function provenanceConflictLines(conflicts) {
542
607
  }
543
608
  // ---------- library handlers ----------
544
609
  export async function handlePublish(args) {
545
- const libraryPath = await resolveLibraryPath(args);
610
+ // First, before anything is read through it: cwd is a containment root
611
+ // for spec_path below and the source of the config that gates the write.
612
+ const cwd = await optionalCwd(args.cwd);
613
+ const config = await loadEffectiveConfig(cwd);
614
+ refuseOnConfigProblems(config, "codecarto_publish");
615
+ const libraryPath = resolveLibraryPath(args, config);
546
616
  const marker = await discoverLibrary(libraryPath);
547
617
  if (!marker) {
548
618
  throw new McpError(ErrorCode.InvalidParams, `No CodeCartographer library at ${libraryPath} (missing .codecarto-library marker). Create one before publishing.`);
549
619
  }
550
620
  // Build allowed roots for spec_path containment: workspace .codecarto/ and library path
551
621
  const allowedRoots = [libraryPath];
552
- if (typeof args.cwd === "string" && args.cwd.trim() !== "") {
553
- allowedRoots.push(join(args.cwd.trim(), ".codecarto"));
554
- }
622
+ if (cwd)
623
+ allowedRoots.push(join(cwd, ".codecarto"));
555
624
  const spec = await readSpecArg(args, allowedRoots);
556
625
  if (typeof args.source_repo !== "string" || args.source_repo.trim() === "") {
557
626
  throw new McpError(ErrorCode.InvalidParams, "source_repo is required");
@@ -567,7 +636,7 @@ export async function handlePublish(args) {
567
636
  if (!isValidSlug(slug)) {
568
637
  throw new McpError(ErrorCode.InvalidParams, `Resolved slug "${slug}" is invalid. Provide an explicit slug (lowercase ASCII, starts with a letter, max 64 chars).`);
569
638
  }
570
- const defaults = await resolveDefaultsFromWorkspace(args.cwd, {
639
+ const defaults = await resolveDefaultsFromWorkspace(cwd, {
571
640
  pipeline: args.pipeline,
572
641
  namespace: args.namespace,
573
642
  });
@@ -596,7 +665,6 @@ export async function handlePublish(args) {
596
665
  // opt-out, and a host that never configured the key keeps the behavior it
597
666
  // had. Runs after every argument check so the preview names the resolved
598
667
  // slug and namespace, and before publishEntry so nothing is written.
599
- const config = await loadEffectiveConfig(args.cwd);
600
668
  if (config.library.publish_confirm && config.library.publish_confirm_configured && args.confirm !== true) {
601
669
  const preview = await previewPublishVersion(libraryPath, spec, { slug, namespace }, { forceNewVersion });
602
670
  const label = `${namespace ? `${namespace}/` : ""}${slug}`;
@@ -665,7 +733,9 @@ export async function handlePublish(args) {
665
733
  });
666
734
  }
667
735
  export async function handleLibraryList(args) {
668
- const libraryPath = await resolveLibraryPath(args);
736
+ const config = await loadEffectiveConfig(await optionalCwd(args.cwd));
737
+ refuseOnConfigProblems(config, "codecarto_library_list");
738
+ const libraryPath = resolveLibraryPath(args, config);
669
739
  const marker = await discoverLibrary(libraryPath);
670
740
  if (!marker) {
671
741
  throw new McpError(ErrorCode.InvalidParams, `No CodeCartographer library at ${libraryPath} (missing .codecarto-library marker).`);
@@ -706,7 +776,9 @@ export async function handleLibraryList(args) {
706
776
  });
707
777
  }
708
778
  export async function handleLibraryReindex(args) {
709
- const libraryPath = await resolveLibraryPath(args);
779
+ const config = await loadEffectiveConfig(await optionalCwd(args.cwd));
780
+ refuseOnConfigProblems(config, "codecarto_library_reindex");
781
+ const libraryPath = resolveLibraryPath(args, config);
710
782
  const marker = await discoverLibrary(libraryPath);
711
783
  if (!marker) {
712
784
  throw new McpError(ErrorCode.InvalidParams, `No CodeCartographer library at ${libraryPath} (missing .codecarto-library marker).`);
@@ -742,10 +814,17 @@ export async function handleLibraryInit(args) {
742
814
  });
743
815
  // Write config to user-global location
744
816
  const configPath = resolveUserConfigPath();
745
- await writeLibraryConfig(configPath, libraryPath, args.namespace ?? null);
817
+ // Writes library.path (and library.namespace when given) and nothing else:
818
+ // in particular it does not set publish_confirm, so initializing a library
819
+ // does not switch the codecarto_publish confirm gate on (#244). A config
820
+ // file that cannot be parsed is left alone and reported.
821
+ await writeLibraryConfig(configPath, libraryPath, args.namespace ?? null).catch((error) => {
822
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
823
+ });
824
+ const written = args.namespace ? "library.path and library.namespace" : "library.path";
746
825
  const msg = result.alreadyExisted
747
- ? `Library already exists at ${libraryPath} (marker preserved). Config written to ${configPath}.`
748
- : `Created library at ${libraryPath} with marker "${result.marker.name}". Config written to ${configPath}.`;
826
+ ? `Library already exists at ${libraryPath} (marker preserved). Wrote ${written} to ${configPath}; other keys untouched.`
827
+ : `Created library at ${libraryPath} with marker "${result.marker.name}". Wrote ${written} to ${configPath}; other keys untouched.`;
749
828
  return textResult(msg, {
750
829
  libraryPath,
751
830
  markerName: result.marker.name,
@@ -808,6 +887,9 @@ export async function handleConfig(args) {
808
887
  ` Library marker: ${markerStatus}`,
809
888
  ` User-global config: ${userConfigPath}`,
810
889
  ` Workspace config: ${workspaceConfigPath ?? "(no cwd provided)"}`,
890
+ // A file that could not be used is the one thing this tool exists
891
+ // to surface; the library tools refuse while any is listed (#242).
892
+ ...describeConfigProblems(config),
811
893
  ].join("\n"), {
812
894
  libraryPath: config.library.path,
813
895
  libraryNamespace: config.library.namespace,
@@ -815,6 +897,7 @@ export async function handleConfig(args) {
815
897
  llmSteerNextPhase: config.orchestrator.llm_steer_next_phase,
816
898
  userConfigPath,
817
899
  workspaceConfigPath,
900
+ problems: config.problems,
818
901
  });
819
902
  }
820
903
  // ---------- MCP parity handlers: open, usage, dashboard, list_skills ----------
@@ -825,8 +908,16 @@ export async function handleOpen(args) {
825
908
  throw new McpError(ErrorCode.InvalidRequest, "No existing CodeCartographer workspace found. Run codecarto_init first.");
826
909
  }
827
910
  const state = await requireWorkspace(cwd);
828
- const nextPhase = getNextEligiblePhase(state)?.id ?? "complete";
829
- 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
+ });
830
921
  }
831
922
  export async function handleUsage(args) {
832
923
  const cwd = await validateCwd(args.cwd);
@@ -949,18 +1040,40 @@ export async function handleBroadside(args) {
949
1040
  if (!["submit", "collect", "status", "models"].includes(action)) {
950
1041
  throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${action}. Valid actions: submit, collect, status, models.`);
951
1042
  }
952
- 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
+ }
953
1059
  if (action === "status") {
954
- const { state } = await runBroadsideStatus(cwd);
955
- 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 } : {}) });
956
1067
  }
957
1068
  const apiKey = resolveBroadsideApiKey(args.api_key, config);
958
1069
  // Every run knob resolves the same way: explicit parameter, else the repo's
959
1070
  // config.yaml default, else the shipped default baked into loadBroadsideConfig.
960
- 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
961
1074
  ? args.wait_seconds
962
1075
  : config.waitSeconds;
963
- const waitMs = waitSeconds > 0 ? waitSeconds * 1000 : undefined;
1076
+ const waitMs = waitSeconds * 1000;
964
1077
  const includeSynthesis = args.include_synthesis ?? config.includeSynthesis;
965
1078
  const includeTriage = args.include_triage ?? config.includeTriage;
966
1079
  const retryTruncated = args.retry_truncated ?? config.retryTruncated;
@@ -989,7 +1102,9 @@ export async function handleBroadside(args) {
989
1102
  else {
990
1103
  lenses = config.defaultLenses;
991
1104
  }
992
- const maxCost = typeof args.max_cost === "number" && args.max_cost > 0 ? args.max_cost : config.maxCost;
1105
+ // An explicit 0 is "no limit" (#231); absent falls back to config.yaml,
1106
+ // whose own default is BROADSIDE_DEFAULT_MAX_COST.
1107
+ const maxCost = typeof args.max_cost === "number" && args.max_cost >= 0 ? args.max_cost : config.maxCost;
993
1108
  const result = await runBroadsideSubmit(cwd, apiKey, {
994
1109
  lenses,
995
1110
  model: config.model,
@@ -1026,11 +1141,13 @@ export async function handleBroadside(args) {
1026
1141
  });
1027
1142
  }
1028
1143
  // action === "collect"
1144
+ const runId = typeof args.run_id === "string" && args.run_id.trim() ? args.run_id.trim() : undefined;
1029
1145
  const collect = await runBroadsideCollect(cwd, apiKey, {
1030
1146
  waitMs,
1031
1147
  includeSynthesis,
1032
1148
  includeTriage,
1033
1149
  retryTruncated,
1150
+ ...(runId && { runId }),
1034
1151
  }).catch((error) => {
1035
1152
  throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
1036
1153
  });
@@ -1098,7 +1215,13 @@ const TOOLS = [
1098
1215
  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.",
1099
1216
  inputSchema: {
1100
1217
  type: "object",
1101
- properties: { cwd: { type: "string", description: "Absolute path to the target repository." } },
1218
+ properties: {
1219
+ cwd: { type: "string", description: "Absolute path to the target repository." },
1220
+ unattended: {
1221
+ type: "boolean",
1222
+ 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.",
1223
+ },
1224
+ },
1102
1225
  required: ["cwd"],
1103
1226
  },
1104
1227
  },
@@ -1110,13 +1233,14 @@ const TOOLS = [
1110
1233
  properties: {
1111
1234
  cwd: { type: "string", description: "Absolute path to the target repository." },
1112
1235
  phase: { type: "string", description: "Phase id from the active pipeline." },
1236
+ 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." },
1113
1237
  },
1114
1238
  required: ["cwd", "phase"],
1115
1239
  },
1116
1240
  },
1117
1241
  {
1118
1242
  name: "codecarto_validate",
1119
- description: "Validate a phase's primary output against the validation block in the produced markdown. Returns overall PASS/PASS WITH GAPS/FAIL/MISSING plus the parsed criteria rows. If phase is omitted, validates the next eligible phase.",
1243
+ description: "Read the ## Validation table a phase wrote at the end of its primary output — the phase's own PASS/PARTIAL/FAIL per completion criterion — apply two cross-checks (findings evidence/action pairing; declared secondary outputs present), and return PASS/PASS WITH GAPS/FAIL/MISSING plus the parsed rows. It does not judge the criteria itself. If phase is omitted, validates the next eligible phase.",
1120
1244
  inputSchema: {
1121
1245
  type: "object",
1122
1246
  properties: {
@@ -1128,7 +1252,7 @@ const TOOLS = [
1128
1252
  },
1129
1253
  {
1130
1254
  name: "codecarto_complete",
1131
- 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.",
1255
+ 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.",
1132
1256
  inputSchema: {
1133
1257
  type: "object",
1134
1258
  properties: {
@@ -1354,11 +1478,15 @@ const TOOLS = [
1354
1478
  },
1355
1479
  api_key: {
1356
1480
  type: "string",
1357
- description: "OpenRouter API key. Prefer the OPENROUTER_API_KEY environment variable or .codecarto/broadside/config.yaml.",
1481
+ 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.)",
1482
+ },
1483
+ run_id: {
1484
+ type: "string",
1485
+ 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.",
1358
1486
  },
1359
1487
  wait_seconds: {
1360
1488
  type: "number",
1361
- 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.",
1489
+ 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).",
1362
1490
  },
1363
1491
  include_synthesis: {
1364
1492
  type: "boolean",
@@ -1374,7 +1502,7 @@ const TOOLS = [
1374
1502
  },
1375
1503
  max_cost: {
1376
1504
  type: "number",
1377
- 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.",
1505
+ 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.",
1378
1506
  },
1379
1507
  force: {
1380
1508
  type: "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.19.6",
3
+ "version": "0.21.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",
@@ -76,6 +76,7 @@
76
76
  "typescript": "^5.9.3"
77
77
  },
78
78
  "overrides": {
79
- "undici": "^8.10.0"
79
+ "undici": "^8.10.0",
80
+ "hono": "^4.13.5"
80
81
  }
81
82
  }