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.
@@ -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
@@ -3,4 +3,4 @@
3
3
  # workspace's framework-owned files (GUIDE.md, templates/, workflow/ pipelines
4
4
  # and VALIDATE.md) predate the running release. Written at release time and
5
5
  # copied verbatim by init — never edit by hand.
6
- scaffold_version: 0.19.5
6
+ scaffold_version: 0.19.6
@@ -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
- // Amendment closeout + THREAD_LOG entry, same idempotence rule as
138
- // completion: the closeout link appears in THREAD_LOG at most once.
139
- const closeoutFile = `${dateOnly(timestamp)}-amendment-${amendment.slug}.md`;
140
- const closeoutsDir = join(lockedState.workspaceDir, "closeouts");
141
- await mkdir(closeoutsDir, { recursive: true });
142
- const body = amendment.closeout_content.trim() || renderAmendmentCloseout(amendment, applied, timestamp);
143
- await writeFile(join(closeoutsDir, closeoutFile), `${body}\n`, "utf8");
144
- const summary = amendment.closeout_summary.trim()
145
- || `Amendment applied: ${applied.openQuestionsClosed.length} open question(s) and ${applied.postPipelineClosed.length} post-pipeline item(s) closed.`;
146
- const entry = `- ${dateOnly(timestamp)} — amendment:${amendment.slug} — ${summary} — [closeout](closeouts/${closeoutFile})`;
147
- const threadLogPath = join(lockedState.workspaceDir, "THREAD_LOG.md");
148
- let current = "";
149
- try {
150
- current = await readFile(threadLogPath, "utf8");
151
- }
152
- catch {
153
- // Created below when absent.
154
- }
155
- if (!current.split(/\r?\n/).some((line) => line.includes(`[closeout](closeouts/${closeoutFile})`))) {
156
- await appendFile(threadLogPath, `${newlineIfUnterminated(current)}${entry}\n`, "utf8");
157
- }
158
- closeoutNotice = `Closeout: .codecarto/closeouts/${closeoutFile}`;
159
- return { state: { ...lockedState, status: nextStatus } };
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
  }
@@ -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, rename, stat, writeFile } from "node:fs/promises";
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
- const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
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.
@@ -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
- 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 } };
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,
@@ -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 = {
@@ -853,10 +864,7 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
853
864
  lines.push("");
854
865
  }
855
866
  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);
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
- 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);
952
+ await atomicWriteFile(path, `${stringifySimpleYaml(value)}\n`);
948
953
  }
949
954
  // ─── Hash ───────────────────────────────────────────────────────────────────
950
955
  function sha256(content) {
@@ -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
- export declare function acquireLock(lockPath: string): Promise<{
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>;
@@ -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 { open, rm, stat } from "node:fs/promises";
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: async () => {
417
- await rm(lockPath, { force: true }).catch(() => undefined);
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
+ }
@@ -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>;