codecartographer-pi 0.23.0 → 0.24.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.
@@ -16,5 +16,6 @@ export * from "./dashboard.ts";
16
16
  export * from "./library.ts";
17
17
  export * from "./synthesis.ts";
18
18
  export * from "./broadside.ts";
19
+ export * from "./broadside-verify.ts";
19
20
  export * from "./secrets.ts";
20
21
  export * from "./dashboard-writer.ts";
@@ -19,5 +19,6 @@ export * from "./dashboard.js";
19
19
  export * from "./library.js";
20
20
  export * from "./synthesis.js";
21
21
  export * from "./broadside.js";
22
+ export * from "./broadside-verify.js";
22
23
  export * from "./secrets.js";
23
24
  export * from "./dashboard-writer.js";
@@ -2,6 +2,12 @@ import type { ClosureEntry, NormalizedStatus, OpenQuestionEntry, PostPipelineEnt
2
2
  export declare const LOCK_RETRY_MS = 125;
3
3
  export declare const LOCK_TIMEOUT_MS = 5000;
4
4
  export declare const STALE_LOCK_MS = 60000;
5
+ /**
6
+ * How old the removal lock (`<lock>.break`, see {@link withRemovalLock}) may
7
+ * be before it is treated as left behind by a crashed process. It is held
8
+ * across one stat and one rm, so anything this old was abandoned.
9
+ */
10
+ export declare const BREAK_LOCK_STALE_MS = 5000;
5
11
  export declare function assertSafePhaseId(phaseId: string): void;
6
12
  /**
7
13
  * A YAML scalar as text: strings as written, numbers and booleans spelled
@@ -73,5 +79,13 @@ export interface LockHandle {
73
79
  * the token, release removed whoever's lock was there: after a stale break
74
80
  * the previous holder's release deleted the new holder's lock, and a third
75
81
  * writer walked straight in (#227).
82
+ *
83
+ * Every removal — a release or a stale break — happens under the removal
84
+ * lock (`<lock>.break`) and re-checks what it is about to remove there.
85
+ * Two waiters that both saw a stale lock used to both `rm` it: the second
86
+ * `rm` landed after the first waiter had re-created the file, so both held
87
+ * the lock (#342). A file can only be created while the path is free, and
88
+ * only a removal-lock holder removes, so what a holder verified is what it
89
+ * removes.
76
90
  */
77
91
  export declare function acquireLock(lockPath: string): Promise<LockHandle>;
@@ -8,6 +8,12 @@ import { loadYamlFile } from "./yaml.js";
8
8
  export const LOCK_RETRY_MS = 125;
9
9
  export const LOCK_TIMEOUT_MS = 5000;
10
10
  export const STALE_LOCK_MS = 60_000;
11
+ /**
12
+ * How old the removal lock (`<lock>.break`, see {@link withRemovalLock}) may
13
+ * be before it is treated as left behind by a crashed process. It is held
14
+ * across one stat and one rm, so anything this old was abandoned.
15
+ */
16
+ export const BREAK_LOCK_STALE_MS = LOCK_TIMEOUT_MS;
11
17
  export function assertSafePhaseId(phaseId) {
12
18
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phaseId)) {
13
19
  throw new Error(`Invalid phase id: ${phaseId}`);
@@ -434,6 +440,14 @@ export function applyHandoff(status, handoff) {
434
440
  * the token, release removed whoever's lock was there: after a stale break
435
441
  * the previous holder's release deleted the new holder's lock, and a third
436
442
  * writer walked straight in (#227).
443
+ *
444
+ * Every removal — a release or a stale break — happens under the removal
445
+ * lock (`<lock>.break`) and re-checks what it is about to remove there.
446
+ * Two waiters that both saw a stale lock used to both `rm` it: the second
447
+ * `rm` landed after the first waiter had re-created the file, so both held
448
+ * the lock (#342). A file can only be created while the path is free, and
449
+ * only a removal-lock holder removes, so what a holder verified is what it
450
+ * removes.
437
451
  */
438
452
  export async function acquireLock(lockPath) {
439
453
  const startedAt = Date.now();
@@ -464,9 +478,13 @@ export async function acquireLock(lockPath) {
464
478
  try {
465
479
  const lockStat = await stat(lockPath);
466
480
  if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
467
- brokeStale = await describeLockHolder(lockPath);
468
- await rm(lockPath, { force: true }).catch(() => undefined);
469
- continue;
481
+ const broken = await breakStaleLock(lockPath);
482
+ if (broken) {
483
+ brokeStale = broken;
484
+ continue;
485
+ }
486
+ // Another waiter is breaking it, or already has: fall
487
+ // through to a wait and try the open again.
470
488
  }
471
489
  }
472
490
  catch {
@@ -479,6 +497,70 @@ export async function acquireLock(lockPath) {
479
497
  }
480
498
  }
481
499
  }
500
+ /**
501
+ * Run `remove` while holding `<lockPath>.break`, the lock that serializes
502
+ * removals of `lockPath`. Waits up to {@link LOCK_TIMEOUT_MS}; a removal lock
503
+ * older than {@link BREAK_LOCK_STALE_MS} is a crashed remover's and is
504
+ * cleared. Resolves to `undefined` when the removal lock could not be had
505
+ * in time — the caller decides what that means.
506
+ */
507
+ async function withRemovalLock(lockPath, remove) {
508
+ const breakPath = `${lockPath}.break`;
509
+ const startedAt = Date.now();
510
+ while (true) {
511
+ try {
512
+ const handle = await open(breakPath, "wx");
513
+ await handle.close();
514
+ break;
515
+ }
516
+ catch (error) {
517
+ if (error.code !== "EEXIST")
518
+ throw error;
519
+ try {
520
+ const breakStat = await stat(breakPath);
521
+ if (Date.now() - breakStat.mtimeMs > BREAK_LOCK_STALE_MS) {
522
+ await rm(breakPath, { force: true }).catch(() => undefined);
523
+ continue;
524
+ }
525
+ }
526
+ catch {
527
+ continue;
528
+ }
529
+ if (Date.now() - startedAt > LOCK_TIMEOUT_MS)
530
+ return undefined;
531
+ await sleep(LOCK_RETRY_MS);
532
+ }
533
+ }
534
+ try {
535
+ return await remove();
536
+ }
537
+ finally {
538
+ await rm(breakPath, { force: true }).catch(() => undefined);
539
+ }
540
+ }
541
+ /**
542
+ * Remove a lock older than {@link STALE_LOCK_MS}, under the removal lock and
543
+ * only if it is still that old there: the holder may have released and a
544
+ * new one acquired between the caller's stat and this one. Resolves to the
545
+ * broken lock's holder, or null when nothing was removed.
546
+ */
547
+ async function breakStaleLock(lockPath) {
548
+ const broken = await withRemovalLock(lockPath, async () => {
549
+ let lockStat;
550
+ try {
551
+ lockStat = await stat(lockPath);
552
+ }
553
+ catch {
554
+ return null;
555
+ }
556
+ if (Date.now() - lockStat.mtimeMs <= STALE_LOCK_MS)
557
+ return null;
558
+ const holder = await describeLockHolder(lockPath);
559
+ await rm(lockPath, { force: true }).catch(() => undefined);
560
+ return holder;
561
+ });
562
+ return broken ?? null;
563
+ }
482
564
  /**
483
565
  * Remove the lock at `lockPath` only if it is still ours. A lock that vanished
484
566
  * (someone broke it as stale) or that now carries another holder's token is
@@ -486,16 +568,25 @@ export async function acquireLock(lockPath) {
486
568
  * than removed unverified.
487
569
  */
488
570
  async function releaseOwnedLock(lockPath, token) {
489
- let content;
490
- try {
491
- content = await readFile(lockPath, "utf8");
492
- }
493
- catch {
494
- return;
495
- }
496
- if (content.split(/\r?\n/)[2] !== token)
497
- return;
498
- await rm(lockPath, { force: true }).catch(() => undefined);
571
+ const removeIfOwned = async () => {
572
+ let content;
573
+ try {
574
+ content = await readFile(lockPath, "utf8");
575
+ }
576
+ catch {
577
+ return true;
578
+ }
579
+ if (content.split(/\r?\n/)[2] !== token)
580
+ return true;
581
+ await rm(lockPath, { force: true }).catch(() => undefined);
582
+ return true;
583
+ };
584
+ // Serialized with stale breaks so a break in progress cannot land on a
585
+ // lock this release has already replaced (#342). A removal lock that
586
+ // cannot be had in time falls back to the token-checked removal alone —
587
+ // the guarantee before #342, never less.
588
+ if ((await withRemovalLock(lockPath, removeIfOwned)) === undefined)
589
+ await removeIfOwned();
499
590
  }
500
591
  async function describeLockHolder(lockPath) {
501
592
  try {
@@ -32,5 +32,11 @@ export declare function finishPhase(phaseId: string, outcome: {
32
32
  * Clear a phase from the activity map. The widget (M2) will linger finished
33
33
  * phases for a turn or two before calling this; for now (M1) we clear after
34
34
  * a fixed timeout so the orchestrator notification stays meaningful.
35
+ *
36
+ * With `activity`, clear only while the map still holds that very entry.
37
+ * The runner's linger timer fires 30 s after a phase ends; a re-run of the
38
+ * same phase inside that window used to lose its live entry to the earlier
39
+ * run's timer — the widget dropped it and the re-entry guard let a second
40
+ * sub-agent start on the phase (#343).
35
41
  */
36
- export declare function clearPhase(phaseId: string): void;
42
+ export declare function clearPhase(phaseId: string, activity?: PhaseActivity): void;
@@ -42,7 +42,15 @@ export function finishPhase(phaseId, outcome) {
42
42
  * Clear a phase from the activity map. The widget (M2) will linger finished
43
43
  * phases for a turn or two before calling this; for now (M1) we clear after
44
44
  * a fixed timeout so the orchestrator notification stays meaningful.
45
+ *
46
+ * With `activity`, clear only while the map still holds that very entry.
47
+ * The runner's linger timer fires 30 s after a phase ends; a re-run of the
48
+ * same phase inside that window used to lose its live entry to the earlier
49
+ * run's timer — the widget dropped it and the re-entry guard let a second
50
+ * sub-agent start on the phase (#343).
45
51
  */
46
- export function clearPhase(phaseId) {
52
+ export function clearPhase(phaseId, activity) {
53
+ if (activity && phaseActivity.get(phaseId) !== activity)
54
+ return;
47
55
  phaseActivity.delete(phaseId);
48
56
  }
@@ -132,8 +132,9 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
132
132
  return { status: "error", activity, error: message };
133
133
  }
134
134
  finally {
135
- // Linger 30s so /codecarto-status can show that the phase ran.
136
- setTimeout(() => clearPhase(phase.id), 30_000);
135
+ // Linger 30s so /codecarto-status can show that the phase ran. Tied
136
+ // to this run's entry: a re-run inside the window keeps its own (#343).
137
+ setTimeout(() => clearPhase(phase.id, activity), 30_000).unref?.();
137
138
  }
138
139
  }
139
140
  /**
@@ -1,5 +1,5 @@
1
1
  import { type BroadsideLensId } from "../../core/index.ts";
2
- export type BroadsideAction = "submit" | "collect" | "status" | "models";
2
+ export type BroadsideAction = "submit" | "collect" | "status" | "models" | "verify";
3
3
  export interface BroadsideFlags {
4
4
  action: BroadsideAction;
5
5
  /** Empty means "the repository's default lens set". */
@@ -22,11 +22,13 @@ export interface BroadsideFlags {
22
22
  model?: string;
23
23
  /** For submit: per-lens model overrides, layered over config.yaml's (#141). */
24
24
  lensModels?: Partial<Record<BroadsideLensId, string>>;
25
+ /** For verify: how many findings to read (#143). */
26
+ top?: number;
25
27
  benchmarks: boolean;
26
28
  unknown: string[];
27
29
  /** Set on an invalid combination. The caller surfaces it as an error. */
28
30
  error?: string;
29
31
  }
30
32
  /** Every token the completer offers, in the order it offers them. */
31
- export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--run=", "--model=", "--lens-model=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
33
+ export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "verify", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--run=", "--model=", "--lens-model=", "--top=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
32
34
  export declare function parseBroadsideFlags(args: string): BroadsideFlags;
@@ -6,6 +6,7 @@
6
6
  // /codecarto-broadside collect --wait=900
7
7
  // /codecarto-broadside status
8
8
  // /codecarto-broadside models --benchmarks
9
+ // /codecarto-broadside verify --top=10 → read the top findings against the source
9
10
  //
10
11
  // Flags mirror the codecarto_broadside tool parameters, with the negative
11
12
  // forms spelled out because a slash command has no place to pass `false`:
@@ -14,8 +15,9 @@
14
15
  // --max-cost=N --no-retry-truncated
15
16
  // --wait=SECONDS --benchmarks (models only)
16
17
  // --run=ID (collect only: an older run, as listed by status)
17
- // --model=ID (submit only: the run's batch model, as listed by models)
18
+ // --model=ID (submit: the run's batch model, as listed by models; verify: the sync model to read with)
18
19
  // --lens-model=LENS:ID (submit only, repeatable: one lens on its own model)
20
+ // --top=N (verify only: how many findings to read, most severe first)
19
21
  //
20
22
  // A model id itself contains a colon (`vendor/name:batch`), so --lens-model
21
23
  // splits on the first colon only: `security:deepseek/deepseek-v4-pro:batch`.
@@ -28,13 +30,14 @@
28
30
  // The parser never throws. index.ts decides how to surface unknown tokens and
29
31
  // invalid combinations, matching parseNextFlags.
30
32
  import { BROADSIDE_LENS_IDS } from "../../core/index.js";
31
- const ACTIONS = new Set(["submit", "collect", "status", "models"]);
33
+ const ACTIONS = new Set(["submit", "collect", "status", "models", "verify"]);
32
34
  /** Every token the completer offers, in the order it offers them. */
33
35
  export const KNOWN_BROADSIDE_TOKENS = [
34
36
  "submit",
35
37
  "collect",
36
38
  "status",
37
39
  "models",
40
+ "verify",
38
41
  ...BROADSIDE_LENS_IDS,
39
42
  "--incremental",
40
43
  "--no-incremental",
@@ -43,6 +46,7 @@ export const KNOWN_BROADSIDE_TOKENS = [
43
46
  "--run=",
44
47
  "--model=",
45
48
  "--lens-model=",
49
+ "--top=",
46
50
  "--no-synthesis",
47
51
  "--no-triage",
48
52
  "--no-retry-truncated",
@@ -122,6 +126,14 @@ export function parseBroadsideFlags(args) {
122
126
  result.runId = value || undefined;
123
127
  continue;
124
128
  }
129
+ if (token.startsWith("--top=")) {
130
+ const value = parseNumeric(token, "--top", result);
131
+ if (value !== undefined && (!Number.isInteger(value) || value < 1))
132
+ result.error ??= `--top needs a positive whole number (got "${token.slice("--top=".length)}").`;
133
+ else if (value !== undefined)
134
+ result.top = value;
135
+ continue;
136
+ }
125
137
  if (token.startsWith("--model=")) {
126
138
  const value = token.slice("--model=".length).trim();
127
139
  // An empty value is a mistyped selection, not "use the default":
@@ -167,11 +179,17 @@ export function parseBroadsideFlags(args) {
167
179
  if (result.action === "status" && result.waitSeconds !== undefined) {
168
180
  result.error ??= "--wait is only meaningful for submit and collect; status reads recorded state.";
169
181
  }
170
- if (result.runId !== undefined && result.action !== "collect") {
171
- result.error ??= `--run is only meaningful for collect (got action "${result.action}").`;
182
+ if (result.runId !== undefined && result.action !== "collect" && result.action !== "verify") {
183
+ result.error ??= `--run is only meaningful for collect and verify (got action "${result.action}").`;
184
+ }
185
+ if (result.model !== undefined && result.action !== "submit" && result.action !== "verify") {
186
+ result.error ??= `--model is only meaningful for submit and verify (got action "${result.action}").`;
187
+ }
188
+ if (result.top !== undefined && result.action !== "verify") {
189
+ result.error ??= `--top is only meaningful for verify (got action "${result.action}").`;
172
190
  }
173
- if (result.model !== undefined && result.action !== "submit") {
174
- result.error ??= `--model is only meaningful for submit (got action "${result.action}").`;
191
+ if (result.action === "verify" && result.waitSeconds !== undefined) {
192
+ result.error ??= "--wait is only meaningful for submit and collect; verify runs to completion.";
175
193
  }
176
194
  if (result.lensModels !== undefined && result.action !== "submit") {
177
195
  result.error ??= `--lens-model is only meaningful for submit (got action "${result.action}").`;
@@ -10,8 +10,8 @@ import { completeLastToken } from "./completions.js";
10
10
  import { buildPiGuideMessage } from "./guide-framing.js";
11
11
  import { isCtxLive, notifyCtx } from "./notify.js";
12
12
  import { phaseCompactionExtension } from "./phase-compaction.js";
13
- import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, describeConfigProblems, describeIncrementalFallback, 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, writeDashboard, } from "../../core/index.js";
14
- import { initLibrary } from "../../core/library.js";
13
+ import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, runBroadsideVerify, verifyResultText, statusText, describeConfigProblems, describeIncrementalFallback, 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, writeDashboard, } from "../../core/index.js";
14
+ import { initLibrary, isValidSlug } from "../../core/library.js";
15
15
  import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
16
16
  const STATUS_WIDGET_ID = "codecarto-widget";
17
17
  // Broad-Side gets its own widget id: a scout run is legal on a repository with
@@ -995,7 +995,7 @@ export default function codeCartographerExtension(pi) {
995
995
  },
996
996
  });
997
997
  pi.registerCommand("codecarto-broadside", {
998
- description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models] [lenses…] [--model=ID] [--lens-model=LENS:ID] [flags]",
998
+ description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models|verify] [lenses…] [--model=ID] [--lens-model=LENS:ID] [--top=N] [flags]",
999
999
  // Completes the token under the cursor, so lens names and flags are
1000
1000
  // offered after the action too, and keeps everything typed before it.
1001
1001
  getArgumentCompletions: (prefix) => completeLastToken(prefix, KNOWN_BROADSIDE_TOKENS.map((value) => ({ value }))),
@@ -1181,6 +1181,35 @@ export default function codeCartographerExtension(pi) {
1181
1181
  }
1182
1182
  return;
1183
1183
  }
1184
+ if (flags.action === "verify") {
1185
+ // The verification pass (#143): one sync call per finding with
1186
+ // read-only tools; the widget counts verdicts as they land.
1187
+ const tally = { done: 0 };
1188
+ renderProgress("Reading the top findings against the source…");
1189
+ try {
1190
+ const verified = await runBroadsideVerify(ctx.cwd, apiKey, {
1191
+ ...(flags.runId && { runId: flags.runId }),
1192
+ ...(flags.top !== undefined && { top: flags.top }),
1193
+ ...(flags.model && { model: flags.model }),
1194
+ maxCost: flags.maxCost ?? config.maxCost,
1195
+ signal: ctx.signal,
1196
+ onProgress: (finding) => {
1197
+ tally.done += 1;
1198
+ progress.set(`#${finding.index}`, `${finding.verdict} — ${finding.title}`);
1199
+ renderProgress(`Verifying findings… ${tally.done} read`);
1200
+ },
1201
+ });
1202
+ const lines = verifyResultText(verified).split("\n");
1203
+ const confirmed = verified.findings.filter((f) => f.verdict === "confirmed").length;
1204
+ finish(lines, `Broad-Side verify: ${confirmed} confirmed of ${verified.findings.length} read`, verified.status === "completed" ? "info" : "warning");
1205
+ }
1206
+ catch (error) {
1207
+ if (ctx.hasUI)
1208
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1209
+ notifyCtx(ctx, `Broad-Side verify failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1210
+ }
1211
+ return;
1212
+ }
1184
1213
  // action === "collect"
1185
1214
  renderProgress("Polling batches…");
1186
1215
  try {
@@ -1325,12 +1354,41 @@ export default function codeCartographerExtension(pi) {
1325
1354
  pi.registerCommand("codecarto-library-init", {
1326
1355
  description: "Initialize a CodeCartographer library and configure it: /codecarto-library-init <path> [--namespace <name>]",
1327
1356
  handler: async (args, ctx) => {
1328
- const parts = args.trim().split(/\s+/);
1329
- const pathArg = parts[0];
1357
+ const usage = "Usage: /codecarto-library-init <path> [--namespace <name>]";
1358
+ const parts = args.trim() === "" ? [] : args.trim().split(/\s+/);
1359
+ // `--namespace` with nothing after it used to read as "no namespace"
1360
+ // and initialize an unnamespaced library without a word (Broad-Side
1361
+ // verify, 2026-09-13 run): the flag is either complete or refused.
1330
1362
  const namespaceIdx = parts.indexOf("--namespace");
1331
- const namespace = namespaceIdx >= 0 ? parts[namespaceIdx + 1] : null;
1363
+ let namespace = null;
1364
+ if (namespaceIdx >= 0) {
1365
+ const value = parts[namespaceIdx + 1];
1366
+ if (value === undefined || value.startsWith("--")) {
1367
+ notifyCtx(ctx, `--namespace needs a name. ${usage}`, "warning");
1368
+ return;
1369
+ }
1370
+ // The rule publish applies to it later, applied before it is
1371
+ // written into the config: lowercase ASCII, starts with a
1372
+ // letter, at most 64 characters.
1373
+ if (!isValidSlug(value)) {
1374
+ notifyCtx(ctx, `Invalid namespace "${value}" (lowercase ASCII, starts with a letter, max 64 chars). ${usage}`, "warning");
1375
+ return;
1376
+ }
1377
+ namespace = value;
1378
+ parts.splice(namespaceIdx, 2);
1379
+ }
1380
+ const stray = parts.find((part) => part.startsWith("--"));
1381
+ if (stray) {
1382
+ notifyCtx(ctx, `Unknown flag ${stray}. ${usage}`, "warning");
1383
+ return;
1384
+ }
1385
+ const [pathArg, ...extra] = parts;
1332
1386
  if (!pathArg) {
1333
- notifyCtx(ctx, "Usage: /codecarto-library-init <path> [--namespace <name>]", "warning");
1387
+ notifyCtx(ctx, usage, "warning");
1388
+ return;
1389
+ }
1390
+ if (extra.length > 0) {
1391
+ notifyCtx(ctx, `One path, please — got ${parts.length}. ${usage}`, "warning");
1334
1392
  return;
1335
1393
  }
1336
1394
  // The same expansion the config loader applies, so `~user/x` and a
@@ -233,7 +233,7 @@ export declare function handleAmend(args: {
233
233
  }>;
234
234
  export declare function handleBroadside(args: {
235
235
  cwd: string;
236
- action: "submit" | "collect" | "status" | "models";
236
+ action: "submit" | "collect" | "status" | "models" | "verify";
237
237
  lenses?: string[];
238
238
  api_key?: string;
239
239
  wait_seconds?: number;
@@ -247,6 +247,7 @@ export declare function handleBroadside(args: {
247
247
  incremental?: boolean;
248
248
  model?: string;
249
249
  lens_models?: Record<string, string>;
250
+ top?: number;
250
251
  }): Promise<{
251
252
  content: {
252
253
  type: "text";
@@ -17,7 +17,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
17
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
18
18
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
19
19
  import { basename, isAbsolute, join } from "node:path";
20
- import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, seedOrchestratorFiles, statusLineWriter, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../core/index.js";
20
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, runBroadsideVerify, verifyResultText, seedOrchestratorFiles, statusLineWriter, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../core/index.js";
21
21
  import { applyAmendment } from "../core/amendment.js";
22
22
  import { appendUsageRun } from "../core/usage.js";
23
23
  import { initLibrary } from "../core/library.js";
@@ -808,6 +808,11 @@ export async function handleLibraryInit(args) {
808
808
  }
809
809
  const libraryPath = args.library_path;
810
810
  const namespaced = !!args.namespace;
811
+ // The rule codecarto_publish applies to the namespace later, applied
812
+ // before it is written into the config.
813
+ if (namespaced && !isValidSlug(args.namespace)) {
814
+ throw new McpError(ErrorCode.InvalidParams, `Invalid namespace "${args.namespace}" (lowercase ASCII, starts with a letter, max 64 chars).`);
815
+ }
811
816
  const result = await initLibrary(libraryPath, {
812
817
  name: args.name,
813
818
  namespaced,
@@ -1037,8 +1042,8 @@ function resolveBroadsideApiKey(explicit, config) {
1037
1042
  export async function handleBroadside(args) {
1038
1043
  const cwd = await validateCwd(args.cwd);
1039
1044
  const action = args.action ?? "submit";
1040
- if (!["submit", "collect", "status", "models"].includes(action)) {
1041
- throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${action}. Valid actions: submit, collect, status, models.`);
1045
+ if (!["submit", "collect", "status", "models", "verify"].includes(action)) {
1046
+ throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${action}. Valid actions: submit, collect, status, models, verify.`);
1042
1047
  }
1043
1048
  // A config.yaml that exists but cannot be read refuses every action that
1044
1049
  // would act on it (#232); status only reads recorded runs, so it answers
@@ -1173,8 +1178,40 @@ export async function handleBroadside(args) {
1173
1178
  maxCost: result.maxCost,
1174
1179
  });
1175
1180
  }
1176
- // action === "collect"
1177
1181
  const runId = typeof args.run_id === "string" && args.run_id.trim() ? args.run_id.trim() : undefined;
1182
+ if (action === "verify") {
1183
+ // The verification pass (#143): one sync call per finding with read-only
1184
+ // tools, most severe first. `max_cost` is a running cap here — a sync
1185
+ // call's cost is known only when it returns — so the pass stops before
1186
+ // the next finding once reached; absent, config.yaml's cap applies.
1187
+ if (args.top !== undefined && !(typeof args.top === "number" && Number.isInteger(args.top) && args.top >= 1)) {
1188
+ throw new McpError(ErrorCode.InvalidParams, "top must be a positive integer.");
1189
+ }
1190
+ if (args.model !== undefined && !(typeof args.model === "string" && args.model.trim())) {
1191
+ throw new McpError(ErrorCode.InvalidParams, "model must be a non-empty OpenRouter model id.");
1192
+ }
1193
+ const maxCost = typeof args.max_cost === "number" && args.max_cost >= 0 ? args.max_cost : config.maxCost;
1194
+ const verified = await runBroadsideVerify(cwd, apiKey, {
1195
+ ...(runId && { runId }),
1196
+ ...(args.top !== undefined && { top: args.top }),
1197
+ ...(typeof args.model === "string" && args.model.trim() && { model: args.model.trim() }),
1198
+ maxCost,
1199
+ signal: serverLifetime?.signal,
1200
+ }).catch((error) => {
1201
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
1202
+ });
1203
+ return textResult(verifyResultText(verified), {
1204
+ runId: verified.runId,
1205
+ outputDir: verified.outputDir,
1206
+ status: verified.status,
1207
+ model: verified.model,
1208
+ candidates: verified.candidates,
1209
+ totalCost: verified.totalCost,
1210
+ ...(verified.stoppedByCost && { stoppedByCost: true }),
1211
+ findings: verified.findings,
1212
+ });
1213
+ }
1214
+ // action === "collect"
1178
1215
  const collect = await runBroadsideCollect(cwd, apiKey, {
1179
1216
  waitMs,
1180
1217
  includeSynthesis,
@@ -1502,8 +1539,8 @@ const TOOLS = [
1502
1539
  cwd: { type: "string", description: "Absolute path to the target repository." },
1503
1540
  action: {
1504
1541
  type: "string",
1505
- enum: ["submit", "collect", "status", "models"],
1506
- description: "submit fires all lens batches and returns batch ids; collect polls submitted batches, saves results, and optionally runs the synthesis pass; status shows recorded runs; models lists batch-capable models with pricing and capabilities.",
1542
+ enum: ["submit", "collect", "status", "models", "verify"],
1543
+ description: "submit fires all lens batches and returns batch ids; collect polls submitted batches, saves results, and optionally runs the synthesis pass; status shows recorded runs; models lists batch-capable models with pricing and capabilities; verify reads a collected run's top defect and security findings against the repository with read-only tools (one sync-priced call each, about a cent on the default model) and writes verified.md/verified.json beside triage.md with a verdict per finding: confirmed (a reachable failure, with the trigger), not-a-defect, discarded, or unclear.",
1507
1544
  },
1508
1545
  lenses: {
1509
1546
  type: "array",
@@ -1516,7 +1553,11 @@ const TOOLS = [
1516
1553
  },
1517
1554
  run_id: {
1518
1555
  type: "string",
1519
- description: "For collect: the run to collect, as listed by the status action. Defaults to the most recent run; pass this to collect an older run that is still in flight after a newer submit.",
1556
+ description: "For collect and verify: the run to act on, as listed by the status action. Defaults to the most recent run; pass this to collect an older run that is still in flight after a newer submit.",
1557
+ },
1558
+ top: {
1559
+ type: "integer",
1560
+ description: "For verify: how many findings to read, most severe first (default 10). Each costs one sync call; max_cost caps the pass as a running total.",
1520
1561
  },
1521
1562
  wait_seconds: {
1522
1563
  type: "number",
@@ -1536,7 +1577,7 @@ const TOOLS = [
1536
1577
  },
1537
1578
  max_cost: {
1538
1579
  type: "number",
1539
- description: "Approximate run expense limit in USD. The submit action estimates the run cost from slice sizes and the configured model's per-token pricing (live OpenRouter lookup, cached 24h) and refuses to submit when the estimate exceeds the limit unless force is true. Falls back to max_cost in .codecarto/broadside/config.yaml, whose default is $1.00; pass 0 for no limit.",
1580
+ description: "Approximate run expense limit in USD. The submit action estimates the run cost from slice sizes and the configured model's per-token pricing (live OpenRouter lookup, cached 24h) and refuses to submit when the estimate exceeds the limit unless force is true. For verify it is a running cap: the pass stops before the next finding once the calls so far have reached it. Falls back to max_cost in .codecarto/broadside/config.yaml, whose default is $1.00; pass 0 for no limit.",
1540
1581
  },
1541
1582
  force: {
1542
1583
  type: "boolean",
@@ -1552,7 +1593,7 @@ const TOOLS = [
1552
1593
  },
1553
1594
  model: {
1554
1595
  type: "string",
1555
- description: "For submit: the OpenRouter batch model for this run (an id ending in :batch, as listed by action 'models'). Falls back to model in .codecarto/broadside/config.yaml, then the shipped default. Pre-flighted like the configured model: priced from the live catalog, refused without structured-output support, clamped to its completion ceiling. The models listing is advisory — some catalog ids have no batch endpoint and are refused at submit, at no cost; the listing tags ids this repository has already seen accepted or refused.",
1596
+ description: "For verify: the sync (non-batch) OpenRouter model to read with; defaults to the run's model without its :batch suffix. For submit: the OpenRouter batch model for this run (an id ending in :batch, as listed by action 'models'). Falls back to model in .codecarto/broadside/config.yaml, then the shipped default. Pre-flighted like the configured model: priced from the live catalog, refused without structured-output support, clamped to its completion ceiling. The models listing is advisory — some catalog ids have no batch endpoint and are refused at submit, at no cost; the listing tags ids this repository has already seen accepted or refused.",
1556
1597
  },
1557
1598
  lens_models: {
1558
1599
  type: "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",