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
@@ -8,13 +8,19 @@
8
8
  // workspace. Overrides individual keys from the user-global layer.
9
9
  //
10
10
  // Resolution order (top wins): per-workspace > user-global > defaults.
11
- // Missing files at either layer fall back to defaults; malformed YAML
12
- // at either layer is non-fatal (drops the layer, logs nothing).
11
+ // A missing file at either layer falls back to defaults. A file that exists
12
+ // but cannot be used — unparseable YAML, a section that is not a mapping, a
13
+ // key of the wrong type, a relative `library.path` — is dropped at the
14
+ // granularity of the fault and the fault is recorded in `problems`, so the
15
+ // tools can say which file and which key rather than silently answering
16
+ // from the wrong settings (#242, #243).
13
17
  //
14
- // `library.path` is returned tilde-expanded and absolute so consumers
15
- // don't have to expand themselves.
18
+ // `library.path` is returned tilde-expanded and absolute. A relative value
19
+ // is refused: it would resolve against wherever the MCP server or Pi was
20
+ // launched, not against the config file, so the library would move with the
21
+ // launch directory.
16
22
  import { homedir } from "node:os";
17
- import { dirname, join, resolve } from "node:path";
23
+ import { dirname, isAbsolute, join, resolve } from "node:path";
18
24
  import { mkdir, readFile, writeFile } from "node:fs/promises";
19
25
  import { expandTilde, pathExists } from "./utils.js";
20
26
  import { loadYamlFile, parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
@@ -40,11 +46,12 @@ const DEFAULT_CONFIG = {
40
46
  publish_confirm: true,
41
47
  publish_confirm_configured: false,
42
48
  },
49
+ problems: [],
43
50
  };
44
51
  export async function loadCodecartoConfig(workspaceDir) {
45
- const userRaw = await loadRawIfExists(resolveUserConfigPath());
46
- const workspaceRaw = await loadRawIfExists(join(workspaceDir, CONFIG_RELATIVE_PATH));
47
- return mergeLayered([userRaw, workspaceRaw]);
52
+ const user = await loadRawIfExists(resolveUserConfigPath());
53
+ const workspace = await loadRawIfExists(join(workspaceDir, CONFIG_RELATIVE_PATH));
54
+ return mergeLayered([user, workspace]);
48
55
  }
49
56
  /**
50
57
  * Read the user-global config directly. Exposed so wrappers can show
@@ -52,57 +59,113 @@ export async function loadCodecartoConfig(workspaceDir) {
52
59
  * load a workspace first.
53
60
  */
54
61
  export async function loadUserConfig() {
55
- const userRaw = await loadRawIfExists(resolveUserConfigPath());
56
- return mergeLayered([userRaw]);
62
+ return mergeLayered([await loadRawIfExists(resolveUserConfigPath())]);
57
63
  }
58
64
  async function loadRawIfExists(path) {
59
65
  if (!(await pathExists(path)))
60
- return null;
66
+ return { path, raw: null };
61
67
  try {
62
- return await loadYamlFile(path);
68
+ const parsed = await loadYamlFile(path);
69
+ if (parsed === null || parsed === undefined)
70
+ return { path, raw: null };
71
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
72
+ return { path, raw: null, problem: { path, message: "the file is not a YAML mapping; the whole file was ignored" } };
73
+ }
74
+ return { path, raw: parsed };
63
75
  }
64
- catch {
65
- return null;
76
+ catch (error) {
77
+ const reason = error instanceof Error ? error.message : String(error);
78
+ return { path, raw: null, problem: { path, message: `could not be parsed (${reason}); the whole file was ignored` } };
66
79
  }
67
80
  }
68
81
  function mergeLayered(layers) {
69
82
  let merged = cloneDefault();
70
- for (const layer of layers)
71
- merged = applyRaw(merged, layer);
83
+ for (const layer of layers) {
84
+ if (layer.problem) {
85
+ merged.problems.push(layer.problem);
86
+ continue;
87
+ }
88
+ merged = applyRaw(merged, layer.raw, layer.path);
89
+ }
72
90
  return merged;
73
91
  }
74
92
  /**
75
- * Apply one raw config layer over an existing config. Public so tests can
93
+ * Apply one raw config layer over the defaults. Public so tests can
76
94
  * exercise layering without filesystem fixtures, and so wrappers can mock
77
- * a layer in memory (e.g. "what if library_path were X").
95
+ * a layer in memory (e.g. "what if library_path were X"). `sourcePath`
96
+ * names the layer in any problem it produces.
78
97
  */
79
- export function mergeConfig(raw) {
80
- return applyRaw(cloneDefault(), raw);
98
+ export function mergeConfig(raw, sourcePath = "(in-memory config)") {
99
+ return applyRaw(cloneDefault(), raw, sourcePath);
81
100
  }
82
- function applyRaw(base, raw) {
83
- if (!raw || typeof raw !== "object")
84
- return base;
101
+ /**
102
+ * The lines both surfaces print for a config with problems: one header,
103
+ * then one line per fault naming its file. Empty when there are none.
104
+ */
105
+ export function describeConfigProblems(config) {
106
+ if (config.problems.length === 0)
107
+ return [];
108
+ const noun = config.problems.length === 1 ? "problem" : "problems";
109
+ return [
110
+ `Config ${noun} (${config.problems.length}) — these settings are not in effect:`,
111
+ ...config.problems.map((problem) => ` - ${problem.path}: ${problem.message}`),
112
+ ];
113
+ }
114
+ function applyRaw(base, raw, sourcePath) {
85
115
  const out = {
86
116
  orchestrator: { ...base.orchestrator },
87
117
  library: { ...base.library },
118
+ problems: [...base.problems],
88
119
  };
120
+ if (!raw || typeof raw !== "object")
121
+ return out;
122
+ const problem = (message) => out.problems.push({ path: sourcePath, message });
123
+ const describe = (value) => (typeof value === "string" ? JSON.stringify(value) : String(value));
89
124
  const o = raw.orchestrator;
90
- if (o && typeof o === "object") {
91
- if (typeof o.llm_steer_next_phase === "boolean") {
92
- out.orchestrator.llm_steer_next_phase = o.llm_steer_next_phase;
125
+ if (o !== undefined) {
126
+ if (!o || typeof o !== "object" || Array.isArray(o)) {
127
+ problem("orchestrator must be a mapping; the section was ignored");
128
+ }
129
+ else if (o.llm_steer_next_phase !== undefined) {
130
+ if (typeof o.llm_steer_next_phase === "boolean") {
131
+ out.orchestrator.llm_steer_next_phase = o.llm_steer_next_phase;
132
+ }
133
+ else {
134
+ problem(`orchestrator.llm_steer_next_phase must be true or false, got ${describe(o.llm_steer_next_phase)}; the key was ignored`);
135
+ }
93
136
  }
94
137
  }
95
138
  const l = raw.library;
96
- if (l && typeof l === "object") {
97
- if (typeof l.path === "string" && l.path.trim() !== "") {
98
- out.library.path = resolve(expandTilde(l.path.trim()));
139
+ if (l !== undefined) {
140
+ if (!l || typeof l !== "object" || Array.isArray(l)) {
141
+ problem("library must be a mapping; the section was ignored");
99
142
  }
100
- if (typeof l.namespace === "string" && l.namespace.trim() !== "") {
101
- out.library.namespace = l.namespace.trim();
102
- }
103
- if (typeof l.publish_confirm === "boolean") {
104
- out.library.publish_confirm = l.publish_confirm;
105
- out.library.publish_confirm_configured = true;
143
+ else {
144
+ if (typeof l.path === "string" && l.path.trim() !== "") {
145
+ const expanded = expandTilde(l.path.trim());
146
+ if (isAbsolute(expanded)) {
147
+ out.library.path = resolve(expanded);
148
+ }
149
+ else {
150
+ problem(`library.path must be absolute or start with ~ (got ${describe(l.path.trim())}); the key was ignored`);
151
+ }
152
+ }
153
+ else if (l.path !== undefined && l.path !== null && typeof l.path !== "string") {
154
+ problem(`library.path must be a string, got ${describe(l.path)}; the key was ignored`);
155
+ }
156
+ if (typeof l.namespace === "string" && l.namespace.trim() !== "") {
157
+ out.library.namespace = l.namespace.trim();
158
+ }
159
+ else if (l.namespace !== undefined && l.namespace !== null && typeof l.namespace !== "string") {
160
+ problem(`library.namespace must be a string, got ${describe(l.namespace)}; the key was ignored`);
161
+ }
162
+ if (typeof l.publish_confirm === "boolean") {
163
+ out.library.publish_confirm = l.publish_confirm;
164
+ out.library.publish_confirm_configured = true;
165
+ }
166
+ else if (l.publish_confirm !== undefined) {
167
+ problem(`library.publish_confirm must be true or false, got ${describe(l.publish_confirm)}; the key was ignored`);
168
+ }
106
169
  }
107
170
  }
108
171
  return out;
@@ -111,25 +174,42 @@ function cloneDefault() {
111
174
  return {
112
175
  orchestrator: { ...DEFAULT_CONFIG.orchestrator },
113
176
  library: { ...DEFAULT_CONFIG.library },
177
+ problems: [],
114
178
  };
115
179
  }
116
180
  /**
117
- * Write a `library:` block into a config file (user-global or workspace).
118
- * Creates the file and parent directories if needed. Preserves any existing
119
- * `orchestrator:` block. Overwrites the `library:` block if present.
181
+ * Record a library in a config file (user-global or workspace): set
182
+ * `library.path`, and `library.namespace` when one is given. Every other key
183
+ * in the file, `library.publish_confirm` included, is left exactly as it was
184
+ * — library-init writes only what it was asked for, so it cannot switch the
185
+ * MCP confirm gate on behind the user's back (#244). Creates the file and
186
+ * parent directories if needed. A file that exists but does not parse is
187
+ * never overwritten: the caller is told to fix it first.
120
188
  */
121
- export async function writeLibraryConfig(configPath, libraryPath, namespace = null, publishConfirm = true) {
189
+ export async function writeLibraryConfig(configPath, libraryPath, namespace = null) {
122
190
  let existing = {};
123
191
  if (await pathExists(configPath)) {
192
+ const raw = await readFile(configPath, "utf8");
193
+ let parsed;
124
194
  try {
125
- const raw = await readFile(configPath, "utf8");
126
- existing = parseSimpleYaml(raw);
195
+ parsed = parseSimpleYaml(raw);
196
+ }
197
+ catch (error) {
198
+ const reason = error instanceof Error ? error.message : String(error);
199
+ throw new Error(`Refusing to rewrite ${configPath}: it could not be parsed (${reason}). Fix or remove the file, then run library-init again.`);
127
200
  }
128
- catch {
129
- // Malformed file — start fresh
201
+ if (parsed !== null && parsed !== undefined) {
202
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
203
+ throw new Error(`Refusing to rewrite ${configPath}: it is not a YAML mapping. Fix or remove the file, then run library-init again.`);
204
+ }
205
+ existing = parsed;
130
206
  }
131
207
  }
132
- const library = { path: libraryPath, publish_confirm: publishConfirm };
208
+ const previous = existing.library;
209
+ const library = previous && typeof previous === "object" && !Array.isArray(previous)
210
+ ? { ...previous }
211
+ : {};
212
+ library.path = libraryPath;
133
213
  if (namespace)
134
214
  library.namespace = namespace;
135
215
  const updated = { ...existing, library };
@@ -4,7 +4,80 @@ 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;
41
+ /**
42
+ * Point `current_phase` and `next_actions` at whatever the engine finds
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.
48
+ */
49
+ export declare function recomputeCursor(state: WorkspaceState): PipelinePhase | null;
50
+ /**
51
+ * Phases status.yaml records as complete whose primary output is not on disk.
52
+ * status.yaml is committed and the findings are ignored by default, so a fresh
53
+ * clone says "complete" about reports it does not have; both surfaces' status
54
+ * name the gap rather than let the two files disagree in silence (#259).
55
+ */
56
+ export declare function listMissingCompletedOutputs(state: WorkspaceState): Promise<Array<{
57
+ phaseId: string;
58
+ path: string;
59
+ }>>;
60
+ /** The status lines both surfaces print for {@link listMissingCompletedOutputs}; empty when nothing is missing. */
61
+ export declare function describeMissingCompletedOutputs(missing: Array<{
62
+ phaseId: string;
63
+ path: string;
64
+ }>): string[];
7
65
  export declare function resolvePhase(state: WorkspaceState, phaseId?: string): PipelinePhase | null;
8
66
  export declare function resolvePipelineChoice(input: string): string | null;
67
+ /** What an Overall line can say; MISSING is the validator's own word for an absent output. */
68
+ type OverallVerdict = Exclude<ValidationResult["overall"], "MISSING">;
69
+ /**
70
+ * Read the verdict off a `**Overall:**` line, tolerating decoration around
71
+ * it: `**Overall:** PASS (6/6)`, `**Overall:** **PASS WITH GAPS** — see §3`,
72
+ * `**Overall**: \`PASS\`.`, a leading list marker. The verdict is whatever the
73
+ * value starts with; anything after it is commentary. Returns null for a
74
+ * line that is not an Overall line at all, and `{ verdict: null }` for one
75
+ * whose value does not start with a verdict, so the caller can say which
76
+ * line it could not read rather than reporting a bare FAIL (#247).
77
+ */
78
+ export declare function parseOverallLine(line: string): {
79
+ verdict: OverallVerdict | null;
80
+ } | null;
9
81
  export declare function validatePhaseOutput(state: WorkspaceState, phaseId?: string): Promise<ValidationResult>;
10
82
  export declare function buildValidationSummary(validation: ValidationResult): string[];
83
+ export {};
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { basename, join } from "node:path";
4
4
  import { pathExists } from "./utils.js";
5
5
  import { crossCheckFindings, findingsPairingGateActive } from "./findings.js";
6
+ import { beginPhaseAction, buildTerminalNextActions } from "./status.js";
6
7
  export const PIPELINE_ALIASES = {
7
8
  "full-with-audit": "workflow/pipeline-full-with-audit.yaml",
8
9
  "full-with-deep-audit": "workflow/pipeline-full-with-deep-audit.yaml",
@@ -39,6 +40,100 @@ export function getNextEligiblePhase(state) {
39
40
  }
40
41
  return null;
41
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
+ }
83
+ /**
84
+ * Point `current_phase` and `next_actions` at whatever the engine finds
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.
90
+ */
91
+ export function recomputeCursor(state) {
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;
108
+ }
109
+ /**
110
+ * Phases status.yaml records as complete whose primary output is not on disk.
111
+ * status.yaml is committed and the findings are ignored by default, so a fresh
112
+ * clone says "complete" about reports it does not have; both surfaces' status
113
+ * name the gap rather than let the two files disagree in silence (#259).
114
+ */
115
+ export async function listMissingCompletedOutputs(state) {
116
+ const missing = [];
117
+ for (const phase of state.pipeline.phases) {
118
+ if (!phase.primary_output)
119
+ continue;
120
+ if (state.status.phases[phase.id]?.status !== "complete")
121
+ continue;
122
+ if (await pathExists(join(state.workspaceDir, phase.primary_output)))
123
+ continue;
124
+ missing.push({ phaseId: phase.id, path: phase.primary_output });
125
+ }
126
+ return missing;
127
+ }
128
+ /** The status lines both surfaces print for {@link listMissingCompletedOutputs}; empty when nothing is missing. */
129
+ export function describeMissingCompletedOutputs(missing) {
130
+ if (missing.length === 0)
131
+ return [];
132
+ return [
133
+ `Outputs missing on disk for ${missing.length} complete phase(s) — findings are gitignored by default, so a clone carries the status but not the reports; re-run the phase here, or commit findings (see README, "What to commit"):`,
134
+ ...missing.map((entry) => ` - ${entry.phaseId}: .codecarto/${entry.path}`),
135
+ ];
136
+ }
42
137
  export function resolvePhase(state, phaseId) {
43
138
  const trimmed = phaseId?.trim();
44
139
  if (!trimmed)
@@ -66,6 +161,28 @@ export function resolvePipelineChoice(input) {
66
161
  return PIPELINE_ALIASES[trimmed];
67
162
  return trimmed.endsWith(".yaml") ? trimmed : null;
68
163
  }
164
+ /**
165
+ * Read the verdict off a `**Overall:**` line, tolerating decoration around
166
+ * it: `**Overall:** PASS (6/6)`, `**Overall:** **PASS WITH GAPS** — see §3`,
167
+ * `**Overall**: \`PASS\`.`, a leading list marker. The verdict is whatever the
168
+ * value starts with; anything after it is commentary. Returns null for a
169
+ * line that is not an Overall line at all, and `{ verdict: null }` for one
170
+ * whose value does not start with a verdict, so the caller can say which
171
+ * line it could not read rather than reporting a bare FAIL (#247).
172
+ */
173
+ export function parseOverallLine(line) {
174
+ const match = /^(?:[-*>]\s+)?\*\*Overall(?::\*\*|\*\*:)\s*(.*)$/i.exec(line.trim());
175
+ if (!match)
176
+ return null;
177
+ const value = (match[1] ?? "").replace(/^[\s*_`]+/, "").toUpperCase();
178
+ if (/^PASS WITH GAPS(?![A-Z])/.test(value))
179
+ return { verdict: "PASS WITH GAPS" };
180
+ if (/^PASS(?![A-Z])/.test(value))
181
+ return { verdict: "PASS" };
182
+ if (/^FAIL(?![A-Z])/.test(value))
183
+ return { verdict: "FAIL" };
184
+ return { verdict: null };
185
+ }
69
186
  export async function validatePhaseOutput(state, phaseId) {
70
187
  const phase = resolvePhase(state, phaseId);
71
188
  if (!phase) {
@@ -119,6 +236,10 @@ export async function validatePhaseOutput(state, phaseId) {
119
236
  const validationContent = content.slice(validationHeadingIndex);
120
237
  const rows = [];
121
238
  let overall = "FAIL";
239
+ const errors = [];
240
+ // The last **Overall:** line wins; what it said is remembered so the error
241
+ // can quote it when the verdict could not be read or was FAIL.
242
+ let overallLine = null;
122
243
  for (const rawLine of validationContent.split(/\r?\n/)) {
123
244
  const line = rawLine.trim();
124
245
  if (line.startsWith("|")) {
@@ -134,18 +255,21 @@ export async function validatePhaseOutput(state, phaseId) {
134
255
  });
135
256
  }
136
257
  }
137
- const overallMatch = line.match(/^\*\*Overall:\*\*\s*(.+)$/i);
138
- if (overallMatch?.[1]) {
139
- const normalizedOverall = overallMatch[1].trim().toUpperCase();
140
- if (normalizedOverall === "PASS")
141
- overall = "PASS";
142
- else if (normalizedOverall === "PASS WITH GAPS")
143
- overall = "PASS WITH GAPS";
144
- else
145
- overall = "FAIL";
258
+ const parsed = parseOverallLine(line);
259
+ if (parsed) {
260
+ overallLine = { text: line, verdict: parsed.verdict };
261
+ overall = parsed.verdict ?? "FAIL";
146
262
  }
147
263
  }
148
- const errors = [];
264
+ if (!overallLine) {
265
+ errors.push("No **Overall:** line found in the ## Validation block. End the block with `**Overall:** PASS`, `**Overall:** PASS WITH GAPS`, or `**Overall:** FAIL`.");
266
+ }
267
+ else if (overallLine.verdict === null) {
268
+ errors.push(`Could not read the verdict on the Overall line: "${overallLine.text}". It must start with PASS, PASS WITH GAPS, or FAIL; anything after the verdict is ignored.`);
269
+ }
270
+ else if (overallLine.verdict === "FAIL") {
271
+ errors.push(`The Overall line says FAIL: "${overallLine.text}".`);
272
+ }
149
273
  const gaps = rows
150
274
  .filter((row) => row.result.toUpperCase().includes("PARTIAL"))
151
275
  .map((row) => `${row.criterion}: ${row.evidence}`);
@@ -1,5 +1,16 @@
1
1
  import type { CarryForwardEntry, OpenQuestionEntry, PipelinePhase, WorkspaceState } from "./types.ts";
2
2
  import { type PhasePreflightResult } from "./synthesis.ts";
3
+ /** Chars of one spliced item shown before it is cut; the full text stays in its file. */
4
+ export declare const SPLICED_TEXT_LIMIT = 400;
5
+ /**
6
+ * Quote text an earlier session wrote — a routed item's description, a
7
+ * coverage bullet, a library headline — so it reads as data inside the next
8
+ * prompt, not as one of the framework's instructions (#253): one line, capped
9
+ * at {@link SPLICED_TEXT_LIMIT}, inside `«…»`, which the framework never uses
10
+ * for its own lines. Guillemets inside the text become single ones so the
11
+ * boundary stays unambiguous.
12
+ */
13
+ export declare function quoteSpliced(text: string, limit?: number): string;
3
14
  export declare function describeEntry(entry: OpenQuestionEntry | CarryForwardEntry): string;
4
15
  export declare function collectRoutedCarryForward(state: WorkspaceState, targetPhaseId: string): CarryForwardEntry[];
5
16
  export interface BuildPhasePromptOptions {
@@ -23,4 +34,13 @@ export interface BuildPhasePromptOptions {
23
34
  export declare function buildPhasePrompt(state: WorkspaceState, phase: PipelinePhase, forced: boolean, options?: BuildPhasePromptOptions): Promise<string>;
24
35
  export declare function closeoutFileName(date: string, phaseOrModule: string): string;
25
36
  export declare function listSkillNames(workspaceDir: string): Promise<string[]>;
37
+ /**
38
+ * Resolve a user-supplied skill name to an installed post-pipeline skill.
39
+ *
40
+ * The name is matched against `listSkillNames()` by exact directory name and
41
+ * is never joined onto a path, so `../findings/architecture` or any other
42
+ * traversal cannot reach a SKILL.md outside `skills/` and be spliced into the
43
+ * prompt. Returns the canonical name, or null when nothing installed matches.
44
+ */
45
+ export declare function resolveSkillName(workspaceDir: string, name: string): Promise<string | null>;
26
46
  export declare function buildSkillPrompt(state: WorkspaceState, skillName: string): Promise<string>;