@bridge_gpt/mcp-server 0.2.66 → 0.2.67

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 (100) hide show
  1. package/README.md +252 -77
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +43 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/commands.generated.js +11 -11
  8. package/build/conductor/bridge-api-client.js +143 -1
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/doctor.js +3 -3
  11. package/build/conductor/epic-implementer-cli.js +22 -6
  12. package/build/conductor/errors.js +4 -3
  13. package/build/conductor/guided-repair.js +6 -2
  14. package/build/conductor/hook-runtime-qualification.js +1 -1
  15. package/build/conductor/merge-execution-contract.js +27 -8
  16. package/build/conductor/merge-execution-eligibility.js +3 -2
  17. package/build/conductor/pr-ci-producer.js +1 -1
  18. package/build/conductor/readiness-cli.js +18 -2
  19. package/build/conductor/readiness-sections.js +41 -6
  20. package/build/conductor/readiness.js +181 -29
  21. package/build/conductor/recovery-cli.js +200 -8
  22. package/build/conductor/recovery-operations.js +69 -3
  23. package/build/conductor/repair-contract.js +54 -3
  24. package/build/conductor/repository-workflow-remedy.js +125 -0
  25. package/build/conductor/tools.js +2 -2
  26. package/build/conductor-bundle-artifacts.js +8 -0
  27. package/build/docs.generated.js +8 -3
  28. package/build/drive-epic-key.js +6 -5
  29. package/build/drive-epic.js +85 -54
  30. package/build/epic-coherence-input.js +2 -1
  31. package/build/epic-implementer-bin.js +22 -20
  32. package/build/epic-implementer-bundle-cli.js +2 -2
  33. package/build/executor/cli.js +3 -2
  34. package/build/executor/completion-contract.js +48 -5
  35. package/build/executor/completion-supervision.js +4 -0
  36. package/build/executor/env.js +19 -0
  37. package/build/executor/heartbeat.js +1 -1
  38. package/build/executor/http-client.js +20 -0
  39. package/build/executor/job-errors.js +25 -0
  40. package/build/executor/job-runner.js +225 -41
  41. package/build/executor/observation.js +6 -1
  42. package/build/executor/preflight.js +51 -14
  43. package/build/executor/required-work-ledger.js +6 -1
  44. package/build/executor/resume-pre-spawn.js +2 -0
  45. package/build/executor/runner.js +8 -2
  46. package/build/executor/service-unit.js +7 -1
  47. package/build/executor/worker-finalization.js +82 -29
  48. package/build/executor/worker-guard-hook.js +137 -33
  49. package/build/executor/worktree.js +82 -1
  50. package/build/index.js +403 -429
  51. package/build/install-bridge-conductor.js +8 -6
  52. package/build/install-bridge.js +3 -3
  53. package/build/phase-context-git-probe.js +68 -0
  54. package/build/phase-result-artifacts.js +173 -5
  55. package/build/pipelines.generated.js +18 -24
  56. package/build/plane/alembic-head.js +12 -3
  57. package/build/plane/base-worktree-guard.js +147 -0
  58. package/build/plane/cli.js +217 -17
  59. package/build/plane/code-snapshot.js +468 -0
  60. package/build/plane/defaults.js +48 -3
  61. package/build/plane/endpoint.js +41 -0
  62. package/build/plane/manifest.js +120 -1
  63. package/build/plane/member-roster.js +94 -19
  64. package/build/plane/preflight.js +30 -2
  65. package/build/plane/process-cwd.js +67 -0
  66. package/build/plane/restart.js +542 -0
  67. package/build/plane/shutdown.js +31 -0
  68. package/build/plane/status.js +143 -8
  69. package/build/plane/supervisor.js +586 -179
  70. package/build/plane/types.js +54 -2
  71. package/build/printed-remedy-commands.js +37 -0
  72. package/build/readiness-check.js +1 -1
  73. package/build/readme.generated.js +1 -1
  74. package/build/review-generation.js +22 -0
  75. package/build/setup-epic.js +44 -19
  76. package/build/sfcc/config.js +9 -0
  77. package/build/sfcc/credentials.js +100 -38
  78. package/build/sfcc/hostname.js +67 -0
  79. package/build/sfcc/permissions.js +17 -13
  80. package/build/sfcc/register.js +1 -1
  81. package/build/sfcc/tool-wrapper.js +39 -43
  82. package/build/sfcc/write-grants.js +17 -11
  83. package/build/sfcc/write-result.js +4 -3
  84. package/build/sfcc/writes-site-preference.js +20 -8
  85. package/build/start-tickets.js +179 -31
  86. package/build/tool-error-envelope.js +61 -0
  87. package/build/version.generated.js +3 -3
  88. package/build/visual-diff.js +22 -1
  89. package/build/worker-guard-hook-bin.js +3 -6
  90. package/build/worker-guard-hook-main.js +47 -0
  91. package/build/worktree-core.js +130 -4
  92. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  93. package/docs/EPIC-IMPLEMENTER.md +662 -0
  94. package/docs/install/mcp-tool-integrations.md +24 -15
  95. package/docs/install/sfcc-integration.md +12 -4
  96. package/package.json +1 -1
  97. package/pipelines/plan-epic.json +0 -5
  98. package/pipelines/review-ticket.json +1 -1
  99. package/docs/CONDUCTOR.md +0 -605
  100. package/pipelines/idea-to-pr.json +0 -49
@@ -106,6 +106,7 @@
106
106
  * The command does NOT persist a repository-level review policy: that is
107
107
  * BAPI-694's. The policy selected here is per-run and is reported as such.
108
108
  */
109
+ import { PRINTED_INSTALL_COMMAND } from "./printed-remedy-commands.js";
109
110
  import { bootstrapConductorSupervisorDefaults, classifySupervisorBootstrapFailure, fetchConductorReadiness, previewConductorSupervisorBootstrap, } from "./conductor/bridge-api-client.js";
110
111
  import { CONDUCTOR_OPERATOR_RUNBOOK_POINTER, EXECUTOR_PROVISIONING_GUIDANCE, conductorInstallDoctorExitCode, conductorInstallDoctorHasFatal, formatConductorInstallDoctorReport, } from "./conductor/install-doctor.js";
111
112
  import { describeNativeLedgerAvailability, } from "./conductor/doctor.js";
@@ -138,7 +139,7 @@ export function getInstallBridgeConductorUsage() {
138
139
  return [
139
140
  "Usage: install conductor [options]",
140
141
  "",
141
- "Bootstrap Epic Conductor v2 for this repository: run the unified read-only",
142
+ "Bootstrap the Epic Implementer for this repository: run the unified read-only",
142
143
  "doctor, write the safe project-default supervisor posture, optionally scaffold",
143
144
  "the claude-review workflow, report the capability matrix, and re-run the doctor.",
144
145
  "",
@@ -183,9 +184,10 @@ export function getInstallBridgeConductorUsage() {
183
184
  " approves one named server and has no default.",
184
185
  " --execution-mode <mode> local | durable (default: durable with",
185
186
  " --executor-id, otherwise local).",
186
- " --policy-file <path> Run policy the profile references; an unattended",
187
- " run needs notify.webhook_url reachable from the",
188
- " API server (notify.local_sink is not enough).",
187
+ " --policy-file <path> Run policy the profile references. An unattended",
188
+ " run escalates to a notify.webhook_url reachable",
189
+ " from the API server or, with none declared, to the",
190
+ " account's email (notify.local_sink is not enough).",
189
191
  " --dry-run Diagnose and preview only. Performs NO writes:",
190
192
  " no POST, no file write, no host MCP config write,",
191
193
  " no Git hook write, no install-state or capability",
@@ -208,7 +210,7 @@ export function getInstallBridgeConductorUsage() {
208
210
  "nor changed. No file is ever created: when no supported config carries a",
209
211
  "a Bridge entry, the run says so and points at the base `install` command.",
210
212
  "The MCP server captures its active groups at startup, so a merge activates on",
211
- "the next launch — restart your MCP client to see the conductor tools.",
213
+ "the next launch — restart your MCP client to see the Epic Implementer tools.",
212
214
  "",
213
215
  "Local observability (a SEPARATE consent): installs or updates this",
214
216
  "repository's managed conductor Git hooks (post-commit,",
@@ -939,7 +941,7 @@ export function formatConductorRestartAdvisory(mergedProfile) {
939
941
  "client to gain access to the Epic Implementer tools.");
940
942
  }
941
943
  /** Remediation printed when no supported host config carries a Bridge entry. */
942
- export const CONDUCTOR_PROFILE_EMPTY_MERGE_REMEDIATION = `run \`npx ${MCP_PACKAGE_NAME} install\` to create and register the host MCP entry, ` +
944
+ export const CONDUCTOR_PROFILE_EMPTY_MERGE_REMEDIATION = `run \`${PRINTED_INSTALL_COMMAND}\` to create and register the host MCP entry, ` +
943
945
  "then re-run `install conductor`.";
944
946
  /** Remediation printed whenever the managed conductor hooks were not installed. */
945
947
  export const CONDUCTOR_HOOKS_MANUAL_REMEDIATION = "re-run `install conductor` and accept the local-observability consent, or run " +
@@ -171,7 +171,7 @@ import { runInstallBridgeConductorCli, } from "./install-bridge-conductor.js";
171
171
  // BAPI-1136: the merge-workflow generate-and-PR installer. Domain logic lives in
172
172
  // its own module; this file supplies the parser and the process seams.
173
173
  import { runMergeWorkflowInstall, } from "./merge-workflow-install.js";
174
- import { MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
174
+ import { MERGE_WORKFLOW_CODEOWNER_INSTRUCTION, MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
175
175
  import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, } from "./merge-workflow-config.js";
176
176
  import { runConductorInstallDoctor, EPIC_IMPLEMENTER_PROFILE_TOKEN, } from "./conductor/install-doctor.js";
177
177
  import { resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
@@ -564,7 +564,7 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
564
564
  " -h, --help Show this help.",
565
565
  "",
566
566
  "Subcommands:",
567
- " install conductor Bootstrap Epic Conductor v2 for this repository",
567
+ " install conductor Bootstrap the Epic Implementer for this repository",
568
568
  " (BAPI-679). Runs a unified READ-ONLY doctor that",
569
569
  " composes the conductor, executor-preflight,",
570
570
  " install-status, and executor service-unit",
@@ -1548,7 +1548,7 @@ export function parseMergeWorkflowInstallArgs(argv) {
1548
1548
  /** Usage text for the merge-workflow installer. */
1549
1549
  export function getMergeWorkflowInstallUsage() {
1550
1550
  return [
1551
- `Usage: ${MERGE_WORKFLOW_REMEDY_COMMAND} --codeowner <@org/team|@user> [--dry-run]`,
1551
+ `Usage: ${MERGE_WORKFLOW_REMEDY_COMMAND} [--dry-run]\n${MERGE_WORKFLOW_CODEOWNER_INSTRUCTION}`,
1552
1552
  "",
1553
1553
  "Generate the Epic Implementer merge workflow plus its CODEOWNERS fragment and",
1554
1554
  "repository-rule guidance, commit them on a managed branch, push with your own",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * BAPI-1363 — the bounded git probe behind `get_phase_context`.
3
+ *
4
+ * `phase-result-artifacts.ts` is filesystem-only by contract, so the question "is
5
+ * the recorded checkpoint actually in this worktree's history?" is answered here
6
+ * and passed to it as plain values. Every call is shell-free, rooted at the
7
+ * server-resolved project root, and bounded by a short timeout.
8
+ *
9
+ * Nothing in this module throws on a git problem. A failed probe degrades to
10
+ * `"unknown"` with a closed-vocabulary reason, which the artifact layer treats as
11
+ * "not evidence", so work is redone rather than trusted. Raw stderr, command text,
12
+ * and paths never leave this module.
13
+ */
14
+ import { execFile } from "child_process";
15
+ /** Bound on each local git call. */
16
+ export const PHASE_CONTEXT_GIT_TIMEOUT_MS = 5000;
17
+ const FULL_HEX_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
18
+ /** Build a runner over `execFileImpl` (the real `execFile` unless a test injects one). */
19
+ export function createPhaseContextGitRunner(execFileImpl = execFile) {
20
+ return (cwd, args) => new Promise((resolve, reject) => {
21
+ execFileImpl("git", args, { cwd, shell: false, timeout: PHASE_CONTEXT_GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
22
+ if (err)
23
+ reject(err);
24
+ else
25
+ resolve(String(stdout));
26
+ });
27
+ });
28
+ }
29
+ /** Closed-vocabulary classification of a failed git call. Raw error text never escapes. */
30
+ function classifyGitFailure(err) {
31
+ const code = err?.code;
32
+ return code === "ENOENT" ? "git_unavailable" : "probe_failed";
33
+ }
34
+ /**
35
+ * Capture HEAD once, then — only when a checkpoint SHA was recorded — probe its
36
+ * ancestry against that same captured HEAD.
37
+ *
38
+ * Only git's ordinary not-ancestor exit (status 1) is `false`. Anything else that
39
+ * goes wrong is `"unknown"`, never a guess.
40
+ */
41
+ export async function evaluateCheckpointReachability(options) {
42
+ const run = options.run ?? createPhaseContextGitRunner();
43
+ let head;
44
+ try {
45
+ head = (await run(options.root, ["rev-parse", "HEAD"])).trim();
46
+ }
47
+ catch (err) {
48
+ const reason = classifyGitFailure(err);
49
+ return { reachable: "unknown", reason: reason === "git_unavailable" ? reason : "head_unavailable" };
50
+ }
51
+ if (!FULL_HEX_SHA.test(head))
52
+ return { reachable: "unknown", reason: "head_unavailable" };
53
+ if (options.checkpointSha === undefined) {
54
+ return { head, reachable: "unknown", reason: "not_evaluated" };
55
+ }
56
+ if (!FULL_HEX_SHA.test(options.checkpointSha)) {
57
+ return { head, reachable: "unknown", reason: "invalid_sha" };
58
+ }
59
+ try {
60
+ await run(options.root, ["merge-base", "--is-ancestor", options.checkpointSha, head]);
61
+ return { head, reachable: true };
62
+ }
63
+ catch (err) {
64
+ if (err?.code === 1)
65
+ return { head, reachable: false };
66
+ return { head, reachable: "unknown", reason: classifyGitFailure(err) };
67
+ }
68
+ }
@@ -59,7 +59,7 @@ export const PHASE_RESULT_ARTIFACT_VERSION = 1;
59
59
  * it reaches the filesystem, rather than being sanitized into something that
60
60
  * silently addresses a different ticket's artifact.
61
61
  */
62
- const TICKET_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
62
+ const TICKET_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
63
63
  /** Terminal dispositions that mean "this step is done; do not redo it". */
64
64
  const REENTRY_TERMINAL_DISPOSITIONS = new Set([
65
65
  "executed",
@@ -74,6 +74,24 @@ const REENTRY_TERMINAL_DISPOSITIONS = new Set([
74
74
  */
75
75
  export const INLINE_ROUTING_SOURCE = "inline:get_plan_metadata+phase_result_artifacts";
76
76
  // ---------------------------------------------------------------------------
77
+ // Shapes
78
+ // ---------------------------------------------------------------------------
79
+ /**
80
+ * Why a checkpoint is not trusted as commit evidence. Closed vocabulary: these
81
+ * tokens are the only diagnostics a response may carry about a failed probe, so
82
+ * no raw git output, path, or credential can ride along.
83
+ */
84
+ export const CHECKPOINT_STALE_REASONS = [
85
+ "not_ancestor",
86
+ "git_unavailable",
87
+ "invalid_sha",
88
+ "probe_failed",
89
+ "head_unavailable",
90
+ "checkpoint_changed",
91
+ "coverage_mismatch",
92
+ "not_evaluated",
93
+ ];
94
+ // ---------------------------------------------------------------------------
77
95
  // Path safety
78
96
  // ---------------------------------------------------------------------------
79
97
  function assertValidTicketKey(ticketKey) {
@@ -265,6 +283,9 @@ export async function loadInlinePhaseContext(options) {
265
283
  const fingerprint = planFingerprint(metadata);
266
284
  let ledger = [];
267
285
  const artifactPaths = [];
286
+ // Latest record index per produce step, within the produce artifact's own list.
287
+ const produceLatestIndex = new Map();
288
+ let produceRecordCount = 0;
268
289
  // Canonical phase order, not directory order: the ledger is chronological and
269
290
  // production always precedes the phases that verify it.
270
291
  for (const priorPhase of PLAN_PHASES) {
@@ -277,21 +298,72 @@ export async function loadInlinePhaseContext(options) {
277
298
  // Re-check against the CURRENT metadata: an artifact that was valid when
278
299
  // written must still describe steps this phase owns under the plan in play now.
279
300
  const routable = assertStoredRecordsRoutable(artifact.records, metadata, priorPhase, relativePath);
301
+ if (priorPhase === "produce") {
302
+ produceRecordCount = routable.length;
303
+ routable.forEach((record, index) => produceLatestIndex.set(record.stepId, index));
304
+ }
280
305
  ledger = [...ledger, ...sanitizePlanStepRecords(routable)];
281
306
  artifactPaths.push(relativePath);
282
307
  }
283
308
  const checkpoint = await readInlineCheckpoint(docsDir, ticketKey);
284
309
  if (checkpoint)
285
310
  artifactPaths.push(checkpointRelativePath(ticketKey));
311
+ // Reachability is decided by the caller's git probe. Anything other than a
312
+ // confirmed `true` fails toward redo, and a checkpoint that changed between the
313
+ // probe and this load is not the one that was probed.
314
+ let reachable = options.checkpointReachable ?? "unknown";
315
+ let staleReason;
316
+ if (reachable === "unknown") {
317
+ staleReason = options.checkpointUnknownReason ?? "not_evaluated";
318
+ }
319
+ else if (reachable === false) {
320
+ staleReason = "not_ancestor";
321
+ }
322
+ if (checkpoint &&
323
+ reachable !== "unknown" &&
324
+ options.probedCheckpoint !== undefined &&
325
+ !sameCheckpoint(options.probedCheckpoint, checkpoint)) {
326
+ reachable = "unknown";
327
+ staleReason = "checkpoint_changed";
328
+ }
329
+ // A checkpoint covers the produce prefix only when its binding is intact.
330
+ const coverageIntact = checkpoint !== undefined &&
331
+ checkpoint.producedRecordCount > 0 &&
332
+ checkpoint.producedRecordCount <= produceRecordCount &&
333
+ checkpoint.producePlanFingerprint === fingerprint;
334
+ const coveredCount = checkpoint && reachable === true && coverageIntact
335
+ ? checkpoint.producedRecordCount
336
+ : 0;
337
+ if (checkpoint &&
338
+ reachable === true &&
339
+ checkpoint.producedRecordCount > 0 &&
340
+ !coverageIntact) {
341
+ staleReason = "coverage_mismatch";
342
+ }
286
343
  const current = currentLedgerState(ledger);
287
344
  const currentRecordsByStep = {};
288
345
  const terminalStepIds = [];
346
+ const uncommittedStepIds = [];
289
347
  for (const [stepId, record] of current) {
290
348
  currentRecordsByStep[stepId] = record;
291
- if (REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition))
349
+ if (!REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition))
350
+ continue;
351
+ const producedAt = produceLatestIndex.get(stepId);
352
+ if (producedAt !== undefined && producedAt >= coveredCount) {
353
+ // Latest produce record is not inside the committed prefix: the work it
354
+ // describes is not evidenced at the evaluated HEAD.
355
+ uncommittedStepIds.push(stepId);
356
+ }
357
+ else {
292
358
  terminalStepIds.push(stepId);
359
+ }
293
360
  }
294
361
  terminalStepIds.sort((a, b) => a - b);
362
+ uncommittedStepIds.sort((a, b) => a - b);
363
+ const checkpointIsEvidence = checkpoint !== undefined && reachable === true && staleReason === undefined;
364
+ const ledgerNote = uncommittedStepIds.length > 0
365
+ ? "Produce steps listed in uncommittedStepIds are recorded as done but no reachable checkpoint covers them, so redo them (checking for equivalent existing work first) and do not report them as already done."
366
+ : "";
295
367
  return {
296
368
  phase,
297
369
  routingSource: INLINE_ROUTING_SOURCE,
@@ -306,12 +378,40 @@ export async function loadInlinePhaseContext(options) {
306
378
  ledger,
307
379
  currentRecordsByStep,
308
380
  terminalStepIds,
381
+ uncommittedStepIds,
382
+ ledgerNote,
309
383
  unresolved: unresolvedLedgerRecords(ledger),
310
- ...(checkpoint ? { checkpoint } : {}),
384
+ ...(options.evaluatedHead ? { evaluatedHead: options.evaluatedHead } : {}),
385
+ ...(checkpoint && checkpointIsEvidence
386
+ ? { checkpoint: { branch: checkpoint.branch, sha: checkpoint.sha } }
387
+ : {}),
388
+ ...(checkpoint && !checkpointIsEvidence
389
+ ? {
390
+ staleCheckpoint: {
391
+ branch: checkpoint.branch,
392
+ sha: checkpoint.sha,
393
+ reason: staleReason ?? "not_evaluated",
394
+ },
395
+ }
396
+ : {}),
311
397
  artifactPaths,
312
398
  };
313
399
  }
314
- async function readInlineCheckpoint(docsDir, ticketKey) {
400
+ function sameCheckpoint(a, b) {
401
+ return (a.branch === b.branch &&
402
+ a.sha === b.sha &&
403
+ a.producedRecordCount === b.producedRecordCount &&
404
+ a.producePlanFingerprint === b.producePlanFingerprint);
405
+ }
406
+ /**
407
+ * Read and structurally validate the checkpoint artifact.
408
+ *
409
+ * Filesystem only. A legacy checkpoint with no coverage fields reads as covering
410
+ * zero produce records; it is never upgraded by inferring coverage from the
411
+ * current ledger. Malformed coverage, or a mis-versioned artifact, fails closed.
412
+ */
413
+ export async function readInlineCheckpoint(docsDir, ticketKey) {
414
+ ticketKey = assertValidTicketKey(ticketKey);
315
415
  const relativePath = checkpointRelativePath(ticketKey);
316
416
  const absolutePath = resolveWithinRoot(docsDir, relativePath);
317
417
  const parsed = await readJsonArtifact(absolutePath, relativePath);
@@ -333,7 +433,69 @@ async function readInlineCheckpoint(docsDir, ticketKey) {
333
433
  artifact.sha.trim() === "") {
334
434
  throw new PlanLedgerError(`${relativePath} is missing its branch or sha`);
335
435
  }
336
- return { branch: artifact.branch, sha: artifact.sha };
436
+ const hasCount = artifact.produce_record_count !== undefined;
437
+ const fingerprintValue = artifact.produce_plan_fingerprint;
438
+ if (!hasCount) {
439
+ if (fingerprintValue !== undefined && fingerprintValue !== null) {
440
+ throw new PlanLedgerError(`${relativePath} has a produce fingerprint but no record count`);
441
+ }
442
+ return {
443
+ branch: artifact.branch,
444
+ sha: artifact.sha,
445
+ producedRecordCount: 0,
446
+ producePlanFingerprint: null,
447
+ };
448
+ }
449
+ const count = artifact.produce_record_count;
450
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0) {
451
+ throw new PlanLedgerError(`${relativePath} has an invalid produce_record_count`);
452
+ }
453
+ if (count > 0 && (typeof fingerprintValue !== "string" || fingerprintValue === "")) {
454
+ throw new PlanLedgerError(`${relativePath} covers produce records but has no valid produce_plan_fingerprint`);
455
+ }
456
+ if (count === 0 &&
457
+ fingerprintValue !== undefined &&
458
+ fingerprintValue !== null &&
459
+ typeof fingerprintValue !== "string") {
460
+ throw new PlanLedgerError(`${relativePath} has an invalid produce_plan_fingerprint`);
461
+ }
462
+ return {
463
+ branch: artifact.branch,
464
+ sha: artifact.sha,
465
+ producedRecordCount: count,
466
+ producePlanFingerprint: typeof fingerprintValue === "string" && fingerprintValue !== "" ? fingerprintValue : null,
467
+ };
468
+ }
469
+ /**
470
+ * How much of the produce artifact a checkpoint written now would cover.
471
+ *
472
+ * Structural validation only, with no plan metadata fetch: the count and the
473
+ * artifact's stored fingerprint are read as written. An absent artifact covers
474
+ * zero records, and nothing is created to calculate that.
475
+ */
476
+ async function readProduceCoverage(docsDir, ticketKey) {
477
+ const relativePath = phaseResultRelativePath(ticketKey, "produce");
478
+ const absolutePath = resolveWithinRoot(docsDir, relativePath);
479
+ const parsed = await readJsonArtifact(absolutePath, relativePath);
480
+ if (parsed === undefined)
481
+ return { count: 0, fingerprint: null };
482
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
483
+ throw new PlanLedgerError(`${relativePath} is not a phase-result artifact object`);
484
+ }
485
+ const artifact = parsed;
486
+ if (artifact.artifact_version !== PHASE_RESULT_ARTIFACT_VERSION) {
487
+ throw new PlanLedgerError(`${relativePath} has unsupported artifact_version ${String(artifact.artifact_version)}; expected ${PHASE_RESULT_ARTIFACT_VERSION}`);
488
+ }
489
+ if (artifact.ticket_key !== ticketKey || artifact.phase !== "produce") {
490
+ throw new PlanLedgerError(`${relativePath} does not describe the produce phase of '${ticketKey}'`);
491
+ }
492
+ if (typeof artifact.plan_fingerprint !== "string" || artifact.plan_fingerprint === "") {
493
+ throw new PlanLedgerError(`${relativePath} has no plan_fingerprint`);
494
+ }
495
+ if (!Array.isArray(artifact.records)) {
496
+ throw new PlanLedgerError(`${relativePath} has no records array`);
497
+ }
498
+ return { count: artifact.records.length, fingerprint: artifact.plan_fingerprint };
337
499
  }
338
500
  // ---------------------------------------------------------------------------
339
501
  // Writing
@@ -435,6 +597,10 @@ export async function writeInlineCheckpoint(options) {
435
597
  if (checkpoint.pushed !== true || checkpoint.remoteMatchesHead !== true) {
436
598
  throw new PlanLedgerError("checkpoint must report pushed and remoteMatchesHead true; a checkpoint that is not on origin is not a checkpoint");
437
599
  }
600
+ // Coverage is derived here from the produce artifact as it stands, never taken
601
+ // from the caller: the push facts above are the caller's claim, but what the
602
+ // push covers is a fact about the ledger this module owns.
603
+ const coverage = await readProduceCoverage(docsDir, ticketKey);
438
604
  const relativePath = checkpointRelativePath(ticketKey);
439
605
  const absolutePath = resolveWithinRoot(docsDir, relativePath);
440
606
  const artifact = {
@@ -444,6 +610,8 @@ export async function writeInlineCheckpoint(options) {
444
610
  sha,
445
611
  pushed: true,
446
612
  remote_matches_head: true,
613
+ produce_record_count: coverage.count,
614
+ produce_plan_fingerprint: coverage.fingerprint,
447
615
  };
448
616
  await writeAtomically(absolutePath, relativePath, artifact);
449
617
  return { branch, sha, artifactPath: relativePath };