release-skill 0.8.1 → 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 (66) 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 +31 -0
  7. package/INSTALL.md +14 -2
  8. package/INSTALL.zh-CN.md +12 -2
  9. package/README.md +28 -16
  10. package/README.zh-CN.md +23 -14
  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-local-finish.mjs +4 -0
  14. package/adapters/claude/bin/release-skill.bundle.mjs +2416 -1320
  15. package/adapters/claude/schemas/release-plan.schema.json +40 -3
  16. package/adapters/claude/skills/release-finish/SKILL.md +65 -0
  17. package/adapters/claude/skills/release-verify/SKILL.md +6 -1
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
  20. package/adapters/codex/bin/release-skill.bundle.mjs +2416 -1320
  21. package/adapters/codex/schemas/release-plan.schema.json +40 -3
  22. package/adapters/codex/skills/release-finish/SKILL.md +72 -0
  23. package/adapters/codex/skills/release-verify/SKILL.md +6 -1
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
  26. package/adapters/kimi/bin/release-skill.bundle.mjs +2416 -1320
  27. package/adapters/kimi/schemas/release-plan.schema.json +40 -3
  28. package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
  29. package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  31. package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
  32. package/adapters/workbuddy/bin/release-skill.bundle.mjs +2416 -1320
  33. package/adapters/workbuddy/schemas/release-plan.schema.json +40 -3
  34. package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
  35. package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
  36. package/bin/release-skill-cli.mjs +126 -1
  37. package/bin/release-skill-local-finish.mjs +4 -0
  38. package/bin/release-skill.bundle.mjs +2416 -1320
  39. package/package.json +1 -1
  40. package/platform-manifest.json +30 -4
  41. package/schemas/release-plan.schema.json +40 -3
  42. package/skills/release-finish/SKILL.md +65 -0
  43. package/skills/release-verify/SKILL.md +6 -1
  44. package/skills-src/release-finish/SKILL.md +65 -0
  45. package/skills-src/release-verify/SKILL.md +6 -1
  46. package/src/commands/approve.mjs +14 -2
  47. package/src/commands/distribute.mjs +562 -321
  48. package/src/commands/post-release-local.mjs +611 -0
  49. package/src/commands/postverify.mjs +262 -155
  50. package/src/commands/prepare.mjs +67 -52
  51. package/src/commands/publish.mjs +11 -10
  52. package/src/commands/reconcile.mjs +11 -10
  53. package/src/commands/setup.mjs +26 -13
  54. package/src/commands/ship.mjs +45 -12
  55. package/src/commands/verify.mjs +19 -8
  56. package/src/core/adoption-assessment.mjs +0 -3
  57. package/src/core/approval.mjs +11 -8
  58. package/src/core/hooks.mjs +0 -16
  59. package/src/core/plan.mjs +19 -11
  60. package/src/core/postpublish-approval.mjs +12 -2
  61. package/src/core/postpublish-bundle.mjs +51 -13
  62. package/src/core/postpublish.mjs +146 -6
  63. package/src/core/preset-executor.mjs +53 -0
  64. package/src/core/proposal-inbox.mjs +91 -27
  65. package/src/core/recovery.mjs +27 -12
  66. package/src/producers/build-adapters.mjs +10 -1
@@ -43,22 +43,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
43
43
  // Validation
44
44
  // ---------------------------------------------------------------------------
45
45
 
46
- /**
47
- * A release plan binds at most one postPublish declaration. Both prepare
48
- * and adoption assessment consume this pure rule and retain their own
49
- * error codes and presentation. Declaration contents are validated by
50
- * the existing postPublish validator, not by this cross-unit check.
51
- *
52
- * @param {Array<Object>} units - config.releaseUnits.
53
- * @returns {null|{unitIds: string[]}} Sorted conflicting unit IDs, or null.
54
- */
55
- export function findPostPublishUnitConflict(units) {
56
- const declaring = (units ?? [])
57
- .filter((unit) => unit && typeof unit === 'object' && unit.postPublish !== undefined);
58
- if (declaring.length <= 1) return null;
59
- return { unitIds: declaring.map((unit) => unit.id).sort() };
60
- }
61
-
62
46
  /**
63
47
  * Validate the shape of a hook descriptor against the project contract.
64
48
  *
package/src/core/plan.mjs CHANGED
@@ -202,7 +202,8 @@ export function validatePlan(plan) {
202
202
  }
203
203
 
204
204
  /**
205
- * planVersion 2 digest scope (design: t1-2-digest-decoupling.md §4.1).
205
+ * planVersion 2/3 digest scope (design: t1-2-digest-decoupling.md §4.1;
206
+ * v3 inherits the v2 scope per 多发布单元 postPublish v3 §4.2).
206
207
  *
207
208
  * The record layer -- top-level `digest`, lifecycle `status`, `createdAt`,
208
209
  * the whole `baseline` object, and each action's runtime `status` -- is
@@ -210,8 +211,10 @@ export function validatePlan(plan) {
210
211
  * but is excluded from the digest. Everything else (planVersion, units with
211
212
  * every frozenSnapshot artifact identity field, externalActions identity,
212
213
  * production, configDigest, verificationGates, snapshotDigest) binds.
214
+ * For v3 the frozen `postPublish` array and every item binding field stay in
215
+ * the binding layer: any order or binding change moves the digest.
213
216
  *
214
- * This is the ONLY place the v2 record layer is defined.
217
+ * This is the ONLY place the v2/v3 record layer is defined.
215
218
  *
216
219
  * @param {object} plan - A plan object.
217
220
  * @returns {object} The binding-layer projection of the plan.
@@ -242,9 +245,12 @@ function stripRecordLayerV2(plan) {
242
245
  *
243
246
  * Digest scope forks on `plan.planVersion` (centralized here, never spread
244
247
  * elsewhere):
245
- * - planVersion 2: record-layer fields (status/createdAt/baseline/action
248
+ * - planVersion 2/3: record-layer fields (status/createdAt/baseline/action
246
249
  * status/digest) are stripped before hashing, so re-preparing over the
247
- * same sources yields the same digest. See `stripRecordLayerV2`.
250
+ * same sources yields the same digest. See `stripRecordLayerV2`. v3
251
+ * inherits the v2 scope exactly (multi-release-unit postPublish v3 §4.2);
252
+ * versions beyond {2,3} are NOT admitted and stay fail-closed on the
253
+ * legacy v1 path rather than being silently bound to v2 semantics.
248
254
  * - planVersion 1 or absent (legacy): only the top-level `digest` field is
249
255
  * stripped. Byte-for-byte legacy semantics -- this path must never change.
250
256
  *
@@ -258,7 +264,7 @@ export function computePlanDigest(plan) {
258
264
  // 而是经 digest.mjs 的宽松输入域包装(contracts 权威序列化 + 本地归一化)
259
265
  // 后由 Foundation digestBytes 摘要 —— 对纯 JSON 计划与 digestDocument 字节
260
266
  // 完全一致,对含 undefined 的计划保持迁移前字节语义。
261
- if (plan.planVersion === 2) {
267
+ if (plan.planVersion === 2 || plan.planVersion === 3) {
262
268
  return sha256Hex(canonicalJson(stripRecordLayerV2(plan)));
263
269
  }
264
270
  // Strip the digest field if present so the hash is self-consistent.
@@ -341,14 +347,15 @@ export async function writePlanImmutable(planPath, plan) {
341
347
  // EEXIST 直接抛冲突,无幂等分支;wrapper 补齐,fail-closed 无双路径)。
342
348
  const existing = await readFile(planPath, 'utf8').catch(() => null);
343
349
  if (existing === json) return { planPath, planDigest };
344
- // planVersion 2 (design: t1-2-digest-decoupling.md §4.1): record-layer
345
- // fields (status/createdAt/baseline) are excluded from the digest, so
350
+ // planVersion 2/3 (design: t1-2-digest-decoupling.md §4.1; v3 inherits
351
+ // the scope per 多发布单元 postPublish v3 §4.2): record-layer fields
352
+ // (status/createdAt/baseline) are excluded from the digest, so
346
353
  // re-preparing over identical binding content yields the SAME digest
347
354
  // with legitimately different bytes. Reuse the existing authority IFF
348
355
  // it is genuinely self-consistent (its embedded digest and recomputed
349
356
  // digest both equal this digest) -- the authority is never replaced.
350
357
  // Any other divergence (tampering, malformed file) still fails closed.
351
- if (plan.planVersion === 2 && existing !== null) {
358
+ if ((plan.planVersion === 2 || plan.planVersion === 3) && existing !== null) {
352
359
  let existingPlan = null;
353
360
  try {
354
361
  existingPlan = JSON.parse(existing);
@@ -386,7 +393,7 @@ export async function writePlanImmutable(planPath, plan) {
386
393
  /**
387
394
  * Canonical normalized freeze timestamp shape (`YYYY-MM-DDTHH:MM:SS+00:00`).
388
395
  * Mirrors the frozenSnapshot.commitTimestamp pattern in the release-plan
389
- * schema; used by the planVersion 2 completeness gate, which requires a
396
+ * schema; used by the planVersion 2/3 completeness gate, which requires a
390
397
  * canonical normalized timestamp without re-binding it to plan.createdAt.
391
398
  */
392
399
  const CANONICAL_COMMIT_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/;
@@ -568,8 +575,9 @@ export function validatePlanActionCompleteness(plan, options = {}) {
568
575
  }
569
576
  if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
570
577
  failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
571
- } else if (plan.planVersion === 2) {
572
- // planVersion 2 (design: t1-2-digest-decoupling.md §4.2): the freeze
578
+ } else if (plan.planVersion === 2 || plan.planVersion === 3) {
579
+ // planVersion 2/3 (design: t1-2-digest-decoupling.md §4.2; v3 inherits
580
+ // the semantics per 多发布单元 postPublish v3 §4.2): the freeze
573
581
  // timestamp is derived from the headCommit committer date, so it no
574
582
  // longer equals plan.createdAt (record-layer real clock). It must
575
583
  // still be a canonical normalized timestamp -- the same pattern the
@@ -47,6 +47,7 @@ import { ReleaseError, GATE_FAILED } from './errors.mjs';
47
47
  import { computePlanDigest } from './plan.mjs';
48
48
  import { computeApprovalDigest, validateApprovalTimeWindow } from './approval.mjs';
49
49
  import { resolvePresetRequiresApproval } from './presets.mjs';
50
+ import { normalizePostPublishView, validatePostPublishHookIdUniqueness } from './postpublish.mjs';
50
51
  import { readTrustedPackageResource } from './trusted-resource.mjs';
51
52
 
52
53
  const postpublishApprovalSchema = JSON.parse((await readTrustedPackageResource(
@@ -79,7 +80,8 @@ export function validatePostPublishApprovalRecordSchema(approval) {
79
80
  * - record shape (postpublish-approval-record schema; additionalProperties
80
81
  * is false, so plan-level fields like approvedActions are rejected);
81
82
  * - planDigest equals the computed plan digest;
82
- * - hookId names a hook declared in the frozen plan's postPublish.hooks;
83
+ * - hookId names a hook declared in the frozen plan's postPublish
84
+ * declarations (unified normalized view, globally unique across units);
83
85
  * - that hook actually declares requiresApproval: true;
84
86
  * - the shared approval time window (24h max, 5-minute skew, unexpired).
85
87
  *
@@ -103,7 +105,15 @@ export function validatePostPublishApproval(plan, approval, options = {}) {
103
105
  );
104
106
  }
105
107
 
106
- const hooks = plan.postPublish?.hooks ?? [];
108
+ // §4.3 unified normalization (rework R-02): the frozen plan's postPublish
109
+ // may be the legacy single declaration (planVersion 1/2) or the v3
110
+ // declaration array; every consumer resolves declared hooks through the
111
+ // SAME normalized view, and the SAME array-level uniqueness authority
112
+ // re-asserts that hookId is globally unique across declarations — one
113
+ // (planDigest, hookId) approval must never authorize two units.
114
+ const declarations = normalizePostPublishView(plan);
115
+ validatePostPublishHookIdUniqueness(declarations);
116
+ const hooks = declarations.flatMap((declaration) => declaration.hooks ?? []);
107
117
  const hook = hooks.find((entry) => entry.id === approval.hookId);
108
118
  if (!hook) {
109
119
  throw new ReleaseError(
@@ -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']);