release-skill 0.1.4 → 0.1.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 (71) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/CHANGELOG.md +104 -0
  5. package/INSTALL.md +81 -1
  6. package/INSTALL.zh-CN.md +69 -1
  7. package/README.md +233 -8
  8. package/README.zh-CN.md +188 -8
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +14164 -9912
  12. package/adapters/claude/bin/release-skill.mjs +24 -4
  13. package/adapters/claude/native/safe-write/binding.gyp +2 -1
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +1 -1
  16. package/adapters/claude/schemas/.render-manifest.json +10 -10
  17. package/adapters/claude/schemas/release-project.schema.json +141 -0
  18. package/adapters/claude/skills/release-help/SKILL.md +21 -0
  19. package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
  20. package/adapters/claude/skills/release-publish/SKILL.md +3 -1
  21. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  22. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  23. package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
  24. package/adapters/codex/bin/release-skill.mjs +24 -4
  25. package/adapters/codex/native/safe-write/binding.gyp +2 -1
  26. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  27. package/adapters/codex/native/safe-write/prebuilds.json +1 -1
  28. package/adapters/codex/schemas/.render-manifest.json +10 -10
  29. package/adapters/codex/schemas/release-project.schema.json +141 -0
  30. package/adapters/codex/skills/release-help/SKILL.md +21 -0
  31. package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
  32. package/adapters/codex/skills/release-publish/SKILL.md +3 -1
  33. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  34. package/bin/release-skill-cli.mjs +163 -4
  35. package/bin/release-skill.bundle.mjs +14164 -9912
  36. package/bin/release-skill.mjs +24 -4
  37. package/native/safe-write/binding.gyp +2 -1
  38. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  39. package/native/safe-write/prebuilds.json +1 -1
  40. package/package.json +2 -2
  41. package/references/.render-manifest.json +4 -4
  42. package/references/02-project-config.md +24 -0
  43. package/references/05-evidence-and-errors.md +5 -0
  44. package/schemas/.render-manifest.json +10 -10
  45. package/schemas/release-project.schema.json +141 -0
  46. package/scripts/build-bundle.mjs +15 -2
  47. package/skills/release-help/SKILL.md +21 -0
  48. package/skills/release-prepare/SKILL.md +17 -6
  49. package/skills/release-publish/SKILL.md +3 -1
  50. package/skills/release-reconcile/SKILL.md +1 -1
  51. package/skills-src/release-help/SKILL.md +21 -0
  52. package/skills-src/release-prepare/SKILL.md +17 -6
  53. package/skills-src/release-publish/SKILL.md +3 -1
  54. package/skills-src/release-reconcile/SKILL.md +1 -1
  55. package/src/adapters/plugin-marketplace.mjs +70 -3
  56. package/src/artifacts/transaction-journal.mjs +1126 -105
  57. package/src/artifacts/transaction.mjs +313 -130
  58. package/src/commands/docs.mjs +332 -0
  59. package/src/commands/prepare.mjs +324 -17
  60. package/src/commands/reconcile.mjs +4 -1
  61. package/src/commands/verify.mjs +4 -1
  62. package/src/core/errors.mjs +64 -2
  63. package/src/core/plan.mjs +59 -1
  64. package/src/core/redact.mjs +206 -0
  65. package/src/docs/changelog-renderer.mjs +853 -0
  66. package/src/docs/config.mjs +337 -0
  67. package/src/docs/notes-loader.mjs +432 -0
  68. package/src/docs/notes.mjs +553 -0
  69. package/src/docs/readme-renderer.mjs +647 -0
  70. package/src/docs/refresh-planner.mjs +542 -0
  71. package/src/docs/refresh-service.mjs +675 -0
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Runs the full prepare pipeline in order:
5
5
  * 1. Load and validate project configuration
6
+ * 1b. Resolve authoritative versions and gate release-document freshness
7
+ * (read-only; blocks stale docs before hooks, baseline, snapshots,
8
+ * remote checks, and plan write; re-checked after hooks)
6
9
  * 2. Capture Git baseline (HEAD, tree hash, dirty files)
7
10
  * 3. Run project-declared hooks (build, test)
8
11
  * 4. For each release unit: build snapshot, scan for leakage, evaluate README
@@ -40,7 +43,7 @@ import {
40
43
  normalizeGitTimestamp,
41
44
  sealFrozenSnapshot,
42
45
  } from '../snapshot/frozen.mjs';
43
- import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED } from '../core/errors.mjs';
46
+ import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE } from '../core/errors.mjs';
44
47
  import { acquireProjectLock } from '../artifacts/project-lock.mjs';
45
48
  import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
46
49
  import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
@@ -54,19 +57,21 @@ import { createProductionPrepareRunDir } from '../core/run.mjs';
54
57
  * Resolve the target version for a release unit.
55
58
  *
56
59
  * Resolution rules:
57
- * 1. If explicitVersion is provided, use it (overrides everything).
58
- * 2. Otherwise, read from `<root>/<unit.source>/<unit.version.source>`.
60
+ * 1. The version is read AUTHORITATIVELY from
61
+ * `<root>/<unit.source>/<unit.version.source>`; it is never overridden.
62
+ * 2. An explicitVersion (when provided) is only a consistency ASSERTION:
63
+ * a mismatch fails closed with GATE_FAILED.
59
64
  * 3. Reject: absolute path, path escape, missing file, invalid JSON,
60
65
  * missing/empty version field.
61
66
  * 4. For v0.1: if multiple units resolve to different versions, fail closed.
62
67
  *
63
68
  * @param {object} unit - The release unit configuration.
64
69
  * @param {string} root - Absolute project root.
65
- * @param {string} [explicitVersion] - Explicit version override.
66
- * @returns {Promise<string>} The resolved version string.
70
+ * @param {string} [explicitVersion] - Explicit version consistency assertion.
71
+ * @returns {Promise<string>} The resolved authoritative version string.
67
72
  * @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
68
73
  */
69
- async function resolveUnitVersion(unit, root, explicitVersion) {
74
+ export async function resolveUnitVersion(unit, root, explicitVersion) {
70
75
  // Validate unit.version.source exists
71
76
  const versionSource = unit.version?.source;
72
77
  if (!versionSource || typeof versionSource !== 'string') {
@@ -157,7 +162,7 @@ async function resolveUnitVersion(unit, root, explicitVersion) {
157
162
  * @returns {Promise<string[]>} Array of resolved versions (one per unit).
158
163
  * @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
159
164
  */
160
- async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
165
+ export async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
161
166
  await evidence.append({ phase: 'version-resolution', status: 'started' });
162
167
 
163
168
  const resolvedVersions = [];
@@ -198,10 +203,12 @@ async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
198
203
  * @param {object} config - The loaded project config.
199
204
  * @param {string} root - Absolute project root.
200
205
  * @param {object} evidence - The evidence writer.
206
+ * @param {Function} [hookFn] - Hook runner (default runHook); tests inject a
207
+ * spy that records call order while delegating to the real implementation.
201
208
  * @returns {Promise<void>}
202
209
  * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code.
203
210
  */
204
- async function runDeclaredHooks(config, root, evidence) {
211
+ async function runDeclaredHooks(config, root, evidence, hookFn = runHook) {
205
212
  const hookOrder = ['docs', 'build', 'test', 'typecheck'];
206
213
  const hooks = config.hooks ?? {};
207
214
 
@@ -217,7 +224,7 @@ async function runDeclaredHooks(config, root, evidence) {
217
224
 
218
225
  let result;
219
226
  try {
220
- result = await runHook(hook, { root });
227
+ result = await hookFn(hook, { root });
221
228
  } catch (err) {
222
229
  await evidence.append({
223
230
  phase: 'hooks',
@@ -260,6 +267,222 @@ async function runDeclaredHooks(config, root, evidence) {
260
267
  }
261
268
  }
262
269
 
270
+ // ---------------------------------------------------------------------------
271
+ // Release-documents freshness gate
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Resolve the release-documents planner the freshness gate runs with.
276
+ *
277
+ * An injected planner (test spy or documented bypass) is returned unchanged.
278
+ * Otherwise the default planner is loaded LAZILY from the refresh service:
279
+ * src/docs/** joins the published package snapshot at the public-asset
280
+ * generation stage, so prepare.mjs must not carry a static import of it —
281
+ * every staged runtime file must stay importable from the minimal public
282
+ * distribution. The load happens only when a release unit actually
283
+ * configures releaseDocuments, and an unavailable module fails closed —
284
+ * the freshness gate is never silently skipped.
285
+ *
286
+ * @param {Function} [injected] - Injected planner (takes precedence).
287
+ * @returns {Promise<Function>} The planner to use.
288
+ * @throws {ReleaseError} GATE_FAILED when the default planner is unavailable.
289
+ */
290
+ async function resolveReleaseDocsPlanFn(injected) {
291
+ if (typeof injected === 'function') return injected;
292
+ let loaded;
293
+ try {
294
+ loaded = await import('../docs/refresh-service.mjs');
295
+ } catch (err) {
296
+ throw new ReleaseError(
297
+ GATE_FAILED,
298
+ 'the release-documents refresh planner is unavailable; the freshness gate cannot run',
299
+ { reason: 'RELEASE_DOCS_PLAN_UNAVAILABLE', cause: err?.code ?? 'UNKNOWN' },
300
+ );
301
+ }
302
+ if (typeof loaded.planReleaseDocsRefreshForUnit !== 'function') {
303
+ throw new ReleaseError(
304
+ GATE_FAILED,
305
+ 'the release-documents refresh planner is unavailable; the freshness gate cannot run',
306
+ { reason: 'RELEASE_DOCS_PLAN_UNAVAILABLE' },
307
+ );
308
+ }
309
+ return loaded.planReleaseDocsRefreshForUnit;
310
+ }
311
+
312
+ /**
313
+ * Read-only release-documents freshness gate
314
+ * (2026-07-21-release-docs-command-and-prepare-gate §5).
315
+ *
316
+ * Runs the SAME read-only refresh planner the standalone `docs refresh`
317
+ * dry-run uses, for every release unit that configures `releaseDocuments`:
318
+ *
319
+ * - no unit configures releaseDocuments → no check runs and no
320
+ * docs-freshness evidence is appended (legacy behaviour preserved);
321
+ * - a clean unit appends a `completed` docs-freshness event carrying its
322
+ * unitId and refreshDigest;
323
+ * - the first `changes` result appends a `blocking` event and throws
324
+ * RELEASE_DOCS_STALE with the exact dry-run/write argv the operator needs
325
+ * to refresh (canonical relative paths only — never bodies, absolute
326
+ * paths, or serialized bytes).
327
+ *
328
+ * When `expectedBindings` is supplied (the post-hook pass), a plan that
329
+ * re-renders clean is STILL compared against the pre-hook binding: hooks
330
+ * run as arbitrary local processes and may rewrite bytes the renderers
331
+ * deliberately preserve (for example text outside the managed regions).
332
+ * Any divergence from the validated binding — the refreshDigest or any
333
+ * per-target old digest — fails closed with RELEASE_DOCS_STALE so an
334
+ * inconsistent plan can never be frozen.
335
+ *
336
+ * The gate is strictly read-only: prepare never writes README/CHANGELOG
337
+ * implicitly. Refreshed bytes enter the baseline/workspace/snapshot/plan
338
+ * digests through the standalone docs command, which naturally invalidates
339
+ * approvals bound to the pre-refresh plan digest.
340
+ *
341
+ * @param {object} options
342
+ * @param {object[]} options.units - Release units from the loaded config.
343
+ * @param {string[]} options.resolvedVersions - Authoritative versions,
344
+ * index-aligned with `units` (resolved once before hooks, reused after).
345
+ * @param {string} options.root - Absolute project root.
346
+ * @param {object} options.config - The loaded project config.
347
+ * @param {object} options.evidence - The evidence writer.
348
+ * @param {Function} [options.planFn] - Read-only planner; tests inject spies
349
+ * or documented bypasses. When omitted, the shared
350
+ * planReleaseDocsRefreshForUnit is loaded lazily (see
351
+ * resolveReleaseDocsPlanFn — keeps the public boundary import-clean and
352
+ * fails closed when unavailable).
353
+ * @param {string} options.reasonTag - Gate-pass identifier bound into the
354
+ * evidence and error details ('RELEASE_DOCS_STALE' before hooks,
355
+ * 'CHANGES_AFTER_HOOKS' after hooks).
356
+ * @param {Map<string, { refreshDigest: string, files: Map<string, string> }>}
357
+ * [options.expectedBindings] - Pre-hook bindings (unitId → refreshDigest +
358
+ * canonical target path → old digest) the post-hook pass fails closed
359
+ * against. Omitted on the pre-hook pass.
360
+ * @returns {Promise<Map<string, { refreshDigest: string, files: Map<string, string> }>>}
361
+ * The bindings observed on this pass (empty when no unit configures
362
+ * releaseDocuments); the pre-hook pass result feeds the post-hook pass.
363
+ * @throws {ReleaseError} RELEASE_DOCS_STALE when any configured unit is stale
364
+ * or drifted from the expected binding.
365
+ */
366
+ async function runReleaseDocsFreshnessGate({
367
+ units,
368
+ resolvedVersions,
369
+ root,
370
+ config,
371
+ evidence,
372
+ planFn,
373
+ reasonTag,
374
+ expectedBindings = null,
375
+ }) {
376
+ const configured = [];
377
+ for (let index = 0; index < units.length; index += 1) {
378
+ const unit = units[index];
379
+ if (unit && unit.releaseDocuments !== undefined && unit.releaseDocuments !== null) {
380
+ configured.push({ unit, index });
381
+ }
382
+ }
383
+ const bindings = new Map();
384
+ if (configured.length === 0) return bindings;
385
+
386
+ const effectivePlanFn = await resolveReleaseDocsPlanFn(planFn);
387
+
388
+ await evidence.append({ phase: 'docs-freshness', status: 'started', reasonTag });
389
+
390
+ for (const { unit, index } of configured) {
391
+ const { display } = await effectivePlanFn({
392
+ root,
393
+ config,
394
+ unit,
395
+ version: resolvedVersions[index],
396
+ });
397
+
398
+ bindings.set(unit.id, {
399
+ refreshDigest: display.refreshDigest,
400
+ files: new Map(display.files.map((file) => [file.path, file.oldDigest])),
401
+ });
402
+
403
+ if (display.status === 'clean') {
404
+ // Bound-change detection (post-hook pass): a hook may rewrite bytes
405
+ // the renderers preserve, which still re-plan clean. Compare against
406
+ // the pre-hook binding and fail closed on ANY divergence.
407
+ const expected = expectedBindings?.get(unit.id);
408
+ const driftedFiles = expected
409
+ ? display.files.filter((file) => expected.files.get(file.path) !== file.oldDigest)
410
+ : [];
411
+ if (expected && (display.refreshDigest !== expected.refreshDigest || driftedFiles.length > 0)) {
412
+ await evidence.append({
413
+ phase: 'docs-freshness',
414
+ status: 'blocking',
415
+ unitId: unit.id,
416
+ reason: reasonTag,
417
+ refreshDigest: display.refreshDigest,
418
+ changedPaths: driftedFiles.map((file) => file.path),
419
+ });
420
+ throw new ReleaseError(
421
+ RELEASE_DOCS_STALE,
422
+ `release documents for unit "${unit.id}" changed after hooks`,
423
+ {
424
+ reason: reasonTag,
425
+ unitId: unit.id,
426
+ version: resolvedVersions[index],
427
+ refreshDigest: display.refreshDigest,
428
+ changedPaths: driftedFiles.map((file) => file.path),
429
+ files: driftedFiles.map(({ path, kind, locale, change, oldDigest, newDigest }) => ({
430
+ path,
431
+ kind,
432
+ locale,
433
+ change,
434
+ oldDigest,
435
+ newDigest,
436
+ })),
437
+ dryRunArgv: [...display.nextCommand.argv],
438
+ writeArgv: display.nextCommand.writeArgv ? [...display.nextCommand.writeArgv] : null,
439
+ },
440
+ );
441
+ }
442
+ await evidence.append({
443
+ phase: 'docs-freshness',
444
+ status: 'completed',
445
+ unitId: unit.id,
446
+ refreshDigest: display.refreshDigest,
447
+ });
448
+ continue;
449
+ }
450
+
451
+ const changedFiles = display.files.filter((file) => file.changed);
452
+ await evidence.append({
453
+ phase: 'docs-freshness',
454
+ status: 'blocking',
455
+ unitId: unit.id,
456
+ reason: reasonTag,
457
+ refreshDigest: display.refreshDigest,
458
+ changedPaths: changedFiles.map((file) => file.path),
459
+ });
460
+ throw new ReleaseError(
461
+ RELEASE_DOCS_STALE,
462
+ `release documents are stale for unit "${unit.id}"`,
463
+ {
464
+ reason: reasonTag,
465
+ unitId: unit.id,
466
+ version: resolvedVersions[index],
467
+ refreshDigest: display.refreshDigest,
468
+ changedPaths: changedFiles.map((file) => file.path),
469
+ files: changedFiles.map(({ path, kind, locale, change, oldDigest, newDigest }) => ({
470
+ path,
471
+ kind,
472
+ locale,
473
+ change,
474
+ oldDigest,
475
+ newDigest,
476
+ })),
477
+ dryRunArgv: [...display.nextCommand.argv],
478
+ writeArgv: [...display.nextCommand.writeArgv],
479
+ },
480
+ );
481
+ }
482
+
483
+ return bindings;
484
+ }
485
+
263
486
  // ---------------------------------------------------------------------------
264
487
  // Snapshot pipeline
265
488
  // ---------------------------------------------------------------------------
@@ -690,6 +913,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
690
913
  const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
691
914
  if (claudeDist) {
692
915
  const identity = marketplaceIdentity(claudeDist);
916
+ const claudeTimeoutMs = Number.isInteger(claudeDist.timeoutMs) ? claudeDist.timeoutMs : 300000;
693
917
  actions.push({
694
918
  id: `claude-marketplace-install-${unit.id}`,
695
919
  type: 'claude-marketplace-install',
@@ -702,6 +926,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
702
926
  repo: unit.publicRepo,
703
927
  version,
704
928
  entrySkill: identity.entrySkill,
929
+ timeoutMs: claudeTimeoutMs,
705
930
  },
706
931
  expected: {
707
932
  installed: true,
@@ -716,6 +941,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
716
941
  const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
717
942
  if (codexDist) {
718
943
  const identity = marketplaceIdentity(codexDist);
944
+ const codexTimeoutMs = Number.isInteger(codexDist.timeoutMs) ? codexDist.timeoutMs : 300000;
719
945
  actions.push({
720
946
  id: `codex-marketplace-install-${unit.id}`,
721
947
  type: 'codex-marketplace-install',
@@ -728,6 +954,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
728
954
  repo: unit.publicRepo,
729
955
  version,
730
956
  entrySkill: identity.entrySkill,
957
+ timeoutMs: codexTimeoutMs,
731
958
  },
732
959
  expected: {
733
960
  installed: true,
@@ -884,6 +1111,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
884
1111
  const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
885
1112
  if (claudeDist) {
886
1113
  const identity = marketplaceIdentity(claudeDist);
1114
+ const claudeTimeoutMs = Number.isInteger(claudeDist.timeoutMs) ? claudeDist.timeoutMs : 300000;
887
1115
  actions.push({
888
1116
  id: `claude-marketplace-install-${unit.id}`,
889
1117
  type: 'claude-marketplace-install',
@@ -899,6 +1127,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
899
1127
  entrySkill: identity.entrySkill,
900
1128
  snapshotPath: asset.snapshotPath,
901
1129
  manifestDigest: asset.manifestDigest,
1130
+ timeoutMs: claudeTimeoutMs,
902
1131
  },
903
1132
  expected: {
904
1133
  installed: true,
@@ -918,6 +1147,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
918
1147
  const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
919
1148
  if (codexDist) {
920
1149
  const identity = marketplaceIdentity(codexDist);
1150
+ const codexTimeoutMs = Number.isInteger(codexDist.timeoutMs) ? codexDist.timeoutMs : 300000;
921
1151
  actions.push({
922
1152
  id: `codex-marketplace-install-${unit.id}`,
923
1153
  type: 'codex-marketplace-install',
@@ -933,6 +1163,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
933
1163
  entrySkill: identity.entrySkill,
934
1164
  snapshotPath: asset.snapshotPath,
935
1165
  manifestDigest: asset.manifestDigest,
1166
+ timeoutMs: codexTimeoutMs,
936
1167
  },
937
1168
  expected: {
938
1169
  installed: true,
@@ -977,6 +1208,13 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
977
1208
  * means the user accepts hook side-effect risks, not that hooks are safe.
978
1209
  * @param {boolean} [options.verificationGatesAuthorized] - Must be explicitly
979
1210
  * true when project verification gates are declared.
1211
+ * @param {Function} [options.releaseDocsPlanFn] - Read-only release-documents
1212
+ * planner used by the freshness gate (default
1213
+ * planReleaseDocsRefreshForUnit); tests inject spies or documented
1214
+ * bypasses. Prepare itself never writes README/CHANGELOG.
1215
+ * @param {Function} [options.runHookFn] - Hook runner passed to
1216
+ * runDeclaredHooks (default runHook); tests inject a spy that records call
1217
+ * order while delegating to the real implementation.
980
1218
  *
981
1219
  * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
982
1220
  *
@@ -1066,6 +1304,32 @@ export async function prepareRelease(options) {
1066
1304
  configDigest,
1067
1305
  });
1068
1306
 
1307
+ // --- Step 1b: Resolve authoritative versions and gate release-document
1308
+ // freshness BEFORE hook authorization ---
1309
+ // Authoritative versions resolve exactly once here and are reused by
1310
+ // every downstream consumer, so a hook can never silently switch the
1311
+ // version a plan binds. Units that configure releaseDocuments must be
1312
+ // clean under the read-only refresh planner before any hook, verification
1313
+ // gate, baseline, snapshot, remote check, or plan write runs. Units
1314
+ // without releaseDocuments keep the exact legacy behaviour (the gate
1315
+ // appends no evidence and performs no check).
1316
+ const configUnits = config.releaseUnits ?? [];
1317
+ const resolvedVersions = await resolveAllUnitVersions(
1318
+ configUnits,
1319
+ realRoot,
1320
+ version,
1321
+ evidence,
1322
+ );
1323
+ const preHookDocsBindings = await runReleaseDocsFreshnessGate({
1324
+ units: configUnits,
1325
+ resolvedVersions,
1326
+ root: realRoot,
1327
+ config,
1328
+ evidence,
1329
+ planFn: options.releaseDocsPlanFn,
1330
+ reasonTag: 'RELEASE_DOCS_STALE',
1331
+ });
1332
+
1069
1333
  // --- Step 2: Hook authorization gate ---
1070
1334
  // Hooks are user-configured arbitrary local processes without filesystem
1071
1335
  // or network isolation. They may write outside the project, access local
@@ -1157,9 +1421,55 @@ export async function prepareRelease(options) {
1157
1421
 
1158
1422
  // --- Step 3: Run declared hooks ---
1159
1423
  await evidence.append({ phase: 'hooks', status: 'started' });
1160
- await runDeclaredHooks(config, realRoot, evidence);
1424
+ await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook);
1161
1425
  await evidence.append({ phase: 'hooks', status: 'completed' });
1162
1426
 
1427
+ // --- Step 3b: Re-check release-document freshness AFTER hooks ---
1428
+ // Declared hooks run as arbitrary local processes; they may rewrite a
1429
+ // release unit's authoritative version source or any declared release
1430
+ // document. Re-bind the pre-hook authoritative versions and re-run the
1431
+ // same read-only planner gate; any drift or new change fails closed
1432
+ // BEFORE the baseline, snapshots, remote checks, and plan write, so an
1433
+ // inconsistent plan can never be frozen. Skipped entirely when no unit
1434
+ // configures releaseDocuments (legacy behaviour preserved).
1435
+ if (configUnits.some((unit) => unit && unit.releaseDocuments)) {
1436
+ for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
1437
+ const postHookVersion = await resolveUnitVersion(
1438
+ configUnits[unitIndex],
1439
+ realRoot,
1440
+ version,
1441
+ );
1442
+ if (postHookVersion !== resolvedVersions[unitIndex]) {
1443
+ await evidence.append({
1444
+ phase: 'docs-freshness',
1445
+ status: 'blocking',
1446
+ unitId: configUnits[unitIndex].id,
1447
+ reason: 'VERSION_DRIFT_AFTER_HOOKS',
1448
+ });
1449
+ throw new ReleaseError(
1450
+ RELEASE_DOCS_STALE,
1451
+ `release unit "${configUnits[unitIndex].id}" authoritative version changed after hooks`,
1452
+ {
1453
+ reason: 'VERSION_DRIFT_AFTER_HOOKS',
1454
+ unitId: configUnits[unitIndex].id,
1455
+ version: resolvedVersions[unitIndex],
1456
+ currentVersion: postHookVersion,
1457
+ },
1458
+ );
1459
+ }
1460
+ }
1461
+ await runReleaseDocsFreshnessGate({
1462
+ units: configUnits,
1463
+ resolvedVersions,
1464
+ root: realRoot,
1465
+ config,
1466
+ evidence,
1467
+ planFn: options.releaseDocsPlanFn,
1468
+ reasonTag: 'CHANGES_AFTER_HOOKS',
1469
+ expectedBindings: preHookDocsBindings,
1470
+ });
1471
+ }
1472
+
1163
1473
  // --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
1164
1474
  // reflects any file changes introduced by hooks) ---
1165
1475
  await evidence.append({ phase: 'baseline', status: 'started' });
@@ -1175,13 +1485,10 @@ export async function prepareRelease(options) {
1175
1485
  });
1176
1486
 
1177
1487
  // --- Step 4b: Per-unit previous public baseline observe ---
1178
- const configUnits = config.releaseUnits ?? [];
1179
- const resolvedVersions = await resolveAllUnitVersions(
1180
- configUnits,
1181
- realRoot,
1182
- version,
1183
- evidence,
1184
- );
1488
+ // `configUnits` and `resolvedVersions` were resolved once in Step 1b
1489
+ // (before hook authorization and the docs freshness gate) and are reused
1490
+ // here verbatim, so the frozen plan binds exactly the versions the
1491
+ // pre-hook gate validated.
1185
1492
  const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
1186
1493
  try {
1187
1494
  const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
@@ -222,7 +222,10 @@ export async function reconcileRelease(options) {
222
222
  // =======================================================================
223
223
  await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'started' });
224
224
 
225
- const completenessResult = validatePlanActionCompleteness(plan);
225
+ // Use legacyCompatibility: old PARTIAL plans (pre-v0.1.5) lack
226
+ // parameters.timeoutMs. Reconcile must still pass these plans,
227
+ // while strict mode (prepare/approve/publish) rejects them.
228
+ const completenessResult = validatePlanActionCompleteness(plan, { legacyCompatibility: true });
226
229
  if (!completenessResult.passed) {
227
230
  await evidence.append({
228
231
  phase: 'safety-gate',
@@ -669,7 +669,10 @@ export async function verifyRelease(options) {
669
669
  }
670
670
 
671
671
  // Validate plan action completeness before checkpoint mapping
672
- const completenessResult = validatePlanActionCompleteness(plan);
672
+ // Use legacyCompatibility: old plans (pre-v0.1.5) lack
673
+ // parameters.timeoutMs. Verify must still pass these plans,
674
+ // while strict mode (prepare/approve/publish) rejects them.
675
+ const completenessResult = validatePlanActionCompleteness(plan, { legacyCompatibility: true });
673
676
  if (!completenessResult.passed) {
674
677
  throw new ReleaseError(
675
678
  GATE_FAILED,
@@ -1,6 +1,53 @@
1
1
  // Stable error codes and exit codes for the release-skill system.
2
2
  // Error codes are grouped by phase; each maps to a unique stable exit code.
3
3
 
4
+ // Defect #3 choke-point dependency: the deep redaction authority lives in
5
+ // core/redact.mjs (pure, zero-dependency). It must load WITHOUT any top-level
6
+ // await: a TLA here made esbuild turn this module's bundled init block async,
7
+ // and the artifacts tree/entry import cycle then deadlocked during bundled
8
+ // command initialization, so `await import(bundlePath)` in
9
+ // bin/release-skill.mjs never settled and Node exited with code 13
10
+ // "Detected unsettled top-level await" instead of the real business exit code
11
+ // (AC-7). Two TLA-free mechanisms install the redactor instead:
12
+ //
13
+ // 1. Eager registration: real entries (bin/release-skill-cli.mjs, therefore
14
+ // also the self-contained bundle and the Claude/Codex adapters built from
15
+ // it) import errors.mjs and redact.mjs statically and call
16
+ // registerPathRedactor() synchronously before any command code runs —
17
+ // deterministic in source and bundled form, no microtask window.
18
+ // 2. Self-load fallback: the fire-and-forget dynamic import below covers
19
+ // consumers that import errors.mjs alone (unit tests and the
20
+ // artifacts-safe-fs production-loader subprocess fixtures). It is
21
+ // deliberately NOT awaited: this module stays synchronously loadable (no
22
+ // TLA in the module graph), and a missing redact.mjs (isolated fixtures
23
+ // that copy errors.mjs alone) simply degrades to the identity function via
24
+ // the rejection handler. In every real deployment redact.mjs ships
25
+ // alongside errors.mjs (enforced by error-path-redaction.test.mjs and the
26
+ // bundle/adapters --check gates), so the constructor below always redacts
27
+ // in production.
28
+ let redactSensitivePaths = (value) => value;
29
+
30
+ /**
31
+ * Install the path-redaction authority used by the ReleaseError choke point.
32
+ * Idempotent; non-function arguments are ignored so a bad caller can never
33
+ * disable redaction. Called eagerly by CLI entries (static import, before any
34
+ * command runs) and by the self-load fallback below.
35
+ *
36
+ * @param {(value: unknown) => unknown} fn deep redaction from core/redact.mjs.
37
+ */
38
+ export function registerPathRedactor(fn) {
39
+ if (typeof fn === 'function') redactSensitivePaths = fn;
40
+ }
41
+
42
+ // Fire-and-forget self-load (never awaited → no top-level await). The
43
+ // rejection handler is attached synchronously in the same tick, so an absent
44
+ // redact.mjs degrades to identity without ever producing an unhandled
45
+ // rejection or keeping the event loop alive.
46
+ import('./redact.mjs').then(
47
+ (mod) => registerPathRedactor(mod.redactSensitivePaths),
48
+ () => { /* isolated copy without redact.mjs: keep identity redaction */ },
49
+ );
50
+
4
51
  /** @type {Readonly<Record<string, number>>} */
5
52
  const EXIT_CODE_MAP = Object.freeze({
6
53
  CONFIG_INVALID: 10,
@@ -35,6 +82,11 @@ const EXIT_CODE_MAP = Object.freeze({
35
82
  SAFE_WRITE_UNAVAILABLE: 39,
36
83
  SETUP_DIGEST_MISMATCH: 40,
37
84
  CONFIG_EXISTS: 41,
85
+ RELEASE_DOCS_INVALID: 42,
86
+ RELEASE_DOCS_TRANSLATION_MISSING: 43,
87
+ RELEASE_DOCS_CONFLICT: 44,
88
+ RELEASE_DOCS_REFRESH_STALE: 45,
89
+ RELEASE_DOCS_STALE: 46,
38
90
  });
39
91
 
40
92
  // ---- Error code constants ----
@@ -71,6 +123,11 @@ export const TRANSACTION_INCOMPLETE = 'TRANSACTION_INCOMPLETE';
71
123
  export const SAFE_WRITE_UNAVAILABLE = 'SAFE_WRITE_UNAVAILABLE';
72
124
  export const SETUP_DIGEST_MISMATCH = 'SETUP_DIGEST_MISMATCH';
73
125
  export const CONFIG_EXISTS = 'CONFIG_EXISTS';
126
+ export const RELEASE_DOCS_INVALID = 'RELEASE_DOCS_INVALID';
127
+ export const RELEASE_DOCS_TRANSLATION_MISSING = 'RELEASE_DOCS_TRANSLATION_MISSING';
128
+ export const RELEASE_DOCS_CONFLICT = 'RELEASE_DOCS_CONFLICT';
129
+ export const RELEASE_DOCS_REFRESH_STALE = 'RELEASE_DOCS_REFRESH_STALE';
130
+ export const RELEASE_DOCS_STALE = 'RELEASE_DOCS_STALE';
74
131
 
75
132
  /**
76
133
  * Typed error for release-skill operations.
@@ -82,10 +139,15 @@ export const CONFIG_EXISTS = 'CONFIG_EXISTS';
82
139
  */
83
140
  export class ReleaseError extends Error {
84
141
  constructor(code, message, details = {}, exitCode) {
85
- super(message);
142
+ // Defect #3 choke point: redact absolute filesystem paths from the
143
+ // message and details before assignment so every consumer (all CLI
144
+ // catches in text and JSON modes, recoverable-error propagation chains,
145
+ // and toJSON) can only observe redacted values. Error codes, exit codes,
146
+ // and the envelope key set are unchanged; redaction is value-level only.
147
+ super(redactSensitivePaths(message));
86
148
  this.name = 'ReleaseError';
87
149
  this.code = code;
88
- this.details = details;
150
+ this.details = redactSensitivePaths(details);
89
151
  this.exitCode = exitCode ?? EXIT_CODE_MAP[code] ?? 1;
90
152
 
91
153
  // Maintain proper stack trace in V8 environments