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.
@@ -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, rename, writeFile } from "node:fs/promises";
10
+ import { readFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
- import { pathExists } from "./utils.js";
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 tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
37
- await writeFile(tempPath, `${stringifySimpleYaml(current)}\n`, "utf8");
38
- await rename(tempPath, path);
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();
@@ -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
- * Symlink-aware version of isWithinPath. Resolves symlinks on both the path
8
- * and root before comparing, preventing bypass via symlinks inside the
9
- * allowed root that point outside it.
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
- * Falls back to lexical isWithinPath if realpath fails (e.g., path does not
12
- * exist yet), which is safe for write targets that haven't been created.
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>;
@@ -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 { access } from "node:fs/promises";
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
- * Symlink-aware version of isWithinPath. Resolves symlinks on both the path
47
- * and root before comparing, preventing bypass via symlinks inside the
48
- * allowed root that point outside it.
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
- * Falls back to lexical isWithinPath if realpath fails (e.g., path does not
51
- * exist yet), which is safe for write targets that haven't been created.
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 isWithinPathResolved(path, root) {
54
- try {
55
- const resolvedPath = await realpath(path);
56
- const resolvedRoot = await realpath(root);
57
- return isWithinPath(resolvedPath, resolvedRoot);
58
- }
59
- catch {
60
- // If realpath fails (path doesn't exist yet, broken symlink, etc.),
61
- // fall back to lexical check. For write targets this is safe because
62
- // the parent directory should already be within the root.
63
- return isWithinPath(path, root);
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);
@@ -19,9 +19,24 @@ export declare const ORCHESTRATOR_FILES: readonly [{
19
19
  readonly template: "thread-log.md";
20
20
  }];
21
21
  /**
22
- * Copy the packaged template into a target workspace, skipping this
23
- * repository's own project state. Directories are still created, so a fresh
24
- * workspace has an empty `closeouts/` rather than no `closeouts/`.
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
- export declare function updateStatusAtomically(cwd: string, updater: (state: WorkspaceState) => Promise<{
89
- state: WorkspaceState;
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
- }): Promise<WorkspaceState>;
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
@@ -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, rename, writeFile } from "node:fs/promises";
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(["BACKLOG.md", "THREAD_LOG.md", "CONVENTIONS.md", "DECISIONS.md"]);
127
- const INIT_EXCLUDED_DIR_CONTENTS = new Set(["closeouts"]);
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
- * Copy the packaged template into a target workspace, skipping this
137
- * repository's own project state. Directories are still created, so a fresh
138
- * workspace has an empty `closeouts/` rather than no `closeouts/`.
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
- const segments = relativePath.split(/[\\/]/);
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(["BACKLOG.md", "THREAD_LOG.md", "CONVENTIONS.md", "DECISIONS.md"]);
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(packagedWorkspaceDir)) {
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
- return (await listTemplateFiles(packagedWorkspaceDir)).sort();
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
- const serialized = `${stringifySimpleYaml(nextState.status)}\n`;
336
- const tempPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
337
- await writeFile(tempPath, serialized, "utf8");
338
- await rename(tempPath, statusPath);
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
- const serialized = `${stringifySimpleYaml(freshStatus)}\n`;
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, rename, writeFile } from "node:fs/promises";
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
- const path = join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH);
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;