codecartographer-pi 0.19.6 → 0.20.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 +1 -1
- package/.codecarto/broadside/SKILL.md +14 -0
- package/.codecarto/broadside/config.yaml +18 -0
- package/.codecarto/workflow/VALIDATE.md +2 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/dist/core/broadside.d.ts +72 -2
- package/dist/core/broadside.js +348 -63
- package/dist/core/completion.js +81 -22
- 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 +4 -1
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +80 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +43 -10
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +8 -0
- package/dist/core/status.js +8 -3
- package/dist/core/synthesis.js +5 -2
- package/dist/core/workspace.d.ts +55 -8
- package/dist/core/workspace.js +115 -7
- package/dist/core/yaml.js +173 -14
- 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.js +10 -6
- 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 +65 -18
- package/dist/mcp-server/server.js +107 -49
- package/package.json +3 -2
package/dist/core/workspace.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// + normalizes the per-project workspace state from disk, and provides the
|
|
4
4
|
// atomic status-update primitive used by /codecarto-complete.
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
-
import { appendFile, copyFile, cp, mkdir, readFile, readdir } from "node:fs/promises";
|
|
6
|
+
import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename } from "node:fs/promises";
|
|
7
7
|
import { basename, dirname, join, relative } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import {
|
|
9
|
+
import { getPipelineLabel, recomputeCursor } from "./pipeline.js";
|
|
10
|
+
import { acquireLock, applyHandoff, autoAssignIds, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
|
|
10
11
|
import { atomicWriteFile, compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
|
|
11
12
|
import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
|
|
12
13
|
// Walk up from the current file to find the package root. Needed because the
|
|
@@ -253,6 +254,44 @@ export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceD
|
|
|
253
254
|
}
|
|
254
255
|
await ensureWorkspaceGitignore(targetWorkspaceDir);
|
|
255
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Move a workspace's session state out into `backupDir`, keeping relative
|
|
259
|
+
* paths: status, the usage log, every declared phase output, handoffs and
|
|
260
|
+
* checkpoints, closeouts, the dashboard, the orchestrator files, Broad-Side
|
|
261
|
+
* runs, and any lock or temp file — everything {@link isTemplatePath} says a
|
|
262
|
+
* session wrote rather than the framework shipped. Framework-owned files stay
|
|
263
|
+
* where they are, as do the directories, so the workspace keeps its shape.
|
|
264
|
+
*
|
|
265
|
+
* This is what a forced re-init does when `.codecarto/` is the packaged
|
|
266
|
+
* template itself (#245). A checkout's `.codecarto/` is the template and a
|
|
267
|
+
* live workspace at once, so the ordinary force — rename the directory away,
|
|
268
|
+
* copy the template in — would move the very files it copies from. Before
|
|
269
|
+
* this, that case skipped the backup entirely and reset status in place.
|
|
270
|
+
*
|
|
271
|
+
* @returns the workspace-relative paths moved, sorted.
|
|
272
|
+
*/
|
|
273
|
+
export async function backupWorkspaceState(workspaceDir, backupDir) {
|
|
274
|
+
const declaredOutputs = await listDeclaredOutputs(workspaceDir);
|
|
275
|
+
const moved = [];
|
|
276
|
+
const walk = async (dir, segments) => {
|
|
277
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
278
|
+
const entrySegments = [...segments, entry.name];
|
|
279
|
+
const source = join(dir, entry.name);
|
|
280
|
+
if (entry.isDirectory()) {
|
|
281
|
+
await walk(source, entrySegments);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (isTemplatePath(entrySegments, declaredOutputs))
|
|
285
|
+
continue;
|
|
286
|
+
const destination = join(backupDir, ...entrySegments);
|
|
287
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
288
|
+
await rename(source, destination);
|
|
289
|
+
moved.push(entrySegments.join("/"));
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
await walk(workspaceDir, []);
|
|
293
|
+
return moved.sort();
|
|
294
|
+
}
|
|
256
295
|
/**
|
|
257
296
|
* Give the workspace its ignore rules when it has none. npm never packs a file
|
|
258
297
|
* named `.gitignore`, so an npm-installed template carried no rules and the
|
|
@@ -492,13 +531,31 @@ export async function updateStatusAtomically(cwd, updater) {
|
|
|
492
531
|
await lock.release();
|
|
493
532
|
}
|
|
494
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* The lines both surfaces print for the carry-forwards a switch moved to
|
|
536
|
+
* post_pipeline: one summary, then one line per entry naming where it was
|
|
537
|
+
* going and how to close it now. Empty when nothing moved.
|
|
538
|
+
*/
|
|
539
|
+
export function describeDanglingCarryForward(dangling) {
|
|
540
|
+
if (dangling.length === 0)
|
|
541
|
+
return [];
|
|
542
|
+
const noun = dangling.length === 1 ? "carry-forward item" : "carry-forward items";
|
|
543
|
+
const lines = [`${dangling.length} ${noun} targeted a dropped phase and moved to post_pipeline (close with an amendment's post_pipeline_closures):`];
|
|
544
|
+
for (const entry of dangling) {
|
|
545
|
+
const label = entry.description ? `: ${entry.description}` : "";
|
|
546
|
+
lines.push(` - ${entry.id} (${entry.source_phase} → ${entry.target_phase})${label}`);
|
|
547
|
+
}
|
|
548
|
+
return lines;
|
|
549
|
+
}
|
|
495
550
|
/**
|
|
496
551
|
* Switch the active pipeline in-place without deleting findings, handoffs,
|
|
497
552
|
* usage data, closeouts, or checkpoints. Phases that exist in both the old
|
|
498
553
|
* and new pipelines preserve their completion status, owner notes, open
|
|
499
|
-
* questions, and carry-forward entries
|
|
500
|
-
*
|
|
501
|
-
*
|
|
554
|
+
* questions, and carry-forward entries; the cursor is then recomputed from
|
|
555
|
+
* those carried completions (#236). Phases unique to the new pipeline start
|
|
556
|
+
* as pending. Phases unique to the old pipeline are dropped from status.yaml
|
|
557
|
+
* (but their findings remain on disk under findings/), and any carry-forward
|
|
558
|
+
* that targeted one of them moves to post_pipeline (#237).
|
|
502
559
|
*/
|
|
503
560
|
export async function switchPipeline(cwd, newPipelinePath) {
|
|
504
561
|
const workspaceDir = join(cwd, ".codecarto");
|
|
@@ -531,14 +588,65 @@ export async function switchPipeline(cwd, newPipelinePath) {
|
|
|
531
588
|
const dropped = currentState.pipeline.phase_order.filter((phaseId) => !newPipeline.phase_order.includes(phaseId));
|
|
532
589
|
const newPhases = newPipeline.phase_order.filter((phaseId) => !currentState.pipeline.phase_order.includes(phaseId));
|
|
533
590
|
// Preserve post_pipeline entries from the old status.
|
|
534
|
-
freshStatus.post_pipeline = currentState.status.post_pipeline;
|
|
591
|
+
freshStatus.post_pipeline = [...currentState.status.post_pipeline];
|
|
592
|
+
// A carried phase may have routed work to a phase the new pipeline does
|
|
593
|
+
// not run. Left in place, that entry would never appear in a phase
|
|
594
|
+
// prompt and nothing but a later handoff could close it (#237). It is
|
|
595
|
+
// post-pipeline work now, by the same rule completion applies to a
|
|
596
|
+
// handoff whose target is not a downstream active phase.
|
|
597
|
+
const dangling = [];
|
|
598
|
+
const activePhases = new Set(newPipeline.phase_order);
|
|
599
|
+
const newLabel = getPipelineLabel(newPipelinePath);
|
|
600
|
+
const postPipelineById = new Map(freshStatus.post_pipeline.filter((entry) => entry.id).map((entry) => [entry.id, entry]));
|
|
601
|
+
for (const [phaseId, phase] of Object.entries(freshStatus.phases)) {
|
|
602
|
+
if (!phase.carry_forward.some((entry) => entry.target_phase && !activePhases.has(entry.target_phase)))
|
|
603
|
+
continue;
|
|
604
|
+
// Handoff-routed entries always carry a `cf-<phase>-N` id; only a
|
|
605
|
+
// hand-edited status can lack one, and post_pipeline keys by id.
|
|
606
|
+
autoAssignIds(phase.carry_forward, "cf", phaseId);
|
|
607
|
+
const kept = [];
|
|
608
|
+
for (const entry of phase.carry_forward) {
|
|
609
|
+
if (!entry.target_phase || activePhases.has(entry.target_phase)) {
|
|
610
|
+
kept.push(entry);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const note = `Routed to ${entry.target_phase}, which the ${newLabel} pipeline does not run.`;
|
|
614
|
+
const moved = {
|
|
615
|
+
id: entry.id,
|
|
616
|
+
...(entry.kind !== undefined && { kind: entry.kind }),
|
|
617
|
+
...(entry.description !== undefined && { description: entry.description }),
|
|
618
|
+
deferred_reason: entry.deferred_reason ? `${entry.deferred_reason} ${note}` : note,
|
|
619
|
+
source_phase: phaseId,
|
|
620
|
+
status: "pending",
|
|
621
|
+
};
|
|
622
|
+
if (postPipelineById.has(entry.id)) {
|
|
623
|
+
const index = freshStatus.post_pipeline.findIndex((existing) => existing.id === entry.id);
|
|
624
|
+
freshStatus.post_pipeline[index] = moved;
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
freshStatus.post_pipeline.push(moved);
|
|
628
|
+
postPipelineById.set(entry.id, moved);
|
|
629
|
+
}
|
|
630
|
+
dangling.push({
|
|
631
|
+
id: entry.id,
|
|
632
|
+
source_phase: phaseId,
|
|
633
|
+
target_phase: entry.target_phase,
|
|
634
|
+
...(entry.description !== undefined && { description: entry.description }),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
phase.carry_forward = kept;
|
|
638
|
+
}
|
|
535
639
|
freshStatus.last_updated = new Date().toISOString();
|
|
640
|
+
// createEmptyStatus pointed the cursor at phase one; the carried
|
|
641
|
+
// completions may have moved it (#236). Ask the engine, exactly as
|
|
642
|
+
// completion does, so status and next agree from the moment of the switch.
|
|
643
|
+
recomputeCursor({ ...currentState, pipeline: newPipeline, status: freshStatus });
|
|
536
644
|
assertCanonicalStatus(freshStatus);
|
|
537
645
|
await atomicWriteFile(statusPath, `${stringifySimpleYaml(freshStatus)}\n`);
|
|
538
646
|
const state = await getWorkspaceState(cwd);
|
|
539
647
|
if (!state)
|
|
540
648
|
throw new Error("Failed to reload workspace state after pipeline switch.");
|
|
541
|
-
return { state, carried, dropped, newPhases };
|
|
649
|
+
return { state, carried, dropped, newPhases, dangling };
|
|
542
650
|
}
|
|
543
651
|
finally {
|
|
544
652
|
await lock.release();
|
package/dist/core/yaml.js
CHANGED
|
@@ -71,6 +71,55 @@ function findKeySeparator(text) {
|
|
|
71
71
|
}
|
|
72
72
|
return -1;
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether a line's content is a mapping entry in YAML's sense: a key followed
|
|
76
|
+
* by `:` and then a space or the end of the line. `findKeySeparator` alone
|
|
77
|
+
* finds any bare colon, which would read a wrapped value such as
|
|
78
|
+
* `see https://example.com` as a key.
|
|
79
|
+
*/
|
|
80
|
+
function looksLikeMappingEntry(text) {
|
|
81
|
+
const separator = findKeySeparator(text);
|
|
82
|
+
if (separator === -1)
|
|
83
|
+
return false;
|
|
84
|
+
const next = text[separator + 1];
|
|
85
|
+
return next === undefined || next === " " || next === "\t";
|
|
86
|
+
}
|
|
87
|
+
function looksLikeSequenceItem(text) {
|
|
88
|
+
return text.startsWith("- ") || text === "-";
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether the scalar text is plain: unquoted, and not a block indicator. Only
|
|
92
|
+
* a plain scalar can continue onto more-indented lines.
|
|
93
|
+
*/
|
|
94
|
+
function isPlainScalarText(text) {
|
|
95
|
+
const trimmed = text.trim();
|
|
96
|
+
if (trimmed === "")
|
|
97
|
+
return false;
|
|
98
|
+
if (trimmed.startsWith('"') || trimmed.startsWith("'"))
|
|
99
|
+
return false;
|
|
100
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{"))
|
|
101
|
+
return false;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fold a plain scalar's first line with its continuation lines: a single line
|
|
106
|
+
* break becomes a space and a run of k blank lines becomes k newlines, as for
|
|
107
|
+
* a folded block scalar.
|
|
108
|
+
*/
|
|
109
|
+
function foldPlainScalar(first, continuation) {
|
|
110
|
+
let result = first;
|
|
111
|
+
let pendingBreaks = 0;
|
|
112
|
+
for (const line of continuation) {
|
|
113
|
+
if (line === "") {
|
|
114
|
+
pendingBreaks++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
result += pendingBreaks > 0 ? "\n".repeat(pendingBreaks) : " ";
|
|
118
|
+
pendingBreaks = 0;
|
|
119
|
+
result += line;
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
74
123
|
export function parseYamlScalar(rawValue) {
|
|
75
124
|
const trimmed = stripYamlComment(rawValue).trim();
|
|
76
125
|
if (trimmed === "")
|
|
@@ -163,10 +212,70 @@ function applyBlockScalar(blockLines, header) {
|
|
|
163
212
|
export function parseSimpleYaml(raw) {
|
|
164
213
|
const lines = raw.split(/\r?\n/);
|
|
165
214
|
let index = 0;
|
|
215
|
+
/**
|
|
216
|
+
* Every parse error names the line it failed on, quotes it, and says which
|
|
217
|
+
* construct was expected: a model writing a handoff has to be able to tell
|
|
218
|
+
* a parser limitation from its own mistake (#246).
|
|
219
|
+
*/
|
|
220
|
+
const fail = (at, message) => {
|
|
221
|
+
throw new Error(`YAML line ${at + 1}: ${message} — "${(lines[at] ?? "").trim()}"`);
|
|
222
|
+
};
|
|
223
|
+
// YAML indents with spaces only. A tab was counted as two columns and then
|
|
224
|
+
// sliced as one character, so `\tkey: v` parsed as the key "" — garbage
|
|
225
|
+
// rather than an error.
|
|
226
|
+
for (let at = 0; at < lines.length; at++) {
|
|
227
|
+
if (/^ *\t/.test(lines[at] ?? "") && !isBlankOrComment(lines[at] ?? "")) {
|
|
228
|
+
fail(at, "tabs are not allowed in YAML indentation; use spaces");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
166
231
|
const skipBlank = () => {
|
|
167
232
|
while (index < lines.length && isBlankOrComment(lines[index] ?? ""))
|
|
168
233
|
index++;
|
|
169
234
|
};
|
|
235
|
+
/**
|
|
236
|
+
* Consume the continuation lines of a plain scalar that began on the line
|
|
237
|
+
* just consumed: lines indented deeper than `baseIndent` that are neither
|
|
238
|
+
* a mapping entry nor a sequence item nor a comment. A wrapped
|
|
239
|
+
* `closeout_summary:` is exactly this shape, and it used to fail the
|
|
240
|
+
* indentation check with a message that blamed whitespace (#246, probe D).
|
|
241
|
+
* Blank lines inside the run are kept (they fold to newlines); trailing
|
|
242
|
+
* blank lines are left for the caller.
|
|
243
|
+
*/
|
|
244
|
+
const collectPlainContinuation = (baseIndent) => {
|
|
245
|
+
const collected = [];
|
|
246
|
+
let pendingBlank = 0;
|
|
247
|
+
let cursor = index;
|
|
248
|
+
while (cursor < lines.length) {
|
|
249
|
+
const candidate = lines[cursor] ?? "";
|
|
250
|
+
if (candidate.trim() === "") {
|
|
251
|
+
pendingBlank++;
|
|
252
|
+
cursor++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const candidateIndent = countIndent(candidate);
|
|
256
|
+
if (candidateIndent <= baseIndent)
|
|
257
|
+
break;
|
|
258
|
+
const text = candidate.slice(candidateIndent);
|
|
259
|
+
if (text.startsWith("#") || looksLikeSequenceItem(text) || looksLikeMappingEntry(text))
|
|
260
|
+
break;
|
|
261
|
+
for (let i = 0; i < pendingBlank; i++)
|
|
262
|
+
collected.push("");
|
|
263
|
+
pendingBlank = 0;
|
|
264
|
+
collected.push(stripYamlComment(text).trim());
|
|
265
|
+
cursor++;
|
|
266
|
+
index = cursor;
|
|
267
|
+
}
|
|
268
|
+
return collected;
|
|
269
|
+
};
|
|
270
|
+
/** A scalar value read from `rawValue`, folded with any continuation lines. */
|
|
271
|
+
const parseScalarWithContinuation = (rawValue, baseIndent) => {
|
|
272
|
+
if (!isPlainScalarText(rawValue))
|
|
273
|
+
return parseYamlScalar(rawValue);
|
|
274
|
+
const continuation = collectPlainContinuation(baseIndent);
|
|
275
|
+
if (continuation.length === 0)
|
|
276
|
+
return parseYamlScalar(rawValue);
|
|
277
|
+
return foldPlainScalar(stripYamlComment(rawValue).trim(), continuation);
|
|
278
|
+
};
|
|
170
279
|
/**
|
|
171
280
|
* Read the body of a block scalar that opened on the line just consumed.
|
|
172
281
|
* Shared by mapping values (`key: >-`) and sequence items (`- >-`): when only
|
|
@@ -228,34 +337,58 @@ export function parseSimpleYaml(raw) {
|
|
|
228
337
|
if (lineIndent < indent)
|
|
229
338
|
break;
|
|
230
339
|
if (lineIndent > indent) {
|
|
231
|
-
|
|
340
|
+
fail(index, `this line is indented ${lineIndent} columns but the mapping it belongs to starts at column ${indent}; a sibling key must align with the first key, and a wrapped value must not contain ": "`);
|
|
232
341
|
}
|
|
233
342
|
const trimmed = line.slice(indent);
|
|
234
|
-
if (trimmed
|
|
235
|
-
|
|
343
|
+
if (looksLikeSequenceItem(trimmed)) {
|
|
344
|
+
// A list that is a key's value is consumed by that key's branch
|
|
345
|
+
// below before the loop ever sees it, so a dash here has no key.
|
|
346
|
+
fail(index, "a sequence item where a mapping entry was expected; a list that belongs to the key above must be indented under it, or sit at that key's own column");
|
|
347
|
+
}
|
|
236
348
|
const separator = findKeySeparator(trimmed);
|
|
237
349
|
if (separator === -1) {
|
|
238
|
-
|
|
350
|
+
fail(index, 'expected a mapping entry ("key: value")');
|
|
239
351
|
}
|
|
240
352
|
const key = trimmed.slice(0, separator).trim();
|
|
241
353
|
const rawValue = trimmed.slice(separator + 1).trim();
|
|
242
|
-
index++;
|
|
243
354
|
if (seen.has(key)) {
|
|
244
|
-
|
|
355
|
+
fail(index, `Duplicate YAML key: ${key}`);
|
|
245
356
|
}
|
|
246
357
|
seen.add(key);
|
|
358
|
+
index++;
|
|
247
359
|
const blockHeader = parseBlockScalarHeader(rawValue);
|
|
248
360
|
if (blockHeader) {
|
|
249
361
|
assign(key, applyBlockScalar(collectBlockScalarLines(indent), blockHeader));
|
|
250
362
|
continue;
|
|
251
363
|
}
|
|
252
364
|
if (rawValue !== "") {
|
|
253
|
-
assign(key,
|
|
365
|
+
assign(key, parseScalarWithContinuation(rawValue, indent));
|
|
254
366
|
continue;
|
|
255
367
|
}
|
|
256
368
|
skipBlank();
|
|
257
|
-
if (index
|
|
258
|
-
assign(key,
|
|
369
|
+
if (index >= lines.length) {
|
|
370
|
+
assign(key, null);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const nextLine = lines[index] ?? "";
|
|
374
|
+
const nextIndent = countIndent(nextLine);
|
|
375
|
+
if (nextIndent > indent) {
|
|
376
|
+
const nextText = nextLine.slice(nextIndent);
|
|
377
|
+
if (looksLikeMappingEntry(nextText) || looksLikeSequenceItem(nextText)) {
|
|
378
|
+
assign(key, parseBlock(nextIndent));
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// A scalar that starts on the line after its key
|
|
382
|
+
// (`summary:` / ` The phase mapped…`), wrapped or not.
|
|
383
|
+
index++;
|
|
384
|
+
assign(key, parseScalarWithContinuation(nextText, indent));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else if (nextIndent === indent && looksLikeSequenceItem(nextLine.slice(nextIndent))) {
|
|
388
|
+
// YAML lets a block sequence sit at the same indent as the key it
|
|
389
|
+
// belongs to (`items:` / `- a`). This read as `items: null` and
|
|
390
|
+
// then, at the top level, dropped every line after it (#246).
|
|
391
|
+
assign(key, parseSequence(indent));
|
|
259
392
|
}
|
|
260
393
|
else {
|
|
261
394
|
assign(key, null);
|
|
@@ -286,8 +419,17 @@ export function parseSimpleYaml(raw) {
|
|
|
286
419
|
index++;
|
|
287
420
|
if (rawItem === "") {
|
|
288
421
|
skipBlank();
|
|
289
|
-
|
|
290
|
-
|
|
422
|
+
const nextLine = lines[index] ?? "";
|
|
423
|
+
const nextIndent = countIndent(nextLine);
|
|
424
|
+
if (index < lines.length && nextIndent > indent) {
|
|
425
|
+
const nextText = nextLine.slice(nextIndent);
|
|
426
|
+
if (looksLikeMappingEntry(nextText) || looksLikeSequenceItem(nextText)) {
|
|
427
|
+
result.push(parseBlock(nextIndent));
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
index++;
|
|
431
|
+
result.push(parseScalarWithContinuation(nextText, indent));
|
|
432
|
+
}
|
|
291
433
|
}
|
|
292
434
|
else {
|
|
293
435
|
result.push(null);
|
|
@@ -299,12 +441,21 @@ export function parseSimpleYaml(raw) {
|
|
|
299
441
|
result.push(applyBlockScalar(collectBlockScalarLines(indent), itemBlockHeader));
|
|
300
442
|
continue;
|
|
301
443
|
}
|
|
444
|
+
if (looksLikeSequenceItem(rawItem)) {
|
|
445
|
+
// A sequence item that is itself a sequence (`- - a`). Rewrite the
|
|
446
|
+
// line as the inner item at its own column and parse a block there,
|
|
447
|
+
// so the inner sequence's later items (` - b`) find their first.
|
|
448
|
+
index--;
|
|
449
|
+
lines[index] = `${" ".repeat(itemIndent)}${afterDash.trimStart()}`;
|
|
450
|
+
result.push(parseBlock(itemIndent));
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
302
453
|
const separator = findKeySeparator(rawItem);
|
|
303
454
|
if (separator !== -1) {
|
|
304
455
|
const key = rawItem.slice(0, separator).trim();
|
|
305
456
|
const rawValue = rawItem.slice(separator + 1).trim();
|
|
306
457
|
const item = {};
|
|
307
|
-
item[key] = rawValue === "" ? null :
|
|
458
|
+
item[key] = rawValue === "" ? null : parseScalarWithContinuation(rawValue, indent);
|
|
308
459
|
skipBlank();
|
|
309
460
|
if (rawValue === "" && index < lines.length && countIndent(lines[index] ?? "") > indent + 1) {
|
|
310
461
|
item[key] = parseBlock(countIndent(lines[index] ?? ""));
|
|
@@ -317,14 +468,22 @@ export function parseSimpleYaml(raw) {
|
|
|
317
468
|
result.push(item);
|
|
318
469
|
continue;
|
|
319
470
|
}
|
|
320
|
-
result.push(
|
|
471
|
+
result.push(parseScalarWithContinuation(rawItem, indent));
|
|
321
472
|
}
|
|
322
473
|
return result;
|
|
323
474
|
};
|
|
324
475
|
skipBlank();
|
|
325
476
|
if (index >= lines.length)
|
|
326
477
|
return {};
|
|
327
|
-
|
|
478
|
+
const document = parseBlock(countIndent(lines[index] ?? ""));
|
|
479
|
+
// A top-level block that ends before the input does used to leave the rest
|
|
480
|
+
// unread and unreported — a mapping followed by a stray `- item` silently
|
|
481
|
+
// lost everything from that line on (#246).
|
|
482
|
+
skipBlank();
|
|
483
|
+
if (index < lines.length) {
|
|
484
|
+
fail(index, "unexpected content after the document's top-level block ended (is this line indented like its neighbours?)");
|
|
485
|
+
}
|
|
486
|
+
return document;
|
|
328
487
|
}
|
|
329
488
|
export function formatYamlScalar(value) {
|
|
330
489
|
if (value === null)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { readFile, readdir } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { disposeChildSession } from "./agent-runner.js";
|
|
13
14
|
import { closeoutFileName, pathExists } from "../../core/index.js";
|
|
14
15
|
import { createChildModelRuntime } from "./child-model-runtime.js";
|
|
15
16
|
/** Closeout content over this many bytes is truncated before being passed to
|
|
@@ -151,20 +152,26 @@ async function runRewriterOnce(ctx, prompt) {
|
|
|
151
152
|
tools: [],
|
|
152
153
|
resourceLoader: loader,
|
|
153
154
|
});
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
try {
|
|
156
|
+
await session.prompt(prompt);
|
|
157
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
158
|
+
const msg = session.messages[i];
|
|
159
|
+
if (msg.role !== "assistant")
|
|
160
|
+
continue;
|
|
161
|
+
const blocks = msg.content;
|
|
162
|
+
const parts = [];
|
|
163
|
+
for (const c of blocks) {
|
|
164
|
+
if (c.type === "text" && c.text)
|
|
165
|
+
parts.push(c.text);
|
|
166
|
+
}
|
|
167
|
+
const joined = parts.join("\n").trim();
|
|
168
|
+
if (joined)
|
|
169
|
+
return joined;
|
|
164
170
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
// One prompt, one answer; the child has nothing left to do (#256).
|
|
175
|
+
disposeChildSession(session);
|
|
168
176
|
}
|
|
169
|
-
return "";
|
|
170
177
|
}
|
|
@@ -11,7 +11,6 @@ export declare function primaryOutputExists(cwd: string, primaryOutput: string):
|
|
|
11
11
|
export declare function buildPhaseContinuationPrompt(compacted: boolean): string;
|
|
12
12
|
export declare function waitForCompaction(compactionCompleted: Promise<boolean>, timeoutMs?: number): Promise<boolean>;
|
|
13
13
|
export interface PhaseRunCallbacks {
|
|
14
|
-
onSessionCreated?: (session: AgentSession) => void;
|
|
15
14
|
onToolStart?: (toolCallId: string, toolName: string) => void;
|
|
16
15
|
onToolEnd?: (toolCallId: string, toolName: string) => void;
|
|
17
16
|
onTextDelta?: (delta: string, fullText: string) => void;
|
|
@@ -37,7 +36,6 @@ export interface PhaseRunOptions {
|
|
|
37
36
|
primaryOutput?: string;
|
|
38
37
|
}
|
|
39
38
|
export interface PhaseRunResult {
|
|
40
|
-
session: AgentSession;
|
|
41
39
|
responseText: string;
|
|
42
40
|
toolUses: number;
|
|
43
41
|
turnCount: number;
|
|
@@ -61,3 +59,9 @@ export interface PhaseRunResult {
|
|
|
61
59
|
* shows lineage.
|
|
62
60
|
*/
|
|
63
61
|
export declare function runPhase(ctx: ExtensionContext, prompt: string, callbacks?: PhaseRunCallbacks, options?: PhaseRunOptions, signal?: AbortSignal): Promise<PhaseRunResult>;
|
|
62
|
+
/**
|
|
63
|
+
* Dispose a child session, swallowing whatever dispose throws: the work is
|
|
64
|
+
* done and its result is already in hand, so a failing cleanup hook must not
|
|
65
|
+
* turn a finished phase into an error.
|
|
66
|
+
*/
|
|
67
|
+
export declare function disposeChildSession(session: Pick<AgentSession, "dispose">): void;
|
|
@@ -113,7 +113,6 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
|
|
|
113
113
|
resourceLoader: loader,
|
|
114
114
|
});
|
|
115
115
|
await session.bindExtensions({});
|
|
116
|
-
callbacks.onSessionCreated?.(session);
|
|
117
116
|
let toolUses = 0;
|
|
118
117
|
let turnCount = 0;
|
|
119
118
|
let currentMessageText = "";
|
|
@@ -200,19 +199,38 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
|
|
|
200
199
|
if (!aborted)
|
|
201
200
|
await session.prompt(buildPhaseContinuationPrompt(compacted));
|
|
202
201
|
}
|
|
202
|
+
return {
|
|
203
|
+
responseText: getLastAssistantText(session) || currentMessageText,
|
|
204
|
+
toolUses,
|
|
205
|
+
turnCount,
|
|
206
|
+
aborted,
|
|
207
|
+
sessionFile: sessionManager.getSessionFile(),
|
|
208
|
+
};
|
|
203
209
|
}
|
|
204
210
|
finally {
|
|
205
211
|
unsubscribe();
|
|
206
212
|
abortCleanup();
|
|
213
|
+
// The child is done, on every path. Dispose aborts whatever it still
|
|
214
|
+
// has in flight, drops its agent subscription and listeners, and runs
|
|
215
|
+
// the per-session resource cleanups extensions registered — a seven
|
|
216
|
+
// phase auto run used to keep all of that for every phase, rewrite,
|
|
217
|
+
// and narration until the process exited (#256). Nothing reads the
|
|
218
|
+
// session after this: the result carries the text and the file path.
|
|
219
|
+
disposeChildSession(session);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Dispose a child session, swallowing whatever dispose throws: the work is
|
|
224
|
+
* done and its result is already in hand, so a failing cleanup hook must not
|
|
225
|
+
* turn a finished phase into an error.
|
|
226
|
+
*/
|
|
227
|
+
export function disposeChildSession(session) {
|
|
228
|
+
try {
|
|
229
|
+
session.dispose();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// nothing to do with a cleanup failure but move on
|
|
207
233
|
}
|
|
208
|
-
return {
|
|
209
|
-
session,
|
|
210
|
-
responseText: getLastAssistantText(session) || currentMessageText,
|
|
211
|
-
toolUses,
|
|
212
|
-
turnCount,
|
|
213
|
-
aborted,
|
|
214
|
-
sessionFile: sessionManager.getSessionFile(),
|
|
215
|
-
};
|
|
216
234
|
}
|
|
217
235
|
/**
|
|
218
236
|
* Walk session.messages backward to find the last non-empty assistant text.
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { type CompactionTelemetry } from "../../core/usage.ts";
|
|
3
2
|
export type PhaseStatus = "running" | "completed" | "error" | "aborted";
|
|
4
3
|
export interface PhaseActivity {
|
|
@@ -20,7 +19,6 @@ export interface PhaseActivity {
|
|
|
20
19
|
};
|
|
21
20
|
/** Compaction outcomes observed during this phase session. */
|
|
22
21
|
compactions: CompactionTelemetry;
|
|
23
|
-
session?: AgentSession;
|
|
24
22
|
error?: string;
|
|
25
23
|
}
|
|
26
24
|
export declare function getPhaseActivity(phaseId: string): PhaseActivity | undefined;
|
|
@@ -17,8 +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 { writeDashboard } from "
|
|
21
|
-
import { appendUsageRun, buildPhasePrompt, buildValidationSummary, completeValidatedPhase, formatMillis, formatTokenCount, getNextEligiblePhase, getWorkspaceState, loadCodecartoConfig, PACKAGE_VERSION, PhasePreflightError, runPhasePreflight, validatePhaseOutput, } from "../../core/index.js";
|
|
20
|
+
import { appendUsageRun, buildPhasePrompt, buildValidationSummary, completeValidatedPhase, formatMillis, formatTokenCount, getNextEligiblePhase, getWorkspaceState, describeConfigProblems, loadCodecartoConfig, PACKAGE_VERSION, PhasePreflightError, runPhasePreflight, validatePhaseOutput, writeDashboard, } from "../../core/index.js";
|
|
22
21
|
/**
|
|
23
22
|
* Run one phase end to end: optional LLM-steered rewrite, spawn the sub-agent,
|
|
24
23
|
* wait for it, then emit the side effects the historical /codecarto-next chain
|
|
@@ -60,7 +59,6 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
|
|
|
60
59
|
getAgentsWidget().attach(ctx.ui);
|
|
61
60
|
try {
|
|
62
61
|
const result = await runPhase(ctx, prompt, {
|
|
63
|
-
onSessionCreated: (session) => { activity.session = session; },
|
|
64
62
|
onToolStart: (id, name) => { activity.activeTools.set(id, name); activity.toolUses++; },
|
|
65
63
|
onToolEnd: (id) => { activity.activeTools.delete(id); },
|
|
66
64
|
onTextDelta: (_delta, fullText) => { activity.responseText = fullText; },
|
|
@@ -101,7 +99,9 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
|
|
|
101
99
|
display: true,
|
|
102
100
|
});
|
|
103
101
|
void recordUsage(state.workspaceDir, phase.id, status, activity, result.sessionFile);
|
|
104
|
-
|
|
102
|
+
// The phase's sub-agent replaced the session, so reading ctx.cwd here
|
|
103
|
+
// would throw (#201); the state captured before the run has the root.
|
|
104
|
+
void writeDashboard(state.cwd, PACKAGE_VERSION);
|
|
105
105
|
return {
|
|
106
106
|
status: result.aborted ? "aborted" : "completed",
|
|
107
107
|
activity,
|
|
@@ -128,7 +128,7 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
|
|
|
128
128
|
display: true,
|
|
129
129
|
});
|
|
130
130
|
void recordUsage(state.workspaceDir, phase.id, "error", activity);
|
|
131
|
-
void writeDashboard(
|
|
131
|
+
void writeDashboard(state.cwd, PACKAGE_VERSION);
|
|
132
132
|
return { status: "error", activity, error: message };
|
|
133
133
|
}
|
|
134
134
|
finally {
|
|
@@ -190,6 +190,10 @@ export async function runAuto(ctx, pi, initialState, options) {
|
|
|
190
190
|
const totalTokens = { input: 0, output: 0, cacheWrite: 0 };
|
|
191
191
|
const totalPhases = initialState.pipeline.phase_order.length;
|
|
192
192
|
const config = await loadCodecartoConfig(initialState.workspaceDir);
|
|
193
|
+
// Once, before the loop: a dropped config file changes what this run does
|
|
194
|
+
// (the steer toggle lives there) and nothing else in the loop reads it.
|
|
195
|
+
if (config.problems.length > 0)
|
|
196
|
+
notifyCtx(ctx, describeConfigProblems(config).join("\n"), "warning");
|
|
193
197
|
const llmSteerEnabled = options.llmSteerOverride ?? config.orchestrator.llm_steer_next_phase;
|
|
194
198
|
let state = initialState;
|
|
195
199
|
while (true) {
|
|
@@ -241,7 +245,7 @@ export async function runAuto(ctx, pi, initialState, options) {
|
|
|
241
245
|
if (phaseResult.status === "completed") {
|
|
242
246
|
// State must be refreshed because the sub-agent may have written
|
|
243
247
|
// findings to disk that the validator reads.
|
|
244
|
-
const stateForValidation = (await getWorkspaceState(
|
|
248
|
+
const stateForValidation = (await getWorkspaceState(autoCwd)) ?? state;
|
|
245
249
|
validation = await validatePhaseOutput(stateForValidation, phase.id);
|
|
246
250
|
}
|
|
247
251
|
const decision = decideAfterPhase(phaseResult.status, phaseResult.error, validation, options.strict);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { readFile, readdir } from "node:fs/promises";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { disposeChildSession } from "./agent-runner.js";
|
|
16
17
|
import { atomicWriteFile, computeTotals, NARRATION_CACHE_RELATIVE_PATH, loadUsage, pathExists, stringifySimpleYaml, } from "../../core/index.js";
|
|
17
18
|
import { createChildModelRuntime } from "./child-model-runtime.js";
|
|
18
19
|
// Per-closeout byte budget when stuffing the narrator's input. Three
|
|
@@ -158,8 +159,14 @@ async function runNarratorOnce(ctx, prompt) {
|
|
|
158
159
|
tools: [],
|
|
159
160
|
resourceLoader: loader,
|
|
160
161
|
});
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
try {
|
|
163
|
+
await session.prompt(prompt);
|
|
164
|
+
return getLastAssistantText(session);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
// One prompt, one answer; the child has nothing left to do (#256).
|
|
168
|
+
disposeChildSession(session);
|
|
169
|
+
}
|
|
163
170
|
}
|
|
164
171
|
function getLastAssistantText(session) {
|
|
165
172
|
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
@@ -1,8 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Render and atomically replace `.codecarto/dashboard.html`.
|
|
3
|
-
* @returns true when a fresh dashboard landed on disk; false when the
|
|
4
|
-
* workspace is missing or any gather/render/write step failed (swallowed —
|
|
5
|
-
* lifecycle callers must never fail on a dashboard problem, but they may
|
|
6
|
-
* report truthfully whether a refresh happened).
|
|
7
|
-
*/
|
|
8
|
-
export declare function writeDashboard(cwd: string, packageVersion: string): Promise<boolean>;
|
|
1
|
+
export { writeDashboard } from "../../core/dashboard-writer.ts";
|