codecartographer-pi 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.codecarto/GUIDE.md +3 -3
  2. package/.codecarto/findings/architecture/SKILL.md +1 -0
  3. package/.codecarto/findings/contracts/SKILL.md +1 -0
  4. package/.codecarto/findings/defect-scan/SKILL.md +15 -1
  5. package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
  6. package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
  7. package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
  8. package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
  9. package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
  10. package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
  11. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
  12. package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
  13. package/.codecarto/findings/porting/SKILL.md +2 -1
  14. package/.codecarto/findings/protocols/SKILL.md +1 -0
  15. package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
  16. package/.codecarto/skills/spec-delta-application/SKILL.md +1 -1
  17. package/.codecarto/templates/amendment.yaml +3 -3
  18. package/.codecarto/templates/architecture-map.md +1 -1
  19. package/.codecarto/templates/defect-report.md +23 -0
  20. package/.codecarto/templates/mechanical-defects.md +22 -0
  21. package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
  22. package/.codecarto/templates/semantic-defects.md +26 -0
  23. package/.codecarto/templates/spike-report.md +2 -2
  24. package/.codecarto/workflow/VALIDATE.md +1 -1
  25. package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
  26. package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
  27. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
  28. package/.codecarto/workflow/pipeline-scout-first.yaml +5 -1
  29. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  30. package/README.md +14 -10
  31. package/agent-skill/codecartographer/SKILL.md +1 -1
  32. package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
  33. package/agent-skill/codecartographer/references/library.md +3 -3
  34. package/agent-skill/codecartographer/references/orchestration.md +1 -1
  35. package/agent-skill/codecartographer/references/phase-recovery.md +1 -1
  36. package/dist/core/amendment.d.ts +5 -0
  37. package/dist/core/amendment.js +23 -4
  38. package/dist/core/completion.d.ts +5 -0
  39. package/dist/core/completion.js +18 -2
  40. package/dist/core/dashboard.js +5 -3
  41. package/dist/core/findings.d.ts +59 -0
  42. package/dist/core/findings.js +145 -0
  43. package/dist/core/index.d.ts +1 -0
  44. package/dist/core/index.js +1 -0
  45. package/dist/core/library.d.ts +139 -1
  46. package/dist/core/library.js +291 -40
  47. package/dist/core/orchestrator-config.d.ts +6 -0
  48. package/dist/core/orchestrator-config.js +2 -0
  49. package/dist/core/pipeline.js +15 -0
  50. package/dist/core/prompts.js +1 -1
  51. package/dist/core/status.d.ts +2 -1
  52. package/dist/core/status.js +33 -11
  53. package/dist/core/types.d.ts +6 -0
  54. package/dist/core/utils.d.ts +14 -0
  55. package/dist/core/utils.js +30 -0
  56. package/dist/core/workspace.d.ts +16 -0
  57. package/dist/core/workspace.js +42 -22
  58. package/dist/core/yaml.js +19 -4
  59. package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
  60. package/dist/extensions/codecarto/broadside-flags.d.ts +7 -2
  61. package/dist/extensions/codecarto/broadside-flags.js +22 -9
  62. package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
  63. package/dist/extensions/codecarto/index.js +379 -20
  64. package/dist/extensions/codecarto/phase-compaction.js +4 -0
  65. package/dist/mcp-server/server.js +127 -10
  66. package/package.json +2 -2
@@ -75,9 +75,39 @@ export interface LibraryIndex {
75
75
  namespaces: string[];
76
76
  entries: LibraryIndexEntry[];
77
77
  }
78
+ /** One older version whose recorded source_repo names a different repository than the newest version's. */
79
+ export interface ProvenanceConflictVersion {
80
+ version: number;
81
+ source_repo: string;
82
+ }
83
+ /** An entry whose version history spans more than one repository. */
84
+ export interface ProvenanceConflict {
85
+ slug: string;
86
+ namespace?: string;
87
+ /** The newest version — the one whose metadata the index reports for the whole entry. */
88
+ latest_version: number;
89
+ /** The source_repo recorded on the newest version, i.e. what index.yaml advertises. */
90
+ source_repo: string;
91
+ /** Older versions that disagree with it, ascending. Versions with missing or unreadable metadata are skipped. */
92
+ disagreeing_versions: ProvenanceConflictVersion[];
93
+ }
94
+ /**
95
+ * What `reindex` returns: the index it wrote, plus findings that ride on the
96
+ * return value only. `provenance_conflicts` is never serialized into
97
+ * index.yaml or INDEX.md — both shapes are ABI (docs/library-format.md).
98
+ */
99
+ export interface ReindexResult extends LibraryIndex {
100
+ provenance_conflicts: ProvenanceConflict[];
101
+ }
78
102
  export declare function discoverLibrary(libraryPath: string): Promise<LibraryMarker | null>;
79
103
  export declare function readMarker(libraryRoot: string): Promise<LibraryMarker | null>;
80
104
  export declare function writeMarker(libraryRoot: string, marker: LibraryMarker): Promise<void>;
105
+ /**
106
+ * The level a marker's `visibility` or an entry's `confidentiality` is taken
107
+ * to have when it declares none. It is the default `initLibrary` writes and
108
+ * the default docs/library-format.md gives the entry field.
109
+ */
110
+ export declare const DEFAULT_VISIBILITY: LibraryVisibility;
81
111
  export interface InitLibraryOptions {
82
112
  /** Library name (defaults to basename of the path). */
83
113
  name?: string;
@@ -106,6 +136,11 @@ export declare function isValidSlug(slug: string): boolean;
106
136
  * Caller is responsible for collision handling — derived slugs may already
107
137
  * exist in the library and the calling UX (Pi or MCP) is the right place
108
138
  * to ask the user about it.
139
+ *
140
+ * A clone's remote URL and its checkout directory derive the same slug
141
+ * (`…/whisper.git`, `git@host:acme/whisper`, and `/path/whisper` all give
142
+ * `whisper`), which is what lets the Pi command switch from recording the
143
+ * directory to recording the remote without renaming anyone's entry.
109
144
  */
110
145
  export declare function deriveSlug(sourceRepo: string): string;
111
146
  /**
@@ -153,6 +188,15 @@ export interface PublishOptions {
153
188
  * the repository genuinely moved (rename, org transfer, host change).
154
189
  */
155
190
  allowSourceRepoChange?: boolean;
191
+ /**
192
+ * Permit publishing when the entry's `confidentiality` is more restricted
193
+ * than the library's `visibility` — an `internal` entry into a `shared` or
194
+ * `public` library, a `shared` entry into a `public` one. Off by default:
195
+ * that direction exposes the spec to everyone the library reaches. Set
196
+ * this only when the exposure is intended. It does not change the
197
+ * confidentiality recorded on the entry.
198
+ */
199
+ allowConfidentialityMismatch?: boolean;
156
200
  }
157
201
  export interface PublishResult {
158
202
  slug: string;
@@ -162,6 +206,52 @@ export interface PublishResult {
162
206
  entryDir: string;
163
207
  versionDir: string;
164
208
  }
209
+ /**
210
+ * Thrown by `publishEntry` when the entry is more restricted than the library
211
+ * it is headed for. Nothing has been written when this is raised. It carries
212
+ * the two compared levels so a wrapper with a user to ask (Pi) can pose the
213
+ * question from the values rather than by matching the message.
214
+ */
215
+ export declare class ConfidentialityMismatchError extends Error {
216
+ readonly entryConfidentiality: LibraryVisibility;
217
+ readonly libraryVisibility: LibraryVisibility;
218
+ constructor(message: string, entryConfidentiality: LibraryVisibility, libraryVisibility: LibraryVisibility);
219
+ }
220
+ /**
221
+ * Thrown by `publishEntry` when the target entry's newest version records a
222
+ * `source_repo` that denotes a different repository than the incoming one.
223
+ * Nothing has been written when this is raised. It carries both values so a
224
+ * wrapper with a user to ask (Pi) can pose "did the repository move?" from
225
+ * the values rather than by matching the message.
226
+ */
227
+ export declare class SourceRepoMismatchError extends Error {
228
+ /** The `source_repo` the entry's newest version records. */
229
+ readonly recorded: string;
230
+ /** The `source_repo` this publish carries. */
231
+ readonly incoming: string;
232
+ constructor(message: string, recorded: string, incoming: string);
233
+ }
234
+ /** What `publishEntry` would do to an entry's version history, without doing it. */
235
+ export interface PublishVersionPreview {
236
+ /** Highest version directory present, or 0 for a new entry. */
237
+ latestVersion: number;
238
+ /** The version the publish would write or update. */
239
+ version: number;
240
+ /** True when a new version directory would be created; false for a metadata-only update. */
241
+ isNewVersion: boolean;
242
+ }
243
+ /**
244
+ * Read-only preview of the version a publish would land on. This is the same
245
+ * content-hash decision `publishEntry` makes (it calls this), so a wrapper
246
+ * that has to describe a publish before performing it — the MCP server's
247
+ * `publish_confirm` refusal — shows what would actually happen. Neither the
248
+ * collision nor the confidentiality guard is evaluated here; those still run
249
+ * on the real publish.
250
+ */
251
+ export declare function previewPublishVersion(libraryRoot: string, spec: string, ref: {
252
+ slug: string;
253
+ namespace?: string;
254
+ }, opts?: Pick<PublishOptions, "forceNewVersion">): Promise<PublishVersionPreview>;
165
255
  export declare function publishEntry(libraryRoot: string, spec: string, input: PublishInput, opts?: PublishOptions): Promise<PublishResult>;
166
256
  export interface EntryRef {
167
257
  slug: string;
@@ -182,7 +272,55 @@ export interface ListEntriesFilter {
182
272
  source_repo?: string;
183
273
  }
184
274
  export declare function listEntries(libraryRoot: string, filter?: ListEntriesFilter): Promise<LibraryIndexEntry[]>;
185
- export declare function reindex(libraryRoot: string): Promise<LibraryIndex>;
275
+ export declare function reindex(libraryRoot: string): Promise<ReindexResult>;
276
+ /**
277
+ * Read-only check over the given entries: does every version of each entry
278
+ * record the same repository as its newest version? Comparison goes through
279
+ * `sameSourceRepo`, so spellings of one repository (scheme, `.git`, SCP
280
+ * syntax, casing where safe) do not count as disagreement. A version whose
281
+ * metadata is missing, unreadable, or lacks `source_repo` is skipped rather
282
+ * than reported — the stance the publish guard takes — and an entry whose
283
+ * newest version is unreadable is skipped entirely, since there is nothing to
284
+ * compare against. Never writes.
285
+ *
286
+ * A repository that genuinely moved and was re-published with
287
+ * `allowSourceRepoChange` leaves the same on-disk shape as a collision and is
288
+ * reported the same way; the history alone cannot tell the two apart.
289
+ */
290
+ export declare function detectProvenanceConflicts(libraryRoot: string, entries: ReadonlyArray<Pick<LibraryIndexEntry, "slug" | "namespace">>): Promise<ProvenanceConflict[]>;
291
+ /** What a Pi publish records as `source_repo`, and where the value came from. */
292
+ export interface ResolvedSourceRepo {
293
+ /** The value to record: a remote's fetch URL verbatim, or the directory itself. */
294
+ source_repo: string;
295
+ /**
296
+ * The git remote the URL was read from (`origin`, or the current branch's
297
+ * upstream remote), or null when the directory was recorded instead — it
298
+ * is not a git work tree, is a subdirectory of one, or has no usable
299
+ * remote.
300
+ */
301
+ remote: string | null;
302
+ }
303
+ /**
304
+ * Resolve the repository reference a publish from `cwd` should record. The
305
+ * remote is preferred over the path because a path means nothing outside the
306
+ * machine that published it, and because a slug derived from the remote is
307
+ * stable across clones of one repository, which is what lets the collision
308
+ * guard compare something meaningful (#147).
309
+ *
310
+ * Resolution order: `origin`'s fetch URL, else the fetch URL of the remote
311
+ * the current branch tracks, else `cwd`. The remote is consulted only when
312
+ * `cwd` is the root of its work tree. A subdirectory keeps recording its
313
+ * path: every subdirectory of one repository would otherwise resolve to the
314
+ * same URL and the same slug, and the second one published would land as a
315
+ * new version of the first with no guard able to tell — exactly the
316
+ * cross-project append the guard exists to refuse.
317
+ *
318
+ * The URL is stored as git reports it. Spellings of one repository are
319
+ * reconciled at comparison time by `sameSourceRepo`, not here, so the
320
+ * recorded value stays human-readable. Never throws: a missing `git` binary
321
+ * or any git failure falls back to the path.
322
+ */
323
+ export declare function resolvePublishSourceRepo(cwd: string): Promise<ResolvedSourceRepo>;
186
324
  export interface CommitOptions {
187
325
  addAll?: boolean;
188
326
  }
@@ -15,18 +15,24 @@
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.
23
- // - Git operations (`commitPublish`) shell out to the `git` binary.
24
- // Failures are non-fatal — the caller decides how to surface them.
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.
28
+ // - Git operations (`commitPublish`, `resolvePublishSourceRepo`) shell out
29
+ // to the `git` binary. Failures are non-fatal — the caller decides how to
30
+ // surface them, and the resolver falls back to the directory itself.
25
31
  import { createHash } from "node:crypto";
26
32
  import { spawn } from "node:child_process";
27
33
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
28
34
  import { basename, join, resolve } from "node:path";
29
- import { isPlainObject, pathExists } from "./utils.js";
35
+ import { canonicalPath, isPlainObject, normalizeForComparison, pathExists } from "./utils.js";
30
36
  import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
31
37
  // ─── Constants ──────────────────────────────────────────────────────────────
32
38
  export const LIBRARY_MARKER_FILE = ".codecarto-library";
@@ -86,6 +92,16 @@ function normalizeMarker(raw) {
86
92
  function isVisibility(v) {
87
93
  return v === "internal" || v === "shared" || v === "public";
88
94
  }
95
+ /**
96
+ * The level a marker's `visibility` or an entry's `confidentiality` is taken
97
+ * to have when it declares none. It is the default `initLibrary` writes and
98
+ * the default docs/library-format.md gives the entry field.
99
+ */
100
+ export const DEFAULT_VISIBILITY = "internal";
101
+ // Ordered from most to least restricted. An entry may sit in a library at or
102
+ // below its own level; one above it would expose the entry to everyone the
103
+ // library reaches.
104
+ const VISIBILITY_RANK = { internal: 0, shared: 1, public: 2 };
89
105
  /**
90
106
  * Initialize a CodeCartographer library at the given path: create the
91
107
  * directory if needed, write the `.codecarto-library` marker if missing,
@@ -102,7 +118,7 @@ export async function initLibrary(libraryPath, options = {}) {
102
118
  schema_version: MARKER_SCHEMA_VERSION,
103
119
  name,
104
120
  namespaced: options.namespaced ?? false,
105
- visibility: options.visibility ?? "internal",
121
+ visibility: options.visibility ?? DEFAULT_VISIBILITY,
106
122
  created_at: new Date().toISOString(),
107
123
  };
108
124
  await writeMarker(libraryPath, marker);
@@ -122,9 +138,19 @@ export function isValidSlug(slug) {
122
138
  * Caller is responsible for collision handling — derived slugs may already
123
139
  * exist in the library and the calling UX (Pi or MCP) is the right place
124
140
  * to ask the user about it.
141
+ *
142
+ * A clone's remote URL and its checkout directory derive the same slug
143
+ * (`…/whisper.git`, `git@host:acme/whisper`, and `/path/whisper` all give
144
+ * `whisper`), which is what lets the Pi command switch from recording the
145
+ * directory to recording the remote without renaming anyone's entry.
125
146
  */
126
147
  export function deriveSlug(sourceRepo) {
127
- const cleaned = sourceRepo.replace(/\.git$/i, "").replace(/\\/g, "/");
148
+ let cleaned = sourceRepo.replace(/\.git$/i, "").replace(/\\/g, "/");
149
+ // SCP shorthand for a repository at the root of a host (`git@host:whisper`)
150
+ // has no slash at all, so the colon is the only separator to split on. Any
151
+ // form with a slash already yields the right trailing segment below.
152
+ if (!cleaned.includes("/"))
153
+ cleaned = cleaned.replace(/^[^:]*:/, "");
128
154
  const parts = cleaned.split("/").filter((p) => p.length > 0);
129
155
  const last = parts[parts.length - 1] ?? "entry";
130
156
  const slug = last
@@ -180,8 +206,10 @@ export function normalizeSourceRepo(sourceRepo) {
180
206
  // Windows drive paths. A POSIX absolute path is not: /srv/Repos/tool and
181
207
  // /srv/repos/tool are two directories on Linux, and folding them together
182
208
  // 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.
209
+ // catch. Pi records the analyzed directory as source_repo whenever it has
210
+ // no git remote to record instead, and every entry Pi published before it
211
+ // resolved remotes holds one, so local paths are a common case here rather
212
+ // than a curiosity.
185
213
  return isCaseSensitivePath(s) ? s : s.toLowerCase();
186
214
  }
187
215
  /** An absolute POSIX path (or a `~` home reference), where case is significant. */
@@ -193,10 +221,11 @@ export function sameSourceRepo(a, b) {
193
221
  return normalizeSourceRepo(a) === normalizeSourceRepo(b);
194
222
  }
195
223
  /**
196
- * The `source_repo` recorded on an entry's newest version, or null when it
224
+ * The `source_repo` recorded on one version of an entry, or null when it
197
225
  * cannot be determined (no metadata, unreadable, or malformed). Null means
198
226
  * "unknown", and callers treat unknown as permission to proceed rather than
199
- * as a mismatch.
227
+ * as a mismatch — the publish guard lets the publish through, and conflict
228
+ * detection skips the version.
200
229
  */
201
230
  async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
202
231
  const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
@@ -213,6 +242,26 @@ async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
213
242
  return null;
214
243
  }
215
244
  }
245
+ /**
246
+ * The `provenance` block recorded on one version of an entry, or undefined
247
+ * when there is none to carry forward (no metadata, unreadable, malformed,
248
+ * or a version that never had the block — a hand-built entry, say). The
249
+ * metadata-only publish branch uses this so an identical re-publish, which
250
+ * neither surface sends `provenance` with, rewrites `metadata.yaml` without
251
+ * dropping what the version's original publish recorded.
252
+ */
253
+ async function readRecordedProvenance(libraryRoot, namespace, slug, version) {
254
+ const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
255
+ if (!(await pathExists(metaPath)))
256
+ return undefined;
257
+ try {
258
+ const raw = parseSimpleYaml(await readFile(metaPath, "utf8"));
259
+ return normalizeMetadata(raw, { slug, namespace, version }).provenance;
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ }
216
265
  // ─── Path helpers ───────────────────────────────────────────────────────────
217
266
  function entryRoot(libraryRoot, namespace, slug) {
218
267
  return namespace ? join(libraryRoot, ENTRIES_DIR, namespace, slug) : join(libraryRoot, ENTRIES_DIR, slug);
@@ -253,6 +302,64 @@ async function readLatestPointer(entryDir) {
253
302
  return null;
254
303
  }
255
304
  }
305
+ /**
306
+ * Thrown by `publishEntry` when the entry is more restricted than the library
307
+ * it is headed for. Nothing has been written when this is raised. It carries
308
+ * the two compared levels so a wrapper with a user to ask (Pi) can pose the
309
+ * question from the values rather than by matching the message.
310
+ */
311
+ export class ConfidentialityMismatchError extends Error {
312
+ entryConfidentiality;
313
+ libraryVisibility;
314
+ constructor(message, entryConfidentiality, libraryVisibility) {
315
+ super(message);
316
+ this.name = "ConfidentialityMismatchError";
317
+ this.entryConfidentiality = entryConfidentiality;
318
+ this.libraryVisibility = libraryVisibility;
319
+ }
320
+ }
321
+ /**
322
+ * Thrown by `publishEntry` when the target entry's newest version records a
323
+ * `source_repo` that denotes a different repository than the incoming one.
324
+ * Nothing has been written when this is raised. It carries both values so a
325
+ * wrapper with a user to ask (Pi) can pose "did the repository move?" from
326
+ * the values rather than by matching the message.
327
+ */
328
+ export class SourceRepoMismatchError extends Error {
329
+ /** The `source_repo` the entry's newest version records. */
330
+ recorded;
331
+ /** The `source_repo` this publish carries. */
332
+ incoming;
333
+ constructor(message, recorded, incoming) {
334
+ super(message);
335
+ this.name = "SourceRepoMismatchError";
336
+ this.recorded = recorded;
337
+ this.incoming = incoming;
338
+ }
339
+ }
340
+ /**
341
+ * Read-only preview of the version a publish would land on. This is the same
342
+ * content-hash decision `publishEntry` makes (it calls this), so a wrapper
343
+ * that has to describe a publish before performing it — the MCP server's
344
+ * `publish_confirm` refusal — shows what would actually happen. Neither the
345
+ * collision nor the confidentiality guard is evaluated here; those still run
346
+ * on the real publish.
347
+ */
348
+ export async function previewPublishVersion(libraryRoot, spec, ref, opts = {}) {
349
+ const entryDir = entryRoot(libraryRoot, ref.namespace, ref.slug);
350
+ const existingVersions = await listVersionDirs(entryDir);
351
+ const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
352
+ if (latestVersion > 0 && !opts.forceNewVersion) {
353
+ const latestSpecPath = join(versionDir(libraryRoot, ref.namespace, ref.slug, latestVersion), SPEC_FILE);
354
+ if (await pathExists(latestSpecPath)) {
355
+ const existingSpec = await readFile(latestSpecPath, "utf8");
356
+ if (sha256(existingSpec) === sha256(spec)) {
357
+ return { latestVersion, version: latestVersion, isNewVersion: false };
358
+ }
359
+ }
360
+ }
361
+ return { latestVersion, version: latestVersion + 1, isNewVersion: true };
362
+ }
256
363
  export async function publishEntry(libraryRoot, spec, input, opts = {}) {
257
364
  const marker = await readMarker(libraryRoot);
258
365
  if (!marker) {
@@ -272,9 +379,8 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
272
379
  }
273
380
  const namespace = input.namespace;
274
381
  const entryDir = entryRoot(libraryRoot, namespace, input.slug);
275
- const existingVersions = await listVersionDirs(entryDir);
276
- const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
277
- const newSpecHash = sha256(spec);
382
+ const preview = await previewPublishVersion(libraryRoot, spec, { slug: input.slug, namespace }, opts);
383
+ const latestVersion = preview.latestVersion;
278
384
  // Collision guard. Slugs derive from the trailing path segment of the source
279
385
  // repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
280
386
  // onto one slug. Without this check the second publish would append its spec
@@ -286,39 +392,61 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
286
392
  const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
287
393
  if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
288
394
  const label = namespace ? `${namespace}/${input.slug}` : input.slug;
289
- throw new Error(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
395
+ throw new SourceRepoMismatchError(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
290
396
  `"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
291
397
  `append this spec to a different project's version history. Publish this project ` +
292
398
  `under a distinct slug to shelve it separately, or — if the repository itself ` +
293
399
  `moved (rename, org transfer, host change) — re-publish with the source-repo ` +
294
400
  `change allowed: allow_source_repo_change on codecarto_publish, ` +
295
- `allowSourceRepoChange in PublishOptions.`);
401
+ `allowSourceRepoChange in PublishOptions.`, recorded, input.source_repo);
296
402
  }
297
403
  }
298
- // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes,
299
- // update metadata in place and return without bumping the version.
300
- if (latestVersion > 0 && !opts.forceNewVersion) {
404
+ // Confidentiality guard. Levels are ordered internal < shared < public. An
405
+ // entry may sit in a library at or below its own level, but one more
406
+ // restricted than its library would be exposed to everyone the library
407
+ // reaches: an internal spec in a public library is a leak. Either side that
408
+ // declares nothing counts as internal — the marker default initLibrary
409
+ // writes, and the entry default docs/library-format.md documents — so a
410
+ // library with no visibility field accepts everything it did before. Like
411
+ // the collision guard this runs ahead of the idempotence branch, so a
412
+ // metadata-only update cannot reclassify an entry past it, and it fails
413
+ // before anything is written.
414
+ const entryConfidentiality = input.confidentiality ?? DEFAULT_VISIBILITY;
415
+ const libraryVisibility = marker.visibility ?? DEFAULT_VISIBILITY;
416
+ if (!opts.allowConfidentialityMismatch && VISIBILITY_RANK[entryConfidentiality] < VISIBILITY_RANK[libraryVisibility]) {
417
+ const label = namespace ? `${namespace}/${input.slug}` : input.slug;
418
+ const declared = input.confidentiality ? "" : " (the default when none is declared)";
419
+ throw new ConfidentialityMismatchError(`Refusing to publish: entry "${label}" has confidentiality "${entryConfidentiality}"${declared}, ` +
420
+ `but library "${marker.name}" has visibility "${libraryVisibility}". Publishing would expose a ` +
421
+ `spec classified "${entryConfidentiality}" to everyone the "${libraryVisibility}" library reaches. ` +
422
+ `Publish it to a library whose visibility is "${entryConfidentiality}" or narrower, declare a ` +
423
+ `confidentiality of "${libraryVisibility}" or wider if the spec may travel that far, or — if ` +
424
+ `this exposure is intended — re-publish with the mismatch allowed: ` +
425
+ `allow_confidentiality_mismatch on codecarto_publish, allowConfidentialityMismatch in PublishOptions.`, entryConfidentiality, libraryVisibility);
426
+ }
427
+ // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes
428
+ // (decided by previewPublishVersion above), update metadata in place and
429
+ // return without bumping the version.
430
+ if (!preview.isNewVersion) {
301
431
  const latestVersionDir = versionDir(libraryRoot, namespace, input.slug, latestVersion);
302
- const latestSpecPath = join(latestVersionDir, SPEC_FILE);
303
- if (await pathExists(latestSpecPath)) {
304
- const existingSpec = await readFile(latestSpecPath, "utf8");
305
- if (sha256(existingSpec) === newSpecHash) {
306
- const metadata = buildMetadata(input, latestVersion);
307
- await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
308
- if (!opts.skipReindex)
309
- await reindex(libraryRoot);
310
- return {
311
- slug: input.slug,
312
- namespace,
313
- version: latestVersion,
314
- isNewVersion: false,
315
- entryDir,
316
- versionDir: latestVersionDir,
317
- };
318
- }
319
- }
432
+ // buildMetadata writes provenance only when the input carries it, and
433
+ // neither surface sends it on publish — so without this the rewrite
434
+ // would drop the block the version's original publish recorded.
435
+ const provenance = input.provenance ?? (await readRecordedProvenance(libraryRoot, namespace, input.slug, latestVersion));
436
+ const metadata = buildMetadata({ ...input, provenance }, latestVersion);
437
+ await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
438
+ if (!opts.skipReindex)
439
+ await reindex(libraryRoot);
440
+ return {
441
+ slug: input.slug,
442
+ namespace,
443
+ version: latestVersion,
444
+ isNewVersion: false,
445
+ entryDir,
446
+ versionDir: latestVersionDir,
447
+ };
320
448
  }
321
- const nextVersion = latestVersion + 1;
449
+ const nextVersion = preview.version;
322
450
  const finalVersionDir = versionDir(libraryRoot, namespace, input.slug, nextVersion);
323
451
  const stagingDir = `${entryDir}.publish.${process.pid}.${Date.now()}`;
324
452
  // Stage all files under a sibling directory, then atomically rename it
@@ -605,7 +733,10 @@ export async function reindex(libraryRoot) {
605
733
  };
606
734
  await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
607
735
  await writeIndexMarkdown(libraryRoot, index, marker);
608
- return index;
736
+ // Reported, not written. The index files above are ABI, so the conflict
737
+ // list travels on the return value only (see ReindexResult).
738
+ const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, entries);
739
+ return { ...index, provenance_conflicts };
609
740
  }
610
741
  async function buildIndexEntry(libraryRoot, namespace, slug) {
611
742
  const entryDir = entryRoot(libraryRoot, namespace, slug);
@@ -687,7 +818,10 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
687
818
  lines.push("");
688
819
  lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with \`codecarto library-reindex\`._`);
689
820
  lines.push("");
690
- lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${index.namespaces.length || 1} ${index.namespaces.length === 1 ? "namespace" : "namespaces"}.`);
821
+ // A single-tenant library has no namespaces but is still one namespace's
822
+ // worth of entries; count once so the noun agrees with the number shown.
823
+ const namespaceCount = index.namespaces.length || 1;
824
+ lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${namespaceCount} ${namespaceCount === 1 ? "namespace" : "namespaces"}.`);
691
825
  lines.push("");
692
826
  if (marker.namespaced) {
693
827
  const grouped = new Map();
@@ -725,7 +859,11 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
725
859
  await rename(tempPath, path);
726
860
  }
727
861
  function formatIndexRow(e, namespaced) {
728
- const pathPart = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}/latest/` : `${ENTRIES_DIR}/${e.slug}/latest/`;
862
+ // Link to the newest version directory, not `latest/`: the pointer is a
863
+ // one-line regular file (see the module header), so a `latest/` link has
864
+ // nothing to land on when the library is browsed on a forge.
865
+ const entryPath = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}` : `${ENTRIES_DIR}/${e.slug}`;
866
+ const pathPart = `${entryPath}/v${e.latest_version}/`;
729
867
  const slugLink = `[${escapeMd(e.slug)}](${pathPart})`;
730
868
  const headline = escapeMd(e.headline).replace(/\n+/g, " ");
731
869
  const tags = e.tags.length === 0 ? "" : e.tags.map(escapeMd).join(", ");
@@ -737,6 +875,70 @@ function escapeMd(value) {
737
875
  // break out of the table cell (code scanning alert #3).
738
876
  return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
739
877
  }
878
+ // ─── Provenance conflicts ───────────────────────────────────────────────────
879
+ //
880
+ // Before publish refused cross-project appends (#123), two projects whose
881
+ // source_repo shared a trailing path segment derived the same slug, and the
882
+ // second publish landed as the next version of the first project's entry.
883
+ // Nothing rewrites those entries after the fact: the index reads only the
884
+ // newest version's metadata, so it advertises every version under whichever
885
+ // project published last, and a synthesis run reading the entry gets one
886
+ // project's spec history presented as another's (#148). Detection reads every
887
+ // version and reports the disagreement. Repair is deliberately manual —
888
+ // splitting an entry means inventing a slug, renumbering versions and
889
+ // repointing `latest`, all of which are paths docs/library-format.md calls
890
+ // ABI — so nothing here renames, renumbers, or moves anything.
891
+ /**
892
+ * Read-only check over the given entries: does every version of each entry
893
+ * record the same repository as its newest version? Comparison goes through
894
+ * `sameSourceRepo`, so spellings of one repository (scheme, `.git`, SCP
895
+ * syntax, casing where safe) do not count as disagreement. A version whose
896
+ * metadata is missing, unreadable, or lacks `source_repo` is skipped rather
897
+ * than reported — the stance the publish guard takes — and an entry whose
898
+ * newest version is unreadable is skipped entirely, since there is nothing to
899
+ * compare against. Never writes.
900
+ *
901
+ * A repository that genuinely moved and was re-published with
902
+ * `allowSourceRepoChange` leaves the same on-disk shape as a collision and is
903
+ * reported the same way; the history alone cannot tell the two apart.
904
+ */
905
+ export async function detectProvenanceConflicts(libraryRoot, entries) {
906
+ const conflicts = [];
907
+ for (const entry of entries) {
908
+ const conflict = await findProvenanceConflict(libraryRoot, entry.namespace, entry.slug);
909
+ if (conflict)
910
+ conflicts.push(conflict);
911
+ }
912
+ return conflicts;
913
+ }
914
+ async function findProvenanceConflict(libraryRoot, namespace, slug) {
915
+ const versions = await listVersionDirs(entryRoot(libraryRoot, namespace, slug));
916
+ if (versions.length < 2)
917
+ return null;
918
+ const latest = versions[versions.length - 1];
919
+ const latestRepo = await readRecordedSourceRepo(libraryRoot, namespace, slug, latest);
920
+ if (latestRepo === null)
921
+ return null;
922
+ const disagreeing = [];
923
+ for (const version of versions.slice(0, -1)) {
924
+ const recorded = await readRecordedSourceRepo(libraryRoot, namespace, slug, version);
925
+ if (recorded === null)
926
+ continue;
927
+ if (!sameSourceRepo(recorded, latestRepo))
928
+ disagreeing.push({ version, source_repo: recorded });
929
+ }
930
+ if (disagreeing.length === 0)
931
+ return null;
932
+ const conflict = {
933
+ slug,
934
+ latest_version: latest,
935
+ source_repo: latestRepo,
936
+ disagreeing_versions: disagreeing,
937
+ };
938
+ if (namespace)
939
+ conflict.namespace = namespace;
940
+ return conflict;
941
+ }
740
942
  // ─── Atomic YAML write ──────────────────────────────────────────────────────
741
943
  async function atomicWriteYaml(path, value) {
742
944
  const serialized = `${stringifySimpleYaml(value)}\n`;
@@ -748,6 +950,55 @@ async function atomicWriteYaml(path, value) {
748
950
  function sha256(content) {
749
951
  return createHash("sha256").update(content, "utf8").digest("hex");
750
952
  }
953
+ /**
954
+ * Resolve the repository reference a publish from `cwd` should record. The
955
+ * remote is preferred over the path because a path means nothing outside the
956
+ * machine that published it, and because a slug derived from the remote is
957
+ * stable across clones of one repository, which is what lets the collision
958
+ * guard compare something meaningful (#147).
959
+ *
960
+ * Resolution order: `origin`'s fetch URL, else the fetch URL of the remote
961
+ * the current branch tracks, else `cwd`. The remote is consulted only when
962
+ * `cwd` is the root of its work tree. A subdirectory keeps recording its
963
+ * path: every subdirectory of one repository would otherwise resolve to the
964
+ * same URL and the same slug, and the second one published would land as a
965
+ * new version of the first with no guard able to tell — exactly the
966
+ * cross-project append the guard exists to refuse.
967
+ *
968
+ * The URL is stored as git reports it. Spellings of one repository are
969
+ * reconciled at comparison time by `sameSourceRepo`, not here, so the
970
+ * recorded value stays human-readable. Never throws: a missing `git` binary
971
+ * or any git failure falls back to the path.
972
+ */
973
+ export async function resolvePublishSourceRepo(cwd) {
974
+ const asPath = { source_repo: cwd, remote: null };
975
+ try {
976
+ const toplevel = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
977
+ if (!toplevel.ok || toplevel.stdout.trim() === "")
978
+ return asPath;
979
+ const [canonicalCwd, canonicalTop] = await Promise.all([canonicalPath(cwd), canonicalPath(toplevel.stdout.trim())]);
980
+ if (normalizeForComparison(canonicalCwd) !== normalizeForComparison(canonicalTop))
981
+ return asPath;
982
+ const origin = await runGit(cwd, ["remote", "get-url", "origin"]);
983
+ if (origin.ok && origin.stdout.trim() !== "")
984
+ return { source_repo: origin.stdout.trim(), remote: "origin" };
985
+ const branch = await runGit(cwd, ["symbolic-ref", "--short", "HEAD"]);
986
+ if (!branch.ok || branch.stdout.trim() === "")
987
+ return asPath;
988
+ const upstream = await runGit(cwd, ["config", "--get", `branch.${branch.stdout.trim()}.remote`]);
989
+ const remote = upstream.stdout.trim();
990
+ // `.` marks a branch tracking another local branch; there is no URL behind it.
991
+ if (!upstream.ok || remote === "" || remote === ".")
992
+ return asPath;
993
+ const url = await runGit(cwd, ["remote", "get-url", remote]);
994
+ if (url.ok && url.stdout.trim() !== "")
995
+ return { source_repo: url.stdout.trim(), remote };
996
+ return asPath;
997
+ }
998
+ catch {
999
+ return asPath;
1000
+ }
1001
+ }
751
1002
  /**
752
1003
  * Optional convenience: stage and commit publish output. Never pushes.
753
1004
  * On any failure, returns `{ ok: false, skipped: <reason> }` rather than
@@ -17,6 +17,12 @@ export interface LibraryConfig {
17
17
  /** Whether `codecarto publish` should display a confirmation prompt
18
18
  * with slug + source + library path before writing. Default true. */
19
19
  publish_confirm: boolean;
20
+ /** True when some config layer set `publish_confirm`; false when the
21
+ * default above supplied it. Pi confirms either way (a dialog costs
22
+ * nothing), but the MCP server's refuse-unless-confirmed gate on
23
+ * `codecarto_publish` costs every host a round trip, so it applies only
24
+ * to hosts that actually configured the key. */
25
+ publish_confirm_configured: boolean;
20
26
  }
21
27
  export interface CodecartoConfig {
22
28
  orchestrator: OrchestratorConfig;
@@ -38,6 +38,7 @@ const DEFAULT_CONFIG = {
38
38
  path: null,
39
39
  namespace: null,
40
40
  publish_confirm: true,
41
+ publish_confirm_configured: false,
41
42
  },
42
43
  };
43
44
  export async function loadCodecartoConfig(workspaceDir) {
@@ -101,6 +102,7 @@ function applyRaw(base, raw) {
101
102
  }
102
103
  if (typeof l.publish_confirm === "boolean") {
103
104
  out.library.publish_confirm = l.publish_confirm;
105
+ out.library.publish_confirm_configured = true;
104
106
  }
105
107
  }
106
108
  return out;