codecartographer-pi 0.24.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.
@@ -50,7 +50,11 @@ not replace any phase; it tells phases where to look.
50
50
  - `architecture-*.json` → the architecture phase's seed of prior knowledge
51
51
  - `api-*.json` → endpoints and data types (contracts/protocols phases)
52
52
  - `security-*.json` → auth, trust boundaries (defect-scan-semantic pass 5)
53
- - `defect-*.json` → mechanical defect leads (defect-scan-mechanical)
53
+ - `defect-*.json` → mechanical defect leads (defect-scan-mechanical). The
54
+ scan is asked to name the input, call site, or sequence that reaches
55
+ each failure, and to file a cast, assertion, or style observation that
56
+ every caller satisfies at severity low under the pattern `type-hygiene`
57
+ — read those as notes, not defects.
54
58
  - `conventions-*.json` → naming/idiom candidates for CONVENTIONS.md
55
59
  - `porting-*.json` → platform coupling (porting phase)
56
60
  4. `run-meta.json` records scope: which lenses ran, at what cost, with what
@@ -108,14 +112,16 @@ every source file; the **security** and **API** lenses target where the
108
112
  trust boundary usually lives — `server/**`, `**/auth*`, `**/middleware/**`,
109
113
  `SECURITY.md` (security) and `server/**`, `api/**`, `src/server/**`,
110
114
  `src/api/**`, `**/*routes*`, `**/*router*`, `**/*handler*`, `**/*endpoint*`
111
- (API). A repository whose server is `src/server.js` matches none of those, so
112
- when the targeted patterns find nothing those two lenses **fall back to every
113
- source file** — priced as such, chunked at the lens's slice size rather than
114
- truncated, and said so on the lens line of the estimate, the submit report,
115
- `status`, and the prompt the model receives. A lens whose targeted patterns
116
- and fallback both find nothing (only test files, say) is skipped with a line
117
- naming both. `max_cost` is the guard against a fallback scan on a large
118
- repository being more than you meant to spend.
115
+ (API). A repository whose server is `src/server.js` matches none of those,
116
+ and one whose only match is `SECURITY.md` has given the lens a policy to read
117
+ and no code to check it against; so when the targeted patterns find **no
118
+ source file** those two lenses **fall back to every source file**, on top of
119
+ whatever did match — priced as such, chunked at the lens's slice size rather
120
+ than truncated, and said so on the lens line of the estimate, the submit
121
+ report, `status`, and the prompt the model receives. A lens whose targeted
122
+ patterns and fallback both find nothing (only test files, say) is skipped
123
+ with a line naming both. `max_cost` is the guard against a fallback scan on
124
+ a large repository being more than you meant to spend.
119
125
 
120
126
  Collect runs two cross-lens post-passes by default: **synthesis** (the
121
127
  executive report) and **triage** (the prioritized work order). Pass
@@ -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.24.0
6
+ scaffold_version: 0.24.1
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`). 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.
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 no code under them — priced as such, and said so on the estimate — so a service whose server is `src/server.js`, or one whose only match is its `SECURITY.md`, 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
 
@@ -39,8 +39,11 @@ export declare function listAmendmentNames(workspaceDir: string): Promise<string
39
39
  export declare function loadAmendmentFile(name: string, workspaceDir: string): Promise<Amendment>;
40
40
  /**
41
41
  * Apply one amendment to canonical state under the completion lock. Refuses
42
- * while the pipeline is incomplete — mid-pipeline resolutions belong in the
43
- * phase handoff, and allowing both channels at once would race them.
44
- * Idempotent: ids that no longer match anything are reported, not fatal.
42
+ * while the pipeline is incomplete, judged on the state read under the lock:
43
+ * the check used to run on a read taken before the lock, so a status change
44
+ * that landed in between — a pipeline switch, a re-init, a rolled-back
45
+ * completion — was amended over as if the pipeline were still complete
46
+ * (Broad-Side verify, 2026-09-13 run). Idempotent: ids that no longer match
47
+ * anything are reported, not fatal.
45
48
  */
46
49
  export declare function applyAmendment(cwd: string, name: string): Promise<AmendmentResult>;
@@ -91,17 +91,12 @@ function renderAmendmentCloseout(amendment, applied, timestamp) {
91
91
  return lines.join("\n");
92
92
  }
93
93
  /**
94
- * Apply one amendment to canonical state under the completion lock. Refuses
95
- * while the pipeline is incomplete — mid-pipeline resolutions belong in the
96
- * phase handoff, and allowing both channels at once would race them.
97
- * Idempotent: ids that no longer match anything are reported, not fatal.
94
+ * The refusal an amendment gets while the pipeline is incomplete —
95
+ * mid-pipeline resolutions belong in the phase handoff, and allowing both
96
+ * channels at once would race them.
98
97
  */
99
- export async function applyAmendment(cwd, name) {
100
- const initialState = await getWorkspaceState(cwd);
101
- if (!initialState)
102
- throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
103
- const amendment = await loadAmendmentFile(name, initialState.workspaceDir);
104
- const outcome = resolvePipelineOutcome(initialState);
98
+ function refuseUnlessComplete(state) {
99
+ const outcome = resolvePipelineOutcome(state);
105
100
  if (outcome.kind === "eligible") {
106
101
  throw new Error(`Cannot amend: the pipeline is not complete (next phase: ${outcome.phase.id}). `
107
102
  + `Resolve open questions and routed items through that phase's handoff (open_question_closures / carry_forward_closures) instead.`);
@@ -111,10 +106,26 @@ export async function applyAmendment(cwd, name) {
111
106
  // finish is not there yet (#228).
112
107
  throw new Error(`Cannot amend: the pipeline is not complete. ${describeStuckPipeline(outcome.blocked)}`);
113
108
  }
109
+ }
110
+ /**
111
+ * Apply one amendment to canonical state under the completion lock. Refuses
112
+ * while the pipeline is incomplete, judged on the state read under the lock:
113
+ * the check used to run on a read taken before the lock, so a status change
114
+ * that landed in between — a pipeline switch, a re-init, a rolled-back
115
+ * completion — was amended over as if the pipeline were still complete
116
+ * (Broad-Side verify, 2026-09-13 run). Idempotent: ids that no longer match
117
+ * anything are reported, not fatal.
118
+ */
119
+ export async function applyAmendment(cwd, name) {
120
+ const initialState = await getWorkspaceState(cwd);
121
+ if (!initialState)
122
+ throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
123
+ const amendment = await loadAmendmentFile(name, initialState.workspaceDir);
114
124
  const timestamp = new Date().toISOString();
115
125
  const applied = { openQuestionsClosed: [], postPipelineClosed: [], unknownIds: [] };
116
126
  let closeoutNotice = "";
117
127
  const updatedState = await updateStatusAtomically(cwd, async (lockedState) => {
128
+ refuseUnlessComplete(lockedState);
118
129
  const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
119
130
  for (const closureId of amendment.open_question_closures) {
120
131
  if (!closureId)
@@ -518,12 +518,16 @@ type LensDefinition = {
518
518
  skipTestFiles?: boolean;
519
519
  globsFor: (info: RepoInfo) => string[];
520
520
  /**
521
- * Where to look when `globsFor` matches nothing (#319). The security and
522
- * api lenses target server/, auth, and middleware paths because that is
523
- * where the trust boundary usually lives; a service whose server is
524
- * `src/server.js` matched none of them and got no security review at all.
525
- * The fallback is the language's whole source set — priced as such, and
526
- * said so in the estimate, the run record, and the prompt.
521
+ * Where to look when `globsFor` matches no source file (#319). The
522
+ * security and api lenses target server/, auth, and middleware paths
523
+ * because that is where the trust boundary usually lives; a service whose
524
+ * server is `src/server.js` matched none of them and got no security
525
+ * review at all. A match that is only documents is the same starvation:
526
+ * `SECURITY.md` satisfied the security lens on CodeCartographer itself,
527
+ * which then reviewed a policy and reported zero findings. The fallback
528
+ * is the language's whole source set, added to whatever did match —
529
+ * priced as such, and said so in the estimate, the run record, and the
530
+ * prompt.
527
531
  */
528
532
  fallbackGlobsFor?: (info: RepoInfo) => string[];
529
533
  systemPrompt: (info: RepoInfo) => string;
@@ -554,10 +558,15 @@ type CollectedFile = {
554
558
  moduleName: string;
555
559
  };
556
560
  /**
557
- * The files a lens will read: its targeted globs, or — when those match
558
- * nothing and the lens declares a fallback — the fallback globs, with a
559
- * sentence saying so (#319). The sentence travels to the estimate, the
560
- * batch entry, and the prompt, so a fallback scan is never a silent one.
561
+ * The files a lens will read: its targeted globs, or — when those match no
562
+ * source file and the lens declares a fallback — the fallback globs on top
563
+ * of whatever did match, with a sentence saying so (#319). The sentence
564
+ * travels to the estimate, the batch entry, and the prompt, so a fallback
565
+ * scan is never a silent one.
566
+ *
567
+ * "No source file" rather than "no file": a policy document or a config
568
+ * file under a targeted path satisfies the globs and leaves the lens with
569
+ * nothing to review, and the coverage note it writes back is the only sign.
561
570
  */
562
571
  export declare function selectLensFiles(allFiles: string[], lens: LensDefinition, info: RepoInfo): {
563
572
  files: CollectedFile[];
@@ -749,7 +749,19 @@ const LENSES = {
749
749
  "Return a JSON object following the defect_scan_report schema. " +
750
750
  "Cite file:line for every finding. List which patterns you checked. " +
751
751
  "If the code looks clean for a pattern, say so rather than staying silent. " +
752
- "Prefer precision over volume — 3 solid findings beat 15 vague ones.");
752
+ "Prefer precision over volume — 3 solid findings beat 15 vague ones.\n\n" +
753
+ // The verification pass (#143) confirmed 2 of the 12 top findings a
754
+ // scan produced with the paragraph above alone; the other ten were
755
+ // casts and assertions every caller satisfied, guards that lived one
756
+ // call away, or environments the project does not target. The rubric
757
+ // the verifier applies is asked of the scan itself, up front.
758
+ "A finding is a reachable failure: name in the description the concrete input, call site, or sequence " +
759
+ "that reaches it and what then goes wrong. A cast, assertion, `any`, or non-null `!` that every caller " +
760
+ "you can see satisfies, a hypothetical about a runtime or environment the project does not target, or a " +
761
+ "style or type-hygiene observation is not a defect — leave it out, or if it is worth a note, report it " +
762
+ "at severity low under the pattern name `type-hygiene` so it ranks apart from reachable failures. " +
763
+ "When the guard you looked for may live in another module, say which check you could not find " +
764
+ "rather than asserting it is absent; severity high or medium is for failures you traced to a trigger.");
753
765
  },
754
766
  userPrompt: (info, source, moduleName) => `Scan this ${info.language} module for mechanical defects.\n\n` +
755
767
  `Module: ${moduleName}\n\n` +
@@ -1254,25 +1266,51 @@ function collectFilesMatching(allFiles, lens, globs) {
1254
1266
  }
1255
1267
  return out;
1256
1268
  }
1269
+ /** Code in any language Broad-Side scans as, whatever this repo's is. */
1270
+ const SOURCE_EXTENSIONS = new Set(Object.values(SOURCE_SPECS).flatMap((spec) => spec.exts));
1271
+ function isSourceFile(relPath) {
1272
+ const dot = relPath.lastIndexOf(".");
1273
+ return dot > relPath.lastIndexOf("/") && SOURCE_EXTENSIONS.has(relPath.slice(dot).toLowerCase());
1274
+ }
1275
+ /** `a, b, c and 4 more` — a matched-file list short enough for a status line. */
1276
+ function listSome(paths, max = 3) {
1277
+ if (paths.length <= max)
1278
+ return paths.join(", ");
1279
+ return `${paths.slice(0, max).join(", ")} and ${paths.length - max} more`;
1280
+ }
1257
1281
  /**
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.
1282
+ * The files a lens will read: its targeted globs, or — when those match no
1283
+ * source file and the lens declares a fallback — the fallback globs on top
1284
+ * of whatever did match, with a sentence saying so (#319). The sentence
1285
+ * travels to the estimate, the batch entry, and the prompt, so a fallback
1286
+ * scan is never a silent one.
1287
+ *
1288
+ * "No source file" rather than "no file": a policy document or a config
1289
+ * file under a targeted path satisfies the globs and leaves the lens with
1290
+ * nothing to review, and the coverage note it writes back is the only sign.
1262
1291
  */
1263
1292
  export function selectLensFiles(allFiles, lens, info) {
1264
1293
  const globs = lens.globsFor(info).filter(Boolean);
1265
1294
  const targeted = collectFilesMatching(allFiles, lens, globs);
1266
- if (targeted.length > 0 || globs.length === 0 || !lens.fallbackGlobsFor)
1295
+ if (globs.length === 0 || !lens.fallbackGlobsFor)
1296
+ return { files: targeted };
1297
+ if (targeted.some((f) => isSourceFile(f.relPath)))
1267
1298
  return { files: targeted };
1268
1299
  const fallbackGlobs = lens.fallbackGlobsFor(info).filter(Boolean);
1269
- const files = collectFilesMatching(allFiles, lens, fallbackGlobs);
1270
- if (files.length === 0)
1271
- return { files };
1300
+ const matched = new Set(targeted.map((f) => f.relPath));
1301
+ const sources = collectFilesMatching(allFiles, lens, fallbackGlobs).filter((f) => !matched.has(f.relPath));
1302
+ if (sources.length === 0)
1303
+ return { files: targeted };
1304
+ const excluded = lens.skipTestFiles ? "test files excluded" : "";
1305
+ const scanned = `scanned all ${info.language} sources (${fallbackGlobs.join(", ")})`;
1272
1306
  return {
1273
- files,
1274
- fallback: `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}; ` +
1275
- `scanned all ${info.language} sources (${fallbackGlobs.join(", ")}) instead`,
1307
+ // What did match rides first: the policy the model is about to check
1308
+ // the code against, ahead of the code.
1309
+ files: [...targeted, ...sources],
1310
+ fallback: targeted.length === 0
1311
+ ? `no files matched ${globs.join(", ")}${excluded ? ` (${excluded})` : ""}; ${scanned} instead`
1312
+ : `no source files matched ${globs.join(", ")} (only ${listSome(targeted.map((f) => f.relPath))}` +
1313
+ `${excluded ? `; ${excluded}` : ""}); ${scanned} as well`,
1276
1314
  };
1277
1315
  }
1278
1316
  function collectLensFiles(allFiles, lens, info) {
@@ -1396,8 +1434,8 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
1396
1434
  // so the model judges the trust boundary wherever it appears
1397
1435
  // and does not report the missing server/ as a finding (#319).
1398
1436
  (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"
1437
+ ? `NOTE: this repository has no source files under the paths this lens usually reads (${slice.fallback}). ` +
1438
+ "What follows is every source file it has, after anything those paths did match; locate the trust boundary and the request-handling code wherever they live.\n\n"
1401
1439
  : "") + lens.userPrompt(info, slice.content, slice.moduleName),
1402
1440
  },
1403
1441
  ],
@@ -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
  /**
@@ -11,7 +11,7 @@ import { buildPiGuideMessage } from "./guide-framing.js";
11
11
  import { isCtxLive, notifyCtx } from "./notify.js";
12
12
  import { phaseCompactionExtension } from "./phase-compaction.js";
13
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 } from "../../core/library.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
@@ -1354,12 +1354,41 @@ export default function codeCartographerExtension(pi) {
1354
1354
  pi.registerCommand("codecarto-library-init", {
1355
1355
  description: "Initialize a CodeCartographer library and configure it: /codecarto-library-init <path> [--namespace <name>]",
1356
1356
  handler: async (args, ctx) => {
1357
- const parts = args.trim().split(/\s+/);
1358
- 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.
1359
1362
  const namespaceIdx = parts.indexOf("--namespace");
1360
- 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;
1361
1386
  if (!pathArg) {
1362
- 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");
1363
1392
  return;
1364
1393
  }
1365
1394
  // The same expansion the config loader applies, so `~user/x` and a
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.24.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",