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
@@ -110,7 +110,13 @@ async function appendDecisionLog(workspaceDir, phaseId, closeoutFile, decisions)
110
110
  const number = String(nextNumber + index).padStart(3, "0");
111
111
  return `D${number} | ${decision.trim()} | ${source} | closeouts/${closeoutFile} §Decisions Beyond Prompt (${phaseId})`;
112
112
  });
113
- content += `${content.endsWith("\n") ? "" : "\n"}${rows.join("\n")}\n`;
113
+ // A row appended straight after the section's explanatory paragraph is
114
+ // rendered as part of that paragraph by most Markdown renderers; a blank
115
+ // line makes the rows their own block (self-audit F4). Rows already
116
+ // present stay contiguous with the new ones.
117
+ const trailing = content.replace(/\n+$/, "").split("\n").pop() ?? "";
118
+ const separator = /^D\d+\s*\|/.test(trailing) || trailing.trim() === "" ? "" : "\n";
119
+ content += `${content.endsWith("\n") ? "" : "\n"}${separator}${rows.join("\n")}\n`;
114
120
  await writeFile(join(workspaceDir, "DECISIONS.md"), content, "utf8");
115
121
  return fresh.length;
116
122
  }
@@ -33,7 +33,7 @@ import { spawn } from "node:child_process";
33
33
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
34
34
  import { basename, join, resolve } from "node:path";
35
35
  import { acquireLock } from "./status.js";
36
- import { atomicWriteFile, canonicalPath, isPlainObject, normalizeForComparison, pathExists, uniqueTempSuffix } from "./utils.js";
36
+ import { atomicWriteFile, canonicalPath, GIT_TIMEOUT_MS, isPlainObject, normalizeForComparison, pathExists, uniqueTempSuffix } from "./utils.js";
37
37
  import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
38
38
  // ─── Constants ──────────────────────────────────────────────────────────────
39
39
  export const LIBRARY_MARKER_FILE = ".codecarto-library";
@@ -830,7 +830,7 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
830
830
  const lines = [];
831
831
  lines.push(`# ${escapeMd(marker.name)} — Library Index`);
832
832
  lines.push("");
833
- lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with \`codecarto library-reindex\`._`);
833
+ lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with the \`codecarto_library_reindex\` MCP tool._`);
834
834
  lines.push("");
835
835
  // A single-tenant library has no namespaces but is still one namespace's
836
836
  // worth of entries; count once so the noun agrees with the number shown.
@@ -1041,7 +1041,9 @@ export async function commitPublish(libraryRoot, message, opts = {}) {
1041
1041
  }
1042
1042
  function runGit(cwd, args) {
1043
1043
  return new Promise((resolvePromise) => {
1044
- const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1044
+ // Bounded like every fetch: a credential helper waiting on a prompt
1045
+ // used to hang publish or source-repo resolution for good (sem 3.12).
1046
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
1045
1047
  let stdout = "";
1046
1048
  let stderr = "";
1047
1049
  child.stdout.on("data", (b) => {
@@ -4,11 +4,47 @@ export declare const DEFAULT_PIPELINE_PATH = "workflow/pipeline-full-with-deep-a
4
4
  export declare function getPhaseMap(pipeline: PipelineFile): Map<string, PipelinePhase>;
5
5
  export declare function getPipelineLabel(pipelinePath: string): string;
6
6
  export declare function getNextEligiblePhase(state: WorkspaceState): PipelinePhase | null;
7
+ /** One phase the pipeline cannot reach, and the dependencies keeping it there. */
8
+ export interface BlockedPhase {
9
+ phaseId: string;
10
+ /** Each unmet `depends_on` entry, with why it will not clear on its own. */
11
+ missing: Array<{
12
+ dependencyId: string;
13
+ reason: "not-in-pipeline" | "blocked";
14
+ }>;
15
+ }
16
+ /**
17
+ * What the pipeline can do next. `getNextEligiblePhase` returned null both
18
+ * when every phase was complete and when the remaining phases waited on a
19
+ * dependency that would never clear, and every consumer read null as
20
+ * complete: a DAG with an unmet dependency reported 1/2 complete and unlocked
21
+ * the post-pipeline skills (#228). The third outcome is the difference.
22
+ */
23
+ export type PipelineOutcome = {
24
+ kind: "eligible";
25
+ phase: PipelinePhase;
26
+ } | {
27
+ kind: "complete";
28
+ } | {
29
+ kind: "stuck";
30
+ blocked: BlockedPhase[];
31
+ };
32
+ export declare function resolvePipelineOutcome(state: WorkspaceState): PipelineOutcome;
33
+ /** True when every phase in the active pipeline is complete. */
34
+ export declare function isPipelineComplete(state: WorkspaceState): boolean;
35
+ /**
36
+ * One sentence both surfaces print for a stuck pipeline, naming each blocked
37
+ * phase and the dependency keeping it there. The pipeline file is the thing to
38
+ * fix — or switch away from — so the sentence says so.
39
+ */
40
+ export declare function describeStuckPipeline(blocked: BlockedPhase[]): string;
7
41
  /**
8
42
  * Point `current_phase` and `next_actions` at whatever the engine finds
9
- * eligible now, or at the terminal routing when nothing is. Completion and a
10
- * pipeline switch both derive the cursor this way (#236), so status.yaml never
11
- * disagrees with the phase records it sits beside. Returns the eligible phase.
43
+ * eligible now, at the terminal routing when every phase is complete, or at
44
+ * the first blocked phase with the stuck sentence when nothing can run.
45
+ * Completion and a pipeline switch both derive the cursor this way (#236), so
46
+ * status.yaml never disagrees with the phase records it sits beside. Returns
47
+ * the eligible phase, or null.
12
48
  */
13
49
  export declare function recomputeCursor(state: WorkspaceState): PipelinePhase | null;
14
50
  /**
@@ -40,17 +40,71 @@ export function getNextEligiblePhase(state) {
40
40
  }
41
41
  return null;
42
42
  }
43
+ export function resolvePipelineOutcome(state) {
44
+ const phase = getNextEligiblePhase(state);
45
+ if (phase)
46
+ return { kind: "eligible", phase };
47
+ const phaseMap = getPhaseMap(state.pipeline);
48
+ const isComplete = (phaseId) => state.status.phases[phaseId]?.status === "complete";
49
+ const incomplete = state.pipeline.phase_order.filter((phaseId) => !isComplete(phaseId));
50
+ if (incomplete.length === 0)
51
+ return { kind: "complete" };
52
+ // Nothing is eligible and something is incomplete, so every incomplete
53
+ // phase has an unmet dependency. Each one is either a phase this pipeline
54
+ // does not declare, or one of the blocked phases themselves (a cycle, or a
55
+ // chain back to one).
56
+ const blocked = incomplete.map((phaseId) => ({
57
+ phaseId,
58
+ missing: (phaseMap.get(phaseId)?.depends_on ?? [])
59
+ .filter((dependencyId) => !isComplete(dependencyId))
60
+ .map((dependencyId) => ({
61
+ dependencyId,
62
+ reason: state.pipeline.phase_order.includes(dependencyId) ? "blocked" : "not-in-pipeline",
63
+ })),
64
+ }));
65
+ return { kind: "stuck", blocked };
66
+ }
67
+ /** True when every phase in the active pipeline is complete. */
68
+ export function isPipelineComplete(state) {
69
+ return resolvePipelineOutcome(state).kind === "complete";
70
+ }
71
+ /**
72
+ * One sentence both surfaces print for a stuck pipeline, naming each blocked
73
+ * phase and the dependency keeping it there. The pipeline file is the thing to
74
+ * fix — or switch away from — so the sentence says so.
75
+ */
76
+ export function describeStuckPipeline(blocked) {
77
+ const parts = blocked.map((entry) => {
78
+ const deps = entry.missing.map((m) => m.reason === "not-in-pipeline" ? `${m.dependencyId}, which is not in this pipeline` : `${m.dependencyId}, which is itself blocked`);
79
+ return `${entry.phaseId} depends on ${deps.join(" and ") || "nothing it can reach"}`;
80
+ });
81
+ return `Pipeline is stuck: ${parts.join("; ")}. No phase can run until the pipeline file's depends_on is fixed (or switch pipelines with codecarto_switch_pipeline / /codecarto-switch-pipeline).`;
82
+ }
43
83
  /**
44
84
  * Point `current_phase` and `next_actions` at whatever the engine finds
45
- * eligible now, or at the terminal routing when nothing is. Completion and a
46
- * pipeline switch both derive the cursor this way (#236), so status.yaml never
47
- * disagrees with the phase records it sits beside. Returns the eligible phase.
85
+ * eligible now, at the terminal routing when every phase is complete, or at
86
+ * the first blocked phase with the stuck sentence when nothing can run.
87
+ * Completion and a pipeline switch both derive the cursor this way (#236), so
88
+ * status.yaml never disagrees with the phase records it sits beside. Returns
89
+ * the eligible phase, or null.
48
90
  */
49
91
  export function recomputeCursor(state) {
50
- const next = getNextEligiblePhase(state);
51
- state.status.current_phase = next?.id ?? "complete";
52
- state.status.next_actions = next ? [beginPhaseAction(next)] : buildTerminalNextActions(state.status);
53
- return next;
92
+ const outcome = resolvePipelineOutcome(state);
93
+ if (outcome.kind === "eligible") {
94
+ state.status.current_phase = outcome.phase.id;
95
+ state.status.next_actions = [beginPhaseAction(outcome.phase)];
96
+ return outcome.phase;
97
+ }
98
+ if (outcome.kind === "stuck") {
99
+ // The cursor stays on the first phase that cannot run; "complete" is
100
+ // reserved for the state where nothing is left (#228).
101
+ state.status.current_phase = outcome.blocked[0]?.phaseId ?? "complete";
102
+ state.status.next_actions = [describeStuckPipeline(outcome.blocked)];
103
+ return null;
104
+ }
105
+ state.status.current_phase = "complete";
106
+ state.status.next_actions = buildTerminalNextActions(state.status);
107
+ return null;
54
108
  }
55
109
  /**
56
110
  * Phases status.yaml records as complete whose primary output is not on disk.
@@ -120,6 +120,13 @@ export async function buildPhasePrompt(state, phase, forced, options = {}) {
120
120
  const preflight = options.preflight ?? await runPhasePreflight(state, phase);
121
121
  const synthesisWorkflow = state.pipeline.workflow_name === "evidence-backed-project-synthesis";
122
122
  const handoffTemplateExists = await pathExists(join(state.workspaceDir, "templates", "phase-handoff.yaml"));
123
+ // The framework's own three files are the first reads on every phase, and
124
+ // after the first phase they are the same three files. Say so, so a host
125
+ // that carries context across phases spends it on the phase's inputs
126
+ // rather than re-reading the guide (self-audit F5). status.yaml is the
127
+ // exception: completion rewrote it, and it is the cursor.
128
+ const laterPhase = Object.values(state.status.phases).some((phaseState) => phaseState.status === "complete");
129
+ const unchangedNote = laterPhase ? " (framework-owned; unchanged since your last phase unless the scaffold was refreshed — skim rather than re-read if you still hold it)" : "";
123
130
  const lines = [
124
131
  `Read .codecarto/GUIDE.md and continue the CodeCartographer workflow for the phase \`${phase.id}\`.`,
125
132
  synthesisWorkflow
@@ -127,11 +134,11 @@ export async function buildPhasePrompt(state, phase, forced, options = {}) {
127
134
  : "Work on this phase only. The analyzed source code is the repository outside .codecarto/.",
128
135
  "",
129
136
  "Required reads before analysis:",
130
- "- .codecarto/GUIDE.md",
131
- "- .codecarto/workflow/status.yaml",
137
+ `- .codecarto/GUIDE.md${unchangedNote}`,
138
+ `- .codecarto/workflow/status.yaml${laterPhase ? " (rewritten by the last completion; read it)" : ""}`,
132
139
  ];
133
140
  if (handoffTemplateExists) {
134
- lines.push("- .codecarto/templates/phase-handoff.yaml");
141
+ lines.push(`- .codecarto/templates/phase-handoff.yaml${unchangedNote}`);
135
142
  }
136
143
  const primaryOutput = phase.primary_output ? `.codecarto/${phase.primary_output}` : undefined;
137
144
  if (primaryOutput) {
@@ -3,6 +3,14 @@ export declare const LOCK_RETRY_MS = 125;
3
3
  export declare const LOCK_TIMEOUT_MS = 5000;
4
4
  export declare const STALE_LOCK_MS = 60000;
5
5
  export declare function assertSafePhaseId(phaseId: string): void;
6
+ /**
7
+ * A YAML scalar as text: strings as written, numbers and booleans spelled
8
+ * back out. Files written before #225 hold owner notes such as `2048` or
9
+ * `true` bare, which the reader returns as a number or a boolean; dropping
10
+ * or crashing on those would lose real state, so they are read as the text
11
+ * they were. Anything else (null, arrays, objects) has no text.
12
+ */
13
+ export declare function textOf(value: unknown): string | null;
6
14
  export declare function ensureArray(value: unknown): string[];
7
15
  /**
8
16
  * Normalize a handoff's `open_question_closures` (#122, #186). Accepts both
@@ -13,8 +13,28 @@ export function assertSafePhaseId(phaseId) {
13
13
  throw new Error(`Invalid phase id: ${phaseId}`);
14
14
  }
15
15
  }
16
+ /**
17
+ * A YAML scalar as text: strings as written, numbers and booleans spelled
18
+ * back out. Files written before #225 hold owner notes such as `2048` or
19
+ * `true` bare, which the reader returns as a number or a boolean; dropping
20
+ * or crashing on those would lose real state, so they are read as the text
21
+ * they were. Anything else (null, arrays, objects) has no text.
22
+ */
23
+ export function textOf(value) {
24
+ if (typeof value === "string")
25
+ return value;
26
+ if (typeof value === "number" || typeof value === "boolean")
27
+ return String(value);
28
+ return null;
29
+ }
30
+ /** Like {@link textOf}, trimmed, and "" for a value that has no text. */
31
+ function trimmedText(value) {
32
+ return (textOf(value) ?? "").trim();
33
+ }
16
34
  export function ensureArray(value) {
17
- return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
35
+ if (!Array.isArray(value))
36
+ return [];
37
+ return value.map(textOf).filter((entry) => entry !== null);
18
38
  }
19
39
  function coerceEntry(value, allowTargetPhase) {
20
40
  if (typeof value === "string") {
@@ -27,22 +47,22 @@ function coerceEntry(value, allowTargetPhase) {
27
47
  return null;
28
48
  const raw = value;
29
49
  const entry = {};
30
- if (typeof raw.id === "string" && raw.id.trim())
31
- entry.id = raw.id.trim();
32
- if (typeof raw.kind === "string" && raw.kind.trim())
33
- entry.kind = raw.kind.trim();
34
- if (typeof raw.description === "string" && raw.description.trim())
35
- entry.description = raw.description.trim();
36
- if (typeof raw.deferred_reason === "string" && raw.deferred_reason.trim())
37
- entry.deferred_reason = raw.deferred_reason.trim();
38
- if (allowTargetPhase && typeof raw.target_phase === "string" && raw.target_phase.trim())
39
- entry.target_phase = raw.target_phase.trim();
50
+ if (trimmedText(raw.id))
51
+ entry.id = trimmedText(raw.id);
52
+ if (trimmedText(raw.kind))
53
+ entry.kind = trimmedText(raw.kind);
54
+ if (trimmedText(raw.description))
55
+ entry.description = trimmedText(raw.description);
56
+ if (trimmedText(raw.deferred_reason))
57
+ entry.deferred_reason = trimmedText(raw.deferred_reason);
58
+ if (allowTargetPhase && trimmedText(raw.target_phase))
59
+ entry.target_phase = trimmedText(raw.target_phase);
40
60
  // derives_from rides the same flag as target_phase: it is a carry-forward
41
61
  // concept only — the id of the open question this routed item answers one
42
62
  // candidate of (#122, #186). An open_questions entry has nothing to derive
43
63
  // from, so the field is dropped there rather than silently carried.
44
- if (allowTargetPhase && typeof raw.derives_from === "string" && raw.derives_from.trim())
45
- entry.derives_from = raw.derives_from.trim();
64
+ if (allowTargetPhase && trimmedText(raw.derives_from))
65
+ entry.derives_from = trimmedText(raw.derives_from);
46
66
  return Object.keys(entry).length > 0 ? entry : null;
47
67
  }
48
68
  /**
@@ -218,11 +238,13 @@ export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
218
238
  };
219
239
  }
220
240
  }
241
+ // Coerced, not assumed: a status.yaml written before #225 spells a
242
+ // digit-named project bare, and the reader returns a number for it.
221
243
  return {
222
- project_name: status.project_name?.trim() || basename(cwd),
223
- pipeline: status.pipeline?.trim() || pipelinePath,
224
- current_phase: status.current_phase?.trim() || pipeline.phase_order[0] || "complete",
225
- last_updated: status.last_updated?.trim() || "",
244
+ project_name: trimmedText(status.project_name) || basename(cwd),
245
+ pipeline: trimmedText(status.pipeline) || pipelinePath,
246
+ current_phase: trimmedText(status.current_phase) || pipeline.phase_order[0] || "complete",
247
+ last_updated: trimmedText(status.last_updated) || "",
226
248
  schema_version: typeof status.schema_version === "number" ? status.schema_version : 1,
227
249
  phases,
228
250
  next_actions: ensureArray(status.next_actions),
@@ -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,17 @@ 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;
21
+ /** For submit: the run's batch model, replacing config.yaml's (#141). */
22
+ model?: string;
23
+ /** For submit: per-lens model overrides, layered over config.yaml's (#141). */
24
+ lensModels?: Partial<Record<BroadsideLensId, string>>;
19
25
  benchmarks: boolean;
20
26
  unknown: string[];
21
27
  /** Set on an invalid combination. The caller surfaces it as an error. */
22
28
  error?: string;
23
29
  }
24
30
  /** 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"];
31
+ export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--run=", "--model=", "--lens-model=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
26
32
  export declare function parseBroadsideFlags(args: string): BroadsideFlags;
@@ -13,6 +13,12 @@
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)
17
+ // --model=ID (submit only: the run's batch model, as listed by models)
18
+ // --lens-model=LENS:ID (submit only, repeatable: one lens on its own model)
19
+ //
20
+ // A model id itself contains a colon (`vendor/name:batch`), so --lens-model
21
+ // splits on the first colon only: `security:deepseek/deepseek-v4-pro:batch`.
16
22
  //
17
23
  // --incremental has a spelled-out negative because the value is tri-state:
18
24
  // absent defers to config.yaml, so a repository that set `incremental: true`
@@ -34,6 +40,9 @@ export const KNOWN_BROADSIDE_TOKENS = [
34
40
  "--no-incremental",
35
41
  "--max-cost=",
36
42
  "--wait=",
43
+ "--run=",
44
+ "--model=",
45
+ "--lens-model=",
37
46
  "--no-synthesis",
38
47
  "--no-triage",
39
48
  "--no-retry-truncated",
@@ -106,6 +115,39 @@ export function parseBroadsideFlags(args) {
106
115
  result.waitSeconds = parseNumeric(token, "--wait", result);
107
116
  continue;
108
117
  }
118
+ if (token.startsWith("--run=")) {
119
+ const value = token.slice("--run=".length).trim();
120
+ if (!value)
121
+ result.error ??= "--run= needs a run id (see /codecarto-broadside status).";
122
+ result.runId = value || undefined;
123
+ continue;
124
+ }
125
+ if (token.startsWith("--model=")) {
126
+ const value = token.slice("--model=".length).trim();
127
+ // An empty value is a mistyped selection, not "use the default":
128
+ // the command is about to spend money on whichever model wins.
129
+ if (!value)
130
+ result.error ??= "--model= needs an OpenRouter batch model id (see /codecarto-broadside models).";
131
+ result.model = value || undefined;
132
+ continue;
133
+ }
134
+ if (token.startsWith("--lens-model=")) {
135
+ const value = token.slice("--lens-model=".length).trim();
136
+ const colon = value.indexOf(":");
137
+ const lensId = colon > 0 ? value.slice(0, colon).trim() : "";
138
+ const modelId = colon > 0 ? value.slice(colon + 1).trim() : "";
139
+ if (!lensId || !modelId) {
140
+ result.error ??= `--lens-model needs LENS:MODEL, e.g. --lens-model=security:vendor/name:batch (got "${value}").`;
141
+ }
142
+ else if (!BROADSIDE_LENS_IDS.includes(lensId)) {
143
+ result.error ??= `--lens-model: unknown lens "${lensId}". Lenses: ${BROADSIDE_LENS_IDS.join(", ")}.`;
144
+ }
145
+ else {
146
+ result.lensModels ??= {};
147
+ result.lensModels[lensId] = modelId;
148
+ }
149
+ continue;
150
+ }
109
151
  result.unknown.push(token);
110
152
  }
111
153
  // Flags that only mean something for one action are refused rather than
@@ -125,5 +167,14 @@ export function parseBroadsideFlags(args) {
125
167
  if (result.action === "status" && result.waitSeconds !== undefined) {
126
168
  result.error ??= "--wait is only meaningful for submit and collect; status reads recorded state.";
127
169
  }
170
+ if (result.runId !== undefined && result.action !== "collect") {
171
+ result.error ??= `--run is only meaningful for collect (got action "${result.action}").`;
172
+ }
173
+ if (result.model !== undefined && result.action !== "submit") {
174
+ result.error ??= `--model is only meaningful for submit (got action "${result.action}").`;
175
+ }
176
+ if (result.lensModels !== undefined && result.action !== "submit") {
177
+ result.error ??= `--lens-model is only meaningful for submit (got action "${result.action}").`;
178
+ }
128
179
  return result;
129
180
  }