codecartographer-pi 0.16.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 (40) 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 +3 -1
  15. package/agent-skill/codecartographer/references/broadside.md +115 -0
  16. package/agent-skill/codecartographer/references/library.md +1 -1
  17. package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
  18. package/dist/core/broadside.d.ts +421 -0
  19. package/dist/core/broadside.js +2349 -0
  20. package/dist/core/completion.js +20 -4
  21. package/dist/core/index.d.ts +1 -0
  22. package/dist/core/index.js +1 -0
  23. package/dist/core/library.d.ts +22 -0
  24. package/dist/core/library.js +101 -1
  25. package/dist/core/orchestrator-config.js +5 -2
  26. package/dist/core/pipeline.js +1 -0
  27. package/dist/core/status.js +9 -1
  28. package/dist/core/utils.js +7 -1
  29. package/dist/core/workspace.d.ts +17 -0
  30. package/dist/core/workspace.js +68 -2
  31. package/dist/extensions/codecarto/agent-runner.js +6 -0
  32. package/dist/extensions/codecarto/broadside-flags.d.ts +21 -0
  33. package/dist/extensions/codecarto/broadside-flags.js +116 -0
  34. package/dist/extensions/codecarto/index.js +232 -4
  35. package/dist/mcp-server/server.d.ts +22 -0
  36. package/dist/mcp-server/server.js +218 -11
  37. package/package.json +10 -1
  38. package/.codecarto/BACKLOG.md +0 -184
  39. package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +0 -118
  40. package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +0 -111
@@ -1,6 +1,6 @@
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";
3
+ import { getNextEligiblePhase, resolvePhase, validatePhaseOutput } from "./pipeline.js";
4
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";
@@ -275,6 +275,22 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
275
275
  const phase = resolvePhase(lockedState, validation.phaseId);
276
276
  if (!phase?.primary_output)
277
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;
278
294
  const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
279
295
  const existingPhase = nextStatus.phases[validation.phaseId] ?? {
280
296
  status: "pending",
@@ -283,7 +299,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
283
299
  open_questions: [],
284
300
  carry_forward: [],
285
301
  };
286
- const gapEntries = validation.rows
302
+ const gapEntries = lockedValidation.rows
287
303
  .filter((row) => row.result.toUpperCase().includes("PARTIAL"))
288
304
  .map((row) => ({
289
305
  kind: "needs-maintainer-decision",
@@ -303,7 +319,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
303
319
  ...existingPhase.owner_notes,
304
320
  `Completed via ${sourceLabel}.`,
305
321
  `Primary output: .codecarto/${validation.primaryOutput}`,
306
- `Validation: ${validation.overall}`,
322
+ `Validation: ${lockedValidation.overall}`,
307
323
  ]),
308
324
  outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
309
325
  open_questions: mergedOpenQuestions,
@@ -318,7 +334,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
318
334
  nextStatus.next_actions = nextEligible
319
335
  ? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
320
336
  : buildTerminalNextActions(nextStatus);
321
- const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, validation, completionTimestamp, handoff);
337
+ const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, lockedValidation, completionTimestamp, handoff);
322
338
  closeoutPath = artifacts.closeoutPath;
323
339
  orchestratorCheckpoint = buildOrchestratorCheckpoint(artifacts.decisionsAppended, artifacts.totalPendingProposals, nextStatus);
324
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",
@@ -345,7 +345,15 @@ export async function acquireLock(lockPath) {
345
345
  while (true) {
346
346
  try {
347
347
  const handle = await open(lockPath, "wx");
348
- 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
+ }
349
357
  await handle.close();
350
358
  return {
351
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;
@@ -0,0 +1,116 @@
1
+ // Argument parser for /codecarto-broadside. The grammar is one optional
2
+ // action followed by lens names and flags, in any order:
3
+ //
4
+ // /codecarto-broadside → submit, default lenses
5
+ // /codecarto-broadside submit architecture security → submit, two lenses
6
+ // /codecarto-broadside collect --wait=900
7
+ // /codecarto-broadside status
8
+ // /codecarto-broadside models --benchmarks
9
+ //
10
+ // Flags mirror the codecarto_broadside tool parameters, with the negative
11
+ // forms spelled out because a slash command has no place to pass `false`:
12
+ // --incremental --no-synthesis
13
+ // --max-cost=N --no-triage
14
+ // --wait=SECONDS --no-retry-truncated
15
+ // --benchmarks (models only)
16
+ //
17
+ // The parser never throws. index.ts decides how to surface unknown tokens and
18
+ // invalid combinations, matching parseNextFlags.
19
+ import { BROADSIDE_LENS_IDS } from "../../core/index.js";
20
+ const ACTIONS = new Set(["submit", "collect", "status", "models"]);
21
+ /** Every token the completer offers, in the order it offers them. */
22
+ export const KNOWN_BROADSIDE_TOKENS = [
23
+ "submit",
24
+ "collect",
25
+ "status",
26
+ "models",
27
+ ...BROADSIDE_LENS_IDS,
28
+ "--incremental",
29
+ "--max-cost=",
30
+ "--wait=",
31
+ "--no-synthesis",
32
+ "--no-triage",
33
+ "--no-retry-truncated",
34
+ "--benchmarks",
35
+ ];
36
+ // A numeric flag with a missing or unparseable value is an error, not a
37
+ // silent fallback to the config default: "--max-cost=" almost certainly means
38
+ // the user meant to cap the spend and mistyped it.
39
+ function parseNumeric(token, name, result) {
40
+ const raw = token.slice(name.length + 1);
41
+ const value = Number(raw);
42
+ if (!raw || !Number.isFinite(value) || value < 0) {
43
+ result.error = `${name} needs a non-negative number (got "${raw}").`;
44
+ return undefined;
45
+ }
46
+ return value;
47
+ }
48
+ export function parseBroadsideFlags(args) {
49
+ const tokens = args.trim().split(/\s+/).filter((token) => token.length > 0);
50
+ const result = {
51
+ action: "submit",
52
+ lenses: [],
53
+ incremental: false,
54
+ benchmarks: false,
55
+ unknown: [],
56
+ };
57
+ let actionSeen = false;
58
+ for (const token of tokens) {
59
+ if (!actionSeen && ACTIONS.has(token)) {
60
+ result.action = token;
61
+ actionSeen = true;
62
+ continue;
63
+ }
64
+ if (BROADSIDE_LENS_IDS.includes(token)) {
65
+ // A lens named twice is one lens, not two batches of it.
66
+ if (!result.lenses.includes(token))
67
+ result.lenses.push(token);
68
+ continue;
69
+ }
70
+ if (token === "--incremental") {
71
+ result.incremental = true;
72
+ continue;
73
+ }
74
+ if (token === "--no-synthesis") {
75
+ result.includeSynthesis = false;
76
+ continue;
77
+ }
78
+ if (token === "--no-triage") {
79
+ result.includeTriage = false;
80
+ continue;
81
+ }
82
+ if (token === "--no-retry-truncated") {
83
+ result.retryTruncated = false;
84
+ continue;
85
+ }
86
+ if (token === "--benchmarks") {
87
+ result.benchmarks = true;
88
+ continue;
89
+ }
90
+ if (token.startsWith("--max-cost=")) {
91
+ result.maxCost = parseNumeric(token, "--max-cost", result);
92
+ continue;
93
+ }
94
+ if (token.startsWith("--wait=")) {
95
+ result.waitSeconds = parseNumeric(token, "--wait", result);
96
+ continue;
97
+ }
98
+ result.unknown.push(token);
99
+ }
100
+ // Flags that only mean something for one action are refused rather than
101
+ // ignored: silently dropping --incremental on a collect would read as
102
+ // "collected incrementally", which is not a thing.
103
+ if (result.lenses.length > 0 && result.action !== "submit") {
104
+ result.error ??= `Lens names are only meaningful for submit (got action "${result.action}").`;
105
+ }
106
+ if (result.incremental && result.action !== "submit") {
107
+ result.error ??= `--incremental is only meaningful for submit (got action "${result.action}").`;
108
+ }
109
+ if (result.benchmarks && result.action !== "models") {
110
+ result.error ??= `--benchmarks is only meaningful for models (got action "${result.action}").`;
111
+ }
112
+ if (result.action === "status" && result.waitSeconds !== undefined) {
113
+ result.error ??= "--wait is only meaningful for submit and collect; status reads recorded state.";
114
+ }
115
+ return result;
116
+ }