codecartographer-pi 0.15.0 → 0.17.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 (44) hide show
  1. package/.codecarto/GUIDE.md +15 -2
  2. package/.codecarto/README.md +3 -0
  3. package/.codecarto/broadside/SKILL.md +143 -0
  4. package/.codecarto/broadside/config.yaml +104 -0
  5. package/.codecarto/findings/broadside-scout/README.md +20 -0
  6. package/.codecarto/findings/broadside-scout/SKILL.md +101 -0
  7. package/.codecarto/skills/spec-delta-application/SKILL.md +3 -1
  8. package/.codecarto/templates/backlog-project.md +51 -0
  9. package/.codecarto/templates/broadside-scout-brief.md +97 -0
  10. package/.codecarto/{THREAD_LOG.md → templates/thread-log.md} +2 -5
  11. package/.codecarto/workflow/pipeline-scout-first.yaml +271 -0
  12. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  13. package/README.md +47 -2
  14. package/agent-skill/codecartographer/SKILL.md +6 -1
  15. package/agent-skill/codecartographer/references/broadside.md +115 -0
  16. package/agent-skill/codecartographer/references/library.md +32 -0
  17. package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
  18. package/dist/core/amendment.js +4 -1
  19. package/dist/core/broadside.d.ts +421 -0
  20. package/dist/core/broadside.js +2349 -0
  21. package/dist/core/completion.js +49 -14
  22. package/dist/core/index.d.ts +1 -0
  23. package/dist/core/index.js +1 -0
  24. package/dist/core/library.d.ts +22 -0
  25. package/dist/core/library.js +101 -1
  26. package/dist/core/orchestrator-config.js +5 -2
  27. package/dist/core/pipeline.js +1 -0
  28. package/dist/core/status.d.ts +8 -0
  29. package/dist/core/status.js +31 -1
  30. package/dist/core/utils.js +7 -1
  31. package/dist/core/workspace.d.ts +17 -0
  32. package/dist/core/workspace.js +68 -2
  33. package/dist/extensions/codecarto/agent-runner.js +6 -0
  34. package/dist/extensions/codecarto/broadside-flags.d.ts +21 -0
  35. package/dist/extensions/codecarto/broadside-flags.js +116 -0
  36. package/dist/extensions/codecarto/dashboard-writer.d.ts +8 -1
  37. package/dist/extensions/codecarto/dashboard-writer.js +10 -1
  38. package/dist/extensions/codecarto/index.js +232 -4
  39. package/dist/mcp-server/server.d.ts +22 -0
  40. package/dist/mcp-server/server.js +241 -13
  41. package/package.json +10 -1
  42. package/.codecarto/BACKLOG.md +0 -184
  43. package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +0 -118
  44. package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +0 -111
@@ -1,7 +1,7 @@
1
1
  import { appendFile, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { getNextEligiblePhase, resolvePhase } from "./pipeline.js";
4
- import { applyHandoff, autoAssignIds, loadHandoffFile, normalizeStatus } from "./status.js";
3
+ import { getNextEligiblePhase, resolvePhase, validatePhaseOutput } from "./pipeline.js";
4
+ import { applyHandoff, autoAssignIds, buildTerminalNextActions, loadHandoffFile, normalizeStatus } from "./status.js";
5
5
  import { dateOnly, pathExists, uniqueStrings } from "./utils.js";
6
6
  import { getWorkspaceState, updateStatusAtomically } from "./workspace.js";
7
7
  /**
@@ -46,6 +46,18 @@ function visibleMarkdown(content) {
46
46
  export const DECISIONS_COMPLETION_LOG_HEADING = "## Completion log";
47
47
  /** Section heading completion stages proposed conventions under. */
48
48
  export const CONVENTIONS_PENDING_HEADING = "## Pending proposals";
49
+ /**
50
+ * True when `heading` exists as its own visible line. Both orchestrator-file
51
+ * templates mention their headings in running prose (issue #111: a raw
52
+ * substring check saw the decisions-template's "…rows under `## Completion
53
+ * log`…" sentence and never inserted the real heading), so presence checks
54
+ * must match a whole trimmed line of comment-stripped content.
55
+ */
56
+ function hasVisibleHeadingLine(content, heading) {
57
+ return visibleMarkdown(content)
58
+ .split(/\r?\n/)
59
+ .some((line) => line.trim() === heading);
60
+ }
49
61
  /**
50
62
  * Ensure an orchestrator file exists: prefer the workspace's template, fall
51
63
  * back to a minimal header for scaffolds that predate the template.
@@ -90,7 +102,7 @@ async function appendDecisionLog(workspaceDir, phaseId, closeoutFile, decisions)
90
102
  if (Number.isFinite(parsed) && parsed >= nextNumber)
91
103
  nextNumber = parsed + 1;
92
104
  }
93
- if (!content.includes(DECISIONS_COMPLETION_LOG_HEADING)) {
105
+ if (!hasVisibleHeadingLine(content, DECISIONS_COMPLETION_LOG_HEADING)) {
94
106
  content += `${content.endsWith("\n") ? "" : "\n"}\n${DECISIONS_COMPLETION_LOG_HEADING}\n\nAppended by completion from each phase handoff's \`decisions\` array. The orchestrator may re-file entries into the category sections above; numbering is shared with them.\n`;
95
107
  }
96
108
  const source = closeoutFile.replace(/\.md$/, "");
@@ -114,13 +126,20 @@ export async function countPendingProposals(workspaceDir, content) {
114
126
  return 0;
115
127
  text = await readFile(filePath, "utf8");
116
128
  }
117
- const start = text.indexOf(CONVENTIONS_PENDING_HEADING);
118
- if (start === -1)
129
+ // Same line-anchored rule as the heading-insertion checks: a prose mention
130
+ // of the heading must not open the section early and miscount.
131
+ const lines = visibleMarkdown(text).split(/\r?\n/);
132
+ const headingIndex = lines.findIndex((line) => line.trim() === CONVENTIONS_PENDING_HEADING);
133
+ if (headingIndex === -1)
119
134
  return 0;
120
- const rest = text.slice(start + CONVENTIONS_PENDING_HEADING.length);
121
- const end = rest.indexOf("\n## ");
122
- const section = end === -1 ? rest : rest.slice(0, end);
123
- return section.split(/\r?\n/).filter((line) => line.startsWith("- **")).length;
135
+ let count = 0;
136
+ for (const line of lines.slice(headingIndex + 1)) {
137
+ if (line.startsWith("## "))
138
+ break;
139
+ if (line.startsWith("- **"))
140
+ count += 1;
141
+ }
142
+ return count;
124
143
  }
125
144
  /**
126
145
  * Stage handoff `proposed_conventions` in CONVENTIONS.md under
@@ -135,7 +154,7 @@ async function stageProposedConventions(workspaceDir, phaseId, timestamp, propos
135
154
  return { staged: 0, totalPending: await countPendingProposals(workspaceDir) };
136
155
  }
137
156
  let content = await ensureOrchestratorFile(workspaceDir, "CONVENTIONS.md", "conventions-template.md", "# Conventions\n\nCross-cutting patterns promoted to project-wide invariants. This scaffold predates templates/conventions-template.md; refresh the framework-owned files for the full format.\n");
138
- if (!content.includes(CONVENTIONS_PENDING_HEADING)) {
157
+ if (!hasVisibleHeadingLine(content, CONVENTIONS_PENDING_HEADING)) {
139
158
  content += `${content.endsWith("\n") ? "" : "\n"}\n${CONVENTIONS_PENDING_HEADING}\n\nStaged by completion from each phase handoff's \`proposed_conventions\`. The orchestrator promotes an entry into a numbered convention above (or removes it with a note) at the phase boundary — see GUIDE.md §Roles.\n`;
140
159
  }
141
160
  // Same visible-content rule as the decision log: template comments must not
@@ -256,6 +275,22 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
256
275
  const phase = resolvePhase(lockedState, validation.phaseId);
257
276
  if (!phase?.primary_output)
258
277
  throw new Error(`Phase ${validation.phaseId} is missing primary_output.`);
278
+ // Re-validate the output under the lock (#132). The caller's
279
+ // validation snapshot can predate a concurrent edit or another
280
+ // session's completion; a stale PASS must not complete a phase whose
281
+ // output no longer validates. The locked recheck is the authoritative
282
+ // one and is what every artifact below is written from.
283
+ // A validation that never touched a file on disk (no outputPath)
284
+ // has nothing to race against, so the caller's result stands — the
285
+ // real surfaces (MCP, Pi) always validate real files.
286
+ const authoritative = validation.outputPath
287
+ ? await validatePhaseOutput(lockedState, validation.phaseId)
288
+ : validation;
289
+ if (authoritative.overall === "FAIL" || authoritative.overall === "MISSING") {
290
+ throw new Error(`Refusing to complete ${validation.phaseId}: the output no longer validates under the status lock ` +
291
+ `(now ${authoritative.overall}). It changed since the last validation — re-run validation and fix the output first.`);
292
+ }
293
+ const lockedValidation = authoritative;
259
294
  const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
260
295
  const existingPhase = nextStatus.phases[validation.phaseId] ?? {
261
296
  status: "pending",
@@ -264,7 +299,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
264
299
  open_questions: [],
265
300
  carry_forward: [],
266
301
  };
267
- const gapEntries = validation.rows
302
+ const gapEntries = lockedValidation.rows
268
303
  .filter((row) => row.result.toUpperCase().includes("PARTIAL"))
269
304
  .map((row) => ({
270
305
  kind: "needs-maintainer-decision",
@@ -284,7 +319,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
284
319
  ...existingPhase.owner_notes,
285
320
  `Completed via ${sourceLabel}.`,
286
321
  `Primary output: .codecarto/${validation.primaryOutput}`,
287
- `Validation: ${validation.overall}`,
322
+ `Validation: ${lockedValidation.overall}`,
288
323
  ]),
289
324
  outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
290
325
  open_questions: mergedOpenQuestions,
@@ -298,8 +333,8 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
298
333
  nextStatus.current_phase = nextEligible?.id ?? "complete";
299
334
  nextStatus.next_actions = nextEligible
300
335
  ? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
301
- : ["All phases complete. Review findings, open questions, and downstream implementation notes."];
302
- const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, validation, completionTimestamp, handoff);
336
+ : buildTerminalNextActions(nextStatus);
337
+ const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, lockedValidation, completionTimestamp, handoff);
303
338
  closeoutPath = artifacts.closeoutPath;
304
339
  orchestratorCheckpoint = buildOrchestratorCheckpoint(artifacts.decisionsAppended, artifacts.totalPendingProposals, nextStatus);
305
340
  return { state: { ...nextWorkspace, status: nextStatus } };
@@ -13,3 +13,4 @@ export * from "./guide.ts";
13
13
  export * from "./dashboard.ts";
14
14
  export * from "./library.ts";
15
15
  export * from "./synthesis.ts";
16
+ export * from "./broadside.ts";
@@ -16,3 +16,4 @@ export * from "./guide.js";
16
16
  export * from "./dashboard.js";
17
17
  export * from "./library.js";
18
18
  export * from "./synthesis.js";
19
+ export * from "./broadside.js";
@@ -108,6 +108,20 @@ export declare function isValidSlug(slug: string): boolean;
108
108
  * to ask the user about it.
109
109
  */
110
110
  export declare function deriveSlug(sourceRepo: string): string;
111
+ /**
112
+ * Reduce a repo reference to a comparable form so that spellings of the same
113
+ * repository do not read as different projects. Handles scheme, `git@host:path`
114
+ * SCP syntax, a `www.` host prefix, a trailing `.git`, repeated and trailing
115
+ * slashes, backslash separators, and case.
116
+ *
117
+ * This is deliberately conservative: it only collapses spellings that are
118
+ * unambiguously the same target. Anything it cannot prove equivalent stays
119
+ * distinct, because the caller treats "different" as a hard error. Case is the
120
+ * one place that cuts the other way — see the note above the return.
121
+ */
122
+ export declare function normalizeSourceRepo(sourceRepo: string): string;
123
+ /** True when two repo references denote the same repository. */
124
+ export declare function sameSourceRepo(a: string, b: string): boolean;
111
125
  export interface PublishInput {
112
126
  slug: string;
113
127
  namespace?: string;
@@ -131,6 +145,14 @@ export interface PublishOptions {
131
145
  forceNewVersion?: boolean;
132
146
  /** Skip the regen of index.yaml + INDEX.md (caller will batch). */
133
147
  skipReindex?: boolean;
148
+ /**
149
+ * Permit publishing when the target entry's recorded `source_repo` differs
150
+ * from the incoming one. Off by default: a mismatch usually means two
151
+ * different projects derived the same slug, and continuing would append
152
+ * one project's spec to the other's version history. Set this only when
153
+ * the repository genuinely moved (rename, org transfer, host change).
154
+ */
155
+ allowSourceRepoChange?: boolean;
134
156
  }
135
157
  export interface PublishResult {
136
158
  slug: string;
@@ -136,6 +136,83 @@ export function deriveSlug(sourceRepo) {
136
136
  const safe = slug.length === 0 || !/^[a-z]/.test(slug) ? `entry-${slug}`.slice(0, 64) : slug;
137
137
  return RESERVED_SLUGS.has(safe) ? `${safe}-entry` : safe;
138
138
  }
139
+ /**
140
+ * Reduce a repo reference to a comparable form so that spellings of the same
141
+ * repository do not read as different projects. Handles scheme, `git@host:path`
142
+ * SCP syntax, a `www.` host prefix, a trailing `.git`, repeated and trailing
143
+ * slashes, backslash separators, and case.
144
+ *
145
+ * This is deliberately conservative: it only collapses spellings that are
146
+ * unambiguously the same target. Anything it cannot prove equivalent stays
147
+ * distinct, because the caller treats "different" as a hard error. Case is the
148
+ * one place that cuts the other way — see the note above the return.
149
+ */
150
+ export function normalizeSourceRepo(sourceRepo) {
151
+ let s = sourceRepo.trim().replace(/\\/g, "/");
152
+ // Order matters here. The scheme comes off first so that the SCP branch
153
+ // below sees only genuine `host:path` syntax, and the userinfo strip runs
154
+ // before either interpretation of a colon. Getting this order wrong makes
155
+ // `ssh://git@host/acme/tool` and `https://host/acme/tool` read as two
156
+ // different repositories, which would refuse a legitimate re-publish.
157
+ const hadScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(s);
158
+ s = s.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "");
159
+ // git@, user:token@, oauth2:x-oauth-basic@ ...
160
+ s = s.replace(/^[^/@]+@/, "");
161
+ // SCP syntax (git@github.com:acme/tool) only ever appears without a scheme,
162
+ // where the colon separates host from path rather than naming a port. The
163
+ // dot requirement keeps a Windows drive letter (C:/repos/tool) out of this
164
+ // branch.
165
+ if (!hadScheme)
166
+ s = s.replace(/^([^:/]+\.[^:/]+):(.+)$/, "$1/$2");
167
+ // A default port for the transports in play is not a distinguishing part of
168
+ // the address. Any other port is left alone, since two services on one host
169
+ // may genuinely differ by port.
170
+ s = s.replace(/^([^/]+):(?:22|80|443)(?=\/|$)/, "$1");
171
+ s = s.replace(/^www\./i, "");
172
+ s = s.replace(/\.git$/i, "");
173
+ // Repeated separators name the same location. A leading `//` is the one
174
+ // exception: on Windows that is a UNC share (\\server\share), which is not
175
+ // the same place as /server/share.
176
+ s = s.startsWith("//") ? `/${s.replace(/\/{2,}/g, "/")}` : s.replace(/\/{2,}/g, "/");
177
+ s = s.replace(/\/+$/, "");
178
+ // Case folding is only safe where the target is case-insensitive. Hosts are,
179
+ // as are the repository paths the major forges serve over them, and so are
180
+ // Windows drive paths. A POSIX absolute path is not: /srv/Repos/tool and
181
+ // /srv/repos/tool are two directories on Linux, and folding them together
182
+ // would hide exactly the cross-project collision this comparison exists to
183
+ // catch. Pi records the analyzed directory as source_repo, so local paths
184
+ // are a common case here rather than a curiosity.
185
+ return isCaseSensitivePath(s) ? s : s.toLowerCase();
186
+ }
187
+ /** An absolute POSIX path (or a `~` home reference), where case is significant. */
188
+ function isCaseSensitivePath(s) {
189
+ return s.startsWith("/") || s === "~" || s.startsWith("~/");
190
+ }
191
+ /** True when two repo references denote the same repository. */
192
+ export function sameSourceRepo(a, b) {
193
+ return normalizeSourceRepo(a) === normalizeSourceRepo(b);
194
+ }
195
+ /**
196
+ * The `source_repo` recorded on an entry's newest version, or null when it
197
+ * cannot be determined (no metadata, unreadable, or malformed). Null means
198
+ * "unknown", and callers treat unknown as permission to proceed rather than
199
+ * as a mismatch.
200
+ */
201
+ async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
202
+ const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
203
+ if (!(await pathExists(metaPath)))
204
+ return null;
205
+ try {
206
+ const raw = parseSimpleYaml(await readFile(metaPath, "utf8"));
207
+ if (!isPlainObject(raw))
208
+ return null;
209
+ const recorded = raw.source_repo;
210
+ return typeof recorded === "string" && recorded.trim() !== "" ? recorded : null;
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
139
216
  // ─── Path helpers ───────────────────────────────────────────────────────────
140
217
  function entryRoot(libraryRoot, namespace, slug) {
141
218
  return namespace ? join(libraryRoot, ENTRIES_DIR, namespace, slug) : join(libraryRoot, ENTRIES_DIR, slug);
@@ -198,6 +275,26 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
198
275
  const existingVersions = await listVersionDirs(entryDir);
199
276
  const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
200
277
  const newSpecHash = sha256(spec);
278
+ // Collision guard. Slugs derive from the trailing path segment of the source
279
+ // repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
280
+ // onto one slug. Without this check the second publish would append its spec
281
+ // to the first project's version history, and the index would then report the
282
+ // newcomer's source_repo as though it owned every prior version. Checked
283
+ // before the idempotence branch below, because a metadata-only update would
284
+ // overwrite the wrong entry just as silently.
285
+ if (latestVersion > 0 && !opts.allowSourceRepoChange) {
286
+ const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
287
+ if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
288
+ const label = namespace ? `${namespace}/${input.slug}` : input.slug;
289
+ throw new Error(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
290
+ `"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
291
+ `append this spec to a different project's version history. Publish this project ` +
292
+ `under a distinct slug to shelve it separately, or — if the repository itself ` +
293
+ `moved (rename, org transfer, host change) — re-publish with the source-repo ` +
294
+ `change allowed: allow_source_repo_change on codecarto_publish, ` +
295
+ `allowSourceRepoChange in PublishOptions.`);
296
+ }
297
+ }
201
298
  // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes,
202
299
  // update metadata in place and return without bumping the version.
203
300
  if (latestVersion > 0 && !opts.forceNewVersion) {
@@ -635,7 +732,10 @@ function formatIndexRow(e, namespaced) {
635
732
  return `| ${slugLink} | v${e.latest_version} | ${headline} | ${tags} |`;
636
733
  }
637
734
  function escapeMd(value) {
638
- return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
735
+ // Backslashes first: escaping only the pipe lets an input ending in `\`
736
+ // turn the emitted `\|` into a literal-backslash-plus-cell-delimiter and
737
+ // break out of the table cell (code scanning alert #3).
738
+ return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
639
739
  }
640
740
  // ─── Atomic YAML write ──────────────────────────────────────────────────────
641
741
  async function atomicWriteYaml(path, value) {
@@ -14,7 +14,7 @@
14
14
  // `library.path` is returned tilde-expanded and absolute so consumers
15
15
  // don't have to expand themselves.
16
16
  import { homedir } from "node:os";
17
- import { join, resolve } from "node:path";
17
+ import { dirname, join, resolve } from "node:path";
18
18
  import { mkdir, readFile, writeFile } from "node:fs/promises";
19
19
  import { expandTilde, pathExists } from "./utils.js";
20
20
  import { loadYamlFile, parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
@@ -131,7 +131,10 @@ export async function writeLibraryConfig(configPath, libraryPath, namespace = nu
131
131
  if (namespace)
132
132
  library.namespace = namespace;
133
133
  const updated = { ...existing, library };
134
- const dir = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/")) : ".";
134
+ // dirname() honors the platform separator; the previous hand-rolled
135
+ // `includes("/")` check treated every Windows path as a bare filename
136
+ // and left mkdir a no-op before the writeFile ENOENT'd (#128).
137
+ const dir = dirname(configPath);
135
138
  await mkdir(dir, { recursive: true });
136
139
  await writeFile(configPath, `${stringifySimpleYaml(updated)}\n`, "utf8");
137
140
  }
@@ -5,6 +5,7 @@ import { pathExists } from "./utils.js";
5
5
  export const PIPELINE_ALIASES = {
6
6
  "full-with-audit": "workflow/pipeline-full-with-audit.yaml",
7
7
  "full-with-deep-audit": "workflow/pipeline-full-with-deep-audit.yaml",
8
+ "scout-first": "workflow/pipeline-scout-first.yaml",
8
9
  full: "workflow/pipeline.yaml",
9
10
  "defect-scan": "workflow/pipeline-defect-scan.yaml",
10
11
  lite: "workflow/pipeline-lite.yaml",
@@ -9,6 +9,14 @@ export declare function autoAssignIds(entries: OpenQuestionEntry[], prefix: stri
9
9
  export declare function ensurePostPipelineArray(value: unknown): PostPipelineEntry[];
10
10
  export declare function ensurePhaseRecord(value: unknown): Record<string, StatusPhase>;
11
11
  export declare function createEmptyStatus(projectName: string, pipelinePath: string, pipeline: PipelineFile): NormalizedStatus;
12
+ /**
13
+ * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
14
+ * moment every phase completes is exactly when skills, amendments, publishing,
15
+ * and the dashboard apply; the prior static sentence left them undiscovered —
16
+ * the 0.15.0 field test finished two full runs with every one of them unused.
17
+ * Amendment recomputes this list so closure counts never go stale.
18
+ */
19
+ export declare function buildTerminalNextActions(status: NormalizedStatus): string[];
12
20
  export declare function normalizeStatus(status: StatusFile, pipeline: PipelineFile, pipelinePath: string, cwd: string): NormalizedStatus;
13
21
  export declare function parseHandoff(value: unknown): PhaseHandoff;
14
22
  /**
@@ -126,6 +126,28 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
126
126
  post_pipeline: [],
127
127
  };
128
128
  }
129
+ /**
130
+ * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
131
+ * moment every phase completes is exactly when skills, amendments, publishing,
132
+ * and the dashboard apply; the prior static sentence left them undiscovered —
133
+ * the 0.15.0 field test finished two full runs with every one of them unused.
134
+ * Amendment recomputes this list so closure counts never go stale.
135
+ */
136
+ export function buildTerminalNextActions(status) {
137
+ const openQuestions = Object.values(status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
138
+ const postPipeline = status.post_pipeline.length;
139
+ const actions = [
140
+ "All phases complete. Review findings; post-pipeline skills: codecarto_list_skills / codecarto_skill.",
141
+ ];
142
+ if (openQuestions > 0 || postPipeline > 0) {
143
+ actions.push(`${openQuestions} open question(s) and ${postPipeline} post-pipeline item(s) remain — apply resolutions with codecarto_amend (write scratch/amendments/<slug>.yaml from templates/amendment.yaml).`);
144
+ }
145
+ if ("reimplementation-spec" in status.phases) {
146
+ actions.push("Publish the finished spec to a library: codecarto_publish (create one with codecarto_library_init; see the library guide topic).");
147
+ }
148
+ actions.push("Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; codecarto_dashboard re-renders on demand). Usage totals: codecarto_usage.");
149
+ return actions;
150
+ }
129
151
  export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
130
152
  if (typeof status.schema_version === "number" && status.schema_version > 1) {
131
153
  throw new Error(`Unsupported status schema_version ${status.schema_version}. Supported: 1.`);
@@ -323,7 +345,15 @@ export async function acquireLock(lockPath) {
323
345
  while (true) {
324
346
  try {
325
347
  const handle = await open(lockPath, "wx");
326
- await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
348
+ try {
349
+ await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
350
+ }
351
+ catch (error) {
352
+ // A non-EEXIST write failure must not leak the descriptor the
353
+ // open just created (#131); close best-effort, then rethrow.
354
+ await handle.close().catch(() => undefined);
355
+ throw error;
356
+ }
327
357
  await handle.close();
328
358
  return {
329
359
  release: async () => {
@@ -34,7 +34,13 @@ export function isWithinPath(path, root) {
34
34
  const normalizedRoot = normalizeForComparison(resolve(root));
35
35
  if (normalizedPath === normalizedRoot)
36
36
  return true;
37
- return normalizedPath.startsWith(`${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`);
37
+ // A filesystem root (e.g. "/" or "C:\") already ends in a separator;
38
+ // appending another one produced a prefix ("//" / "C:\\") that no real
39
+ // path starts with, falsely rejecting every legitimate subpath (#130).
40
+ const prefix = normalizedRoot.endsWith("/") || normalizedRoot.endsWith("\\")
41
+ ? normalizedRoot
42
+ : `${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`;
43
+ return normalizedPath.startsWith(prefix);
38
44
  }
39
45
  /**
40
46
  * Symlink-aware version of isWithinPath. Resolves symlinks on both the path
@@ -11,7 +11,24 @@ export declare const ORCHESTRATOR_FILES: readonly [{
11
11
  }, {
12
12
  readonly file: "DECISIONS.md";
13
13
  readonly template: "decisions-template.md";
14
+ }, {
15
+ readonly file: "BACKLOG.md";
16
+ readonly template: "backlog-project.md";
17
+ }, {
18
+ readonly file: "THREAD_LOG.md";
19
+ readonly template: "thread-log.md";
14
20
  }];
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/`.
25
+ *
26
+ * @param targetWorkspaceDir - Absolute path to the `.codecarto/` to create or merge into.
27
+ * @param sourceWorkspaceDir - The template to copy from. Defaults to the packaged
28
+ * template; tests pass a synthetic directory so they can prove the state filter
29
+ * without mutating the repository's own live workspace mid-suite.
30
+ */
31
+ export declare function copyPackagedWorkspace(targetWorkspaceDir: string, sourceWorkspaceDir?: string): Promise<void>;
15
32
  /**
16
33
  * Seed the orchestrator-maintained files from the workspace's templates
17
34
  * (issue #98): orchestration is on by default, so a fresh workspace starts
@@ -3,7 +3,7 @@
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, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
6
+ import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, writeFile } 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";
@@ -102,7 +102,71 @@ export async function getWorkspaceState(cwd) {
102
102
  export const ORCHESTRATOR_FILES = [
103
103
  { file: "CONVENTIONS.md", template: "conventions-template.md" },
104
104
  { file: "DECISIONS.md", template: "decisions-template.md" },
105
+ { file: "BACKLOG.md", template: "backlog-project.md" },
106
+ { file: "THREAD_LOG.md", template: "thread-log.md" },
105
107
  ];
108
+ /**
109
+ * Project state that must never travel from the packaged template into a new
110
+ * workspace.
111
+ *
112
+ * This repository's `.codecarto/` is two things at once: the template that gets
113
+ * copied into a user's repo, and CodeCartographer's own live workspace. The
114
+ * second role writes real project state into it — a backlog of framework
115
+ * deferrals, a thread log, closeouts of sessions where CodeCartographer
116
+ * analyzed itself. Copying the tree wholesale handed every new workspace ~40 KB
117
+ * of another project's history as its own, and the damage was not only clutter:
118
+ * GUIDE.md keys first-time-project setup on `closeouts/` being empty, so a
119
+ * shipped closeout told every new session it was not the first to touch the
120
+ * project, suppressing the orchestrator role that issues #97/#98 made the
121
+ * default.
122
+ *
123
+ * The four top-level files are seeded fresh from templates instead
124
+ * ({@link ORCHESTRATOR_FILES}); `closeouts/` is created empty.
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"]);
128
+ // broadside/ is machine-local scan state (state.json, timestamped run dirs)
129
+ // except for its two template files — the same carve-out .codecarto/.gitignore
130
+ // makes for this repository itself. Without this, init from a local checkout
131
+ // with live scan state handed every new workspace another project's runs.
132
+ // Literal rather than an import from broadside.ts, which imports this module.
133
+ const BROADSIDE_DIR_NAME = "broadside";
134
+ const INIT_BROADSIDE_TEMPLATE_FILES = new Set(["SKILL.md", "config.yaml"]);
135
+ /**
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/`.
139
+ *
140
+ * @param targetWorkspaceDir - Absolute path to the `.codecarto/` to create or merge into.
141
+ * @param sourceWorkspaceDir - The template to copy from. Defaults to the packaged
142
+ * template; tests pass a synthetic directory so they can prove the state filter
143
+ * without mutating the repository's own live workspace mid-suite.
144
+ */
145
+ export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceDir = packagedWorkspaceDir) {
146
+ await cp(sourceWorkspaceDir, targetWorkspaceDir, {
147
+ recursive: true,
148
+ filter: (source) => {
149
+ const relativePath = relative(sourceWorkspaceDir, source);
150
+ if (!relativePath)
151
+ 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]);
160
+ },
161
+ });
162
+ // The published tarball carries no empty directories, so an excluded-contents
163
+ // directory may not exist to be copied at all. Create them either way: a
164
+ // workspace whose closeouts/ is missing rather than empty reads differently
165
+ // to anything that lists it.
166
+ for (const name of INIT_EXCLUDED_DIR_CONTENTS) {
167
+ await mkdir(join(targetWorkspaceDir, name), { recursive: true });
168
+ }
169
+ }
106
170
  /**
107
171
  * Seed the orchestrator-maintained files from the workspace's templates
108
172
  * (issue #98): orchestration is on by default, so a fresh workspace starts
@@ -131,7 +195,9 @@ export async function seedOrchestratorFiles(workspaceDir) {
131
195
  * Everything else present in the packaged template is framework-owned.
132
196
  */
133
197
  const REFRESH_EXCLUDED_TOP_LEVEL = new Set(["BACKLOG.md", "THREAD_LOG.md", "CONVENTIONS.md", "DECISIONS.md"]);
134
- const REFRESH_EXCLUDED_DIRS = new Set(["scratch", "inputs", "closeouts"]);
198
+ // broadside/ holds machine-local scout state (batch ids, API key config,
199
+ // generated results) — refresh must never overwrite it.
200
+ const REFRESH_EXCLUDED_DIRS = new Set(["scratch", "inputs", "closeouts", "broadside"]);
135
201
  const REFRESH_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", "config.yaml", ".usage.local.yaml"]);
136
202
  async function listTemplateFiles(dir, relativeDir = "") {
137
203
  const entries = await readdir(dir, { withFileTypes: true });
@@ -183,6 +183,12 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
183
183
  }
184
184
  try {
185
185
  await session.prompt(prompt);
186
+ // If no compaction fired during the run, the promise above would
187
+ // otherwise strand the continuation path for the full settle timeout
188
+ // (#129). Settle it with what actually happened — resolve() is
189
+ // idempotent, so a real compaction_end event earlier in the run
190
+ // keeps its `true`.
191
+ resolveCompaction?.(false);
186
192
  let primaryOutputPresent = true;
187
193
  if (options.primaryOutput) {
188
194
  primaryOutputPresent = await primaryOutputExists(cwd, options.primaryOutput);
@@ -0,0 +1,21 @@
1
+ import { type BroadsideLensId } from "../../core/index.ts";
2
+ export type BroadsideAction = "submit" | "collect" | "status" | "models";
3
+ export interface BroadsideFlags {
4
+ action: BroadsideAction;
5
+ /** Empty means "the repository's default lens set". */
6
+ lenses: BroadsideLensId[];
7
+ incremental: boolean;
8
+ includeSynthesis?: boolean;
9
+ includeTriage?: boolean;
10
+ retryTruncated?: boolean;
11
+ /** Undefined means "use the repository's config default". */
12
+ maxCost?: number;
13
+ waitSeconds?: number;
14
+ benchmarks: boolean;
15
+ unknown: string[];
16
+ /** Set on an invalid combination. The caller surfaces it as an error. */
17
+ error?: string;
18
+ }
19
+ /** Every token the completer offers, in the order it offers them. */
20
+ export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--max-cost=", "--wait=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
21
+ export declare function parseBroadsideFlags(args: string): BroadsideFlags;