codecartographer-pi 0.19.5 → 0.20.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 (46) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +14 -0
  3. package/.codecarto/broadside/config.yaml +18 -0
  4. package/.codecarto/templates/gitignore +55 -0
  5. package/.codecarto/workflow/VALIDATE.md +2 -1
  6. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  7. package/README.md +10 -6
  8. package/dist/core/amendment.js +28 -23
  9. package/dist/core/broadside.d.ts +72 -2
  10. package/dist/core/broadside.js +351 -68
  11. package/dist/core/completion.js +95 -26
  12. package/dist/core/dashboard-writer.d.ts +8 -0
  13. package/dist/core/dashboard-writer.js +159 -0
  14. package/dist/core/index.d.ts +2 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/library.js +115 -107
  17. package/dist/core/orchestrator-config.d.ts +32 -7
  18. package/dist/core/orchestrator-config.js +124 -44
  19. package/dist/core/pipeline.d.ts +37 -0
  20. package/dist/core/pipeline.js +80 -10
  21. package/dist/core/prompts.d.ts +20 -0
  22. package/dist/core/prompts.js +43 -10
  23. package/dist/core/secrets.d.ts +16 -0
  24. package/dist/core/secrets.js +98 -0
  25. package/dist/core/status.d.ts +30 -2
  26. package/dist/core/status.js +54 -8
  27. package/dist/core/synthesis.js +5 -2
  28. package/dist/core/usage.d.ts +8 -0
  29. package/dist/core/usage.js +35 -7
  30. package/dist/core/utils.d.ts +32 -5
  31. package/dist/core/utils.js +81 -19
  32. package/dist/core/workspace.d.ts +99 -18
  33. package/dist/core/workspace.js +275 -36
  34. package/dist/core/yaml.js +173 -14
  35. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  36. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  37. package/dist/extensions/codecarto/agent-runner.js +27 -9
  38. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  39. package/dist/extensions/codecarto/auto-runner.js +10 -6
  40. package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
  41. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  42. package/dist/extensions/codecarto/dashboard-writer.js +5 -157
  43. package/dist/extensions/codecarto/index.js +73 -21
  44. package/dist/extensions/codecarto/phase-compaction.js +7 -7
  45. package/dist/mcp-server/server.js +111 -50
  46. package/package.json +3 -2
@@ -1,7 +1,7 @@
1
1
  import { appendFile, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { getNextEligiblePhase, resolvePhase, validatePhaseOutput } from "./pipeline.js";
4
- import { applyHandoff, autoAssignIds, buildTerminalNextActions, loadHandoffFile, normalizeStatus } from "./status.js";
3
+ import { recomputeCursor, resolvePhase, validatePhaseOutput } from "./pipeline.js";
4
+ import { applyHandoff, autoAssignIds, loadHandoffFile, normalizeStatus } from "./status.js";
5
5
  import { compareDottedVersions, dateOnly, newlineIfUnterminated, pathExists, uniqueStrings } from "./utils.js";
6
6
  import { getWorkspaceState, updateStatusAtomically } from "./workspace.js";
7
7
  /**
@@ -253,6 +253,80 @@ export function closureEvidenceGateActive(scaffoldVersion) {
253
253
  const comparison = compareDottedVersions(scaffoldVersion, CLOSURE_EVIDENCE_GATE_SCAFFOLD_VERSION);
254
254
  return comparison !== null && comparison >= 0;
255
255
  }
256
+ /**
257
+ * Whether `text` names one of `ids` as a whole token: the id must not run
258
+ * straight into another id character on either side, so `arch-CF2` matches
259
+ * "routed as `arch-CF2`" and not "arch-CF20".
260
+ */
261
+ function mentionsAnyId(text, ids) {
262
+ for (const id of ids) {
263
+ let from = 0;
264
+ while (true) {
265
+ const at = text.indexOf(id, from);
266
+ if (at === -1)
267
+ break;
268
+ const before = at === 0 ? "" : text[at - 1];
269
+ const after = text[at + id.length] ?? "";
270
+ if (!/[A-Za-z0-9_-]/.test(before) && !/[A-Za-z0-9_-]/.test(after))
271
+ return true;
272
+ from = at + 1;
273
+ }
274
+ }
275
+ return false;
276
+ }
277
+ /**
278
+ * Turn each PARTIAL validation row into a `needs-maintainer-decision`
279
+ * question on the phase, unless the row's criterion or evidence cell names an
280
+ * entry the workspace already tracks: an open question, carry-forward, or
281
+ * post-pipeline item, from this handoff or an earlier phase. VALIDATE.md asks
282
+ * the phase to name the tracking entry in the evidence cell; honouring it is
283
+ * what stops a routed gap from also becoming a duplicate question that every
284
+ * later phase re-triages and someone must close (#239).
285
+ *
286
+ * Runs after the handoff has been applied. That is also what keeps the auto
287
+ * ids distinct from the handoff's: both `autoAssignIds` from `oq-<phase>-1`,
288
+ * and assigning the gaps first let a later id-less handoff question replace a
289
+ * gap under the same id.
290
+ */
291
+ function addPartialRowQuestions(status, phaseId, rows) {
292
+ const phase = status.phases[phaseId];
293
+ if (!phase)
294
+ return;
295
+ const trackedIds = new Set();
296
+ for (const phaseState of Object.values(status.phases)) {
297
+ for (const entry of [...(phaseState.open_questions ?? []), ...(phaseState.carry_forward ?? [])]) {
298
+ if (entry.id)
299
+ trackedIds.add(entry.id);
300
+ }
301
+ }
302
+ for (const entry of status.post_pipeline) {
303
+ if (entry.id)
304
+ trackedIds.add(entry.id);
305
+ }
306
+ const gapEntries = [];
307
+ for (const row of rows) {
308
+ if (!row.result.toUpperCase().includes("PARTIAL"))
309
+ continue;
310
+ if (mentionsAnyId(`${row.criterion} ${row.evidence}`, trackedIds))
311
+ continue;
312
+ const candidate = {
313
+ kind: "needs-maintainer-decision",
314
+ description: row.criterion || "Partial validation gap",
315
+ deferred_reason: row.evidence || "Marked PARTIAL by validation",
316
+ };
317
+ // Idempotent across re-runs of completion: the same gap is one question.
318
+ if (phase.open_questions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason))
319
+ continue;
320
+ gapEntries.push(candidate);
321
+ }
322
+ if (gapEntries.length === 0)
323
+ return;
324
+ // Reserve every id already on the phase so the new ones skip them; the
325
+ // placeholders keep autoAssignIds from touching the existing entries.
326
+ const reserved = phase.open_questions.filter((entry) => entry.id).map((entry) => ({ id: entry.id }));
327
+ autoAssignIds([...reserved, ...gapEntries], "oq", phaseId);
328
+ phase.open_questions.push(...gapEntries);
329
+ }
256
330
  export async function completeValidatedPhase(cwd, validation, sourceLabel) {
257
331
  const initialState = await getWorkspaceState(cwd);
258
332
  if (!initialState)
@@ -391,20 +465,6 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
391
465
  open_questions: [],
392
466
  carry_forward: [],
393
467
  };
394
- const gapEntries = lockedValidation.rows
395
- .filter((row) => row.result.toUpperCase().includes("PARTIAL"))
396
- .map((row) => ({
397
- kind: "needs-maintainer-decision",
398
- description: row.criterion || "Partial validation gap",
399
- deferred_reason: row.evidence || "Marked PARTIAL by validation",
400
- }));
401
- autoAssignIds(gapEntries, "oq", validation.phaseId);
402
- const mergedOpenQuestions = [...existingPhase.open_questions];
403
- for (const candidate of gapEntries) {
404
- if (!mergedOpenQuestions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason)) {
405
- mergedOpenQuestions.push(candidate);
406
- }
407
- }
408
468
  nextStatus.phases[validation.phaseId] = {
409
469
  status: "complete",
410
470
  owner_notes: uniqueStrings([
@@ -414,22 +474,31 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
414
474
  `Validation: ${lockedValidation.overall}`,
415
475
  ]),
416
476
  outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
417
- open_questions: mergedOpenQuestions,
477
+ open_questions: [...existingPhase.open_questions],
418
478
  carry_forward: existingPhase.carry_forward ?? [],
419
479
  };
420
480
  if (handoff)
421
481
  applyHandoff(nextStatus, handoff);
482
+ // After the handoff, so a gap the handoff routed is recognised as
483
+ // tracked and the ids the handoff introduced are known (#239).
484
+ addPartialRowQuestions(nextStatus, validation.phaseId, lockedValidation.rows);
422
485
  nextStatus.last_updated = completionTimestamp;
423
486
  const nextWorkspace = { ...lockedState, status: nextStatus };
424
- const nextEligible = getNextEligiblePhase(nextWorkspace);
425
- nextStatus.current_phase = nextEligible?.id ?? "complete";
426
- nextStatus.next_actions = nextEligible
427
- ? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
428
- : buildTerminalNextActions(nextStatus);
429
- const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, lockedValidation, completionTimestamp, handoff);
430
- closeoutPath = artifacts.closeoutPath;
431
- orchestratorCheckpoint = buildOrchestratorCheckpoint(artifacts.decisionsAppended, artifacts.totalPendingProposals, nextStatus);
432
- return { state: { ...nextWorkspace, status: nextStatus } };
487
+ recomputeCursor(nextWorkspace);
488
+ return {
489
+ state: { ...nextWorkspace, status: nextStatus },
490
+ // The closeout, THREAD_LOG line, decision rows, and staged proposals
491
+ // all assert that the phase is complete, so they are written only
492
+ // after status.yaml has landed (#234). Each writer is idempotent
493
+ // (canonical closeout name, link-deduped index line, text-deduped
494
+ // rows), so re-running completion regenerates whatever a failure
495
+ // here left out.
496
+ afterCommit: async () => {
497
+ const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, lockedValidation, completionTimestamp, handoff);
498
+ closeoutPath = artifacts.closeoutPath;
499
+ orchestratorCheckpoint = buildOrchestratorCheckpoint(artifacts.decisionsAppended, artifacts.totalPendingProposals, nextStatus);
500
+ },
501
+ };
433
502
  });
434
503
  return {
435
504
  updatedState,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Render and atomically replace `.codecarto/dashboard.html`.
3
+ * @returns true when a fresh dashboard landed on disk; false when the
4
+ * workspace is missing or any gather/render/write step failed (swallowed —
5
+ * lifecycle callers must never fail on a dashboard problem, but they may
6
+ * report truthfully whether a refresh happened).
7
+ */
8
+ export declare function writeDashboard(cwd: string, packageVersion: string): Promise<boolean>;
@@ -0,0 +1,159 @@
1
+ // I/O wrapper that gathers all dashboard inputs and writes the rendered
2
+ // HTML to `.codecarto/dashboard.html`. Best-effort — failures are swallowed
3
+ // and never escalate to a phase error the user sees, mirroring the
4
+ // recordUsage discipline at extensions/codecarto/index.ts.
5
+ //
6
+ // Lived in the Pi extension until #254, with the MCP server reaching across
7
+ // to import it; both surfaces refresh the dashboard at the same lifecycle
8
+ // points (init, completion, amendment, pipeline switch, on demand), so it is
9
+ // a core primitive like everything else they share.
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { atomicWriteFile, DASHBOARD_RELATIVE_PATH, getWorkspaceState, loadUsage, NARRATION_CACHE_RELATIVE_PATH, parseSimpleYaml, pathExists, renderDashboard, } from "./index.js";
13
+ const CLOSEOUT_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/;
14
+ /**
15
+ * Render and atomically replace `.codecarto/dashboard.html`.
16
+ * @returns true when a fresh dashboard landed on disk; false when the
17
+ * workspace is missing or any gather/render/write step failed (swallowed —
18
+ * lifecycle callers must never fail on a dashboard problem, but they may
19
+ * report truthfully whether a refresh happened).
20
+ */
21
+ export async function writeDashboard(cwd, packageVersion) {
22
+ try {
23
+ const state = await getWorkspaceState(cwd);
24
+ if (!state)
25
+ return false;
26
+ const workspaceDir = state.workspaceDir;
27
+ const [usage, closeouts, outputsPresent, narration] = await Promise.all([
28
+ loadUsage(workspaceDir),
29
+ listCloseouts(workspaceDir),
30
+ buildOutputsPresent(state.workspaceDir, state.pipeline),
31
+ loadNarration(workspaceDir),
32
+ ]);
33
+ const inputs = {
34
+ status: state.status,
35
+ pipeline: state.pipeline,
36
+ usage,
37
+ closeouts,
38
+ outputsPresent,
39
+ packageVersion,
40
+ generatedAt: new Date().toISOString(),
41
+ narration,
42
+ };
43
+ const html = renderDashboard(inputs);
44
+ await atomicWriteFile(join(workspaceDir, DASHBOARD_RELATIVE_PATH), html);
45
+ return true;
46
+ }
47
+ catch {
48
+ // Best-effort: a failed dashboard write must not surface as a phase
49
+ // error. The user's pipeline state is unaffected; the next state
50
+ // change will trigger another render attempt.
51
+ return false;
52
+ }
53
+ }
54
+ async function listCloseouts(workspaceDir) {
55
+ const dir = join(workspaceDir, "closeouts");
56
+ if (!(await pathExists(dir)))
57
+ return [];
58
+ let entries;
59
+ try {
60
+ entries = await readdir(dir);
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ const out = [];
66
+ for (const name of entries) {
67
+ const m = CLOSEOUT_FILENAME_RE.exec(name);
68
+ if (!m)
69
+ continue;
70
+ out.push({ date: m[1], phaseOrModule: m[2], fileName: name, summary: await readCloseoutSummary(join(dir, name)) });
71
+ }
72
+ return out;
73
+ }
74
+ async function readCloseoutSummary(path) {
75
+ try {
76
+ const raw = await readFile(path, "utf8");
77
+ const lines = raw.split(/\r?\n/);
78
+ const summaryStart = lines.findIndex((line) => /^##\s+Summary\s*$/i.test(line.trim()));
79
+ if (summaryStart === -1)
80
+ return undefined;
81
+ const body = [];
82
+ for (const line of lines.slice(summaryStart + 1)) {
83
+ if (/^##\s+/.test(line.trim()))
84
+ break;
85
+ const trimmed = line.trim();
86
+ if (!trimmed || trimmed === "-")
87
+ continue;
88
+ body.push(trimmed.replace(/^[-*]\s+/, ""));
89
+ if (body.join(" ").length > 280)
90
+ break;
91
+ }
92
+ const summary = body.join(" ").trim();
93
+ return summary ? `${summary.slice(0, 280)}${summary.length > 280 ? "…" : ""}` : undefined;
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ async function buildOutputsPresent(workspaceDir, pipeline) {
100
+ const out = new Map();
101
+ for (const phaseId of pipeline.phase_order) {
102
+ const phaseDef = pipeline.phases.find((p) => p.id === phaseId);
103
+ if (!phaseDef)
104
+ continue;
105
+ const entry = { secondary: [] };
106
+ if (phaseDef.primary_output) {
107
+ entry.primary = {
108
+ path: phaseDef.primary_output,
109
+ exists: await pathExists(join(workspaceDir, phaseDef.primary_output)),
110
+ };
111
+ }
112
+ for (const sec of phaseDef.secondary_outputs ?? []) {
113
+ entry.secondary.push({
114
+ path: sec.path,
115
+ exists: await pathExists(join(workspaceDir, sec.path)),
116
+ });
117
+ }
118
+ out.set(phaseId, entry);
119
+ }
120
+ return out;
121
+ }
122
+ async function loadNarration(workspaceDir) {
123
+ const path = join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH);
124
+ if (!(await pathExists(path)))
125
+ return undefined;
126
+ try {
127
+ const raw = await readFile(path, "utf8");
128
+ const { frontmatter, body } = splitFrontmatter(raw);
129
+ if (!frontmatter)
130
+ return undefined;
131
+ const generatedAt = typeof frontmatter.generatedAt === "string" ? frontmatter.generatedAt : "";
132
+ const phaseCountAtGeneration = typeof frontmatter.phaseCountAtGeneration === "number" ? frontmatter.phaseCountAtGeneration : 0;
133
+ if (!generatedAt)
134
+ return undefined;
135
+ return { content: body.trim(), generatedAt, phaseCountAtGeneration };
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ function splitFrontmatter(raw) {
142
+ if (!raw.startsWith("---\n"))
143
+ return { frontmatter: null, body: raw };
144
+ const end = raw.indexOf("\n---\n", 4);
145
+ if (end === -1)
146
+ return { frontmatter: null, body: raw };
147
+ const yamlText = raw.slice(4, end);
148
+ const body = raw.slice(end + 5);
149
+ try {
150
+ const parsed = parseSimpleYaml(yamlText);
151
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
152
+ return { frontmatter: parsed, body };
153
+ }
154
+ }
155
+ catch {
156
+ // fall through
157
+ }
158
+ return { frontmatter: null, body };
159
+ }
@@ -16,3 +16,5 @@ export * from "./dashboard.ts";
16
16
  export * from "./library.ts";
17
17
  export * from "./synthesis.ts";
18
18
  export * from "./broadside.ts";
19
+ export * from "./secrets.ts";
20
+ export * from "./dashboard-writer.ts";
@@ -19,3 +19,5 @@ export * from "./dashboard.js";
19
19
  export * from "./library.js";
20
20
  export * from "./synthesis.js";
21
21
  export * from "./broadside.js";
22
+ export * from "./secrets.js";
23
+ export * from "./dashboard-writer.js";
@@ -32,10 +32,13 @@ import { createHash } from "node:crypto";
32
32
  import { spawn } from "node:child_process";
33
33
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
34
34
  import { basename, join, resolve } from "node:path";
35
- import { canonicalPath, isPlainObject, normalizeForComparison, pathExists } from "./utils.js";
35
+ import { acquireLock } from "./status.js";
36
+ import { atomicWriteFile, canonicalPath, isPlainObject, normalizeForComparison, pathExists, uniqueTempSuffix } from "./utils.js";
36
37
  import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
37
38
  // ─── Constants ──────────────────────────────────────────────────────────────
38
39
  export const LIBRARY_MARKER_FILE = ".codecarto-library";
40
+ /** Transient lock taken for the duration of one publish (see publishEntry). */
41
+ const PUBLISH_LOCK_FILE = ".publish.lock";
39
42
  export const LIBRARY_INDEX_FILE = "index.yaml";
40
43
  export const LIBRARY_INDEX_MD_FILE = "INDEX.md";
41
44
  export const ENTRIES_DIR = "entries";
@@ -73,9 +76,7 @@ export async function writeMarker(libraryRoot, marker) {
73
76
  await mkdir(libraryRoot, { recursive: true });
74
77
  const markerPath = join(libraryRoot, LIBRARY_MARKER_FILE);
75
78
  const normalized = normalizeMarker(marker);
76
- const tempPath = `${markerPath}.${process.pid}.${Date.now()}.tmp`;
77
- await writeFile(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
78
- await rename(tempPath, markerPath);
79
+ await atomicWriteFile(markerPath, `${JSON.stringify(normalized, null, 2)}\n`);
79
80
  }
80
81
  function normalizeMarker(raw) {
81
82
  const r = raw;
@@ -285,9 +286,7 @@ async function listVersionDirs(entryDir) {
285
286
  }
286
287
  async function writeLatestPointer(entryDir, versionDirName) {
287
288
  const latestPath = join(entryDir, LATEST_POINTER_FILE);
288
- const tempPath = `${latestPath}.${process.pid}.${Date.now()}.tmp`;
289
- await writeFile(tempPath, `${versionDirName}\n`, "utf8");
290
- await rename(tempPath, latestPath);
289
+ await atomicWriteFile(latestPath, `${versionDirName}\n`);
291
290
  }
292
291
  async function readLatestPointer(entryDir) {
293
292
  const latestPath = join(entryDir, LATEST_POINTER_FILE);
@@ -379,108 +378,120 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
379
378
  }
380
379
  const namespace = input.namespace;
381
380
  const entryDir = entryRoot(libraryRoot, namespace, input.slug);
382
- const preview = await previewPublishVersion(libraryRoot, spec, { slug: input.slug, namespace }, opts);
383
- const latestVersion = preview.latestVersion;
384
- // Collision guard. Slugs derive from the trailing path segment of the source
385
- // repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
386
- // onto one slug. Without this check the second publish would append its spec
387
- // to the first project's version history, and the index would then report the
388
- // newcomer's source_repo as though it owned every prior version. Checked
389
- // before the idempotence branch below, because a metadata-only update would
390
- // overwrite the wrong entry just as silently.
391
- if (latestVersion > 0 && !opts.allowSourceRepoChange) {
392
- const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
393
- if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
381
+ // One publisher at a time. Version assignment reads the entry directory
382
+ // and then creates v<N+1>; two publishes of one slug in the same window
383
+ // both picked the same N, so the loser died on a raw rename error and the
384
+ // winner's latest pointer could be overwritten (#240). The lock lives in
385
+ // the library root, which exists before any entry does, so a refused
386
+ // publish still writes nothing.
387
+ const lock = await acquireLock(join(libraryRoot, PUBLISH_LOCK_FILE));
388
+ try {
389
+ const preview = await previewPublishVersion(libraryRoot, spec, { slug: input.slug, namespace }, opts);
390
+ const latestVersion = preview.latestVersion;
391
+ // Collision guard. Slugs derive from the trailing path segment of the source
392
+ // repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
393
+ // onto one slug. Without this check the second publish would append its spec
394
+ // to the first project's version history, and the index would then report the
395
+ // newcomer's source_repo as though it owned every prior version. Checked
396
+ // before the idempotence branch below, because a metadata-only update would
397
+ // overwrite the wrong entry just as silently.
398
+ if (latestVersion > 0 && !opts.allowSourceRepoChange) {
399
+ const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
400
+ if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
401
+ const label = namespace ? `${namespace}/${input.slug}` : input.slug;
402
+ throw new SourceRepoMismatchError(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
403
+ `"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
404
+ `append this spec to a different project's version history. Publish this project ` +
405
+ `under a distinct slug to shelve it separately, or — if the repository itself ` +
406
+ `moved (rename, org transfer, host change) — re-publish with the source-repo ` +
407
+ `change allowed: allow_source_repo_change on codecarto_publish, ` +
408
+ `allowSourceRepoChange in PublishOptions.`, recorded, input.source_repo);
409
+ }
410
+ }
411
+ // Confidentiality guard. Levels are ordered internal < shared < public. An
412
+ // entry may sit in a library at or below its own level, but one more
413
+ // restricted than its library would be exposed to everyone the library
414
+ // reaches: an internal spec in a public library is a leak. Either side that
415
+ // declares nothing counts as internal — the marker default initLibrary
416
+ // writes, and the entry default docs/library-format.md documents — so a
417
+ // library with no visibility field accepts everything it did before. Like
418
+ // the collision guard this runs ahead of the idempotence branch, so a
419
+ // metadata-only update cannot reclassify an entry past it, and it fails
420
+ // before anything is written.
421
+ const entryConfidentiality = input.confidentiality ?? DEFAULT_VISIBILITY;
422
+ const libraryVisibility = marker.visibility ?? DEFAULT_VISIBILITY;
423
+ if (!opts.allowConfidentialityMismatch && VISIBILITY_RANK[entryConfidentiality] < VISIBILITY_RANK[libraryVisibility]) {
394
424
  const label = namespace ? `${namespace}/${input.slug}` : input.slug;
395
- throw new SourceRepoMismatchError(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
396
- `"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
397
- `append this spec to a different project's version history. Publish this project ` +
398
- `under a distinct slug to shelve it separately, or — if the repository itself ` +
399
- `moved (rename, org transfer, host change) — re-publish with the source-repo ` +
400
- `change allowed: allow_source_repo_change on codecarto_publish, ` +
401
- `allowSourceRepoChange in PublishOptions.`, recorded, input.source_repo);
425
+ const declared = input.confidentiality ? "" : " (the default when none is declared)";
426
+ throw new ConfidentialityMismatchError(`Refusing to publish: entry "${label}" has confidentiality "${entryConfidentiality}"${declared}, ` +
427
+ `but library "${marker.name}" has visibility "${libraryVisibility}". Publishing would expose a ` +
428
+ `spec classified "${entryConfidentiality}" to everyone the "${libraryVisibility}" library reaches. ` +
429
+ `Publish it to a library whose visibility is "${entryConfidentiality}" or narrower, declare a ` +
430
+ `confidentiality of "${libraryVisibility}" or wider if the spec may travel that far, or — if ` +
431
+ `this exposure is intended — re-publish with the mismatch allowed: ` +
432
+ `allow_confidentiality_mismatch on codecarto_publish, allowConfidentialityMismatch in PublishOptions.`, entryConfidentiality, libraryVisibility);
402
433
  }
403
- }
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) {
431
- const latestVersionDir = versionDir(libraryRoot, namespace, input.slug, latestVersion);
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);
434
+ // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes
435
+ // (decided by previewPublishVersion above), update metadata in place and
436
+ // return without bumping the version.
437
+ if (!preview.isNewVersion) {
438
+ const latestVersionDir = versionDir(libraryRoot, namespace, input.slug, latestVersion);
439
+ // buildMetadata writes provenance only when the input carries it, and
440
+ // neither surface sends it on publish — so without this the rewrite
441
+ // would drop the block the version's original publish recorded.
442
+ const provenance = input.provenance ?? (await readRecordedProvenance(libraryRoot, namespace, input.slug, latestVersion));
443
+ const metadata = buildMetadata({ ...input, provenance }, latestVersion);
444
+ await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
445
+ if (!opts.skipReindex)
446
+ await reindex(libraryRoot);
447
+ return {
448
+ slug: input.slug,
449
+ namespace,
450
+ version: latestVersion,
451
+ isNewVersion: false,
452
+ entryDir,
453
+ versionDir: latestVersionDir,
454
+ };
455
+ }
456
+ const nextVersion = preview.version;
457
+ const finalVersionDir = versionDir(libraryRoot, namespace, input.slug, nextVersion);
458
+ const stagingDir = `${entryDir}.publish.${uniqueTempSuffix()}`;
459
+ // Stage all files under a sibling directory, then atomically rename it
460
+ // into place as v<N>. If the rename fails partway, the staging dir is
461
+ // left for the user to inspect or remove.
462
+ await mkdir(stagingDir, { recursive: true });
463
+ try {
464
+ const metadata = buildMetadata({ ...input, provenance: input.provenance ?? { prior_version: latestVersion === 0 ? null : latestVersion, mutation_source: null } }, nextVersion);
465
+ await writeFile(join(stagingDir, SPEC_FILE), spec, "utf8");
466
+ await atomicWriteYaml(join(stagingDir, METADATA_FILE), metadata);
467
+ await mkdir(entryDir, { recursive: true });
468
+ await rename(stagingDir, finalVersionDir);
469
+ }
470
+ catch (err) {
471
+ // Best-effort cleanup of the staging directory.
472
+ try {
473
+ await rm(stagingDir, { recursive: true, force: true });
474
+ }
475
+ catch {
476
+ // swallow — leave the staging dir for diagnostics
477
+ }
478
+ throw err;
479
+ }
480
+ await writeLatestPointer(entryDir, `v${nextVersion}`);
438
481
  if (!opts.skipReindex)
439
482
  await reindex(libraryRoot);
440
483
  return {
441
484
  slug: input.slug,
442
485
  namespace,
443
- version: latestVersion,
444
- isNewVersion: false,
486
+ version: nextVersion,
487
+ isNewVersion: true,
445
488
  entryDir,
446
- versionDir: latestVersionDir,
489
+ versionDir: finalVersionDir,
447
490
  };
448
491
  }
449
- const nextVersion = preview.version;
450
- const finalVersionDir = versionDir(libraryRoot, namespace, input.slug, nextVersion);
451
- const stagingDir = `${entryDir}.publish.${process.pid}.${Date.now()}`;
452
- // Stage all files under a sibling directory, then atomically rename it
453
- // into place as v<N>. If the rename fails partway, the staging dir is
454
- // left for the user to inspect or remove.
455
- await mkdir(stagingDir, { recursive: true });
456
- try {
457
- const metadata = buildMetadata({ ...input, provenance: input.provenance ?? { prior_version: latestVersion === 0 ? null : latestVersion, mutation_source: null } }, nextVersion);
458
- await writeFile(join(stagingDir, SPEC_FILE), spec, "utf8");
459
- await atomicWriteYaml(join(stagingDir, METADATA_FILE), metadata);
460
- await mkdir(entryDir, { recursive: true });
461
- await rename(stagingDir, finalVersionDir);
462
- }
463
- catch (err) {
464
- // Best-effort cleanup of the staging directory.
465
- try {
466
- await rm(stagingDir, { recursive: true, force: true });
467
- }
468
- catch {
469
- // swallow — leave the staging dir for diagnostics
470
- }
471
- throw err;
492
+ finally {
493
+ await lock.release();
472
494
  }
473
- await writeLatestPointer(entryDir, `v${nextVersion}`);
474
- if (!opts.skipReindex)
475
- await reindex(libraryRoot);
476
- return {
477
- slug: input.slug,
478
- namespace,
479
- version: nextVersion,
480
- isNewVersion: true,
481
- entryDir,
482
- versionDir: finalVersionDir,
483
- };
484
495
  }
485
496
  function buildMetadata(input, version) {
486
497
  const out = {
@@ -665,7 +676,10 @@ export async function listEntries(libraryRoot, filter = {}) {
665
676
  return false;
666
677
  if (filter.slug !== undefined && e.slug !== filter.slug)
667
678
  return false;
668
- if (filter.source_repo !== undefined && e.source_repo !== filter.source_repo)
679
+ // Same equivalence the publish guard applies: `.git`, scheme, userinfo,
680
+ // default port, and forge-host case do not make two references two
681
+ // repositories, so they must not make a filter miss one either (#257).
682
+ if (filter.source_repo !== undefined && !sameSourceRepo(e.source_repo, filter.source_repo))
669
683
  return false;
670
684
  if (filter.tag !== undefined && !e.tags.includes(filter.tag))
671
685
  return false;
@@ -853,10 +867,7 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
853
867
  lines.push("");
854
868
  }
855
869
  const content = lines.join("\n");
856
- const path = join(libraryRoot, LIBRARY_INDEX_MD_FILE);
857
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
858
- await writeFile(tempPath, content, "utf8");
859
- await rename(tempPath, path);
870
+ await atomicWriteFile(join(libraryRoot, LIBRARY_INDEX_MD_FILE), content);
860
871
  }
861
872
  function formatIndexRow(e, namespaced) {
862
873
  // Link to the newest version directory, not `latest/`: the pointer is a
@@ -941,10 +952,7 @@ async function findProvenanceConflict(libraryRoot, namespace, slug) {
941
952
  }
942
953
  // ─── Atomic YAML write ──────────────────────────────────────────────────────
943
954
  async function atomicWriteYaml(path, value) {
944
- const serialized = `${stringifySimpleYaml(value)}\n`;
945
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
946
- await writeFile(tempPath, serialized, "utf8");
947
- await rename(tempPath, path);
955
+ await atomicWriteFile(path, `${stringifySimpleYaml(value)}\n`);
948
956
  }
949
957
  // ─── Hash ───────────────────────────────────────────────────────────────────
950
958
  function sha256(content) {