codecartographer-pi 0.18.0 → 0.19.1

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.
@@ -83,8 +83,42 @@ function buildStatusLines(state, extraLines = []) {
83
83
  }
84
84
  return lines;
85
85
  }
86
+ /**
87
+ * Whether `ctx` still belongs to the live session.
88
+ *
89
+ * Pi invalidates an extension ctx when the session is replaced, and from then
90
+ * on *every* property access on it throws — `ctx.cwd` and `ctx.hasUI` included.
91
+ * A phase runs as a sub-agent, so by the time post-phase work fires, the ctx
92
+ * captured when the command started may already be dead. That is an ordinary
93
+ * outcome rather than an error: the UI it would have refreshed is gone with the
94
+ * session. Callers skip their UI work instead of throwing into a `void` call
95
+ * that nothing is waiting on.
96
+ */
97
+ function isCtxLive(ctx) {
98
+ try {
99
+ return typeof ctx.cwd === "string";
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
105
+ /**
106
+ * Notify through `ctx`, dropping the message if the session it belonged to is
107
+ * gone.
108
+ *
109
+ * `ctx.hasUI` throws on a stale ctx rather than returning false, so the usual
110
+ * `if (ctx.hasUI) ctx.ui.notify(...)` guard was itself a throw site. Inside a
111
+ * promise chain that was worse than a lost message: the `.catch` handler threw
112
+ * while reporting the original failure, and that second rejection had nothing
113
+ * left to catch it.
114
+ */
115
+ function notifyCtx(ctx, message, level) {
116
+ if (!isCtxLive(ctx) || !ctx.hasUI)
117
+ return;
118
+ ctx.ui.notify(message, level);
119
+ }
86
120
  function setUiState(ctx, state, extraLines = []) {
87
- if (!ctx.hasUI)
121
+ if (!isCtxLive(ctx) || !ctx.hasUI)
88
122
  return;
89
123
  if (!state) {
90
124
  ctx.ui.setStatus(STATUS_LINE_ID, undefined);
@@ -260,6 +294,11 @@ export default function codeCartographerExtension(pi) {
260
294
  // remembered here for the completers that list files under .codecarto/.
261
295
  let sessionCwd;
262
296
  const readWorkspaceState = async (ctx, notifyOnError = true) => {
297
+ // `ctx.cwd` was read before the try, so a stale ctx made this reject
298
+ // rather than return null as its signature promises — and the callers
299
+ // that fire it without awaiting turned that into an unhandled rejection.
300
+ if (!isCtxLive(ctx))
301
+ return null;
263
302
  sessionCwd = ctx.cwd;
264
303
  try {
265
304
  return await getWorkspaceState(ctx.cwd);
@@ -268,12 +307,16 @@ export default function codeCartographerExtension(pi) {
268
307
  const message = error instanceof Error ? error.message : String(error);
269
308
  lastFeedbackLines = [message];
270
309
  setUiState(ctx, null);
271
- if (notifyOnError && ctx.hasUI)
310
+ // The ctx can die between the read above and here, so the error
311
+ // path must not assume it is still usable either.
312
+ if (notifyOnError && isCtxLive(ctx) && ctx.hasUI)
272
313
  ctx.ui.notify(message, "error");
273
314
  return null;
274
315
  }
275
316
  };
276
317
  const refreshWorkspaceUi = async (ctx, extraLines) => {
318
+ if (!isCtxLive(ctx))
319
+ return null;
277
320
  if (!codecartoModeActive) {
278
321
  setUiState(ctx, null);
279
322
  return null;
@@ -290,15 +333,19 @@ export default function codeCartographerExtension(pi) {
290
333
  const ensureWorkspaceState = async (ctx) => {
291
334
  if (!codecartoModeActive) {
292
335
  setUiState(ctx, null);
293
- ctx.ui.notify("CodeCartographer is not active in this session. Run /codecarto-init first.", "warning");
336
+ notifyCtx(ctx, "CodeCartographer is not active in this session. Run /codecarto-init first.", "warning");
294
337
  return null;
295
338
  }
296
339
  const state = await readWorkspaceState(ctx);
297
340
  if (state)
298
341
  return state;
342
+ // Reached when the ctx is stale as well as when there is no workspace,
343
+ // so neither `ctx.cwd` nor the notify below may assume a live ctx.
344
+ if (!isCtxLive(ctx))
345
+ return null;
299
346
  const hasWorkspace = await pathExists(join(ctx.cwd, ".codecarto", "workflow", "status.yaml"));
300
347
  if (!hasWorkspace)
301
- ctx.ui.notify("No .codecarto/ workspace found. Run /codecarto-init first.", "warning");
348
+ notifyCtx(ctx, "No .codecarto/ workspace found. Run /codecarto-init first.", "warning");
302
349
  return null;
303
350
  };
304
351
  pi.on("session_start", async (_event, ctx) => {
@@ -607,34 +654,36 @@ export default function codeCartographerExtension(pi) {
607
654
  // phase so status.yaml advances without requiring the user to manually
608
655
  // run /codecarto-validate then /codecarto-complete. This mirrors what
609
656
  // the auto loop (runAuto) does after each phase.
657
+ // The sub-agent replaces the session, which invalidates this ctx —
658
+ // every later property access on it throws. Capture the directory
659
+ // now so the post-phase work below does not depend on the ctx
660
+ // surviving, and route UI updates through notifyCtx, which drops
661
+ // them if it has not.
662
+ const phaseCwd = ctx.cwd;
610
663
  void runSinglePhase(ctx, pi, state, phase, { llmSteerEnabled, signal: ctx.signal, preflight })
611
664
  .then(async (result) => {
612
665
  if (result.status !== "completed")
613
666
  return;
614
667
  // Refresh state from disk — the sub-agent may have written
615
668
  // findings that the validator needs to read.
616
- const stateForValidation = (await getWorkspaceState(ctx.cwd)) ?? state;
669
+ const stateForValidation = (await getWorkspaceState(phaseCwd)) ?? state;
617
670
  const validation = await validatePhaseOutput(stateForValidation, phase.id).catch((error) => (error instanceof Error ? error : new Error(String(error))));
618
671
  if (validation instanceof Error) {
619
- if (ctx.hasUI)
620
- ctx.ui.notify(`Auto-validation error for ${phase.id}: ${validation.message}`, "warning");
672
+ notifyCtx(ctx, `Auto-validation error for ${phase.id}: ${validation.message}`, "warning");
621
673
  lastFeedbackLines = [`Validation error: ${validation.message}`, "Run `/codecarto-validate` then `/codecarto-complete` manually."];
622
674
  return;
623
675
  }
624
676
  if (validation.overall === "FAIL" || validation.overall === "MISSING") {
625
- if (ctx.hasUI)
626
- ctx.ui.notify(`Phase ${phase.id} validation: ${validation.overall}. Fix the output, then re-run /codecarto-next.`, "warning");
677
+ notifyCtx(ctx, `Phase ${phase.id} validation: ${validation.overall}. Fix the output, then re-run /codecarto-next.`, "warning");
627
678
  lastFeedbackLines = buildValidationSummary(validation);
628
679
  return;
629
680
  }
630
681
  // PASS or PASS WITH GAPS — auto-complete the phase.
631
682
  try {
632
- const { updatedState, closeoutNotice } = await autoCompletePhase(ctx, validation);
633
- if (ctx.hasUI) {
634
- ctx.ui.notify(`Phase ${phase.id} auto-completed (validation: ${validation.overall}).`, validation.overall === "PASS WITH GAPS" ? "warning" : "info");
635
- if (closeoutNotice)
636
- ctx.ui.notify(closeoutNotice, "info");
637
- }
683
+ const { updatedState, closeoutNotice } = await autoCompletePhase(phaseCwd, validation);
684
+ notifyCtx(ctx, `Phase ${phase.id} auto-completed (validation: ${validation.overall}).`, validation.overall === "PASS WITH GAPS" ? "warning" : "info");
685
+ if (closeoutNotice)
686
+ notifyCtx(ctx, closeoutNotice, "info");
638
687
  lastFeedbackLines = [
639
688
  `Completed phase: ${validation.phaseId}`,
640
689
  `Validation: ${validation.overall}`,
@@ -645,22 +694,20 @@ export default function codeCartographerExtension(pi) {
645
694
  }
646
695
  catch (error) {
647
696
  const message = error instanceof Error ? error.message : String(error);
648
- if (ctx.hasUI)
649
- ctx.ui.notify(`Auto-completion failed for ${phase.id}: ${message}. Run /codecarto-complete manually.`, "warning");
697
+ notifyCtx(ctx, `Auto-completion failed for ${phase.id}: ${message}. Run /codecarto-complete manually.`, "warning");
650
698
  lastFeedbackLines = [`Auto-completion failed: ${message}`, "Run `/codecarto-complete` manually."];
651
699
  }
652
700
  })
653
701
  .catch((error) => {
654
702
  const message = error instanceof Error ? error.message : String(error);
655
- if (ctx.hasUI)
656
- ctx.ui.notify(`Post-phase processing error for ${phase.id}: ${message}`, "warning");
703
+ notifyCtx(ctx, `Post-phase processing error for ${phase.id}: ${message}`, "warning");
657
704
  lastFeedbackLines = [`Post-phase error: ${message}`];
658
705
  })
659
706
  .finally(() => {
660
707
  // Refresh the status widget after the phase resolves so the
661
708
  // "Open questions / Carry-forward / Next" lines reflect any
662
709
  // owner_notes the sub-agent wrote to status.yaml.
663
- void refreshWorkspaceUi(ctx);
710
+ void refreshWorkspaceUi(ctx).catch(() => undefined);
664
711
  });
665
712
  },
666
713
  });
@@ -738,7 +785,7 @@ export default function codeCartographerExtension(pi) {
738
785
  ctx.ui.notify(`Cannot complete ${validation.phaseId}: ${validation.overall}`, "error");
739
786
  return;
740
787
  }
741
- const { updatedState, closeoutNotice, warnings } = await autoCompletePhase(ctx, validation);
788
+ const { updatedState, closeoutNotice, warnings } = await autoCompletePhase(ctx.cwd, validation);
742
789
  lastFeedbackLines = [
743
790
  `Completed phase: ${validation.phaseId}`,
744
791
  `Validation: ${validation.overall}`,
@@ -884,7 +931,7 @@ export default function codeCartographerExtension(pi) {
884
931
  }
885
932
  lastFeedbackLines = [`Queued the CodeCartographer guide: ${document.topic}`];
886
933
  if (codecartoModeActive)
887
- void refreshWorkspaceUi(ctx, lastFeedbackLines);
934
+ void refreshWorkspaceUi(ctx, lastFeedbackLines).catch(() => undefined);
888
935
  ctx.ui.notify(`Queued the CodeCartographer guide (${document.topic})`, "info");
889
936
  },
890
937
  });
@@ -917,7 +964,7 @@ export default function codeCartographerExtension(pi) {
917
964
  // A workspace session already has a widget; fold the result into it.
918
965
  if (ctx.hasUI)
919
966
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
920
- void refreshWorkspaceUi(ctx, lines);
967
+ void refreshWorkspaceUi(ctx, lines).catch(() => undefined);
921
968
  }
922
969
  else if (ctx.hasUI) {
923
970
  // Scout-only repository: the Broad-Side widget is the only place
@@ -24,6 +24,20 @@ import { initLibrary } from "../core/library.js";
24
24
  import { loadUserConfig, resolveUserConfigPath } from "../core/orchestrator-config.js";
25
25
  import { writeDashboard } from "../extensions/codecarto/dashboard-writer.js";
26
26
  // ---------- input helpers ----------
27
+ /**
28
+ * Normalize an optional `phase` argument. A client can send any JSON, and
29
+ * `args.phase?.trim()` throws a bare TypeError on a number or an object —
30
+ * surfacing as an opaque InternalError rather than telling the caller which
31
+ * argument was wrong. Sibling handlers already guard the required case.
32
+ */
33
+ function requireOptionalPhase(phase) {
34
+ if (phase === undefined || phase === null)
35
+ return undefined;
36
+ if (typeof phase !== "string") {
37
+ throw new McpError(ErrorCode.InvalidParams, `phase must be a string when provided, got ${typeof phase}`);
38
+ }
39
+ return phase.trim() || undefined;
40
+ }
27
41
  async function validateCwd(cwd) {
28
42
  if (typeof cwd !== "string" || !cwd.trim()) {
29
43
  throw new McpError(ErrorCode.InvalidParams, "cwd is required");
@@ -148,7 +162,7 @@ export async function handleStatus(args) {
148
162
  const currentPhase = nextPhase?.id ?? state.status.current_phase ?? "complete";
149
163
  const completed = state.pipeline.phase_order.filter((id) => state.status.phases[id]?.status === "complete").length;
150
164
  const totalCarryForward = Object.values(state.status.phases).reduce((sum, phase) => sum + (phase.carry_forward?.length ?? 0), 0);
151
- const currentOpenQuestions = currentPhase === "complete" ? 0 : state.status.phases[currentPhase]?.open_questions.length ?? 0;
165
+ const currentOpenQuestions = currentPhase === "complete" ? 0 : state.status.phases[currentPhase]?.open_questions?.length ?? 0;
152
166
  const terminalOpenQuestions = Object.values(state.status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
153
167
  const postPipelinePending = state.status.post_pipeline.filter((entry) => entry.status !== "resolved").length;
154
168
  const scaffoldNotice = describeScaffoldStaleness(state);
@@ -237,7 +251,7 @@ export async function handlePhase(args) {
237
251
  export async function handleValidate(args) {
238
252
  const cwd = await validateCwd(args.cwd);
239
253
  const state = await requireWorkspace(cwd);
240
- const validation = await validatePhaseOutput(state, args.phase?.trim() || undefined).catch((error) => {
254
+ const validation = await validatePhaseOutput(state, requireOptionalPhase(args.phase)).catch((error) => {
241
255
  throw new McpError(ErrorCode.InvalidParams, error instanceof Error ? error.message : String(error));
242
256
  });
243
257
  const summary = buildValidationSummary(validation).join("\n");
@@ -256,7 +270,7 @@ export async function handleValidate(args) {
256
270
  export async function handleComplete(args) {
257
271
  const cwd = await validateCwd(args.cwd);
258
272
  const initialState = await requireWorkspace(cwd);
259
- const validation = await validatePhaseOutput(initialState, args.phase?.trim() || undefined).catch((error) => {
273
+ const validation = await validatePhaseOutput(initialState, requireOptionalPhase(args.phase)).catch((error) => {
260
274
  throw new McpError(ErrorCode.InvalidParams, error instanceof Error ? error.message : String(error));
261
275
  });
262
276
  if (validation.overall === "FAIL" || validation.overall === "MISSING") {
@@ -377,9 +391,17 @@ async function resolveLibraryPath(args) {
377
391
  * the publish tool enforces.
378
392
  */
379
393
  async function loadEffectiveConfig(cwd) {
380
- return typeof cwd === "string" && cwd.trim() !== ""
381
- ? loadCodecartoConfig(join(cwd.trim(), ".codecarto"))
382
- : loadUserConfig();
394
+ if (typeof cwd !== "string" || cwd.trim() === "")
395
+ return loadUserConfig();
396
+ // A relative path here resolves against the server process's working
397
+ // directory, not the caller's, so it would quietly read some other
398
+ // workspace's config — and this config decides whether publish_confirm
399
+ // gates the write. Refuse rather than answer from the wrong file.
400
+ const trimmed = cwd.trim();
401
+ if (!isAbsolute(trimmed)) {
402
+ throw new McpError(ErrorCode.InvalidParams, `cwd must be an absolute path, got: ${trimmed}`);
403
+ }
404
+ return loadCodecartoConfig(join(trimmed, ".codecarto"));
383
405
  }
384
406
  function asStringArray(value, fieldName) {
385
407
  if (!Array.isArray(value)) {
@@ -731,6 +753,13 @@ export async function handleLibraryInit(args) {
731
753
  }
732
754
  export async function handleVision(args) {
733
755
  const cwd = await validateCwd(args.cwd);
756
+ // raw_text is interpolated straight into the returned prompt, so an absent
757
+ // value silently becomes the literal string "undefined" for the agent to
758
+ // synthesize a vision brief from. Every sibling handler validates its
759
+ // required string argument; this one did not.
760
+ if (typeof args.raw_text !== "string" || !args.raw_text.trim()) {
761
+ throw new McpError(ErrorCode.InvalidParams, "raw_text is required (the user's raw product description)");
762
+ }
734
763
  const workspaceDir = join(cwd, ".codecarto");
735
764
  const interviewPath = join(workspaceDir, "findings", "vision-capture", "INTERVIEW.md");
736
765
  const visionPath = join(workspaceDir, "inputs", "vision.md");
@@ -757,8 +786,8 @@ export async function handleVision(args) {
757
786
  });
758
787
  }
759
788
  export async function handleConfig(args) {
760
- const config = args.cwd
761
- ? await loadCodecartoConfig(join(args.cwd, ".codecarto"))
789
+ const config = args.cwd !== undefined && args.cwd !== null
790
+ ? await loadCodecartoConfig(join(await validateCwd(args.cwd), ".codecarto"))
762
791
  : await loadUserConfig();
763
792
  const userConfigPath = resolveUserConfigPath();
764
793
  const workspaceConfigPath = args.cwd ? join(args.cwd, ".codecarto", "workflow", "config.yaml") : null;
@@ -976,6 +1005,11 @@ export async function handleBroadside(args) {
976
1005
  includeTriage,
977
1006
  retryTruncated,
978
1007
  onStatus: (lensId, status, counts) => lines.push(` ${lensId}: ${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`),
1008
+ }).catch((error) => {
1009
+ // The `collect` action normalizes this same call; without it here,
1010
+ // a failure during submit-with-wait reached the client as an
1011
+ // opaque InternalError instead of naming its cause.
1012
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
979
1013
  });
980
1014
  lines.push("", collectResultText(collect));
981
1015
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
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",