release-skill 0.1.5 → 0.1.7

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +4 -4
  4. package/CHANGELOG.md +108 -0
  5. package/INSTALL.md +81 -1
  6. package/INSTALL.zh-CN.md +69 -1
  7. package/LICENSE +1 -0
  8. package/NOTICE +10 -0
  9. package/README.md +181 -8
  10. package/README.zh-CN.md +153 -8
  11. package/adapters/claude/.claude-plugin/marketplace.json +3 -3
  12. package/adapters/claude/.claude-plugin/plugin.json +2 -2
  13. package/adapters/claude/bin/release-skill.bundle.mjs +14182 -10017
  14. package/adapters/claude/bin/release-skill.mjs +24 -4
  15. package/adapters/claude/native/safe-write/binding.gyp +2 -1
  16. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  17. package/adapters/claude/native/safe-write/prebuilds.json +1 -1
  18. package/adapters/claude/schemas/.render-manifest.json +10 -10
  19. package/adapters/claude/schemas/release-project.schema.json +118 -0
  20. package/adapters/claude/skills/release-help/SKILL.md +21 -0
  21. package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
  22. package/adapters/claude/skills/release-publish/SKILL.md +3 -1
  23. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  24. package/adapters/codex/.codex-plugin/plugin.json +4 -4
  25. package/adapters/codex/bin/release-skill.bundle.mjs +14182 -10017
  26. package/adapters/codex/bin/release-skill.mjs +24 -4
  27. package/adapters/codex/native/safe-write/binding.gyp +2 -1
  28. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  29. package/adapters/codex/native/safe-write/prebuilds.json +1 -1
  30. package/adapters/codex/schemas/.render-manifest.json +10 -10
  31. package/adapters/codex/schemas/release-project.schema.json +118 -0
  32. package/adapters/codex/skills/release-help/SKILL.md +21 -0
  33. package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
  34. package/adapters/codex/skills/release-publish/SKILL.md +3 -1
  35. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  36. package/bin/release-skill-cli.mjs +163 -4
  37. package/bin/release-skill.bundle.mjs +14182 -10017
  38. package/bin/release-skill.mjs +24 -4
  39. package/native/safe-write/binding.gyp +2 -1
  40. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  41. package/native/safe-write/prebuilds.json +1 -1
  42. package/package.json +13 -5
  43. package/references/.render-manifest.json +4 -4
  44. package/references/02-project-config.md +24 -0
  45. package/references/05-evidence-and-errors.md +5 -0
  46. package/schemas/.render-manifest.json +10 -10
  47. package/schemas/release-project.schema.json +118 -0
  48. package/scripts/build-bundle.mjs +15 -2
  49. package/skills/release-help/SKILL.md +21 -0
  50. package/skills/release-prepare/SKILL.md +17 -6
  51. package/skills/release-publish/SKILL.md +3 -1
  52. package/skills/release-reconcile/SKILL.md +1 -1
  53. package/skills-src/release-help/SKILL.md +21 -0
  54. package/skills-src/release-prepare/SKILL.md +17 -6
  55. package/skills-src/release-publish/SKILL.md +3 -1
  56. package/skills-src/release-reconcile/SKILL.md +1 -1
  57. package/src/artifacts/transaction-journal.mjs +1126 -105
  58. package/src/artifacts/transaction.mjs +313 -130
  59. package/src/commands/docs.mjs +332 -0
  60. package/src/commands/prepare.mjs +316 -17
  61. package/src/core/errors.mjs +64 -2
  62. package/src/core/redact.mjs +206 -0
  63. package/src/docs/changelog-renderer.mjs +853 -0
  64. package/src/docs/config.mjs +337 -0
  65. package/src/docs/notes-loader.mjs +432 -0
  66. package/src/docs/notes.mjs +553 -0
  67. package/src/docs/readme-renderer.mjs +647 -0
  68. package/src/docs/refresh-planner.mjs +542 -0
  69. 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
  // ---------------------------------------------------------------------------
@@ -985,6 +1208,13 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
985
1208
  * means the user accepts hook side-effect risks, not that hooks are safe.
986
1209
  * @param {boolean} [options.verificationGatesAuthorized] - Must be explicitly
987
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.
988
1218
  *
989
1219
  * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
990
1220
  *
@@ -1074,6 +1304,32 @@ export async function prepareRelease(options) {
1074
1304
  configDigest,
1075
1305
  });
1076
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
+
1077
1333
  // --- Step 2: Hook authorization gate ---
1078
1334
  // Hooks are user-configured arbitrary local processes without filesystem
1079
1335
  // or network isolation. They may write outside the project, access local
@@ -1165,9 +1421,55 @@ export async function prepareRelease(options) {
1165
1421
 
1166
1422
  // --- Step 3: Run declared hooks ---
1167
1423
  await evidence.append({ phase: 'hooks', status: 'started' });
1168
- await runDeclaredHooks(config, realRoot, evidence);
1424
+ await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook);
1169
1425
  await evidence.append({ phase: 'hooks', status: 'completed' });
1170
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
+
1171
1473
  // --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
1172
1474
  // reflects any file changes introduced by hooks) ---
1173
1475
  await evidence.append({ phase: 'baseline', status: 'started' });
@@ -1183,13 +1485,10 @@ export async function prepareRelease(options) {
1183
1485
  });
1184
1486
 
1185
1487
  // --- Step 4b: Per-unit previous public baseline observe ---
1186
- const configUnits = config.releaseUnits ?? [];
1187
- const resolvedVersions = await resolveAllUnitVersions(
1188
- configUnits,
1189
- realRoot,
1190
- version,
1191
- evidence,
1192
- );
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.
1193
1492
  const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
1194
1493
  try {
1195
1494
  const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
@@ -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