codecartographer-pi 0.19.5 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +14 -0
  3. package/.codecarto/broadside/config.yaml +18 -0
  4. package/.codecarto/templates/gitignore +55 -0
  5. package/.codecarto/workflow/VALIDATE.md +2 -1
  6. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  7. package/README.md +10 -6
  8. package/dist/core/amendment.js +28 -23
  9. package/dist/core/broadside.d.ts +72 -2
  10. package/dist/core/broadside.js +351 -68
  11. package/dist/core/completion.js +95 -26
  12. package/dist/core/dashboard-writer.d.ts +8 -0
  13. package/dist/core/dashboard-writer.js +159 -0
  14. package/dist/core/index.d.ts +2 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/library.js +115 -107
  17. package/dist/core/orchestrator-config.d.ts +32 -7
  18. package/dist/core/orchestrator-config.js +124 -44
  19. package/dist/core/pipeline.d.ts +37 -0
  20. package/dist/core/pipeline.js +80 -10
  21. package/dist/core/prompts.d.ts +20 -0
  22. package/dist/core/prompts.js +43 -10
  23. package/dist/core/secrets.d.ts +16 -0
  24. package/dist/core/secrets.js +98 -0
  25. package/dist/core/status.d.ts +30 -2
  26. package/dist/core/status.js +54 -8
  27. package/dist/core/synthesis.js +5 -2
  28. package/dist/core/usage.d.ts +8 -0
  29. package/dist/core/usage.js +35 -7
  30. package/dist/core/utils.d.ts +32 -5
  31. package/dist/core/utils.js +81 -19
  32. package/dist/core/workspace.d.ts +99 -18
  33. package/dist/core/workspace.js +275 -36
  34. package/dist/core/yaml.js +173 -14
  35. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  36. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  37. package/dist/extensions/codecarto/agent-runner.js +27 -9
  38. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  39. package/dist/extensions/codecarto/auto-runner.js +10 -6
  40. package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
  41. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  42. package/dist/extensions/codecarto/dashboard-writer.js +5 -157
  43. package/dist/extensions/codecarto/index.js +73 -21
  44. package/dist/extensions/codecarto/phase-compaction.js +7 -7
  45. package/dist/mcp-server/server.js +111 -50
  46. package/package.json +3 -2
@@ -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,34 @@ 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
+ * Move a workspace's session state out into `backupDir`, keeping relative
49
+ * paths: status, the usage log, every declared phase output, handoffs and
50
+ * checkpoints, closeouts, the dashboard, the orchestrator files, Broad-Side
51
+ * runs, and any lock or temp file — everything {@link isTemplatePath} says a
52
+ * session wrote rather than the framework shipped. Framework-owned files stay
53
+ * where they are, as do the directories, so the workspace keeps its shape.
54
+ *
55
+ * This is what a forced re-init does when `.codecarto/` is the packaged
56
+ * template itself (#245). A checkout's `.codecarto/` is the template and a
57
+ * live workspace at once, so the ordinary force — rename the directory away,
58
+ * copy the template in — would move the very files it copies from. Before
59
+ * this, that case skipped the backup entirely and reset status in place.
60
+ *
61
+ * @returns the workspace-relative paths moved, sorted.
62
+ */
63
+ export declare function backupWorkspaceState(workspaceDir: string, backupDir: string): Promise<string[]>;
64
+ /**
65
+ * Give the workspace its ignore rules when it has none. npm never packs a file
66
+ * named `.gitignore`, so an npm-installed template carried no rules and the
67
+ * workspaces initialised from it committed the dashboard, the usage log with
68
+ * its absolute session paths, and the Broad-Side config with any API key in
69
+ * it (#229). The rules ship as `templates/gitignore` instead and are copied
70
+ * to `.gitignore` here; an existing `.gitignore` is the user's and is left
71
+ * alone.
72
+ * @returns whether a file was written.
73
+ */
74
+ export declare function ensureWorkspaceGitignore(workspaceDir: string): Promise<boolean>;
32
75
  /**
33
76
  * Seed the orchestrator-maintained files from the workspace's templates
34
77
  * (issue #98): orchestration is on by default, so a fresh workspace starts
@@ -62,7 +105,7 @@ export type RefreshScaffoldResult = {
62
105
  * exact set {@link refreshScaffold} copies, computed without writing anything.
63
106
  * A wrapper that asks before refreshing shows this.
64
107
  */
65
- export declare function listScaffoldRefreshFiles(): Promise<string[]>;
108
+ export declare function listScaffoldRefreshFiles(sourceWorkspaceDir?: string): Promise<string[]>;
66
109
  /**
67
110
  * Refresh a workspace's framework-owned files from the packaged template
68
111
  * (issue #102): both staleness notices instruct exactly this, and the only
@@ -85,26 +128,64 @@ export declare function refreshScaffold(cwd: string): Promise<RefreshScaffoldRes
85
128
  * fail: unversioned workspaces must keep working.
86
129
  */
87
130
  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
- }> | {
131
+ /** What an {@link updateStatusAtomically} updater returns. */
132
+ export interface StatusUpdate {
93
133
  state: WorkspaceState;
94
134
  handoff?: PhaseHandoff;
95
135
  threadLogEntry?: string;
96
- }): Promise<WorkspaceState>;
136
+ /**
137
+ * Runs under the lock only after status.yaml has been renamed into place —
138
+ * the commit point. Closeouts, index lines, decision rows, and anything
139
+ * else that asserts "this phase is complete" belong here rather than in
140
+ * the updater, so a failed commit leaves none of them behind (#234). A
141
+ * step that fails here surfaces as an error, but the status change stands;
142
+ * steps must therefore be idempotent so re-running the operation
143
+ * regenerates what they write.
144
+ */
145
+ afterCommit?: (committed: WorkspaceState) => Promise<void> | void;
146
+ }
147
+ export declare function updateStatusAtomically(cwd: string, updater: (state: WorkspaceState) => Promise<StatusUpdate> | StatusUpdate): Promise<WorkspaceState>;
97
148
  /**
98
- * Switch the active pipeline in-place without deleting findings, handoffs,
99
- * usage data, closeouts, or checkpoints. Phases that exist in both the old
100
- * and new pipelines preserve their completion status, owner notes, open
101
- * questions, and carry-forward entries. Phases unique to the new pipeline
102
- * start as pending. Phases unique to the old pipeline are dropped from
103
- * status.yaml (but their findings remain on disk under findings/).
149
+ * A carry-forward whose `target_phase` the new pipeline does not run. The
150
+ * switch moves it to `post_pipeline` (the framework's own rule for work no
151
+ * active downstream phase will pick up; completion refuses such a target in a
152
+ * handoff) so an amendment can still close it, and reports it here so both
153
+ * surfaces can say what moved and where it was going.
104
154
  */
105
- export declare function switchPipeline(cwd: string, newPipelinePath: string): Promise<{
155
+ export interface DanglingCarryForward {
156
+ /** The entry's id, unchanged, now keyed in `post_pipeline`. */
157
+ id: string;
158
+ /** The phase whose handoff routed it. */
159
+ source_phase: string;
160
+ /** The phase it targeted, which the new pipeline lacks. */
161
+ target_phase: string;
162
+ description?: string;
163
+ }
164
+ export interface SwitchPipelineResult {
106
165
  state: WorkspaceState;
166
+ /** Phases in both pipelines whose `complete` status carried over. */
107
167
  carried: string[];
168
+ /** Phases of the old pipeline the new one does not run. */
108
169
  dropped: string[];
170
+ /** Phases of the new pipeline the old one did not have. */
109
171
  newPhases: string[];
110
- }>;
172
+ /** Carry-forwards re-routed to `post_pipeline` because their target was dropped (#237). */
173
+ dangling: DanglingCarryForward[];
174
+ }
175
+ /**
176
+ * The lines both surfaces print for the carry-forwards a switch moved to
177
+ * post_pipeline: one summary, then one line per entry naming where it was
178
+ * going and how to close it now. Empty when nothing moved.
179
+ */
180
+ export declare function describeDanglingCarryForward(dangling: DanglingCarryForward[]): string[];
181
+ /**
182
+ * Switch the active pipeline in-place without deleting findings, handoffs,
183
+ * usage data, closeouts, or checkpoints. Phases that exist in both the old
184
+ * and new pipelines preserve their completion status, owner notes, open
185
+ * questions, and carry-forward entries; the cursor is then recomputed from
186
+ * those carried completions (#236). Phases unique to the new pipeline start
187
+ * as pending. Phases unique to the old pipeline are dropped from status.yaml
188
+ * (but their findings remain on disk under findings/), and any carry-forward
189
+ * that targeted one of them moves to post_pipeline (#237).
190
+ */
191
+ export declare function switchPipeline(cwd: string, newPipelinePath: string): Promise<SwitchPipelineResult>;
@@ -3,11 +3,12 @@
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, rename } from "node:fs/promises";
7
7
  import { basename, dirname, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { acquireLock, applyHandoff, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
10
- import { compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
9
+ import { getPipelineLabel, recomputeCursor } from "./pipeline.js";
10
+ import { acquireLock, applyHandoff, autoAssignIds, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
11
+ import { atomicWriteFile, compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
11
12
  import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
12
13
  // Walk up from the current file to find the package root. Needed because the
13
14
  // source lives at <root>/core/workspace.ts (one level below the package root)
@@ -123,8 +124,25 @@ export const ORCHESTRATOR_FILES = [
123
124
  * The four top-level files are seeded fresh from templates instead
124
125
  * ({@link ORCHESTRATOR_FILES}); `closeouts/` is created empty.
125
126
  */
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"]);
127
+ const INIT_EXCLUDED_TOP_LEVEL = new Set([
128
+ "BACKLOG.md",
129
+ "THREAD_LOG.md",
130
+ "CONVENTIONS.md",
131
+ "DECISIONS.md",
132
+ // Rendered from a workspace's own state; the narration cache holds an
133
+ // LLM summary of it.
134
+ "dashboard.html",
135
+ ".dashboard-narration.local.md",
136
+ ]);
137
+ // Directories that exist in every workspace but whose contents are one
138
+ // project's sessions: closeouts, and scratch (handoffs, checkpoints,
139
+ // amendments) apart from its .gitkeep.
140
+ const INIT_EXCLUDED_DIR_CONTENTS = new Set(["closeouts", "scratch"]);
141
+ // Project state under workflow/: init writes a fresh status.yaml itself, and
142
+ // the two dot-files hold one machine's usage log and session pointer.
143
+ const INIT_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", ".usage.local.yaml", ".orchestrator.local.yaml"]);
144
+ // Where the workspace's ignore rules ship (see ensureWorkspaceGitignore).
145
+ const GITIGNORE_TEMPLATE_RELATIVE_PATH = "templates/gitignore";
128
146
  // broadside/ is machine-local scan state (state.json, timestamped run dirs)
129
147
  // except for its two template files — the same carve-out .codecarto/.gitignore
130
148
  // makes for this repository itself. Without this, init from a local checkout
@@ -133,9 +151,83 @@ const INIT_EXCLUDED_DIR_CONTENTS = new Set(["closeouts"]);
133
151
  const BROADSIDE_DIR_NAME = "broadside";
134
152
  const INIT_BROADSIDE_TEMPLATE_FILES = new Set(["SKILL.md", "config.yaml"]);
135
153
  /**
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/`.
154
+ * Every workspace-relative path a packaged pipeline declares as a phase output,
155
+ * primary or secondary. Findings directories ship README and SKILL stubs
156
+ * beside the reports sessions write, so the reports have to be excluded by
157
+ * name rather than by directory, and the pipelines are the source of truth
158
+ * for those names. A pipeline file that fails to load contributes nothing.
159
+ */
160
+ export async function listDeclaredOutputs(sourceWorkspaceDir = packagedWorkspaceDir) {
161
+ const outputs = new Set();
162
+ const workflowDir = join(sourceWorkspaceDir, "workflow");
163
+ let names;
164
+ try {
165
+ names = await readdir(workflowDir);
166
+ }
167
+ catch {
168
+ return outputs;
169
+ }
170
+ for (const name of names) {
171
+ if (!/^pipeline.*\.ya?ml$/.test(name))
172
+ continue;
173
+ let pipeline;
174
+ try {
175
+ pipeline = await loadYamlFile(join(workflowDir, name));
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ for (const phase of pipeline?.phases ?? []) {
181
+ if (typeof phase.primary_output === "string")
182
+ outputs.add(toPosixRelative(phase.primary_output));
183
+ for (const secondary of phase.secondary_outputs ?? []) {
184
+ const path = secondary.path;
185
+ if (typeof path === "string")
186
+ outputs.add(toPosixRelative(path));
187
+ }
188
+ }
189
+ }
190
+ return outputs;
191
+ }
192
+ function toPosixRelative(path) {
193
+ return path.replace(/\\/g, "/").replace(/^\.\//, "");
194
+ }
195
+ /**
196
+ * Whether a template path (as segments) is framework-owned and travels into a
197
+ * new workspace. Everything a session produces stays behind: the declared
198
+ * phase outputs, handoffs and checkpoints, closeouts, the dashboard, usage,
199
+ * status, Broad-Side runs, and any lock or temp file a crashed process left.
200
+ * Directories pass so the workspace keeps its shape (an empty `closeouts/`,
201
+ * a `findings/<phase>/` for every phase).
202
+ */
203
+ function isTemplatePath(segments, declaredOutputs) {
204
+ const posixPath = segments.join("/");
205
+ if (declaredOutputs.has(posixPath))
206
+ return false;
207
+ if (/\.(lock|tmp)$/.test(posixPath))
208
+ return false;
209
+ if (segments.length === 1)
210
+ return !INIT_EXCLUDED_TOP_LEVEL.has(segments[0]);
211
+ const [top, second] = segments;
212
+ if (top === BROADSIDE_DIR_NAME)
213
+ return segments.length === 2 && INIT_BROADSIDE_TEMPLATE_FILES.has(second);
214
+ if (top === "scratch")
215
+ return segments.length === 2 && second === ".gitkeep";
216
+ if (top === "workflow")
217
+ return !(segments.length === 2 && INIT_EXCLUDED_WORKFLOW_FILES.has(second));
218
+ return !INIT_EXCLUDED_DIR_CONTENTS.has(top);
219
+ }
220
+ /**
221
+ * Copy the packaged template into a target workspace, skipping everything a
222
+ * session wrote into it. Directories are still created, so a fresh workspace
223
+ * has an empty `closeouts/` rather than no `closeouts/`.
224
+ *
225
+ * This repository's `.codecarto/` is the template *and* CodeCartographer's own
226
+ * live workspace, so a checkout install's template can hold finished phase
227
+ * reports, handoffs, and a dashboard. Copying those seeded every new workspace
228
+ * with another project's findings, and validation then passed on them (#224).
229
+ * The exclusion is by declared output path ({@link listDeclaredOutputs}), so a
230
+ * new phase's report is covered the moment its pipeline names it.
139
231
  *
140
232
  * @param targetWorkspaceDir - Absolute path to the `.codecarto/` to create or merge into.
141
233
  * @param sourceWorkspaceDir - The template to copy from. Defaults to the packaged
@@ -143,20 +235,14 @@ const INIT_BROADSIDE_TEMPLATE_FILES = new Set(["SKILL.md", "config.yaml"]);
143
235
  * without mutating the repository's own live workspace mid-suite.
144
236
  */
145
237
  export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceDir = packagedWorkspaceDir) {
238
+ const declaredOutputs = await listDeclaredOutputs(sourceWorkspaceDir);
146
239
  await cp(sourceWorkspaceDir, targetWorkspaceDir, {
147
240
  recursive: true,
148
241
  filter: (source) => {
149
242
  const relativePath = relative(sourceWorkspaceDir, source);
150
243
  if (!relativePath)
151
244
  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]);
245
+ return isTemplatePath(relativePath.split(/[\\/]/), declaredOutputs);
160
246
  },
161
247
  });
162
248
  // The published tarball carries no empty directories, so an excluded-contents
@@ -166,6 +252,65 @@ export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceD
166
252
  for (const name of INIT_EXCLUDED_DIR_CONTENTS) {
167
253
  await mkdir(join(targetWorkspaceDir, name), { recursive: true });
168
254
  }
255
+ await ensureWorkspaceGitignore(targetWorkspaceDir);
256
+ }
257
+ /**
258
+ * Move a workspace's session state out into `backupDir`, keeping relative
259
+ * paths: status, the usage log, every declared phase output, handoffs and
260
+ * checkpoints, closeouts, the dashboard, the orchestrator files, Broad-Side
261
+ * runs, and any lock or temp file — everything {@link isTemplatePath} says a
262
+ * session wrote rather than the framework shipped. Framework-owned files stay
263
+ * where they are, as do the directories, so the workspace keeps its shape.
264
+ *
265
+ * This is what a forced re-init does when `.codecarto/` is the packaged
266
+ * template itself (#245). A checkout's `.codecarto/` is the template and a
267
+ * live workspace at once, so the ordinary force — rename the directory away,
268
+ * copy the template in — would move the very files it copies from. Before
269
+ * this, that case skipped the backup entirely and reset status in place.
270
+ *
271
+ * @returns the workspace-relative paths moved, sorted.
272
+ */
273
+ export async function backupWorkspaceState(workspaceDir, backupDir) {
274
+ const declaredOutputs = await listDeclaredOutputs(workspaceDir);
275
+ const moved = [];
276
+ const walk = async (dir, segments) => {
277
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
278
+ const entrySegments = [...segments, entry.name];
279
+ const source = join(dir, entry.name);
280
+ if (entry.isDirectory()) {
281
+ await walk(source, entrySegments);
282
+ continue;
283
+ }
284
+ if (isTemplatePath(entrySegments, declaredOutputs))
285
+ continue;
286
+ const destination = join(backupDir, ...entrySegments);
287
+ await mkdir(dirname(destination), { recursive: true });
288
+ await rename(source, destination);
289
+ moved.push(entrySegments.join("/"));
290
+ }
291
+ };
292
+ await walk(workspaceDir, []);
293
+ return moved.sort();
294
+ }
295
+ /**
296
+ * Give the workspace its ignore rules when it has none. npm never packs a file
297
+ * named `.gitignore`, so an npm-installed template carried no rules and the
298
+ * workspaces initialised from it committed the dashboard, the usage log with
299
+ * its absolute session paths, and the Broad-Side config with any API key in
300
+ * it (#229). The rules ship as `templates/gitignore` instead and are copied
301
+ * to `.gitignore` here; an existing `.gitignore` is the user's and is left
302
+ * alone.
303
+ * @returns whether a file was written.
304
+ */
305
+ export async function ensureWorkspaceGitignore(workspaceDir) {
306
+ const target = join(workspaceDir, ".gitignore");
307
+ if (await pathExists(target))
308
+ return false;
309
+ const template = join(workspaceDir, GITIGNORE_TEMPLATE_RELATIVE_PATH);
310
+ if (!(await pathExists(template)))
311
+ return false;
312
+ await copyFile(template, target);
313
+ return true;
169
314
  }
170
315
  /**
171
316
  * Seed the orchestrator-maintained files from the workspace's templates
@@ -194,11 +339,20 @@ export async function seedOrchestratorFiles(workspaceDir) {
194
339
  * user-owned top-level files, and the directories sessions write into.
195
340
  * Everything else present in the packaged template is framework-owned.
196
341
  */
197
- const REFRESH_EXCLUDED_TOP_LEVEL = new Set(["BACKLOG.md", "THREAD_LOG.md", "CONVENTIONS.md", "DECISIONS.md"]);
342
+ const REFRESH_EXCLUDED_TOP_LEVEL = new Set([
343
+ "BACKLOG.md",
344
+ "THREAD_LOG.md",
345
+ "CONVENTIONS.md",
346
+ "DECISIONS.md",
347
+ "dashboard.html",
348
+ ".dashboard-narration.local.md",
349
+ // The user's ignore rules; created from templates/gitignore when absent.
350
+ ".gitignore",
351
+ ]);
198
352
  // broadside/ holds machine-local scout state (batch ids, API key config,
199
353
  // generated results) — refresh must never overwrite it.
200
354
  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"]);
355
+ const REFRESH_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", "config.yaml", ".usage.local.yaml", ".orchestrator.local.yaml"]);
202
356
  /**
203
357
  * What a scaffold refresh never touches, for a wrapper that asks before
204
358
  * refreshing to show. The same sets drive {@link refreshScaffold}, so the
@@ -209,7 +363,7 @@ export const SCAFFOLD_REFRESH_PROTECTED = Object.freeze({
209
363
  dirs: Object.freeze([...REFRESH_EXCLUDED_DIRS]),
210
364
  workflowFiles: Object.freeze([...REFRESH_EXCLUDED_WORKFLOW_FILES]),
211
365
  });
212
- async function listTemplateFiles(dir, relativeDir = "") {
366
+ async function listTemplateFiles(dir, declaredOutputs, relativeDir = "") {
213
367
  const entries = await readdir(dir, { withFileTypes: true });
214
368
  const files = [];
215
369
  for (const entry of entries) {
@@ -217,13 +371,20 @@ async function listTemplateFiles(dir, relativeDir = "") {
217
371
  if (entry.isDirectory()) {
218
372
  if (!relativeDir && REFRESH_EXCLUDED_DIRS.has(entry.name))
219
373
  continue;
220
- files.push(...await listTemplateFiles(join(dir, entry.name), relativePath));
374
+ files.push(...await listTemplateFiles(join(dir, entry.name), declaredOutputs, relativePath));
221
375
  continue;
222
376
  }
223
377
  if (!relativeDir && REFRESH_EXCLUDED_TOP_LEVEL.has(entry.name))
224
378
  continue;
225
379
  if (relativeDir === "workflow" && REFRESH_EXCLUDED_WORKFLOW_FILES.has(entry.name))
226
380
  continue;
381
+ // A checkout install's template can hold finished reports (the repository
382
+ // analyses itself); refreshing those over a user's own would be worse
383
+ // than init copying them (#224).
384
+ if (declaredOutputs.has(relativePath))
385
+ continue;
386
+ if (/\.(lock|tmp)$/.test(entry.name))
387
+ continue;
227
388
  files.push(relativePath);
228
389
  }
229
390
  return files;
@@ -233,11 +394,12 @@ async function listTemplateFiles(dir, relativeDir = "") {
233
394
  * exact set {@link refreshScaffold} copies, computed without writing anything.
234
395
  * A wrapper that asks before refreshing shows this.
235
396
  */
236
- export async function listScaffoldRefreshFiles() {
237
- if (!existsSync(packagedWorkspaceDir)) {
397
+ export async function listScaffoldRefreshFiles(sourceWorkspaceDir = packagedWorkspaceDir) {
398
+ if (!existsSync(sourceWorkspaceDir)) {
238
399
  throw new Error("Packaged .codecarto template is missing. Reinstall codecartographer-pi.");
239
400
  }
240
- return (await listTemplateFiles(packagedWorkspaceDir)).sort();
401
+ const declaredOutputs = await listDeclaredOutputs(sourceWorkspaceDir);
402
+ return (await listTemplateFiles(sourceWorkspaceDir, declaredOutputs)).sort();
241
403
  }
242
404
  /**
243
405
  * Refresh a workspace's framework-owned files from the packaged template
@@ -265,6 +427,10 @@ export async function refreshScaffold(cwd) {
265
427
  await mkdir(dirname(target), { recursive: true });
266
428
  await copyFile(join(packagedWorkspaceDir, relativePath), target);
267
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);
268
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.`;
269
435
  const threadLogPath = join(state.workspaceDir, "THREAD_LOG.md");
270
436
  let currentLog = "";
@@ -332,10 +498,17 @@ export async function updateStatusAtomically(cwd, updater) {
332
498
  applyHandoff(nextState.status, handoff);
333
499
  }
334
500
  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);
501
+ await atomicWriteFile(statusPath, `${stringifySimpleYaml(nextState.status)}\n`);
502
+ if (result.afterCommit) {
503
+ try {
504
+ await result.afterCommit(nextState);
505
+ }
506
+ catch (error) {
507
+ const reason = error instanceof Error ? error.message : String(error);
508
+ throw new Error(`status.yaml was updated, but a step that runs after the update failed: ${reason} ` +
509
+ "The status change stands; re-run the same operation to regenerate what that step writes.", { cause: error });
510
+ }
511
+ }
339
512
  if (result.threadLogEntry) {
340
513
  const threadLogPath = join(workspaceDir, "THREAD_LOG.md");
341
514
  let currentLog = "";
@@ -358,13 +531,31 @@ export async function updateStatusAtomically(cwd, updater) {
358
531
  await lock.release();
359
532
  }
360
533
  }
534
+ /**
535
+ * The lines both surfaces print for the carry-forwards a switch moved to
536
+ * post_pipeline: one summary, then one line per entry naming where it was
537
+ * going and how to close it now. Empty when nothing moved.
538
+ */
539
+ export function describeDanglingCarryForward(dangling) {
540
+ if (dangling.length === 0)
541
+ return [];
542
+ const noun = dangling.length === 1 ? "carry-forward item" : "carry-forward items";
543
+ const lines = [`${dangling.length} ${noun} targeted a dropped phase and moved to post_pipeline (close with an amendment's post_pipeline_closures):`];
544
+ for (const entry of dangling) {
545
+ const label = entry.description ? `: ${entry.description}` : "";
546
+ lines.push(` - ${entry.id} (${entry.source_phase} → ${entry.target_phase})${label}`);
547
+ }
548
+ return lines;
549
+ }
361
550
  /**
362
551
  * Switch the active pipeline in-place without deleting findings, handoffs,
363
552
  * usage data, closeouts, or checkpoints. Phases that exist in both the old
364
553
  * and new pipelines preserve their completion status, owner notes, open
365
- * questions, and carry-forward entries. Phases unique to the new pipeline
366
- * start as pending. Phases unique to the old pipeline are dropped from
367
- * status.yaml (but their findings remain on disk under findings/).
554
+ * questions, and carry-forward entries; the cursor is then recomputed from
555
+ * those carried completions (#236). Phases unique to the new pipeline start
556
+ * as pending. Phases unique to the old pipeline are dropped from status.yaml
557
+ * (but their findings remain on disk under findings/), and any carry-forward
558
+ * that targeted one of them moves to post_pipeline (#237).
368
559
  */
369
560
  export async function switchPipeline(cwd, newPipelinePath) {
370
561
  const workspaceDir = join(cwd, ".codecarto");
@@ -397,17 +588,65 @@ export async function switchPipeline(cwd, newPipelinePath) {
397
588
  const dropped = currentState.pipeline.phase_order.filter((phaseId) => !newPipeline.phase_order.includes(phaseId));
398
589
  const newPhases = newPipeline.phase_order.filter((phaseId) => !currentState.pipeline.phase_order.includes(phaseId));
399
590
  // Preserve post_pipeline entries from the old status.
400
- freshStatus.post_pipeline = currentState.status.post_pipeline;
591
+ freshStatus.post_pipeline = [...currentState.status.post_pipeline];
592
+ // A carried phase may have routed work to a phase the new pipeline does
593
+ // not run. Left in place, that entry would never appear in a phase
594
+ // prompt and nothing but a later handoff could close it (#237). It is
595
+ // post-pipeline work now, by the same rule completion applies to a
596
+ // handoff whose target is not a downstream active phase.
597
+ const dangling = [];
598
+ const activePhases = new Set(newPipeline.phase_order);
599
+ const newLabel = getPipelineLabel(newPipelinePath);
600
+ const postPipelineById = new Map(freshStatus.post_pipeline.filter((entry) => entry.id).map((entry) => [entry.id, entry]));
601
+ for (const [phaseId, phase] of Object.entries(freshStatus.phases)) {
602
+ if (!phase.carry_forward.some((entry) => entry.target_phase && !activePhases.has(entry.target_phase)))
603
+ continue;
604
+ // Handoff-routed entries always carry a `cf-<phase>-N` id; only a
605
+ // hand-edited status can lack one, and post_pipeline keys by id.
606
+ autoAssignIds(phase.carry_forward, "cf", phaseId);
607
+ const kept = [];
608
+ for (const entry of phase.carry_forward) {
609
+ if (!entry.target_phase || activePhases.has(entry.target_phase)) {
610
+ kept.push(entry);
611
+ continue;
612
+ }
613
+ const note = `Routed to ${entry.target_phase}, which the ${newLabel} pipeline does not run.`;
614
+ const moved = {
615
+ id: entry.id,
616
+ ...(entry.kind !== undefined && { kind: entry.kind }),
617
+ ...(entry.description !== undefined && { description: entry.description }),
618
+ deferred_reason: entry.deferred_reason ? `${entry.deferred_reason} ${note}` : note,
619
+ source_phase: phaseId,
620
+ status: "pending",
621
+ };
622
+ if (postPipelineById.has(entry.id)) {
623
+ const index = freshStatus.post_pipeline.findIndex((existing) => existing.id === entry.id);
624
+ freshStatus.post_pipeline[index] = moved;
625
+ }
626
+ else {
627
+ freshStatus.post_pipeline.push(moved);
628
+ postPipelineById.set(entry.id, moved);
629
+ }
630
+ dangling.push({
631
+ id: entry.id,
632
+ source_phase: phaseId,
633
+ target_phase: entry.target_phase,
634
+ ...(entry.description !== undefined && { description: entry.description }),
635
+ });
636
+ }
637
+ phase.carry_forward = kept;
638
+ }
401
639
  freshStatus.last_updated = new Date().toISOString();
640
+ // createEmptyStatus pointed the cursor at phase one; the carried
641
+ // completions may have moved it (#236). Ask the engine, exactly as
642
+ // completion does, so status and next agree from the moment of the switch.
643
+ recomputeCursor({ ...currentState, pipeline: newPipeline, status: freshStatus });
402
644
  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);
645
+ await atomicWriteFile(statusPath, `${stringifySimpleYaml(freshStatus)}\n`);
407
646
  const state = await getWorkspaceState(cwd);
408
647
  if (!state)
409
648
  throw new Error("Failed to reload workspace state after pipeline switch.");
410
- return { state, carried, dropped, newPhases };
649
+ return { state, carried, dropped, newPhases, dangling };
411
650
  }
412
651
  finally {
413
652
  await lock.release();