codecartographer-pi 0.16.0 → 0.17.1
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 +16 -3
- package/.codecarto/README.md +3 -0
- package/.codecarto/broadside/SKILL.md +143 -0
- package/.codecarto/broadside/config.yaml +104 -0
- package/.codecarto/findings/architecture/SKILL.md +1 -0
- package/.codecarto/findings/broadside-scout/README.md +20 -0
- package/.codecarto/findings/broadside-scout/SKILL.md +101 -0
- package/.codecarto/findings/contracts/SKILL.md +1 -0
- package/.codecarto/findings/defect-scan/SKILL.md +15 -1
- package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
- package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
- package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
- package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
- package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
- package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
- package/.codecarto/findings/porting/SKILL.md +2 -1
- package/.codecarto/findings/protocols/SKILL.md +1 -0
- package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
- package/.codecarto/skills/spec-delta-application/SKILL.md +3 -1
- package/.codecarto/templates/architecture-map.md +1 -1
- package/.codecarto/templates/backlog-project.md +51 -0
- package/.codecarto/templates/broadside-scout-brief.md +97 -0
- package/.codecarto/templates/defect-report.md +23 -0
- package/.codecarto/templates/mechanical-defects.md +22 -0
- package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
- package/.codecarto/templates/semantic-defects.md +26 -0
- package/.codecarto/{THREAD_LOG.md → templates/thread-log.md} +2 -5
- package/.codecarto/workflow/VALIDATE.md +1 -1
- package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
- package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
- package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
- package/.codecarto/workflow/pipeline-scout-first.yaml +275 -0
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +51 -6
- package/agent-skill/codecartographer/SKILL.md +3 -1
- package/agent-skill/codecartographer/references/broadside.md +115 -0
- package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
- package/agent-skill/codecartographer/references/library.md +2 -2
- package/agent-skill/codecartographer/references/orchestration.md +1 -1
- package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
- package/dist/core/amendment.js +2 -2
- package/dist/core/broadside.d.ts +421 -0
- package/dist/core/broadside.js +2349 -0
- package/dist/core/completion.d.ts +5 -0
- package/dist/core/completion.js +38 -6
- package/dist/core/dashboard.js +5 -3
- package/dist/core/findings.d.ts +59 -0
- package/dist/core/findings.js +145 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.d.ts +88 -1
- package/dist/core/library.js +260 -7
- package/dist/core/orchestrator-config.js +5 -2
- package/dist/core/pipeline.js +16 -0
- package/dist/core/prompts.js +1 -1
- package/dist/core/status.js +23 -7
- package/dist/core/types.d.ts +6 -0
- package/dist/core/utils.d.ts +14 -0
- package/dist/core/utils.js +37 -1
- package/dist/core/workspace.d.ts +17 -0
- package/dist/core/workspace.js +79 -20
- package/dist/core/yaml.js +19 -4
- package/dist/extensions/codecarto/agent-runner.js +6 -0
- package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
- package/dist/extensions/codecarto/broadside-flags.d.ts +26 -0
- package/dist/extensions/codecarto/broadside-flags.js +129 -0
- package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
- package/dist/extensions/codecarto/index.js +270 -18
- package/dist/extensions/codecarto/phase-compaction.js +4 -0
- package/dist/mcp-server/server.d.ts +22 -0
- package/dist/mcp-server/server.js +282 -17
- package/package.json +11 -2
- 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/library.js
CHANGED
|
@@ -15,11 +15,16 @@
|
|
|
15
15
|
// - publishEntry is content-hash idempotent: re-publishing the same
|
|
16
16
|
// spec bytes does not create a new version. Metadata-only changes
|
|
17
17
|
// (headline, tags, capabilities) update the existing latest
|
|
18
|
-
// metadata.yaml in place
|
|
18
|
+
// metadata.yaml in place; the version's recorded provenance is
|
|
19
|
+
// carried forward unless the publish supplies its own.
|
|
19
20
|
// - reindex regenerates index.yaml and INDEX.md from filesystem state.
|
|
20
21
|
// Treat both as derived artifacts; never hand-edit. Resolution
|
|
21
22
|
// recipe for git merge conflicts is documented in
|
|
22
23
|
// docs/library-format.md.
|
|
24
|
+
// - reindex also reports, on its return value and never in the index
|
|
25
|
+
// files, entries whose versions disagree about source_repo — the shape
|
|
26
|
+
// a slug collision left behind before publish refused cross-project
|
|
27
|
+
// appends. Repair is manual; see the "Provenance conflicts" section.
|
|
23
28
|
// - Git operations (`commitPublish`) shell out to the `git` binary.
|
|
24
29
|
// Failures are non-fatal — the caller decides how to surface them.
|
|
25
30
|
import { createHash } from "node:crypto";
|
|
@@ -86,6 +91,16 @@ function normalizeMarker(raw) {
|
|
|
86
91
|
function isVisibility(v) {
|
|
87
92
|
return v === "internal" || v === "shared" || v === "public";
|
|
88
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The level a marker's `visibility` or an entry's `confidentiality` is taken
|
|
96
|
+
* to have when it declares none. It is the default `initLibrary` writes and
|
|
97
|
+
* the default docs/library-format.md gives the entry field.
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_VISIBILITY = "internal";
|
|
100
|
+
// Ordered from most to least restricted. An entry may sit in a library at or
|
|
101
|
+
// below its own level; one above it would expose the entry to everyone the
|
|
102
|
+
// library reaches.
|
|
103
|
+
const VISIBILITY_RANK = { internal: 0, shared: 1, public: 2 };
|
|
89
104
|
/**
|
|
90
105
|
* Initialize a CodeCartographer library at the given path: create the
|
|
91
106
|
* directory if needed, write the `.codecarto-library` marker if missing,
|
|
@@ -102,7 +117,7 @@ export async function initLibrary(libraryPath, options = {}) {
|
|
|
102
117
|
schema_version: MARKER_SCHEMA_VERSION,
|
|
103
118
|
name,
|
|
104
119
|
namespaced: options.namespaced ?? false,
|
|
105
|
-
visibility: options.visibility ??
|
|
120
|
+
visibility: options.visibility ?? DEFAULT_VISIBILITY,
|
|
106
121
|
created_at: new Date().toISOString(),
|
|
107
122
|
};
|
|
108
123
|
await writeMarker(libraryPath, marker);
|
|
@@ -136,6 +151,104 @@ export function deriveSlug(sourceRepo) {
|
|
|
136
151
|
const safe = slug.length === 0 || !/^[a-z]/.test(slug) ? `entry-${slug}`.slice(0, 64) : slug;
|
|
137
152
|
return RESERVED_SLUGS.has(safe) ? `${safe}-entry` : safe;
|
|
138
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Reduce a repo reference to a comparable form so that spellings of the same
|
|
156
|
+
* repository do not read as different projects. Handles scheme, `git@host:path`
|
|
157
|
+
* SCP syntax, a `www.` host prefix, a trailing `.git`, repeated and trailing
|
|
158
|
+
* slashes, backslash separators, and case.
|
|
159
|
+
*
|
|
160
|
+
* This is deliberately conservative: it only collapses spellings that are
|
|
161
|
+
* unambiguously the same target. Anything it cannot prove equivalent stays
|
|
162
|
+
* distinct, because the caller treats "different" as a hard error. Case is the
|
|
163
|
+
* one place that cuts the other way — see the note above the return.
|
|
164
|
+
*/
|
|
165
|
+
export function normalizeSourceRepo(sourceRepo) {
|
|
166
|
+
let s = sourceRepo.trim().replace(/\\/g, "/");
|
|
167
|
+
// Order matters here. The scheme comes off first so that the SCP branch
|
|
168
|
+
// below sees only genuine `host:path` syntax, and the userinfo strip runs
|
|
169
|
+
// before either interpretation of a colon. Getting this order wrong makes
|
|
170
|
+
// `ssh://git@host/acme/tool` and `https://host/acme/tool` read as two
|
|
171
|
+
// different repositories, which would refuse a legitimate re-publish.
|
|
172
|
+
const hadScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(s);
|
|
173
|
+
s = s.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "");
|
|
174
|
+
// git@, user:token@, oauth2:x-oauth-basic@ ...
|
|
175
|
+
s = s.replace(/^[^/@]+@/, "");
|
|
176
|
+
// SCP syntax (git@github.com:acme/tool) only ever appears without a scheme,
|
|
177
|
+
// where the colon separates host from path rather than naming a port. The
|
|
178
|
+
// dot requirement keeps a Windows drive letter (C:/repos/tool) out of this
|
|
179
|
+
// branch.
|
|
180
|
+
if (!hadScheme)
|
|
181
|
+
s = s.replace(/^([^:/]+\.[^:/]+):(.+)$/, "$1/$2");
|
|
182
|
+
// A default port for the transports in play is not a distinguishing part of
|
|
183
|
+
// the address. Any other port is left alone, since two services on one host
|
|
184
|
+
// may genuinely differ by port.
|
|
185
|
+
s = s.replace(/^([^/]+):(?:22|80|443)(?=\/|$)/, "$1");
|
|
186
|
+
s = s.replace(/^www\./i, "");
|
|
187
|
+
s = s.replace(/\.git$/i, "");
|
|
188
|
+
// Repeated separators name the same location. A leading `//` is the one
|
|
189
|
+
// exception: on Windows that is a UNC share (\\server\share), which is not
|
|
190
|
+
// the same place as /server/share.
|
|
191
|
+
s = s.startsWith("//") ? `/${s.replace(/\/{2,}/g, "/")}` : s.replace(/\/{2,}/g, "/");
|
|
192
|
+
s = s.replace(/\/+$/, "");
|
|
193
|
+
// Case folding is only safe where the target is case-insensitive. Hosts are,
|
|
194
|
+
// as are the repository paths the major forges serve over them, and so are
|
|
195
|
+
// Windows drive paths. A POSIX absolute path is not: /srv/Repos/tool and
|
|
196
|
+
// /srv/repos/tool are two directories on Linux, and folding them together
|
|
197
|
+
// would hide exactly the cross-project collision this comparison exists to
|
|
198
|
+
// catch. Pi records the analyzed directory as source_repo, so local paths
|
|
199
|
+
// are a common case here rather than a curiosity.
|
|
200
|
+
return isCaseSensitivePath(s) ? s : s.toLowerCase();
|
|
201
|
+
}
|
|
202
|
+
/** An absolute POSIX path (or a `~` home reference), where case is significant. */
|
|
203
|
+
function isCaseSensitivePath(s) {
|
|
204
|
+
return s.startsWith("/") || s === "~" || s.startsWith("~/");
|
|
205
|
+
}
|
|
206
|
+
/** True when two repo references denote the same repository. */
|
|
207
|
+
export function sameSourceRepo(a, b) {
|
|
208
|
+
return normalizeSourceRepo(a) === normalizeSourceRepo(b);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The `source_repo` recorded on one version of an entry, or null when it
|
|
212
|
+
* cannot be determined (no metadata, unreadable, or malformed). Null means
|
|
213
|
+
* "unknown", and callers treat unknown as permission to proceed rather than
|
|
214
|
+
* as a mismatch — the publish guard lets the publish through, and conflict
|
|
215
|
+
* detection skips the version.
|
|
216
|
+
*/
|
|
217
|
+
async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
|
|
218
|
+
const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
|
|
219
|
+
if (!(await pathExists(metaPath)))
|
|
220
|
+
return null;
|
|
221
|
+
try {
|
|
222
|
+
const raw = parseSimpleYaml(await readFile(metaPath, "utf8"));
|
|
223
|
+
if (!isPlainObject(raw))
|
|
224
|
+
return null;
|
|
225
|
+
const recorded = raw.source_repo;
|
|
226
|
+
return typeof recorded === "string" && recorded.trim() !== "" ? recorded : null;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The `provenance` block recorded on one version of an entry, or undefined
|
|
234
|
+
* when there is none to carry forward (no metadata, unreadable, malformed,
|
|
235
|
+
* or a version that never had the block — a hand-built entry, say). The
|
|
236
|
+
* metadata-only publish branch uses this so an identical re-publish, which
|
|
237
|
+
* neither surface sends `provenance` with, rewrites `metadata.yaml` without
|
|
238
|
+
* dropping what the version's original publish recorded.
|
|
239
|
+
*/
|
|
240
|
+
async function readRecordedProvenance(libraryRoot, namespace, slug, version) {
|
|
241
|
+
const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
|
|
242
|
+
if (!(await pathExists(metaPath)))
|
|
243
|
+
return undefined;
|
|
244
|
+
try {
|
|
245
|
+
const raw = parseSimpleYaml(await readFile(metaPath, "utf8"));
|
|
246
|
+
return normalizeMetadata(raw, { slug, namespace, version }).provenance;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
139
252
|
// ─── Path helpers ───────────────────────────────────────────────────────────
|
|
140
253
|
function entryRoot(libraryRoot, namespace, slug) {
|
|
141
254
|
return namespace ? join(libraryRoot, ENTRIES_DIR, namespace, slug) : join(libraryRoot, ENTRIES_DIR, slug);
|
|
@@ -176,6 +289,22 @@ async function readLatestPointer(entryDir) {
|
|
|
176
289
|
return null;
|
|
177
290
|
}
|
|
178
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Thrown by `publishEntry` when the entry is more restricted than the library
|
|
294
|
+
* it is headed for. Nothing has been written when this is raised. It carries
|
|
295
|
+
* the two compared levels so a wrapper with a user to ask (Pi) can pose the
|
|
296
|
+
* question from the values rather than by matching the message.
|
|
297
|
+
*/
|
|
298
|
+
export class ConfidentialityMismatchError extends Error {
|
|
299
|
+
entryConfidentiality;
|
|
300
|
+
libraryVisibility;
|
|
301
|
+
constructor(message, entryConfidentiality, libraryVisibility) {
|
|
302
|
+
super(message);
|
|
303
|
+
this.name = "ConfidentialityMismatchError";
|
|
304
|
+
this.entryConfidentiality = entryConfidentiality;
|
|
305
|
+
this.libraryVisibility = libraryVisibility;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
179
308
|
export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
180
309
|
const marker = await readMarker(libraryRoot);
|
|
181
310
|
if (!marker) {
|
|
@@ -198,6 +327,49 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
|
198
327
|
const existingVersions = await listVersionDirs(entryDir);
|
|
199
328
|
const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
|
|
200
329
|
const newSpecHash = sha256(spec);
|
|
330
|
+
// Collision guard. Slugs derive from the trailing path segment of the source
|
|
331
|
+
// repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
|
|
332
|
+
// onto one slug. Without this check the second publish would append its spec
|
|
333
|
+
// to the first project's version history, and the index would then report the
|
|
334
|
+
// newcomer's source_repo as though it owned every prior version. Checked
|
|
335
|
+
// before the idempotence branch below, because a metadata-only update would
|
|
336
|
+
// overwrite the wrong entry just as silently.
|
|
337
|
+
if (latestVersion > 0 && !opts.allowSourceRepoChange) {
|
|
338
|
+
const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
|
|
339
|
+
if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
|
|
340
|
+
const label = namespace ? `${namespace}/${input.slug}` : input.slug;
|
|
341
|
+
throw new Error(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
|
|
342
|
+
`"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
|
|
343
|
+
`append this spec to a different project's version history. Publish this project ` +
|
|
344
|
+
`under a distinct slug to shelve it separately, or — if the repository itself ` +
|
|
345
|
+
`moved (rename, org transfer, host change) — re-publish with the source-repo ` +
|
|
346
|
+
`change allowed: allow_source_repo_change on codecarto_publish, ` +
|
|
347
|
+
`allowSourceRepoChange in PublishOptions.`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Confidentiality guard. Levels are ordered internal < shared < public. An
|
|
351
|
+
// entry may sit in a library at or below its own level, but one more
|
|
352
|
+
// restricted than its library would be exposed to everyone the library
|
|
353
|
+
// reaches: an internal spec in a public library is a leak. Either side that
|
|
354
|
+
// declares nothing counts as internal — the marker default initLibrary
|
|
355
|
+
// writes, and the entry default docs/library-format.md documents — so a
|
|
356
|
+
// library with no visibility field accepts everything it did before. Like
|
|
357
|
+
// the collision guard this runs ahead of the idempotence branch, so a
|
|
358
|
+
// metadata-only update cannot reclassify an entry past it, and it fails
|
|
359
|
+
// before anything is written.
|
|
360
|
+
const entryConfidentiality = input.confidentiality ?? DEFAULT_VISIBILITY;
|
|
361
|
+
const libraryVisibility = marker.visibility ?? DEFAULT_VISIBILITY;
|
|
362
|
+
if (!opts.allowConfidentialityMismatch && VISIBILITY_RANK[entryConfidentiality] < VISIBILITY_RANK[libraryVisibility]) {
|
|
363
|
+
const label = namespace ? `${namespace}/${input.slug}` : input.slug;
|
|
364
|
+
const declared = input.confidentiality ? "" : " (the default when none is declared)";
|
|
365
|
+
throw new ConfidentialityMismatchError(`Refusing to publish: entry "${label}" has confidentiality "${entryConfidentiality}"${declared}, ` +
|
|
366
|
+
`but library "${marker.name}" has visibility "${libraryVisibility}". Publishing would expose a ` +
|
|
367
|
+
`spec classified "${entryConfidentiality}" to everyone the "${libraryVisibility}" library reaches. ` +
|
|
368
|
+
`Publish it to a library whose visibility is "${entryConfidentiality}" or narrower, declare a ` +
|
|
369
|
+
`confidentiality of "${libraryVisibility}" or wider if the spec may travel that far, or — if ` +
|
|
370
|
+
`this exposure is intended — re-publish with the mismatch allowed: ` +
|
|
371
|
+
`allow_confidentiality_mismatch on codecarto_publish, allowConfidentialityMismatch in PublishOptions.`, entryConfidentiality, libraryVisibility);
|
|
372
|
+
}
|
|
201
373
|
// Content-hash idempotence: if the latest version's spec matches bytes-for-bytes,
|
|
202
374
|
// update metadata in place and return without bumping the version.
|
|
203
375
|
if (latestVersion > 0 && !opts.forceNewVersion) {
|
|
@@ -206,7 +378,11 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
|
206
378
|
if (await pathExists(latestSpecPath)) {
|
|
207
379
|
const existingSpec = await readFile(latestSpecPath, "utf8");
|
|
208
380
|
if (sha256(existingSpec) === newSpecHash) {
|
|
209
|
-
|
|
381
|
+
// buildMetadata writes provenance only when the input carries it, and
|
|
382
|
+
// neither surface sends it on publish — so without this the rewrite
|
|
383
|
+
// would drop the block the version's original publish recorded.
|
|
384
|
+
const provenance = input.provenance ?? (await readRecordedProvenance(libraryRoot, namespace, input.slug, latestVersion));
|
|
385
|
+
const metadata = buildMetadata({ ...input, provenance }, latestVersion);
|
|
210
386
|
await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
|
|
211
387
|
if (!opts.skipReindex)
|
|
212
388
|
await reindex(libraryRoot);
|
|
@@ -508,7 +684,10 @@ export async function reindex(libraryRoot) {
|
|
|
508
684
|
};
|
|
509
685
|
await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
|
|
510
686
|
await writeIndexMarkdown(libraryRoot, index, marker);
|
|
511
|
-
|
|
687
|
+
// Reported, not written. The index files above are ABI, so the conflict
|
|
688
|
+
// list travels on the return value only (see ReindexResult).
|
|
689
|
+
const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, entries);
|
|
690
|
+
return { ...index, provenance_conflicts };
|
|
512
691
|
}
|
|
513
692
|
async function buildIndexEntry(libraryRoot, namespace, slug) {
|
|
514
693
|
const entryDir = entryRoot(libraryRoot, namespace, slug);
|
|
@@ -590,7 +769,10 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
|
|
|
590
769
|
lines.push("");
|
|
591
770
|
lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with \`codecarto library-reindex\`._`);
|
|
592
771
|
lines.push("");
|
|
593
|
-
|
|
772
|
+
// A single-tenant library has no namespaces but is still one namespace's
|
|
773
|
+
// worth of entries; count once so the noun agrees with the number shown.
|
|
774
|
+
const namespaceCount = index.namespaces.length || 1;
|
|
775
|
+
lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${namespaceCount} ${namespaceCount === 1 ? "namespace" : "namespaces"}.`);
|
|
594
776
|
lines.push("");
|
|
595
777
|
if (marker.namespaced) {
|
|
596
778
|
const grouped = new Map();
|
|
@@ -628,14 +810,85 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
|
|
|
628
810
|
await rename(tempPath, path);
|
|
629
811
|
}
|
|
630
812
|
function formatIndexRow(e, namespaced) {
|
|
631
|
-
|
|
813
|
+
// Link to the newest version directory, not `latest/`: the pointer is a
|
|
814
|
+
// one-line regular file (see the module header), so a `latest/` link has
|
|
815
|
+
// nothing to land on when the library is browsed on a forge.
|
|
816
|
+
const entryPath = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}` : `${ENTRIES_DIR}/${e.slug}`;
|
|
817
|
+
const pathPart = `${entryPath}/v${e.latest_version}/`;
|
|
632
818
|
const slugLink = `[${escapeMd(e.slug)}](${pathPart})`;
|
|
633
819
|
const headline = escapeMd(e.headline).replace(/\n+/g, " ");
|
|
634
820
|
const tags = e.tags.length === 0 ? "" : e.tags.map(escapeMd).join(", ");
|
|
635
821
|
return `| ${slugLink} | v${e.latest_version} | ${headline} | ${tags} |`;
|
|
636
822
|
}
|
|
637
823
|
function escapeMd(value) {
|
|
638
|
-
|
|
824
|
+
// Backslashes first: escaping only the pipe lets an input ending in `\`
|
|
825
|
+
// turn the emitted `\|` into a literal-backslash-plus-cell-delimiter and
|
|
826
|
+
// break out of the table cell (code scanning alert #3).
|
|
827
|
+
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
828
|
+
}
|
|
829
|
+
// ─── Provenance conflicts ───────────────────────────────────────────────────
|
|
830
|
+
//
|
|
831
|
+
// Before publish refused cross-project appends (#123), two projects whose
|
|
832
|
+
// source_repo shared a trailing path segment derived the same slug, and the
|
|
833
|
+
// second publish landed as the next version of the first project's entry.
|
|
834
|
+
// Nothing rewrites those entries after the fact: the index reads only the
|
|
835
|
+
// newest version's metadata, so it advertises every version under whichever
|
|
836
|
+
// project published last, and a synthesis run reading the entry gets one
|
|
837
|
+
// project's spec history presented as another's (#148). Detection reads every
|
|
838
|
+
// version and reports the disagreement. Repair is deliberately manual —
|
|
839
|
+
// splitting an entry means inventing a slug, renumbering versions and
|
|
840
|
+
// repointing `latest`, all of which are paths docs/library-format.md calls
|
|
841
|
+
// ABI — so nothing here renames, renumbers, or moves anything.
|
|
842
|
+
/**
|
|
843
|
+
* Read-only check over the given entries: does every version of each entry
|
|
844
|
+
* record the same repository as its newest version? Comparison goes through
|
|
845
|
+
* `sameSourceRepo`, so spellings of one repository (scheme, `.git`, SCP
|
|
846
|
+
* syntax, casing where safe) do not count as disagreement. A version whose
|
|
847
|
+
* metadata is missing, unreadable, or lacks `source_repo` is skipped rather
|
|
848
|
+
* than reported — the stance the publish guard takes — and an entry whose
|
|
849
|
+
* newest version is unreadable is skipped entirely, since there is nothing to
|
|
850
|
+
* compare against. Never writes.
|
|
851
|
+
*
|
|
852
|
+
* A repository that genuinely moved and was re-published with
|
|
853
|
+
* `allowSourceRepoChange` leaves the same on-disk shape as a collision and is
|
|
854
|
+
* reported the same way; the history alone cannot tell the two apart.
|
|
855
|
+
*/
|
|
856
|
+
export async function detectProvenanceConflicts(libraryRoot, entries) {
|
|
857
|
+
const conflicts = [];
|
|
858
|
+
for (const entry of entries) {
|
|
859
|
+
const conflict = await findProvenanceConflict(libraryRoot, entry.namespace, entry.slug);
|
|
860
|
+
if (conflict)
|
|
861
|
+
conflicts.push(conflict);
|
|
862
|
+
}
|
|
863
|
+
return conflicts;
|
|
864
|
+
}
|
|
865
|
+
async function findProvenanceConflict(libraryRoot, namespace, slug) {
|
|
866
|
+
const versions = await listVersionDirs(entryRoot(libraryRoot, namespace, slug));
|
|
867
|
+
if (versions.length < 2)
|
|
868
|
+
return null;
|
|
869
|
+
const latest = versions[versions.length - 1];
|
|
870
|
+
const latestRepo = await readRecordedSourceRepo(libraryRoot, namespace, slug, latest);
|
|
871
|
+
if (latestRepo === null)
|
|
872
|
+
return null;
|
|
873
|
+
const disagreeing = [];
|
|
874
|
+
for (const version of versions.slice(0, -1)) {
|
|
875
|
+
const recorded = await readRecordedSourceRepo(libraryRoot, namespace, slug, version);
|
|
876
|
+
if (recorded === null)
|
|
877
|
+
continue;
|
|
878
|
+
if (!sameSourceRepo(recorded, latestRepo))
|
|
879
|
+
disagreeing.push({ version, source_repo: recorded });
|
|
880
|
+
}
|
|
881
|
+
if (disagreeing.length === 0)
|
|
882
|
+
return null;
|
|
883
|
+
const conflict = {
|
|
884
|
+
slug,
|
|
885
|
+
latest_version: latest,
|
|
886
|
+
source_repo: latestRepo,
|
|
887
|
+
disagreeing_versions: disagreeing,
|
|
888
|
+
};
|
|
889
|
+
if (namespace)
|
|
890
|
+
conflict.namespace = namespace;
|
|
891
|
+
return conflict;
|
|
639
892
|
}
|
|
640
893
|
// ─── Atomic YAML write ──────────────────────────────────────────────────────
|
|
641
894
|
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
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { pathExists } from "./utils.js";
|
|
5
|
+
import { crossCheckFindings, findingsPairingGateActive } from "./findings.js";
|
|
5
6
|
export const PIPELINE_ALIASES = {
|
|
6
7
|
"full-with-audit": "workflow/pipeline-full-with-audit.yaml",
|
|
7
8
|
"full-with-deep-audit": "workflow/pipeline-full-with-deep-audit.yaml",
|
|
9
|
+
"scout-first": "workflow/pipeline-scout-first.yaml",
|
|
8
10
|
full: "workflow/pipeline.yaml",
|
|
9
11
|
"defect-scan": "workflow/pipeline-defect-scan.yaml",
|
|
10
12
|
lite: "workflow/pipeline-lite.yaml",
|
|
@@ -154,6 +156,16 @@ export async function validatePhaseOutput(state, phaseId) {
|
|
|
154
156
|
errors.push("One or more validation criteria are marked FAIL.");
|
|
155
157
|
overall = "FAIL";
|
|
156
158
|
}
|
|
159
|
+
// Findings cross-checks (#122): the validation table says whether criteria
|
|
160
|
+
// were met; these read what the findings' own evidence and action cells
|
|
161
|
+
// say. Deterministic on two cells the model wrote, so the pairing rule can
|
|
162
|
+
// gate — on a scaffold that offers `verify at runtime`. Older scaffolds warn.
|
|
163
|
+
const crossCheck = crossCheckFindings(content, { gate: findingsPairingGateActive(state.scaffoldVersion) });
|
|
164
|
+
if (crossCheck.errors.length > 0) {
|
|
165
|
+
errors.push(...crossCheck.errors);
|
|
166
|
+
overall = "FAIL";
|
|
167
|
+
}
|
|
168
|
+
const warnings = crossCheck.warnings;
|
|
157
169
|
if (overall === "FAIL" && errors.length === 0) {
|
|
158
170
|
errors.push("Validation overall result is FAIL.");
|
|
159
171
|
}
|
|
@@ -168,6 +180,7 @@ export async function validatePhaseOutput(state, phaseId) {
|
|
|
168
180
|
gaps,
|
|
169
181
|
errors,
|
|
170
182
|
secondaryOutputs,
|
|
183
|
+
...(warnings.length > 0 && { warnings }),
|
|
171
184
|
};
|
|
172
185
|
}
|
|
173
186
|
export function buildValidationSummary(validation) {
|
|
@@ -183,6 +196,9 @@ export function buildValidationSummary(validation) {
|
|
|
183
196
|
if (validation.errors.length > 0) {
|
|
184
197
|
lines.push(...validation.errors.slice(0, 3));
|
|
185
198
|
}
|
|
199
|
+
for (const warning of validation.warnings ?? []) {
|
|
200
|
+
lines.push(`NOTE: ${warning} Non-gating.`);
|
|
201
|
+
}
|
|
186
202
|
const missingSecondary = (validation.secondaryOutputs ?? []).filter((output) => !output.exists);
|
|
187
203
|
if (missingSecondary.length > 0) {
|
|
188
204
|
lines.push(`NOTE: ${missingSecondary.length} declared secondary output(s) not written: ${missingSecondary.map((output) => `.codecarto/${output.path}`).join(", ")} — write each, or account for it in Coverage and limits / a routed handoff entry. Non-gating.`);
|
package/dist/core/prompts.js
CHANGED
|
@@ -33,7 +33,7 @@ async function buildOrchestratorDuties(state, phase, auto) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
if (retriage.length > 0) {
|
|
36
|
-
lines.push("- Re-triage these open questions' kind labels — a label is a claim needing its own evidence; re-test whether each is now answerable by reading before accepting it:");
|
|
36
|
+
lines.push("- Re-triage these open questions' kind labels — a label is a claim needing its own evidence; re-test whether each is now answerable by reading before accepting it. If one still needs a runtime test, no finding in this phase may assert one of its candidate answers with a settled action (fix before porting / fix now): the finding inherits the question's uncertainty as `verify at runtime` until runtime evidence closes the question:");
|
|
37
37
|
for (const label of retriage.slice(0, RETRIAGE_LIST_LIMIT))
|
|
38
38
|
lines.push(` - ${label}`);
|
|
39
39
|
if (retriage.length > RETRIAGE_LIST_LIMIT)
|
package/dist/core/status.js
CHANGED
|
@@ -274,36 +274,44 @@ export function applyHandoff(status, handoff) {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
|
-
// Now merge into the current phase: overwrite by id or append new
|
|
277
|
+
// Now merge into the current phase: overwrite by id or append new. An entry
|
|
278
|
+
// with neither id nor description has no key to merge on; it is kept as-is
|
|
279
|
+
// rather than lost when the array is rebuilt from the map (#134).
|
|
278
280
|
const localOqMap = new Map();
|
|
281
|
+
const unkeyedOpenQuestions = [];
|
|
279
282
|
for (const entry of phase.open_questions) {
|
|
280
283
|
const key = entry.id || entry.description || "";
|
|
281
284
|
if (key)
|
|
282
285
|
localOqMap.set(key, entry);
|
|
286
|
+
else
|
|
287
|
+
unkeyedOpenQuestions.push(entry);
|
|
283
288
|
}
|
|
284
289
|
for (const entry of handoff.open_questions) {
|
|
285
290
|
const key = entry.id || entry.description || "";
|
|
286
291
|
if (key)
|
|
287
292
|
localOqMap.set(key, entry);
|
|
288
293
|
else
|
|
289
|
-
|
|
294
|
+
unkeyedOpenQuestions.push(entry);
|
|
290
295
|
}
|
|
291
|
-
phase.open_questions = [...localOqMap.values()];
|
|
292
|
-
// Merge carry_forward: overwrite by id or append new
|
|
296
|
+
phase.open_questions = [...localOqMap.values(), ...unkeyedOpenQuestions];
|
|
297
|
+
// Merge carry_forward: overwrite by id or append new, same unkeyed rule
|
|
293
298
|
const cfMap = new Map();
|
|
299
|
+
const unkeyedCarryForward = [];
|
|
294
300
|
for (const entry of phase.carry_forward) {
|
|
295
301
|
const key = entry.id || entry.description || "";
|
|
296
302
|
if (key)
|
|
297
303
|
cfMap.set(key, entry);
|
|
304
|
+
else
|
|
305
|
+
unkeyedCarryForward.push(entry);
|
|
298
306
|
}
|
|
299
307
|
for (const entry of handoff.carry_forward) {
|
|
300
308
|
const key = entry.id || entry.description || "";
|
|
301
309
|
if (key)
|
|
302
310
|
cfMap.set(key, entry);
|
|
303
311
|
else
|
|
304
|
-
|
|
312
|
+
unkeyedCarryForward.push(entry);
|
|
305
313
|
}
|
|
306
|
-
phase.carry_forward = [...cfMap.values()];
|
|
314
|
+
phase.carry_forward = [...cfMap.values(), ...unkeyedCarryForward];
|
|
307
315
|
// Apply closures: remove carry_forward entries from ALL phases by id
|
|
308
316
|
for (const closureId of handoff.carry_forward_closures) {
|
|
309
317
|
if (!closureId)
|
|
@@ -345,7 +353,15 @@ export async function acquireLock(lockPath) {
|
|
|
345
353
|
while (true) {
|
|
346
354
|
try {
|
|
347
355
|
const handle = await open(lockPath, "wx");
|
|
348
|
-
|
|
356
|
+
try {
|
|
357
|
+
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
// A non-EEXIST write failure must not leak the descriptor the
|
|
361
|
+
// open just created (#131); close best-effort, then rethrow.
|
|
362
|
+
await handle.close().catch(() => undefined);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
349
365
|
await handle.close();
|
|
350
366
|
return {
|
|
351
367
|
release: async () => {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -94,6 +94,12 @@ export type ValidationResult = {
|
|
|
94
94
|
path: string;
|
|
95
95
|
exists: boolean;
|
|
96
96
|
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Non-gating observations from the findings cross-checks (issue #122):
|
|
99
|
+
* contradictions worth the reader's attention that must not stop an
|
|
100
|
+
* --auto run. Rendered as NOTE lines by buildValidationSummary.
|
|
101
|
+
*/
|
|
102
|
+
warnings?: string[];
|
|
97
103
|
};
|
|
98
104
|
/**
|
|
99
105
|
* One convention a phase proposes for promotion. Completion stages these in
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -15,6 +15,14 @@ export declare function isWithinPathResolved(path: string, root: string): Promis
|
|
|
15
15
|
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
16
16
|
export declare function uniqueStrings(items: string[]): string[];
|
|
17
17
|
export declare function dateOnly(timestamp: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* The separator to put before a line appended to file content `current` so
|
|
20
|
+
* the line starts at column 0. A file whose last line lacks a trailing newline
|
|
21
|
+
* (a hand-edited THREAD_LOG.md, an editor that strips final newlines) would
|
|
22
|
+
* otherwise have the appended entry glued onto that line (#134). Empty or
|
|
23
|
+
* absent content needs no separator.
|
|
24
|
+
*/
|
|
25
|
+
export declare function newlineIfUnterminated(current: string): string;
|
|
18
26
|
/**
|
|
19
27
|
* Expand a leading `~` or `~/` to the user's home directory. Node's `path`
|
|
20
28
|
* module deliberately doesn't do this (it's a shell convention, not a path
|
|
@@ -36,3 +44,9 @@ export declare function formatTokenCount(count: number): string;
|
|
|
36
44
|
* dashboard renderer can reuse without crossing the core/extensions boundary.
|
|
37
45
|
*/
|
|
38
46
|
export declare function formatMillis(ms: number): string;
|
|
47
|
+
/**
|
|
48
|
+
* Compare two dotted `major.minor.patch` versions. Returns -1, 0, or 1, or
|
|
49
|
+
* null when either side is not a plain three-part version (pre-release tags,
|
|
50
|
+
* hand-edited markers) so callers can fall back to string equality.
|
|
51
|
+
*/
|
|
52
|
+
export declare function compareDottedVersions(a: string, b: string): number | null;
|
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
|
|
@@ -66,6 +72,16 @@ export function uniqueStrings(items) {
|
|
|
66
72
|
export function dateOnly(timestamp) {
|
|
67
73
|
return timestamp.slice(0, 10);
|
|
68
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The separator to put before a line appended to file content `current` so
|
|
77
|
+
* the line starts at column 0. A file whose last line lacks a trailing newline
|
|
78
|
+
* (a hand-edited THREAD_LOG.md, an editor that strips final newlines) would
|
|
79
|
+
* otherwise have the appended entry glued onto that line (#134). Empty or
|
|
80
|
+
* absent content needs no separator.
|
|
81
|
+
*/
|
|
82
|
+
export function newlineIfUnterminated(current) {
|
|
83
|
+
return current === "" || current.endsWith("\n") ? "" : "\n";
|
|
84
|
+
}
|
|
69
85
|
/**
|
|
70
86
|
* Expand a leading `~` or `~/` to the user's home directory. Node's `path`
|
|
71
87
|
* module deliberately doesn't do this (it's a shell convention, not a path
|
|
@@ -108,3 +124,23 @@ export function formatMillis(ms) {
|
|
|
108
124
|
const seconds = Math.floor((ms % 60_000) / 1000);
|
|
109
125
|
return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
|
|
110
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Compare two dotted `major.minor.patch` versions. Returns -1, 0, or 1, or
|
|
129
|
+
* null when either side is not a plain three-part version (pre-release tags,
|
|
130
|
+
* hand-edited markers) so callers can fall back to string equality.
|
|
131
|
+
*/
|
|
132
|
+
export function compareDottedVersions(a, b) {
|
|
133
|
+
const parse = (version) => {
|
|
134
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
135
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
136
|
+
};
|
|
137
|
+
const left = parse(a);
|
|
138
|
+
const right = parse(b);
|
|
139
|
+
if (!left || !right)
|
|
140
|
+
return null;
|
|
141
|
+
for (let i = 0; i < 3; i++) {
|
|
142
|
+
if (left[i] !== right[i])
|
|
143
|
+
return left[i] < right[i] ? -1 : 1;
|
|
144
|
+
}
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
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
|