codecartographer-pi 0.19.5 → 0.19.6
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/templates/gitignore +55 -0
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/dist/core/amendment.js +28 -23
- package/dist/core/broadside.js +3 -5
- package/dist/core/completion.js +14 -4
- package/dist/core/library.js +111 -106
- package/dist/core/status.d.ts +22 -2
- package/dist/core/status.js +46 -5
- package/dist/core/usage.d.ts +8 -0
- package/dist/core/usage.js +35 -7
- package/dist/core/utils.d.ts +32 -5
- package/dist/core/utils.js +81 -19
- package/dist/core/workspace.d.ts +44 -10
- package/dist/core/workspace.js +161 -30
- package/dist/extensions/codecarto/dashboard-narrator.js +3 -6
- package/dist/extensions/codecarto/dashboard-writer.js +3 -6
- package/dist/extensions/codecarto/index.js +9 -4
- package/dist/extensions/codecarto/phase-compaction.js +7 -7
- package/dist/mcp-server/server.js +4 -1
- package/package.json +1 -1
package/dist/core/usage.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// running, and phases run sequentially against this file, so a plain
|
|
8
8
|
// read-modify-write is safe enough. If parallel-phase dispatch ever ships,
|
|
9
9
|
// switch this to atomic-rename (see core/workspace.ts for the pattern).
|
|
10
|
-
import { readFile
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { acquireLock } from "./status.js";
|
|
13
|
+
import { atomicWriteFile, pathExists } from "./utils.js";
|
|
13
14
|
import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
|
|
14
15
|
export const USAGE_RELATIVE_PATH = "workflow/.usage.local.yaml";
|
|
15
16
|
const SCHEMA_VERSION = 1;
|
|
@@ -29,13 +30,40 @@ export async function loadUsage(workspaceDir) {
|
|
|
29
30
|
return emptyUsage();
|
|
30
31
|
}
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Append one run. The read-modify-write runs under the file's lock and lands
|
|
35
|
+
* through an atomic write, so concurrent appends (a Pi runner and an MCP host
|
|
36
|
+
* on one workspace, two phases finishing together) each keep their record
|
|
37
|
+
* (#226, #238). A log that exists but does not parse is never rewritten: the
|
|
38
|
+
* lenient {@link loadUsage} is for display, and appending over its empty
|
|
39
|
+
* fallback destroyed every record the file still held.
|
|
40
|
+
*/
|
|
32
41
|
export async function appendUsageRun(workspaceDir, run) {
|
|
33
|
-
const current = await loadUsage(workspaceDir);
|
|
34
|
-
current.runs.push(run);
|
|
35
42
|
const path = join(workspaceDir, USAGE_RELATIVE_PATH);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
const lock = await acquireLock(`${path}.lock`);
|
|
44
|
+
try {
|
|
45
|
+
const current = await loadUsageForAppend(path);
|
|
46
|
+
current.runs.push(run);
|
|
47
|
+
await atomicWriteFile(path, `${stringifySimpleYaml(current)}\n`);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await lock.release();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function loadUsageForAppend(path) {
|
|
54
|
+
if (!(await pathExists(path)))
|
|
55
|
+
return emptyUsage();
|
|
56
|
+
const raw = await readFile(path, "utf8");
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = parseSimpleYaml(raw);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
63
|
+
throw new Error(`Refusing to append to ${USAGE_RELATIVE_PATH}: the existing file does not parse (${reason}). ` +
|
|
64
|
+
"Its records are still in it — move the file aside to start a new log.");
|
|
65
|
+
}
|
|
66
|
+
return normalize(parsed);
|
|
39
67
|
}
|
|
40
68
|
export function computeTotals(file) {
|
|
41
69
|
const totals = emptyTotals();
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
export declare function sleep(ms: number): Promise<void>;
|
|
2
2
|
export declare function pathExists(path: string): Promise<boolean>;
|
|
3
|
+
/**
|
|
4
|
+
* A temp-file suffix that is unique within and across processes: pid, a
|
|
5
|
+
* per-process sequence number, and random bytes. `<pid>.<Date.now()>` alone
|
|
6
|
+
* collides whenever two writers hit one target inside a millisecond, and the
|
|
7
|
+
* loser's rename then either fails with ENOENT or clobbers the winner (#226).
|
|
8
|
+
*/
|
|
9
|
+
export declare function uniqueTempSuffix(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Write `content` to `path` atomically: a uniquely named sibling temp file,
|
|
12
|
+
* then a rename over the target. Readers see the old bytes or the new bytes,
|
|
13
|
+
* never a truncated file. On failure the temp file is removed best-effort and
|
|
14
|
+
* the error propagates. Every framework file that is rewritten in place goes
|
|
15
|
+
* through this so no caller hand-rolls the temp name.
|
|
16
|
+
*/
|
|
17
|
+
export declare function atomicWriteFile(path: string, content: string): Promise<void>;
|
|
3
18
|
export declare function canonicalPath(path: string): Promise<string>;
|
|
4
19
|
export declare function normalizeForComparison(path: string): string;
|
|
5
20
|
export declare function isWithinPath(path: string, root: string): boolean;
|
|
6
21
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
22
|
+
* Resolve a path the way the kernel will when something is written to it:
|
|
23
|
+
* every component that already exists is followed through symlinks
|
|
24
|
+
* (`realpath`), and the not-yet-existing tail is appended lexically. A `..`
|
|
25
|
+
* is applied to the *resolved* prefix, not the spelled one, because
|
|
26
|
+
* `link/..` means the link target's parent on disk. A relative `path` is
|
|
27
|
+
* taken against `base` without normalisation for the same reason.
|
|
10
28
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
29
|
+
* `realpath` alone throws for a file that does not exist yet, and a lexical
|
|
30
|
+
* fallback let `.codecarto/link/new.md` through when `link` was a symlink to
|
|
31
|
+
* somewhere outside the workspace — the file landed outside (#223).
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveExistingPrefix(path: string, base?: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Symlink-aware version of isWithinPath for paths that may not exist yet:
|
|
36
|
+
* the existing prefix of `path` is resolved through symlinks
|
|
37
|
+
* ({@link resolveExistingPrefix}), the root through `realpath`, and the two
|
|
38
|
+
* are compared lexically. A symlinked ancestor that points outside the root
|
|
39
|
+
* fails whether or not the target file exists.
|
|
13
40
|
*/
|
|
14
41
|
export declare function isWithinPathResolved(path: string, root: string): Promise<boolean>;
|
|
15
42
|
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
package/dist/core/utils.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// General-purpose helpers used by yaml/status/prompts and by wrapper-specific
|
|
2
2
|
// path-boundary enforcement (Pi tool interception, MCP cwd validation).
|
|
3
|
-
import {
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { access, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import { constants } from "node:fs";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
|
-
import { join, normalize, resolve } from "node:path";
|
|
7
|
-
import { realpath } from "node:fs/promises";
|
|
7
|
+
import { dirname, isAbsolute, join, normalize, parse, resolve, sep } from "node:path";
|
|
8
8
|
export function sleep(ms) {
|
|
9
9
|
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
10
10
|
}
|
|
@@ -17,6 +17,35 @@ export async function pathExists(path) {
|
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
let tempSequence = 0;
|
|
21
|
+
/**
|
|
22
|
+
* A temp-file suffix that is unique within and across processes: pid, a
|
|
23
|
+
* per-process sequence number, and random bytes. `<pid>.<Date.now()>` alone
|
|
24
|
+
* collides whenever two writers hit one target inside a millisecond, and the
|
|
25
|
+
* loser's rename then either fails with ENOENT or clobbers the winner (#226).
|
|
26
|
+
*/
|
|
27
|
+
export function uniqueTempSuffix() {
|
|
28
|
+
tempSequence = (tempSequence + 1) % 0x7fffffff;
|
|
29
|
+
return `${process.pid}.${tempSequence}.${randomBytes(4).toString("hex")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Write `content` to `path` atomically: a uniquely named sibling temp file,
|
|
33
|
+
* then a rename over the target. Readers see the old bytes or the new bytes,
|
|
34
|
+
* never a truncated file. On failure the temp file is removed best-effort and
|
|
35
|
+
* the error propagates. Every framework file that is rewritten in place goes
|
|
36
|
+
* through this so no caller hand-rolls the temp name.
|
|
37
|
+
*/
|
|
38
|
+
export async function atomicWriteFile(path, content) {
|
|
39
|
+
const tempPath = `${path}.${uniqueTempSuffix()}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
await writeFile(tempPath, content, "utf8");
|
|
42
|
+
await rename(tempPath, path);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
20
49
|
export async function canonicalPath(path) {
|
|
21
50
|
try {
|
|
22
51
|
return await realpath(path);
|
|
@@ -43,25 +72,58 @@ export function isWithinPath(path, root) {
|
|
|
43
72
|
return normalizedPath.startsWith(prefix);
|
|
44
73
|
}
|
|
45
74
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
75
|
+
* Resolve a path the way the kernel will when something is written to it:
|
|
76
|
+
* every component that already exists is followed through symlinks
|
|
77
|
+
* (`realpath`), and the not-yet-existing tail is appended lexically. A `..`
|
|
78
|
+
* is applied to the *resolved* prefix, not the spelled one, because
|
|
79
|
+
* `link/..` means the link target's parent on disk. A relative `path` is
|
|
80
|
+
* taken against `base` without normalisation for the same reason.
|
|
49
81
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
82
|
+
* `realpath` alone throws for a file that does not exist yet, and a lexical
|
|
83
|
+
* fallback let `.codecarto/link/new.md` through when `link` was a symlink to
|
|
84
|
+
* somewhere outside the workspace — the file landed outside (#223).
|
|
52
85
|
*/
|
|
53
|
-
export async function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
86
|
+
export async function resolveExistingPrefix(path, base = process.cwd()) {
|
|
87
|
+
const raw = isAbsolute(path) ? path : `${base}${sep}${path}`;
|
|
88
|
+
const { root } = parse(raw);
|
|
89
|
+
const segments = raw
|
|
90
|
+
.slice(root.length)
|
|
91
|
+
.split(/[\\/]+/)
|
|
92
|
+
.filter((segment) => segment !== "" && segment !== ".");
|
|
93
|
+
let current = await canonicalPath(root || sep);
|
|
94
|
+
const tail = [];
|
|
95
|
+
for (const segment of segments) {
|
|
96
|
+
if (segment === "..") {
|
|
97
|
+
if (tail.length > 0)
|
|
98
|
+
tail.pop();
|
|
99
|
+
else
|
|
100
|
+
current = dirname(current);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (tail.length > 0) {
|
|
104
|
+
// Once one component is missing, nothing below it can exist either.
|
|
105
|
+
tail.push(segment);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
current = await realpath(join(current, segment));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
tail.push(segment);
|
|
113
|
+
}
|
|
64
114
|
}
|
|
115
|
+
return tail.length === 0 ? current : join(current, ...tail);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Symlink-aware version of isWithinPath for paths that may not exist yet:
|
|
119
|
+
* the existing prefix of `path` is resolved through symlinks
|
|
120
|
+
* ({@link resolveExistingPrefix}), the root through `realpath`, and the two
|
|
121
|
+
* are compared lexically. A symlinked ancestor that points outside the root
|
|
122
|
+
* fails whether or not the target file exists.
|
|
123
|
+
*/
|
|
124
|
+
export async function isWithinPathResolved(path, root) {
|
|
125
|
+
const [resolvedPath, resolvedRoot] = await Promise.all([resolveExistingPrefix(path), canonicalPath(root)]);
|
|
126
|
+
return isWithinPath(resolvedPath, resolvedRoot);
|
|
65
127
|
}
|
|
66
128
|
export function isPlainObject(value) {
|
|
67
129
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -19,9 +19,24 @@ export declare const ORCHESTRATOR_FILES: readonly [{
|
|
|
19
19
|
readonly template: "thread-log.md";
|
|
20
20
|
}];
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Every workspace-relative path a packaged pipeline declares as a phase output,
|
|
23
|
+
* primary or secondary. Findings directories ship README and SKILL stubs
|
|
24
|
+
* beside the reports sessions write, so the reports have to be excluded by
|
|
25
|
+
* name rather than by directory, and the pipelines are the source of truth
|
|
26
|
+
* for those names. A pipeline file that fails to load contributes nothing.
|
|
27
|
+
*/
|
|
28
|
+
export declare function listDeclaredOutputs(sourceWorkspaceDir?: string): Promise<Set<string>>;
|
|
29
|
+
/**
|
|
30
|
+
* Copy the packaged template into a target workspace, skipping everything a
|
|
31
|
+
* session wrote into it. Directories are still created, so a fresh workspace
|
|
32
|
+
* has an empty `closeouts/` rather than no `closeouts/`.
|
|
33
|
+
*
|
|
34
|
+
* This repository's `.codecarto/` is the template *and* CodeCartographer's own
|
|
35
|
+
* live workspace, so a checkout install's template can hold finished phase
|
|
36
|
+
* reports, handoffs, and a dashboard. Copying those seeded every new workspace
|
|
37
|
+
* with another project's findings, and validation then passed on them (#224).
|
|
38
|
+
* The exclusion is by declared output path ({@link listDeclaredOutputs}), so a
|
|
39
|
+
* new phase's report is covered the moment its pipeline names it.
|
|
25
40
|
*
|
|
26
41
|
* @param targetWorkspaceDir - Absolute path to the `.codecarto/` to create or merge into.
|
|
27
42
|
* @param sourceWorkspaceDir - The template to copy from. Defaults to the packaged
|
|
@@ -29,6 +44,17 @@ export declare const ORCHESTRATOR_FILES: readonly [{
|
|
|
29
44
|
* without mutating the repository's own live workspace mid-suite.
|
|
30
45
|
*/
|
|
31
46
|
export declare function copyPackagedWorkspace(targetWorkspaceDir: string, sourceWorkspaceDir?: string): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Give the workspace its ignore rules when it has none. npm never packs a file
|
|
49
|
+
* named `.gitignore`, so an npm-installed template carried no rules and the
|
|
50
|
+
* workspaces initialised from it committed the dashboard, the usage log with
|
|
51
|
+
* its absolute session paths, and the Broad-Side config with any API key in
|
|
52
|
+
* it (#229). The rules ship as `templates/gitignore` instead and are copied
|
|
53
|
+
* to `.gitignore` here; an existing `.gitignore` is the user's and is left
|
|
54
|
+
* alone.
|
|
55
|
+
* @returns whether a file was written.
|
|
56
|
+
*/
|
|
57
|
+
export declare function ensureWorkspaceGitignore(workspaceDir: string): Promise<boolean>;
|
|
32
58
|
/**
|
|
33
59
|
* Seed the orchestrator-maintained files from the workspace's templates
|
|
34
60
|
* (issue #98): orchestration is on by default, so a fresh workspace starts
|
|
@@ -62,7 +88,7 @@ export type RefreshScaffoldResult = {
|
|
|
62
88
|
* exact set {@link refreshScaffold} copies, computed without writing anything.
|
|
63
89
|
* A wrapper that asks before refreshing shows this.
|
|
64
90
|
*/
|
|
65
|
-
export declare function listScaffoldRefreshFiles(): Promise<string[]>;
|
|
91
|
+
export declare function listScaffoldRefreshFiles(sourceWorkspaceDir?: string): Promise<string[]>;
|
|
66
92
|
/**
|
|
67
93
|
* Refresh a workspace's framework-owned files from the packaged template
|
|
68
94
|
* (issue #102): both staleness notices instruct exactly this, and the only
|
|
@@ -85,15 +111,23 @@ export declare function refreshScaffold(cwd: string): Promise<RefreshScaffoldRes
|
|
|
85
111
|
* fail: unversioned workspaces must keep working.
|
|
86
112
|
*/
|
|
87
113
|
export declare function describeScaffoldStaleness(state: WorkspaceState): string | null;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
handoff?: PhaseHandoff;
|
|
91
|
-
threadLogEntry?: string;
|
|
92
|
-
}> | {
|
|
114
|
+
/** What an {@link updateStatusAtomically} updater returns. */
|
|
115
|
+
export interface StatusUpdate {
|
|
93
116
|
state: WorkspaceState;
|
|
94
117
|
handoff?: PhaseHandoff;
|
|
95
118
|
threadLogEntry?: string;
|
|
96
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Runs under the lock only after status.yaml has been renamed into place —
|
|
121
|
+
* the commit point. Closeouts, index lines, decision rows, and anything
|
|
122
|
+
* else that asserts "this phase is complete" belong here rather than in
|
|
123
|
+
* the updater, so a failed commit leaves none of them behind (#234). A
|
|
124
|
+
* step that fails here surfaces as an error, but the status change stands;
|
|
125
|
+
* steps must therefore be idempotent so re-running the operation
|
|
126
|
+
* regenerates what they write.
|
|
127
|
+
*/
|
|
128
|
+
afterCommit?: (committed: WorkspaceState) => Promise<void> | void;
|
|
129
|
+
}
|
|
130
|
+
export declare function updateStatusAtomically(cwd: string, updater: (state: WorkspaceState) => Promise<StatusUpdate> | StatusUpdate): Promise<WorkspaceState>;
|
|
97
131
|
/**
|
|
98
132
|
* Switch the active pipeline in-place without deleting findings, handoffs,
|
|
99
133
|
* usage data, closeouts, or checkpoints. Phases that exist in both the old
|
package/dist/core/workspace.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// + normalizes the per-project workspace state from disk, and provides the
|
|
4
4
|
// atomic status-update primitive used by /codecarto-complete.
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
-
import { appendFile, copyFile, cp, mkdir, readFile, readdir
|
|
6
|
+
import { appendFile, copyFile, cp, mkdir, readFile, readdir } from "node:fs/promises";
|
|
7
7
|
import { basename, dirname, join, relative } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { acquireLock, applyHandoff, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
|
|
10
|
-
import { compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
|
|
10
|
+
import { atomicWriteFile, compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
|
|
11
11
|
import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
|
|
12
12
|
// Walk up from the current file to find the package root. Needed because the
|
|
13
13
|
// source lives at <root>/core/workspace.ts (one level below the package root)
|
|
@@ -123,8 +123,25 @@ export const ORCHESTRATOR_FILES = [
|
|
|
123
123
|
* The four top-level files are seeded fresh from templates instead
|
|
124
124
|
* ({@link ORCHESTRATOR_FILES}); `closeouts/` is created empty.
|
|
125
125
|
*/
|
|
126
|
-
const INIT_EXCLUDED_TOP_LEVEL = new Set([
|
|
127
|
-
|
|
126
|
+
const INIT_EXCLUDED_TOP_LEVEL = new Set([
|
|
127
|
+
"BACKLOG.md",
|
|
128
|
+
"THREAD_LOG.md",
|
|
129
|
+
"CONVENTIONS.md",
|
|
130
|
+
"DECISIONS.md",
|
|
131
|
+
// Rendered from a workspace's own state; the narration cache holds an
|
|
132
|
+
// LLM summary of it.
|
|
133
|
+
"dashboard.html",
|
|
134
|
+
".dashboard-narration.local.md",
|
|
135
|
+
]);
|
|
136
|
+
// Directories that exist in every workspace but whose contents are one
|
|
137
|
+
// project's sessions: closeouts, and scratch (handoffs, checkpoints,
|
|
138
|
+
// amendments) apart from its .gitkeep.
|
|
139
|
+
const INIT_EXCLUDED_DIR_CONTENTS = new Set(["closeouts", "scratch"]);
|
|
140
|
+
// Project state under workflow/: init writes a fresh status.yaml itself, and
|
|
141
|
+
// the two dot-files hold one machine's usage log and session pointer.
|
|
142
|
+
const INIT_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", ".usage.local.yaml", ".orchestrator.local.yaml"]);
|
|
143
|
+
// Where the workspace's ignore rules ship (see ensureWorkspaceGitignore).
|
|
144
|
+
const GITIGNORE_TEMPLATE_RELATIVE_PATH = "templates/gitignore";
|
|
128
145
|
// broadside/ is machine-local scan state (state.json, timestamped run dirs)
|
|
129
146
|
// except for its two template files — the same carve-out .codecarto/.gitignore
|
|
130
147
|
// makes for this repository itself. Without this, init from a local checkout
|
|
@@ -133,9 +150,83 @@ const INIT_EXCLUDED_DIR_CONTENTS = new Set(["closeouts"]);
|
|
|
133
150
|
const BROADSIDE_DIR_NAME = "broadside";
|
|
134
151
|
const INIT_BROADSIDE_TEMPLATE_FILES = new Set(["SKILL.md", "config.yaml"]);
|
|
135
152
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
153
|
+
* Every workspace-relative path a packaged pipeline declares as a phase output,
|
|
154
|
+
* primary or secondary. Findings directories ship README and SKILL stubs
|
|
155
|
+
* beside the reports sessions write, so the reports have to be excluded by
|
|
156
|
+
* name rather than by directory, and the pipelines are the source of truth
|
|
157
|
+
* for those names. A pipeline file that fails to load contributes nothing.
|
|
158
|
+
*/
|
|
159
|
+
export async function listDeclaredOutputs(sourceWorkspaceDir = packagedWorkspaceDir) {
|
|
160
|
+
const outputs = new Set();
|
|
161
|
+
const workflowDir = join(sourceWorkspaceDir, "workflow");
|
|
162
|
+
let names;
|
|
163
|
+
try {
|
|
164
|
+
names = await readdir(workflowDir);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return outputs;
|
|
168
|
+
}
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
if (!/^pipeline.*\.ya?ml$/.test(name))
|
|
171
|
+
continue;
|
|
172
|
+
let pipeline;
|
|
173
|
+
try {
|
|
174
|
+
pipeline = await loadYamlFile(join(workflowDir, name));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
for (const phase of pipeline?.phases ?? []) {
|
|
180
|
+
if (typeof phase.primary_output === "string")
|
|
181
|
+
outputs.add(toPosixRelative(phase.primary_output));
|
|
182
|
+
for (const secondary of phase.secondary_outputs ?? []) {
|
|
183
|
+
const path = secondary.path;
|
|
184
|
+
if (typeof path === "string")
|
|
185
|
+
outputs.add(toPosixRelative(path));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return outputs;
|
|
190
|
+
}
|
|
191
|
+
function toPosixRelative(path) {
|
|
192
|
+
return path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Whether a template path (as segments) is framework-owned and travels into a
|
|
196
|
+
* new workspace. Everything a session produces stays behind: the declared
|
|
197
|
+
* phase outputs, handoffs and checkpoints, closeouts, the dashboard, usage,
|
|
198
|
+
* status, Broad-Side runs, and any lock or temp file a crashed process left.
|
|
199
|
+
* Directories pass so the workspace keeps its shape (an empty `closeouts/`,
|
|
200
|
+
* a `findings/<phase>/` for every phase).
|
|
201
|
+
*/
|
|
202
|
+
function isTemplatePath(segments, declaredOutputs) {
|
|
203
|
+
const posixPath = segments.join("/");
|
|
204
|
+
if (declaredOutputs.has(posixPath))
|
|
205
|
+
return false;
|
|
206
|
+
if (/\.(lock|tmp)$/.test(posixPath))
|
|
207
|
+
return false;
|
|
208
|
+
if (segments.length === 1)
|
|
209
|
+
return !INIT_EXCLUDED_TOP_LEVEL.has(segments[0]);
|
|
210
|
+
const [top, second] = segments;
|
|
211
|
+
if (top === BROADSIDE_DIR_NAME)
|
|
212
|
+
return segments.length === 2 && INIT_BROADSIDE_TEMPLATE_FILES.has(second);
|
|
213
|
+
if (top === "scratch")
|
|
214
|
+
return segments.length === 2 && second === ".gitkeep";
|
|
215
|
+
if (top === "workflow")
|
|
216
|
+
return !(segments.length === 2 && INIT_EXCLUDED_WORKFLOW_FILES.has(second));
|
|
217
|
+
return !INIT_EXCLUDED_DIR_CONTENTS.has(top);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Copy the packaged template into a target workspace, skipping everything a
|
|
221
|
+
* session wrote into it. Directories are still created, so a fresh workspace
|
|
222
|
+
* has an empty `closeouts/` rather than no `closeouts/`.
|
|
223
|
+
*
|
|
224
|
+
* This repository's `.codecarto/` is the template *and* CodeCartographer's own
|
|
225
|
+
* live workspace, so a checkout install's template can hold finished phase
|
|
226
|
+
* reports, handoffs, and a dashboard. Copying those seeded every new workspace
|
|
227
|
+
* with another project's findings, and validation then passed on them (#224).
|
|
228
|
+
* The exclusion is by declared output path ({@link listDeclaredOutputs}), so a
|
|
229
|
+
* new phase's report is covered the moment its pipeline names it.
|
|
139
230
|
*
|
|
140
231
|
* @param targetWorkspaceDir - Absolute path to the `.codecarto/` to create or merge into.
|
|
141
232
|
* @param sourceWorkspaceDir - The template to copy from. Defaults to the packaged
|
|
@@ -143,20 +234,14 @@ const INIT_BROADSIDE_TEMPLATE_FILES = new Set(["SKILL.md", "config.yaml"]);
|
|
|
143
234
|
* without mutating the repository's own live workspace mid-suite.
|
|
144
235
|
*/
|
|
145
236
|
export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceDir = packagedWorkspaceDir) {
|
|
237
|
+
const declaredOutputs = await listDeclaredOutputs(sourceWorkspaceDir);
|
|
146
238
|
await cp(sourceWorkspaceDir, targetWorkspaceDir, {
|
|
147
239
|
recursive: true,
|
|
148
240
|
filter: (source) => {
|
|
149
241
|
const relativePath = relative(sourceWorkspaceDir, source);
|
|
150
242
|
if (!relativePath)
|
|
151
243
|
return true; // the workspace root itself
|
|
152
|
-
|
|
153
|
-
if (segments.length === 1)
|
|
154
|
-
return !INIT_EXCLUDED_TOP_LEVEL.has(segments[0]);
|
|
155
|
-
if (segments[0] === BROADSIDE_DIR_NAME) {
|
|
156
|
-
return segments.length === 2 && INIT_BROADSIDE_TEMPLATE_FILES.has(segments[1]);
|
|
157
|
-
}
|
|
158
|
-
// Keep the directory, drop what this repository wrote inside it.
|
|
159
|
-
return !INIT_EXCLUDED_DIR_CONTENTS.has(segments[0]);
|
|
244
|
+
return isTemplatePath(relativePath.split(/[\\/]/), declaredOutputs);
|
|
160
245
|
},
|
|
161
246
|
});
|
|
162
247
|
// The published tarball carries no empty directories, so an excluded-contents
|
|
@@ -166,6 +251,27 @@ export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceD
|
|
|
166
251
|
for (const name of INIT_EXCLUDED_DIR_CONTENTS) {
|
|
167
252
|
await mkdir(join(targetWorkspaceDir, name), { recursive: true });
|
|
168
253
|
}
|
|
254
|
+
await ensureWorkspaceGitignore(targetWorkspaceDir);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Give the workspace its ignore rules when it has none. npm never packs a file
|
|
258
|
+
* named `.gitignore`, so an npm-installed template carried no rules and the
|
|
259
|
+
* workspaces initialised from it committed the dashboard, the usage log with
|
|
260
|
+
* its absolute session paths, and the Broad-Side config with any API key in
|
|
261
|
+
* it (#229). The rules ship as `templates/gitignore` instead and are copied
|
|
262
|
+
* to `.gitignore` here; an existing `.gitignore` is the user's and is left
|
|
263
|
+
* alone.
|
|
264
|
+
* @returns whether a file was written.
|
|
265
|
+
*/
|
|
266
|
+
export async function ensureWorkspaceGitignore(workspaceDir) {
|
|
267
|
+
const target = join(workspaceDir, ".gitignore");
|
|
268
|
+
if (await pathExists(target))
|
|
269
|
+
return false;
|
|
270
|
+
const template = join(workspaceDir, GITIGNORE_TEMPLATE_RELATIVE_PATH);
|
|
271
|
+
if (!(await pathExists(template)))
|
|
272
|
+
return false;
|
|
273
|
+
await copyFile(template, target);
|
|
274
|
+
return true;
|
|
169
275
|
}
|
|
170
276
|
/**
|
|
171
277
|
* Seed the orchestrator-maintained files from the workspace's templates
|
|
@@ -194,11 +300,20 @@ export async function seedOrchestratorFiles(workspaceDir) {
|
|
|
194
300
|
* user-owned top-level files, and the directories sessions write into.
|
|
195
301
|
* Everything else present in the packaged template is framework-owned.
|
|
196
302
|
*/
|
|
197
|
-
const REFRESH_EXCLUDED_TOP_LEVEL = new Set([
|
|
303
|
+
const REFRESH_EXCLUDED_TOP_LEVEL = new Set([
|
|
304
|
+
"BACKLOG.md",
|
|
305
|
+
"THREAD_LOG.md",
|
|
306
|
+
"CONVENTIONS.md",
|
|
307
|
+
"DECISIONS.md",
|
|
308
|
+
"dashboard.html",
|
|
309
|
+
".dashboard-narration.local.md",
|
|
310
|
+
// The user's ignore rules; created from templates/gitignore when absent.
|
|
311
|
+
".gitignore",
|
|
312
|
+
]);
|
|
198
313
|
// broadside/ holds machine-local scout state (batch ids, API key config,
|
|
199
314
|
// generated results) — refresh must never overwrite it.
|
|
200
315
|
const REFRESH_EXCLUDED_DIRS = new Set(["scratch", "inputs", "closeouts", "broadside"]);
|
|
201
|
-
const REFRESH_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", "config.yaml", ".usage.local.yaml"]);
|
|
316
|
+
const REFRESH_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", "config.yaml", ".usage.local.yaml", ".orchestrator.local.yaml"]);
|
|
202
317
|
/**
|
|
203
318
|
* What a scaffold refresh never touches, for a wrapper that asks before
|
|
204
319
|
* refreshing to show. The same sets drive {@link refreshScaffold}, so the
|
|
@@ -209,7 +324,7 @@ export const SCAFFOLD_REFRESH_PROTECTED = Object.freeze({
|
|
|
209
324
|
dirs: Object.freeze([...REFRESH_EXCLUDED_DIRS]),
|
|
210
325
|
workflowFiles: Object.freeze([...REFRESH_EXCLUDED_WORKFLOW_FILES]),
|
|
211
326
|
});
|
|
212
|
-
async function listTemplateFiles(dir, relativeDir = "") {
|
|
327
|
+
async function listTemplateFiles(dir, declaredOutputs, relativeDir = "") {
|
|
213
328
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
214
329
|
const files = [];
|
|
215
330
|
for (const entry of entries) {
|
|
@@ -217,13 +332,20 @@ async function listTemplateFiles(dir, relativeDir = "") {
|
|
|
217
332
|
if (entry.isDirectory()) {
|
|
218
333
|
if (!relativeDir && REFRESH_EXCLUDED_DIRS.has(entry.name))
|
|
219
334
|
continue;
|
|
220
|
-
files.push(...await listTemplateFiles(join(dir, entry.name), relativePath));
|
|
335
|
+
files.push(...await listTemplateFiles(join(dir, entry.name), declaredOutputs, relativePath));
|
|
221
336
|
continue;
|
|
222
337
|
}
|
|
223
338
|
if (!relativeDir && REFRESH_EXCLUDED_TOP_LEVEL.has(entry.name))
|
|
224
339
|
continue;
|
|
225
340
|
if (relativeDir === "workflow" && REFRESH_EXCLUDED_WORKFLOW_FILES.has(entry.name))
|
|
226
341
|
continue;
|
|
342
|
+
// A checkout install's template can hold finished reports (the repository
|
|
343
|
+
// analyses itself); refreshing those over a user's own would be worse
|
|
344
|
+
// than init copying them (#224).
|
|
345
|
+
if (declaredOutputs.has(relativePath))
|
|
346
|
+
continue;
|
|
347
|
+
if (/\.(lock|tmp)$/.test(entry.name))
|
|
348
|
+
continue;
|
|
227
349
|
files.push(relativePath);
|
|
228
350
|
}
|
|
229
351
|
return files;
|
|
@@ -233,11 +355,12 @@ async function listTemplateFiles(dir, relativeDir = "") {
|
|
|
233
355
|
* exact set {@link refreshScaffold} copies, computed without writing anything.
|
|
234
356
|
* A wrapper that asks before refreshing shows this.
|
|
235
357
|
*/
|
|
236
|
-
export async function listScaffoldRefreshFiles() {
|
|
237
|
-
if (!existsSync(
|
|
358
|
+
export async function listScaffoldRefreshFiles(sourceWorkspaceDir = packagedWorkspaceDir) {
|
|
359
|
+
if (!existsSync(sourceWorkspaceDir)) {
|
|
238
360
|
throw new Error("Packaged .codecarto template is missing. Reinstall codecartographer-pi.");
|
|
239
361
|
}
|
|
240
|
-
|
|
362
|
+
const declaredOutputs = await listDeclaredOutputs(sourceWorkspaceDir);
|
|
363
|
+
return (await listTemplateFiles(sourceWorkspaceDir, declaredOutputs)).sort();
|
|
241
364
|
}
|
|
242
365
|
/**
|
|
243
366
|
* Refresh a workspace's framework-owned files from the packaged template
|
|
@@ -265,6 +388,10 @@ export async function refreshScaffold(cwd) {
|
|
|
265
388
|
await mkdir(dirname(target), { recursive: true });
|
|
266
389
|
await copyFile(join(packagedWorkspaceDir, relativePath), target);
|
|
267
390
|
}
|
|
391
|
+
// Workspaces initialised from an npm install before the rules shipped as a
|
|
392
|
+
// template have no .gitignore at all; give them one without touching an
|
|
393
|
+
// existing (user-owned) file.
|
|
394
|
+
await ensureWorkspaceGitignore(state.workspaceDir);
|
|
268
395
|
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.`;
|
|
269
396
|
const threadLogPath = join(state.workspaceDir, "THREAD_LOG.md");
|
|
270
397
|
let currentLog = "";
|
|
@@ -332,10 +459,17 @@ export async function updateStatusAtomically(cwd, updater) {
|
|
|
332
459
|
applyHandoff(nextState.status, handoff);
|
|
333
460
|
}
|
|
334
461
|
assertCanonicalStatus(nextState.status);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
462
|
+
await atomicWriteFile(statusPath, `${stringifySimpleYaml(nextState.status)}\n`);
|
|
463
|
+
if (result.afterCommit) {
|
|
464
|
+
try {
|
|
465
|
+
await result.afterCommit(nextState);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
469
|
+
throw new Error(`status.yaml was updated, but a step that runs after the update failed: ${reason} ` +
|
|
470
|
+
"The status change stands; re-run the same operation to regenerate what that step writes.", { cause: error });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
339
473
|
if (result.threadLogEntry) {
|
|
340
474
|
const threadLogPath = join(workspaceDir, "THREAD_LOG.md");
|
|
341
475
|
let currentLog = "";
|
|
@@ -400,10 +534,7 @@ export async function switchPipeline(cwd, newPipelinePath) {
|
|
|
400
534
|
freshStatus.post_pipeline = currentState.status.post_pipeline;
|
|
401
535
|
freshStatus.last_updated = new Date().toISOString();
|
|
402
536
|
assertCanonicalStatus(freshStatus);
|
|
403
|
-
|
|
404
|
-
const tempPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
|
|
405
|
-
await writeFile(tempPath, serialized, "utf8");
|
|
406
|
-
await rename(tempPath, statusPath);
|
|
537
|
+
await atomicWriteFile(statusPath, `${stringifySimpleYaml(freshStatus)}\n`);
|
|
407
538
|
const state = await getWorkspaceState(cwd);
|
|
408
539
|
if (!state)
|
|
409
540
|
throw new Error("Failed to reload workspace state after pipeline switch.");
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Same one-shot pattern as agent-rewriter.ts:runRewriterOnce — different
|
|
12
12
|
// system prompt, different output target. Never throws.
|
|
13
|
-
import { readFile, readdir
|
|
13
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { computeTotals, NARRATION_CACHE_RELATIVE_PATH, loadUsage, pathExists, stringifySimpleYaml, } from "../../core/index.js";
|
|
16
|
+
import { atomicWriteFile, computeTotals, NARRATION_CACHE_RELATIVE_PATH, loadUsage, pathExists, stringifySimpleYaml, } from "../../core/index.js";
|
|
17
17
|
import { createChildModelRuntime } from "./child-model-runtime.js";
|
|
18
18
|
// Per-closeout byte budget when stuffing the narrator's input. Three
|
|
19
19
|
// closeouts × 4 KB each ≈ 12 KB of prompt context, which is well under any
|
|
@@ -133,10 +133,7 @@ async function writeNarrationCache(workspaceDir, content, phaseCount) {
|
|
|
133
133
|
const generatedAt = new Date().toISOString();
|
|
134
134
|
const frontmatter = stringifySimpleYaml({ generatedAt, phaseCountAtGeneration: phaseCount });
|
|
135
135
|
const body = `---\n${frontmatter}\n---\n${content}\n`;
|
|
136
|
-
|
|
137
|
-
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
138
|
-
await writeFile(tempPath, body, "utf8");
|
|
139
|
-
await rename(tempPath, path);
|
|
136
|
+
await atomicWriteFile(join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH), body);
|
|
140
137
|
}
|
|
141
138
|
async function runNarratorOnce(ctx, prompt) {
|
|
142
139
|
const cwd = ctx.cwd;
|