codecartographer-pi 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.codecarto/broadside/SKILL.md +20 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +5 -4
  4. package/agent-skill/codecartographer/references/broadside.md +5 -1
  5. package/dist/core/broadside/client.d.ts +56 -0
  6. package/dist/core/broadside/client.js +200 -0
  7. package/dist/core/broadside/collect.d.ts +68 -0
  8. package/dist/core/broadside/collect.js +676 -0
  9. package/dist/core/broadside/constants.d.ts +51 -0
  10. package/dist/core/broadside/constants.js +74 -0
  11. package/dist/core/broadside/lenses.d.ts +31 -0
  12. package/dist/core/broadside/lenses.js +312 -0
  13. package/dist/core/broadside/models.d.ts +46 -0
  14. package/dist/core/broadside/models.js +321 -0
  15. package/dist/core/broadside/render.d.ts +20 -0
  16. package/dist/core/broadside/render.js +285 -0
  17. package/dist/core/broadside/repo.d.ts +58 -0
  18. package/dist/core/broadside/repo.js +592 -0
  19. package/dist/core/broadside/requests.d.ts +23 -0
  20. package/dist/core/broadside/requests.js +71 -0
  21. package/dist/core/broadside/results.d.ts +36 -0
  22. package/dist/core/broadside/results.js +163 -0
  23. package/dist/core/broadside/schemas.d.ts +2 -0
  24. package/dist/core/broadside/schemas.js +342 -0
  25. package/dist/core/broadside/state.d.ts +99 -0
  26. package/dist/core/broadside/state.js +384 -0
  27. package/dist/core/broadside/submit.d.ts +30 -0
  28. package/dist/core/broadside/submit.js +350 -0
  29. package/dist/core/broadside/types.d.ts +491 -0
  30. package/dist/core/broadside/types.js +107 -0
  31. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  32. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  33. package/dist/core/broadside.d.ts +14 -890
  34. package/dist/core/broadside.js +25 -3564
  35. package/dist/core/completion.js +91 -72
  36. package/dist/core/dashboard-writer.js +9 -1
  37. package/dist/core/index.d.ts +0 -1
  38. package/dist/core/index.js +0 -1
  39. package/dist/core/library.d.ts +24 -1
  40. package/dist/core/library.js +46 -15
  41. package/dist/core/orchestrator-config.js +22 -8
  42. package/dist/core/status.d.ts +42 -23
  43. package/dist/core/status.js +163 -137
  44. package/dist/core/workspace.d.ts +2 -0
  45. package/dist/core/workspace.js +49 -25
  46. package/dist/core/yaml.js +9 -3
  47. package/dist/extensions/codecarto/auto-runner.d.ts +7 -0
  48. package/dist/extensions/codecarto/auto-runner.js +54 -23
  49. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  50. package/dist/extensions/codecarto/broadside-flags.js +13 -0
  51. package/dist/extensions/codecarto/index.js +13 -7
  52. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  53. package/dist/mcp-server/server.d.ts +1 -0
  54. package/dist/mcp-server/server.js +28 -5
  55. package/package.json +1 -1
@@ -280,6 +280,91 @@ function mentionsAnyId(text, ids) {
280
280
  }
281
281
  return false;
282
282
  }
283
+ /**
284
+ * The handoff gates that depend on the state they are judged against —
285
+ * carry_forward targets against the pipeline order, D1 against the open
286
+ * questions and derives_from links, D3 against question kinds. Run once
287
+ * before the lock as the cheap refusal and again on the locked read: a
288
+ * status change between the two (a concurrent completion, an amendment, a
289
+ * rollback) used to be applied over as if the first verdict still held
290
+ * (#360; the same shape as #337 for amendments). Returns the warnings the
291
+ * version-gated D3 produces on an older scaffold.
292
+ */
293
+ function judgeHandoffAgainstState(state, handoff, validation) {
294
+ const warnings = [];
295
+ if (handoff) {
296
+ const activePhases = new Set(state.pipeline.phase_order);
297
+ const sourceIndex = state.pipeline.phase_order.indexOf(validation.phaseId);
298
+ for (const entry of handoff.carry_forward) {
299
+ const targetIndex = entry.target_phase ? state.pipeline.phase_order.indexOf(entry.target_phase) : -1;
300
+ if (!entry.target_phase || !activePhases.has(entry.target_phase) || targetIndex <= sourceIndex) {
301
+ throw new Error(`Invalid handoff: carry_forward target_phase ${entry.target_phase ?? "(missing)"} is not a downstream active pipeline phase; use post_pipeline for work after the pipeline`);
302
+ }
303
+ }
304
+ for (const entry of handoff.post_pipeline) {
305
+ if (!entry.id?.trim())
306
+ throw new Error("Invalid handoff: post_pipeline entries require a canonical id");
307
+ }
308
+ // Closure integrity, gating (#122, #186). Both checks are deterministic
309
+ // reads of ids the model wrote itself, so neither can wedge an --auto run
310
+ // on a heuristic; both sit here, before the lock, alongside the
311
+ // target_phase check, so a refusal mutates nothing.
312
+ const questionsById = new Map();
313
+ const derivesFromById = new Map();
314
+ for (const phaseState of Object.values(state.status.phases)) {
315
+ for (const entry of phaseState.open_questions ?? []) {
316
+ if (entry.id)
317
+ questionsById.set(entry.id, entry);
318
+ }
319
+ for (const entry of phaseState.carry_forward ?? []) {
320
+ if (entry.id && entry.derives_from)
321
+ derivesFromById.set(entry.id, entry.derives_from);
322
+ }
323
+ }
324
+ const closingQuestionIds = new Set(handoff.open_question_closures.map((closure) => closure.id).filter(Boolean));
325
+ // D1: a routed item that declares `derives_from` is one candidate answer
326
+ // to that question. Closing it while the question stands is exactly the
327
+ // contradiction #122 reported — the routed candidate shipped as settled
328
+ // while the question that said "source alone cannot determine which" was
329
+ // still open. A derives_from naming an id that no longer exists is fine:
330
+ // the question was already resolved.
331
+ for (const closureId of handoff.carry_forward_closures) {
332
+ const questionId = derivesFromById.get(closureId);
333
+ if (!questionId || !questionsById.has(questionId))
334
+ continue;
335
+ if (closingQuestionIds.has(questionId))
336
+ continue;
337
+ throw new Error(`Refusing to complete ${validation.phaseId}: the handoff closes carry_forward ${closureId}, which derives_from open question ${questionId} — and ${questionId} is still unresolved and is not in this handoff's open_question_closures. `
338
+ + `A routed item is one candidate answer to the question it came from; closing it does not settle the question. `
339
+ + `Either close ${questionId} in this same handoff with the evidence that settles it, or leave ${closureId} routed and give the finding an unsettled action ("verify at runtime") instead.`);
340
+ }
341
+ // D3: a `needs-runtime-test` question closes on runtime evidence, not on
342
+ // another source read. Requiring the evidence string to be non-empty is
343
+ // the whole gate — judging what it says stays prose guidance.
344
+ //
345
+ // Unlike D1, this one can fire on a handoff that uses none of the new
346
+ // fields: a bare-string closure was the only shape before this release.
347
+ // Gating it unconditionally would apply a rule to workspaces whose own
348
+ // templates never state it, so it is version-gated exactly like Stage
349
+ // 2's pairing check — refuse on a scaffold that documents the rule, warn
350
+ // on one that predates it.
351
+ const evidenceGateActive = closureEvidenceGateActive(state.scaffoldVersion);
352
+ for (const closure of handoff.open_question_closures) {
353
+ if (questionsById.get(closure.id)?.kind !== "needs-runtime-test")
354
+ continue;
355
+ if (closure.evidence?.trim())
356
+ continue;
357
+ const detail = `open_question_closures closes ${closure.id}, whose kind is needs-runtime-test, without evidence. `
358
+ + `A runtime question closes on runtime evidence — a spike report or an observation against the running system — not on a source read. `
359
+ + `Write the closure as an object: { id: ${closure.id}, evidence: <where that evidence lives> }. If you do not have it, leave the question open.`;
360
+ if (evidenceGateActive)
361
+ throw new Error(`Refusing to complete ${validation.phaseId}: ${detail}`);
362
+ warnings.push(`${detail} Warning only: this workspace's scaffold predates the requirement — refresh it `
363
+ + `(codecarto_refresh_scaffold on MCP, /codecarto-refresh-scaffold on Pi) to make this gating.`);
364
+ }
365
+ }
366
+ return warnings;
367
+ }
283
368
  /**
284
369
  * Turn each PARTIAL validation row into a `needs-maintainer-decision`
285
370
  * question on the phase, unless the row's criterion or evidence cell names an
@@ -354,78 +439,10 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
354
439
  + `plus closeout_summary and optional closeout_content. Then re-run completion.`);
355
440
  }
356
441
  }
442
+ // The cheap refusal, before the lock; the verdict that counts is taken
443
+ // again on the locked read below.
444
+ let stateWarnings = judgeHandoffAgainstState(initialState, handoff, validation);
357
445
  const warnings = [];
358
- if (handoff) {
359
- const activePhases = new Set(initialState.pipeline.phase_order);
360
- const sourceIndex = initialState.pipeline.phase_order.indexOf(validation.phaseId);
361
- for (const entry of handoff.carry_forward) {
362
- const targetIndex = entry.target_phase ? initialState.pipeline.phase_order.indexOf(entry.target_phase) : -1;
363
- if (!entry.target_phase || !activePhases.has(entry.target_phase) || targetIndex <= sourceIndex) {
364
- throw new Error(`Invalid handoff: carry_forward target_phase ${entry.target_phase ?? "(missing)"} is not a downstream active pipeline phase; use post_pipeline for work after the pipeline`);
365
- }
366
- }
367
- for (const entry of handoff.post_pipeline) {
368
- if (!entry.id?.trim())
369
- throw new Error("Invalid handoff: post_pipeline entries require a canonical id");
370
- }
371
- // Closure integrity, gating (#122, #186). Both checks are deterministic
372
- // reads of ids the model wrote itself, so neither can wedge an --auto run
373
- // on a heuristic; both sit here, before the lock, alongside the
374
- // target_phase check, so a refusal mutates nothing.
375
- const questionsById = new Map();
376
- const derivesFromById = new Map();
377
- for (const phaseState of Object.values(initialState.status.phases)) {
378
- for (const entry of phaseState.open_questions ?? []) {
379
- if (entry.id)
380
- questionsById.set(entry.id, entry);
381
- }
382
- for (const entry of phaseState.carry_forward ?? []) {
383
- if (entry.id && entry.derives_from)
384
- derivesFromById.set(entry.id, entry.derives_from);
385
- }
386
- }
387
- const closingQuestionIds = new Set(handoff.open_question_closures.map((closure) => closure.id).filter(Boolean));
388
- // D1: a routed item that declares `derives_from` is one candidate answer
389
- // to that question. Closing it while the question stands is exactly the
390
- // contradiction #122 reported — the routed candidate shipped as settled
391
- // while the question that said "source alone cannot determine which" was
392
- // still open. A derives_from naming an id that no longer exists is fine:
393
- // the question was already resolved.
394
- for (const closureId of handoff.carry_forward_closures) {
395
- const questionId = derivesFromById.get(closureId);
396
- if (!questionId || !questionsById.has(questionId))
397
- continue;
398
- if (closingQuestionIds.has(questionId))
399
- continue;
400
- throw new Error(`Refusing to complete ${validation.phaseId}: the handoff closes carry_forward ${closureId}, which derives_from open question ${questionId} — and ${questionId} is still unresolved and is not in this handoff's open_question_closures. `
401
- + `A routed item is one candidate answer to the question it came from; closing it does not settle the question. `
402
- + `Either close ${questionId} in this same handoff with the evidence that settles it, or leave ${closureId} routed and give the finding an unsettled action ("verify at runtime") instead.`);
403
- }
404
- // D3: a `needs-runtime-test` question closes on runtime evidence, not on
405
- // another source read. Requiring the evidence string to be non-empty is
406
- // the whole gate — judging what it says stays prose guidance.
407
- //
408
- // Unlike D1, this one can fire on a handoff that uses none of the new
409
- // fields: a bare-string closure was the only shape before this release.
410
- // Gating it unconditionally would apply a rule to workspaces whose own
411
- // templates never state it, so it is version-gated exactly like Stage
412
- // 2's pairing check — refuse on a scaffold that documents the rule, warn
413
- // on one that predates it.
414
- const evidenceGateActive = closureEvidenceGateActive(initialState.scaffoldVersion);
415
- for (const closure of handoff.open_question_closures) {
416
- if (questionsById.get(closure.id)?.kind !== "needs-runtime-test")
417
- continue;
418
- if (closure.evidence?.trim())
419
- continue;
420
- const detail = `open_question_closures closes ${closure.id}, whose kind is needs-runtime-test, without evidence. `
421
- + `A runtime question closes on runtime evidence — a spike report or an observation against the running system — not on a source read. `
422
- + `Write the closure as an object: { id: ${closure.id}, evidence: <where that evidence lives> }. If you do not have it, leave the question open.`;
423
- if (evidenceGateActive)
424
- throw new Error(`Refusing to complete ${validation.phaseId}: ${detail}`);
425
- warnings.push(`${detail} Warning only: this workspace's scaffold predates the requirement — refresh it `
426
- + `(codecarto_refresh_scaffold on MCP, /codecarto-refresh-scaffold on Pi) to make this gating.`);
427
- }
428
- }
429
446
  // Closure integrity (#122, warning only): a handoff can close a carry-forward
430
447
  // or open question the report never addressed — "closed in the handoff,
431
448
  // resolved nowhere." The id of every claimed closure should appear somewhere
@@ -444,6 +461,8 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
444
461
  let closeoutPath;
445
462
  let orchestratorCheckpoint;
446
463
  const updatedState = await updateStatusAtomically(cwd, async (lockedState) => {
464
+ // The verdict that counts is the one on the state about to be written.
465
+ stateWarnings = judgeHandoffAgainstState(lockedState, handoff, validation);
447
466
  const phase = resolvePhase(lockedState, validation.phaseId);
448
467
  if (!phase?.primary_output)
449
468
  throw new Error(`Phase ${validation.phaseId} is missing primary_output.`);
@@ -510,6 +529,6 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
510
529
  updatedState,
511
530
  closeoutNotice: closeoutPath ? `Closeout: ${closeoutPath}` : undefined,
512
531
  orchestratorCheckpoint,
513
- warnings,
532
+ warnings: [...stateWarnings, ...warnings],
514
533
  };
515
534
  }
@@ -9,7 +9,15 @@
9
9
  // a core primitive like everything else they share.
10
10
  import { readdir, readFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
- import { atomicWriteFile, DASHBOARD_RELATIVE_PATH, getWorkspaceState, loadUsage, NARRATION_CACHE_RELATIVE_PATH, parseSimpleYaml, pathExists, renderDashboard, } from "./index.js";
12
+ // From the modules that define them, not the barrel: `core/index.ts`
13
+ // re-exports this file, and a module that imports the barrel that exports it
14
+ // is a cycle that only resolves because every binding is read at call time
15
+ // (#371).
16
+ import { DASHBOARD_RELATIVE_PATH, NARRATION_CACHE_RELATIVE_PATH, renderDashboard } from "./dashboard.js";
17
+ import { loadUsage } from "./usage.js";
18
+ import { atomicWriteFile, pathExists } from "./utils.js";
19
+ import { getWorkspaceState } from "./workspace.js";
20
+ import { parseSimpleYaml } from "./yaml.js";
13
21
  const CLOSEOUT_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/;
14
22
  /**
15
23
  * Render and atomically replace `.codecarto/dashboard.html`.
@@ -16,6 +16,5 @@ export * from "./dashboard.ts";
16
16
  export * from "./library.ts";
17
17
  export * from "./synthesis.ts";
18
18
  export * from "./broadside.ts";
19
- export * from "./broadside-verify.ts";
20
19
  export * from "./secrets.ts";
21
20
  export * from "./dashboard-writer.ts";
@@ -19,6 +19,5 @@ export * from "./dashboard.js";
19
19
  export * from "./library.js";
20
20
  export * from "./synthesis.js";
21
21
  export * from "./broadside.js";
22
- export * from "./broadside-verify.js";
23
22
  export * from "./secrets.js";
24
23
  export * from "./dashboard-writer.js";
@@ -271,8 +271,31 @@ export interface ListEntriesFilter {
271
271
  slug?: string;
272
272
  source_repo?: string;
273
273
  }
274
+ /** How `listEntries` found the index it answered from (#357). */
275
+ export type LibraryIndexState = "indexed" | "missing" | "unparseable";
274
276
  export declare function listEntries(libraryRoot: string, filter?: ListEntriesFilter): Promise<LibraryIndexEntry[]>;
275
- export declare function reindex(libraryRoot: string): Promise<ReindexResult>;
277
+ /**
278
+ * The entries plus where they came from. A list is read-only: when
279
+ * `index.yaml` is missing or does not parse, the entries are built from the
280
+ * entry directories in memory and `indexState` says so, so a caller can ask
281
+ * for a reindex — the list itself never writes the index, which is the
282
+ * publisher's under the publish lock (#357; it used to rewrite a corrupt
283
+ * index unlocked, racing a publish for the same file).
284
+ */
285
+ export declare function listEntriesWithIndexState(libraryRoot: string, filter?: ListEntriesFilter): Promise<{
286
+ entries: LibraryIndexEntry[];
287
+ indexState: LibraryIndexState;
288
+ }>;
289
+ /**
290
+ * Rebuild `index.yaml` and `INDEX.md` from the entry directories. Every
291
+ * writer of the index takes the publish lock: a reindex that scanned the
292
+ * directories before a publish's rename and wrote after the publish's own
293
+ * reindex used to drop the new version from the index until the next
294
+ * reindex (#357). `holdingLock` is for the publisher, which already has it.
295
+ */
296
+ export declare function reindex(libraryRoot: string, opts?: {
297
+ holdingLock?: boolean;
298
+ }): Promise<ReindexResult>;
276
299
  /**
277
300
  * Read-only check over the given entries: does every version of each entry
278
301
  * record the same repository as its newest version? Comparison goes through
@@ -443,7 +443,7 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
443
443
  const metadata = buildMetadata({ ...input, provenance }, latestVersion);
444
444
  await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
445
445
  if (!opts.skipReindex)
446
- await reindex(libraryRoot);
446
+ await reindex(libraryRoot, { holdingLock: true });
447
447
  return {
448
448
  slug: input.slug,
449
449
  namespace,
@@ -479,7 +479,7 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
479
479
  }
480
480
  await writeLatestPointer(entryDir, `v${nextVersion}`);
481
481
  if (!opts.skipReindex)
482
- await reindex(libraryRoot);
482
+ await reindex(libraryRoot, { holdingLock: true });
483
483
  return {
484
484
  slug: input.slug,
485
485
  namespace,
@@ -653,25 +653,38 @@ function isReasoning(v) {
653
653
  return v === "high" || v === "medium" || v === "low" || v === "default" || v === "unknown";
654
654
  }
655
655
  export async function listEntries(libraryRoot, filter = {}) {
656
+ return (await listEntriesWithIndexState(libraryRoot, filter)).entries;
657
+ }
658
+ /**
659
+ * The entries plus where they came from. A list is read-only: when
660
+ * `index.yaml` is missing or does not parse, the entries are built from the
661
+ * entry directories in memory and `indexState` says so, so a caller can ask
662
+ * for a reindex — the list itself never writes the index, which is the
663
+ * publisher's under the publish lock (#357; it used to rewrite a corrupt
664
+ * index unlocked, racing a publish for the same file).
665
+ */
666
+ export async function listEntriesWithIndexState(libraryRoot, filter = {}) {
656
667
  const marker = await readMarker(libraryRoot);
657
668
  if (!marker)
658
- return [];
659
- // Prefer the index if it's present; fall back to a fresh reindex if not.
669
+ return { entries: [], indexState: "missing" };
660
670
  const indexPath = join(libraryRoot, LIBRARY_INDEX_FILE);
661
671
  let index;
672
+ let indexState = "indexed";
662
673
  if (await pathExists(indexPath)) {
663
674
  try {
664
675
  const raw = await readFile(indexPath, "utf8");
665
676
  index = normalizeIndex(parseSimpleYaml(raw), marker);
666
677
  }
667
678
  catch {
668
- index = await reindex(libraryRoot);
679
+ index = await scanLibrary(libraryRoot, marker);
680
+ indexState = "unparseable";
669
681
  }
670
682
  }
671
683
  else {
672
- index = await reindex(libraryRoot);
684
+ index = await scanLibrary(libraryRoot, marker);
685
+ indexState = "missing";
673
686
  }
674
- return index.entries.filter((e) => {
687
+ const entries = index.entries.filter((e) => {
675
688
  if (filter.namespace !== undefined && e.namespace !== filter.namespace)
676
689
  return false;
677
690
  if (filter.slug !== undefined && e.slug !== filter.slug)
@@ -685,13 +698,37 @@ export async function listEntries(libraryRoot, filter = {}) {
685
698
  return false;
686
699
  return true;
687
700
  });
701
+ return { entries, indexState };
688
702
  }
689
703
  // ─── Reindex ────────────────────────────────────────────────────────────────
690
- export async function reindex(libraryRoot) {
704
+ /**
705
+ * Rebuild `index.yaml` and `INDEX.md` from the entry directories. Every
706
+ * writer of the index takes the publish lock: a reindex that scanned the
707
+ * directories before a publish's rename and wrote after the publish's own
708
+ * reindex used to drop the new version from the index until the next
709
+ * reindex (#357). `holdingLock` is for the publisher, which already has it.
710
+ */
711
+ export async function reindex(libraryRoot, opts = {}) {
691
712
  const marker = await readMarker(libraryRoot);
692
713
  if (!marker) {
693
714
  throw new Error(`Not a CodeCartographer library: ${LIBRARY_MARKER_FILE} missing at ${libraryRoot}`);
694
715
  }
716
+ const lock = opts.holdingLock ? null : await acquireLock(join(libraryRoot, PUBLISH_LOCK_FILE));
717
+ try {
718
+ const index = await scanLibrary(libraryRoot, marker);
719
+ await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
720
+ await writeIndexMarkdown(libraryRoot, index, marker);
721
+ // Reported, not written. The index files above are ABI, so the conflict
722
+ // list travels on the return value only (see ReindexResult).
723
+ const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, index.entries);
724
+ return { ...index, provenance_conflicts };
725
+ }
726
+ finally {
727
+ await lock?.release();
728
+ }
729
+ }
730
+ /** The index as the entry directories would have it — a read, nothing written. */
731
+ async function scanLibrary(libraryRoot, marker) {
695
732
  const entries = [];
696
733
  const namespacesSeen = new Set();
697
734
  const entriesRoot = join(libraryRoot, ENTRIES_DIR);
@@ -737,7 +774,7 @@ export async function reindex(libraryRoot) {
737
774
  return nsA < nsB ? -1 : 1;
738
775
  return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
739
776
  });
740
- const index = {
777
+ return {
741
778
  schema_version: INDEX_SCHEMA_VERSION,
742
779
  library_name: marker.name,
743
780
  generated_at: new Date().toISOString(),
@@ -745,12 +782,6 @@ export async function reindex(libraryRoot) {
745
782
  namespaces: [...namespacesSeen].sort(),
746
783
  entries,
747
784
  };
748
- await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
749
- await writeIndexMarkdown(libraryRoot, index, marker);
750
- // Reported, not written. The index files above are ABI, so the conflict
751
- // list travels on the return value only (see ReindexResult).
752
- const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, entries);
753
- return { ...index, provenance_conflicts };
754
785
  }
755
786
  async function buildIndexEntry(libraryRoot, namespace, slug) {
756
787
  const entryDir = entryRoot(libraryRoot, namespace, slug);
@@ -21,8 +21,9 @@
21
21
  // launch directory.
22
22
  import { homedir } from "node:os";
23
23
  import { dirname, isAbsolute, join, resolve } from "node:path";
24
- import { mkdir, readFile, writeFile } from "node:fs/promises";
25
- import { expandTilde, pathExists } from "./utils.js";
24
+ import { mkdir, readFile } from "node:fs/promises";
25
+ import { atomicWriteFile, expandTilde, pathExists } from "./utils.js";
26
+ import { acquireLock } from "./status.js";
26
27
  import { loadYamlFile, parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
27
28
  export const CONFIG_RELATIVE_PATH = "workflow/config.yaml";
28
29
  export const USER_CONFIG_DIR = join(homedir(), ".codecarto");
@@ -187,6 +188,24 @@ function cloneDefault() {
187
188
  * never overwritten: the caller is told to fix it first.
188
189
  */
189
190
  export async function writeLibraryConfig(configPath, libraryPath, namespace = null) {
191
+ // dirname() honors the platform separator; the previous hand-rolled
192
+ // `includes("/")` check treated every Windows path as a bare filename
193
+ // and left mkdir a no-op before the writeFile ENOENT'd (#128).
194
+ const dir = dirname(configPath);
195
+ await mkdir(dir, { recursive: true });
196
+ // A read-modify-write of a file every workspace on the machine reads:
197
+ // under a lock beside it, and landed atomically, so two library-inits
198
+ // (two hosts, or MCP and Pi) cannot lose each other's change and a crash
199
+ // mid-write cannot truncate it (#361).
200
+ const lock = await acquireLock(`${configPath}.lock`);
201
+ try {
202
+ await writeLibraryConfigLocked(configPath, libraryPath, namespace);
203
+ }
204
+ finally {
205
+ await lock.release();
206
+ }
207
+ }
208
+ async function writeLibraryConfigLocked(configPath, libraryPath, namespace) {
190
209
  let existing = {};
191
210
  if (await pathExists(configPath)) {
192
211
  const raw = await readFile(configPath, "utf8");
@@ -213,10 +232,5 @@ export async function writeLibraryConfig(configPath, libraryPath, namespace = nu
213
232
  if (namespace)
214
233
  library.namespace = namespace;
215
234
  const updated = { ...existing, library };
216
- // dirname() honors the platform separator; the previous hand-rolled
217
- // `includes("/")` check treated every Windows path as a bare filename
218
- // and left mkdir a no-op before the writeFile ENOENT'd (#128).
219
- const dir = dirname(configPath);
220
- await mkdir(dir, { recursive: true });
221
- await writeFile(configPath, `${stringifySimpleYaml(updated)}\n`, "utf8");
235
+ await atomicWriteFile(configPath, `${stringifySimpleYaml(updated)}\n`);
222
236
  }
@@ -1,13 +1,12 @@
1
1
  import type { ClosureEntry, NormalizedStatus, OpenQuestionEntry, PostPipelineEntry, PhaseHandoff, PipelineFile, ProposedConventionEntry, StatusFile, StatusPhase } from "./types.ts";
2
2
  export declare const LOCK_RETRY_MS = 125;
3
3
  export declare const LOCK_TIMEOUT_MS = 5000;
4
- export declare const STALE_LOCK_MS = 60000;
5
4
  /**
6
- * How old the removal lock (`<lock>.break`, see {@link withRemovalLock}) may
7
- * be before it is treated as left behind by a crashed process. It is held
8
- * across one stat and one rm, so anything this old was abandoned.
5
+ * How long a lock ticket may go unrefreshed before its owner is presumed
6
+ * hung. Owners refresh every quarter of this while they wait or hold, so a
7
+ * live holder is never broken by age; a dead one is removed at once.
9
8
  */
10
- export declare const BREAK_LOCK_STALE_MS = 5000;
9
+ export declare const STALE_LOCK_MS = 60000;
11
10
  export declare function assertSafePhaseId(phaseId: string): void;
12
11
  /**
13
12
  * A YAML scalar as text: strings as written, numbers and booleans spelled
@@ -58,34 +57,54 @@ export declare function parseHandoff(value: unknown): PhaseHandoff;
58
57
  export declare function ensureProposedConventionArray(value: unknown): ProposedConventionEntry[];
59
58
  export declare function loadHandoffFile(phaseId: string, workspaceDir: string): Promise<PhaseHandoff | null>;
60
59
  export declare function applyHandoff(status: NormalizedStatus, handoff: PhaseHandoff): NormalizedStatus;
61
- /** What {@link acquireLock} hands back: a release that only ever removes its own lock. */
60
+ /** What {@link acquireLock} hands back: a release that only ever removes its own ticket. */
62
61
  export interface LockHandle {
63
62
  release: () => Promise<void>;
64
63
  /**
65
- * Set when acquiring meant breaking a lock older than {@link STALE_LOCK_MS}:
66
- * the previous holder as its lock file recorded it, for callers that log.
64
+ * Set when acquiring meant removing a ticket whose holder was dead or had
65
+ * stopped refreshing it for {@link STALE_LOCK_MS}: the previous holder as
66
+ * its ticket recorded it, for callers that log.
67
67
  */
68
68
  brokeStale?: {
69
69
  pid: number | null;
70
70
  since: string | null;
71
71
  };
72
72
  }
73
+ export interface AcquireLockOptions {
74
+ /** How long to wait for the lock; default {@link LOCK_TIMEOUT_MS}. */
75
+ timeoutMs?: number;
76
+ /**
77
+ * How long a ticket may go unrefreshed before its holder is presumed
78
+ * hung; default {@link STALE_LOCK_MS}. A dead holder is removed at once.
79
+ */
80
+ staleMs?: number;
81
+ }
73
82
  /**
74
- * Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
75
- * and breaking a lock older than {@link STALE_LOCK_MS}.
83
+ * Take the lock named by `lockPath`, waiting up to `timeoutMs`.
84
+ *
85
+ * The lock is a queue of **tickets**: files beside `lockPath` named
86
+ * `<lock>.t.<order>-<pid>-<token>`, one per waiter, each written by its
87
+ * owner alone. The holder is the owner of the first ticket in name order
88
+ * whose process is alive and whose ticket has been refreshed within
89
+ * `staleMs`; every owner refreshes its ticket on a timer while it waits and
90
+ * while it holds, so a live holder is never broken however long it holds
91
+ * (#355 — a publish across a full reindex used to lose its lock at 60 s).
92
+ * A ticket whose owner is dead, or has not refreshed it in `staleMs`, is
93
+ * removed by whoever notices — by its own unique name, so two waiters
94
+ * removing the same dead ticket remove the same inode and nothing else.
95
+ * No shared path is ever removed and re-created, which is the race every
96
+ * `rm`-then-recreate stale break carries (#342, #344, #355).
76
97
  *
77
- * The lock file records `pid`, timestamp, and a per-acquisition token, and
78
- * release removes the file only while it still carries that token. Without
79
- * the token, release removed whoever's lock was there: after a stale break
80
- * the previous holder's release deleted the new holder's lock, and a third
81
- * writer walked straight in (#227).
98
+ * Ordering follows Lamport's bakery: a waiter announces it is *choosing*
99
+ * (`<lock>.c.<token>`), takes a number one larger than any ticket it can
100
+ * see, writes its ticket, and withdraws the marker; nobody concludes it
101
+ * holds the lock while a marker it does not own exists — so a waiter that
102
+ * took its number but has not yet written its ticket cannot be overtaken.
103
+ * Two tickets with the same number (chosen at the same moment) break the
104
+ * tie on the rest of the name, which every observer sorts the same way.
82
105
  *
83
- * Every removal — a release or a stale break — happens under the removal
84
- * lock (`<lock>.break`) and re-checks what it is about to remove there.
85
- * Two waiters that both saw a stale lock used to both `rm` it: the second
86
- * `rm` landed after the first waiter had re-created the file, so both held
87
- * the lock (#342). A file can only be created while the path is free, and
88
- * only a removal-lock holder removes, so what a holder verified is what it
89
- * removes.
106
+ * A plain `lockPath` file left by a pre-#355 process is honoured while it is
107
+ * younger than `staleMs` and removed once it is not, so an upgrade under a
108
+ * live older process does not let two writers in.
90
109
  */
91
- export declare function acquireLock(lockPath: string): Promise<LockHandle>;
110
+ export declare function acquireLock(lockPath: string, options?: AcquireLockOptions): Promise<LockHandle>;