release-skill 0.2.4 → 0.2.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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +45 -0
  7. package/INSTALL.md +32 -8
  8. package/INSTALL.zh-CN.md +28 -6
  9. package/README.md +29 -15
  10. package/README.zh-CN.md +27 -15
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +2202 -708
  14. package/adapters/claude/schemas/.render-manifest.json +6 -6
  15. package/adapters/claude/schemas/release-plan.schema.json +158 -0
  16. package/adapters/claude/schemas/release-project.schema.json +6 -0
  17. package/adapters/claude/schemas/release-run.schema.json +150 -0
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill.bundle.mjs +2202 -708
  20. package/adapters/codex/schemas/.render-manifest.json +6 -6
  21. package/adapters/codex/schemas/release-plan.schema.json +158 -0
  22. package/adapters/codex/schemas/release-project.schema.json +6 -0
  23. package/adapters/codex/schemas/release-run.schema.json +150 -0
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/release-skill.bundle.mjs +2202 -708
  26. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  27. package/adapters/kimi/schemas/release-plan.schema.json +158 -0
  28. package/adapters/kimi/schemas/release-project.schema.json +6 -0
  29. package/adapters/kimi/schemas/release-run.schema.json +150 -0
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  31. package/adapters/workbuddy/bin/release-skill.bundle.mjs +2202 -708
  32. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  33. package/adapters/workbuddy/schemas/release-plan.schema.json +158 -0
  34. package/adapters/workbuddy/schemas/release-project.schema.json +6 -0
  35. package/adapters/workbuddy/schemas/release-run.schema.json +150 -0
  36. package/bin/release-skill.bundle.mjs +2202 -708
  37. package/package.json +1 -1
  38. package/references/.render-manifest.json +4 -4
  39. package/references/02-project-config.md +28 -2
  40. package/references/05-evidence-and-errors.md +6 -0
  41. package/schemas/.render-manifest.json +6 -6
  42. package/schemas/release-plan.schema.json +158 -0
  43. package/schemas/release-project.schema.json +6 -0
  44. package/schemas/release-run.schema.json +150 -0
  45. package/scripts/sync-public-files.mjs +20 -9
  46. package/src/adapters/plugin-marketplace.mjs +82 -3
  47. package/src/commands/prepare.mjs +232 -1
  48. package/src/commands/publish.mjs +134 -0
  49. package/src/commands/reconcile.mjs +19 -0
  50. package/src/commands/setup.mjs +26 -0
  51. package/src/commands/verify.mjs +183 -1
  52. package/src/core/errors.mjs +12 -0
  53. package/src/core/plan.mjs +29 -0
  54. package/src/core/skill-resource-closure.mjs +425 -0
  55. package/src/core/source-authority.mjs +547 -0
  56. package/src/platforms/codebuddy.mjs +16 -3
  57. package/src/platforms/kimi.mjs +36 -9
@@ -33,6 +33,11 @@ import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
33
33
  import { createEvidenceWriter } from '../core/evidence.mjs';
34
34
  import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
35
35
  import { sha256Hex } from '../core/digest.mjs';
36
+ import {
37
+ CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
38
+ checkSkillResourceClosure,
39
+ createSkillResourceClosureReceipt,
40
+ } from '../core/skill-resource-closure.mjs';
36
41
  import { buildPublicStaging } from '../snapshot/public-map.mjs';
37
42
  import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
38
43
  import { scanSnapshot } from '../snapshot/scan.mjs';
@@ -44,7 +49,13 @@ import {
44
49
  normalizeGitTimestamp,
45
50
  sealFrozenSnapshot,
46
51
  } from '../snapshot/frozen.mjs';
47
- import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE } from '../core/errors.mjs';
52
+ import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT } from '../core/errors.mjs';
53
+ import {
54
+ SOURCE_INPUT_ALGORITHM_VERSION,
55
+ computeSourceInputClosure,
56
+ checkSourceInputDirty,
57
+ verifySnapshotSourcesMatchClosure,
58
+ } from '../core/source-authority.mjs';
48
59
  import { acquireProjectLock } from '../artifacts/project-lock.mjs';
49
60
  import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
50
61
  import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
@@ -1914,6 +1925,87 @@ export async function prepareRelease(options) {
1914
1925
  });
1915
1926
  }
1916
1927
 
1928
+ // --- Step 3c: Source authority content closure gate ---
1929
+ // After hooks complete, compute the deterministic source-input closure
1930
+ // and verify that closure inputs are clean (no staged/unstaged/untracked
1931
+ // changes). Production configs must declare sourceRepository.
1932
+ const sourceRepository = config.project?.sourceRepository ?? null;
1933
+ const configDefaultBranch = config.project?.defaultBranch ?? null;
1934
+
1935
+ let sourceAuthority = null;
1936
+ let sourceInputClosure = null;
1937
+ if (production) {
1938
+ if (!sourceRepository || typeof sourceRepository !== 'string') {
1939
+ throw new ReleaseError(
1940
+ CONFIG_MISSING,
1941
+ 'production prepare requires project.sourceRepository in configuration; source authority content gate needs a workspace source repository',
1942
+ { configPath },
1943
+ );
1944
+ }
1945
+
1946
+ await evidence.append({ phase: 'source-authority', status: 'started' });
1947
+
1948
+ // Compute source-input closure from resolved unit configs
1949
+ const unitConfigsForClosure = configUnits.map((unit, idx) => ({
1950
+ ...unit,
1951
+ version: { ...unit.version },
1952
+ }));
1953
+ sourceInputClosure = await computeSourceInputClosure({
1954
+ units: unitConfigsForClosure,
1955
+ root: realRoot,
1956
+ });
1957
+
1958
+ await evidence.append({
1959
+ phase: 'source-authority',
1960
+ step: 'closure-computed',
1961
+ entryCount: sourceInputClosure.entries.length,
1962
+ inputDigest: sourceInputClosure.digest,
1963
+ });
1964
+
1965
+ // Check only closure inputs for dirty (not whole workspace)
1966
+ const dirtyResult = await checkSourceInputDirty({
1967
+ closure: sourceInputClosure,
1968
+ root: realRoot,
1969
+ });
1970
+ if (dirtyResult.dirty) {
1971
+ await evidence.append({
1972
+ phase: 'source-authority',
1973
+ status: 'blocking',
1974
+ reason: 'DIRTY_SOURCE_INPUT',
1975
+ dirtyPaths: dirtyResult.dirtyPaths,
1976
+ });
1977
+ throw new ReleaseError(
1978
+ DIRTY_SOURCE_INPUT,
1979
+ `source-input closure files have uncommitted changes: ${dirtyResult.dirtyPaths.join(', ')}`,
1980
+ { dirtyPaths: dirtyResult.dirtyPaths },
1981
+ );
1982
+ }
1983
+
1984
+ await evidence.append({
1985
+ phase: 'source-authority',
1986
+ step: 'dirty-check',
1987
+ status: 'clean',
1988
+ });
1989
+
1990
+ // Build sourceAuthority binding for plan digest
1991
+ sourceAuthority = {
1992
+ sourceRepository,
1993
+ defaultBranch: configDefaultBranch,
1994
+ entries: sourceInputClosure.entries,
1995
+ inputDigest: sourceInputClosure.digest,
1996
+ algorithmVersion: SOURCE_INPUT_ALGORITHM_VERSION,
1997
+ };
1998
+
1999
+ await evidence.append({
2000
+ phase: 'source-authority',
2001
+ status: 'completed',
2002
+ sourceRepository,
2003
+ defaultBranch: configDefaultBranch,
2004
+ inputDigest: sourceInputClosure.digest,
2005
+ remoteObservation: offline ? 'unobserved-offline' : 'deferred-to-publish',
2006
+ });
2007
+ }
2008
+
1917
2009
  // --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
1918
2010
  // reflects any file changes introduced by hooks) ---
1919
2011
  await evidence.append({ phase: 'baseline', status: 'started' });
@@ -2178,6 +2270,75 @@ export async function prepareRelease(options) {
2178
2270
  gateCount: snapshotGateResults.length,
2179
2271
  });
2180
2272
 
2273
+ // Bind the remote source-authority proof to the exact bytes that entered
2274
+ // the frozen snapshots, not merely to an earlier read of the workspace.
2275
+ // Then re-read the complete closure and dirty state once more so version
2276
+ // sources and non-snapshot closure entries cannot drift during prepare.
2277
+ if (production) {
2278
+ const snapshotSourceResult = verifySnapshotSourcesMatchClosure({
2279
+ closure: sourceInputClosure,
2280
+ unitResults,
2281
+ });
2282
+ if (!snapshotSourceResult.passed) {
2283
+ throw new ReleaseError(
2284
+ DIRTY_SOURCE_INPUT,
2285
+ 'source inputs changed between closure calculation and frozen snapshot construction',
2286
+ {
2287
+ reason: 'SNAPSHOT_SOURCE_DRIFT',
2288
+ dirtyPaths: snapshotSourceResult.error.paths,
2289
+ },
2290
+ );
2291
+ }
2292
+
2293
+ const finalClosure = await computeSourceInputClosure({
2294
+ units: configUnits,
2295
+ root: realRoot,
2296
+ });
2297
+ if (finalClosure.digest !== sourceInputClosure.digest) {
2298
+ const initialByPath = new Map(
2299
+ sourceInputClosure.entries.map((entry) => [entry.path, entry]),
2300
+ );
2301
+ const finalByPath = new Map(
2302
+ finalClosure.entries.map((entry) => [entry.path, entry]),
2303
+ );
2304
+ const changedPaths = [...new Set([
2305
+ ...sourceInputClosure.entries.map((entry) => entry.path),
2306
+ ...finalClosure.entries.map((entry) => entry.path),
2307
+ ])].filter((path) => (
2308
+ JSON.stringify(initialByPath.get(path) ?? null)
2309
+ !== JSON.stringify(finalByPath.get(path) ?? null)
2310
+ )).sort();
2311
+ throw new ReleaseError(
2312
+ DIRTY_SOURCE_INPUT,
2313
+ 'source-input closure changed while preparing frozen snapshots',
2314
+ { reason: 'SOURCE_CLOSURE_DRIFT', dirtyPaths: changedPaths },
2315
+ );
2316
+ }
2317
+
2318
+ const finalDirtyResult = await checkSourceInputDirty({
2319
+ closure: finalClosure,
2320
+ root: realRoot,
2321
+ });
2322
+ if (finalDirtyResult.dirty) {
2323
+ throw new ReleaseError(
2324
+ DIRTY_SOURCE_INPUT,
2325
+ `source-input closure files have uncommitted changes after snapshot construction: ${finalDirtyResult.dirtyPaths.join(', ')}`,
2326
+ {
2327
+ reason: 'DIRTY_AFTER_SNAPSHOT',
2328
+ dirtyPaths: finalDirtyResult.dirtyPaths,
2329
+ },
2330
+ );
2331
+ }
2332
+
2333
+ await evidence.append({
2334
+ phase: 'source-authority',
2335
+ step: 'snapshot-binding',
2336
+ status: 'completed',
2337
+ inputDigest: sourceInputClosure.digest,
2338
+ snapshotSourceCount: snapshotSourceResult.observation.snapshotSourceCount,
2339
+ });
2340
+ }
2341
+
2181
2342
  // --- Step 6: Remote uniqueness (deferred to publish preflight) ---
2182
2343
  // Prepare only observes the previous public baseline (already done above).
2183
2344
  // Remote uniqueness checks (tag, GitHub Release, npm version) are deferred
@@ -2234,6 +2395,67 @@ export async function prepareRelease(options) {
2234
2395
  )
2235
2396
  : null;
2236
2397
 
2398
+ // --- Step 7b: Skill resource closure gate ---
2399
+ // Production snapshots are sealed inside buildProductionAssets. Scan only
2400
+ // after that transition so the receipt binds the exact byte/mode identity
2401
+ // publish will re-verify, rather than the writable pre-freeze staging tree.
2402
+ // Non-production plans scan the final staging tree at the same point.
2403
+ // This gate is built in, read-only, and cannot be disabled by overlays.
2404
+ const skillResourceClosureResults = [];
2405
+ for (const { unit, manifest } of unitResults) {
2406
+ await evidence.append({
2407
+ phase: 'skill-resource-closure',
2408
+ status: 'started',
2409
+ unitId: unit.id,
2410
+ });
2411
+
2412
+ const closureResult = await checkSkillResourceClosure({
2413
+ snapshotDir: manifest.outputDir,
2414
+ host: 'root',
2415
+ });
2416
+
2417
+ const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2418
+ skillResourceClosureResults.push(receipt);
2419
+
2420
+ if (closureResult.findings.length > 0) {
2421
+ await evidence.append({
2422
+ phase: 'skill-resource-closure',
2423
+ status: 'blocking',
2424
+ unitId: unit.id,
2425
+ findingCount: closureResult.findings.length,
2426
+ findings: closureResult.findings.map((f) => ({
2427
+ skill: f.skill,
2428
+ line: f.line,
2429
+ reference: f.reference,
2430
+ classification: f.classification,
2431
+ code: f.code,
2432
+ })),
2433
+ });
2434
+ throw new ReleaseError(
2435
+ GATE_FAILED,
2436
+ `skill resource closure gate failed for unit "${unit.id}": ${closureResult.findings.length} finding(s)`,
2437
+ {
2438
+ unitId: unit.id,
2439
+ findingCount: closureResult.findings.length,
2440
+ findings: closureResult.findings,
2441
+ },
2442
+ );
2443
+ }
2444
+
2445
+ await evidence.append({
2446
+ phase: 'skill-resource-closure',
2447
+ status: 'completed',
2448
+ unitId: unit.id,
2449
+ checkerVersion: closureResult.checkerVersion,
2450
+ surfaceCount: receipt.surfaceCount,
2451
+ skillCount: receipt.skillCount,
2452
+ referenceCount: closureResult.referenceCount,
2453
+ sourceOnlyCount: closureResult.sourceOnlyCount,
2454
+ findingCount: 0,
2455
+ receiptDigest: closureResult.receiptDigest,
2456
+ });
2457
+ }
2458
+
2237
2459
  // Freeze external independent marketplace HEADs (production + online only):
2238
2460
  // for each claude/codex/codebuddy distribution declaring marketplaceRepo,
2239
2461
  // resolve the external repo's HEAD sha + default branch and validate the
@@ -2581,6 +2803,14 @@ export async function prepareRelease(options) {
2581
2803
  capturedAt: baseline.capturedAt,
2582
2804
  },
2583
2805
  configDigest,
2806
+ skillResourceClosure: {
2807
+ checkerVersion: SKILL_RESOURCE_CHECKER_VERSION,
2808
+ unitReceipts: skillResourceClosureResults,
2809
+ totalSkillCount: skillResourceClosureResults.reduce((sum, item) => sum + item.skillCount, 0),
2810
+ totalReferenceCount: skillResourceClosureResults.reduce((sum, item) => sum + item.referenceCount, 0),
2811
+ totalSourceOnlyCount: skillResourceClosureResults.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
2812
+ totalFindingCount: 0,
2813
+ },
2584
2814
  verificationGates: config.verificationGates ?? [],
2585
2815
  snapshotDigest: overallSnapshotDigest,
2586
2816
  ...(production ? {
@@ -2591,6 +2821,7 @@ export async function prepareRelease(options) {
2591
2821
  } : {}),
2592
2822
  units,
2593
2823
  externalActions,
2824
+ ...(sourceAuthority ? { sourceAuthority } : {}),
2594
2825
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
2595
2826
  };
2596
2827
 
@@ -41,6 +41,12 @@ import {
41
41
  reObservePreviousPublicBaseline,
42
42
  } from '../core/previous-public-baseline.mjs';
43
43
  import { createEvidenceWriter } from '../core/evidence.mjs';
44
+ import {
45
+ CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
46
+ assertSkillResourceClosureReceipt,
47
+ checkSkillResourceClosure,
48
+ createSkillResourceClosureReceipt,
49
+ } from '../core/skill-resource-closure.mjs';
44
50
  import {
45
51
  ADAPTER_ACTION_TYPE_MAP,
46
52
  TIER_TABLE,
@@ -53,10 +59,15 @@ import { appendRunState, createProductionRunDir, writeRunAtomic, resolveDefaultR
53
59
  import {
54
60
  ReleaseError,
55
61
  GATE_FAILED,
62
+ CONFIG_MISSING,
56
63
  BASELINE_CHANGED,
57
64
  PARTIAL_RELEASE,
58
65
  CONSUMER_VERIFICATION_DEFERRED,
59
66
  } from '../core/errors.mjs';
67
+ import {
68
+ verifyRemoteSourceContent,
69
+ createSourceAuthorityReceipt,
70
+ } from '../core/source-authority.mjs';
60
71
  import { assertTransition, PUBLISHING, PUBLISHED, PARTIAL } from '../core/state-machine.mjs';
61
72
  import { matchObservation } from '../adapters/contract.mjs';
62
73
  import { observeWithRetry, clampPolicyToTimeout, DEFAULT_OBSERVE_RETRY_POLICY, isPropagatingMissing } from '../core/observe-retry.mjs';
@@ -521,6 +532,14 @@ export async function publishRelease(options) {
521
532
  if (productionMode && !isProductionPlan) {
522
533
  throw new ReleaseError(GATE_FAILED, 'production publish requires a github-npm-v1 frozen plan');
523
534
  }
535
+ if (isProductionPlan && !plan.sourceAuthority) {
536
+ throw new ReleaseError(
537
+ CONFIG_MISSING,
538
+ 'production publish requires a frozen sourceAuthority binding; re-run prepare with project.sourceRepository configured',
539
+ { gate: 'source-authority' },
540
+ );
541
+ }
542
+ const verifiedFrozenSnapshots = new Map();
524
543
  if (isProductionPlan) {
525
544
  if (!plan.production.assetRoot || plan.production.assetRoot === '.') {
526
545
  throw new ReleaseError(GATE_FAILED, 'production plan requires a dedicated assetRoot');
@@ -566,6 +585,7 @@ export async function publishRelease(options) {
566
585
  snapshotPath: frozen.path,
567
586
  expectedDigest: frozen.manifestDigest,
568
587
  });
588
+ verifiedFrozenSnapshots.set(unit.id, snapshot.snapshotDir);
569
589
  assertInsideAssetRoot(assetRoot, snapshot.snapshotDir, 'frozen snapshot');
570
590
  const git = await verifyFrozenGitRepository({
571
591
  root,
@@ -599,6 +619,60 @@ export async function publishRelease(options) {
599
619
  await evidence.append({ phase: 'safety-gate', gate: 'frozen-artifacts', status: 'passed' });
600
620
  }
601
621
 
622
+ // =======================================================================
623
+ // Safety Gate 2c: Skill resource closure recheck
624
+ // Re-run the skill resource closure check on each unit's frozen snapshot.
625
+ // Requires findingCount=0 and receipt matches the plan.
626
+ // =======================================================================
627
+ if (plan.skillResourceClosure) {
628
+ await evidence.append({ phase: 'safety-gate', gate: 'skill-resource-closure', status: 'started' });
629
+ const expectedReceipts = plan.skillResourceClosure.unitReceipts ?? [];
630
+ if (plan.skillResourceClosure.checkerVersion !== SKILL_RESOURCE_CHECKER_VERSION) {
631
+ throw new ReleaseError(
632
+ GATE_FAILED,
633
+ `skill resource closure checker version mismatch: plan=${plan.skillResourceClosure.checkerVersion}, current=${SKILL_RESOURCE_CHECKER_VERSION}`,
634
+ );
635
+ }
636
+ if (expectedReceipts.length !== plan.units.length) {
637
+ throw new ReleaseError(GATE_FAILED, 'skill resource closure receipt set does not cover every release unit');
638
+ }
639
+
640
+ for (const [unitId, snapshotDir] of verifiedFrozenSnapshots) {
641
+ const expected = expectedReceipts.find((item) => item.unitId === unitId);
642
+ if (!expected) {
643
+ throw new ReleaseError(GATE_FAILED, `skill resource closure receipt missing for unit "${unitId}"`);
644
+ }
645
+ const closureResult = await checkSkillResourceClosure({
646
+ snapshotDir,
647
+ host: 'root',
648
+ });
649
+ if (closureResult.findings.length > 0) {
650
+ await evidence.append({
651
+ phase: 'safety-gate',
652
+ gate: 'skill-resource-closure',
653
+ status: 'failed',
654
+ unitId,
655
+ findingCount: closureResult.findings.length,
656
+ });
657
+ throw new ReleaseError(
658
+ GATE_FAILED,
659
+ `skill resource closure recheck failed for unit "${unitId}": ${closureResult.findings.length} finding(s)`,
660
+ { unitId, findings: closureResult.findings },
661
+ );
662
+ }
663
+ const observed = createSkillResourceClosureReceipt(closureResult, { unitId });
664
+ assertSkillResourceClosureReceipt(expected, observed, `unit "${unitId}"`);
665
+ }
666
+
667
+ await evidence.append({
668
+ phase: 'safety-gate',
669
+ gate: 'skill-resource-closure',
670
+ status: 'passed',
671
+ receiptCount: expectedReceipts.length,
672
+ recheckedReceiptCount: verifiedFrozenSnapshots.size,
673
+ });
674
+ }
675
+
602
676
  // =======================================================================
603
677
  // Safety Gates 3-7: Load and validate approval record (shared)
604
678
  // =======================================================================
@@ -870,6 +944,65 @@ export async function publishRelease(options) {
870
944
  }
871
945
  }
872
946
 
947
+ // =======================================================================
948
+ // Safety Gate 11: Source authority content closure verification
949
+ // Verifies that the frozen source-input closure content exists in the
950
+ // workspace's remote default branch. Must pass before ANY adapter execute.
951
+ // Failure => zero adapter execute calls.
952
+ // =======================================================================
953
+ let sourceAuthorityReceipt = null;
954
+ if (plan.sourceAuthority) {
955
+ await evidence.append({ phase: 'safety-gate', gate: 'source-authority', status: 'started' });
956
+
957
+ const sa = plan.sourceAuthority;
958
+
959
+ // Verify the remote source tree against the entries frozen into the
960
+ // digest-bound plan. Publish must never rebuild authority from the
961
+ // mutable workspace or current config.
962
+ const frozenClosure = {
963
+ algorithmVersion: sa.algorithmVersion,
964
+ digest: sa.inputDigest,
965
+ entries: sa.entries,
966
+ };
967
+ const remoteResult = await verifyRemoteSourceContent({
968
+ sourceRepository: sa.sourceRepository,
969
+ defaultBranch: sa.defaultBranch,
970
+ closure: frozenClosure,
971
+ readRemoteFn: options.readRemoteSourceFn,
972
+ });
973
+
974
+ if (!remoteResult.passed) {
975
+ const errorCode = remoteResult.error?.code ?? GATE_FAILED;
976
+ await evidence.append({
977
+ phase: 'safety-gate',
978
+ gate: 'source-authority',
979
+ status: 'failed',
980
+ error: remoteResult.error,
981
+ });
982
+ throw new ReleaseError(
983
+ errorCode,
984
+ `source authority content gate failed: ${remoteResult.error?.message}`,
985
+ remoteResult.error,
986
+ );
987
+ }
988
+
989
+ // Create receipt for successful verification
990
+ sourceAuthorityReceipt = createSourceAuthorityReceipt({
991
+ plan,
992
+ result: 'CONSISTENT',
993
+ observation: remoteResult.observation,
994
+ clock: clockFn,
995
+ });
996
+
997
+ await evidence.append({
998
+ phase: 'safety-gate',
999
+ gate: 'source-authority',
1000
+ status: 'passed',
1001
+ sourceRepository: sa.sourceRepository,
1002
+ defaultBranch: sa.defaultBranch,
1003
+ });
1004
+ }
1005
+
873
1006
  // =======================================================================
874
1007
  // All safety gates passed -- prepare for execution
875
1008
  // =======================================================================
@@ -1007,6 +1140,7 @@ export async function publishRelease(options) {
1007
1140
  : {}),
1008
1141
  })),
1009
1142
  startedAt,
1143
+ ...(sourceAuthorityReceipt ? { sourceAuthorityReceipts: [sourceAuthorityReceipt] } : {}),
1010
1144
  ...(finishedAt ? { finishedAt } : {}),
1011
1145
  });
1012
1146
  let stateSequence = 0;
@@ -59,6 +59,7 @@ import {
59
59
  CONSUMER_VERIFICATION_DEFERRED,
60
60
  } from '../core/errors.mjs';
61
61
  import { assertTransition, PARTIAL, PUBLISHED, BLOCKED } from '../core/state-machine.mjs';
62
+ import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
62
63
  import { matchObservation } from '../adapters/contract.mjs';
63
64
  import { observeWithRetry, clampPolicyToTimeout, DEFAULT_OBSERVE_RETRY_POLICY } from '../core/observe-retry.mjs';
64
65
 
@@ -233,6 +234,18 @@ export async function reconcileRelease(options) {
233
234
  `reconcile source command must be publish or reconcile, got "${sourceRun.command}"`,
234
235
  );
235
236
  }
237
+ let sourceAuthorityReceipt = null;
238
+ if (plan.sourceAuthority) {
239
+ const receiptResult = verifySourceAuthorityReceipt({ plan, run: sourceRun });
240
+ if (!receiptResult.passed) {
241
+ throw new ReleaseError(
242
+ GATE_FAILED,
243
+ `reconcile source run has no valid source-authority receipt: ${receiptResult.reason}`,
244
+ { gate: 'source-authority', sourceRunId: sourceRun.runId },
245
+ );
246
+ }
247
+ sourceAuthorityReceipt = receiptResult.receipt;
248
+ }
236
249
 
237
250
  let consumedApprovalPath = sourceRun.approvalPath;
238
251
  let consumedApprovalDigest = sourceRun.approvalDigest;
@@ -956,6 +969,9 @@ export async function reconcileRelease(options) {
956
969
  sourceRunDigest: sourceAuthorityDigest,
957
970
  sourceRunPath,
958
971
  status,
972
+ ...(sourceAuthorityReceipt
973
+ ? { sourceAuthorityReceipts: [sourceAuthorityReceipt] }
974
+ : {}),
959
975
  checkpoints: planActions.map((action) => {
960
976
  const value = actionResults.get(action.id);
961
977
  const normalized = value === 'deferred' ? 'deferred'
@@ -1303,6 +1319,9 @@ export async function reconcileRelease(options) {
1303
1319
  sourceRunDigest,
1304
1320
  sourceRunPath,
1305
1321
  status: overallStatus,
1322
+ ...(sourceAuthorityReceipt
1323
+ ? { sourceAuthorityReceipts: [sourceAuthorityReceipt] }
1324
+ : {}),
1306
1325
  checkpoints: planActions.map((a) => {
1307
1326
  const status = actionResults.get(a.id) ?? 'pending';
1308
1327
  const normalized = status === 'deferred' ? 'deferred'
@@ -976,6 +976,21 @@ function buildRecommendedProposal(facts, candidates) {
976
976
  const legacyOwner = facts.legacyReleaseConfigs
977
977
  .map((c) => c.owner)
978
978
  .find(Boolean);
979
+ const sourceRepositoryCandidates = [...new Set(
980
+ facts.git.remotes.map((remote) => remote.repo).filter(Boolean),
981
+ )].sort();
982
+ if (sourceRepositoryCandidates.length !== 1) {
983
+ return {
984
+ answers: null,
985
+ conflicts: [{
986
+ code: sourceRepositoryCandidates.length === 0
987
+ ? 'SOURCE_REPOSITORY_MISSING'
988
+ : 'SOURCE_REPOSITORY_AMBIGUOUS',
989
+ candidates: sourceRepositoryCandidates,
990
+ }],
991
+ assumptions,
992
+ };
993
+ }
979
994
 
980
995
  const units = [];
981
996
  for (const unit of candidates.units) {
@@ -1156,6 +1171,7 @@ function buildRecommendedProposal(facts, candidates) {
1156
1171
  project: {
1157
1172
  name: facts.packages[0]?.name ?? 'project',
1158
1173
  defaultBranch: facts.legacyReleaseConfigs[0]?.defaultBranch ?? facts.git.branch ?? 'main',
1174
+ sourceRepository: sourceRepositoryCandidates[0],
1159
1175
  },
1160
1176
  releaseUnits: units,
1161
1177
  ...(selectedGateIds.length > 0 ? {
@@ -1517,6 +1533,9 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
1517
1533
 
1518
1534
  const facts = await discoverFacts(rootReal);
1519
1535
  const candidates = buildCandidates(facts);
1536
+ const sourceRepositoryCandidates = [...new Set(
1537
+ facts.git.remotes.map((remote) => remote.repo).filter(Boolean),
1538
+ )].sort();
1520
1539
  let answers = null;
1521
1540
  if (answersPath) {
1522
1541
  const resolvedAnswers = isAbsolute(answersPath) ? answersPath : resolve(rootReal, answersPath);
@@ -1527,6 +1546,7 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
1527
1546
  const digestAuthority = {
1528
1547
  setupVersion: 1,
1529
1548
  facts,
1549
+ sourceRepositoryCandidates,
1530
1550
  releaseUnitCandidates: candidates.units,
1531
1551
  gateCandidates: candidates.gates,
1532
1552
  selectedGateIds,
@@ -1599,6 +1619,9 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
1599
1619
  const lockedAuthority = {
1600
1620
  setupVersion: 1,
1601
1621
  facts: lockedFacts,
1622
+ sourceRepositoryCandidates: [...new Set(
1623
+ lockedFacts.git.remotes.map((remote) => remote.repo).filter(Boolean),
1624
+ )].sort(),
1602
1625
  releaseUnitCandidates: lockedCandidates.units,
1603
1626
  gateCandidates: lockedCandidates.gates,
1604
1627
  selectedGateIds: lockedAnswers.selectedGateIds,
@@ -1622,6 +1645,9 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
1622
1645
  const finalAuthority = {
1623
1646
  setupVersion: 1,
1624
1647
  facts: finalFacts,
1648
+ sourceRepositoryCandidates: [...new Set(
1649
+ finalFacts.git.remotes.map((remote) => remote.repo).filter(Boolean),
1650
+ )].sort(),
1625
1651
  releaseUnitCandidates: finalCandidates.units,
1626
1652
  gateCandidates: finalCandidates.gates,
1627
1653
  selectedGateIds: finalAnswers.selectedGateIds,