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.
- package/.codecarto/GUIDE.md +1 -1
- package/.codecarto/broadside/SKILL.md +14 -0
- package/.codecarto/broadside/config.yaml +18 -0
- package/.codecarto/templates/gitignore +55 -0
- package/.codecarto/workflow/VALIDATE.md +2 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/dist/core/amendment.js +28 -23
- package/dist/core/broadside.d.ts +72 -2
- package/dist/core/broadside.js +351 -68
- package/dist/core/completion.js +95 -26
- package/dist/core/dashboard-writer.d.ts +8 -0
- package/dist/core/dashboard-writer.js +159 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.js +115 -107
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +80 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +43 -10
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +30 -2
- package/dist/core/status.js +54 -8
- package/dist/core/synthesis.js +5 -2
- 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 +99 -18
- package/dist/core/workspace.js +275 -36
- package/dist/core/yaml.js +173 -14
- package/dist/extensions/codecarto/agent-rewriter.js +21 -14
- package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
- package/dist/extensions/codecarto/agent-runner.js +27 -9
- package/dist/extensions/codecarto/agent-state.d.ts +0 -2
- package/dist/extensions/codecarto/auto-runner.js +10 -6
- package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
- package/dist/extensions/codecarto/dashboard-writer.js +5 -157
- package/dist/extensions/codecarto/index.js +73 -21
- package/dist/extensions/codecarto/phase-compaction.js +7 -7
- package/dist/mcp-server/server.js +111 -50
- package/package.json +3 -2
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,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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
|
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>;
|
package/dist/core/workspace.js
CHANGED
|
@@ -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
|
|
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 {
|
|
10
|
-
import {
|
|
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([
|
|
127
|
-
|
|
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
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
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([
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
-
|
|
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();
|