codecartographer-pi 0.24.1 → 0.26.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.
Files changed (55) hide show
  1. package/.codecarto/broadside/SKILL.md +20 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +5 -4
  4. package/agent-skill/codecartographer/references/broadside.md +5 -1
  5. package/dist/core/broadside/client.d.ts +56 -0
  6. package/dist/core/broadside/client.js +200 -0
  7. package/dist/core/broadside/collect.d.ts +68 -0
  8. package/dist/core/broadside/collect.js +676 -0
  9. package/dist/core/broadside/constants.d.ts +51 -0
  10. package/dist/core/broadside/constants.js +74 -0
  11. package/dist/core/broadside/lenses.d.ts +31 -0
  12. package/dist/core/broadside/lenses.js +312 -0
  13. package/dist/core/broadside/models.d.ts +46 -0
  14. package/dist/core/broadside/models.js +321 -0
  15. package/dist/core/broadside/render.d.ts +20 -0
  16. package/dist/core/broadside/render.js +285 -0
  17. package/dist/core/broadside/repo.d.ts +58 -0
  18. package/dist/core/broadside/repo.js +592 -0
  19. package/dist/core/broadside/requests.d.ts +23 -0
  20. package/dist/core/broadside/requests.js +71 -0
  21. package/dist/core/broadside/results.d.ts +36 -0
  22. package/dist/core/broadside/results.js +163 -0
  23. package/dist/core/broadside/schemas.d.ts +2 -0
  24. package/dist/core/broadside/schemas.js +342 -0
  25. package/dist/core/broadside/state.d.ts +99 -0
  26. package/dist/core/broadside/state.js +384 -0
  27. package/dist/core/broadside/submit.d.ts +30 -0
  28. package/dist/core/broadside/submit.js +350 -0
  29. package/dist/core/broadside/types.d.ts +491 -0
  30. package/dist/core/broadside/types.js +107 -0
  31. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  32. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  33. package/dist/core/broadside.d.ts +14 -890
  34. package/dist/core/broadside.js +25 -3564
  35. package/dist/core/completion.js +91 -72
  36. package/dist/core/dashboard-writer.js +9 -1
  37. package/dist/core/index.d.ts +0 -1
  38. package/dist/core/index.js +0 -1
  39. package/dist/core/library.d.ts +24 -1
  40. package/dist/core/library.js +46 -15
  41. package/dist/core/orchestrator-config.js +22 -8
  42. package/dist/core/status.d.ts +42 -23
  43. package/dist/core/status.js +163 -137
  44. package/dist/core/workspace.d.ts +2 -0
  45. package/dist/core/workspace.js +49 -25
  46. package/dist/core/yaml.js +9 -3
  47. package/dist/extensions/codecarto/auto-runner.d.ts +7 -0
  48. package/dist/extensions/codecarto/auto-runner.js +54 -23
  49. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  50. package/dist/extensions/codecarto/broadside-flags.js +13 -0
  51. package/dist/extensions/codecarto/index.js +13 -7
  52. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  53. package/dist/mcp-server/server.d.ts +1 -0
  54. package/dist/mcp-server/server.js +28 -5
  55. package/package.json +1 -1
@@ -1,19 +1,18 @@
1
1
  // Status normalization, atomic writes, and file-lock primitives. Pure
2
2
  // framework logic shared by every wrapper.
3
3
  import { randomBytes } from "node:crypto";
4
- import { open, readFile, rm, stat } from "node:fs/promises";
5
- import { basename, join } from "node:path";
4
+ import { open, readdir, readFile, rm, stat, unlink, utimes } from "node:fs/promises";
5
+ import { basename, dirname, join } from "node:path";
6
6
  import { pathExists, sleep } from "./utils.js";
7
7
  import { loadYamlFile } from "./yaml.js";
8
8
  export const LOCK_RETRY_MS = 125;
9
9
  export const LOCK_TIMEOUT_MS = 5000;
10
- export const STALE_LOCK_MS = 60_000;
11
10
  /**
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.
11
+ * How long a lock ticket may go unrefreshed before its owner is presumed
12
+ * hung. Owners refresh every quarter of this while they wait or hold, so a
13
+ * live holder is never broken by age; a dead one is removed at once.
15
14
  */
16
- export const BREAK_LOCK_STALE_MS = LOCK_TIMEOUT_MS;
15
+ export const STALE_LOCK_MS = 60_000;
17
16
  export function assertSafePhaseId(phaseId) {
18
17
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phaseId)) {
19
18
  throw new Error(`Invalid phase id: ${phaseId}`);
@@ -432,161 +431,188 @@ export function applyHandoff(status, handoff) {
432
431
  return status;
433
432
  }
434
433
  /**
435
- * Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
436
- * and breaking a lock older than {@link STALE_LOCK_MS}.
434
+ * Take the lock named by `lockPath`, waiting up to `timeoutMs`.
437
435
  *
438
- * The lock file records `pid`, timestamp, and a per-acquisition token, and
439
- * release removes the file only while it still carries that token. Without
440
- * the token, release removed whoever's lock was there: after a stale break
441
- * the previous holder's release deleted the new holder's lock, and a third
442
- * writer walked straight in (#227).
436
+ * The lock is a queue of **tickets**: files beside `lockPath` named
437
+ * `<lock>.t.<order>-<pid>-<token>`, one per waiter, each written by its
438
+ * owner alone. The holder is the owner of the first ticket in name order
439
+ * whose process is alive and whose ticket has been refreshed within
440
+ * `staleMs`; every owner refreshes its ticket on a timer while it waits and
441
+ * while it holds, so a live holder is never broken however long it holds
442
+ * (#355 — a publish across a full reindex used to lose its lock at 60 s).
443
+ * A ticket whose owner is dead, or has not refreshed it in `staleMs`, is
444
+ * removed by whoever notices — by its own unique name, so two waiters
445
+ * removing the same dead ticket remove the same inode and nothing else.
446
+ * No shared path is ever removed and re-created, which is the race every
447
+ * `rm`-then-recreate stale break carries (#342, #344, #355).
443
448
  *
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.
449
+ * Ordering follows Lamport's bakery: a waiter announces it is *choosing*
450
+ * (`<lock>.c.<token>`), takes a number one larger than any ticket it can
451
+ * see, writes its ticket, and withdraws the marker; nobody concludes it
452
+ * holds the lock while a marker it does not own exists — so a waiter that
453
+ * took its number but has not yet written its ticket cannot be overtaken.
454
+ * Two tickets with the same number (chosen at the same moment) break the
455
+ * tie on the rest of the name, which every observer sorts the same way.
456
+ *
457
+ * A plain `lockPath` file left by a pre-#355 process is honoured while it is
458
+ * younger than `staleMs` and removed once it is not, so an upgrade under a
459
+ * live older process does not let two writers in.
451
460
  */
452
- export async function acquireLock(lockPath) {
461
+ export async function acquireLock(lockPath, options = {}) {
462
+ const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS;
463
+ const staleMs = options.staleMs ?? STALE_LOCK_MS;
453
464
  const startedAt = Date.now();
454
- const token = `${process.pid}.${randomBytes(8).toString("hex")}`;
465
+ const dir = dirname(lockPath);
466
+ const base = basename(lockPath);
467
+ const token = randomBytes(8).toString("hex");
468
+ const choosingPath = join(dir, `${base}.c.${token}`);
455
469
  let brokeStale;
470
+ // The doorway: announce, take a number, write the ticket, withdraw. The
471
+ // number is one more than the largest ticket number on the floor, taken
472
+ // while the marker is up — so a waiter that arrives after us reads our
473
+ // ticket and takes a larger number, and two that choose at once get the
474
+ // same number and settle it on the token. A wall-clock number would let
475
+ // a later arrival in the same millisecond sort ahead of a holder.
476
+ await writeExclusive(choosingPath, `${process.pid}\n${new Date().toISOString()}\n${token}\n`);
477
+ const numbered = (await readdir(dir).catch(() => []))
478
+ .filter((name) => name.startsWith(`${base}.t.`))
479
+ .map((name) => Number.parseInt(name.slice(`${base}.t.`.length), 10))
480
+ .filter((n) => Number.isFinite(n));
481
+ const order = String(Math.max(0, ...numbered) + 1).padStart(15, "0");
482
+ const ticketPath = join(dir, `${base}.t.${order}-${process.pid}-${token}`);
483
+ const ticketName = basename(ticketPath);
484
+ try {
485
+ await writeExclusive(ticketPath, `${process.pid}\n${new Date().toISOString()}\n${token}\n`);
486
+ }
487
+ finally {
488
+ await rm(choosingPath, { force: true }).catch(() => undefined);
489
+ }
490
+ // The heartbeat: a ticket that keeps being touched is a live owner's.
491
+ const heartbeat = setInterval(() => {
492
+ const now = new Date();
493
+ void utimes(ticketPath, now, now).catch(() => undefined);
494
+ }, Math.max(50, Math.floor(staleMs / 4)));
495
+ heartbeat.unref?.();
496
+ const giveUp = async () => {
497
+ clearInterval(heartbeat);
498
+ await rm(ticketPath, { force: true }).catch(() => undefined);
499
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
500
+ };
456
501
  while (true) {
457
- try {
458
- const handle = await open(lockPath, "wx");
459
- try {
460
- await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n${token}\n`, "utf8");
502
+ const names = await readdir(dir).catch(() => []);
503
+ let blocked = false;
504
+ // A pre-#355 lock file: honour it while fresh, remove it when stale.
505
+ if (names.includes(base)) {
506
+ const legacyStat = await stat(lockPath).catch(() => null);
507
+ if (legacyStat && Date.now() - legacyStat.mtimeMs <= staleMs)
508
+ blocked = true;
509
+ else if (legacyStat) {
510
+ const holder = await describeLockHolder(lockPath);
511
+ if (await removeIfPresent(lockPath))
512
+ brokeStale = holder;
461
513
  }
462
- catch (error) {
463
- // A non-EEXIST write failure must not leak the descriptor the
464
- // open just created (#131); close best-effort, then rethrow.
465
- await handle.close().catch(() => undefined);
466
- throw error;
467
- }
468
- await handle.close();
469
- return {
470
- release: () => releaseOwnedLock(lockPath, token),
471
- ...(brokeStale && { brokeStale }),
472
- };
473
514
  }
474
- catch (error) {
475
- const nodeError = error;
476
- if (nodeError.code !== "EEXIST")
477
- throw error;
478
- try {
479
- const lockStat = await stat(lockPath);
480
- if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
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.
488
- }
489
- }
490
- catch {
515
+ // Someone is between taking a number and writing their ticket: their
516
+ // number may be earlier than ours. Wait, unless they died in the door.
517
+ for (const name of names) {
518
+ if (!name.startsWith(`${base}.c.`) || name === basename(choosingPath))
519
+ continue;
520
+ const path = join(dir, name);
521
+ if (await ownerIsGone(path, staleMs)) {
522
+ await rm(path, { force: true }).catch(() => undefined);
491
523
  continue;
492
524
  }
493
- if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
494
- throw new Error(`Timed out waiting for lock: ${lockPath}`);
525
+ blocked = true;
526
+ }
527
+ // Every ticket ahead of ours whose owner is alive blocks us; a dead or
528
+ // hung owner's ticket is removed by its own name.
529
+ if (!blocked) {
530
+ const ahead = names.filter((name) => name.startsWith(`${base}.t.`) && name < ticketName).sort();
531
+ for (const name of ahead) {
532
+ const path = join(dir, name);
533
+ if (await ownerIsGone(path, staleMs)) {
534
+ // Recorded only by the waiter whose rm actually removed it; the
535
+ // others saw the same dead ticket and removed nothing.
536
+ const holder = await describeLockHolder(path);
537
+ if (await removeIfPresent(path))
538
+ brokeStale = holder;
539
+ continue;
540
+ }
541
+ blocked = true;
542
+ break;
495
543
  }
496
- await sleep(LOCK_RETRY_MS);
497
544
  }
545
+ if (!blocked) {
546
+ // Our own ticket must still be there: a waiter that judged us hung
547
+ // (the machine slept past staleMs) has already let someone in.
548
+ if (!(await pathExists(ticketPath)))
549
+ return giveUp();
550
+ return {
551
+ release: async () => {
552
+ clearInterval(heartbeat);
553
+ await rm(ticketPath, { force: true }).catch(() => undefined);
554
+ },
555
+ ...(brokeStale && { brokeStale }),
556
+ };
557
+ }
558
+ if (Date.now() - startedAt > timeoutMs)
559
+ return giveUp();
560
+ await sleep(LOCK_RETRY_MS);
498
561
  }
499
562
  }
500
563
  /**
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.
564
+ * Remove `path`; true when this call removed it, false when it was already
565
+ * gone. `unlink`, not `rm`: `fs.promises.rm` reports success to every one
566
+ * of several concurrent callers, and the point here is to know which one
567
+ * actually took the file away.
506
568
  */
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
- }
569
+ async function removeIfPresent(path) {
570
+ try {
571
+ await unlink(path);
572
+ return true;
573
+ }
574
+ catch (error) {
575
+ if (error.code === "ENOENT")
576
+ return false;
577
+ throw error;
533
578
  }
579
+ }
580
+ /** Create `path` exclusively with `content`; the descriptor is closed either way (#131). */
581
+ async function writeExclusive(path, content) {
582
+ const handle = await open(path, "wx");
534
583
  try {
535
- return await remove();
584
+ await handle.writeFile(content, "utf8");
536
585
  }
537
586
  finally {
538
- await rm(breakPath, { force: true }).catch(() => undefined);
587
+ await handle.close().catch(() => undefined);
539
588
  }
540
589
  }
541
590
  /**
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
- }
564
- /**
565
- * Remove the lock at `lockPath` only if it is still ours. A lock that vanished
566
- * (someone broke it as stale) or that now carries another holder's token is
567
- * left alone; one whose content cannot be read is left to go stale rather
568
- * than removed unverified.
591
+ * Whether the owner of a ticket or marker is dead (its pid no longer
592
+ * exists) or has stopped refreshing it for `staleMs`. A pid that exists but
593
+ * cannot be signalled (another user's process) counts as alive. A file that
594
+ * vanished while we looked is gone, and so is its owner's claim.
569
595
  */
570
- async function releaseOwnedLock(lockPath, token) {
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);
596
+ async function ownerIsGone(path, staleMs) {
597
+ let fileStat;
598
+ try {
599
+ fileStat = await stat(path);
600
+ }
601
+ catch {
582
602
  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();
603
+ }
604
+ if (Date.now() - fileStat.mtimeMs > staleMs)
605
+ return true;
606
+ const { pid } = await describeLockHolder(path);
607
+ if (pid === null || pid === process.pid)
608
+ return false;
609
+ try {
610
+ process.kill(pid, 0);
611
+ return false;
612
+ }
613
+ catch (error) {
614
+ return error.code === "ESRCH";
615
+ }
590
616
  }
591
617
  async function describeLockHolder(lockPath) {
592
618
  try {
@@ -99,6 +99,8 @@ export type RefreshScaffoldResult = {
99
99
  scaffoldVersionBefore?: string;
100
100
  /** The running framework version the scaffold now matches. */
101
101
  scaffoldVersionAfter: string;
102
+ /** False when THREAD_LOG already carried this version transition's line (#356). */
103
+ threadLogEntryAppended: boolean;
102
104
  };
103
105
  /**
104
106
  * The workspace-relative paths a scaffold refresh would write, sorted — the
@@ -414,38 +414,62 @@ export async function listScaffoldRefreshFiles(sourceWorkspaceDir = packagedWork
414
414
  * version transition.
415
415
  */
416
416
  export async function refreshScaffold(cwd) {
417
- const state = await getWorkspaceState(cwd);
418
- if (!state)
417
+ const located = await getWorkspaceState(cwd);
418
+ if (!located)
419
419
  throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
420
420
  if (!existsSync(packagedWorkspaceDir)) {
421
421
  throw new Error("Packaged .codecarto template is missing. Reinstall codecartographer-pi.");
422
422
  }
423
- const scaffoldVersionBefore = state.scaffoldVersion;
424
- const files = await listScaffoldRefreshFiles();
425
- for (const relativePath of files) {
426
- const target = join(state.workspaceDir, relativePath);
427
- await mkdir(dirname(target), { recursive: true });
428
- await copyFile(join(packagedWorkspaceDir, relativePath), target);
429
- }
430
- // Workspaces initialised from an npm install before the rules shipped as a
431
- // template have no .gitignore at all; give them one without touching an
432
- // existing (user-owned) file.
433
- await ensureWorkspaceGitignore(state.workspaceDir);
434
- const entry = `- ${new Date().toISOString().slice(0, 10)} — scaffold-refresh — Refreshed ${files.length} framework-owned file(s) from the packaged template (${scaffoldVersionBefore ?? "unversioned"} → ${PACKAGE_VERSION}); project state, user config, and session outputs untouched.`;
435
- const threadLogPath = join(state.workspaceDir, "THREAD_LOG.md");
436
- let currentLog = "";
423
+ // Under the status lock: a completion in flight re-reads the active
424
+ // pipeline inside it, and this rewrites every pipeline (#356). Everything
425
+ // is read before anything is written, so a template that cannot be read
426
+ // leaves the scaffold as it was; each file then lands atomically, and a
427
+ // failure between two files leaves a partial refresh that re-running
428
+ // completes — the copy is idempotent.
429
+ const lock = await acquireLock(join(located.workspaceDir, "workflow", "status.yaml.lock"));
437
430
  try {
438
- currentLog = await readFile(threadLogPath, "utf8");
431
+ const state = (await getWorkspaceState(cwd)) ?? located;
432
+ const scaffoldVersionBefore = state.scaffoldVersion;
433
+ const files = await listScaffoldRefreshFiles();
434
+ const staged = new Map();
435
+ for (const relativePath of files) {
436
+ staged.set(relativePath, await readFile(join(packagedWorkspaceDir, relativePath), "utf8"));
437
+ }
438
+ for (const [relativePath, content] of staged) {
439
+ const target = join(state.workspaceDir, relativePath);
440
+ await mkdir(dirname(target), { recursive: true });
441
+ await atomicWriteFile(target, content);
442
+ }
443
+ // Workspaces initialised from an npm install before the rules shipped as a
444
+ // template have no .gitignore at all; give them one without touching an
445
+ // existing (user-owned) file.
446
+ await ensureWorkspaceGitignore(state.workspaceDir);
447
+ // One THREAD_LOG line per version change, like completion's and
448
+ // amendment's link-deduped entries: re-running the refresh at the same
449
+ // version regenerates the files and adds nothing to the index.
450
+ const transition = `(${scaffoldVersionBefore ?? "unversioned"} → ${PACKAGE_VERSION})`;
451
+ const entry = `- ${new Date().toISOString().slice(0, 10)} — scaffold-refresh — Refreshed ${files.length} framework-owned file(s) from the packaged template ${transition}; project state, user config, and session outputs untouched.`;
452
+ const threadLogPath = join(state.workspaceDir, "THREAD_LOG.md");
453
+ let currentLog = "";
454
+ try {
455
+ currentLog = await readFile(threadLogPath, "utf8");
456
+ }
457
+ catch {
458
+ // Created by the append when absent (pre-template scaffolds).
459
+ }
460
+ const alreadyLogged = currentLog.split(/\r?\n/).some((line) => line.includes("— scaffold-refresh —") && line.includes(transition));
461
+ if (!alreadyLogged)
462
+ await appendFile(threadLogPath, `${newlineIfUnterminated(currentLog)}${entry}\n`, "utf8");
463
+ return {
464
+ written: files,
465
+ ...(scaffoldVersionBefore !== undefined && { scaffoldVersionBefore }),
466
+ scaffoldVersionAfter: PACKAGE_VERSION,
467
+ threadLogEntryAppended: !alreadyLogged,
468
+ };
439
469
  }
440
- catch {
441
- // Created by the append when absent (pre-template scaffolds).
470
+ finally {
471
+ await lock.release();
442
472
  }
443
- await appendFile(threadLogPath, `${newlineIfUnterminated(currentLog)}${entry}\n`, "utf8");
444
- return {
445
- written: files,
446
- ...(scaffoldVersionBefore !== undefined && { scaffoldVersionBefore }),
447
- scaffoldVersionAfter: PACKAGE_VERSION,
448
- };
449
473
  }
450
474
  // Numeric x.y.z comparison; null when either side is not a plain dotted triple.
451
475
  /**
package/dist/core/yaml.js CHANGED
@@ -455,15 +455,21 @@ export function parseSimpleYaml(raw) {
455
455
  const key = rawItem.slice(0, separator).trim();
456
456
  const rawValue = rawItem.slice(separator + 1).trim();
457
457
  const item = {};
458
- item[key] = rawValue === "" ? null : parseScalarWithContinuation(rawValue, indent);
458
+ // Through defineProperty for the same reason the mapping parser
459
+ // does it (#365): `item["__proto__"] = …` would set the item's
460
+ // prototype and drop the key from its own entries.
461
+ const assignItem = (k, v) => {
462
+ Object.defineProperty(item, k, { value: v, writable: true, enumerable: true, configurable: true });
463
+ };
464
+ assignItem(key, rawValue === "" ? null : parseScalarWithContinuation(rawValue, indent));
459
465
  skipBlank();
460
466
  if (rawValue === "" && index < lines.length && countIndent(lines[index] ?? "") > indent + 1) {
461
- item[key] = parseBlock(countIndent(lines[index] ?? ""));
467
+ assignItem(key, parseBlock(countIndent(lines[index] ?? "")));
462
468
  }
463
469
  if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
464
470
  const nested = parseMapping(itemIndent);
465
471
  for (const [nestedKey, nestedValue] of Object.entries(nested))
466
- item[nestedKey] = nestedValue;
472
+ assignItem(nestedKey, nestedValue);
467
473
  }
468
474
  result.push(item);
469
475
  continue;
@@ -97,4 +97,11 @@ export type AutoDecision = {
97
97
  };
98
98
  export declare function decideAfterPhase(phaseStatus: SinglePhaseResult["status"], phaseError: string | undefined, validation: ValidationResult | null, strict: boolean): AutoDecision;
99
99
  export declare function runAuto(ctx: ExtensionContext, pi: ExtensionAPI, initialState: WorkspaceState, options: AutoRunOptions): Promise<AutoRunResult>;
100
+ /**
101
+ * The one-line notification for an auto run's end. A run that stopped short
102
+ * carries its reason: the auto-summary message and the widget carry it too,
103
+ * but under `pi -p` neither is rendered, and "stopped: 0/1 phases" alone
104
+ * sent a reader back to the code to find out why (#347).
105
+ */
106
+ export declare function describeAutoOutcome(result: AutoRunResult): string;
100
107
  export declare function buildAutoSummary(result: AutoRunResult, availableSkills?: string[]): string;
@@ -29,31 +29,49 @@ import { appendUsageRun, buildPhasePrompt, buildValidationSummary, completeValid
29
29
  * getPhaseActivity) and attached the agents widget if it wanted live progress.
30
30
  */
31
31
  export async function runSinglePhase(ctx, pi, state, phase, options) {
32
- let prompt = await buildPhasePrompt(state, phase, false, {
33
- auto: options.auto === true,
34
- preflight: options.preflight,
35
- });
36
- if (options.llmSteerEnabled) {
37
- notifyCtx(ctx, `Customizing ${phase.id} prompt via LLM rewriter…`, "info");
38
- const rewrite = await rewritePhasePrompt({ ctx, state, originalPrompt: prompt, nextPhaseId: phase.id });
39
- if (rewrite.used) {
40
- prompt = rewrite.prompt;
41
- notifyCtx(ctx, `LLM rewriter customized ${phase.id} seed prompt.`, "info");
42
- pi.sendMessage({
43
- customType: "codecarto-steering",
44
- content: buildSteeringMessage({
45
- nextPhaseId: phase.id,
46
- prevPhaseId: rewrite.prevPhaseId,
47
- rewrittenPrompt: rewrite.prompt,
48
- }),
49
- display: true,
50
- });
51
- }
52
- else {
53
- notifyCtx(ctx, `LLM rewriter skipped (${rewrite.skipReason}); using stock prompt.`, "warning");
54
- }
32
+ // Reserve the phase before the first await. The callers check
33
+ // isPhaseRunning and then call in, but the prompt build and the LLM-steer
34
+ // rewrite below are awaits, and a second call during them used to find
35
+ // the phase unregistered and spawn a second sub-agent for it (#359).
36
+ // The check and the reservation are synchronous, so they are one step.
37
+ if (isPhaseRunning(phase.id)) {
38
+ return { status: "error", activity: getPhaseActivity(phase.id), error: `Phase ${phase.id} is already running.` };
55
39
  }
56
40
  const activity = startPhase(phase.id);
41
+ let prompt;
42
+ try {
43
+ prompt = await buildPhasePrompt(state, phase, false, {
44
+ auto: options.auto === true,
45
+ preflight: options.preflight,
46
+ });
47
+ if (options.llmSteerEnabled) {
48
+ notifyCtx(ctx, `Customizing ${phase.id} prompt via LLM rewriter…`, "info");
49
+ const rewrite = await rewritePhasePrompt({ ctx, state, originalPrompt: prompt, nextPhaseId: phase.id });
50
+ if (rewrite.used) {
51
+ prompt = rewrite.prompt;
52
+ notifyCtx(ctx, `LLM rewriter customized ${phase.id} seed prompt.`, "info");
53
+ pi.sendMessage({
54
+ customType: "codecarto-steering",
55
+ content: buildSteeringMessage({
56
+ nextPhaseId: phase.id,
57
+ prevPhaseId: rewrite.prevPhaseId,
58
+ rewrittenPrompt: rewrite.prompt,
59
+ }),
60
+ display: true,
61
+ });
62
+ }
63
+ else {
64
+ notifyCtx(ctx, `LLM rewriter skipped (${rewrite.skipReason}); using stock prompt.`, "warning");
65
+ }
66
+ }
67
+ }
68
+ catch (error) {
69
+ // A prelude that fails releases the reservation the way a failed
70
+ // phase does, so the next attempt is not told the phase is running.
71
+ finishPhase(phase.id, { status: "error", error: error instanceof Error ? error.message : String(error) });
72
+ setTimeout(() => clearPhase(phase.id, activity), 30_000).unref?.();
73
+ throw error;
74
+ }
57
75
  notifyCtx(ctx, `CodeCartographer phase: ${phase.id} (sub-agent running)`, "info");
58
76
  if (ctx.hasUI)
59
77
  getAgentsWidget().attach(ctx.ui);
@@ -305,6 +323,19 @@ export async function runAuto(ctx, pi, initialState, options) {
305
323
  // ----------------------------------------------------------------------------
306
324
  // buildAutoSummary — the codecarto-auto-summary message body
307
325
  // ----------------------------------------------------------------------------
326
+ /**
327
+ * The one-line notification for an auto run's end. A run that stopped short
328
+ * carries its reason: the auto-summary message and the widget carry it too,
329
+ * but under `pi -p` neither is rendered, and "stopped: 0/1 phases" alone
330
+ * sent a reader back to the code to find out why (#347).
331
+ */
332
+ export function describeAutoOutcome(result) {
333
+ const line = `Auto pipeline ${result.outcome}: ${result.phasesRun.length}/${result.totalPhases} phases.`;
334
+ if (result.outcome === "complete")
335
+ return line;
336
+ const reason = result.reason.trim();
337
+ return reason ? `${line.replace(/\.$/, "")} — ${reason}` : line;
338
+ }
308
339
  export function buildAutoSummary(result, availableSkills = []) {
309
340
  const totalTokens = result.totalTokens.input + result.totalTokens.output;
310
341
  const wallTime = formatMillis(result.endedAt - result.startedAt);
@@ -24,11 +24,13 @@ export interface BroadsideFlags {
24
24
  lensModels?: Partial<Record<BroadsideLensId, string>>;
25
25
  /** For verify: how many findings to read (#143). */
26
26
  top?: number;
27
+ /** For collect: reset the settled post-passes and run them again (#338). */
28
+ regeneratePostPasses?: boolean;
27
29
  benchmarks: boolean;
28
30
  unknown: string[];
29
31
  /** Set on an invalid combination. The caller surfaces it as an error. */
30
32
  error?: string;
31
33
  }
32
34
  /** Every token the completer offers, in the order it offers them. */
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"];
35
+ 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=", "--regenerate", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
34
36
  export declare function parseBroadsideFlags(args: string): BroadsideFlags;
@@ -7,6 +7,7 @@
7
7
  // /codecarto-broadside status
8
8
  // /codecarto-broadside models --benchmarks
9
9
  // /codecarto-broadside verify --top=10 → read the top findings against the source
10
+ // /codecarto-broadside collect --regenerate → rebuild synthesis and triage from the verdicts
10
11
  //
11
12
  // Flags mirror the codecarto_broadside tool parameters, with the negative
12
13
  // forms spelled out because a slash command has no place to pass `false`:
@@ -18,6 +19,7 @@
18
19
  // --model=ID (submit: the run's batch model, as listed by models; verify: the sync model to read with)
19
20
  // --lens-model=LENS:ID (submit only, repeatable: one lens on its own model)
20
21
  // --top=N (verify only: how many findings to read, most severe first)
22
+ // --regenerate (collect only: reset the settled post-passes and run them again, verdicts included)
21
23
  //
22
24
  // A model id itself contains a colon (`vendor/name:batch`), so --lens-model
23
25
  // splits on the first colon only: `security:deepseek/deepseek-v4-pro:batch`.
@@ -47,6 +49,7 @@ export const KNOWN_BROADSIDE_TOKENS = [
47
49
  "--model=",
48
50
  "--lens-model=",
49
51
  "--top=",
52
+ "--regenerate",
50
53
  "--no-synthesis",
51
54
  "--no-triage",
52
55
  "--no-retry-truncated",
@@ -107,6 +110,10 @@ export function parseBroadsideFlags(args) {
107
110
  result.retryTruncated = false;
108
111
  continue;
109
112
  }
113
+ if (token === "--regenerate") {
114
+ result.regeneratePostPasses = true;
115
+ continue;
116
+ }
110
117
  if (token === "--benchmarks") {
111
118
  result.benchmarks = true;
112
119
  continue;
@@ -188,6 +195,12 @@ export function parseBroadsideFlags(args) {
188
195
  if (result.top !== undefined && result.action !== "verify") {
189
196
  result.error ??= `--top is only meaningful for verify (got action "${result.action}").`;
190
197
  }
198
+ if (result.regeneratePostPasses && result.action !== "collect") {
199
+ result.error ??= `--regenerate is only meaningful for collect (got action "${result.action}").`;
200
+ }
201
+ if (result.regeneratePostPasses && result.includeSynthesis === false && result.includeTriage === false) {
202
+ result.error ??= "--regenerate with both --no-synthesis and --no-triage leaves nothing to regenerate.";
203
+ }
191
204
  if (result.action === "verify" && result.waitSeconds !== undefined) {
192
205
  result.error ??= "--wait is only meaningful for submit and collect; verify runs to completion.";
193
206
  }