Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **Block scalars still failed inside a sequence, so 0.19.3 did not actually finish #211.** The fix landed on mapping values (`key: >-`) and not on sequence items (`- >-`), and the second is the shape an LLM reaches for when it writes a list of prose entries — a `decisions:` list with one folded entry per decision. A real run hit it the same day 0.19.3 shipped: the architecture phase ran for 25 turns and 73 tool uses, wrote a `PASS WITH GAPS` artifact, and then auto-completion died on `Invalid YAML indentation near: The architecture map treats the legacy filesystem pipeline…`. The phase stayed `pending`, the dashboard showed 0/7 with the artifact and token usage recorded beside it, and the auto run stopped one phase in. `- |-` was never supported either, so this was not a regression from #211 so much as a hole it left. The block-scalar body reader is now one shared routine used by both the mapping and sequence paths rather than logic inlined in one of them, so the two cannot diverge again.

### Changed

- **`/codecarto-next`'s flags now explain themselves.** `--auto` and `--llm-steer` are independent — one decides how many phases run, the other decides what prompt each gets — and the combination that suits most full runs, `--auto --llm-steer`, was not guessable from a completion list that showed four bare flag names and a description that just listed them again. Completions now carry a sentence each (the `AutocompleteItem.description` field was there all along, unused), `--strict` is offered only once `--auto` is present rather than inviting the one combination the parser rejects, `/codecarto-init` names the full-run command at the moment someone needs it, and the Pi guide addendum documents all four flags with a table of the four sensible invocations. The addendum also tells the agent to volunteer that command after an init instead of waiting to be asked, and pre-empts two things that read as failures but are not: the first phase is never steered because there is no closeout to steer from, and a stopped auto run explains itself in its summary block rather than in the phase result.


## [0.19.3] — 2026-09-10

### Fixed
Expand Down
48 changes: 32 additions & 16 deletions core/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,31 @@ export function parseSimpleYaml(raw: string): unknown {
while (index < lines.length && isBlankOrComment(lines[index] ?? "")) index++;
};

/**
* Read the body of a block scalar that opened on the line just consumed.
* Shared by mapping values (`key: >-`) and sequence items (`- >-`): when only
* the mapping path had it, a handoff whose `decisions:` list used `- >-`
* still failed with the indentation error that #211 was supposed to end.
*/
const collectBlockScalarLines = (baseIndent: number): string[] => {
const blockLines: string[] = [];
let contentIndent: number | null = null;
while (index < lines.length) {
const blockLine = lines[index] ?? "";
if (blockLine.trim() === "") {
blockLines.push("");
index++;
continue;
}
const blockIndent = countIndent(blockLine);
if (blockIndent <= baseIndent) break;
contentIndent ??= blockIndent;
blockLines.push(blockLine.slice(Math.min(contentIndent, blockIndent)));
index++;
}
return blockLines;
};

const parseBlock = (indent: number): unknown => {
skipBlank();
if (index >= lines.length) return {};
Expand Down Expand Up @@ -240,22 +265,7 @@ export function parseSimpleYaml(raw: string): unknown {

const blockHeader = parseBlockScalarHeader(rawValue);
if (blockHeader) {
const blockLines: string[] = [];
let contentIndent: number | null = null;
while (index < lines.length) {
const blockLine = lines[index] ?? "";
if (blockLine.trim() === "") {
blockLines.push("");
index++;
continue;
}
const blockIndent = countIndent(blockLine);
if (blockIndent <= indent) break;
contentIndent ??= blockIndent;
blockLines.push(blockLine.slice(Math.min(contentIndent, blockIndent)));
index++;
}
assign(key, applyBlockScalar(blockLines, blockHeader));
assign(key, applyBlockScalar(collectBlockScalarLines(indent), blockHeader));
continue;
}

Expand Down Expand Up @@ -306,6 +316,12 @@ export function parseSimpleYaml(raw: string): unknown {
continue;
}

const itemBlockHeader = parseBlockScalarHeader(rawItem);
if (itemBlockHeader) {
result.push(applyBlockScalar(collectBlockScalarLines(indent), itemBlockHeader));
continue;
}

const separator = findKeySeparator(rawItem);
if (separator !== -1) {
const key = rawItem.slice(0, separator).trim();
Expand Down
21 changes: 21 additions & 0 deletions extensions/codecarto/guide-framing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ export const PI_SURFACE_ADDENDUM = [
`- **Every tool name maps to a slash command the user runs**, mechanically: \`codecarto_status\` → \`/codecarto-status\`, \`codecarto_next\` → \`/codecarto-next\`, and so on. Two have no Pi equivalent: ${MCP_ONLY_TOOLS.map((name) => `\`${name}\``).join(" and ")}.`,
"- **Ignore \"every tool takes an absolute `cwd`\".** Slash commands act on the session's own directory; there is no `cwd` argument to pass.",
"- **The drive loop is different.** `/codecarto-next` executes the phase itself, as an isolated sub-agent, and then auto-validates and auto-completes it. The guide's hand-written loop — take the prompt, execute it, write the handoff, then validate and complete yourself — describes the MCP surface. On Pi the user drives and the extension executes; your job is to explain what the framework is doing and answer questions about it, not to reproduce that loop by hand.",
"",
"### How to drive a run",
"",
"`/codecarto-next` takes flags that change how much runs and how each phase is seeded. They are independent: `--auto` decides *how many phases run*, `--llm-steer` decides *what prompt each one gets*.",
"",
"| Invocation | What it does |",
"| --- | --- |",
"| `/codecarto-next` | Runs the next eligible phase, once. Good for watching a single phase or retrying one that stopped. |",
"| `/codecarto-next --auto` | Runs every remaining phase back to back, validating and completing each before starting the next. Stops on a validation failure or a sub-agent error. |",
"| `/codecarto-next --auto --llm-steer` | The same, with each phase's prompt rewritten from the previous phase's closeout. **This is the usual choice for a full run** — it is what makes phase N+1 aware of what phase N found. |",
"| `/codecarto-next --auto --strict --llm-steer` | The same, but also stops on `PASS WITH GAPS` instead of advancing through it. Use when gaps should be reviewed rather than carried forward. |",
"",
"Notes worth passing on when the user asks:",
"",
"- **The first phase is never steered** — there is no previous closeout to steer from, so it reports `LLM rewriter skipped (no previous phase to steer from)` and uses the stock prompt. That message is normal, not a failure.",
"- **Steering costs an extra model call per phase**, on top of the phase sub-agent itself.",
"- `--strict` is only valid with `--auto`; on its own it is an error.",
"- `--no-llm-steer` forces steering off for one invocation when the workspace config has it on (`orchestrator.llm_steer_next_phase`, default off).",
"- **An auto run that stops says why in its summary block.** If a phase produced its artifact but the pipeline still shows it incomplete, read the `Auto pipeline stopped at …` message rather than assuming the phase failed — the phase usually succeeded and something after it did not.",
"",
"If the user has just initialized a workspace and has not said what they want, tell them the run command rather than waiting to be asked: `/codecarto-next --auto --llm-steer` for a full pass, or plain `/codecarto-next` to watch one phase first.",
].join("\n");

/**
Expand Down
30 changes: 24 additions & 6 deletions extensions/codecarto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,8 +609,14 @@ export default function codeCartographerExtension(pi: ExtensionAPI) {
await seedOrchestratorFiles(targetWorkspaceDir);

codecartoModeActive = true;
lastFeedbackLines = [`Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`];
ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)})`, "info");
// Name the run command here: init is the moment someone needs it, and
// the flags that make a full run useful are not guessable from the
// command name alone.
lastFeedbackLines = [
`Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`,
"Full run: `/codecarto-next --auto --llm-steer` — or `/codecarto-next` to watch one phase first.",
];
ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)}). Full run: /codecarto-next --auto --llm-steer`, "info");
// Render the initial dashboard (empty usage, all phases pending) so
// the user sees the file exist immediately after /codecarto-init.
void writeDashboard(ctx.cwd, PACKAGE_VERSION);
Expand Down Expand Up @@ -686,11 +692,23 @@ export default function codeCartographerExtension(pi: ExtensionAPI) {
});

pi.registerCommand("codecarto-next", {
description: "Run the next eligible CodeCartographer phase as a sub-agent. Flags: --llm-steer / --no-llm-steer / --auto [--strict]",
description: "Run the next phase as a sub-agent. Full run: --auto --llm-steer. Add --strict to stop on PASS WITH GAPS.",
getArgumentCompletions: (prefix) => {
const items = ["--llm-steer", "--no-llm-steer", "--auto", "--strict"]
.filter((value) => value.startsWith(prefix))
.map((value) => ({ value, label: value }));
// Descriptions, not bare flag names: the completion list is the only
// place most users will ever see what these do, and the useful
// combination (--auto --llm-steer) is not guessable from the names.
// --strict is offered only once --auto is present, because on its own
// it is rejected — suggesting it standalone invites the one error the
// parser has.
const autoAlreadyTyped = prefix.includes("--auto");
const items = [
{ value: "--auto", label: "--auto", description: "run every remaining phase back to back (recommended with --llm-steer)" },
{ value: "--llm-steer", label: "--llm-steer", description: "seed each phase from the previous phase's closeout; no effect on the first phase" },
{ value: "--no-llm-steer", label: "--no-llm-steer", description: "force steering off when the workspace config turns it on" },
...(autoAlreadyTyped
? [{ value: "--strict", label: "--strict", description: "with --auto: stop on PASS WITH GAPS instead of advancing" }]
: []),
].filter((item) => item.value.startsWith(prefix.split(/\s+/).pop() ?? prefix));
return items.length > 0 ? items : null;
},
handler: async (args, ctx) => {
Expand Down
25 changes: 25 additions & 0 deletions tests/guide-framing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,28 @@ test("the MCP-only tool list matches the real gap between the surfaces", async (
assert.ok(PI_SURFACE_ADDENDUM.includes(name), `${name} must be named in the addendum`);
}
});

// The flags are the least discoverable part of the Pi surface: the useful
// combination is not guessable from the names, and a model that has read the
// guide is the thing best placed to tell the user about it.
test("the addendum names the recommended full-run invocation", () => {
assert.match(PI_SURFACE_ADDENDUM, /\/codecarto-next --auto --llm-steer/);
assert.match(PI_SURFACE_ADDENDUM, /usual choice for a full run/i);
});

test("the addendum explains the flags a user will otherwise misread", () => {
assert.match(PI_SURFACE_ADDENDUM, /first phase is never steered/i, "the skip message reads as a failure otherwise");
assert.match(PI_SURFACE_ADDENDUM, /--strict` is only valid with `--auto/);
assert.match(PI_SURFACE_ADDENDUM, /extra model call per phase/i, "steering has a cost worth stating");
});

test("the addendum tells the agent to volunteer the run command after init", () => {
assert.match(PI_SURFACE_ADDENDUM, /tell them the run command rather than waiting to be asked/i);
});

test("the addendum points a stopped auto run at its summary", () => {
// The observed confusion: a phase wrote its artifact, the pipeline still
// showed 0 complete, and the reason was in the auto summary rather than in
// the phase result.
assert.match(PI_SURFACE_ADDENDUM, /Auto pipeline stopped at/);
});
37 changes: 37 additions & 0 deletions tests/pi-parity-commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,40 @@ test("/codecarto-amend previews the closures against status.yaml, applies on yes
assert.equal((await readFile(join(codecarto, "THREAD_LOG.md"), "utf8")).match(/amendment:scope-resolved/g).length, 1, "THREAD_LOG entry appears once");
});
});

// ---------- /codecarto-next flag discoverability ----------

test("/codecarto-next completions describe each flag rather than echoing its name", async () => {
await withTempRepo(async (cwd) => {
const { commands } = createHarness(cwd);
const items = await commands.get("codecarto-next").getArgumentCompletions("--");

const byValue = new Map(items.map((item) => [item.value, item]));
for (const flag of ["--auto", "--llm-steer", "--no-llm-steer"]) {
assert.ok(byValue.has(flag), `${flag} should be offered`);
const { description } = byValue.get(flag);
assert.ok(description && description.length > 0, `${flag} needs a description`);
assert.notEqual(description, flag, "a description that repeats the flag teaches nothing");
}
assert.match(byValue.get("--auto").description, /recommended with --llm-steer/);
});
});

test("/codecarto-next offers --strict only once --auto is present", async () => {
await withTempRepo(async (cwd) => {
const { commands } = createHarness(cwd);
const bare = (await commands.get("codecarto-next").getArgumentCompletions("--")) ?? [];
assert.equal(bare.some((item) => item.value === "--strict"), false, "--strict alone is an error; do not suggest it");

const withAuto = (await commands.get("codecarto-next").getArgumentCompletions("--auto --")) ?? [];
assert.equal(withAuto.some((item) => item.value === "--strict"), true, "--strict is valid once --auto is typed");
});
});

test("/codecarto-init names the full-run command, which is not guessable from the command name", async () => {
await withTempRepo(async (cwd) => {
const { commands, ctx, ui } = createHarness(cwd);
await commands.get("codecarto-init").handler("lite", ctx);
assert.match(lastNotification(ui).message, /\/codecarto-next --auto --llm-steer/);
});
});
32 changes: 32 additions & 0 deletions tests/yaml.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,35 @@ test("a value that merely starts with a block indicator is still a plain scalar"
assert.equal(parseSimpleYaml("k: |x\n").k, "|x");
assert.equal(parseSimpleYaml("k: > not a header\n").k, "> not a header");
});

// A block scalar can also open a sequence item. Supporting it only as a mapping
// value (#211) left `- >-` still failing with the same indentation error, which
// is what a real handoff hit: an LLM writing a `decisions:` list reaches for
// `- >-` per entry just as readily as it reaches for `key: >-`.

test("a block scalar can open a sequence item", () => {
assert.deepEqual(parseSimpleYaml("k:\n - >-\n a\n b\n").k, ["a b"]);
assert.deepEqual(parseSimpleYaml("k:\n - |-\n a\n b\n").k, ["a\nb"]);
});

test("sequence block scalars sit alongside their siblings without swallowing them", () => {
const parsed = parseSimpleYaml(
"decisions:\n - >-\n first one wrapped\n across two lines\n - plain second\n - >-\n third one\nother: kept\n",
);
assert.deepEqual(parsed.decisions, ["first one wrapped across two lines", "plain second", "third one"]);
assert.equal(parsed.other, "kept", "the key after the sequence must survive");
});

test("a block scalar inside a sequence item's mapping still parses", () => {
const parsed = parseSimpleYaml(
"carry_forward:\n - id: arch-CF1\n description: >-\n wrapped one\n wrapped two\n target_phase: contracts\n",
);
assert.deepEqual(parsed.carry_forward, [
{ id: "arch-CF1", description: "wrapped one wrapped two", target_phase: "contracts" },
]);
});

test("chomping works on sequence block scalars too", () => {
assert.deepEqual(parseSimpleYaml("k:\n - >\n a\n").k, ["a\n"]);
assert.deepEqual(parseSimpleYaml("k:\n - >-\n a\n").k, ["a"]);
});