codecartographer-pi 0.19.5 → 0.19.6
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/templates/gitignore +55 -0
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/dist/core/amendment.js +28 -23
- package/dist/core/broadside.js +3 -5
- package/dist/core/completion.js +14 -4
- package/dist/core/library.js +111 -106
- package/dist/core/status.d.ts +22 -2
- package/dist/core/status.js +46 -5
- 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 +44 -10
- package/dist/core/workspace.js +161 -30
- package/dist/extensions/codecarto/dashboard-narrator.js +3 -6
- package/dist/extensions/codecarto/dashboard-writer.js +3 -6
- package/dist/extensions/codecarto/index.js +9 -4
- package/dist/extensions/codecarto/phase-compaction.js +7 -7
- package/dist/mcp-server/server.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Analysis outputs (generated per-project, can be large)
|
|
2
|
+
findings/architecture/architecture-map.md
|
|
3
|
+
findings/defect-scan/defect-report.md
|
|
4
|
+
findings/defect-scan-mechanical/mechanical-defects.md
|
|
5
|
+
findings/defect-scan-semantic/semantic-defects.md
|
|
6
|
+
findings/contracts/behavioral-contracts.md
|
|
7
|
+
findings/protocols/protocols-and-state.md
|
|
8
|
+
findings/porting/reverse-engineering-bundle.md
|
|
9
|
+
findings/reimplementation-spec/reimplementation-spec.md
|
|
10
|
+
findings/broadside-scout/scout-brief.md
|
|
11
|
+
|
|
12
|
+
# Secondary / optional outputs
|
|
13
|
+
findings/public-surfaces/public-surfaces.md
|
|
14
|
+
findings/runtime-lifecycle/runtime-lifecycle.md
|
|
15
|
+
findings/state-and-storage/state-and-storage.md
|
|
16
|
+
findings/build-and-deploy/build-and-deploy.md
|
|
17
|
+
findings/config-model/config-model.md
|
|
18
|
+
|
|
19
|
+
# Scratch working notes
|
|
20
|
+
scratch/*
|
|
21
|
+
!scratch/.gitkeep
|
|
22
|
+
|
|
23
|
+
# Broad-Side machine-local state and generated results (batch ids, run
|
|
24
|
+
# directories, costs). The SKILL.md guidance and the config template are
|
|
25
|
+
# tracked; the API key inside config.yaml is your own risk to commit.
|
|
26
|
+
broadside/*
|
|
27
|
+
!broadside/SKILL.md
|
|
28
|
+
!broadside/config.yaml
|
|
29
|
+
|
|
30
|
+
# Orchestrator session pointer (machine-local, written by /codecarto-init
|
|
31
|
+
# when run from the Pi extension; the MCP path doesn't write it). Contains
|
|
32
|
+
# absolute paths into the user's Pi session storage, so it must never be
|
|
33
|
+
# committed.
|
|
34
|
+
workflow/.orchestrator.local.yaml
|
|
35
|
+
|
|
36
|
+
# Phase-run usage log (machine-local, written by /codecarto-next on each
|
|
37
|
+
# phase completion; consumed by /codecarto-usage). Holds timestamps, token
|
|
38
|
+
# counts, and absolute Pi session-file paths; useless to share, includes
|
|
39
|
+
# local cwd metadata, must never be committed.
|
|
40
|
+
workflow/.usage.local.yaml
|
|
41
|
+
|
|
42
|
+
# Generated HTML dashboard (machine-local; regenerated by /codecarto-init,
|
|
43
|
+
# /codecarto-next, /codecarto-complete, and /codecarto-dashboard). Surfaces
|
|
44
|
+
# data from workflow/.usage.local.yaml which holds absolute Pi session
|
|
45
|
+
# paths; committing the dashboard would transitively leak those.
|
|
46
|
+
dashboard.html
|
|
47
|
+
|
|
48
|
+
# LLM-narrated executive summary cache (produced by
|
|
49
|
+
# /codecarto-dashboard --narrate; preserved across deterministic re-renders
|
|
50
|
+
# until the next --narrate).
|
|
51
|
+
.dashboard-narration.local.md
|
|
52
|
+
|
|
53
|
+
# OS artifacts
|
|
54
|
+
.DS_Store
|
|
55
|
+
Thumbs.db
|
package/dist/core/amendment.js
CHANGED
|
@@ -134,29 +134,34 @@ export async function applyAmendment(cwd, name) {
|
|
|
134
134
|
// carry (issue #114); rebuild them so status never shows stale numbers.
|
|
135
135
|
nextStatus.next_actions = buildTerminalNextActions(nextStatus);
|
|
136
136
|
nextStatus.last_updated = timestamp;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
137
|
+
return {
|
|
138
|
+
state: { ...lockedState, status: nextStatus },
|
|
139
|
+
// Amendment closeout + THREAD_LOG entry, written after the status
|
|
140
|
+
// commit (#234) under the same idempotence rule as completion: the
|
|
141
|
+
// closeout link appears in THREAD_LOG at most once.
|
|
142
|
+
afterCommit: async () => {
|
|
143
|
+
const closeoutFile = `${dateOnly(timestamp)}-amendment-${amendment.slug}.md`;
|
|
144
|
+
const closeoutsDir = join(lockedState.workspaceDir, "closeouts");
|
|
145
|
+
await mkdir(closeoutsDir, { recursive: true });
|
|
146
|
+
const body = amendment.closeout_content.trim() || renderAmendmentCloseout(amendment, applied, timestamp);
|
|
147
|
+
await writeFile(join(closeoutsDir, closeoutFile), `${body}\n`, "utf8");
|
|
148
|
+
const summary = amendment.closeout_summary.trim()
|
|
149
|
+
|| `Amendment applied: ${applied.openQuestionsClosed.length} open question(s) and ${applied.postPipelineClosed.length} post-pipeline item(s) closed.`;
|
|
150
|
+
const entry = `- ${dateOnly(timestamp)} — amendment:${amendment.slug} — ${summary} — [closeout](closeouts/${closeoutFile})`;
|
|
151
|
+
const threadLogPath = join(lockedState.workspaceDir, "THREAD_LOG.md");
|
|
152
|
+
let current = "";
|
|
153
|
+
try {
|
|
154
|
+
current = await readFile(threadLogPath, "utf8");
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Created below when absent.
|
|
158
|
+
}
|
|
159
|
+
if (!current.split(/\r?\n/).some((line) => line.includes(`[closeout](closeouts/${closeoutFile})`))) {
|
|
160
|
+
await appendFile(threadLogPath, `${newlineIfUnterminated(current)}${entry}\n`, "utf8");
|
|
161
|
+
}
|
|
162
|
+
closeoutNotice = `Closeout: .codecarto/closeouts/${closeoutFile}`;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
160
165
|
});
|
|
161
166
|
return { updatedState, closeoutNotice, applied };
|
|
162
167
|
}
|
package/dist/core/broadside.js
CHANGED
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
// executable surfaces (Pi and MCP), not the pure template. What the template does
|
|
32
32
|
// carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
|
|
33
33
|
// served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
|
|
34
|
-
import { mkdir, readFile, readdir,
|
|
34
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
35
35
|
import { execFile } from "node:child_process";
|
|
36
36
|
import { promisify } from "node:util";
|
|
37
37
|
import { join, relative } from "node:path";
|
|
38
|
-
import { pathExists, sleep } from "./utils.js";
|
|
38
|
+
import { atomicWriteFile, pathExists, sleep } from "./utils.js";
|
|
39
39
|
import { acquireLock } from "./status.js";
|
|
40
40
|
import { loadYamlFile } from "./yaml.js";
|
|
41
41
|
import { packagedWorkspaceDir } from "./workspace.js";
|
|
@@ -1279,9 +1279,7 @@ export async function saveBroadsideState(broadsideDir, state) {
|
|
|
1279
1279
|
}
|
|
1280
1280
|
/** Serialize through a temp file so a crash mid-write cannot truncate state.json. */
|
|
1281
1281
|
async function writeBroadsideStateFile(statePath, state) {
|
|
1282
|
-
|
|
1283
|
-
await writeFile(tempPath, `${JSON.stringify(state, null, "\t")}\n`, "utf8");
|
|
1284
|
-
await rename(tempPath, statePath);
|
|
1282
|
+
await atomicWriteFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
|
|
1285
1283
|
}
|
|
1286
1284
|
/**
|
|
1287
1285
|
* Read-modify-write `state.json` under a lock.
|
package/dist/core/completion.js
CHANGED
|
@@ -426,10 +426,20 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
426
426
|
nextStatus.next_actions = nextEligible
|
|
427
427
|
? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
|
|
428
428
|
: buildTerminalNextActions(nextStatus);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
429
|
+
return {
|
|
430
|
+
state: { ...nextWorkspace, status: nextStatus },
|
|
431
|
+
// The closeout, THREAD_LOG line, decision rows, and staged proposals
|
|
432
|
+
// all assert that the phase is complete, so they are written only
|
|
433
|
+
// after status.yaml has landed (#234). Each writer is idempotent
|
|
434
|
+
// (canonical closeout name, link-deduped index line, text-deduped
|
|
435
|
+
// rows), so re-running completion regenerates whatever a failure
|
|
436
|
+
// here left out.
|
|
437
|
+
afterCommit: async () => {
|
|
438
|
+
const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, lockedValidation, completionTimestamp, handoff);
|
|
439
|
+
closeoutPath = artifacts.closeoutPath;
|
|
440
|
+
orchestratorCheckpoint = buildOrchestratorCheckpoint(artifacts.decisionsAppended, artifacts.totalPendingProposals, nextStatus);
|
|
441
|
+
},
|
|
442
|
+
};
|
|
433
443
|
});
|
|
434
444
|
return {
|
|
435
445
|
updatedState,
|
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 = {
|
|
@@ -853,10 +864,7 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
|
|
|
853
864
|
lines.push("");
|
|
854
865
|
}
|
|
855
866
|
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);
|
|
867
|
+
await atomicWriteFile(join(libraryRoot, LIBRARY_INDEX_MD_FILE), content);
|
|
860
868
|
}
|
|
861
869
|
function formatIndexRow(e, namespaced) {
|
|
862
870
|
// Link to the newest version directory, not `latest/`: the pointer is a
|
|
@@ -941,10 +949,7 @@ async function findProvenanceConflict(libraryRoot, namespace, slug) {
|
|
|
941
949
|
}
|
|
942
950
|
// ─── Atomic YAML write ──────────────────────────────────────────────────────
|
|
943
951
|
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);
|
|
952
|
+
await atomicWriteFile(path, `${stringifySimpleYaml(value)}\n`);
|
|
948
953
|
}
|
|
949
954
|
// ─── Hash ───────────────────────────────────────────────────────────────────
|
|
950
955
|
function sha256(content) {
|
package/dist/core/status.d.ts
CHANGED
|
@@ -36,6 +36,26 @@ export declare function parseHandoff(value: unknown): PhaseHandoff;
|
|
|
36
36
|
export declare function ensureProposedConventionArray(value: unknown): ProposedConventionEntry[];
|
|
37
37
|
export declare function loadHandoffFile(phaseId: string, workspaceDir: string): Promise<PhaseHandoff | null>;
|
|
38
38
|
export declare function applyHandoff(status: NormalizedStatus, handoff: PhaseHandoff): NormalizedStatus;
|
|
39
|
-
|
|
39
|
+
/** What {@link acquireLock} hands back: a release that only ever removes its own lock. */
|
|
40
|
+
export interface LockHandle {
|
|
40
41
|
release: () => Promise<void>;
|
|
41
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Set when acquiring meant breaking a lock older than {@link STALE_LOCK_MS}:
|
|
44
|
+
* the previous holder as its lock file recorded it, for callers that log.
|
|
45
|
+
*/
|
|
46
|
+
brokeStale?: {
|
|
47
|
+
pid: number | null;
|
|
48
|
+
since: string | null;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
|
|
53
|
+
* and breaking a lock older than {@link STALE_LOCK_MS}.
|
|
54
|
+
*
|
|
55
|
+
* The lock file records `pid`, timestamp, and a per-acquisition token, and
|
|
56
|
+
* release removes the file only while it still carries that token. Without
|
|
57
|
+
* the token, release removed whoever's lock was there: after a stale break
|
|
58
|
+
* the previous holder's release deleted the new holder's lock, and a third
|
|
59
|
+
* writer walked straight in (#227).
|
|
60
|
+
*/
|
|
61
|
+
export declare function acquireLock(lockPath: string): Promise<LockHandle>;
|
package/dist/core/status.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Status normalization, atomic writes, and file-lock primitives. Pure
|
|
2
2
|
// framework logic shared by every wrapper.
|
|
3
|
-
import {
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { open, readFile, rm, stat } from "node:fs/promises";
|
|
4
5
|
import { basename, join } from "node:path";
|
|
5
6
|
import { pathExists, sleep } from "./utils.js";
|
|
6
7
|
import { loadYamlFile } from "./yaml.js";
|
|
@@ -397,13 +398,25 @@ export function applyHandoff(status, handoff) {
|
|
|
397
398
|
status.post_pipeline = [...legacyPostPipeline, ...postPipeline.values()];
|
|
398
399
|
return status;
|
|
399
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
|
|
403
|
+
* and breaking a lock older than {@link STALE_LOCK_MS}.
|
|
404
|
+
*
|
|
405
|
+
* The lock file records `pid`, timestamp, and a per-acquisition token, and
|
|
406
|
+
* release removes the file only while it still carries that token. Without
|
|
407
|
+
* the token, release removed whoever's lock was there: after a stale break
|
|
408
|
+
* the previous holder's release deleted the new holder's lock, and a third
|
|
409
|
+
* writer walked straight in (#227).
|
|
410
|
+
*/
|
|
400
411
|
export async function acquireLock(lockPath) {
|
|
401
412
|
const startedAt = Date.now();
|
|
413
|
+
const token = `${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
414
|
+
let brokeStale;
|
|
402
415
|
while (true) {
|
|
403
416
|
try {
|
|
404
417
|
const handle = await open(lockPath, "wx");
|
|
405
418
|
try {
|
|
406
|
-
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
419
|
+
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n${token}\n`, "utf8");
|
|
407
420
|
}
|
|
408
421
|
catch (error) {
|
|
409
422
|
// A non-EEXIST write failure must not leak the descriptor the
|
|
@@ -413,9 +426,8 @@ export async function acquireLock(lockPath) {
|
|
|
413
426
|
}
|
|
414
427
|
await handle.close();
|
|
415
428
|
return {
|
|
416
|
-
release:
|
|
417
|
-
|
|
418
|
-
},
|
|
429
|
+
release: () => releaseOwnedLock(lockPath, token),
|
|
430
|
+
...(brokeStale && { brokeStale }),
|
|
419
431
|
};
|
|
420
432
|
}
|
|
421
433
|
catch (error) {
|
|
@@ -425,6 +437,7 @@ export async function acquireLock(lockPath) {
|
|
|
425
437
|
try {
|
|
426
438
|
const lockStat = await stat(lockPath);
|
|
427
439
|
if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
|
|
440
|
+
brokeStale = await describeLockHolder(lockPath);
|
|
428
441
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
429
442
|
continue;
|
|
430
443
|
}
|
|
@@ -439,3 +452,31 @@ export async function acquireLock(lockPath) {
|
|
|
439
452
|
}
|
|
440
453
|
}
|
|
441
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Remove the lock at `lockPath` only if it is still ours. A lock that vanished
|
|
457
|
+
* (someone broke it as stale) or that now carries another holder's token is
|
|
458
|
+
* left alone; one whose content cannot be read is left to go stale rather
|
|
459
|
+
* than removed unverified.
|
|
460
|
+
*/
|
|
461
|
+
async function releaseOwnedLock(lockPath, token) {
|
|
462
|
+
let content;
|
|
463
|
+
try {
|
|
464
|
+
content = await readFile(lockPath, "utf8");
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (content.split(/\r?\n/)[2] !== token)
|
|
470
|
+
return;
|
|
471
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
472
|
+
}
|
|
473
|
+
async function describeLockHolder(lockPath) {
|
|
474
|
+
try {
|
|
475
|
+
const [pidLine, sinceLine] = (await readFile(lockPath, "utf8")).split(/\r?\n/);
|
|
476
|
+
const pid = Number.parseInt(pidLine ?? "", 10);
|
|
477
|
+
return { pid: Number.isFinite(pid) ? pid : null, since: sinceLine?.trim() || null };
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return { pid: null, since: null };
|
|
481
|
+
}
|
|
482
|
+
}
|
package/dist/core/usage.d.ts
CHANGED
|
@@ -44,6 +44,14 @@ export interface UsageTotals {
|
|
|
44
44
|
compactions: CompactionTelemetry;
|
|
45
45
|
}
|
|
46
46
|
export declare function loadUsage(workspaceDir: string): Promise<UsageFile>;
|
|
47
|
+
/**
|
|
48
|
+
* Append one run. The read-modify-write runs under the file's lock and lands
|
|
49
|
+
* through an atomic write, so concurrent appends (a Pi runner and an MCP host
|
|
50
|
+
* on one workspace, two phases finishing together) each keep their record
|
|
51
|
+
* (#226, #238). A log that exists but does not parse is never rewritten: the
|
|
52
|
+
* lenient {@link loadUsage} is for display, and appending over its empty
|
|
53
|
+
* fallback destroyed every record the file still held.
|
|
54
|
+
*/
|
|
47
55
|
export declare function appendUsageRun(workspaceDir: string, run: UsageRun): Promise<void>;
|
|
48
56
|
export declare function computeTotals(file: UsageFile): UsageTotals;
|
|
49
57
|
export declare function computePerPhaseTotals(file: UsageFile): Map<string, UsageTotals>;
|