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.
- package/.codecarto/GUIDE.md +1 -1
- package/.codecarto/broadside/SKILL.md +14 -0
- package/.codecarto/broadside/config.yaml +18 -0
- package/.codecarto/templates/gitignore +55 -0
- package/.codecarto/workflow/VALIDATE.md +2 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/dist/core/amendment.js +28 -23
- package/dist/core/broadside.d.ts +72 -2
- package/dist/core/broadside.js +351 -68
- package/dist/core/completion.js +95 -26
- package/dist/core/dashboard-writer.d.ts +8 -0
- package/dist/core/dashboard-writer.js +159 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.js +115 -107
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +80 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +43 -10
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +30 -2
- package/dist/core/status.js +54 -8
- package/dist/core/synthesis.js +5 -2
- package/dist/core/usage.d.ts +8 -0
- package/dist/core/usage.js +35 -7
- package/dist/core/utils.d.ts +32 -5
- package/dist/core/utils.js +81 -19
- package/dist/core/workspace.d.ts +99 -18
- package/dist/core/workspace.js +275 -36
- package/dist/core/yaml.js +173 -14
- package/dist/extensions/codecarto/agent-rewriter.js +21 -14
- package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
- package/dist/extensions/codecarto/agent-runner.js +27 -9
- package/dist/extensions/codecarto/agent-state.d.ts +0 -2
- package/dist/extensions/codecarto/auto-runner.js +10 -6
- package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
- package/dist/extensions/codecarto/dashboard-writer.js +5 -157
- package/dist/extensions/codecarto/index.js +73 -21
- package/dist/extensions/codecarto/phase-compaction.js +7 -7
- package/dist/mcp-server/server.js +111 -50
- package/package.json +3 -2
package/dist/core/completion.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import { applyHandoff, autoAssignIds,
|
|
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:
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
package/dist/core/library.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
`
|
|
398
|
-
`
|
|
399
|
-
`
|
|
400
|
-
`
|
|
401
|
-
`
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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:
|
|
444
|
-
isNewVersion:
|
|
486
|
+
version: nextVersion,
|
|
487
|
+
isNewVersion: true,
|
|
445
488
|
entryDir,
|
|
446
|
-
versionDir:
|
|
489
|
+
versionDir: finalVersionDir,
|
|
447
490
|
};
|
|
448
491
|
}
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|