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.
- package/.codecarto/GUIDE.md +2 -2
- package/.codecarto/broadside/SKILL.md +21 -3
- package/.codecarto/broadside/config.yaml +35 -9
- package/.codecarto/findings/contracts/SKILL.md +4 -1
- package/.codecarto/findings/defect-scan/SKILL.md +10 -0
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +6 -0
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +8 -1
- package/.codecarto/findings/porting/SKILL.md +4 -0
- package/.codecarto/findings/protocols/SKILL.md +4 -0
- package/.codecarto/templates/mechanical-defects.md +15 -0
- package/.codecarto/templates/reimplementation-spec.md +5 -3
- package/.codecarto/templates/reverse-engineering-bundle.md +10 -1
- package/.codecarto/templates/semantic-defects.md +15 -0
- package/.codecarto/workflow/VALIDATE.md +3 -2
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +13 -9
- package/dist/core/amendment.js +9 -4
- package/dist/core/broadside.d.ts +121 -2
- package/dist/core/broadside.js +478 -92
- package/dist/core/completion.js +88 -23
- package/dist/core/dashboard-writer.d.ts +8 -0
- package/dist/core/dashboard-writer.js +159 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.js +9 -4
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +73 -0
- package/dist/core/pipeline.js +134 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +53 -13
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +16 -0
- package/dist/core/status.js +47 -20
- package/dist/core/synthesis.js +5 -2
- package/dist/core/utils.d.ts +7 -0
- package/dist/core/utils.js +7 -0
- package/dist/core/workspace.d.ts +55 -8
- package/dist/core/workspace.js +116 -8
- package/dist/core/yaml.js +181 -15
- package/dist/extensions/codecarto/agent-rewriter.js +21 -14
- package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
- package/dist/extensions/codecarto/agent-runner.js +27 -9
- package/dist/extensions/codecarto/agent-state.d.ts +0 -2
- package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
- package/dist/extensions/codecarto/auto-runner.js +27 -8
- package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
- package/dist/extensions/codecarto/broadside-flags.js +12 -0
- package/dist/extensions/codecarto/dashboard-narrator.js +9 -2
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
- package/dist/extensions/codecarto/dashboard-writer.js +5 -154
- package/dist/extensions/codecarto/index.js +158 -47
- package/dist/extensions/codecarto/phase-compaction.js +5 -1
- package/dist/mcp-server/server.d.ts +3 -1
- package/dist/mcp-server/server.js +205 -77
- 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
|
-
//
|
|
12
|
-
//
|
|
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
|
|
15
|
-
//
|
|
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
|
|
46
|
-
const
|
|
47
|
-
return mergeLayered([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
91
|
-
if (typeof o
|
|
92
|
-
|
|
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
|
|
97
|
-
if (typeof l
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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 };
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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}`);
|
package/dist/core/prompts.d.ts
CHANGED
|
@@ -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>;
|