codecartographer-pi 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/yaml.js CHANGED
@@ -126,6 +126,19 @@ export function parseSimpleYaml(raw) {
126
126
  };
127
127
  const parseMapping = (indent) => {
128
128
  const result = {};
129
+ // Duplicate detection tracks keys explicitly rather than testing
130
+ // `key in result`. A bare object inherits from Object.prototype, so
131
+ // `"constructor" in result` is already true before anything is parsed —
132
+ // a document whose key is `constructor`, `toString`, `valueOf` or any
133
+ // other prototype member was rejected as a duplicate on first sight.
134
+ const seen = new Set();
135
+ // Assignment goes through defineProperty for the same reason: plain
136
+ // `result[key] = value` with the key `__proto__` invokes the prototype
137
+ // setter instead of creating an entry, so a hand-edited YAML file could
138
+ // change the shape of every object in the process rather than parse.
139
+ const assign = (key, value) => {
140
+ Object.defineProperty(result, key, { value, writable: true, enumerable: true, configurable: true });
141
+ };
129
142
  while (index < lines.length) {
130
143
  skipBlank();
131
144
  if (index >= lines.length)
@@ -147,9 +160,10 @@ export function parseSimpleYaml(raw) {
147
160
  const key = trimmed.slice(0, separator).trim();
148
161
  const rawValue = trimmed.slice(separator + 1).trim();
149
162
  index++;
150
- if (key in result) {
163
+ if (seen.has(key)) {
151
164
  throw new Error(`Duplicate YAML key: ${key} near line: ${line.trim()}`);
152
165
  }
166
+ seen.add(key);
153
167
  if (rawValue === "|" || rawValue === "|-") {
154
168
  const blockLines = [];
155
169
  let contentIndent = null;
@@ -168,19 +182,19 @@ export function parseSimpleYaml(raw) {
168
182
  index++;
169
183
  }
170
184
  const content = blockLines.join("\n").replace(/\n+$/, "");
171
- result[key] = rawValue === "|" ? `${content}\n` : content;
185
+ assign(key, rawValue === "|" ? `${content}\n` : content);
172
186
  continue;
173
187
  }
174
188
  if (rawValue !== "") {
175
- result[key] = parseYamlScalar(rawValue);
189
+ assign(key, parseYamlScalar(rawValue));
176
190
  continue;
177
191
  }
178
192
  skipBlank();
179
193
  if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
180
- result[key] = parseBlock(countIndent(lines[index] ?? ""));
194
+ assign(key, parseBlock(countIndent(lines[index] ?? "")));
181
195
  }
182
196
  else {
183
- result[key] = null;
197
+ assign(key, null);
184
198
  }
185
199
  }
186
200
  return result;
@@ -198,7 +212,13 @@ export function parseSimpleYaml(raw) {
198
212
  const trimmed = line.slice(lineIndent);
199
213
  if (lineIndent !== indent || (!trimmed.startsWith("- ") && trimmed !== "-"))
200
214
  break;
201
- const rawItem = trimmed === "-" ? "" : trimmed.slice(2).trim();
215
+ const afterDash = trimmed === "-" ? "" : trimmed.slice(2);
216
+ const rawItem = afterDash.trim();
217
+ // A sequence item's mapping continues at the column where its own
218
+ // content starts, which is not always two past the dash: `- id: x`
219
+ // aligns its siblings under the `i`, four columns in. Hardcoding two
220
+ // rejected that valid layout as bad indentation.
221
+ const itemIndent = indent + 2 + (afterDash.length - afterDash.trimStart().length);
202
222
  index++;
203
223
  if (rawItem === "") {
204
224
  skipBlank();
@@ -221,7 +241,7 @@ export function parseSimpleYaml(raw) {
221
241
  item[key] = parseBlock(countIndent(lines[index] ?? ""));
222
242
  }
223
243
  if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
224
- const nested = parseMapping(indent + 2);
244
+ const nested = parseMapping(itemIndent);
225
245
  for (const [nestedKey, nestedValue] of Object.entries(nested))
226
246
  item[nestedKey] = nestedValue;
227
247
  }
@@ -11,6 +11,7 @@ import { readFile, readdir } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
12
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
13
13
  import { closeoutFileName, pathExists } from "../../core/index.js";
14
+ import { createChildModelRuntime } from "./child-model-runtime.js";
14
15
  /** Closeout content over this many bytes is truncated before being passed to
15
16
  * the rewriter. Keeps the orchestrator-side cost predictable. */
16
17
  const CLOSEOUT_BYTE_BUDGET = 8000;
@@ -143,6 +144,7 @@ async function runRewriterOnce(ctx, prompt) {
143
144
  const { session } = await createAgentSession({
144
145
  cwd,
145
146
  agentDir,
147
+ modelRuntime: await createChildModelRuntime(ctx, agentDir),
146
148
  sessionManager: SessionManager.inMemory(cwd),
147
149
  settingsManager: SettingsManager.create(cwd, agentDir),
148
150
  model: ctx.model,
@@ -13,6 +13,7 @@ import { access } from "node:fs/promises";
13
13
  import { join, resolve } from "node:path";
14
14
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
15
15
  import { canonicalPath, isWithinPath } from "../../core/index.js";
16
+ import { createChildModelRuntime } from "./child-model-runtime.js";
16
17
  import { phaseCompactionExtension } from "./phase-compaction.js";
17
18
  // Tools available to the phase sub-agent. Matches the codecarto interception
18
19
  // allowlist (SAFE_TOOL_NAMES in extensions/codecarto/index.ts), minus bash.
@@ -104,6 +105,7 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
104
105
  const { session } = await createAgentSession({
105
106
  cwd,
106
107
  agentDir,
108
+ modelRuntime: await createChildModelRuntime(ctx, agentDir),
107
109
  sessionManager,
108
110
  settingsManager: SettingsManager.create(cwd, agentDir),
109
111
  model: ctx.model,
@@ -44,7 +44,7 @@ export interface AutoCompleteResult {
44
44
  /** Non-gating closure-integrity notes from completion (#122). */
45
45
  warnings: string[];
46
46
  }
47
- export declare function autoCompletePhase(ctx: ExtensionContext, validation: ValidationResult): Promise<AutoCompleteResult>;
47
+ export declare function autoCompletePhase(cwd: string, validation: ValidationResult): Promise<AutoCompleteResult>;
48
48
  export type AutoOutcome = "complete" | "stopped" | "aborted";
49
49
  export interface AutoRunOptions {
50
50
  strict: boolean;
@@ -151,9 +151,13 @@ export function isPhaseRunning(phaseId) {
151
151
  const existing = getPhaseActivity(phaseId);
152
152
  return existing?.status === "running";
153
153
  }
154
- export async function autoCompletePhase(ctx, validation) {
155
- const result = await completeValidatedPhase(ctx.cwd, validation, "/codecarto-complete");
156
- void writeDashboard(ctx.cwd, PACKAGE_VERSION);
154
+ export async function autoCompletePhase(
155
+ // A directory, not a ctx: this only ever needed `ctx.cwd`, and its callers
156
+ // run after a sub-agent has invalidated the ctx they captured, where every
157
+ // property access throws.
158
+ cwd, validation) {
159
+ const result = await completeValidatedPhase(cwd, validation, "/codecarto-complete");
160
+ void writeDashboard(cwd, PACKAGE_VERSION);
157
161
  return result;
158
162
  }
159
163
  export function decideAfterPhase(phaseStatus, phaseError, validation, strict) {
@@ -185,6 +189,9 @@ export function decideAfterPhase(phaseStatus, phaseError, validation, strict) {
185
189
  }
186
190
  export async function runAuto(ctx, pi, initialState, options) {
187
191
  const startedAt = Date.now();
192
+ // Captured once: the loop below spawns a sub-agent per phase, and each one
193
+ // invalidates this ctx, after which reading `ctx.cwd` throws.
194
+ const autoCwd = ctx.cwd;
188
195
  const phasesRun = [];
189
196
  const totalTokens = { input: 0, output: 0, cacheWrite: 0 };
190
197
  const totalPhases = initialState.pipeline.phase_order.length;
@@ -262,7 +269,7 @@ export async function runAuto(ctx, pi, initialState, options) {
262
269
  // decision.action === "continue" → auto-complete and loop.
263
270
  // validation is guaranteed non-null on the continue branch.
264
271
  try {
265
- const { updatedState } = await autoCompletePhase(ctx, validation);
272
+ const { updatedState } = await autoCompletePhase(autoCwd, validation);
266
273
  state = updatedState;
267
274
  phasesRun.push(phase.id);
268
275
  options.onPhaseAdvanced?.(state);
@@ -0,0 +1,2 @@
1
+ import { type ExtensionContext, ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ export declare function createChildModelRuntime(ctx: ExtensionContext, agentDir: string): Promise<ModelRuntime>;
@@ -0,0 +1,44 @@
1
+ // Model runtime for codecarto's child sessions (phase sub-agents, the
2
+ // next-phase rewriter, the dashboard narrator).
3
+ //
4
+ // All three child sessions load with `noExtensions: true` so a globally
5
+ // installed codecarto doesn't register its commands and tool guards twice
6
+ // inside its own sub-agent. That isolation has a side effect: providers
7
+ // registered by *other* global extensions via `pi.registerProvider()` — an
8
+ // Ollama Cloud bridge, a company gateway, any custom `streamSimple` provider —
9
+ // are registered onto the parent's ModelRuntime by the resource loader that
10
+ // loaded them. A child that builds a fresh ModelRuntime never sees them, so
11
+ // the parent's selected model resolves to a provider the child does not know,
12
+ // and the session throws `No API key found for <provider>` before its first
13
+ // turn.
14
+ //
15
+ // Carrying the parent's registered provider configs across keeps the child on
16
+ // the same model the user picked without reloading (and re-registering) the
17
+ // extensions themselves.
18
+ import { join } from "node:path";
19
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
20
+ export async function createChildModelRuntime(ctx, agentDir) {
21
+ const runtime = await ModelRuntime.create({
22
+ authPath: join(agentDir, "auth.json"),
23
+ modelsPath: join(agentDir, "models.json"),
24
+ });
25
+ for (const providerId of ctx.modelRegistry.getRegisteredProviderIds()) {
26
+ const config = ctx.modelRegistry.getRegisteredProviderConfig(providerId);
27
+ if (!config)
28
+ continue;
29
+ try {
30
+ runtime.registerProvider(providerId, config);
31
+ }
32
+ catch {
33
+ // A provider the child can't accept is not worth failing the phase
34
+ // over: the child either doesn't need it (the user's model comes
35
+ // from a different provider) or fails later with the provider-
36
+ // specific error, which is more useful than one thrown here.
37
+ }
38
+ }
39
+ // Recompose the provider table so the newly registered providers land in
40
+ // the availability snapshot that `hasConfiguredAuth` reads. Offline: the
41
+ // parent already paid for any network catalog refresh.
42
+ await runtime.refresh({ allowNetwork: false });
43
+ return runtime;
44
+ }
@@ -14,6 +14,7 @@ import { readFile, readdir, rename, writeFile } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
15
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
16
16
  import { computeTotals, NARRATION_CACHE_RELATIVE_PATH, loadUsage, pathExists, stringifySimpleYaml, } from "../../core/index.js";
17
+ import { createChildModelRuntime } from "./child-model-runtime.js";
17
18
  // Per-closeout byte budget when stuffing the narrator's input. Three
18
19
  // closeouts × 4 KB each ≈ 12 KB of prompt context, which is well under any
19
20
  // reasonable model's input window.
@@ -153,6 +154,7 @@ async function runNarratorOnce(ctx, prompt) {
153
154
  const { session } = await createAgentSession({
154
155
  cwd,
155
156
  agentDir,
157
+ modelRuntime: await createChildModelRuntime(ctx, agentDir),
156
158
  sessionManager: SessionManager.inMemory(cwd),
157
159
  settingsManager: SettingsManager.create(cwd, agentDir),
158
160
  model: ctx.model,
@@ -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,25 @@ 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
+ // Completion refuses for reasons the framework words carefully — a
789
+ // missing phase handoff, a carry-forward without `derives_from`, a
790
+ // closure lacking runtime evidence. Those messages are the whole
791
+ // point of the refusal, and this was the one call in this file that
792
+ // let them escape as a rejection instead of showing them. The
793
+ // irony was sharp: /codecarto-next catches this same throw and tells
794
+ // the user to run /codecarto-complete manually, which then threw.
795
+ let completion;
796
+ try {
797
+ completion = await autoCompletePhase(ctx.cwd, validation);
798
+ }
799
+ catch (error) {
800
+ const message = error instanceof Error ? error.message : String(error);
801
+ lastFeedbackLines = [`Completion refused: ${message}`];
802
+ setUiState(ctx, currentState, lastFeedbackLines);
803
+ ctx.ui.notify(message, "error");
804
+ return;
805
+ }
806
+ const { updatedState, closeoutNotice, warnings } = completion;
742
807
  lastFeedbackLines = [
743
808
  `Completed phase: ${validation.phaseId}`,
744
809
  `Validation: ${validation.overall}`,
@@ -884,7 +949,7 @@ export default function codeCartographerExtension(pi) {
884
949
  }
885
950
  lastFeedbackLines = [`Queued the CodeCartographer guide: ${document.topic}`];
886
951
  if (codecartoModeActive)
887
- void refreshWorkspaceUi(ctx, lastFeedbackLines);
952
+ void refreshWorkspaceUi(ctx, lastFeedbackLines).catch(() => undefined);
888
953
  ctx.ui.notify(`Queued the CodeCartographer guide (${document.topic})`, "info");
889
954
  },
890
955
  });
@@ -917,7 +982,7 @@ export default function codeCartographerExtension(pi) {
917
982
  // A workspace session already has a widget; fold the result into it.
918
983
  if (ctx.hasUI)
919
984
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
920
- void refreshWorkspaceUi(ctx, lines);
985
+ void refreshWorkspaceUi(ctx, lines).catch(() => undefined);
921
986
  }
922
987
  else if (ctx.hasUI) {
923
988
  // 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.19.0",
3
+ "version": "0.19.2",
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",