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.
- package/.codecarto/GUIDE.md +15 -2
- package/.codecarto/README.md +3 -0
- package/.codecarto/broadside/SKILL.md +143 -0
- package/.codecarto/broadside/config.yaml +104 -0
- package/.codecarto/findings/broadside-scout/README.md +20 -0
- package/.codecarto/findings/broadside-scout/SKILL.md +101 -0
- package/.codecarto/skills/spec-delta-application/SKILL.md +3 -1
- package/.codecarto/templates/backlog-project.md +51 -0
- package/.codecarto/templates/broadside-scout-brief.md +97 -0
- package/.codecarto/{THREAD_LOG.md → templates/thread-log.md} +2 -5
- package/.codecarto/workflow/pipeline-scout-first.yaml +271 -0
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +47 -2
- package/agent-skill/codecartographer/SKILL.md +3 -1
- package/agent-skill/codecartographer/references/broadside.md +115 -0
- package/agent-skill/codecartographer/references/library.md +1 -1
- package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
- package/dist/core/broadside.d.ts +421 -0
- package/dist/core/broadside.js +2349 -0
- package/dist/core/completion.js +20 -4
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/library.d.ts +22 -0
- package/dist/core/library.js +101 -1
- package/dist/core/orchestrator-config.js +5 -2
- package/dist/core/pipeline.js +1 -0
- package/dist/core/status.js +9 -1
- package/dist/core/utils.js +7 -1
- package/dist/core/workspace.d.ts +17 -0
- package/dist/core/workspace.js +68 -2
- package/dist/extensions/codecarto/agent-runner.js +6 -0
- package/dist/extensions/codecarto/broadside-flags.d.ts +21 -0
- package/dist/extensions/codecarto/broadside-flags.js +116 -0
- package/dist/extensions/codecarto/index.js +232 -4
- package/dist/mcp-server/server.d.ts +22 -0
- package/dist/mcp-server/server.js +218 -11
- package/package.json +10 -1
- package/.codecarto/BACKLOG.md +0 -184
- package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +0 -118
- package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +0 -111
package/dist/core/completion.js
CHANGED
|
@@ -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 =
|
|
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: ${
|
|
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,
|
|
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 } };
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
package/dist/core/library.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/library.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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",
|
package/dist/core/status.js
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/dist/core/utils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -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
|
package/dist/core/workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|