codecartographer-pi 0.22.2 → 0.23.0

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.
@@ -86,6 +86,21 @@ Submits pre-flight the chosen model: pricing comes from the live catalog
86
86
  model that does not advertise structured-output support is refused outright,
87
87
  because every lens depends on `json_schema` response_format.
88
88
 
89
+ Each lens reads a scope of the repository. Architecture reads the manifest,
90
+ entry point, README, and file tree; defect, conventions, and porting read
91
+ every source file; the **security** and **API** lenses target where the
92
+ trust boundary usually lives — `server/**`, `**/auth*`, `**/middleware/**`,
93
+ `SECURITY.md` (security) and `server/**`, `api/**`, `src/server/**`,
94
+ `src/api/**`, `**/*routes*`, `**/*router*`, `**/*handler*`, `**/*endpoint*`
95
+ (API). A repository whose server is `src/server.js` matches none of those, so
96
+ when the targeted patterns find nothing those two lenses **fall back to every
97
+ source file** — priced as such, chunked at the lens's slice size rather than
98
+ truncated, and said so on the lens line of the estimate, the submit report,
99
+ `status`, and the prompt the model receives. A lens whose targeted patterns
100
+ and fallback both find nothing (only test files, say) is skipped with a line
101
+ naming both. `max_cost` is the guard against a fallback scan on a large
102
+ repository being more than you meant to spend.
103
+
89
104
  Collect runs two cross-lens post-passes by default: **synthesis** (the
90
105
  executive report) and **triage** (the prioritized work order). Pass
91
106
  `include_synthesis: false` or `include_triage: false` on collect to skip one.
@@ -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.22.2
6
+ scaffold_version: 0.23.0
package/README.md CHANGED
@@ -383,7 +383,7 @@ Each workflow tool accepts an absolute `cwd` for the target repository. `codecar
383
383
 
384
384
  ## Broad-Side (batch reconnaissance)
385
385
 
386
- Broad-Side is the cheap sweep you run *before* the expensive interactive run. It fires six analysis lenses — architecture, API surface, security, mechanical defect scan, convention extraction, porting — at a repository as single-turn prompts over the [OpenRouter Batch API](https://openrouter.ai/docs), then cross-references them into one executive report (`synthesis.md`) and a prioritized P0–P3 work order (`triage.md`).
386
+ Broad-Side is the cheap sweep you run *before* the expensive interactive run. It fires six analysis lenses — architecture, API surface, security, mechanical defect scan, convention extraction, porting — at a repository as single-turn prompts over the [OpenRouter Batch API](https://openrouter.ai/docs), then cross-references them into one executive report (`synthesis.md`) and a prioritized P0–P3 work order (`triage.md`). The security and API lenses target the paths where a trust boundary usually lives (`server/`, `auth*`, `middleware/`, routers and handlers) and fall back to every source file when a repository has none of them — priced as such, and said so on the estimate — so a service whose server is `src/server.js` still gets its security review.
387
387
 
388
388
  **Broad-Side findings are unverified scouting leads, not evidence.** Each lens is one shot: no cross-file traversal, no runtime verification, no builds, no tests. Every finding is a `file:line` pointer that the interactive pipeline — or you — must confirm before it is a fact. That division of labor is the point: a sub-dollar unattended sweep that tells the expensive run where to look. Nothing downstream may cite a Broad-Side report as a source.
389
389
 
@@ -121,6 +121,12 @@ export type FileSlice = {
121
121
  redactedValues?: number;
122
122
  /** The files in this slice that had at least one value redacted. */
123
123
  redactedFiles?: string[];
124
+ /**
125
+ * Set when the lens's targeted globs matched nothing and the slice was
126
+ * built from its fallback globs instead (#319). The estimate, the batch
127
+ * entry, and the prompt all say so.
128
+ */
129
+ fallback?: string;
124
130
  };
125
131
  /**
126
132
  * OpenRouter's unified `reasoning` control, as sent on a lens request.
@@ -203,6 +209,12 @@ export type BroadsideBatchEntry = {
203
209
  error?: unknown;
204
210
  /** Why a `skipped` lens had nothing to submit: the globs that matched no file. */
205
211
  reason?: string;
212
+ /**
213
+ * Set when the lens scanned its fallback scope because its targeted globs
214
+ * matched nothing (#319): "no files matched …; scanned all javascript
215
+ * sources instead". Absent for a targeted scan.
216
+ */
217
+ fallback?: string;
206
218
  /** Set when this lens used a model other than the run default. */
207
219
  model?: string;
208
220
  /** The completion ceiling of this lens's model; bounds the truncation retry. */
@@ -348,6 +360,8 @@ export type BroadsideEstimate = {
348
360
  /** The model this lens would use — `model` unless a per-lens override applies. */
349
361
  model: string;
350
362
  pricing: ModelPricing;
363
+ /** Set when this lens is priced on its fallback scope (#319); see BroadsideBatchEntry.fallback. */
364
+ fallback?: string;
351
365
  }>;
352
366
  /** True when at least one lens uses a model other than the run default. */
353
367
  mixedModels: boolean;
@@ -490,6 +504,15 @@ type LensDefinition = {
490
504
  reasoning?: BroadsideReasoning;
491
505
  skipTestFiles?: boolean;
492
506
  globsFor: (info: RepoInfo) => string[];
507
+ /**
508
+ * Where to look when `globsFor` matches nothing (#319). The security and
509
+ * api lenses target server/, auth, and middleware paths because that is
510
+ * where the trust boundary usually lives; a service whose server is
511
+ * `src/server.js` matched none of them and got no security review at all.
512
+ * The fallback is the language's whole source set — priced as such, and
513
+ * said so in the estimate, the run record, and the prompt.
514
+ */
515
+ fallbackGlobsFor?: (info: RepoInfo) => string[];
493
516
  systemPrompt: (info: RepoInfo) => string;
494
517
  userPrompt: (info: RepoInfo, source: string, moduleName: string) => string;
495
518
  };
@@ -500,6 +523,20 @@ export declare const BROADSIDE_LANGUAGES: readonly ["go", "python", "rust", "typ
500
523
  export declare function collectRepoInfo(targetDir: string, opts?: {
501
524
  redact?: boolean;
502
525
  }): Promise<RepoInfo>;
526
+ type CollectedFile = {
527
+ relPath: string;
528
+ moduleName: string;
529
+ };
530
+ /**
531
+ * The files a lens will read: its targeted globs, or — when those match
532
+ * nothing and the lens declares a fallback — the fallback globs, with a
533
+ * sentence saying so (#319). The sentence travels to the estimate, the
534
+ * batch entry, and the prompt, so a fallback scan is never a silent one.
535
+ */
536
+ export declare function selectLensFiles(allFiles: string[], lens: LensDefinition, info: RepoInfo): {
537
+ files: CollectedFile[];
538
+ fallback?: string;
539
+ };
503
540
  export declare function gatherSlices(targetDir: string, lens: LensDefinition, info: RepoInfo, opts?: {
504
541
  redact?: boolean;
505
542
  }): Promise<FileSlice[]>;
@@ -698,6 +698,7 @@ const LENSES = {
698
698
  "**/*handler*",
699
699
  "**/*endpoint*",
700
700
  ],
701
+ fallbackGlobsFor: (info) => [info.sourceGlob],
701
702
  systemPrompt: () => "You are a senior API auditor. Given source files from an HTTP server, " +
702
703
  "extract every HTTP endpoint (method, path, handler function, auth requirement) " +
703
704
  "and every key request/response data type. Return a JSON object following the " +
@@ -718,6 +719,7 @@ const LENSES = {
718
719
  globsFor: (info) => info.language === "go"
719
720
  ? ["server/**/*.go", "server/*.go", "**/auth*.go", "**/middleware/**/*.go", "SECURITY.md"]
720
721
  : ["server/**", "**/auth*", "**/middleware/**", "SECURITY.md"],
722
+ fallbackGlobsFor: (info) => [info.sourceGlob],
721
723
  systemPrompt: () => "You are a security engineer performing a first-pass review of a codebase. " +
722
724
  "Given source files, identify potential security issues — focusing on " +
723
725
  "authentication, authorization, input validation, TLS, secrets handling, " +
@@ -1237,8 +1239,7 @@ function resolveSliceMode(lens, files, totalChars) {
1237
1239
  return lens.sliceBy;
1238
1240
  return totalChars > lens.maxChars ? "directory" : "none";
1239
1241
  }
1240
- function collectLensFiles(allFiles, lens, info) {
1241
- const globs = lens.globsFor(info).filter(Boolean);
1242
+ function collectFilesMatching(allFiles, lens, globs) {
1242
1243
  if (globs.length === 0)
1243
1244
  return [];
1244
1245
  const out = [];
@@ -1253,6 +1254,30 @@ function collectLensFiles(allFiles, lens, info) {
1253
1254
  }
1254
1255
  return out;
1255
1256
  }
1257
+ /**
1258
+ * The files a lens will read: its targeted globs, or — when those match
1259
+ * nothing and the lens declares a fallback — the fallback globs, with a
1260
+ * sentence saying so (#319). The sentence travels to the estimate, the
1261
+ * batch entry, and the prompt, so a fallback scan is never a silent one.
1262
+ */
1263
+ export function selectLensFiles(allFiles, lens, info) {
1264
+ const globs = lens.globsFor(info).filter(Boolean);
1265
+ const targeted = collectFilesMatching(allFiles, lens, globs);
1266
+ if (targeted.length > 0 || globs.length === 0 || !lens.fallbackGlobsFor)
1267
+ return { files: targeted };
1268
+ const fallbackGlobs = lens.fallbackGlobsFor(info).filter(Boolean);
1269
+ const files = collectFilesMatching(allFiles, lens, fallbackGlobs);
1270
+ if (files.length === 0)
1271
+ return { files };
1272
+ return {
1273
+ files,
1274
+ fallback: `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}; ` +
1275
+ `scanned all ${info.language} sources (${fallbackGlobs.join(", ")}) instead`,
1276
+ };
1277
+ }
1278
+ function collectLensFiles(allFiles, lens, info) {
1279
+ return selectLensFiles(allFiles, lens, info).files;
1280
+ }
1256
1281
  async function slurpFileList(targetDir, files, maxChars, redact = true) {
1257
1282
  const slices = [];
1258
1283
  let currentModule = "";
@@ -1329,16 +1354,18 @@ export async function gatherSlices(targetDir, lens, info, opts = {}) {
1329
1354
  return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
1330
1355
  }
1331
1356
  const { files: allFiles } = await listRepoFiles(targetDir);
1332
- const files = collectLensFiles(allFiles, lens, info);
1357
+ const { files, fallback } = selectLensFiles(allFiles, lens, info);
1333
1358
  const totalChars = await sumFileSizes(targetDir, files);
1334
1359
  const mode = resolveSliceMode(lens, files, totalChars);
1335
- if (mode === "none") {
1360
+ const slices = mode === "none"
1336
1361
  // Whole-repo slice: one module named after the repo, so a small
1337
1362
  // repo produces a single request instead of one per directory.
1338
- const single = files.map((f) => ({ ...f, moduleName: info.name }));
1339
- return slurpFileList(targetDir, single, lens.maxChars, redact);
1340
- }
1341
- return slurpFileList(targetDir, files, lens.maxChars, redact);
1363
+ ? await slurpFileList(targetDir, files.map((f) => ({ ...f, moduleName: info.name })), lens.maxChars, redact)
1364
+ : await slurpFileList(targetDir, files, lens.maxChars, redact);
1365
+ if (fallback)
1366
+ for (const slice of slices)
1367
+ slice.fallback = fallback;
1368
+ return slices;
1342
1369
  }
1343
1370
  async function sumFileSizes(targetDir, files) {
1344
1371
  let total = 0;
@@ -1362,7 +1389,17 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
1362
1389
  model,
1363
1390
  messages: [
1364
1391
  { role: "system", content: lens.systemPrompt(info) },
1365
- { role: "user", content: lens.userPrompt(info, slice.content, slice.moduleName) },
1392
+ {
1393
+ role: "user",
1394
+ content:
1395
+ // A fallback scan is not "server source files": say what it is,
1396
+ // so the model judges the trust boundary wherever it appears
1397
+ // and does not report the missing server/ as a finding (#319).
1398
+ (slice.fallback
1399
+ ? `NOTE: this repository has no files under the paths this lens usually reads (${slice.fallback}). ` +
1400
+ "What follows is every source file it has; locate the trust boundary and the request-handling code wherever they live.\n\n"
1401
+ : "") + lens.userPrompt(info, slice.content, slice.moduleName),
1402
+ },
1366
1403
  ],
1367
1404
  response_format: { type: "json_schema", json_schema: SCHEMAS[lens.schemaName] },
1368
1405
  max_tokens: maxTokensOverride ?? lens.maxTokens,
@@ -2320,11 +2357,14 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2320
2357
  slicesByLens.set(lensId, slices);
2321
2358
  if (slices.length === 0) {
2322
2359
  const globs = lens.globsFor(info).filter(Boolean);
2360
+ const fallbackGlobs = lens.fallbackGlobsFor?.(info).filter(Boolean) ?? [];
2323
2361
  skipReasons.set(lensId, globs.length === 0
2324
2362
  ? "the lens has no file patterns for this language"
2325
2363
  : matchedBeforeIncremental > 0
2326
2364
  ? "incremental: none of this lens's files changed since the previous run"
2327
- : `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}`);
2365
+ : `no files matched ${globs.join(", ")}` +
2366
+ (fallbackGlobs.length > 0 ? ` or the fallback ${fallbackGlobs.join(", ")}` : "") +
2367
+ (lens.skipTestFiles ? " (test files excluded)" : ""));
2328
2368
  }
2329
2369
  const lensModel = modelForLens(lensId);
2330
2370
  const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
@@ -2347,15 +2387,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2347
2387
  const approved = await opts.confirm({
2348
2388
  model,
2349
2389
  pricing,
2350
- lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => ({
2351
- lensId: lens.id,
2352
- name: lens.name,
2353
- slices: (slicesByLens.get(lens.id) ?? []).length,
2354
- maxTokens,
2355
- cost,
2356
- model: lensModel,
2357
- pricing: lensPricing,
2358
- })),
2390
+ lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => {
2391
+ const fallback = (slicesByLens.get(lens.id) ?? []).find((slice) => slice.fallback)?.fallback;
2392
+ return {
2393
+ lensId: lens.id,
2394
+ name: lens.name,
2395
+ slices: (slicesByLens.get(lens.id) ?? []).length,
2396
+ maxTokens,
2397
+ cost,
2398
+ model: lensModel,
2399
+ pricing: lensPricing,
2400
+ ...(fallback && { fallback }),
2401
+ };
2402
+ }),
2359
2403
  mixedModels: perLensEstimate.some(({ lensModel }) => lensModel !== model),
2360
2404
  totalCost: estimatedTotalCost,
2361
2405
  inputTokens: estimatedInputTokens,
@@ -2419,12 +2463,15 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2419
2463
  const requests = slices.map((sl, i) => buildBatchRequest(lens, info, sl, i, slices.length, lensModel, maxTokens, config.reasoning ?? undefined));
2420
2464
  for (const request of requests)
2421
2465
  requestsByCustomId[request.custom_id] = request;
2466
+ const fallback = slices.find((slice) => slice.fallback)?.fallback;
2422
2467
  const entry = {
2423
2468
  batchId: "",
2424
2469
  requests: requests.length,
2425
2470
  status: "submitting",
2426
2471
  submittedAt: new Date().toISOString(),
2427
2472
  estimatedCost: priced.cost,
2473
+ // A scan of the fallback scope is recorded as such (#319).
2474
+ ...(fallback && { fallback }),
2428
2475
  // Recorded per lens so collect's truncation retry re-submits against
2429
2476
  // the model and ceiling this lens actually used, not the run default.
2430
2477
  ...(lensModel !== model && { model: lensModel }),
@@ -3256,7 +3303,9 @@ export function estimateSubmitText(result, lenses) {
3256
3303
  ? ` — ${explainBatchError(entry.error)}`
3257
3304
  : !entry.batchId && entry.reason
3258
3305
  ? ` — ${entry.reason}`
3259
- : "";
3306
+ : entry.fallback
3307
+ ? ` — ${entry.fallback}`
3308
+ : "";
3260
3309
  lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
3261
3310
  }
3262
3311
  if (result.repo) {
@@ -3492,7 +3541,7 @@ export function statusText(state) {
3492
3541
  if (!entry)
3493
3542
  continue;
3494
3543
  lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}` +
3495
- (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : ""));
3544
+ (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : entry.fallback ? ` — ${entry.fallback}` : ""));
3496
3545
  }
3497
3546
  lines.push(` synthesis: ${run.synthesis.status}`);
3498
3547
  lines.push(` triage: ${run.triage?.status ?? "pending"}`);
@@ -0,0 +1,18 @@
1
+ export interface CompletionCandidate {
2
+ value: string;
3
+ label?: string;
4
+ description?: string;
5
+ }
6
+ export interface CompletionItem {
7
+ value: string;
8
+ label: string;
9
+ description?: string;
10
+ }
11
+ /**
12
+ * Complete the last whitespace-separated token of `prefix` from `candidates`,
13
+ * returning items whose `value` is the full argument text Pi should put in
14
+ * the line — every earlier token, then the candidate. A candidate already
15
+ * typed as an earlier token is not offered again. Returns null when nothing
16
+ * matches, which is what Pi expects for "no popup".
17
+ */
18
+ export declare function completeLastToken(prefix: string, candidates: readonly CompletionCandidate[]): CompletionItem[] | null;
@@ -0,0 +1,36 @@
1
+ // Argument completion for the /codecarto-* slash commands.
2
+ //
3
+ // Pi hands `getArgumentCompletions` everything typed after the command name,
4
+ // and when the user accepts an item it replaces all of that text with the
5
+ // item's `value` (pi 0.85: `argumentText = textBeforeCursor.slice(spaceIndex
6
+ // + 1)` on the way in, `applyCompletion` slicing `cursorCol - prefix.length`
7
+ // on the way out). A completer that matches the *last* token but returns a
8
+ // bare flag therefore erases every flag typed before it: with the popup open,
9
+ // `/codecarto-next --auto --llm-steer` became `/codecarto-next --llm-steer` on
10
+ // Enter, the steered phase ran alone, and the auto run never started. The
11
+ // same shape turned `--auto --strict` into `--strict`, the one combination
12
+ // the parser rejects.
13
+ //
14
+ // So: match the last token, but return the whole line.
15
+ /**
16
+ * Complete the last whitespace-separated token of `prefix` from `candidates`,
17
+ * returning items whose `value` is the full argument text Pi should put in
18
+ * the line — every earlier token, then the candidate. A candidate already
19
+ * typed as an earlier token is not offered again. Returns null when nothing
20
+ * matches, which is what Pi expects for "no popup".
21
+ */
22
+ export function completeLastToken(prefix, candidates) {
23
+ const tokens = prefix.split(/\s+/);
24
+ const last = tokens.pop() ?? "";
25
+ const earlier = tokens.filter((token) => token.length > 0);
26
+ const head = earlier.join(" ");
27
+ const typed = new Set(earlier);
28
+ const items = candidates
29
+ .filter((candidate) => candidate.value.startsWith(last) && !typed.has(candidate.value))
30
+ .map((candidate) => ({
31
+ value: head ? `${head} ${candidate.value}` : candidate.value,
32
+ label: candidate.label ?? candidate.value,
33
+ ...(candidate.description && { description: candidate.description }),
34
+ }));
35
+ return items.length > 0 ? items : null;
36
+ }
@@ -6,6 +6,7 @@ import { parseDashboardFlags } from "./dashboard-flags.js";
6
6
  import { narrateDashboard } from "./dashboard-narrator.js";
7
7
  import { parseBroadsideFlags, KNOWN_BROADSIDE_TOKENS } from "./broadside-flags.js";
8
8
  import { parseNextFlags } from "./next-flags.js";
9
+ import { completeLastToken } from "./completions.js";
9
10
  import { buildPiGuideMessage } from "./guide-framing.js";
10
11
  import { isCtxLive, notifyCtx } from "./notify.js";
11
12
  import { phaseCompactionExtension } from "./phase-compaction.js";
@@ -129,11 +130,14 @@ function describeBroadsideEstimate(estimate) {
129
130
  `Rates: $${estimate.pricing.inputPerM.toFixed(4)}/M in · $${estimate.pricing.outputPerM.toFixed(4)}/M out`,
130
131
  "",
131
132
  "Per lens:",
132
- ...estimate.lenses.map(({ name, slices, cost, model }) => {
133
+ ...estimate.lenses.map(({ name, slices, cost, model, fallback }) => {
133
134
  // Naming the model only when it differs keeps the common case quiet
134
135
  // and makes a mixed-model run impossible to approve without noticing.
135
136
  const override = estimate.mixedModels && model !== estimate.model ? ` on ${model}` : "";
136
- return ` ${name}: ${slices} slice${slices === 1 ? "" : "s"} — ~$${cost.toFixed(4)}${override}`;
137
+ // A lens priced on its fallback scope says so here, where the
138
+ // spend is approved — every source file, not a server directory (#319).
139
+ const scope = fallback ? `\n ↳ ${fallback}` : "";
140
+ return ` ${name}: ${slices} slice${slices === 1 ? "" : "s"} — ~$${cost.toFixed(4)}${override}${scope}`;
137
141
  }),
138
142
  "",
139
143
  `Estimated total: ~$${estimate.totalCost.toFixed(4)} ` +
@@ -432,12 +436,7 @@ export default function codeCartographerExtension(pi) {
432
436
  });
433
437
  pi.registerCommand("codecarto-init", {
434
438
  description: "Initialize .codecarto/ in the current repository",
435
- getArgumentCompletions: (prefix) => {
436
- const items = Object.keys(PIPELINE_ALIASES)
437
- .filter((value) => value.startsWith(prefix))
438
- .map((value) => ({ value, label: value }));
439
- return items.length > 0 ? items : null;
440
- },
439
+ getArgumentCompletions: (prefix) => completeLastToken(prefix, Object.keys(PIPELINE_ALIASES).map((value) => ({ value }))),
441
440
  handler: async (args, ctx) => {
442
441
  const trimmedArgs = args.trim();
443
442
  const pipelineChoice = resolvePipelineChoice(trimmedArgs);
@@ -536,12 +535,7 @@ export default function codeCartographerExtension(pi) {
536
535
  });
537
536
  pi.registerCommand("codecarto-switch-pipeline", {
538
537
  description: "Switch the active pipeline without losing findings or progress: /codecarto-switch-pipeline <variant>",
539
- getArgumentCompletions: (prefix) => {
540
- const items = Object.keys(PIPELINE_ALIASES)
541
- .filter((value) => value.startsWith(prefix))
542
- .map((value) => ({ value, label: value }));
543
- return items.length > 0 ? items : null;
544
- },
538
+ getArgumentCompletions: (prefix) => completeLastToken(prefix, Object.keys(PIPELINE_ALIASES).map((value) => ({ value }))),
545
539
  handler: async (args, ctx) => {
546
540
  const trimmedArgs = args.trim();
547
541
  if (!trimmedArgs) {
@@ -595,16 +589,17 @@ export default function codeCartographerExtension(pi) {
595
589
  // --strict is offered only once --auto is present, because on its own
596
590
  // it is rejected — suggesting it standalone invites the one error the
597
591
  // parser has.
592
+ // Each item's value is the whole argument line (see completions.ts):
593
+ // accepting `--llm-steer` after `--auto ` keeps the `--auto`.
598
594
  const autoAlreadyTyped = prefix.includes("--auto");
599
- const items = [
600
- { value: "--auto", label: "--auto", description: "run every remaining phase back to back (recommended with --llm-steer)" },
601
- { value: "--llm-steer", label: "--llm-steer", description: "seed each phase from the previous phase's closeout; no effect on the first phase" },
602
- { value: "--no-llm-steer", label: "--no-llm-steer", description: "force steering off when the workspace config turns it on" },
595
+ return completeLastToken(prefix, [
596
+ { value: "--auto", description: "run every remaining phase back to back (recommended with --llm-steer)" },
597
+ { value: "--llm-steer", description: "seed each phase from the previous phase's closeout; no effect on the first phase" },
598
+ { value: "--no-llm-steer", description: "force steering off when the workspace config turns it on" },
603
599
  ...(autoAlreadyTyped
604
- ? [{ value: "--strict", label: "--strict", description: "with --auto: stop on PASS WITH GAPS instead of advancing" }]
600
+ ? [{ value: "--strict", description: "with --auto: stop on PASS WITH GAPS instead of advancing" }]
605
601
  : []),
606
- ].filter((item) => item.value.startsWith(prefix.split(/\s+/).pop() ?? prefix));
607
- return items.length > 0 ? items : null;
602
+ ]);
608
603
  },
609
604
  handler: async (args, ctx) => {
610
605
  const flags = parseNextFlags(args);
@@ -967,10 +962,7 @@ export default function codeCartographerExtension(pi) {
967
962
  description: "Read the packaged CodeCartographer agent guide into the session: /codecarto-guide [topic]",
968
963
  getArgumentCompletions: async (prefix) => {
969
964
  const topics = await listGuideTopics().catch(() => ["overview"]);
970
- const items = topics
971
- .filter((value) => value.startsWith(prefix))
972
- .map((value) => ({ value, label: value }));
973
- return items.length > 0 ? items : null;
965
+ return completeLastToken(prefix, topics.map((value) => ({ value })));
974
966
  },
975
967
  handler: async (args, ctx) => {
976
968
  // The guide is packaged with the extension, not copied into a
@@ -1004,12 +996,9 @@ export default function codeCartographerExtension(pi) {
1004
996
  });
1005
997
  pi.registerCommand("codecarto-broadside", {
1006
998
  description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models] [lenses…] [--model=ID] [--lens-model=LENS:ID] [flags]",
1007
- getArgumentCompletions: (prefix) => {
1008
- const items = KNOWN_BROADSIDE_TOKENS
1009
- .filter((value) => value.startsWith(prefix))
1010
- .map((value) => ({ value, label: value }));
1011
- return items.length > 0 ? items : null;
1012
- },
999
+ // Completes the token under the cursor, so lens names and flags are
1000
+ // offered after the action too, and keeps everything typed before it.
1001
+ getArgumentCompletions: (prefix) => completeLastToken(prefix, KNOWN_BROADSIDE_TOKENS.map((value) => ({ value }))),
1013
1002
  handler: async (args, ctx) => {
1014
1003
  const flags = parseBroadsideFlags(args);
1015
1004
  if (flags.unknown.length > 0) {
@@ -1439,12 +1428,7 @@ export default function codeCartographerExtension(pi) {
1439
1428
  });
1440
1429
  pi.registerCommand("codecarto-dashboard", {
1441
1430
  description: "Regenerate .codecarto/dashboard.html (use --narrate for an LLM executive summary)",
1442
- getArgumentCompletions: (prefix) => {
1443
- const items = ["--narrate"]
1444
- .filter((value) => value.startsWith(prefix))
1445
- .map((value) => ({ value, label: value }));
1446
- return items.length > 0 ? items : null;
1447
- },
1431
+ getArgumentCompletions: (prefix) => completeLastToken(prefix, [{ value: "--narrate" }]),
1448
1432
  handler: async (args, ctx) => {
1449
1433
  const flags = parseDashboardFlags(args);
1450
1434
  if (flags.unknown.length > 0) {
@@ -1515,10 +1499,7 @@ export default function codeCartographerExtension(pi) {
1515
1499
  description: "Apply a post-pipeline amendment from .codecarto/scratch/amendments/, after a preview: /codecarto-amend <name | scratch/amendments/name.yaml>",
1516
1500
  getArgumentCompletions: async (prefix) => {
1517
1501
  const names = await listAmendmentNames(join(sessionCwd ?? process.cwd(), ".codecarto"));
1518
- const items = names
1519
- .filter((value) => value.startsWith(prefix))
1520
- .map((value) => ({ value, label: value }));
1521
- return items.length > 0 ? items : null;
1502
+ return completeLastToken(prefix, names.map((value) => ({ value })));
1522
1503
  },
1523
1504
  handler: async (args, ctx) => {
1524
1505
  const state = await ensureWorkspaceState(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
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",