codecartographer-pi 0.25.0 → 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 (50) hide show
  1. package/.codecarto/broadside/SKILL.md +4 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +2 -2
  4. package/dist/core/broadside/client.d.ts +56 -0
  5. package/dist/core/broadside/client.js +200 -0
  6. package/dist/core/broadside/collect.d.ts +68 -0
  7. package/dist/core/broadside/collect.js +676 -0
  8. package/dist/core/broadside/constants.d.ts +51 -0
  9. package/dist/core/broadside/constants.js +74 -0
  10. package/dist/core/broadside/lenses.d.ts +31 -0
  11. package/dist/core/broadside/lenses.js +312 -0
  12. package/dist/core/broadside/models.d.ts +46 -0
  13. package/dist/core/broadside/models.js +321 -0
  14. package/dist/core/broadside/render.d.ts +20 -0
  15. package/dist/core/broadside/render.js +285 -0
  16. package/dist/core/broadside/repo.d.ts +58 -0
  17. package/dist/core/broadside/repo.js +592 -0
  18. package/dist/core/broadside/requests.d.ts +23 -0
  19. package/dist/core/broadside/requests.js +71 -0
  20. package/dist/core/broadside/results.d.ts +36 -0
  21. package/dist/core/broadside/results.js +163 -0
  22. package/dist/core/broadside/schemas.d.ts +2 -0
  23. package/dist/core/broadside/schemas.js +342 -0
  24. package/dist/core/broadside/state.d.ts +99 -0
  25. package/dist/core/broadside/state.js +384 -0
  26. package/dist/core/broadside/submit.d.ts +30 -0
  27. package/dist/core/broadside/submit.js +350 -0
  28. package/dist/core/broadside/types.d.ts +491 -0
  29. package/dist/core/broadside/types.js +107 -0
  30. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  31. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  32. package/dist/core/broadside.d.ts +14 -952
  33. package/dist/core/broadside.js +25 -3726
  34. package/dist/core/completion.js +91 -72
  35. package/dist/core/dashboard-writer.js +9 -1
  36. package/dist/core/index.d.ts +0 -1
  37. package/dist/core/index.js +0 -1
  38. package/dist/core/library.d.ts +24 -1
  39. package/dist/core/library.js +46 -15
  40. package/dist/core/orchestrator-config.js +22 -8
  41. package/dist/core/status.d.ts +42 -23
  42. package/dist/core/status.js +163 -137
  43. package/dist/core/workspace.d.ts +2 -0
  44. package/dist/core/workspace.js +49 -25
  45. package/dist/core/yaml.js +9 -3
  46. package/dist/extensions/codecarto/auto-runner.js +41 -23
  47. package/dist/extensions/codecarto/index.js +9 -4
  48. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  49. package/dist/mcp-server/server.js +15 -4
  50. 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;
@@ -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);
@@ -1507,10 +1507,15 @@ export default function codeCartographerExtension(pi) {
1507
1507
  notifyCtx(ctx, `LLM narration skipped (${result.skipReason}); rendering deterministic dashboard.`, "warning");
1508
1508
  }
1509
1509
  }
1510
- await writeDashboard(ctx.cwd, PACKAGE_VERSION);
1511
- lastFeedbackLines = ["Dashboard regenerated: .codecarto/dashboard.html"];
1510
+ // writeDashboard is best-effort and says so with its result: a
1511
+ // swallowed write failure used to be reported as success (#369).
1512
+ const written = await writeDashboard(ctx.cwd, PACKAGE_VERSION);
1513
+ const line = written
1514
+ ? "Dashboard regenerated: .codecarto/dashboard.html"
1515
+ : "Dashboard not regenerated: the write to .codecarto/dashboard.html failed (permissions or disk?); the previous file, if any, stands.";
1516
+ lastFeedbackLines = [line];
1512
1517
  setUiState(ctx, state, lastFeedbackLines);
1513
- notifyCtx(ctx, "Dashboard regenerated: .codecarto/dashboard.html", "info");
1518
+ notifyCtx(ctx, line, written ? "info" : "error");
1514
1519
  },
1515
1520
  });
1516
1521
  pi.registerCommand("codecarto-refresh-scaffold", {
@@ -1540,7 +1545,7 @@ export default function codeCartographerExtension(pi) {
1540
1545
  lastFeedbackLines = [
1541
1546
  `Refreshed ${result.written.length} framework-owned file(s) from the packaged template (${transition}).`,
1542
1547
  "Project state, user config, findings outputs, scratch, closeouts, and orchestrator files were not touched.",
1543
- "THREAD_LOG.md: one scaffold-refresh entry appended.",
1548
+ result.threadLogEntryAppended ? "THREAD_LOG.md: one scaffold-refresh entry appended." : "THREAD_LOG.md: already carries this refresh; nothing appended.",
1544
1549
  ];
1545
1550
  // Re-read state so the widget's staleness line clears with the marker.
1546
1551
  await refreshWorkspaceUi(ctx, lastFeedbackLines);
@@ -66,8 +66,12 @@ export function phaseCompactionExtension(pi) {
66
66
  if (event.toolName === "edit" || event.toolName === "write") {
67
67
  const inputPath = typeof event.input.path === "string" ? event.input.path : "";
68
68
  const strippedPath = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
69
- // Same containment as the parent extension's hook (#223): follow the
70
- // existing prefix through symlinks, then append the unborn tail.
69
+ // The same symlink-aware containment as the parent extension's hook
70
+ // (#223) — follow the existing prefix through symlinks, then append
71
+ // the unborn tail — over a narrower root on purpose: a phase writes
72
+ // findings, handoffs, and checkpoints under .codecarto/ and never
73
+ // publishes, so the configured library the orchestrator's hook admits
74
+ // is not admitted here (#364).
71
75
  const targetPath = await resolveExistingPrefix(strippedPath, ctx.cwd);
72
76
  const allowedRoot = await canonicalPath(join(ctx.cwd, ".codecarto"));
73
77
  if (!isWithinPath(targetPath, allowedRoot)) {