release-skill 0.8.0 → 0.9.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 (144) 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 +65 -7
  7. package/INSTALL.md +14 -2
  8. package/INSTALL.zh-CN.md +12 -2
  9. package/README.md +51 -31
  10. package/README.zh-CN.md +33 -20
  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/error-codes.json +1 -1
  14. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  15. package/adapters/claude/bin/prebuild-manifest.json +8 -4
  16. package/adapters/claude/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  17. package/adapters/claude/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  18. package/adapters/claude/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  19. package/adapters/claude/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  20. package/adapters/claude/bin/registry.json +22 -1
  21. package/adapters/claude/bin/release-skill-local-finish.mjs +4 -0
  22. package/adapters/claude/bin/release-skill.bundle.mjs +7152 -4231
  23. package/adapters/claude/bin/rules.json +1 -1
  24. package/adapters/claude/schemas/release-plan.schema.json +43 -5
  25. package/adapters/claude/schemas/release-run.schema.json +35 -1
  26. package/adapters/claude/skills/release-finish/SKILL.md +65 -0
  27. package/adapters/claude/skills/release-verify/SKILL.md +6 -1
  28. package/adapters/claude/src/schemas/filesystem-tree-observation.schema.json +55 -0
  29. package/adapters/claude/src/schemas/host-verification-result.schema.json +2 -2
  30. package/adapters/claude/src/schemas/plugin-verification-request.schema.json +374 -0
  31. package/adapters/claude/src/schemas/plugin-verification-result.schema.json +799 -0
  32. package/adapters/claude/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/error-codes.json +1 -1
  35. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  36. package/adapters/codex/bin/prebuild-manifest.json +8 -4
  37. package/adapters/codex/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  38. package/adapters/codex/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  39. package/adapters/codex/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  40. package/adapters/codex/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  41. package/adapters/codex/bin/registry.json +22 -1
  42. package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
  43. package/adapters/codex/bin/release-skill.bundle.mjs +7152 -4231
  44. package/adapters/codex/bin/rules.json +1 -1
  45. package/adapters/codex/schemas/release-plan.schema.json +43 -5
  46. package/adapters/codex/schemas/release-run.schema.json +35 -1
  47. package/adapters/codex/skills/release-finish/SKILL.md +72 -0
  48. package/adapters/codex/skills/release-verify/SKILL.md +6 -1
  49. package/adapters/codex/src/schemas/filesystem-tree-observation.schema.json +55 -0
  50. package/adapters/codex/src/schemas/host-verification-result.schema.json +2 -2
  51. package/adapters/codex/src/schemas/plugin-verification-request.schema.json +374 -0
  52. package/adapters/codex/src/schemas/plugin-verification-result.schema.json +799 -0
  53. package/adapters/codex/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  54. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  55. package/adapters/kimi/bin/error-codes.json +1 -1
  56. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  57. package/adapters/kimi/bin/prebuild-manifest.json +8 -4
  58. package/adapters/kimi/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  59. package/adapters/kimi/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  60. package/adapters/kimi/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  61. package/adapters/kimi/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  62. package/adapters/kimi/bin/registry.json +22 -1
  63. package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
  64. package/adapters/kimi/bin/release-skill.bundle.mjs +7152 -4231
  65. package/adapters/kimi/bin/rules.json +1 -1
  66. package/adapters/kimi/schemas/release-plan.schema.json +43 -5
  67. package/adapters/kimi/schemas/release-run.schema.json +35 -1
  68. package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
  69. package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
  70. package/adapters/kimi/src/schemas/filesystem-tree-observation.schema.json +55 -0
  71. package/adapters/kimi/src/schemas/host-verification-result.schema.json +2 -2
  72. package/adapters/kimi/src/schemas/plugin-verification-request.schema.json +374 -0
  73. package/adapters/kimi/src/schemas/plugin-verification-result.schema.json +799 -0
  74. package/adapters/kimi/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  75. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  76. package/adapters/workbuddy/bin/error-codes.json +1 -1
  77. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  78. package/adapters/workbuddy/bin/prebuild-manifest.json +8 -4
  79. package/adapters/workbuddy/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  80. package/adapters/workbuddy/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  81. package/adapters/workbuddy/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  82. package/adapters/workbuddy/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  83. package/adapters/workbuddy/bin/registry.json +22 -1
  84. package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
  85. package/adapters/workbuddy/bin/release-skill.bundle.mjs +7152 -4231
  86. package/adapters/workbuddy/bin/rules.json +1 -1
  87. package/adapters/workbuddy/schemas/release-plan.schema.json +43 -5
  88. package/adapters/workbuddy/schemas/release-run.schema.json +35 -1
  89. package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
  90. package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
  91. package/adapters/workbuddy/src/schemas/filesystem-tree-observation.schema.json +55 -0
  92. package/adapters/workbuddy/src/schemas/host-verification-result.schema.json +2 -2
  93. package/adapters/workbuddy/src/schemas/plugin-verification-request.schema.json +374 -0
  94. package/adapters/workbuddy/src/schemas/plugin-verification-result.schema.json +799 -0
  95. package/adapters/workbuddy/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  96. package/bin/error-codes.json +1 -1
  97. package/bin/foundation-resource-binding.json +1 -1
  98. package/bin/prebuild-manifest.json +8 -4
  99. package/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  100. package/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  101. package/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  102. package/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  103. package/bin/registry.json +22 -1
  104. package/bin/release-skill-cli.mjs +126 -1
  105. package/bin/release-skill-local-finish.mjs +4 -0
  106. package/bin/release-skill.bundle.mjs +7152 -4231
  107. package/bin/rules.json +1 -1
  108. package/package.json +4 -4
  109. package/platform-manifest.json +30 -4
  110. package/schemas/release-plan.schema.json +43 -5
  111. package/schemas/release-run.schema.json +35 -1
  112. package/skills/release-finish/SKILL.md +65 -0
  113. package/skills/release-verify/SKILL.md +6 -1
  114. package/skills-src/release-finish/SKILL.md +65 -0
  115. package/skills-src/release-verify/SKILL.md +6 -1
  116. package/src/commands/approve.mjs +14 -2
  117. package/src/commands/distribute.mjs +562 -321
  118. package/src/commands/post-release-local.mjs +611 -0
  119. package/src/commands/postverify.mjs +262 -155
  120. package/src/commands/prepare.mjs +83 -57
  121. package/src/commands/publish.mjs +11 -10
  122. package/src/commands/reconcile.mjs +11 -10
  123. package/src/commands/setup.mjs +26 -13
  124. package/src/commands/ship.mjs +45 -12
  125. package/src/commands/verify.mjs +81 -18
  126. package/src/core/adoption-assessment.mjs +0 -3
  127. package/src/core/approval.mjs +11 -8
  128. package/src/core/foundation-plugin-verification.mjs +247 -0
  129. package/src/core/hooks.mjs +0 -16
  130. package/src/core/plan.mjs +19 -11
  131. package/src/core/postpublish-approval.mjs +12 -2
  132. package/src/core/postpublish-bundle.mjs +51 -13
  133. package/src/core/postpublish.mjs +146 -6
  134. package/src/core/preset-executor.mjs +53 -0
  135. package/src/core/proposal-inbox.mjs +91 -27
  136. package/src/core/recovery.mjs +27 -12
  137. package/src/core/skill-resource-closure.mjs +8 -4
  138. package/src/producers/build-adapters.mjs +10 -1
  139. package/src/producers/foundation-resource-projection.mjs +3 -3
  140. package/src/schemas/filesystem-tree-observation.schema.json +55 -0
  141. package/src/schemas/host-verification-result.schema.json +2 -2
  142. package/src/schemas/plugin-verification-request.schema.json +374 -0
  143. package/src/schemas/plugin-verification-result.schema.json +799 -0
  144. package/src/schemas/watchdog-termination-envelope.schema.json +16 -0
@@ -244,28 +244,38 @@ function sameClosureResources(actual, expected) {
244
244
  }
245
245
 
246
246
  /**
247
- * Re-verify the frozen execution bundle and install ONLY the verified bytes
248
- * into the detached tag worktree. Runs BEFORE any hook or external write.
247
+ * Verify the frozen execution bundle closure WITHOUT installing anything
248
+ * (rework R-01: the full-declaration preflight reuses this exact
249
+ * implementation before the first external write — a later declaration's
250
+ * missing/drifted bundle must fail the whole saga with zero writes).
249
251
  *
250
- * Steps:
252
+ * Steps (the first half of verifyAndInstallExecutionBundle, shared verbatim):
251
253
  * 1. Strictly read every frozen resource from the digest-addressed store
252
254
  * (`readFileStrict` with the frozen sha256 content guard); the file mode
253
255
  * must still equal the publication mode — any deviation is tamper;
254
256
  * 2. recompute the closure through Foundation in a disposable workspace and
255
- * compare it (digest + resources) with the plan's frozen closure;
256
- * 3. exclusively publish the verified bytes into the worktree — an occupied
257
- * target means the bundle would shadow a frozen tag file and fails closed.
257
+ * compare it (digest + resources) with the plan's frozen closure.
258
258
  *
259
259
  * @param {object} params
260
- * @param {object} params.plan - Frozen plan (postPublish.executionBundle).
260
+ * @param {object} params.postPublish - The CURRENT declaration being
261
+ * verified (REQUIRED, rework R-06: the v1/v2 normalized single item or the
262
+ * v3 loop item; an array fails closed).
261
263
  * @param {string} params.planPath - Absolute plan authority path (mechanical
262
264
  * bundle-root anchor).
263
- * @param {string} params.worktreePath - Detached tag worktree root.
264
- * @returns {Promise<{installed: string[]}>} Installed relative paths.
265
+ * @returns {Promise<{verified: boolean, bytesByPath: Map<string, Buffer>}>}
266
+ * `verified: false` with an empty map when the declaration carries no
267
+ * executionBundle (a legal no-bundle declaration).
265
268
  */
266
- export async function verifyAndInstallExecutionBundle({ plan, planPath, worktreePath } = {}) {
267
- const bundle = plan?.postPublish?.executionBundle;
268
- if (!bundle) return { installed: [] };
269
+ export async function verifyExecutionBundle({ planPath, postPublish } = {}) {
270
+ const declaration = postPublish;
271
+ if (!declaration || typeof declaration !== 'object') {
272
+ fail('verifyExecutionBundle requires the current postPublish declaration; pass the normalized single item or the v3 loop item explicitly');
273
+ }
274
+ if (Array.isArray(declaration)) {
275
+ fail('verifyExecutionBundle requires the current postPublish declaration, never the declaration array');
276
+ }
277
+ const bundle = declaration.executionBundle;
278
+ if (!bundle) return { verified: false, bytesByPath: new Map() };
269
279
  const closure = bundle.closure;
270
280
  if (!closure || !Array.isArray(closure.resources) || typeof closure.digest !== 'string') {
271
281
  fail('plan carries a malformed executionBundle closure');
@@ -318,10 +328,38 @@ export async function verifyAndInstallExecutionBundle({ plan, planPath, worktree
318
328
  fail(`bundle closure recomputation failed: ${cause?.message ?? cause}`, { kind: cause?.details?.kind });
319
329
  }
320
330
 
331
+ return { verified: true, bytesByPath };
332
+ }
333
+
334
+ /**
335
+ * Re-verify the frozen execution bundle (the SAME closure verification the
336
+ * full-declaration preflight runs) and install ONLY the verified bytes into
337
+ * the detached tag worktree. Runs BEFORE any hook or external write.
338
+ *
339
+ * Install step: exclusively publish the verified bytes into the worktree —
340
+ * an occupied target means the bundle would shadow a frozen tag file and
341
+ * fails closed.
342
+ *
343
+ * @param {object} params
344
+ * @param {object} params.postPublish - The CURRENT declaration being
345
+ * executed (v3 multi-unit contract §4.3). REQUIRED (rework R-06): callers
346
+ * must pass the declaration explicitly — v1/v2 callers pass the single item
347
+ * obtained from normalizePostPublishView(), v3 callers pass their loop
348
+ * item. Omitting it fails closed; an array never matches (fail-closed).
349
+ * @param {string} params.planPath - Absolute plan authority path (mechanical
350
+ * bundle-root anchor).
351
+ * @param {string} params.worktreePath - Detached tag worktree root.
352
+ * @returns {Promise<{installed: string[]}>} Installed relative paths.
353
+ */
354
+ export async function verifyAndInstallExecutionBundle({ planPath, worktreePath, postPublish } = {}) {
355
+ const { bytesByPath } = await verifyExecutionBundle({ planPath, postPublish });
356
+ const declaration = postPublish;
357
+ const closure = declaration?.executionBundle?.closure;
358
+
321
359
  // Install verified bytes into the tag worktree — exclusively: the bundle
322
360
  // must never overwrite (shadow) a file that already belongs to the tag.
323
361
  const installed = [];
324
- for (const resource of closure.resources) {
362
+ for (const resource of closure?.resources ?? []) {
325
363
  try {
326
364
  await publishFileExclusive(worktreePath, resource.path, bytesByPath.get(resource.path), {
327
365
  mode: EXECUTION_BUNDLE_FILE_MODE,
@@ -481,6 +481,131 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
481
481
  return postPublish;
482
482
  }
483
483
 
484
+ /**
485
+ * Version-aware read-only normalization of the frozen plan's postPublish
486
+ * field (multi-release-unit postPublish v3, design §4.2 归一表).
487
+ *
488
+ * This is the SINGLE normalization seam every reader (approve, distribute,
489
+ * postVerify, ship, verify, recovery, publish, reconcile, approval) must use
490
+ * to obtain the declaration array view. The accepted shape is derived from
491
+ * `plan.planVersion` ONLY — readers must never guess the shape from the
492
+ * field value (no shape sniffing), and no second normalizer or compatibility
493
+ * schema may exist. Pure and strictly read-only: never mutates the plan and
494
+ * never rewrites legacy plans, summaries, or evidence.
495
+ *
496
+ * | planVersion | input shape | result |
497
+ * |-------------|---------------|------------------|
498
+ * | 1/2 | absent | [] |
499
+ * | 1/2 | single object | [declaration] |
500
+ * | 1/2 | array | rejected |
501
+ * | 3 | array | the array itself |
502
+ * | 3 | object/absent | rejected |
503
+ *
504
+ * Values outside the table (null, primitives, unknown planVersion) fail
505
+ * closed with GATE_FAILED: a schema-valid plan can never produce them, and a
506
+ * reader must never paper over a shape it does not understand.
507
+ *
508
+ * @param {object} plan - Frozen plan (schema-valid by the caller's contract).
509
+ * @returns {object[]} The declaration array view (empty for legacy absent).
510
+ * @throws {ReleaseError} GATE_FAILED on any shape/version mismatch.
511
+ */
512
+ export function normalizePostPublishView(plan) {
513
+ const version = plan?.planVersion;
514
+ const postPublish = plan?.postPublish;
515
+ if (version === 1 || version === 2) {
516
+ if (postPublish === undefined) return [];
517
+ if (Array.isArray(postPublish)) {
518
+ throw new ReleaseError(
519
+ GATE_FAILED,
520
+ `planVersion ${version} plans must not carry a postPublish array; only planVersion 3 accepts the array shape`,
521
+ { planVersion: version, postPublishKind: 'array' },
522
+ );
523
+ }
524
+ if (postPublish === null || typeof postPublish !== 'object') {
525
+ throw new ReleaseError(
526
+ GATE_FAILED,
527
+ `planVersion ${version} plans must carry a single postPublish object or none; found ${postPublish === null ? 'null' : typeof postPublish}`,
528
+ { planVersion: version },
529
+ );
530
+ }
531
+ return [postPublish];
532
+ }
533
+ if (version === 3) {
534
+ if (!Array.isArray(postPublish)) {
535
+ throw new ReleaseError(
536
+ GATE_FAILED,
537
+ `planVersion 3 plans must carry a postPublish array (possibly empty); found ${postPublish === undefined ? 'no postPublish field' : postPublish === null ? 'null' : 'a single object'}`,
538
+ { planVersion: 3, postPublishKind: postPublish === undefined ? 'absent' : postPublish === null ? 'null' : 'object' },
539
+ );
540
+ }
541
+ return postPublish;
542
+ }
543
+ throw new ReleaseError(
544
+ GATE_FAILED,
545
+ `cannot normalize postPublish shape: unknown planVersion ${String(version)}`,
546
+ { planVersion: version },
547
+ );
548
+ }
549
+
550
+ /**
551
+ * Array-level domain validation: every EXPLICIT hooks[].id must be unique
552
+ * across the whole declaration array (multi-release-unit postPublish v3,
553
+ * design §9.2 rule 3; rework R-02). The (planDigest, hookId) approval
554
+ * contract binds each explicit hook id plan-wide, so a duplicate across
555
+ * units must fail before any approval is minted or consumed and before any
556
+ * side effect — JSON Schema cannot express cross-array business uniqueness,
557
+ * so this function is the SINGLE runtime authority for it.
558
+ *
559
+ * Every entry only normalizes its input to the declaration array view and
560
+ * calls this function: prepare, setup/adoption assessment, approve, the
561
+ * postpublish approval validator, distribute, and postVerify. Target and
562
+ * internal probe local ids may repeat across units (checkpoints are
563
+ * unit-namespaced); explicit hook ids may not.
564
+ *
565
+ * @param {object[]} declarations - The declaration array view
566
+ * (`normalizePostPublishView()` output, or the per-unit postPublish blocks
567
+ * collected by prepare/setup).
568
+ * @throws {ReleaseError} GATE_FAILED with details { hookId, unitIds } on the
569
+ * first cross-unit duplicate.
570
+ */
571
+ export function validatePostPublishHookIdUniqueness(declarations) {
572
+ const explicitHookIdOwner = new Map();
573
+ for (const declaration of declarations ?? []) {
574
+ for (const hook of declaration?.hooks ?? []) {
575
+ const owner = explicitHookIdOwner.get(hook.id);
576
+ if (owner !== undefined && owner !== declaration.unitId) {
577
+ throw new ReleaseError(
578
+ GATE_FAILED,
579
+ `postPublish hook id "${hook.id}" is declared by units "${owner}" and "${declaration.unitId}"; explicit hooks[].id must be unique across the whole project (target and probe local ids may repeat across units)`,
580
+ { hookId: hook.id, unitIds: [owner, declaration.unitId] },
581
+ );
582
+ }
583
+ explicitHookIdOwner.set(hook.id, declaration.unitId);
584
+ }
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Deterministically derive the checkpoint action id for one postPublish
590
+ * local action (target probe/mirror or hook) inside one release unit
591
+ * (§4.3 checkpoint binding). planVersion 3 namespaces every action id under
592
+ * the owning declaration's unitId (`unitId/localId`); legacy plans keep the
593
+ * bare local id so pre-existing checkpoint records and approvals stay valid.
594
+ * This is the SINGLE derivation used by every consumer (distribute,
595
+ * postVerify, recovery, verify), so a checkpoint id can never drift between
596
+ * the run records, the recovery mapping, and the approval binding.
597
+ *
598
+ * @param {object} params
599
+ * @param {number} params.planVersion - Frozen plan's planVersion.
600
+ * @param {string} params.unitId - Owning declaration's unitId.
601
+ * @param {string} params.localId - Local action id (`probe-<target.id>`,
602
+ * `<target.id>`, or the hook id).
603
+ * @returns {string} The checkpoint action id.
604
+ */
605
+ export function postPublishActionId({ planVersion, unitId, localId }) {
606
+ return planVersion === 3 ? `${unitId}/${localId}` : localId;
607
+ }
608
+
484
609
  /**
485
610
  * Order targets so every target comes after its dependsOn target.
486
611
  *
@@ -595,24 +720,39 @@ export function effectiveHookRequiresApproval(hook) {
595
720
  *
596
721
  * @param {object} args
597
722
  * @param {object} args.plan - Frozen plan (digest + units + postPublish).
723
+ * @param {object} args.postPublish - THE current declaration the caller is
724
+ * executing. REQUIRED (rework R-06): callers must pass the declaration
725
+ * explicitly — the unit identity is never derived from a global
726
+ * single-object read (contract §4.3). v1/v2 callers pass the single item
727
+ * obtained from normalizePostPublishView(); v3 callers pass their loop
728
+ * item. Omitting it fails closed with GATE_FAILED; a declaration ARRAY
729
+ * (or anything non-object) fails closed too.
598
730
  * @param {string} args.runId - Current distribute run id.
599
731
  * @param {object} args.sourceRun - Sealed source run (finishedAt = publishedAt).
600
732
  * @param {string} args.payloadDir - Materialized payload directory.
601
733
  * @param {'distribute'|'postVerify'} args.phase
602
734
  * @param {object} [args.verifyEvidence] - Verify evidence (postVerify phase).
603
735
  * @returns {object} The context projection.
736
+ * @throws {ReleaseError} GATE_FAILED when no single declaration is in scope.
604
737
  */
605
- export function buildPostPublishContext({ plan, runId, sourceRun, payloadDir, phase, verifyEvidence }) {
606
- const postPublish = plan.postPublish ?? {};
607
- const unit = (plan.units ?? []).find((entry) => entry.id === postPublish.unitId);
738
+ export function buildPostPublishContext({ plan, postPublish, runId, sourceRun, payloadDir, phase, verifyEvidence }) {
739
+ const declaration = postPublish;
740
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) {
741
+ throw new ReleaseError(
742
+ GATE_FAILED,
743
+ `buildPostPublishContext requires the current postPublish declaration; found ${declaration === undefined ? 'no declaration' : Array.isArray(declaration) ? 'a declaration array' : typeof declaration}`,
744
+ { planVersion: plan?.planVersion, unitId: declaration?.unitId ?? null },
745
+ );
746
+ }
747
+ const unit = (plan.units ?? []).find((entry) => entry.id === declaration.unitId);
608
748
  const frozenSnapshot = unit?.frozenSnapshot ?? {};
609
749
  return {
610
750
  planDigest: plan.digest,
611
751
  runId,
612
- unitId: postPublish.unitId,
752
+ unitId: declaration.unitId,
613
753
  version: unit?.targetVersion,
614
- tag: postPublish.tag,
615
- commit: postPublish.tagCommit,
754
+ tag: declaration.tag,
755
+ commit: declaration.tagCommit,
616
756
  ...(frozenSnapshot.tree !== undefined ? { tree: frozenSnapshot.tree } : {}),
617
757
  ...(frozenSnapshot.manifestDigest !== undefined ? { manifestDigest: frozenSnapshot.manifestDigest } : {}),
618
758
  publishedAt: sourceRun?.finishedAt,
@@ -22,12 +22,17 @@
22
22
  * @module core/preset-executor
23
23
  */
24
24
 
25
+ import { rm } from 'node:fs/promises';
26
+
25
27
  import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
26
28
  import { resolveProposalInboxTransport } from './presets.mjs';
27
29
  import { executeNotifyHandoffHook } from './notify-handoff.mjs';
28
30
  import {
31
+ buildProposalDocument,
29
32
  executeProposalInboxGitPushHook,
30
33
  executeProposalInboxLocalFileHook,
34
+ observeProposalInboxGitPush,
35
+ proposalFileName,
31
36
  } from './proposal-inbox.mjs';
32
37
  import { executeMarketplaceRegistryEntryHook } from './marketplace-registry-entry.mjs';
33
38
  import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
@@ -173,3 +178,51 @@ export async function executePresetHook(params) {
173
178
  }
174
179
  }
175
180
  }
181
+
182
+ /**
183
+ * Narrow READ-ONLY preset preflight (rework R-01): deterministic conflicts a
184
+ * preset can already recognize from its existing observe-before-write
185
+ * observation, evaluated BEFORE the first external write of the phase. This
186
+ * is NOT a second dispatch table and NOT a lock — it only covers the presets
187
+ * whose observation implementation already exists (proposal-inbox git-push:
188
+ * the proposal path exists with different bytes -> the shared observation
189
+ * throws REMOTE_CONFLICT), and every preset hook is re-observed at execution
190
+ * time regardless.
191
+ *
192
+ * Returns `null` for presets/transports without a read-only deterministic
193
+ * conflict observation (proposal-inbox local-file, notify-handoff, ...) —
194
+ * their execution-phase observe-before-write stays the only gate.
195
+ *
196
+ * @param {object} params
197
+ * @param {object} params.hook - Declared preset hook entry.
198
+ * @param {object} params.contextProjection - The deterministic §2.3 context
199
+ * projection of the current run (the same bytes the execution-phase
200
+ * delivery serializes; lineage-stable runId).
201
+ * @param {Function} [params.exec] - Injectable git exec (tests).
202
+ * @returns {Promise<{verdict: string, proposalPath: string}|null>}
203
+ * @throws {ReleaseError} REMOTE_CONFLICT/REMOTE_UNAVAILABLE from the shared
204
+ * observation implementation.
205
+ */
206
+ export async function preflightPresetHook(params) {
207
+ const { hook, contextProjection, exec } = params ?? {};
208
+ if (!hook || typeof hook.preset !== 'string') return null;
209
+ if (hook.preset !== 'proposal-inbox') return null;
210
+ const target = hook.config?.target;
211
+ if (!target) return null; // targetOptional degradation: nothing remote to observe.
212
+ const transport = resolveProposalInboxTransport(hook.config);
213
+ if (transport !== 'git-push') return null; // local-file: no deterministic remote conflict observation.
214
+
215
+ // The same observation implementation the execution-phase delivery uses:
216
+ // build the byte-deterministic proposal and observe-before-write.
217
+ const document = buildProposalDocument(contextProjection);
218
+ const proposalPath = proposalFileName(contextProjection.unitId, contextProjection.version);
219
+ const { cloneDir, verdict } = await observeProposalInboxGitPush({
220
+ remoteUrl: target.remoteUrl,
221
+ branch: target.branch,
222
+ proposalPath,
223
+ proposalDocument: document,
224
+ ...(exec !== undefined ? { exec } : {}),
225
+ });
226
+ await rm(cloneDir, { recursive: true, force: true }).catch(() => {});
227
+ return { verdict, proposalPath };
228
+ }
@@ -288,40 +288,42 @@ export async function crossCheckPushedCommit(exec, remoteUrl, branch, pushedComm
288
288
  }
289
289
 
290
290
  /**
291
- * Deliver one proposal document through the git-push transport:
292
- * clone -> write (absent only) -> commit (frozen identity) -> push.
291
+ * Observe-before-write for the git-push transport (rework R-01): clone the
292
+ * downstream into a fresh disposable checkout and compare any existing
293
+ * proposal bytes with the serialized document. This is the SINGLE observation
294
+ * implementation shared by the execution-phase delivery (observe-before-write)
295
+ * and the full-declaration preflight (deterministic conflict detection BEFORE
296
+ * the first external write) — no second observe algorithm or dispatch table.
293
297
  *
294
- * - identical existing content -> NO_CHANGE, nothing written;
295
- * - different existing content -> REMOTE_CONFLICT (human decision);
296
- * - NEVER --force.
298
+ * - existing identical content -> verdict 'IDENTICAL';
299
+ * - different existing content -> REMOTE_CONFLICT (human decision), thrown
300
+ * from here so preflight and delivery can never drift apart;
301
+ * - absent -> verdict 'ABSENT'.
297
302
  *
298
- * No preset-level dry-run (R4 review m-3): the command-level DRY_RUN skip
299
- * contract skips preset hooks wholesale (SKIPPED/DRY_RUN checkpoints in
300
- * distribute/postVerify) before this delivery is ever invoked, so a dryRun
301
- * parameter here would be unreachable dead surface.
303
+ * On success the CALLER owns `cloneDir` and must remove it (the delivery
304
+ * continues writing/committing/pushing from that checkout; the preflight
305
+ * discards it immediately). On any thrown failure the clone dir is already
306
+ * removed.
302
307
  *
303
308
  * @param {object} params
304
309
  * @param {string} params.remoteUrl - Downstream repository URL.
305
310
  * @param {string} params.branch - Downstream branch.
306
311
  * @param {string} params.proposalPath - Repository-relative proposal path.
307
312
  * @param {object} params.proposalDocument - The proposal document.
308
- * @param {object} params.commitIdentity - Frozen { name, email }.
309
313
  * @param {Function} [params.exec] - Injectable git exec (tests).
310
- * @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
314
+ * @returns {Promise<{cloneDir: string, previousHead: string|null, verdict: 'ABSENT'|'IDENTICAL'}>}
311
315
  */
312
- export async function deliverProposalGitPush(params) {
316
+ export async function observeProposalInboxGitPush(params) {
313
317
  const {
314
318
  remoteUrl,
315
319
  branch,
316
320
  proposalPath,
317
321
  proposalDocument,
318
- commitIdentity,
319
322
  exec: execOpt,
320
323
  } = params ?? {};
321
324
  assertSafeRemoteUrl(remoteUrl);
322
325
  assertSafeBranch(branch);
323
326
  assertSafeProposalPath(proposalPath);
324
- assertCommitIdentity(commitIdentity);
325
327
  if (!proposalDocument || typeof proposalDocument !== 'object' || Array.isArray(proposalDocument)) {
326
328
  throw new ReleaseError(GATE_FAILED, 'proposal-inbox requires a proposal document');
327
329
  }
@@ -365,7 +367,7 @@ export async function deliverProposalGitPush(params) {
365
367
  await git(['checkout', '--quiet', '--orphan', branch]);
366
368
  }
367
369
 
368
- // Observe-before-write: an existing proposal decides the outcome.
370
+ // Observe: an existing proposal decides the outcome.
369
371
  const targetPath = join(cloneDir, proposalPath);
370
372
  let existing = null;
371
373
  try {
@@ -373,20 +375,82 @@ export async function deliverProposalGitPush(params) {
373
375
  } catch {
374
376
  existing = null;
375
377
  }
376
- if (existing !== null) {
377
- if (existing === serialized) {
378
- return {
379
- status: 'NO_CHANGE',
380
- observation: { mode: 'no-change', previousHead, branchTip: previousHead },
381
- };
382
- }
383
- throw new ReleaseError(
384
- REMOTE_CONFLICT,
385
- `proposal ${proposalPath} already exists in ${redactUrlCredentialsIfPresent(remoteUrl)} with different content; overwriting a downstream proposal requires a human decision`,
386
- { remoteUrl: redactUrlCredentialsIfPresent(remoteUrl), proposalPath },
387
- );
378
+ if (existing === null) {
379
+ return { cloneDir, previousHead, verdict: 'ABSENT' };
380
+ }
381
+ if (existing === serialized) {
382
+ return { cloneDir, previousHead, verdict: 'IDENTICAL' };
388
383
  }
384
+ throw new ReleaseError(
385
+ REMOTE_CONFLICT,
386
+ `proposal ${proposalPath} already exists in ${redactUrlCredentialsIfPresent(remoteUrl)} with different content; overwriting a downstream proposal requires a human decision`,
387
+ { remoteUrl: redactUrlCredentialsIfPresent(remoteUrl), proposalPath },
388
+ );
389
+ } catch (cause) {
390
+ await rm(cloneDir, { recursive: true, force: true }).catch(() => {});
391
+ throw cause;
392
+ }
393
+ }
389
394
 
395
+ /**
396
+ * Deliver one proposal document through the git-push transport:
397
+ * observe -> write (absent only) -> commit (frozen identity) -> push.
398
+ *
399
+ * - identical existing content -> NO_CHANGE, nothing written;
400
+ * - different existing content -> REMOTE_CONFLICT (human decision);
401
+ * - NEVER --force.
402
+ *
403
+ * The observe-before-write step calls the SAME shared observation
404
+ * implementation the full-declaration preflight uses, so the execution phase
405
+ * always re-observes the remote (the preflight is never a lock).
406
+ *
407
+ * No preset-level dry-run (R4 review m-3): the command-level DRY_RUN skip
408
+ * contract skips preset hooks wholesale (SKIPPED/DRY_RUN checkpoints in
409
+ * distribute/postVerify) before this delivery is ever invoked, so a dryRun
410
+ * parameter here would be unreachable dead surface.
411
+ *
412
+ * @param {object} params
413
+ * @param {string} params.remoteUrl - Downstream repository URL.
414
+ * @param {string} params.branch - Downstream branch.
415
+ * @param {string} params.proposalPath - Repository-relative proposal path.
416
+ * @param {object} params.proposalDocument - The proposal document.
417
+ * @param {object} params.commitIdentity - Frozen { name, email }.
418
+ * @param {Function} [params.exec] - Injectable git exec (tests).
419
+ * @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
420
+ */
421
+ export async function deliverProposalGitPush(params) {
422
+ const {
423
+ remoteUrl,
424
+ branch,
425
+ proposalPath,
426
+ proposalDocument,
427
+ commitIdentity,
428
+ exec: execOpt,
429
+ } = params ?? {};
430
+ assertCommitIdentity(commitIdentity);
431
+ const exec = typeof execOpt === 'function' ? execOpt : defaultExec;
432
+ const serialized = `${JSON.stringify(proposalDocument, null, 2)}\n`;
433
+
434
+ // Shared observe-before-write: identical -> NO_CHANGE; different -> the
435
+ // shared observation throws REMOTE_CONFLICT (human decision).
436
+ const { cloneDir, previousHead, verdict } = await observeProposalInboxGitPush({
437
+ remoteUrl,
438
+ branch,
439
+ proposalPath,
440
+ proposalDocument,
441
+ ...(execOpt !== undefined ? { exec: execOpt } : {}),
442
+ });
443
+
444
+ try {
445
+ if (verdict === 'IDENTICAL') {
446
+ return {
447
+ status: 'NO_CHANGE',
448
+ observation: { mode: 'no-change', previousHead, branchTip: previousHead },
449
+ };
450
+ }
451
+
452
+ const git = (args, options = {}) => exec('git', args, { cwd: cloneDir, shell: false, timeout: GIT_TIMEOUT_MS, ...options });
453
+ const targetPath = join(cloneDir, proposalPath);
390
454
  await mkdir(dirname(targetPath), { recursive: true });
391
455
  await writeFile(targetPath, serialized);
392
456
  await git(['add', '-A']);
@@ -33,7 +33,7 @@ import { validatePlan, computePlanDigest, assertImmutablePlanAuthority } from '.
33
33
  import { validateApproval, validateApprovalRecordSchema, assertImmutableApprovalAuthority, computeApprovalDigest } from './approval.mjs';
34
34
  import { isMarketplaceAction } from './checkpoints.mjs';
35
35
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
36
- import { effectiveHookRequiresApproval } from './postpublish.mjs';
36
+ import { effectiveHookRequiresApproval, normalizePostPublishView, postPublishActionId } from './postpublish.mjs';
37
37
  import { assertPostPublishApprovalAuthority, validatePostPublishApproval } from './postpublish-approval.mjs';
38
38
 
39
39
  /** Remote errors alone are insufficient to select a safe recovery phase. */
@@ -194,12 +194,18 @@ export async function readRunRecovery(runPath, options = {}) {
194
194
  throw new ReleaseError(GATE_FAILED, 'distribute recovery requires its same-lineage PUBLISHED publication');
195
195
  }
196
196
  // Mapping only: the common 1:1 checkpoint validator remains authoritative.
197
- const actions = (plan.postPublish?.targets ?? []).flatMap((target) => [
198
- { id: `probe-${target.id}`, type: 'distribute-probe' },
199
- { id: target.id, type: 'distribute-mirror' },
200
- ]).concat((plan.postPublish?.hooks ?? [])
201
- .filter((hook) => hook.phase !== 'postVerify')
202
- .map((hook) => ({ id: hook.id, type: 'postpublish-hook' })));
197
+ // §4.3 unified normalization: per-declaration mapping through the shared
198
+ // action-id derivation so v3 checkpoint ids (unitId/localId) match the
199
+ // executed records exactly; legacy plans resolve to the same bare ids.
200
+ const actions = normalizePostPublishView(plan).flatMap((declaration) => [
201
+ ...(declaration.targets ?? []).flatMap((target) => [
202
+ { id: postPublishActionId({ planVersion: plan.planVersion, unitId: declaration.unitId, localId: `probe-${target.id}` }), type: 'distribute-probe' },
203
+ { id: postPublishActionId({ planVersion: plan.planVersion, unitId: declaration.unitId, localId: target.id }), type: 'distribute-mirror' },
204
+ ]),
205
+ ...(declaration.hooks ?? [])
206
+ .filter((hook) => hook.phase !== 'postVerify')
207
+ .map((hook) => ({ id: postPublishActionId({ planVersion: plan.planVersion, unitId: declaration.unitId, localId: hook.id }), type: 'postpublish-hook' })),
208
+ ]);
203
209
  // A preflight BLOCKED record may precede checkpoint initialization.
204
210
  if (!(run.status === 'BLOCKED' && run.checkpoints.length === 0)) {
205
211
  validateRunCheckpointMapping(run, actions);
@@ -268,8 +274,11 @@ export async function readRunRecovery(runPath, options = {}) {
268
274
  }
269
275
  } else if (['publish', 'reconcile'].includes(command)) {
270
276
  if (run.status === 'PUBLISHED') {
271
- const needsDistribution = (plan.postPublish?.targets?.length ?? 0) > 0
272
- || (plan.postPublish?.hooks?.length ?? 0) > 0;
277
+ // §4.3 unified normalization: v3 empty arrays mean no distribute work
278
+ // at all; only declarations carrying targets or hooks require the
279
+ // distribution phase.
280
+ const needsDistribution = normalizePostPublishView(plan).some((declaration) =>
281
+ (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
273
282
  code = needsDistribution ? 'DISTRIBUTE' : 'VERIFY';
274
283
  } else if (run.status === 'PARTIAL') {
275
284
  code = 'RECONCILE';
@@ -280,9 +289,15 @@ export async function readRunRecovery(runPath, options = {}) {
280
289
  } else code = 'DIAGNOSE';
281
290
 
282
291
  if (code === 'DISTRIBUTE') {
283
- const pendingApprovals = (plan.postPublish?.hooks ?? []).filter((hook) => hook.phase !== 'postVerify'
284
- && effectiveHookRequiresApproval(hook)
285
- && !(run.command === 'distribute' && run.checkpoints.some((cp) => cp.actionId === hook.id && completed(cp))));
292
+ // §4.3 unified normalization: approval gating resolves declared hooks
293
+ // across all declarations; checkpoint match uses the shared action-id
294
+ // derivation so a v3 record (unitId/hookId) counts as completed.
295
+ const pendingApprovals = normalizePostPublishView(plan).flatMap((declaration) =>
296
+ (declaration.hooks ?? [])
297
+ .filter((hook) => hook.phase !== 'postVerify' && effectiveHookRequiresApproval(hook))
298
+ .filter((hook) => !(run.command === 'distribute' && run.checkpoints.some((cp) =>
299
+ cp.actionId === postPublishActionId({ planVersion: plan.planVersion, unitId: declaration.unitId, localId: hook.id })
300
+ && completed(cp)))));
286
301
  if (pendingApprovals.length > 0) {
287
302
  try {
288
303
  const approved = new Set();
@@ -376,10 +376,14 @@ export function evaluateConsumerSkillResourceClosureReceipts(plan, receipts) {
376
376
  'kimi-plugin',
377
377
  'codebuddy-plugin',
378
378
  ]);
379
- // When plan declares humanConsumersStrategy: 'manualFollowUps', Kimi/CodeBuddy
380
- // are non-blocking manual follow-up tasks and are not verified by the system.
381
- // Exclude them from the expected receipt set.
382
- const humanConsumerTypes = plan.humanConsumersStrategy === 'manualFollowUps'
379
+ // Kimi/CodeBuddy remain non-blocking manual marketplace/invocation tasks in
380
+ // both strategy generations. The newer strategy adds a Foundation frozen
381
+ // payload observation but still does not establish an installed host
382
+ // surface, so these distributions stay outside this receipt set.
383
+ const humanConsumerTypes = [
384
+ 'manualFollowUps',
385
+ 'foundationPayloadThenManualFollowUps',
386
+ ].includes(plan.humanConsumersStrategy)
383
387
  ? new Set(['kimi-plugin', 'codebuddy-plugin'])
384
388
  : new Set();
385
389
  const expectedKeys = [];
@@ -371,9 +371,17 @@ function renderSkillForPlatform(content, platformName) {
371
371
  const hostLabel = platformName === 'codex' ? 'Codex' : 'Kimi';
372
372
  const preamble = platformName === 'codex' ? CODEX_PREAMBLE : KIMI_PREAMBLE;
373
373
  let rendered = content.replaceAll(
374
+ '${CLAUDE_PLUGIN_ROOT}/bin/release-skill-local-finish.mjs',
375
+ '$RELEASE_SKILL_LOCAL_FINISH_ENTRY',
376
+ ).replaceAll(
374
377
  '${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs',
375
378
  '$RELEASE_SKILL_ENTRY',
376
379
  );
380
+ const renderedPreamble = rendered.includes('$RELEASE_SKILL_LOCAL_FINISH_ENTRY')
381
+ ? preamble
382
+ .replaceAll('RELEASE_SKILL_ENTRY', 'RELEASE_SKILL_LOCAL_FINISH_ENTRY')
383
+ .replaceAll('release-skill.mjs', 'release-skill-local-finish.mjs')
384
+ : preamble;
377
385
  // A remaining bare host-root reference has no safe path-derived equivalent.
378
386
  if (rendered.includes('${CLAUDE_PLUGIN_ROOT}')) {
379
387
  throw new Error(`${hostLabel} rendering found an unsupported bare CLAUDE_PLUGIN_ROOT reference`);
@@ -385,7 +393,7 @@ function renderSkillForPlatform(content, platformName) {
385
393
  throw new Error(`${hostLabel} rendering requires a valid leading YAML frontmatter block`);
386
394
  }
387
395
  const insertionPoint = frontmatterEnd + '\n---\n'.length;
388
- return `${rendered.slice(0, insertionPoint)}\n${preamble}${rendered.slice(insertionPoint)}`;
396
+ return `${rendered.slice(0, insertionPoint)}\n${renderedPreamble}${rendered.slice(insertionPoint)}`;
389
397
  }
390
398
 
391
399
  return content;
@@ -527,6 +535,7 @@ async function generateAdapterFiles(platform, skills, templateJson, root, market
527
535
 
528
536
  const HOST_BIN_FILES = new Set([
529
537
  'release-skill.mjs',
538
+ 'release-skill-local-finish.mjs',
530
539
  'release-skill.bundle.mjs',
531
540
  'release-skill-cli.mjs',
532
541
  'foundation-resource-binding.json',
@@ -55,9 +55,9 @@ import { digestBytes, publishFileOrReplace, readFileStrict, withTemporaryWorkspa
55
55
  import { canonicalJson } from '../core/digest.mjs';
56
56
 
57
57
  export const FOUNDATION_PACKAGES = Object.freeze({
58
- 'skill-family-contracts': '0.12.0',
59
- 'skill-family-engineering-kit': '0.12.0',
60
- 'skill-family-harness-node': '0.12.0',
58
+ 'skill-family-contracts': '0.13.0',
59
+ 'skill-family-engineering-kit': '0.13.0',
60
+ 'skill-family-harness-node': '0.13.0',
61
61
  });
62
62
 
63
63
  export const BINDING_RECORD_PATH = 'bin/foundation-resource-binding.json';