codecartographer-pi 0.19.4 → 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.4
6
+ scaffold_version: 0.19.5
@@ -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({
@@ -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);
@@ -506,7 +469,7 @@ export default function codeCartographerExtension(pi) {
506
469
  `Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`,
507
470
  "Full run: `/codecarto-next --auto --llm-steer` — or `/codecarto-next` to watch one phase first.",
508
471
  ];
509
- ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)}). Full run: /codecarto-next --auto --llm-steer`, "info");
472
+ notifyCtx(ctx, `Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)}). Full run: /codecarto-next --auto --llm-steer`, "info");
510
473
  // Render the initial dashboard (empty usage, all phases pending) so
511
474
  // the user sees the file exist immediately after /codecarto-init.
512
475
  void writeDashboard(ctx.cwd, PACKAGE_VERSION);
@@ -524,7 +487,7 @@ export default function codeCartographerExtension(pi) {
524
487
  const nextPhase = getNextEligiblePhase(state)?.id ?? "complete";
525
488
  lastFeedbackLines = [`Current phase: ${nextPhase}`, `Pipeline: ${getPipelineLabel(state.status.pipeline)}`];
526
489
  setUiState(ctx, state, lastFeedbackLines);
527
- ctx.ui.notify(`CodeCartographer phase: ${nextPhase}`, "info");
490
+ notifyCtx(ctx, `CodeCartographer phase: ${nextPhase}`, "info");
528
491
  },
529
492
  });
530
493
  pi.registerCommand("codecarto-switch-pipeline", {
@@ -538,12 +501,12 @@ export default function codeCartographerExtension(pi) {
538
501
  handler: async (args, ctx) => {
539
502
  const trimmedArgs = args.trim();
540
503
  if (!trimmedArgs) {
541
- 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");
542
505
  return;
543
506
  }
544
507
  const pipelineChoice = resolvePipelineChoice(trimmedArgs);
545
508
  if (!pipelineChoice) {
546
- ctx.ui.notify(`Unknown pipeline: ${trimmedArgs}`, "error");
509
+ notifyCtx(ctx, `Unknown pipeline: ${trimmedArgs}`, "error");
547
510
  return;
548
511
  }
549
512
  const state = await ensureWorkspaceState(ctx);
@@ -551,7 +514,7 @@ export default function codeCartographerExtension(pi) {
551
514
  return;
552
515
  const currentPipeline = state.status.pipeline;
553
516
  if (currentPipeline === pipelineChoice) {
554
- ctx.ui.notify(`Already on pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
517
+ notifyCtx(ctx, `Already on pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
555
518
  return;
556
519
  }
557
520
  try {
@@ -567,14 +530,14 @@ export default function codeCartographerExtension(pi) {
567
530
  lines.push(`Phases not in new pipeline: ${result.dropped.join(", ")} (findings remain on disk)`);
568
531
  lastFeedbackLines = lines;
569
532
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
570
- ctx.ui.notify(`Switched to pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
533
+ notifyCtx(ctx, `Switched to pipeline: ${getPipelineLabel(pipelineChoice)}`, "info");
571
534
  void writeDashboard(ctx.cwd, PACKAGE_VERSION);
572
535
  }
573
536
  catch (error) {
574
537
  const message = error instanceof Error ? error.message : String(error);
575
538
  lastFeedbackLines = [message];
576
539
  setUiState(ctx, state, lastFeedbackLines);
577
- ctx.ui.notify(message, "error");
540
+ notifyCtx(ctx, message, "error");
578
541
  }
579
542
  },
580
543
  });
@@ -601,18 +564,18 @@ export default function codeCartographerExtension(pi) {
601
564
  handler: async (args, ctx) => {
602
565
  const flags = parseNextFlags(args);
603
566
  if (flags.error) {
604
- ctx.ui.notify(flags.error, "error");
567
+ notifyCtx(ctx, flags.error, "error");
605
568
  return;
606
569
  }
607
570
  if (flags.unknown.length > 0) {
608
- ctx.ui.notify(`Unknown /codecarto-next flag: ${flags.unknown.join(" ")}`, "error");
571
+ notifyCtx(ctx, `Unknown /codecarto-next flag: ${flags.unknown.join(" ")}`, "error");
609
572
  return;
610
573
  }
611
574
  const state = await ensureWorkspaceState(ctx);
612
575
  if (!state)
613
576
  return;
614
577
  if (flags.auto) {
615
- ctx.ui.notify(`Auto pipeline${flags.strict ? " (strict)" : ""} running…`, "info");
578
+ notifyCtx(ctx, `Auto pipeline${flags.strict ? " (strict)" : ""} running…`, "info");
616
579
  const result = await runAuto(ctx, pi, state, {
617
580
  strict: flags.strict,
618
581
  llmSteerOverride: flags.llmSteerOverride,
@@ -635,14 +598,14 @@ export default function codeCartographerExtension(pi) {
635
598
  });
636
599
  lastFeedbackLines = [`Auto pipeline ${result.outcome}: ${result.reason}`];
637
600
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
638
- 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");
639
602
  return;
640
603
  }
641
604
  const phase = getNextEligiblePhase(state);
642
605
  if (!phase) {
643
606
  lastFeedbackLines = ["All phases complete."];
644
607
  setUiState(ctx, state, lastFeedbackLines);
645
- ctx.ui.notify("All CodeCartographer phases are complete.", "info");
608
+ notifyCtx(ctx, "All CodeCartographer phases are complete.", "info");
646
609
  return;
647
610
  }
648
611
  let preflight;
@@ -653,13 +616,13 @@ export default function codeCartographerExtension(pi) {
653
616
  const message = error instanceof Error ? error.message : String(error);
654
617
  lastFeedbackLines = [message];
655
618
  setUiState(ctx, state, lastFeedbackLines);
656
- ctx.ui.notify(message, error instanceof PhasePreflightError ? "warning" : "error");
619
+ notifyCtx(ctx, message, error instanceof PhasePreflightError ? "warning" : "error");
657
620
  return;
658
621
  }
659
622
  // Reject re-entry: don't spawn a duplicate runner for a phase that's
660
623
  // already in flight from a previous /codecarto-next invocation.
661
624
  if (isPhaseRunning(phase.id)) {
662
- ctx.ui.notify(`Phase ${phase.id} is already running.`, "warning");
625
+ notifyCtx(ctx, `Phase ${phase.id} is already running.`, "warning");
663
626
  return;
664
627
  }
665
628
  const config = await loadCodecartoConfig(state.workspaceDir);
@@ -735,7 +698,7 @@ export default function codeCartographerExtension(pi) {
735
698
  handler: async (args, ctx) => {
736
699
  const phaseId = args.trim();
737
700
  if (!phaseId) {
738
- ctx.ui.notify("Usage: /codecarto-phase <phase>", "warning");
701
+ notifyCtx(ctx, "Usage: /codecarto-phase <phase>", "warning");
739
702
  return;
740
703
  }
741
704
  const state = await ensureWorkspaceState(ctx);
@@ -743,7 +706,7 @@ export default function codeCartographerExtension(pi) {
743
706
  return;
744
707
  const phase = resolvePhase(state, phaseId);
745
708
  if (!phase) {
746
- ctx.ui.notify(`Unknown phase: ${phaseId}`, "error");
709
+ notifyCtx(ctx, `Unknown phase: ${phaseId}`, "error");
747
710
  return;
748
711
  }
749
712
  let prompt;
@@ -752,7 +715,7 @@ export default function codeCartographerExtension(pi) {
752
715
  }
753
716
  catch (error) {
754
717
  const message = error instanceof Error ? error.message : String(error);
755
- ctx.ui.notify(message, error instanceof PhasePreflightError ? "warning" : "error");
718
+ notifyCtx(ctx, message, error instanceof PhasePreflightError ? "warning" : "error");
756
719
  return;
757
720
  }
758
721
  if (ctx.isIdle()) {
@@ -763,7 +726,7 @@ export default function codeCartographerExtension(pi) {
763
726
  }
764
727
  lastFeedbackLines = [`Queued explicit phase prompt for ${phase.id}`];
765
728
  setUiState(ctx, state, lastFeedbackLines);
766
- ctx.ui.notify(`Queued CodeCartographer phase: ${phase.id}`, "info");
729
+ notifyCtx(ctx, `Queued CodeCartographer phase: ${phase.id}`, "info");
767
730
  },
768
731
  });
769
732
  pi.registerCommand("codecarto-validate", {
@@ -776,13 +739,13 @@ export default function codeCartographerExtension(pi) {
776
739
  if (validation instanceof Error) {
777
740
  lastFeedbackLines = [validation.message];
778
741
  setUiState(ctx, state, lastFeedbackLines);
779
- ctx.ui.notify(validation.message, "error");
742
+ notifyCtx(ctx, validation.message, "error");
780
743
  return;
781
744
  }
782
745
  lastFeedbackLines = buildValidationSummary(validation);
783
746
  setUiState(ctx, state, lastFeedbackLines);
784
747
  const level = validation.overall === "FAIL" || validation.overall === "MISSING" ? "error" : validation.overall === "PASS WITH GAPS" ? "warning" : "info";
785
- ctx.ui.notify(`Validation ${validation.phaseId}: ${validation.overall}`, level);
748
+ notifyCtx(ctx, `Validation ${validation.phaseId}: ${validation.overall}`, level);
786
749
  },
787
750
  });
788
751
  pi.registerCommand("codecarto-complete", {
@@ -795,13 +758,13 @@ export default function codeCartographerExtension(pi) {
795
758
  if (validation instanceof Error) {
796
759
  lastFeedbackLines = [validation.message];
797
760
  setUiState(ctx, currentState, lastFeedbackLines);
798
- ctx.ui.notify(validation.message, "error");
761
+ notifyCtx(ctx, validation.message, "error");
799
762
  return;
800
763
  }
801
764
  if (validation.overall === "FAIL" || validation.overall === "MISSING") {
802
765
  lastFeedbackLines = buildValidationSummary(validation);
803
766
  setUiState(ctx, currentState, lastFeedbackLines);
804
- ctx.ui.notify(`Cannot complete ${validation.phaseId}: ${validation.overall}`, "error");
767
+ notifyCtx(ctx, `Cannot complete ${validation.phaseId}: ${validation.overall}`, "error");
805
768
  return;
806
769
  }
807
770
  // Completion refuses for reasons the framework words carefully — a
@@ -819,7 +782,7 @@ export default function codeCartographerExtension(pi) {
819
782
  const message = error instanceof Error ? error.message : String(error);
820
783
  lastFeedbackLines = [`Completion refused: ${message}`];
821
784
  setUiState(ctx, currentState, lastFeedbackLines);
822
- ctx.ui.notify(message, "error");
785
+ notifyCtx(ctx, message, "error");
823
786
  return;
824
787
  }
825
788
  const { updatedState, closeoutNotice, warnings } = completion;
@@ -834,11 +797,11 @@ export default function codeCartographerExtension(pi) {
834
797
  for (const note of notes)
835
798
  lastFeedbackLines.push(`NOTE: ${note} Non-gating.`);
836
799
  setUiState(ctx, updatedState, lastFeedbackLines);
837
- 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");
838
801
  if (closeoutNotice)
839
- ctx.ui.notify(closeoutNotice, "info");
802
+ notifyCtx(ctx, closeoutNotice, "info");
840
803
  for (const note of notes)
841
- ctx.ui.notify(note, "warning");
804
+ notifyCtx(ctx, note, "warning");
842
805
  },
843
806
  });
844
807
  pi.registerCommand("codecarto-skill", {
@@ -848,7 +811,7 @@ export default function codeCartographerExtension(pi) {
848
811
  if (!skillName) {
849
812
  const available = await listSkillNames(join(ctx.cwd, ".codecarto"));
850
813
  const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : "";
851
- ctx.ui.notify(`Usage: /codecarto-skill <name>${hint}`, "warning");
814
+ notifyCtx(ctx, `Usage: /codecarto-skill <name>${hint}`, "warning");
852
815
  return;
853
816
  }
854
817
  // Broad-Side is a reading guide for batch reconnaissance output, not a
@@ -858,7 +821,7 @@ export default function codeCartographerExtension(pi) {
858
821
  if (skillName === BROADSIDE_SKILL_NAME) {
859
822
  const skill = await readBroadsideSkill(ctx.cwd).catch(() => null);
860
823
  if (!skill) {
861
- 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");
862
825
  return;
863
826
  }
864
827
  const message = [
@@ -872,7 +835,7 @@ export default function codeCartographerExtension(pi) {
872
835
  else {
873
836
  pi.sendUserMessage(message, { deliverAs: "followUp" });
874
837
  }
875
- ctx.ui.notify("Queued the Broad-Side reading guide", "info");
838
+ notifyCtx(ctx, "Queued the Broad-Side reading guide", "info");
876
839
  return;
877
840
  }
878
841
  const state = await ensureWorkspaceState(ctx);
@@ -880,14 +843,14 @@ export default function codeCartographerExtension(pi) {
880
843
  return;
881
844
  const nextPhase = getNextEligiblePhase(state);
882
845
  if (nextPhase) {
883
- 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");
884
847
  return;
885
848
  }
886
849
  const skillFile = join(state.workspaceDir, "skills", skillName, "SKILL.md");
887
850
  if (!(await pathExists(skillFile))) {
888
851
  const available = await listSkillNames(state.workspaceDir);
889
852
  const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : " (no skills installed)";
890
- 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");
891
854
  return;
892
855
  }
893
856
  const prompt = await buildSkillPrompt(state, skillName);
@@ -899,7 +862,7 @@ export default function codeCartographerExtension(pi) {
899
862
  }
900
863
  lastFeedbackLines = [`Queued post-pipeline skill: ${skillName}`];
901
864
  setUiState(ctx, state, lastFeedbackLines);
902
- ctx.ui.notify(`Queued CodeCartographer skill: ${skillName}`, "info");
865
+ notifyCtx(ctx, `Queued CodeCartographer skill: ${skillName}`, "info");
903
866
  },
904
867
  });
905
868
  pi.registerCommand("codecarto-list-skills", {
@@ -928,7 +891,7 @@ export default function codeCartographerExtension(pi) {
928
891
  }
929
892
  lastFeedbackLines = lines;
930
893
  setUiState(ctx, state, lastFeedbackLines);
931
- ctx.ui.notify(skills.length > 0
894
+ notifyCtx(ctx, skills.length > 0
932
895
  ? `${skills.length} post-pipeline skill${skills.length === 1 ? "" : "s"}: ${skills.join(", ")}`
933
896
  : "No post-pipeline skills installed.", "info");
934
897
  },
@@ -952,7 +915,7 @@ export default function codeCartographerExtension(pi) {
952
915
  document = await readGuide(args.trim() || undefined);
953
916
  }
954
917
  catch (error) {
955
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
918
+ notifyCtx(ctx, error instanceof Error ? error.message : String(error), "error");
956
919
  return;
957
920
  }
958
921
  const other = topics.filter((name) => name !== document.topic);
@@ -969,7 +932,7 @@ export default function codeCartographerExtension(pi) {
969
932
  lastFeedbackLines = [`Queued the CodeCartographer guide: ${document.topic}`];
970
933
  if (codecartoModeActive)
971
934
  void refreshWorkspaceUi(ctx, lastFeedbackLines).catch(() => undefined);
972
- ctx.ui.notify(`Queued the CodeCartographer guide (${document.topic})`, "info");
935
+ notifyCtx(ctx, `Queued the CodeCartographer guide (${document.topic})`, "info");
973
936
  },
974
937
  });
975
938
  pi.registerCommand("codecarto-broadside", {
@@ -983,12 +946,12 @@ export default function codeCartographerExtension(pi) {
983
946
  handler: async (args, ctx) => {
984
947
  const flags = parseBroadsideFlags(args);
985
948
  if (flags.unknown.length > 0) {
986
- ctx.ui.notify(`Unknown /codecarto-broadside argument: ${flags.unknown.join(" ")}. ` +
949
+ notifyCtx(ctx, `Unknown /codecarto-broadside argument: ${flags.unknown.join(" ")}. ` +
987
950
  `Actions: submit, collect, status, models. Lenses: ${BROADSIDE_LENS_IDS.join(", ")}.`, "error");
988
951
  return;
989
952
  }
990
953
  if (flags.error) {
991
- ctx.ui.notify(flags.error, "error");
954
+ notifyCtx(ctx, flags.error, "error");
992
955
  return;
993
956
  }
994
957
  // Broad-Side runs on any git repository, with or without a workspace —
@@ -1008,7 +971,7 @@ export default function codeCartographerExtension(pi) {
1008
971
  // the result can live, so it holds it instead of being cleared.
1009
972
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, ["Broad-Side", ...lines]);
1010
973
  }
1011
- ctx.ui.notify(notice, level);
974
+ notifyCtx(ctx, notice, level);
1012
975
  };
1013
976
  if (flags.action === "status") {
1014
977
  const { state } = await runBroadsideStatus(ctx.cwd);
@@ -1018,12 +981,12 @@ export default function codeCartographerExtension(pi) {
1018
981
  }
1019
982
  const apiKey = resolveBroadsideKey(config.apiKey);
1020
983
  if (!apiKey) {
1021
- 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 " +
1022
985
  ".codecarto/broadside/config.yaml. (A slash command takes no key: it would land in the transcript.)", "error");
1023
986
  return;
1024
987
  }
1025
988
  if (flags.action === "models") {
1026
- ctx.ui.notify("Fetching the OpenRouter batch-model catalog…", "info");
989
+ notifyCtx(ctx, "Fetching the OpenRouter batch-model catalog…", "info");
1027
990
  try {
1028
991
  const { entries, benchmarks } = await listBatchModels(broadsideDir, config, apiKey, {
1029
992
  includeBenchmarks: flags.benchmarks,
@@ -1031,7 +994,7 @@ export default function codeCartographerExtension(pi) {
1031
994
  finish(modelsText(entries, { benchmarks, defaultModel: config.model }).split("\n"), `Broad-Side: ${entries.length} batch model${entries.length === 1 ? "" : "s"} listed`);
1032
995
  }
1033
996
  catch (error) {
1034
- 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");
1035
998
  }
1036
999
  return;
1037
1000
  }
@@ -1077,10 +1040,10 @@ export default function codeCartographerExtension(pi) {
1077
1040
  if (ctx.hasUI)
1078
1041
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1079
1042
  if (error instanceof BroadsideCancelledError) {
1080
- ctx.ui.notify("Broad-Side cancelled. Nothing was submitted.", "info");
1043
+ notifyCtx(ctx, "Broad-Side cancelled. Nothing was submitted.", "info");
1081
1044
  return;
1082
1045
  }
1083
- 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");
1084
1047
  return;
1085
1048
  }
1086
1049
  const lines = estimateSubmitText(submit, lenses.map(getLens)).split("\n");
@@ -1089,7 +1052,7 @@ export default function codeCartographerExtension(pi) {
1089
1052
  finish(lines, `Broad-Side submitted: run ${submit.runId} (~$${submit.estimatedTotalCost.toFixed(4)})`);
1090
1053
  return;
1091
1054
  }
1092
- 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");
1093
1056
  try {
1094
1057
  const collect = await runBroadsideCollect(ctx.cwd, apiKey, {
1095
1058
  waitMs,
@@ -1105,7 +1068,7 @@ export default function codeCartographerExtension(pi) {
1105
1068
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1106
1069
  // The batches are submitted and paid for either way — say so, so
1107
1070
  // nobody re-submits a run that is already in flight.
1108
- ctx.ui.notify(`Broad-Side submitted run ${submit.runId}, but collect failed: ` +
1071
+ notifyCtx(ctx, `Broad-Side submitted run ${submit.runId}, but collect failed: ` +
1109
1072
  `${error instanceof Error ? error.message : String(error)}. Retry with /codecarto-broadside collect.`, "error");
1110
1073
  }
1111
1074
  return;
@@ -1129,7 +1092,7 @@ export default function codeCartographerExtension(pi) {
1129
1092
  catch (error) {
1130
1093
  if (ctx.hasUI)
1131
1094
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1132
- 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");
1133
1096
  }
1134
1097
  },
1135
1098
  });
@@ -1141,22 +1104,22 @@ export default function codeCartographerExtension(pi) {
1141
1104
  return;
1142
1105
  const config = await loadCodecartoConfig(state.workspaceDir);
1143
1106
  if (!config.library.path) {
1144
- 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");
1145
1108
  return;
1146
1109
  }
1147
1110
  const marker = await discoverLibrary(config.library.path);
1148
1111
  if (!marker) {
1149
- 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");
1150
1113
  return;
1151
1114
  }
1152
1115
  const phase = resolvePhase(state, "reimplementation-spec");
1153
1116
  if (!phase?.primary_output) {
1154
- 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");
1155
1118
  return;
1156
1119
  }
1157
1120
  const specPath = join(state.workspaceDir, phase.primary_output);
1158
1121
  if (!(await pathExists(specPath))) {
1159
- ctx.ui.notify(`Reimplementation spec is missing: .codecarto/${phase.primary_output}`, "error");
1122
+ notifyCtx(ctx, `Reimplementation spec is missing: .codecarto/${phase.primary_output}`, "error");
1160
1123
  return;
1161
1124
  }
1162
1125
  const spec = await readFile(specPath, "utf8");
@@ -1167,7 +1130,7 @@ export default function codeCartographerExtension(pi) {
1167
1130
  const headline = derivePublishHeadline(spec, ctx.cwd);
1168
1131
  const namespace = marker.namespaced ? config.library.namespace ?? undefined : undefined;
1169
1132
  if (marker.namespaced && !namespace) {
1170
- 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");
1171
1134
  return;
1172
1135
  }
1173
1136
  const label = `${namespace ? `${namespace}/` : ""}${slug}`;
@@ -1212,7 +1175,7 @@ export default function codeCartographerExtension(pi) {
1212
1175
  // recorded the directory to one that records the git remote.
1213
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?`);
1214
1177
  if (!moved) {
1215
- ctx.ui.notify("Publish cancelled. Nothing was written.", "info");
1178
+ notifyCtx(ctx, "Publish cancelled. Nothing was written.", "info");
1216
1179
  return;
1217
1180
  }
1218
1181
  options.allowSourceRepoChange = true;
@@ -1222,7 +1185,7 @@ export default function codeCartographerExtension(pi) {
1222
1185
  // default; whether it may go into a wider library is the user's call.
1223
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?`);
1224
1187
  if (!publishAnyway) {
1225
- ctx.ui.notify("Publish cancelled. Nothing was written.", "info");
1188
+ notifyCtx(ctx, "Publish cancelled. Nothing was written.", "info");
1226
1189
  return;
1227
1190
  }
1228
1191
  options.allowConfidentialityMismatch = true;
@@ -1235,11 +1198,11 @@ export default function codeCartographerExtension(pi) {
1235
1198
  lastFeedbackLines = [`Published ${result.namespace ? `${result.namespace}/` : ""}${result.slug} v${result.version}`, result.isNewVersion ? "New content version." : "Metadata-only update (content unchanged)."];
1236
1199
  await writeDashboard(ctx.cwd, PACKAGE_VERSION);
1237
1200
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
1238
- 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");
1239
1202
  }
1240
1203
  catch (error) {
1241
1204
  const message = error instanceof Error ? error.message : String(error);
1242
- ctx.ui.notify(`Unable to publish: ${message}`, "error");
1205
+ notifyCtx(ctx, `Unable to publish: ${message}`, "error");
1243
1206
  }
1244
1207
  },
1245
1208
  });
@@ -1251,7 +1214,7 @@ export default function codeCartographerExtension(pi) {
1251
1214
  const namespaceIdx = parts.indexOf("--namespace");
1252
1215
  const namespace = namespaceIdx >= 0 ? parts[namespaceIdx + 1] : null;
1253
1216
  if (!pathArg) {
1254
- ctx.ui.notify("Usage: /codecarto-library-init <path> [--namespace <name>]", "warning");
1217
+ notifyCtx(ctx, "Usage: /codecarto-library-init <path> [--namespace <name>]", "warning");
1255
1218
  return;
1256
1219
  }
1257
1220
  const libraryPath = pathArg.startsWith("~") ? join(homedir(), pathArg.slice(1)) : resolve(pathArg);
@@ -1267,14 +1230,12 @@ export default function codeCartographerExtension(pi) {
1267
1230
  ? `Library already exists at ${libraryPath} (marker preserved). Config updated.`
1268
1231
  : `Created library at ${libraryPath} with marker "${result.marker.name}".`;
1269
1232
  lastFeedbackLines = [msg, `Config written to ${configPath}`];
1270
- if (ctx.hasUI) {
1271
- ctx.ui.notify(msg, "info");
1272
- ctx.ui.notify(`Config written to ${configPath}`, "info");
1273
- }
1233
+ notifyCtx(ctx, msg, "info");
1234
+ notifyCtx(ctx, `Config written to ${configPath}`, "info");
1274
1235
  }
1275
1236
  catch (error) {
1276
1237
  const message = error instanceof Error ? error.message : String(error);
1277
- ctx.ui.notify(`Library init failed: ${message}`, "error");
1238
+ notifyCtx(ctx, `Library init failed: ${message}`, "error");
1278
1239
  }
1279
1240
  },
1280
1241
  });
@@ -1300,7 +1261,7 @@ export default function codeCartographerExtension(pi) {
1300
1261
  lastFeedbackLines = lines;
1301
1262
  if (state)
1302
1263
  setUiState(ctx, state, lastFeedbackLines);
1303
- ctx.ui.notify("Configuration shown in status widget.", "info");
1264
+ notifyCtx(ctx, "Configuration shown in status widget.", "info");
1304
1265
  },
1305
1266
  });
1306
1267
  pi.registerCommand("codecarto-usage", {
@@ -1313,7 +1274,7 @@ export default function codeCartographerExtension(pi) {
1313
1274
  if (usage.runs.length === 0) {
1314
1275
  lastFeedbackLines = ["No phase runs recorded yet."];
1315
1276
  setUiState(ctx, state, lastFeedbackLines);
1316
- ctx.ui.notify("No phase runs recorded yet.", "info");
1277
+ notifyCtx(ctx, "No phase runs recorded yet.", "info");
1317
1278
  return;
1318
1279
  }
1319
1280
  const totals = computeTotals(usage);
@@ -1333,7 +1294,7 @@ export default function codeCartographerExtension(pi) {
1333
1294
  }
1334
1295
  lastFeedbackLines = lines;
1335
1296
  setUiState(ctx, state, lastFeedbackLines);
1336
- 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");
1337
1298
  },
1338
1299
  });
1339
1300
  pi.registerCommand("codecarto-dashboard", {
@@ -1347,26 +1308,26 @@ export default function codeCartographerExtension(pi) {
1347
1308
  handler: async (args, ctx) => {
1348
1309
  const flags = parseDashboardFlags(args);
1349
1310
  if (flags.unknown.length > 0) {
1350
- ctx.ui.notify(`Unknown /codecarto-dashboard flag: ${flags.unknown.join(" ")}`, "error");
1311
+ notifyCtx(ctx, `Unknown /codecarto-dashboard flag: ${flags.unknown.join(" ")}`, "error");
1351
1312
  return;
1352
1313
  }
1353
1314
  const state = await ensureWorkspaceState(ctx);
1354
1315
  if (!state)
1355
1316
  return;
1356
1317
  if (flags.narrate) {
1357
- ctx.ui.notify(`Narrating dashboard via LLM…`, "info");
1318
+ notifyCtx(ctx, `Narrating dashboard via LLM…`, "info");
1358
1319
  const result = await narrateDashboard(ctx, state);
1359
1320
  if (result.used) {
1360
- ctx.ui.notify("Narration written to .codecarto/.dashboard-narration.local.md", "info");
1321
+ notifyCtx(ctx, "Narration written to .codecarto/.dashboard-narration.local.md", "info");
1361
1322
  }
1362
1323
  else {
1363
- ctx.ui.notify(`LLM narration skipped (${result.skipReason}); rendering deterministic dashboard.`, "warning");
1324
+ notifyCtx(ctx, `LLM narration skipped (${result.skipReason}); rendering deterministic dashboard.`, "warning");
1364
1325
  }
1365
1326
  }
1366
1327
  await writeDashboard(ctx.cwd, PACKAGE_VERSION);
1367
1328
  lastFeedbackLines = ["Dashboard regenerated: .codecarto/dashboard.html"];
1368
1329
  setUiState(ctx, state, lastFeedbackLines);
1369
- ctx.ui.notify("Dashboard regenerated: .codecarto/dashboard.html", "info");
1330
+ notifyCtx(ctx, "Dashboard regenerated: .codecarto/dashboard.html", "info");
1370
1331
  },
1371
1332
  });
1372
1333
  pi.registerCommand("codecarto-refresh-scaffold", {
@@ -1382,12 +1343,12 @@ export default function codeCartographerExtension(pi) {
1382
1343
  files = await listScaffoldRefreshFiles();
1383
1344
  }
1384
1345
  catch (error) {
1385
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1346
+ notifyCtx(ctx, error instanceof Error ? error.message : String(error), "error");
1386
1347
  return;
1387
1348
  }
1388
1349
  const approved = await ctx.ui.confirm("Refresh the .codecarto/ scaffold from the packaged template?", describeScaffoldRefreshPreview(files, state.scaffoldVersion));
1389
1350
  if (!approved) {
1390
- ctx.ui.notify("Scaffold refresh cancelled. Nothing was written.", "info");
1351
+ notifyCtx(ctx, "Scaffold refresh cancelled. Nothing was written.", "info");
1391
1352
  return;
1392
1353
  }
1393
1354
  try {
@@ -1400,13 +1361,13 @@ export default function codeCartographerExtension(pi) {
1400
1361
  ];
1401
1362
  // Re-read state so the widget's staleness line clears with the marker.
1402
1363
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
1403
- 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");
1404
1365
  }
1405
1366
  catch (error) {
1406
1367
  const message = error instanceof Error ? error.message : String(error);
1407
1368
  lastFeedbackLines = [message];
1408
1369
  setUiState(ctx, state, lastFeedbackLines);
1409
- ctx.ui.notify(`Scaffold refresh failed: ${message}`, "error");
1370
+ notifyCtx(ctx, `Scaffold refresh failed: ${message}`, "error");
1410
1371
  }
1411
1372
  },
1412
1373
  });
@@ -1428,12 +1389,12 @@ export default function codeCartographerExtension(pi) {
1428
1389
  const hint = staged.length > 0
1429
1390
  ? ` (staged: ${staged.join(", ")})`
1430
1391
  : " — write .codecarto/scratch/amendments/<name>.yaml first (see templates/amendment.yaml)";
1431
- ctx.ui.notify(`Usage: /codecarto-amend <name>${hint}`, "warning");
1392
+ notifyCtx(ctx, `Usage: /codecarto-amend <name>${hint}`, "warning");
1432
1393
  return;
1433
1394
  }
1434
1395
  const name = resolveAmendmentName(args, ctx.cwd);
1435
1396
  if (!name) {
1436
- 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");
1437
1398
  return;
1438
1399
  }
1439
1400
  // The same refusals codecarto_amend surfaces, raised before the
@@ -1443,12 +1404,12 @@ export default function codeCartographerExtension(pi) {
1443
1404
  amendment = await loadAmendmentFile(name, state.workspaceDir);
1444
1405
  }
1445
1406
  catch (error) {
1446
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1407
+ notifyCtx(ctx, error instanceof Error ? error.message : String(error), "error");
1447
1408
  return;
1448
1409
  }
1449
1410
  const nextPhase = getNextEligiblePhase(state);
1450
1411
  if (nextPhase) {
1451
- 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}). `
1452
1413
  + "Resolve open questions and routed items through that phase's handoff (open_question_closures / carry_forward_closures) instead.", "error");
1453
1414
  return;
1454
1415
  }
@@ -1456,7 +1417,7 @@ export default function codeCartographerExtension(pi) {
1456
1417
  // before anything is written — MCP's codecarto_amend applies on call.
1457
1418
  const approved = await ctx.ui.confirm(`Apply amendment "${amendment.slug}"?`, describeAmendmentPreview(amendment, state));
1458
1419
  if (!approved) {
1459
- ctx.ui.notify(`Amendment ${amendment.slug} cancelled. Nothing was written.`, "info");
1420
+ notifyCtx(ctx, `Amendment ${amendment.slug} cancelled. Nothing was written.`, "info");
1460
1421
  return;
1461
1422
  }
1462
1423
  try {
@@ -1477,14 +1438,14 @@ export default function codeCartographerExtension(pi) {
1477
1438
  lastFeedbackLines = lines;
1478
1439
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
1479
1440
  const closed = applied.openQuestionsClosed.length + applied.postPipelineClosed.length;
1480
- 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`
1481
1442
  + `${applied.unknownIds.length > 0 ? `; ${applied.unknownIds.length} id(s) matched nothing` : ""}.`, closed === 0 || applied.unknownIds.length > 0 ? "warning" : "info");
1482
1443
  }
1483
1444
  catch (error) {
1484
1445
  const message = error instanceof Error ? error.message : String(error);
1485
1446
  lastFeedbackLines = [message];
1486
1447
  setUiState(ctx, state, lastFeedbackLines);
1487
- ctx.ui.notify(`Amendment failed: ${message}`, "error");
1448
+ notifyCtx(ctx, `Amendment failed: ${message}`, "error");
1488
1449
  }
1489
1450
  },
1490
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.4",
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",