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.
Files changed (77) hide show
  1. package/.codecarto/GUIDE.md +16 -3
  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/architecture/SKILL.md +1 -0
  6. package/.codecarto/findings/broadside-scout/README.md +20 -0
  7. package/.codecarto/findings/broadside-scout/SKILL.md +101 -0
  8. package/.codecarto/findings/contracts/SKILL.md +1 -0
  9. package/.codecarto/findings/defect-scan/SKILL.md +15 -1
  10. package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
  11. package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
  12. package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
  13. package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
  14. package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
  15. package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
  16. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
  17. package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
  18. package/.codecarto/findings/porting/SKILL.md +2 -1
  19. package/.codecarto/findings/protocols/SKILL.md +1 -0
  20. package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
  21. package/.codecarto/skills/spec-delta-application/SKILL.md +3 -1
  22. package/.codecarto/templates/architecture-map.md +1 -1
  23. package/.codecarto/templates/backlog-project.md +51 -0
  24. package/.codecarto/templates/broadside-scout-brief.md +97 -0
  25. package/.codecarto/templates/defect-report.md +23 -0
  26. package/.codecarto/templates/mechanical-defects.md +22 -0
  27. package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
  28. package/.codecarto/templates/semantic-defects.md +26 -0
  29. package/.codecarto/{THREAD_LOG.md → templates/thread-log.md} +2 -5
  30. package/.codecarto/workflow/VALIDATE.md +1 -1
  31. package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
  32. package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
  33. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
  34. package/.codecarto/workflow/pipeline-scout-first.yaml +275 -0
  35. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  36. package/README.md +51 -6
  37. package/agent-skill/codecartographer/SKILL.md +3 -1
  38. package/agent-skill/codecartographer/references/broadside.md +115 -0
  39. package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
  40. package/agent-skill/codecartographer/references/library.md +2 -2
  41. package/agent-skill/codecartographer/references/orchestration.md +1 -1
  42. package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
  43. package/dist/core/amendment.js +2 -2
  44. package/dist/core/broadside.d.ts +421 -0
  45. package/dist/core/broadside.js +2349 -0
  46. package/dist/core/completion.d.ts +5 -0
  47. package/dist/core/completion.js +38 -6
  48. package/dist/core/dashboard.js +5 -3
  49. package/dist/core/findings.d.ts +59 -0
  50. package/dist/core/findings.js +145 -0
  51. package/dist/core/index.d.ts +2 -0
  52. package/dist/core/index.js +2 -0
  53. package/dist/core/library.d.ts +88 -1
  54. package/dist/core/library.js +260 -7
  55. package/dist/core/orchestrator-config.js +5 -2
  56. package/dist/core/pipeline.js +16 -0
  57. package/dist/core/prompts.js +1 -1
  58. package/dist/core/status.js +23 -7
  59. package/dist/core/types.d.ts +6 -0
  60. package/dist/core/utils.d.ts +14 -0
  61. package/dist/core/utils.js +37 -1
  62. package/dist/core/workspace.d.ts +17 -0
  63. package/dist/core/workspace.js +79 -20
  64. package/dist/core/yaml.js +19 -4
  65. package/dist/extensions/codecarto/agent-runner.js +6 -0
  66. package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
  67. package/dist/extensions/codecarto/broadside-flags.d.ts +26 -0
  68. package/dist/extensions/codecarto/broadside-flags.js +129 -0
  69. package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
  70. package/dist/extensions/codecarto/index.js +270 -18
  71. package/dist/extensions/codecarto/phase-compaction.js +4 -0
  72. package/dist/mcp-server/server.d.ts +22 -0
  73. package/dist/mcp-server/server.js +282 -17
  74. package/package.json +11 -2
  75. package/.codecarto/BACKLOG.md +0 -184
  76. package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +0 -118
  77. package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +0 -111
@@ -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 ?? "internal",
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
- const metadata = buildMetadata(input, latestVersion);
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
- return index;
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
- lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${index.namespaces.length || 1} ${index.namespaces.length === 1 ? "namespace" : "namespaces"}.`);
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
- const pathPart = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}/latest/` : `${ENTRIES_DIR}/${e.slug}/latest/`;
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
- return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
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
- 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
  }
@@ -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.`);
@@ -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)
@@ -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
- phase.open_questions.push(entry);
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
- phase.carry_forward.push(entry);
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
- await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
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 () => {
@@ -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
@@ -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;
@@ -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
@@ -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
+ }
@@ -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