codecartographer-pi 0.19.3 → 0.19.5

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.
@@ -3,4 +3,4 @@
3
3
  # workspace's framework-owned files (GUIDE.md, templates/, workflow/ pipelines
4
4
  # and VALIDATE.md) predate the running release. Written at release time and
5
5
  # copied verbatim by init — never edit by hand.
6
- scaffold_version: 0.19.3
6
+ scaffold_version: 0.19.5
package/dist/core/yaml.js CHANGED
@@ -167,6 +167,31 @@ export function parseSimpleYaml(raw) {
167
167
  while (index < lines.length && isBlankOrComment(lines[index] ?? ""))
168
168
  index++;
169
169
  };
170
+ /**
171
+ * Read the body of a block scalar that opened on the line just consumed.
172
+ * Shared by mapping values (`key: >-`) and sequence items (`- >-`): when only
173
+ * the mapping path had it, a handoff whose `decisions:` list used `- >-`
174
+ * still failed with the indentation error that #211 was supposed to end.
175
+ */
176
+ const collectBlockScalarLines = (baseIndent) => {
177
+ const blockLines = [];
178
+ let contentIndent = null;
179
+ while (index < lines.length) {
180
+ const blockLine = lines[index] ?? "";
181
+ if (blockLine.trim() === "") {
182
+ blockLines.push("");
183
+ index++;
184
+ continue;
185
+ }
186
+ const blockIndent = countIndent(blockLine);
187
+ if (blockIndent <= baseIndent)
188
+ break;
189
+ contentIndent ??= blockIndent;
190
+ blockLines.push(blockLine.slice(Math.min(contentIndent, blockIndent)));
191
+ index++;
192
+ }
193
+ return blockLines;
194
+ };
170
195
  const parseBlock = (indent) => {
171
196
  skipBlank();
172
197
  if (index >= lines.length)
@@ -221,23 +246,7 @@ export function parseSimpleYaml(raw) {
221
246
  seen.add(key);
222
247
  const blockHeader = parseBlockScalarHeader(rawValue);
223
248
  if (blockHeader) {
224
- const blockLines = [];
225
- let contentIndent = null;
226
- while (index < lines.length) {
227
- const blockLine = lines[index] ?? "";
228
- if (blockLine.trim() === "") {
229
- blockLines.push("");
230
- index++;
231
- continue;
232
- }
233
- const blockIndent = countIndent(blockLine);
234
- if (blockIndent <= indent)
235
- break;
236
- contentIndent ??= blockIndent;
237
- blockLines.push(blockLine.slice(Math.min(contentIndent, blockIndent)));
238
- index++;
239
- }
240
- assign(key, applyBlockScalar(blockLines, blockHeader));
249
+ assign(key, applyBlockScalar(collectBlockScalarLines(indent), blockHeader));
241
250
  continue;
242
251
  }
243
252
  if (rawValue !== "") {
@@ -285,6 +294,11 @@ export function parseSimpleYaml(raw) {
285
294
  }
286
295
  continue;
287
296
  }
297
+ const itemBlockHeader = parseBlockScalarHeader(rawItem);
298
+ if (itemBlockHeader) {
299
+ result.push(applyBlockScalar(collectBlockScalarLines(indent), itemBlockHeader));
300
+ continue;
301
+ }
288
302
  const separator = findKeySeparator(rawItem);
289
303
  if (separator !== -1) {
290
304
  const key = rawItem.slice(0, separator).trim();
@@ -12,6 +12,7 @@
12
12
  // write, closeout-stub creation, dashboard regen. UI notifications stay in
13
13
  // the calling handler.
14
14
  import { runPhase } from "./agent-runner.js";
15
+ import { notifyCtx } from "./notify.js";
15
16
  import { clearPhase, finishPhase, getPhaseActivity, startPhase } from "./agent-state.js";
16
17
  import { buildSteeringMessage, rewritePhasePrompt } from "./agent-rewriter.js";
17
18
  import { buildPhaseSummary } from "./agent-summary.js";
@@ -34,13 +35,11 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
34
35
  preflight: options.preflight,
35
36
  });
36
37
  if (options.llmSteerEnabled) {
37
- if (ctx.hasUI)
38
- ctx.ui.notify(`Customizing ${phase.id} prompt via LLM rewriter…`, "info");
38
+ notifyCtx(ctx, `Customizing ${phase.id} prompt via LLM rewriter…`, "info");
39
39
  const rewrite = await rewritePhasePrompt({ ctx, state, originalPrompt: prompt, nextPhaseId: phase.id });
40
40
  if (rewrite.used) {
41
41
  prompt = rewrite.prompt;
42
- if (ctx.hasUI)
43
- ctx.ui.notify(`LLM rewriter customized ${phase.id} seed prompt.`, "info");
42
+ notifyCtx(ctx, `LLM rewriter customized ${phase.id} seed prompt.`, "info");
44
43
  pi.sendMessage({
45
44
  customType: "codecarto-steering",
46
45
  content: buildSteeringMessage({
@@ -51,15 +50,14 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
51
50
  display: true,
52
51
  });
53
52
  }
54
- else if (ctx.hasUI) {
55
- ctx.ui.notify(`LLM rewriter skipped (${rewrite.skipReason}); using stock prompt.`, "warning");
53
+ else {
54
+ notifyCtx(ctx, `LLM rewriter skipped (${rewrite.skipReason}); using stock prompt.`, "warning");
56
55
  }
57
56
  }
58
57
  const activity = startPhase(phase.id);
59
- if (ctx.hasUI) {
60
- ctx.ui.notify(`CodeCartographer phase: ${phase.id} (sub-agent running)`, "info");
58
+ notifyCtx(ctx, `CodeCartographer phase: ${phase.id} (sub-agent running)`, "info");
59
+ if (ctx.hasUI)
61
60
  getAgentsWidget().attach(ctx.ui);
62
- }
63
61
  try {
64
62
  const result = await runPhase(ctx, prompt, {
65
63
  onSessionCreated: (session) => { activity.session = session; },
@@ -84,11 +82,9 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
84
82
  }, { sessionName: `CodeCartographer phase: ${phase.id}`, primaryOutput: phase.primary_output }, options.signal);
85
83
  const status = result.aborted ? "aborted" : "completed";
86
84
  finishPhase(phase.id, { status });
87
- if (ctx.hasUI) {
88
- ctx.ui.notify(result.aborted
89
- ? `Phase ${phase.id} aborted.`
90
- : `Phase ${phase.id} sub-agent finished (${result.toolUses} tool uses, ${result.turnCount} turns).`, result.aborted ? "warning" : "info");
91
- }
85
+ notifyCtx(ctx, result.aborted
86
+ ? `Phase ${phase.id} aborted.`
87
+ : `Phase ${phase.id} sub-agent finished (${result.toolUses} tool uses, ${result.turnCount} turns).`, result.aborted ? "warning" : "info");
92
88
  pi.sendMessage({
93
89
  customType: "codecarto-phase-summary",
94
90
  content: buildPhaseSummary({
@@ -115,9 +111,7 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
115
111
  catch (err) {
116
112
  const message = err instanceof Error ? err.message : String(err);
117
113
  finishPhase(phase.id, { status: "error", error: message });
118
- if (ctx.hasUI) {
119
- ctx.ui.notify(`Phase ${phase.id} sub-agent failed: ${message}`, "error");
120
- }
114
+ notifyCtx(ctx, `Phase ${phase.id} sub-agent failed: ${message}`, "error");
121
115
  pi.sendMessage({
122
116
  customType: "codecarto-phase-summary",
123
117
  content: buildPhaseSummary({
@@ -40,6 +40,27 @@ export const PI_SURFACE_ADDENDUM = [
40
40
  `- **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 ")}.`,
41
41
  "- **Ignore \"every tool takes an absolute `cwd`\".** Slash commands act on the session's own directory; there is no `cwd` argument to pass.",
42
42
  "- **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.",
43
+ "",
44
+ "### How to drive a run",
45
+ "",
46
+ "`/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*.",
47
+ "",
48
+ "| Invocation | What it does |",
49
+ "| --- | --- |",
50
+ "| `/codecarto-next` | Runs the next eligible phase, once. Good for watching a single phase or retrying one that stopped. |",
51
+ "| `/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. |",
52
+ "| `/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. |",
53
+ "| `/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. |",
54
+ "",
55
+ "Notes worth passing on when the user asks:",
56
+ "",
57
+ "- **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.",
58
+ "- **Steering costs an extra model call per phase**, on top of the phase sub-agent itself.",
59
+ "- `--strict` is only valid with `--auto`; on its own it is an error.",
60
+ "- `--no-llm-steer` forces steering off for one invocation when the workspace config has it on (`orchestrator.llm_steer_next_phase`, default off).",
61
+ "- **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.",
62
+ "",
63
+ "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.",
43
64
  ].join("\n");
44
65
  /**
45
66
  * Assemble the message /codecarto-guide queues. The guide document is embedded
@@ -9,6 +9,7 @@ import { writeDashboard } from "./dashboard-writer.js";
9
9
  import { parseBroadsideFlags, KNOWN_BROADSIDE_TOKENS } from "./broadside-flags.js";
10
10
  import { parseNextFlags } from "./next-flags.js";
11
11
  import { buildPiGuideMessage } from "./guide-framing.js";
12
+ import { isCtxLive, notifyCtx } from "./notify.js";
12
13
  import { phaseCompactionExtension } from "./phase-compaction.js";
13
14
  import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPathResolved, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listSkillNames, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, } from "../../core/index.js";
14
15
  import { initLibrary } from "../../core/library.js";
@@ -84,40 +85,6 @@ function buildStatusLines(state, extraLines = []) {
84
85
  }
85
86
  return lines;
86
87
  }
87
- /**
88
- * Whether `ctx` still belongs to the live session.
89
- *
90
- * Pi invalidates an extension ctx when the session is replaced, and from then
91
- * on *every* property access on it throws — `ctx.cwd` and `ctx.hasUI` included.
92
- * A phase runs as a sub-agent, so by the time post-phase work fires, the ctx
93
- * captured when the command started may already be dead. That is an ordinary
94
- * outcome rather than an error: the UI it would have refreshed is gone with the
95
- * session. Callers skip their UI work instead of throwing into a `void` call
96
- * that nothing is waiting on.
97
- */
98
- function isCtxLive(ctx) {
99
- try {
100
- return typeof ctx.cwd === "string";
101
- }
102
- catch {
103
- return false;
104
- }
105
- }
106
- /**
107
- * Notify through `ctx`, dropping the message if the session it belonged to is
108
- * gone.
109
- *
110
- * `ctx.hasUI` throws on a stale ctx rather than returning false, so the usual
111
- * `if (ctx.hasUI) ctx.ui.notify(...)` guard was itself a throw site. Inside a
112
- * promise chain that was worse than a lost message: the `.catch` handler threw
113
- * while reporting the original failure, and that second rejection had nothing
114
- * left to catch it.
115
- */
116
- function notifyCtx(ctx, message, level) {
117
- if (!isCtxLive(ctx) || !ctx.hasUI)
118
- return;
119
- ctx.ui.notify(message, level);
120
- }
121
88
  function setUiState(ctx, state, extraLines = []) {
122
89
  if (!isCtxLive(ctx) || !ctx.hasUI)
123
90
  return;
@@ -310,8 +277,8 @@ export default function codeCartographerExtension(pi) {
310
277
  setUiState(ctx, null);
311
278
  // The ctx can die between the read above and here, so the error
312
279
  // path must not assume it is still usable either.
313
- if (notifyOnError && isCtxLive(ctx) && ctx.hasUI)
314
- ctx.ui.notify(message, "error");
280
+ if (notifyOnError)
281
+ notifyCtx(ctx, message, "error");
315
282
  return null;
316
283
  }
317
284
  };
@@ -370,8 +337,7 @@ export default function codeCartographerExtension(pi) {
370
337
  if (!(await pathExists(workspaceDir)))
371
338
  return undefined;
372
339
  if (event.toolName === "bash") {
373
- if (ctx.hasUI)
374
- ctx.ui.notify("Blocked bash in CodeCartographer mode", "warning");
340
+ notifyCtx(ctx, "Blocked bash in CodeCartographer mode", "warning");
375
341
  return { block: true, reason: "CodeCartographer mode disables bash to keep source analysis read-only." };
376
342
  }
377
343
  if (event.toolName === "edit" || event.toolName === "write") {
@@ -385,9 +351,7 @@ export default function codeCartographerExtension(pi) {
385
351
  }
386
352
  const withinAllowed = await Promise.all(allowedRoots.map((allowedRoot) => isWithinPathResolved(targetPath, allowedRoot)));
387
353
  if (!withinAllowed.some((result) => result)) {
388
- if (ctx.hasUI) {
389
- ctx.ui.notify(`Blocked ${event.toolName} outside .codecarto/ or configured library: ${inputPath}`, "warning");
390
- }
354
+ notifyCtx(ctx, `Blocked ${event.toolName} outside .codecarto/ or configured library: ${inputPath}`, "warning");
391
355
  return { block: true, reason: `CodeCartographer mode only allows ${event.toolName} within .codecarto/ or the configured CodeCartographer library.` };
392
356
  }
393
357
  }
@@ -398,7 +362,7 @@ export default function codeCartographerExtension(pi) {
398
362
  handler: async (_args, ctx) => {
399
363
  const workspaceDir = join(ctx.cwd, ".codecarto");
400
364
  if (!(await pathExists(join(workspaceDir, "workflow", "status.yaml")))) {
401
- ctx.ui.notify("No existing CodeCartographer workspace found. Run /codecarto-init first.", "warning");
365
+ notifyCtx(ctx, "No existing CodeCartographer workspace found. Run /codecarto-init first.", "warning");
402
366
  return;
403
367
  }
404
368
  try {
@@ -407,11 +371,11 @@ export default function codeCartographerExtension(pi) {
407
371
  lastFeedbackLines = [`Opened existing workspace: ${getPipelineLabel(state.status.pipeline)}`];
408
372
  pi.setActiveTools(SAFE_TOOL_NAMES);
409
373
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
410
- ctx.ui.notify("Opened existing CodeCartographer workspace without resetting state.", "info");
374
+ notifyCtx(ctx, "Opened existing CodeCartographer workspace without resetting state.", "info");
411
375
  }
412
376
  catch (error) {
413
377
  const message = error instanceof Error ? error.message : String(error);
414
- ctx.ui.notify(`Unable to open CodeCartographer workspace: ${message}`, "error");
378
+ notifyCtx(ctx, `Unable to open CodeCartographer workspace: ${message}`, "error");
415
379
  }
416
380
  },
417
381
  });
@@ -420,7 +384,7 @@ export default function codeCartographerExtension(pi) {
420
384
  handler: async (_args, ctx) => {
421
385
  const interviewPath = join(ctx.cwd, ".codecarto", "findings", "vision-capture", "INTERVIEW.md");
422
386
  if (!(await pathExists(interviewPath))) {
423
- ctx.ui.notify("Vision interview skill not found. Run /codecarto-init synthesis first.", "warning");
387
+ notifyCtx(ctx, "Vision interview skill not found. Run /codecarto-init synthesis first.", "warning");
424
388
  return;
425
389
  }
426
390
  const interviewSkill = await readFile(interviewPath, "utf8");
@@ -442,7 +406,7 @@ export default function codeCartographerExtension(pi) {
442
406
  pi.sendUserMessage(prompt, { deliverAs: "followUp" });
443
407
  }
444
408
  lastFeedbackLines = ["Vision interview started — answer the questions in chat."];
445
- ctx.ui.notify("Vision interview queued — answer the questions in the chat.", "info");
409
+ notifyCtx(ctx, "Vision interview queued — answer the questions in the chat.", "info");
446
410
  },
447
411
  });
448
412
  pi.registerCommand("codecarto-init", {
@@ -457,13 +421,13 @@ export default function codeCartographerExtension(pi) {
457
421
  const trimmedArgs = args.trim();
458
422
  const pipelineChoice = resolvePipelineChoice(trimmedArgs);
459
423
  if (trimmedArgs && !pipelineChoice) {
460
- ctx.ui.notify(`Unknown pipeline: ${trimmedArgs}`, "error");
424
+ notifyCtx(ctx, `Unknown pipeline: ${trimmedArgs}`, "error");
461
425
  return;
462
426
  }
463
427
  const targetWorkspaceDir = join(ctx.cwd, ".codecarto");
464
428
  const sourceWorkspaceDir = packagedWorkspaceDir;
465
429
  if (!(await pathExists(sourceWorkspaceDir))) {
466
- ctx.ui.notify("Packaged .codecarto assets are missing.", "error");
430
+ notifyCtx(ctx, "Packaged .codecarto assets are missing.", "error");
467
431
  return;
468
432
  }
469
433
  const targetExists = await pathExists(targetWorkspaceDir);
@@ -475,8 +439,7 @@ export default function codeCartographerExtension(pi) {
475
439
  return;
476
440
  const backupDir = join(ctx.cwd, `.codecarto-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`);
477
441
  await rename(targetWorkspaceDir, backupDir);
478
- if (ctx.hasUI)
479
- ctx.ui.notify(`Backed up existing workspace to ${basename(backupDir)}/`, "info");
442
+ notifyCtx(ctx, `Backed up existing workspace to ${basename(backupDir)}/`, "info");
480
443
  }
481
444
  }
482
445
  if (!(await pathExists(targetWorkspaceDir))) {
@@ -488,7 +451,7 @@ export default function codeCartographerExtension(pi) {
488
451
  const selectedPipelinePath = pipelineChoice ?? rawStatus.pipeline?.trim() ?? DEFAULT_PIPELINE_PATH;
489
452
  const resolvedPipelinePath = join(targetWorkspaceDir, selectedPipelinePath);
490
453
  if (!(await pathExists(resolvedPipelinePath))) {
491
- ctx.ui.notify(`Pipeline not found: ${selectedPipelinePath}`, "error");
454
+ notifyCtx(ctx, `Pipeline not found: ${selectedPipelinePath}`, "error");
492
455
  return;
493
456
  }
494
457
  const pipeline = await loadYamlFile(resolvedPipelinePath);
@@ -499,8 +462,14 @@ export default function codeCartographerExtension(pi) {
499
462
  // duties maintain so they exist from the first phase.
500
463
  await seedOrchestratorFiles(targetWorkspaceDir);
501
464
  codecartoModeActive = true;
502
- lastFeedbackLines = [`Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`];
503
- ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)})`, "info");
465
+ // Name the run command here: init is the moment someone needs it, and
466
+ // the flags that make a full run useful are not guessable from the
467
+ // command name alone.
468
+ lastFeedbackLines = [
469
+ `Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`,
470
+ "Full run: `/codecarto-next --auto --llm-steer` — or `/codecarto-next` to watch one phase first.",
471
+ ];
472
+ notifyCtx(ctx, `Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)}). Full run: /codecarto-next --auto --llm-steer`, "info");
504
473
  // Render the initial dashboard (empty usage, all phases pending) so
505
474
  // the user sees the file exist immediately after /codecarto-init.
506
475
  void writeDashboard(ctx.cwd, PACKAGE_VERSION);
@@ -518,7 +487,7 @@ export default function codeCartographerExtension(pi) {
518
487
  const nextPhase = getNextEligiblePhase(state)?.id ?? "complete";
519
488
  lastFeedbackLines = [`Current phase: ${nextPhase}`, `Pipeline: ${getPipelineLabel(state.status.pipeline)}`];
520
489
  setUiState(ctx, state, lastFeedbackLines);
521
- ctx.ui.notify(`CodeCartographer phase: ${nextPhase}`, "info");
490
+ notifyCtx(ctx, `CodeCartographer phase: ${nextPhase}`, "info");
522
491
  },
523
492
  });
524
493
  pi.registerCommand("codecarto-switch-pipeline", {
@@ -532,12 +501,12 @@ export default function codeCartographerExtension(pi) {
532
501
  handler: async (args, ctx) => {
533
502
  const trimmedArgs = args.trim();
534
503
  if (!trimmedArgs) {
535
- ctx.ui.notify("Usage: /codecarto-switch-pipeline <variant> (e.g. lite, full, synthesis)", "warning");
504
+ notifyCtx(ctx, "Usage: /codecarto-switch-pipeline <variant> (e.g. lite, full, synthesis)", "warning");
536
505
  return;
537
506
  }
538
507
  const pipelineChoice = resolvePipelineChoice(trimmedArgs);
539
508
  if (!pipelineChoice) {
540
- ctx.ui.notify(`Unknown pipeline: ${trimmedArgs}`, "error");
509
+ notifyCtx(ctx, `Unknown pipeline: ${trimmedArgs}`, "error");
541
510
  return;
542
511
  }
543
512
  const state = await ensureWorkspaceState(ctx);
@@ -545,7 +514,7 @@ export default function codeCartographerExtension(pi) {
545
514
  return;
546
515
  const currentPipeline = state.status.pipeline;
547
516
  if (currentPipeline === pipelineChoice) {
548
- ctx.ui.notify(`Already on pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
517
+ notifyCtx(ctx, `Already on pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
549
518
  return;
550
519
  }
551
520
  try {
@@ -561,40 +530,52 @@ export default function codeCartographerExtension(pi) {
561
530
  lines.push(`Phases not in new pipeline: ${result.dropped.join(", ")} (findings remain on disk)`);
562
531
  lastFeedbackLines = lines;
563
532
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
564
- ctx.ui.notify(`Switched to pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
533
+ notifyCtx(ctx, `Switched to pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
565
534
  void writeDashboard(ctx.cwd, PACKAGE_VERSION);
566
535
  }
567
536
  catch (error) {
568
537
  const message = error instanceof Error ? error.message : String(error);
569
538
  lastFeedbackLines = [message];
570
539
  setUiState(ctx, state, lastFeedbackLines);
571
- ctx.ui.notify(message, "error");
540
+ notifyCtx(ctx, message, "error");
572
541
  }
573
542
  },
574
543
  });
575
544
  pi.registerCommand("codecarto-next", {
576
- description: "Run the next eligible CodeCartographer phase as a sub-agent. Flags: --llm-steer / --no-llm-steer / --auto [--strict]",
545
+ description: "Run the next phase as a sub-agent. Full run: --auto --llm-steer. Add --strict to stop on PASS WITH GAPS.",
577
546
  getArgumentCompletions: (prefix) => {
578
- const items = ["--llm-steer", "--no-llm-steer", "--auto", "--strict"]
579
- .filter((value) => value.startsWith(prefix))
580
- .map((value) => ({ value, label: value }));
547
+ // Descriptions, not bare flag names: the completion list is the only
548
+ // place most users will ever see what these do, and the useful
549
+ // combination (--auto --llm-steer) is not guessable from the names.
550
+ // --strict is offered only once --auto is present, because on its own
551
+ // it is rejected — suggesting it standalone invites the one error the
552
+ // parser has.
553
+ const autoAlreadyTyped = prefix.includes("--auto");
554
+ const items = [
555
+ { value: "--auto", label: "--auto", description: "run every remaining phase back to back (recommended with --llm-steer)" },
556
+ { value: "--llm-steer", label: "--llm-steer", description: "seed each phase from the previous phase's closeout; no effect on the first phase" },
557
+ { value: "--no-llm-steer", label: "--no-llm-steer", description: "force steering off when the workspace config turns it on" },
558
+ ...(autoAlreadyTyped
559
+ ? [{ value: "--strict", label: "--strict", description: "with --auto: stop on PASS WITH GAPS instead of advancing" }]
560
+ : []),
561
+ ].filter((item) => item.value.startsWith(prefix.split(/\s+/).pop() ?? prefix));
581
562
  return items.length > 0 ? items : null;
582
563
  },
583
564
  handler: async (args, ctx) => {
584
565
  const flags = parseNextFlags(args);
585
566
  if (flags.error) {
586
- ctx.ui.notify(flags.error, "error");
567
+ notifyCtx(ctx, flags.error, "error");
587
568
  return;
588
569
  }
589
570
  if (flags.unknown.length > 0) {
590
- ctx.ui.notify(`Unknown /codecarto-next flag: ${flags.unknown.join(" ")}`, "error");
571
+ notifyCtx(ctx, `Unknown /codecarto-next flag: ${flags.unknown.join(" ")}`, "error");
591
572
  return;
592
573
  }
593
574
  const state = await ensureWorkspaceState(ctx);
594
575
  if (!state)
595
576
  return;
596
577
  if (flags.auto) {
597
- ctx.ui.notify(`Auto pipeline${flags.strict ? " (strict)" : ""} running…`, "info");
578
+ notifyCtx(ctx, `Auto pipeline${flags.strict ? " (strict)" : ""} running…`, "info");
598
579
  const result = await runAuto(ctx, pi, state, {
599
580
  strict: flags.strict,
600
581
  llmSteerOverride: flags.llmSteerOverride,
@@ -617,14 +598,14 @@ export default function codeCartographerExtension(pi) {
617
598
  });
618
599
  lastFeedbackLines = [`Auto pipeline ${result.outcome}: ${result.reason}`];
619
600
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
620
- ctx.ui.notify(`Auto pipeline ${result.outcome}: ${result.phasesRun.length}/${result.totalPhases} phases.`, result.outcome === "complete" ? "info" : "warning");
601
+ notifyCtx(ctx, `Auto pipeline ${result.outcome}: ${result.phasesRun.length}/${result.totalPhases} phases.`, result.outcome === "complete" ? "info" : "warning");
621
602
  return;
622
603
  }
623
604
  const phase = getNextEligiblePhase(state);
624
605
  if (!phase) {
625
606
  lastFeedbackLines = ["All phases complete."];
626
607
  setUiState(ctx, state, lastFeedbackLines);
627
- ctx.ui.notify("All CodeCartographer phases are complete.", "info");
608
+ notifyCtx(ctx, "All CodeCartographer phases are complete.", "info");
628
609
  return;
629
610
  }
630
611
  let preflight;
@@ -635,13 +616,13 @@ export default function codeCartographerExtension(pi) {
635
616
  const message = error instanceof Error ? error.message : String(error);
636
617
  lastFeedbackLines = [message];
637
618
  setUiState(ctx, state, lastFeedbackLines);
638
- ctx.ui.notify(message, error instanceof PhasePreflightError ? "warning" : "error");
619
+ notifyCtx(ctx, message, error instanceof PhasePreflightError ? "warning" : "error");
639
620
  return;
640
621
  }
641
622
  // Reject re-entry: don't spawn a duplicate runner for a phase that's
642
623
  // already in flight from a previous /codecarto-next invocation.
643
624
  if (isPhaseRunning(phase.id)) {
644
- ctx.ui.notify(`Phase ${phase.id} is already running.`, "warning");
625
+ notifyCtx(ctx, `Phase ${phase.id} is already running.`, "warning");
645
626
  return;
646
627
  }
647
628
  const config = await loadCodecartoConfig(state.workspaceDir);
@@ -717,7 +698,7 @@ export default function codeCartographerExtension(pi) {
717
698
  handler: async (args, ctx) => {
718
699
  const phaseId = args.trim();
719
700
  if (!phaseId) {
720
- ctx.ui.notify("Usage: /codecarto-phase <phase>", "warning");
701
+ notifyCtx(ctx, "Usage: /codecarto-phase <phase>", "warning");
721
702
  return;
722
703
  }
723
704
  const state = await ensureWorkspaceState(ctx);
@@ -725,7 +706,7 @@ export default function codeCartographerExtension(pi) {
725
706
  return;
726
707
  const phase = resolvePhase(state, phaseId);
727
708
  if (!phase) {
728
- ctx.ui.notify(`Unknown phase: ${phaseId}`, "error");
709
+ notifyCtx(ctx, `Unknown phase: ${phaseId}`, "error");
729
710
  return;
730
711
  }
731
712
  let prompt;
@@ -734,7 +715,7 @@ export default function codeCartographerExtension(pi) {
734
715
  }
735
716
  catch (error) {
736
717
  const message = error instanceof Error ? error.message : String(error);
737
- ctx.ui.notify(message, error instanceof PhasePreflightError ? "warning" : "error");
718
+ notifyCtx(ctx, message, error instanceof PhasePreflightError ? "warning" : "error");
738
719
  return;
739
720
  }
740
721
  if (ctx.isIdle()) {
@@ -745,7 +726,7 @@ export default function codeCartographerExtension(pi) {
745
726
  }
746
727
  lastFeedbackLines = [`Queued explicit phase prompt for ${phase.id}`];
747
728
  setUiState(ctx, state, lastFeedbackLines);
748
- ctx.ui.notify(`Queued CodeCartographer phase: ${phase.id}`, "info");
729
+ notifyCtx(ctx, `Queued CodeCartographer phase: ${phase.id}`, "info");
749
730
  },
750
731
  });
751
732
  pi.registerCommand("codecarto-validate", {
@@ -758,13 +739,13 @@ export default function codeCartographerExtension(pi) {
758
739
  if (validation instanceof Error) {
759
740
  lastFeedbackLines = [validation.message];
760
741
  setUiState(ctx, state, lastFeedbackLines);
761
- ctx.ui.notify(validation.message, "error");
742
+ notifyCtx(ctx, validation.message, "error");
762
743
  return;
763
744
  }
764
745
  lastFeedbackLines = buildValidationSummary(validation);
765
746
  setUiState(ctx, state, lastFeedbackLines);
766
747
  const level = validation.overall === "FAIL" || validation.overall === "MISSING" ? "error" : validation.overall === "PASS WITH GAPS" ? "warning" : "info";
767
- ctx.ui.notify(`Validation ${validation.phaseId}: ${validation.overall}`, level);
748
+ notifyCtx(ctx, `Validation ${validation.phaseId}: ${validation.overall}`, level);
768
749
  },
769
750
  });
770
751
  pi.registerCommand("codecarto-complete", {
@@ -777,13 +758,13 @@ export default function codeCartographerExtension(pi) {
777
758
  if (validation instanceof Error) {
778
759
  lastFeedbackLines = [validation.message];
779
760
  setUiState(ctx, currentState, lastFeedbackLines);
780
- ctx.ui.notify(validation.message, "error");
761
+ notifyCtx(ctx, validation.message, "error");
781
762
  return;
782
763
  }
783
764
  if (validation.overall === "FAIL" || validation.overall === "MISSING") {
784
765
  lastFeedbackLines = buildValidationSummary(validation);
785
766
  setUiState(ctx, currentState, lastFeedbackLines);
786
- ctx.ui.notify(`Cannot complete ${validation.phaseId}: ${validation.overall}`, "error");
767
+ notifyCtx(ctx, `Cannot complete ${validation.phaseId}: ${validation.overall}`, "error");
787
768
  return;
788
769
  }
789
770
  // Completion refuses for reasons the framework words carefully — a
@@ -801,7 +782,7 @@ export default function codeCartographerExtension(pi) {
801
782
  const message = error instanceof Error ? error.message : String(error);
802
783
  lastFeedbackLines = [`Completion refused: ${message}`];
803
784
  setUiState(ctx, currentState, lastFeedbackLines);
804
- ctx.ui.notify(message, "error");
785
+ notifyCtx(ctx, message, "error");
805
786
  return;
806
787
  }
807
788
  const { updatedState, closeoutNotice, warnings } = completion;
@@ -816,11 +797,11 @@ export default function codeCartographerExtension(pi) {
816
797
  for (const note of notes)
817
798
  lastFeedbackLines.push(`NOTE: ${note} Non-gating.`);
818
799
  setUiState(ctx, updatedState, lastFeedbackLines);
819
- ctx.ui.notify(`Marked ${validation.phaseId} complete`, validation.overall === "PASS WITH GAPS" || notes.length > 0 ? "warning" : "info");
800
+ notifyCtx(ctx, `Marked ${validation.phaseId} complete`, validation.overall === "PASS WITH GAPS" || notes.length > 0 ? "warning" : "info");
820
801
  if (closeoutNotice)
821
- ctx.ui.notify(closeoutNotice, "info");
802
+ notifyCtx(ctx, closeoutNotice, "info");
822
803
  for (const note of notes)
823
- ctx.ui.notify(note, "warning");
804
+ notifyCtx(ctx, note, "warning");
824
805
  },
825
806
  });
826
807
  pi.registerCommand("codecarto-skill", {
@@ -830,7 +811,7 @@ export default function codeCartographerExtension(pi) {
830
811
  if (!skillName) {
831
812
  const available = await listSkillNames(join(ctx.cwd, ".codecarto"));
832
813
  const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : "";
833
- ctx.ui.notify(`Usage: /codecarto-skill <name>${hint}`, "warning");
814
+ notifyCtx(ctx, `Usage: /codecarto-skill <name>${hint}`, "warning");
834
815
  return;
835
816
  }
836
817
  // Broad-Side is a reading guide for batch reconnaissance output, not a
@@ -840,7 +821,7 @@ export default function codeCartographerExtension(pi) {
840
821
  if (skillName === BROADSIDE_SKILL_NAME) {
841
822
  const skill = await readBroadsideSkill(ctx.cwd).catch(() => null);
842
823
  if (!skill) {
843
- ctx.ui.notify("Broad-Side reading guide not found. Reinstall codecartographer-pi.", "error");
824
+ notifyCtx(ctx, "Broad-Side reading guide not found. Reinstall codecartographer-pi.", "error");
844
825
  return;
845
826
  }
846
827
  const message = [
@@ -854,7 +835,7 @@ export default function codeCartographerExtension(pi) {
854
835
  else {
855
836
  pi.sendUserMessage(message, { deliverAs: "followUp" });
856
837
  }
857
- ctx.ui.notify("Queued the Broad-Side reading guide", "info");
838
+ notifyCtx(ctx, "Queued the Broad-Side reading guide", "info");
858
839
  return;
859
840
  }
860
841
  const state = await ensureWorkspaceState(ctx);
@@ -862,14 +843,14 @@ export default function codeCartographerExtension(pi) {
862
843
  return;
863
844
  const nextPhase = getNextEligiblePhase(state);
864
845
  if (nextPhase) {
865
- ctx.ui.notify(`Cannot run skill: pipeline is not complete (next phase: ${nextPhase.id}). Finish the pipeline before running post-pipeline skills.`, "error");
846
+ notifyCtx(ctx, `Cannot run skill: pipeline is not complete (next phase: ${nextPhase.id}). Finish the pipeline before running post-pipeline skills.`, "error");
866
847
  return;
867
848
  }
868
849
  const skillFile = join(state.workspaceDir, "skills", skillName, "SKILL.md");
869
850
  if (!(await pathExists(skillFile))) {
870
851
  const available = await listSkillNames(state.workspaceDir);
871
852
  const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : " (no skills installed)";
872
- ctx.ui.notify(`Unknown skill: ${skillName}${hint}. The Broad-Side reading guide is served as \`${BROADSIDE_SKILL_NAME}\` and is not pipeline-gated.`, "error");
853
+ notifyCtx(ctx, `Unknown skill: ${skillName}${hint}. The Broad-Side reading guide is served as \`${BROADSIDE_SKILL_NAME}\` and is not pipeline-gated.`, "error");
873
854
  return;
874
855
  }
875
856
  const prompt = await buildSkillPrompt(state, skillName);
@@ -881,7 +862,7 @@ export default function codeCartographerExtension(pi) {
881
862
  }
882
863
  lastFeedbackLines = [`Queued post-pipeline skill: ${skillName}`];
883
864
  setUiState(ctx, state, lastFeedbackLines);
884
- ctx.ui.notify(`Queued CodeCartographer skill: ${skillName}`, "info");
865
+ notifyCtx(ctx, `Queued CodeCartographer skill: ${skillName}`, "info");
885
866
  },
886
867
  });
887
868
  pi.registerCommand("codecarto-list-skills", {
@@ -910,7 +891,7 @@ export default function codeCartographerExtension(pi) {
910
891
  }
911
892
  lastFeedbackLines = lines;
912
893
  setUiState(ctx, state, lastFeedbackLines);
913
- ctx.ui.notify(skills.length > 0
894
+ notifyCtx(ctx, skills.length > 0
914
895
  ? `${skills.length} post-pipeline skill${skills.length === 1 ? "" : "s"}: ${skills.join(", ")}`
915
896
  : "No post-pipeline skills installed.", "info");
916
897
  },
@@ -934,7 +915,7 @@ export default function codeCartographerExtension(pi) {
934
915
  document = await readGuide(args.trim() || undefined);
935
916
  }
936
917
  catch (error) {
937
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
918
+ notifyCtx(ctx, error instanceof Error ? error.message : String(error), "error");
938
919
  return;
939
920
  }
940
921
  const other = topics.filter((name) => name !== document.topic);
@@ -951,7 +932,7 @@ export default function codeCartographerExtension(pi) {
951
932
  lastFeedbackLines = [`Queued the CodeCartographer guide: ${document.topic}`];
952
933
  if (codecartoModeActive)
953
934
  void refreshWorkspaceUi(ctx, lastFeedbackLines).catch(() => undefined);
954
- ctx.ui.notify(`Queued the CodeCartographer guide (${document.topic})`, "info");
935
+ notifyCtx(ctx, `Queued the CodeCartographer guide (${document.topic})`, "info");
955
936
  },
956
937
  });
957
938
  pi.registerCommand("codecarto-broadside", {
@@ -965,12 +946,12 @@ export default function codeCartographerExtension(pi) {
965
946
  handler: async (args, ctx) => {
966
947
  const flags = parseBroadsideFlags(args);
967
948
  if (flags.unknown.length > 0) {
968
- ctx.ui.notify(`Unknown /codecarto-broadside argument: ${flags.unknown.join(" ")}. ` +
949
+ notifyCtx(ctx, `Unknown /codecarto-broadside argument: ${flags.unknown.join(" ")}. ` +
969
950
  `Actions: submit, collect, status, models. Lenses: ${BROADSIDE_LENS_IDS.join(", ")}.`, "error");
970
951
  return;
971
952
  }
972
953
  if (flags.error) {
973
- ctx.ui.notify(flags.error, "error");
954
+ notifyCtx(ctx, flags.error, "error");
974
955
  return;
975
956
  }
976
957
  // Broad-Side runs on any git repository, with or without a workspace —
@@ -990,7 +971,7 @@ export default function codeCartographerExtension(pi) {
990
971
  // the result can live, so it holds it instead of being cleared.
991
972
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, ["Broad-Side", ...lines]);
992
973
  }
993
- ctx.ui.notify(notice, level);
974
+ notifyCtx(ctx, notice, level);
994
975
  };
995
976
  if (flags.action === "status") {
996
977
  const { state } = await runBroadsideStatus(ctx.cwd);
@@ -1000,12 +981,12 @@ export default function codeCartographerExtension(pi) {
1000
981
  }
1001
982
  const apiKey = resolveBroadsideKey(config.apiKey);
1002
983
  if (!apiKey) {
1003
- ctx.ui.notify("No OpenRouter API key. Set OPENROUTER_API_KEY in the environment, or add api_key to " +
984
+ notifyCtx(ctx, "No OpenRouter API key. Set OPENROUTER_API_KEY in the environment, or add api_key to " +
1004
985
  ".codecarto/broadside/config.yaml. (A slash command takes no key: it would land in the transcript.)", "error");
1005
986
  return;
1006
987
  }
1007
988
  if (flags.action === "models") {
1008
- ctx.ui.notify("Fetching the OpenRouter batch-model catalog…", "info");
989
+ notifyCtx(ctx, "Fetching the OpenRouter batch-model catalog…", "info");
1009
990
  try {
1010
991
  const { entries, benchmarks } = await listBatchModels(broadsideDir, config, apiKey, {
1011
992
  includeBenchmarks: flags.benchmarks,
@@ -1013,7 +994,7 @@ export default function codeCartographerExtension(pi) {
1013
994
  finish(modelsText(entries, { benchmarks, defaultModel: config.model }).split("\n"), `Broad-Side: ${entries.length} batch model${entries.length === 1 ? "" : "s"} listed`);
1014
995
  }
1015
996
  catch (error) {
1016
- ctx.ui.notify(`Model catalog lookup failed: ${error instanceof Error ? error.message : String(error)}`, "error");
997
+ notifyCtx(ctx, `Model catalog lookup failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1017
998
  }
1018
999
  return;
1019
1000
  }
@@ -1059,10 +1040,10 @@ export default function codeCartographerExtension(pi) {
1059
1040
  if (ctx.hasUI)
1060
1041
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1061
1042
  if (error instanceof BroadsideCancelledError) {
1062
- ctx.ui.notify("Broad-Side cancelled. Nothing was submitted.", "info");
1043
+ notifyCtx(ctx, "Broad-Side cancelled. Nothing was submitted.", "info");
1063
1044
  return;
1064
1045
  }
1065
- ctx.ui.notify(`Broad-Side submit failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1046
+ notifyCtx(ctx, `Broad-Side submit failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1066
1047
  return;
1067
1048
  }
1068
1049
  const lines = estimateSubmitText(submit, lenses.map(getLens)).split("\n");
@@ -1071,7 +1052,7 @@ export default function codeCartographerExtension(pi) {
1071
1052
  finish(lines, `Broad-Side submitted: run ${submit.runId} (~$${submit.estimatedTotalCost.toFixed(4)})`);
1072
1053
  return;
1073
1054
  }
1074
- ctx.ui.notify(`Broad-Side submitted run ${submit.runId}; polling for up to ${waitSeconds}s…`, "info");
1055
+ notifyCtx(ctx, `Broad-Side submitted run ${submit.runId}; polling for up to ${waitSeconds}s…`, "info");
1075
1056
  try {
1076
1057
  const collect = await runBroadsideCollect(ctx.cwd, apiKey, {
1077
1058
  waitMs,
@@ -1087,7 +1068,7 @@ export default function codeCartographerExtension(pi) {
1087
1068
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1088
1069
  // The batches are submitted and paid for either way — say so, so
1089
1070
  // nobody re-submits a run that is already in flight.
1090
- ctx.ui.notify(`Broad-Side submitted run ${submit.runId}, but collect failed: ` +
1071
+ notifyCtx(ctx, `Broad-Side submitted run ${submit.runId}, but collect failed: ` +
1091
1072
  `${error instanceof Error ? error.message : String(error)}. Retry with /codecarto-broadside collect.`, "error");
1092
1073
  }
1093
1074
  return;
@@ -1111,7 +1092,7 @@ export default function codeCartographerExtension(pi) {
1111
1092
  catch (error) {
1112
1093
  if (ctx.hasUI)
1113
1094
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1114
- ctx.ui.notify(`Broad-Side collect failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1095
+ notifyCtx(ctx, `Broad-Side collect failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1115
1096
  }
1116
1097
  },
1117
1098
  });
@@ -1123,22 +1104,22 @@ export default function codeCartographerExtension(pi) {
1123
1104
  return;
1124
1105
  const config = await loadCodecartoConfig(state.workspaceDir);
1125
1106
  if (!config.library.path) {
1126
- ctx.ui.notify("No library.path is configured. Create a library directory with a .codecarto-library marker, then set library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml.", "error");
1107
+ notifyCtx(ctx, "No library.path is configured. Create a library directory with a .codecarto-library marker, then set library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml.", "error");
1127
1108
  return;
1128
1109
  }
1129
1110
  const marker = await discoverLibrary(config.library.path);
1130
1111
  if (!marker) {
1131
- ctx.ui.notify(`No CodeCartographer library at ${config.library.path} (missing .codecarto-library). Create a .codecarto-library marker file in that directory.`, "error");
1112
+ notifyCtx(ctx, `No CodeCartographer library at ${config.library.path} (missing .codecarto-library). Create a .codecarto-library marker file in that directory.`, "error");
1132
1113
  return;
1133
1114
  }
1134
1115
  const phase = resolvePhase(state, "reimplementation-spec");
1135
1116
  if (!phase?.primary_output) {
1136
- ctx.ui.notify("The active pipeline does not produce a reimplementation spec to publish.", "error");
1117
+ notifyCtx(ctx, "The active pipeline does not produce a reimplementation spec to publish.", "error");
1137
1118
  return;
1138
1119
  }
1139
1120
  const specPath = join(state.workspaceDir, phase.primary_output);
1140
1121
  if (!(await pathExists(specPath))) {
1141
- ctx.ui.notify(`Reimplementation spec is missing: .codecarto/${phase.primary_output}`, "error");
1122
+ notifyCtx(ctx, `Reimplementation spec is missing: .codecarto/${phase.primary_output}`, "error");
1142
1123
  return;
1143
1124
  }
1144
1125
  const spec = await readFile(specPath, "utf8");
@@ -1149,7 +1130,7 @@ export default function codeCartographerExtension(pi) {
1149
1130
  const headline = derivePublishHeadline(spec, ctx.cwd);
1150
1131
  const namespace = marker.namespaced ? config.library.namespace ?? undefined : undefined;
1151
1132
  if (marker.namespaced && !namespace) {
1152
- ctx.ui.notify("The configured library is namespaced; set library.namespace before publishing.", "error");
1133
+ notifyCtx(ctx, "The configured library is namespaced; set library.namespace before publishing.", "error");
1153
1134
  return;
1154
1135
  }
1155
1136
  const label = `${namespace ? `${namespace}/` : ""}${slug}`;
@@ -1194,7 +1175,7 @@ export default function codeCartographerExtension(pi) {
1194
1175
  // recorded the directory to one that records the git remote.
1195
1176
  const moved = await ctx.ui.confirm("Source repository changed — did it move?", `Library entry ${label} records source_repo "${error.recorded}", but this publish carries "${error.incoming}". If the repository genuinely moved (rename, org transfer, host change — or this is the first publish since CodeCartographer began recording the git remote instead of the local directory), answer yes and this spec is appended as the entry's next version. If these are two different projects that share a directory name, answer no: nothing is written, and the second project needs a distinct slug (codecarto_publish on MCP accepts one). Did the repository move?`);
1196
1177
  if (!moved) {
1197
- ctx.ui.notify("Publish cancelled. Nothing was written.", "info");
1178
+ notifyCtx(ctx, "Publish cancelled. Nothing was written.", "info");
1198
1179
  return;
1199
1180
  }
1200
1181
  options.allowSourceRepoChange = true;
@@ -1204,7 +1185,7 @@ export default function codeCartographerExtension(pi) {
1204
1185
  // default; whether it may go into a wider library is the user's call.
1205
1186
  const publishAnyway = await ctx.ui.confirm("Confidentiality mismatch — publish anyway?", `This spec's confidentiality is "${error.entryConfidentiality}" (CodeCartographer's default; /codecarto-publish declares none), but the library "${marker.name}" has visibility "${error.libraryVisibility}". Publishing would expose it to everyone that library reaches. Publish anyway?`);
1206
1187
  if (!publishAnyway) {
1207
- ctx.ui.notify("Publish cancelled. Nothing was written.", "info");
1188
+ notifyCtx(ctx, "Publish cancelled. Nothing was written.", "info");
1208
1189
  return;
1209
1190
  }
1210
1191
  options.allowConfidentialityMismatch = true;
@@ -1217,11 +1198,11 @@ export default function codeCartographerExtension(pi) {
1217
1198
  lastFeedbackLines = [`Published ${result.namespace ? `${result.namespace}/` : ""}${result.slug} v${result.version}`, result.isNewVersion ? "New content version." : "Metadata-only update (content unchanged)."];
1218
1199
  await writeDashboard(ctx.cwd, PACKAGE_VERSION);
1219
1200
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
1220
- ctx.ui.notify(`Published ${result.namespace ? `${result.namespace}/` : ""}${result.slug} v${result.version}.`, "info");
1201
+ notifyCtx(ctx, `Published ${result.namespace ? `${result.namespace}/` : ""}${result.slug} v${result.version}.`, "info");
1221
1202
  }
1222
1203
  catch (error) {
1223
1204
  const message = error instanceof Error ? error.message : String(error);
1224
- ctx.ui.notify(`Unable to publish: ${message}`, "error");
1205
+ notifyCtx(ctx, `Unable to publish: ${message}`, "error");
1225
1206
  }
1226
1207
  },
1227
1208
  });
@@ -1233,7 +1214,7 @@ export default function codeCartographerExtension(pi) {
1233
1214
  const namespaceIdx = parts.indexOf("--namespace");
1234
1215
  const namespace = namespaceIdx >= 0 ? parts[namespaceIdx + 1] : null;
1235
1216
  if (!pathArg) {
1236
- ctx.ui.notify("Usage: /codecarto-library-init <path> [--namespace <name>]", "warning");
1217
+ notifyCtx(ctx, "Usage: /codecarto-library-init <path> [--namespace <name>]", "warning");
1237
1218
  return;
1238
1219
  }
1239
1220
  const libraryPath = pathArg.startsWith("~") ? join(homedir(), pathArg.slice(1)) : resolve(pathArg);
@@ -1249,14 +1230,12 @@ export default function codeCartographerExtension(pi) {
1249
1230
  ? `Library already exists at ${libraryPath} (marker preserved). Config updated.`
1250
1231
  : `Created library at ${libraryPath} with marker "${result.marker.name}".`;
1251
1232
  lastFeedbackLines = [msg, `Config written to ${configPath}`];
1252
- if (ctx.hasUI) {
1253
- ctx.ui.notify(msg, "info");
1254
- ctx.ui.notify(`Config written to ${configPath}`, "info");
1255
- }
1233
+ notifyCtx(ctx, msg, "info");
1234
+ notifyCtx(ctx, `Config written to ${configPath}`, "info");
1256
1235
  }
1257
1236
  catch (error) {
1258
1237
  const message = error instanceof Error ? error.message : String(error);
1259
- ctx.ui.notify(`Library init failed: ${message}`, "error");
1238
+ notifyCtx(ctx, `Library init failed: ${message}`, "error");
1260
1239
  }
1261
1240
  },
1262
1241
  });
@@ -1282,7 +1261,7 @@ export default function codeCartographerExtension(pi) {
1282
1261
  lastFeedbackLines = lines;
1283
1262
  if (state)
1284
1263
  setUiState(ctx, state, lastFeedbackLines);
1285
- ctx.ui.notify("Configuration shown in status widget.", "info");
1264
+ notifyCtx(ctx, "Configuration shown in status widget.", "info");
1286
1265
  },
1287
1266
  });
1288
1267
  pi.registerCommand("codecarto-usage", {
@@ -1295,7 +1274,7 @@ export default function codeCartographerExtension(pi) {
1295
1274
  if (usage.runs.length === 0) {
1296
1275
  lastFeedbackLines = ["No phase runs recorded yet."];
1297
1276
  setUiState(ctx, state, lastFeedbackLines);
1298
- ctx.ui.notify("No phase runs recorded yet.", "info");
1277
+ notifyCtx(ctx, "No phase runs recorded yet.", "info");
1299
1278
  return;
1300
1279
  }
1301
1280
  const totals = computeTotals(usage);
@@ -1315,7 +1294,7 @@ export default function codeCartographerExtension(pi) {
1315
1294
  }
1316
1295
  lastFeedbackLines = lines;
1317
1296
  setUiState(ctx, state, lastFeedbackLines);
1318
- ctx.ui.notify(`CodeCartographer usage: ${totals.runs} run${totals.runs === 1 ? "" : "s"}, ${formatUsageTokens(totals.tokens.input + totals.tokens.output)} tokens total`, "info");
1297
+ notifyCtx(ctx, `CodeCartographer usage: ${totals.runs} run${totals.runs === 1 ? "" : "s"}, ${formatUsageTokens(totals.tokens.input + totals.tokens.output)} tokens total`, "info");
1319
1298
  },
1320
1299
  });
1321
1300
  pi.registerCommand("codecarto-dashboard", {
@@ -1329,26 +1308,26 @@ export default function codeCartographerExtension(pi) {
1329
1308
  handler: async (args, ctx) => {
1330
1309
  const flags = parseDashboardFlags(args);
1331
1310
  if (flags.unknown.length > 0) {
1332
- ctx.ui.notify(`Unknown /codecarto-dashboard flag: ${flags.unknown.join(" ")}`, "error");
1311
+ notifyCtx(ctx, `Unknown /codecarto-dashboard flag: ${flags.unknown.join(" ")}`, "error");
1333
1312
  return;
1334
1313
  }
1335
1314
  const state = await ensureWorkspaceState(ctx);
1336
1315
  if (!state)
1337
1316
  return;
1338
1317
  if (flags.narrate) {
1339
- ctx.ui.notify(`Narrating dashboard via LLM…`, "info");
1318
+ notifyCtx(ctx, `Narrating dashboard via LLM…`, "info");
1340
1319
  const result = await narrateDashboard(ctx, state);
1341
1320
  if (result.used) {
1342
- ctx.ui.notify("Narration written to .codecarto/.dashboard-narration.local.md", "info");
1321
+ notifyCtx(ctx, "Narration written to .codecarto/.dashboard-narration.local.md", "info");
1343
1322
  }
1344
1323
  else {
1345
- ctx.ui.notify(`LLM narration skipped (${result.skipReason}); rendering deterministic dashboard.`, "warning");
1324
+ notifyCtx(ctx, `LLM narration skipped (${result.skipReason}); rendering deterministic dashboard.`, "warning");
1346
1325
  }
1347
1326
  }
1348
1327
  await writeDashboard(ctx.cwd, PACKAGE_VERSION);
1349
1328
  lastFeedbackLines = ["Dashboard regenerated: .codecarto/dashboard.html"];
1350
1329
  setUiState(ctx, state, lastFeedbackLines);
1351
- ctx.ui.notify("Dashboard regenerated: .codecarto/dashboard.html", "info");
1330
+ notifyCtx(ctx, "Dashboard regenerated: .codecarto/dashboard.html", "info");
1352
1331
  },
1353
1332
  });
1354
1333
  pi.registerCommand("codecarto-refresh-scaffold", {
@@ -1364,12 +1343,12 @@ export default function codeCartographerExtension(pi) {
1364
1343
  files = await listScaffoldRefreshFiles();
1365
1344
  }
1366
1345
  catch (error) {
1367
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1346
+ notifyCtx(ctx, error instanceof Error ? error.message : String(error), "error");
1368
1347
  return;
1369
1348
  }
1370
1349
  const approved = await ctx.ui.confirm("Refresh the .codecarto/ scaffold from the packaged template?", describeScaffoldRefreshPreview(files, state.scaffoldVersion));
1371
1350
  if (!approved) {
1372
- ctx.ui.notify("Scaffold refresh cancelled. Nothing was written.", "info");
1351
+ notifyCtx(ctx, "Scaffold refresh cancelled. Nothing was written.", "info");
1373
1352
  return;
1374
1353
  }
1375
1354
  try {
@@ -1382,13 +1361,13 @@ export default function codeCartographerExtension(pi) {
1382
1361
  ];
1383
1362
  // Re-read state so the widget's staleness line clears with the marker.
1384
1363
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
1385
- ctx.ui.notify(`Refreshed ${result.written.length} framework-owned file(s) (${transition}).`, "info");
1364
+ notifyCtx(ctx, `Refreshed ${result.written.length} framework-owned file(s) (${transition}).`, "info");
1386
1365
  }
1387
1366
  catch (error) {
1388
1367
  const message = error instanceof Error ? error.message : String(error);
1389
1368
  lastFeedbackLines = [message];
1390
1369
  setUiState(ctx, state, lastFeedbackLines);
1391
- ctx.ui.notify(`Scaffold refresh failed: ${message}`, "error");
1370
+ notifyCtx(ctx, `Scaffold refresh failed: ${message}`, "error");
1392
1371
  }
1393
1372
  },
1394
1373
  });
@@ -1410,12 +1389,12 @@ export default function codeCartographerExtension(pi) {
1410
1389
  const hint = staged.length > 0
1411
1390
  ? ` (staged: ${staged.join(", ")})`
1412
1391
  : " — write .codecarto/scratch/amendments/<name>.yaml first (see templates/amendment.yaml)";
1413
- ctx.ui.notify(`Usage: /codecarto-amend <name>${hint}`, "warning");
1392
+ notifyCtx(ctx, `Usage: /codecarto-amend <name>${hint}`, "warning");
1414
1393
  return;
1415
1394
  }
1416
1395
  const name = resolveAmendmentName(args, ctx.cwd);
1417
1396
  if (!name) {
1418
- ctx.ui.notify(`Amendments are read from .codecarto/scratch/amendments/ only; pass the amendment name or a path inside that directory, not ${args.trim()}.`, "error");
1397
+ notifyCtx(ctx, `Amendments are read from .codecarto/scratch/amendments/ only; pass the amendment name or a path inside that directory, not ${args.trim()}.`, "error");
1419
1398
  return;
1420
1399
  }
1421
1400
  // The same refusals codecarto_amend surfaces, raised before the
@@ -1425,12 +1404,12 @@ export default function codeCartographerExtension(pi) {
1425
1404
  amendment = await loadAmendmentFile(name, state.workspaceDir);
1426
1405
  }
1427
1406
  catch (error) {
1428
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1407
+ notifyCtx(ctx, error instanceof Error ? error.message : String(error), "error");
1429
1408
  return;
1430
1409
  }
1431
1410
  const nextPhase = getNextEligiblePhase(state);
1432
1411
  if (nextPhase) {
1433
- ctx.ui.notify(`Cannot amend: the pipeline is not complete (next phase: ${nextPhase.id}). `
1412
+ notifyCtx(ctx, `Cannot amend: the pipeline is not complete (next phase: ${nextPhase.id}). `
1434
1413
  + "Resolve open questions and routed items through that phase's handoff (open_question_closures / carry_forward_closures) instead.", "error");
1435
1414
  return;
1436
1415
  }
@@ -1438,7 +1417,7 @@ export default function codeCartographerExtension(pi) {
1438
1417
  // before anything is written — MCP's codecarto_amend applies on call.
1439
1418
  const approved = await ctx.ui.confirm(`Apply amendment "${amendment.slug}"?`, describeAmendmentPreview(amendment, state));
1440
1419
  if (!approved) {
1441
- ctx.ui.notify(`Amendment ${amendment.slug} cancelled. Nothing was written.`, "info");
1420
+ notifyCtx(ctx, `Amendment ${amendment.slug} cancelled. Nothing was written.`, "info");
1442
1421
  return;
1443
1422
  }
1444
1423
  try {
@@ -1459,14 +1438,14 @@ export default function codeCartographerExtension(pi) {
1459
1438
  lastFeedbackLines = lines;
1460
1439
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
1461
1440
  const closed = applied.openQuestionsClosed.length + applied.postPipelineClosed.length;
1462
- ctx.ui.notify(`Amendment ${amendment.slug} applied: ${applied.openQuestionsClosed.length} open question(s) and ${applied.postPipelineClosed.length} post-pipeline item(s) closed`
1441
+ notifyCtx(ctx, `Amendment ${amendment.slug} applied: ${applied.openQuestionsClosed.length} open question(s) and ${applied.postPipelineClosed.length} post-pipeline item(s) closed`
1463
1442
  + `${applied.unknownIds.length > 0 ? `; ${applied.unknownIds.length} id(s) matched nothing` : ""}.`, closed === 0 || applied.unknownIds.length > 0 ? "warning" : "info");
1464
1443
  }
1465
1444
  catch (error) {
1466
1445
  const message = error instanceof Error ? error.message : String(error);
1467
1446
  lastFeedbackLines = [message];
1468
1447
  setUiState(ctx, state, lastFeedbackLines);
1469
- ctx.ui.notify(`Amendment failed: ${message}`, "error");
1448
+ notifyCtx(ctx, `Amendment failed: ${message}`, "error");
1470
1449
  }
1471
1450
  },
1472
1451
  });
@@ -0,0 +1,16 @@
1
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ export type NotifyLevel = "info" | "warning" | "error";
3
+ /** Whether `ctx` still belongs to the live session. See the module comment. */
4
+ export declare function isCtxLive(ctx: ExtensionContext | ExtensionCommandContext): boolean;
5
+ /**
6
+ * Format a notification for a stream. Exported so tests pin the shape a script
7
+ * would parse: one line, prefixed, level visible.
8
+ */
9
+ export declare function formatStreamNotification(message: string, level: NotifyLevel): string;
10
+ /**
11
+ * Notify through `ctx`: the TUI when there is one, stderr when there is not,
12
+ * nothing when the ctx is stale.
13
+ */
14
+ export declare function notifyCtx(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: NotifyLevel, stream?: {
15
+ write(chunk: string): unknown;
16
+ }): void;
@@ -0,0 +1,53 @@
1
+ // User-facing notifications from the orchestrator side of the Pi extension.
2
+ //
3
+ // Two things go wrong with a bare `ctx.ui.notify(...)`, and this module is the
4
+ // one place that handles both.
5
+ //
6
+ // A stale ctx throws. Pi invalidates an extension ctx when the session is
7
+ // replaced, and from then on *every* property access on it throws — `ctx.cwd`
8
+ // and `ctx.hasUI` included. A phase runs as a sub-agent, so by the time
9
+ // post-phase work fires, the ctx captured when the command started may already
10
+ // be dead. That is an ordinary outcome, not an error: the UI it would have
11
+ // refreshed is gone with the session, so the message is dropped.
12
+ //
13
+ // A non-interactive session shows nothing. Under `pi -p`, `ctx.hasUI` is false
14
+ // and `ctx.ui.notify` is a silent no-op. Commands whose only output is a notify
15
+ // — /codecarto-status, /codecarto-usage, /codecarto-list-skills — then exit 0
16
+ // having printed nothing, which is byte-for-byte what a silent refusal looks
17
+ // like (#219). So when there is no UI, the message goes to stderr instead:
18
+ // stderr rather than stdout because `--mode json` owns stdout for its event
19
+ // stream and prose there would corrupt it.
20
+ //
21
+ // This is for the orchestrator's ctx only. Code that runs inside the phase
22
+ // sub-agent (phase-compaction.ts) also sees `hasUI === false`, but there the
23
+ // parent TUI may be on screen, and writing to stderr would put text into a live
24
+ // terminal. That code keeps its own `if (ctx.hasUI)` guards.
25
+ /** Whether `ctx` still belongs to the live session. See the module comment. */
26
+ export function isCtxLive(ctx) {
27
+ try {
28
+ return typeof ctx.cwd === "string";
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Format a notification for a stream. Exported so tests pin the shape a script
36
+ * would parse: one line, prefixed, level visible.
37
+ */
38
+ export function formatStreamNotification(message, level) {
39
+ return `[codecarto] ${level}: ${message}\n`;
40
+ }
41
+ /**
42
+ * Notify through `ctx`: the TUI when there is one, stderr when there is not,
43
+ * nothing when the ctx is stale.
44
+ */
45
+ export function notifyCtx(ctx, message, level, stream = process.stderr) {
46
+ if (!isCtxLive(ctx))
47
+ return;
48
+ if (ctx.hasUI) {
49
+ ctx.ui.notify(message, level);
50
+ return;
51
+ }
52
+ stream.write(formatStreamNotification(message, level));
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.19.3",
3
+ "version": "0.19.5",
4
4
  "mcpName": "io.github.HuginnIndustries/codecartographer",
5
5
  "description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
6
6
  "type": "module",