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.
- package/.codecarto/broadside/SKILL.md +20 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +5 -4
- package/agent-skill/codecartographer/references/broadside.md +5 -1
- package/dist/core/broadside/client.d.ts +56 -0
- package/dist/core/broadside/client.js +200 -0
- package/dist/core/broadside/collect.d.ts +68 -0
- package/dist/core/broadside/collect.js +676 -0
- package/dist/core/broadside/constants.d.ts +51 -0
- package/dist/core/broadside/constants.js +74 -0
- package/dist/core/broadside/lenses.d.ts +31 -0
- package/dist/core/broadside/lenses.js +312 -0
- package/dist/core/broadside/models.d.ts +46 -0
- package/dist/core/broadside/models.js +321 -0
- package/dist/core/broadside/render.d.ts +20 -0
- package/dist/core/broadside/render.js +285 -0
- package/dist/core/broadside/repo.d.ts +58 -0
- package/dist/core/broadside/repo.js +592 -0
- package/dist/core/broadside/requests.d.ts +23 -0
- package/dist/core/broadside/requests.js +71 -0
- package/dist/core/broadside/results.d.ts +36 -0
- package/dist/core/broadside/results.js +163 -0
- package/dist/core/broadside/schemas.d.ts +2 -0
- package/dist/core/broadside/schemas.js +342 -0
- package/dist/core/broadside/state.d.ts +99 -0
- package/dist/core/broadside/state.js +384 -0
- package/dist/core/broadside/submit.d.ts +30 -0
- package/dist/core/broadside/submit.js +350 -0
- package/dist/core/broadside/types.d.ts +491 -0
- package/dist/core/broadside/types.js +107 -0
- package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
- package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
- package/dist/core/broadside.d.ts +14 -890
- package/dist/core/broadside.js +25 -3564
- package/dist/core/completion.js +91 -72
- package/dist/core/dashboard-writer.js +9 -1
- package/dist/core/index.d.ts +0 -1
- package/dist/core/index.js +0 -1
- package/dist/core/library.d.ts +24 -1
- package/dist/core/library.js +46 -15
- package/dist/core/orchestrator-config.js +22 -8
- package/dist/core/status.d.ts +42 -23
- package/dist/core/status.js +163 -137
- package/dist/core/workspace.d.ts +2 -0
- package/dist/core/workspace.js +49 -25
- package/dist/core/yaml.js +9 -3
- package/dist/extensions/codecarto/auto-runner.d.ts +7 -0
- package/dist/extensions/codecarto/auto-runner.js +54 -23
- package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
- package/dist/extensions/codecarto/broadside-flags.js +13 -0
- package/dist/extensions/codecarto/index.js +13 -7
- package/dist/extensions/codecarto/phase-compaction.js +6 -2
- package/dist/mcp-server/server.d.ts +1 -0
- package/dist/mcp-server/server.js +28 -5
- package/package.json +1 -1
package/dist/core/status.js
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
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
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
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
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
584
|
+
await handle.writeFile(content, "utf8");
|
|
536
585
|
}
|
|
537
586
|
finally {
|
|
538
|
-
await
|
|
587
|
+
await handle.close().catch(() => undefined);
|
|
539
588
|
}
|
|
540
589
|
}
|
|
541
590
|
/**
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
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
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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 {
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -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
|
package/dist/core/workspace.js
CHANGED
|
@@ -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
|
|
418
|
-
if (!
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
}
|