codecartographer-pi 0.20.0 → 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 (39) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +7 -3
  3. package/.codecarto/broadside/config.yaml +17 -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 +1 -1
  15. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  16. package/README.md +3 -3
  17. package/dist/core/amendment.js +9 -4
  18. package/dist/core/broadside.d.ts +49 -0
  19. package/dist/core/broadside.js +131 -30
  20. package/dist/core/completion.js +7 -1
  21. package/dist/core/library.js +5 -3
  22. package/dist/core/pipeline.d.ts +39 -3
  23. package/dist/core/pipeline.js +61 -7
  24. package/dist/core/prompts.js +10 -3
  25. package/dist/core/status.d.ts +8 -0
  26. package/dist/core/status.js +39 -17
  27. package/dist/core/utils.d.ts +7 -0
  28. package/dist/core/utils.js +7 -0
  29. package/dist/core/workspace.js +2 -2
  30. package/dist/core/yaml.js +8 -1
  31. package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
  32. package/dist/extensions/codecarto/auto-runner.js +18 -3
  33. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  34. package/dist/extensions/codecarto/broadside-flags.js +12 -0
  35. package/dist/extensions/codecarto/index.js +95 -31
  36. package/dist/extensions/codecarto/phase-compaction.js +5 -1
  37. package/dist/mcp-server/server.d.ts +3 -1
  38. package/dist/mcp-server/server.js +99 -29
  39. package/package.json +1 -1
@@ -77,3 +77,10 @@ export declare function formatMillis(ms: number): string;
77
77
  * hand-edited markers) so callers can fall back to string equality.
78
78
  */
79
79
  export declare function compareDottedVersions(a: string, b: string): number | null;
80
+ /**
81
+ * Every git subprocess gets this. A hung git — a credential helper waiting
82
+ * on a prompt, a slow filesystem — used to hang a publish or a Broad-Side
83
+ * submit indefinitely while every fetch carried a 30 s timeout (self-audit
84
+ * sem 3.12).
85
+ */
86
+ export declare const GIT_TIMEOUT_MS = 30000;
@@ -206,3 +206,10 @@ export function compareDottedVersions(a, b) {
206
206
  }
207
207
  return 0;
208
208
  }
209
+ /**
210
+ * Every git subprocess gets this. A hung git — a credential helper waiting
211
+ * on a prompt, a slow filesystem — used to hang a publish or a Broad-Side
212
+ * submit indefinitely while every fetch carried a 30 s timeout (self-audit
213
+ * sem 3.12).
214
+ */
215
+ export const GIT_TIMEOUT_MS = 30_000;
@@ -7,7 +7,7 @@ import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename } from "node
7
7
  import { basename, dirname, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { getPipelineLabel, recomputeCursor } from "./pipeline.js";
10
- import { acquireLock, applyHandoff, autoAssignIds, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
10
+ import { acquireLock, applyHandoff, autoAssignIds, createEmptyStatus, normalizeStatus, parseHandoff, textOf } from "./status.js";
11
11
  import { atomicWriteFile, compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
12
12
  import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
13
13
  // Walk up from the current file to find the package root. Needed because the
@@ -68,7 +68,7 @@ export async function getWorkspaceState(cwd) {
68
68
  if (!(await pathExists(statusPath)))
69
69
  return null;
70
70
  const rawStatus = await loadYamlFile(statusPath);
71
- const pipelineRelativePath = rawStatus.pipeline?.trim();
71
+ const pipelineRelativePath = (textOf(rawStatus.pipeline) ?? "").trim();
72
72
  if (!pipelineRelativePath) {
73
73
  throw new Error(`Missing pipeline in ${relative(cwd, statusPath) || statusPath}`);
74
74
  }
package/dist/core/yaml.js CHANGED
@@ -497,8 +497,15 @@ export function formatYamlScalar(value) {
497
497
  const stringValue = String(value);
498
498
  if (stringValue === "")
499
499
  return '""';
500
- if (/^[A-Za-z0-9_./-]+$/.test(stringValue))
500
+ // Bare only when the reader would hand the same string back. The charset
501
+ // keeps out spaces, quotes, `#` and `:`; the round-trip check keeps out the
502
+ // strings the reader coerces — "2048", "true", "null", "1.5" — which used
503
+ // to go out bare and come back as a number, a boolean, or nothing, until a
504
+ // repository named `2048` bricked its workspace on `project_name.trim`
505
+ // (#225). A lone `-` is a sequence marker inside a list, so it is quoted too.
506
+ if (/^[A-Za-z0-9_./-]+$/.test(stringValue) && stringValue !== "-" && parseYamlScalar(stringValue) === stringValue) {
501
507
  return stringValue;
508
+ }
502
509
  return JSON.stringify(stringValue);
503
510
  }
504
511
  export function stringifySimpleYaml(value, indent = 0) {
@@ -45,7 +45,7 @@ export interface AutoCompleteResult {
45
45
  warnings: string[];
46
46
  }
47
47
  export declare function autoCompletePhase(cwd: string, validation: ValidationResult): Promise<AutoCompleteResult>;
48
- export type AutoOutcome = "complete" | "stopped" | "aborted";
48
+ export type AutoOutcome = "complete" | "stopped" | "aborted" | "stuck";
49
49
  export interface AutoRunOptions {
50
50
  strict: boolean;
51
51
  llmSteerOverride?: boolean;
@@ -17,7 +17,7 @@ import { clearPhase, finishPhase, getPhaseActivity, startPhase } from "./agent-s
17
17
  import { buildSteeringMessage, rewritePhasePrompt } from "./agent-rewriter.js";
18
18
  import { buildPhaseSummary } from "./agent-summary.js";
19
19
  import { getAgentsWidget } from "./agent-widget.js";
20
- import { appendUsageRun, buildPhasePrompt, buildValidationSummary, completeValidatedPhase, formatMillis, formatTokenCount, getNextEligiblePhase, getWorkspaceState, describeConfigProblems, loadCodecartoConfig, PACKAGE_VERSION, PhasePreflightError, runPhasePreflight, validatePhaseOutput, writeDashboard, } from "../../core/index.js";
20
+ import { appendUsageRun, buildPhasePrompt, buildValidationSummary, completeValidatedPhase, formatMillis, formatTokenCount, describeStuckPipeline, resolvePipelineOutcome, getWorkspaceState, describeConfigProblems, loadCodecartoConfig, PACKAGE_VERSION, PhasePreflightError, runPhasePreflight, validatePhaseOutput, writeDashboard, } from "../../core/index.js";
21
21
  /**
22
22
  * Run one phase end to end: optional LLM-steered rewrite, spawn the sub-agent,
23
23
  * wait for it, then emit the side effects the historical /codecarto-next chain
@@ -203,13 +203,22 @@ export async function runAuto(ctx, pi, initialState, options) {
203
203
  reason: "User aborted the auto run.",
204
204
  });
205
205
  }
206
- const phase = getNextEligiblePhase(state);
207
- if (!phase) {
206
+ const outcome = resolvePipelineOutcome(state);
207
+ if (outcome.kind === "stuck") {
208
+ // Not "complete": the loop ending because nothing can run is the
209
+ // case that used to read as success (#228).
210
+ return finish({
211
+ outcome: "stuck",
212
+ reason: describeStuckPipeline(outcome.blocked),
213
+ });
214
+ }
215
+ if (outcome.kind === "complete") {
208
216
  return finish({
209
217
  outcome: "complete",
210
218
  reason: "Pipeline complete.",
211
219
  });
212
220
  }
221
+ const phase = outcome.phase;
213
222
  let preflight;
214
223
  try {
215
224
  preflight = await runPhasePreflight(state, phase);
@@ -308,6 +317,8 @@ export function buildAutoSummary(result, availableSkills = []) {
308
317
  return `**Auto pipeline stopped at \`${result.stoppedAt?.phaseId ?? "?"}\`.**`;
309
318
  case "aborted":
310
319
  return `**Auto pipeline aborted${result.stoppedAt?.phaseId ? ` during \`${result.stoppedAt.phaseId}\`` : ""}.**`;
320
+ case "stuck":
321
+ return `**Auto pipeline stuck: no phase can run.**`;
311
322
  }
312
323
  })();
313
324
  const statsLine = `_⟳ ${ranOf} · ${tokensStr} tokens · ${wallTime}_`;
@@ -321,6 +332,10 @@ export function buildAutoSummary(result, availableSkills = []) {
321
332
  lines.push("", result.reason);
322
333
  lines.push("", recoveryHint(result));
323
334
  }
335
+ if (result.outcome === "stuck") {
336
+ // The reason is the stuck sentence, which already says what to fix.
337
+ lines.push("", result.reason);
338
+ }
324
339
  if (result.outcome === "complete") {
325
340
  lines.push("", "Dashboard: `.codecarto/dashboard.html`");
326
341
  if (availableSkills.length > 0) {
@@ -16,11 +16,13 @@ export interface BroadsideFlags {
16
16
  /** Undefined means "use the repository's config default". */
17
17
  maxCost?: number;
18
18
  waitSeconds?: number;
19
+ /** For collect: the run to collect instead of the most recent (#268). */
20
+ runId?: string;
19
21
  benchmarks: boolean;
20
22
  unknown: string[];
21
23
  /** Set on an invalid combination. The caller surfaces it as an error. */
22
24
  error?: string;
23
25
  }
24
26
  /** Every token the completer offers, in the order it offers them. */
25
- export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
27
+ export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--run=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
26
28
  export declare function parseBroadsideFlags(args: string): BroadsideFlags;
@@ -13,6 +13,7 @@
13
13
  // --no-incremental --no-triage
14
14
  // --max-cost=N --no-retry-truncated
15
15
  // --wait=SECONDS --benchmarks (models only)
16
+ // --run=ID (collect only: an older run, as listed by status)
16
17
  //
17
18
  // --incremental has a spelled-out negative because the value is tri-state:
18
19
  // absent defers to config.yaml, so a repository that set `incremental: true`
@@ -34,6 +35,7 @@ export const KNOWN_BROADSIDE_TOKENS = [
34
35
  "--no-incremental",
35
36
  "--max-cost=",
36
37
  "--wait=",
38
+ "--run=",
37
39
  "--no-synthesis",
38
40
  "--no-triage",
39
41
  "--no-retry-truncated",
@@ -106,6 +108,13 @@ export function parseBroadsideFlags(args) {
106
108
  result.waitSeconds = parseNumeric(token, "--wait", result);
107
109
  continue;
108
110
  }
111
+ if (token.startsWith("--run=")) {
112
+ const value = token.slice("--run=".length).trim();
113
+ if (!value)
114
+ result.error ??= "--run= needs a run id (see /codecarto-broadside status).";
115
+ result.runId = value || undefined;
116
+ continue;
117
+ }
109
118
  result.unknown.push(token);
110
119
  }
111
120
  // Flags that only mean something for one action are refused rather than
@@ -125,5 +134,8 @@ export function parseBroadsideFlags(args) {
125
134
  if (result.action === "status" && result.waitSeconds !== undefined) {
126
135
  result.error ??= "--wait is only meaningful for submit and collect; status reads recorded state.";
127
136
  }
137
+ if (result.runId !== undefined && result.action !== "collect") {
138
+ result.error ??= `--run is only meaningful for collect (got action "${result.action}").`;
139
+ }
128
140
  return result;
129
141
  }
@@ -1,5 +1,4 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
2
  import { basename, dirname, join, resolve } from "node:path";
4
3
  import { autoCompletePhase, buildAutoSummary, isPhaseRunning, runAuto, runSinglePhase } from "./auto-runner.js";
5
4
  import { disposeAgentsWidget } from "./agent-widget.js";
@@ -10,7 +9,7 @@ import { parseNextFlags } from "./next-flags.js";
10
9
  import { buildPiGuideMessage } from "./guide-framing.js";
11
10
  import { isCtxLive, notifyCtx } from "./notify.js";
12
11
  import { phaseCompactionExtension } from "./phase-compaction.js";
13
- import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, describeConfigProblems, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
12
+ import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, describeConfigProblems, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
14
13
  import { initLibrary } from "../../core/library.js";
15
14
  import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
16
15
  const STATUS_WIDGET_ID = "codecarto-widget";
@@ -56,9 +55,23 @@ function formatUsageDuration(ms) {
56
55
  const seconds = Math.floor((ms % 60_000) / 1000);
57
56
  return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
58
57
  }
58
+ /**
59
+ * The phase the status line and session name show: the eligible phase, the
60
+ * first blocked phase when the pipeline is stuck, or "complete" — never
61
+ * "complete" for a pipeline that cannot finish (#228).
62
+ */
63
+ function currentPhaseLabel(state) {
64
+ const outcome = resolvePipelineOutcome(state);
65
+ if (outcome.kind === "eligible")
66
+ return outcome.phase.id;
67
+ if (outcome.kind === "stuck")
68
+ return outcome.blocked[0]?.phaseId ?? "stuck";
69
+ return "complete";
70
+ }
59
71
  function buildStatusLines(state, extraLines = []) {
60
- const nextPhase = getNextEligiblePhase(state);
61
- const currentPhase = nextPhase?.id ?? state.status.current_phase ?? "complete";
72
+ const outcome = resolvePipelineOutcome(state);
73
+ const nextPhase = outcome.kind === "eligible" ? outcome.phase : null;
74
+ const currentPhase = currentPhaseLabel(state);
62
75
  const pipelineLabel = getPipelineLabel(state.status.pipeline);
63
76
  const completedCount = state.pipeline.phase_order.filter((phaseId) => state.status.phases[phaseId]?.status === "complete").length;
64
77
  const terminalOpenQuestions = Object.values(state.status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
@@ -68,7 +81,7 @@ function buildStatusLines(state, extraLines = []) {
68
81
  const lines = [
69
82
  "CodeCartographer",
70
83
  `Phase: ${currentPhase}`,
71
- `Pipeline state: ${currentPhase === "complete" ? "complete" : "in progress"}`,
84
+ `Pipeline state: ${outcome.kind === "eligible" ? "in progress" : outcome.kind}`,
72
85
  `Pipeline: ${pipelineLabel}`,
73
86
  `Progress: ${completedCount}/${state.pipeline.phase_order.length} complete`,
74
87
  `Open questions (terminal unresolved): ${terminalOpenQuestions}`,
@@ -76,6 +89,8 @@ function buildStatusLines(state, extraLines = []) {
76
89
  `Post-pipeline work: ${postPipelinePending} pending`,
77
90
  `Next: ${nextAction}`,
78
91
  ];
92
+ if (outcome.kind === "stuck")
93
+ lines.push(describeStuckPipeline(outcome.blocked));
79
94
  const scaffoldNotice = describeScaffoldStaleness(state);
80
95
  if (scaffoldNotice)
81
96
  lines.push(`Scaffold: ${scaffoldNotice}`);
@@ -93,7 +108,7 @@ function setUiState(ctx, state, extraLines = []) {
93
108
  return;
94
109
  }
95
110
  const theme = ctx.ui.theme;
96
- const currentPhase = getNextEligiblePhase(state)?.id ?? state.status.current_phase ?? "complete";
111
+ const currentPhase = currentPhaseLabel(state);
97
112
  ctx.ui.setStatus(STATUS_LINE_ID, `${theme.fg("accent", "CC")} ${theme.fg("dim", currentPhase)}`);
98
113
  ctx.ui.setWidget(STATUS_WIDGET_ID, buildStatusLines(state, extraLines));
99
114
  }
@@ -291,7 +306,7 @@ export default function codeCartographerExtension(pi) {
291
306
  const state = await readWorkspaceState(ctx, false);
292
307
  setUiState(ctx, state, extraLines ?? lastFeedbackLines);
293
308
  if (state) {
294
- const phaseId = getNextEligiblePhase(state)?.id ?? state.status.current_phase;
309
+ const phaseId = currentPhaseLabel(state);
295
310
  if (phaseId)
296
311
  pi.setSessionName(`CodeCartographer: ${phaseId}`);
297
312
  }
@@ -498,13 +513,17 @@ export default function codeCartographerExtension(pi) {
498
513
  const state = await ensureWorkspaceState(ctx);
499
514
  if (!state)
500
515
  return;
501
- const nextPhase = getNextEligiblePhase(state)?.id ?? "complete";
516
+ const outcome = resolvePipelineOutcome(state);
517
+ const nextPhase = currentPhaseLabel(state);
502
518
  // Same check codecarto_status makes: a phase complete in status.yaml
503
519
  // whose report is not on disk (#259).
504
520
  const missingOutputs = describeMissingCompletedOutputs(await listMissingCompletedOutputs(state));
505
521
  lastFeedbackLines = [`Current phase: ${nextPhase}`, `Pipeline: ${getPipelineLabel(state.status.pipeline)}`, ...missingOutputs];
506
522
  setUiState(ctx, state, lastFeedbackLines);
507
- if (missingOutputs.length > 0) {
523
+ if (outcome.kind === "stuck") {
524
+ notifyCtx(ctx, `CodeCartographer phase: ${nextPhase}. ${describeStuckPipeline(outcome.blocked)}`, "error");
525
+ }
526
+ else if (missingOutputs.length > 0) {
508
527
  notifyCtx(ctx, `CodeCartographer phase: ${nextPhase}. ${missingOutputs[0]}`, "warning");
509
528
  }
510
529
  else {
@@ -608,7 +627,7 @@ export default function codeCartographerExtension(pi) {
608
627
  // the readout tracks progress live instead of staying frozen
609
628
  // at the initial phase until the whole auto run finishes.
610
629
  setUiState(ctx, advancedState, [`Auto pipeline${flags.strict ? " (strict)" : ""} running…`]);
611
- const phaseId = getNextEligiblePhase(advancedState)?.id ?? advancedState.status.current_phase;
630
+ const phaseId = currentPhaseLabel(advancedState);
612
631
  if (phaseId)
613
632
  pi.setSessionName(`CodeCartographer: ${phaseId}`);
614
633
  },
@@ -624,13 +643,21 @@ export default function codeCartographerExtension(pi) {
624
643
  notifyCtx(ctx, `Auto pipeline ${result.outcome}: ${result.phasesRun.length}/${result.totalPhases} phases.`, result.outcome === "complete" ? "info" : "warning");
625
644
  return;
626
645
  }
627
- const phase = getNextEligiblePhase(state);
628
- if (!phase) {
646
+ const outcome = resolvePipelineOutcome(state);
647
+ if (outcome.kind === "stuck") {
648
+ const message = describeStuckPipeline(outcome.blocked);
649
+ lastFeedbackLines = [message];
650
+ setUiState(ctx, state, lastFeedbackLines);
651
+ notifyCtx(ctx, message, "error");
652
+ return;
653
+ }
654
+ if (outcome.kind === "complete") {
629
655
  lastFeedbackLines = ["All phases complete."];
630
656
  setUiState(ctx, state, lastFeedbackLines);
631
657
  notifyCtx(ctx, "All CodeCartographer phases are complete.", "info");
632
658
  return;
633
659
  }
660
+ const phase = outcome.phase;
634
661
  let preflight;
635
662
  try {
636
663
  preflight = await runPhasePreflight(state, phase);
@@ -868,9 +895,13 @@ export default function codeCartographerExtension(pi) {
868
895
  const state = await ensureWorkspaceState(ctx);
869
896
  if (!state)
870
897
  return;
871
- const nextPhase = getNextEligiblePhase(state);
872
- if (nextPhase) {
873
- notifyCtx(ctx, `Cannot run skill: pipeline is not complete (next phase: ${nextPhase.id}). Finish the pipeline before running post-pipeline skills.`, "error");
898
+ const outcome = resolvePipelineOutcome(state);
899
+ if (outcome.kind === "eligible") {
900
+ notifyCtx(ctx, `Cannot run skill: pipeline is not complete (next phase: ${outcome.phase.id}). Finish the pipeline before running post-pipeline skills.`, "error");
901
+ return;
902
+ }
903
+ if (outcome.kind === "stuck") {
904
+ notifyCtx(ctx, `Cannot run skill: the pipeline is not complete. ${describeStuckPipeline(outcome.blocked)}`, "error");
874
905
  return;
875
906
  }
876
907
  // Resolve against the installed list only: the name is never joined
@@ -908,11 +939,13 @@ export default function codeCartographerExtension(pi) {
908
939
  const lines = skills.length > 0
909
940
  ? [`Available skills (${skills.length}):`, ...skills.map((name) => ` - ${name}`)]
910
941
  : ["No skills installed."];
911
- const nextPhase = getNextEligiblePhase(state);
942
+ const outcome = resolvePipelineOutcome(state);
912
943
  if (skills.length > 0) {
913
- lines.push(nextPhase
914
- ? `Post-pipeline skills unlock when the pipeline completes (next phase: ${nextPhase.id}).`
915
- : "Run one with /codecarto-skill <name>.");
944
+ lines.push(outcome.kind === "eligible"
945
+ ? `Post-pipeline skills unlock when the pipeline completes (next phase: ${outcome.phase.id}).`
946
+ : outcome.kind === "stuck"
947
+ ? `Post-pipeline skills unlock when the pipeline completes, and it cannot: ${describeStuckPipeline(outcome.blocked)}`
948
+ : "Run one with /codecarto-skill <name>.");
916
949
  }
917
950
  // Broad-Side is listed apart from the post-pipeline set because it
918
951
  // answers to /codecarto-skill without the completion gate.
@@ -988,7 +1021,23 @@ export default function codeCartographerExtension(pi) {
988
1021
  // Broad-Side runs on any git repository, with or without a workspace —
989
1022
  // so this command never goes through ensureWorkspaceState.
990
1023
  const broadsideDir = broadsideDirFor(ctx.cwd);
991
- const config = await loadBroadsideConfig(broadsideDir);
1024
+ // A config.yaml that exists but cannot be read refuses every action
1025
+ // that would act on it (#232); status only reads recorded runs, so it
1026
+ // answers and says the file is unreadable.
1027
+ let config;
1028
+ let configWarning = null;
1029
+ try {
1030
+ config = await loadBroadsideConfig(broadsideDir);
1031
+ }
1032
+ catch (error) {
1033
+ const message = error instanceof Error ? error.message : String(error);
1034
+ if (!(error instanceof BroadsideConfigError) || flags.action !== "status") {
1035
+ notifyCtx(ctx, `Broad-Side ${flags.action} refused: ${message}`, "error");
1036
+ return;
1037
+ }
1038
+ config = defaultBroadsideConfig();
1039
+ configWarning = message;
1040
+ }
992
1041
  const finish = (lines, notice, level = "info") => {
993
1042
  lastFeedbackLines = lines;
994
1043
  if (codecartoModeActive) {
@@ -1005,9 +1054,18 @@ export default function codeCartographerExtension(pi) {
1005
1054
  notifyCtx(ctx, notice, level);
1006
1055
  };
1007
1056
  if (flags.action === "status") {
1008
- const { state } = await runBroadsideStatus(ctx.cwd);
1057
+ let state;
1058
+ try {
1059
+ ({ state } = await runBroadsideStatus(ctx.cwd));
1060
+ }
1061
+ catch (error) {
1062
+ // A corrupt state.json: nothing trustworthy to report, and the
1063
+ // error names the preserved copy (#233).
1064
+ notifyCtx(ctx, `Broad-Side status failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1065
+ return;
1066
+ }
1009
1067
  const runs = state.runs.length;
1010
- finish(statusText(state).split("\n"), runs > 0 ? `Broad-Side: ${runs} recorded run${runs === 1 ? "" : "s"}` : "Broad-Side: no runs recorded yet");
1068
+ finish([...statusText(state).split("\n"), ...(configWarning ? [`Warning: ${configWarning}`] : [])], runs > 0 ? `Broad-Side: ${runs} recorded run${runs === 1 ? "" : "s"}` : "Broad-Side: no runs recorded yet", configWarning ? "warning" : "info");
1011
1069
  return;
1012
1070
  }
1013
1071
  const apiKey = resolveBroadsideKey(config.apiKey);
@@ -1045,8 +1103,10 @@ export default function codeCartographerExtension(pi) {
1045
1103
  progress.set(lensId, `${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`);
1046
1104
  renderProgress("Polling batches…");
1047
1105
  };
1106
+ // An explicit --wait=0 polls once and returns; it used to become
1107
+ // undefined and, in core, the 25-minute budget (#230).
1048
1108
  const waitSeconds = flags.waitSeconds ?? config.waitSeconds;
1049
- const waitMs = waitSeconds > 0 ? waitSeconds * 1000 : undefined;
1109
+ const waitMs = waitSeconds * 1000;
1050
1110
  const includeSynthesis = flags.includeSynthesis ?? config.includeSynthesis;
1051
1111
  const includeTriage = flags.includeTriage ?? config.includeTriage;
1052
1112
  const retryTruncated = flags.retryTruncated ?? config.retryTruncated;
@@ -1113,6 +1173,7 @@ export default function codeCartographerExtension(pi) {
1113
1173
  includeTriage,
1114
1174
  retryTruncated,
1115
1175
  onStatus,
1176
+ ...(flags.runId && { runId: flags.runId }),
1116
1177
  });
1117
1178
  const lines = collectResultText(collect).split("\n");
1118
1179
  const done = collect.status === "completed";
@@ -1255,12 +1316,11 @@ export default function codeCartographerExtension(pi) {
1255
1316
  notifyCtx(ctx, "Usage: /codecarto-library-init <path> [--namespace <name>]", "warning");
1256
1317
  return;
1257
1318
  }
1258
- const libraryPath = pathArg.startsWith("~") ? join(homedir(), pathArg.slice(1)) : resolve(pathArg);
1319
+ // The same expansion the config loader applies, so `~user/x` and a
1320
+ // bare `~` mean the same thing everywhere (self-audit mech 6.14).
1321
+ const libraryPath = resolve(expandTilde(pathArg));
1259
1322
  try {
1260
- const result = await initLibrary(libraryPath, {
1261
- namespaced: !!namespace,
1262
- ...(namespace ? {} : {}),
1263
- });
1323
+ const result = await initLibrary(libraryPath, { namespaced: !!namespace });
1264
1324
  // Write library.path (and library.namespace when given) to the
1265
1325
  // user-global config and nothing else — not publish_confirm, so
1266
1326
  // initializing a library does not change how publish behaves on
@@ -1459,9 +1519,13 @@ export default function codeCartographerExtension(pi) {
1459
1519
  notifyCtx(ctx, error instanceof Error ? error.message : String(error), "error");
1460
1520
  return;
1461
1521
  }
1462
- const nextPhase = getNextEligiblePhase(state);
1463
- if (nextPhase) {
1464
- notifyCtx(ctx, `Cannot amend: the pipeline is not complete (next phase: ${nextPhase.id}). `
1522
+ const outcome = resolvePipelineOutcome(state);
1523
+ if (outcome.kind === "stuck") {
1524
+ notifyCtx(ctx, `Cannot amend: the pipeline is not complete. ${describeStuckPipeline(outcome.blocked)}`, "error");
1525
+ return;
1526
+ }
1527
+ if (outcome.kind === "eligible") {
1528
+ notifyCtx(ctx, `Cannot amend: the pipeline is not complete (next phase: ${outcome.phase.id}). `
1465
1529
  + "Resolve open questions and routed items through that phase's handoff (open_question_closures / carry_forward_closures) instead.", "error");
1466
1530
  return;
1467
1531
  }
@@ -7,7 +7,11 @@ export function phaseIdFromSessionName(sessionName) {
7
7
  if (!sessionName?.startsWith(PHASE_SESSION_PREFIX))
8
8
  return null;
9
9
  const phaseId = sessionName.slice(PHASE_SESSION_PREFIX.length).trim();
10
- return /^[a-z0-9][a-z0-9-]*$/.test(phaseId) ? phaseId : null;
10
+ // The same alphabet assertSafePhaseId admits (core/status.ts). A narrower
11
+ // match here silently dropped the bash block, write confinement, and
12
+ // checkpointing for a custom pipeline whose ids used `.`, `_`, or a capital
13
+ // (self-audit mech 6.11).
14
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phaseId) ? phaseId : null;
11
15
  }
12
16
  export function buildPhaseCompactionInstructions(phaseId, primaryOutput) {
13
17
  const output = primaryOutput ? `.codecarto/${primaryOutput}` : "the phase's declared primary output";
@@ -37,6 +37,7 @@ export declare function handleSwitchPipeline(args: {
37
37
  }>;
38
38
  export declare function handleNext(args: {
39
39
  cwd: string;
40
+ unattended?: boolean;
40
41
  }): Promise<{
41
42
  content: {
42
43
  type: "text";
@@ -49,6 +50,7 @@ export declare function handleNext(args: {
49
50
  export declare function handlePhase(args: {
50
51
  cwd: string;
51
52
  phase: string;
53
+ unattended?: boolean;
52
54
  }): Promise<{
53
55
  content: {
54
56
  type: "text";
@@ -130,7 +132,6 @@ export declare function handleLibraryInit(args: {
130
132
  library_path: string;
131
133
  name?: string;
132
134
  namespace?: string;
133
- cwd?: string;
134
135
  }): Promise<{
135
136
  content: {
136
137
  type: "text";
@@ -236,6 +237,7 @@ export declare function handleBroadside(args: {
236
237
  lenses?: string[];
237
238
  api_key?: string;
238
239
  wait_seconds?: number;
240
+ run_id?: string;
239
241
  include_synthesis?: boolean;
240
242
  include_triage?: boolean;
241
243
  retry_truncated?: boolean;