release-skill 0.1.10 → 0.2.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 (58) 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/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +19 -0
  6. package/INSTALL.md +4 -4
  7. package/INSTALL.zh-CN.md +4 -4
  8. package/README.md +17 -32
  9. package/README.zh-CN.md +17 -25
  10. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  11. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  12. package/adapters/claude/bin/release-skill.bundle.mjs +2721 -1843
  13. package/adapters/claude/schemas/.render-manifest.json +8 -8
  14. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  15. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  16. package/adapters/claude/schemas/release-project.schema.json +14 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
  19. package/adapters/codex/schemas/.render-manifest.json +8 -8
  20. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  21. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  22. package/adapters/codex/schemas/release-project.schema.json +14 -0
  23. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  24. package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
  25. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  26. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  27. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  28. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  29. package/bin/release-skill-cli.mjs +3 -0
  30. package/bin/release-skill.bundle.mjs +2721 -1843
  31. package/package.json +8 -2
  32. package/references/.render-manifest.json +8 -8
  33. package/references/01-state-machine.md +5 -5
  34. package/references/02-project-config.md +1 -1
  35. package/references/05-evidence-and-errors.md +1 -1
  36. package/references/06-adapter-contract.md +41 -1
  37. package/schemas/.render-manifest.json +8 -8
  38. package/schemas/approval-record.schema.json +1 -1
  39. package/schemas/release-plan.schema.json +6 -2
  40. package/schemas/release-project.schema.json +14 -0
  41. package/scripts/sync-public-files.mjs +462 -0
  42. package/src/adapters/contract.mjs +60 -0
  43. package/src/adapters/plugin-marketplace.mjs +289 -736
  44. package/src/commands/prepare.mjs +195 -182
  45. package/src/commands/publish.mjs +438 -122
  46. package/src/commands/reconcile.mjs +369 -191
  47. package/src/commands/verify.mjs +13 -2
  48. package/src/core/approval.mjs +72 -45
  49. package/src/core/baseline.mjs +5 -0
  50. package/src/core/checkpoints.mjs +143 -0
  51. package/src/core/evidence.mjs +30 -3
  52. package/src/core/hook-cache.mjs +254 -0
  53. package/src/core/hooks.mjs +37 -1
  54. package/src/core/observe-retry.mjs +223 -0
  55. package/src/core/plan.mjs +162 -253
  56. package/src/platforms/kimi.mjs +514 -0
  57. package/src/platforms/registry.mjs +393 -0
  58. package/src/producers/build-adapters.mjs +14 -22
@@ -28,6 +28,7 @@ const execFile = promisify(execFileCb);
28
28
  import { loadProjectConfig } from '../core/config.mjs';
29
29
  import { captureBaseline } from '../core/baseline.mjs';
30
30
  import { runHook } from '../core/hooks.mjs';
31
+ import { computeHookCacheKey, readHookCache, writeHookCache } from '../core/hook-cache.mjs';
31
32
  import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
32
33
  import { createEvidenceWriter } from '../core/evidence.mjs';
33
34
  import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
@@ -48,6 +49,7 @@ import { acquireProjectLock } from '../artifacts/project-lock.mjs';
48
49
  import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
49
50
  import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
50
51
  import { createProductionPrepareRunDir } from '../core/run.mjs';
52
+ import { PLATFORMS } from '../platforms/registry.mjs';
51
53
 
52
54
  // ---------------------------------------------------------------------------
53
55
  // Version resolution
@@ -200,17 +202,31 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
200
202
  /**
201
203
  * Run all declared project hooks in order: docs, build, test, typecheck.
202
204
  *
205
+ * Incremental cache (T3.2): a hook that opts in with `cacheable: true` and a
206
+ * non-empty `cacheInputs` is fingerprinted by its configuration plus the
207
+ * content of every matched input file. On an unchanged fingerprint whose last
208
+ * run succeeded, execution is skipped and the cached outcome replayed. The
209
+ * cache only ever skips execution — it runs after the hook authorization gate
210
+ * and never bypasses any GATE; hook order and failure semantics are unchanged.
211
+ * Failures (non-zero exit or HOOK_TIMEOUT) are never cached. A `cacheInputs`
212
+ * glob that matches nothing fails closed before the hook runs.
213
+ *
203
214
  * @param {object} config - The loaded project config.
204
215
  * @param {string} root - Absolute project root.
205
216
  * @param {object} evidence - The evidence writer.
206
217
  * @param {Function} [hookFn] - Hook runner (default runHook); tests inject a
207
218
  * spy that records call order while delegating to the real implementation.
219
+ * @param {object} [options]
220
+ * @param {boolean} [options.hookCache=true] - When false (--no-hook-cache),
221
+ * every hook runs in full and the cache is neither read nor written.
208
222
  * @returns {Promise<void>}
209
- * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code.
223
+ * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
224
+ * throws, or declares a cacheInputs glob that matches no file.
210
225
  */
211
- async function runDeclaredHooks(config, root, evidence, hookFn = runHook) {
226
+ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook, options = {}) {
212
227
  const hookOrder = ['docs', 'build', 'test', 'typecheck'];
213
228
  const hooks = config.hooks ?? {};
229
+ const cacheEnabled = options.hookCache !== false;
214
230
 
215
231
  for (const name of hookOrder) {
216
232
  const hook = hooks[name];
@@ -222,6 +238,36 @@ async function runDeclaredHooks(config, root, evidence, hookFn = runHook) {
222
238
  hookName: name,
223
239
  });
224
240
 
241
+ // --- Incremental cache lookup (opt-in only; default zero change) ---
242
+ let cacheKey;
243
+ if (cacheEnabled && hook.cacheable === true) {
244
+ try {
245
+ ({ cacheKey } = await computeHookCacheKey(hook, root));
246
+ } catch (err) {
247
+ await evidence.append({
248
+ phase: 'hooks',
249
+ status: 'failed',
250
+ hookName: name,
251
+ error: { code: err.code, message: err.message },
252
+ });
253
+ throw err;
254
+ }
255
+
256
+ const cached = await readHookCache(root, name, cacheKey);
257
+ if (cached) {
258
+ // Cache hit: skip execution. The authorization gate already passed and
259
+ // no GATE is bypassed — ordering and failure semantics are untouched.
260
+ await evidence.append({
261
+ phase: 'hooks',
262
+ status: 'completed',
263
+ hookName: name,
264
+ cached: true,
265
+ cacheKey,
266
+ });
267
+ continue;
268
+ }
269
+ }
270
+
225
271
  let result;
226
272
  try {
227
273
  result = await hookFn(hook, { root });
@@ -258,6 +304,26 @@ async function runDeclaredHooks(config, root, evidence, hookFn = runHook) {
258
304
  );
259
305
  }
260
306
 
307
+ // --- Write cache on success only; failures are never cached ---
308
+ if (cacheEnabled && hook.cacheable === true && cacheKey) {
309
+ const written = await writeHookCache(root, name, cacheKey, {
310
+ exitCode: 0,
311
+ stdoutTail: result.stdout.slice(-4000),
312
+ stderrTail: result.stderr.slice(-4000),
313
+ });
314
+ if (!written.ok) {
315
+ // The cache is an optimisation, not a gate: a write failure must not
316
+ // abort prepare. Surface it as a warning-level evidence event.
317
+ await evidence.append({
318
+ phase: 'hooks',
319
+ status: 'warning',
320
+ hookName: name,
321
+ warning: 'hook cache write failed; continuing without caching',
322
+ error: written.error,
323
+ });
324
+ }
325
+ }
326
+
261
327
  await evidence.append({
262
328
  phase: 'hooks',
263
329
  status: 'completed',
@@ -731,6 +797,50 @@ function normalizedProductionConfig(unit) {
731
797
  };
732
798
  }
733
799
 
800
+ /**
801
+ * Derive the deterministic freeze timestamp for planVersion 2 plans (design:
802
+ * t1-2-digest-decoupling.md §4.2): the baseline headCommit's committer date,
803
+ * read via `git show -s --format=%cI` and normalized to canonical UTC second
804
+ * precision with `normalizeGitTimestamp`.
805
+ *
806
+ * Same source commit -> same freeze timestamp -> byte-identical frozen
807
+ * release commit on every re-prepare. Failures fail closed with GATE_FAILED
808
+ * (prepare requires a readable Git repository); there is deliberately no
809
+ * fallback clock.
810
+ *
811
+ * @param {string} root - Repository root (git cwd).
812
+ * @param {string} headCommit - The baseline head commit object id.
813
+ * @param {Function} [exec] - Injectable exec (tests); defaults to execFile.
814
+ * @returns {Promise<string>} Canonical `YYYY-MM-DDTHH:MM:SS+00:00` timestamp.
815
+ * @throws {ReleaseError} GATE_FAILED when the committer date cannot be read
816
+ * or normalized.
817
+ */
818
+ export async function readHeadCommitTimestamp(root, headCommit, exec = execFile) {
819
+ try {
820
+ const { stdout } = await exec(
821
+ 'git',
822
+ ['show', '-s', '--format=%cI', headCommit],
823
+ { cwd: root, shell: false },
824
+ );
825
+ const committerDate = stdout.trim();
826
+ if (!committerDate) {
827
+ throw new ReleaseError(
828
+ GATE_FAILED,
829
+ 'plan freeze timestamp derivation returned an empty headCommit committer date',
830
+ { headCommit },
831
+ );
832
+ }
833
+ return normalizeGitTimestamp(committerDate, 'plan freeze timestamp');
834
+ } catch (error) {
835
+ if (error instanceof ReleaseError) throw error;
836
+ throw new ReleaseError(
837
+ GATE_FAILED,
838
+ 'plan freeze timestamp could not be derived from the headCommit committer date (git show -s --format=%cI failed); no fallback clock is used',
839
+ { headCommit, cause: error?.message ?? String(error) },
840
+ );
841
+ }
842
+ }
843
+
734
844
  async function buildProductionAssets(
735
845
  unitResults,
736
846
  resolvedVersions,
@@ -849,12 +959,6 @@ async function buildProductionAssets(
849
959
  function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
850
960
  const actions = [];
851
961
 
852
- const marketplaceIdentity = (distribution) => ({
853
- plugin: distribution.plugin,
854
- marketplace: distribution.marketplace,
855
- entrySkill: distribution.entrySkill,
856
- });
857
-
858
962
  if (!productionAssets) {
859
963
  for (let index = 0; index < unitResults.length; index += 1) {
860
964
  const { unit } = unitResults[index];
@@ -909,88 +1013,44 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
909
1013
  parameters: { publicRepo: unit.publicRepo, version },
910
1014
  status: 'PENDING',
911
1015
  });
912
- // Consumer marketplace install actions (only when distribution declared)
913
- const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
914
- if (claudeDist) {
915
- const identity = marketplaceIdentity(claudeDist);
916
- const claudeTimeoutMs = Number.isInteger(claudeDist.timeoutMs) ? claudeDist.timeoutMs : 300000;
917
- actions.push({
918
- id: `claude-marketplace-install-${unit.id}`,
919
- type: 'claude-marketplace-install',
920
- adapter: 'plugin-marketplace',
921
- unitId: unit.id,
922
- parameters: {
923
- consumer: 'claude',
924
- plugin: identity.plugin,
925
- marketplace: identity.marketplace,
926
- repo: unit.publicRepo,
927
- version,
928
- entrySkill: identity.entrySkill,
929
- timeoutMs: claudeTimeoutMs,
930
- },
931
- expected: {
932
- installed: true,
933
- plugin: identity.plugin,
934
- marketplace: identity.marketplace,
935
- version,
936
- entrySkill: identity.entrySkill,
937
- },
938
- status: 'PENDING',
939
- });
940
- }
941
- const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
942
- if (codexDist) {
943
- const identity = marketplaceIdentity(codexDist);
944
- const codexTimeoutMs = Number.isInteger(codexDist.timeoutMs) ? codexDist.timeoutMs : 300000;
1016
+ // Consumer marketplace install actions (only when distribution
1017
+ // declared), driven by the platform registry (T2.2 step 3): one loop
1018
+ // body for every platform; the registry declares the per-platform
1019
+ // differences (actionType, distributionType, adapter, and — via the
1020
+ // schema required fields marketplace identity, which kimi does not
1021
+ // carry: Kimi Code has no non-interactive install/marketplace API, so
1022
+ // the kimi action carries no marketplace identity (MINOR-1); plugin +
1023
+ // entrySkill are the meaningful identity fields there).
1024
+ for (const platform of PLATFORMS) {
1025
+ const dist = (unit.distributions ?? []).find((d) => d.type === platform.distributionType);
1026
+ if (!dist) continue;
1027
+ const requiresMarketplace = platform.schemaRequiredFields.includes('marketplace');
1028
+ const timeoutMs = Number.isInteger(dist.timeoutMs) ? dist.timeoutMs : 300000;
945
1029
  actions.push({
946
- id: `codex-marketplace-install-${unit.id}`,
947
- type: 'codex-marketplace-install',
948
- adapter: 'plugin-marketplace',
1030
+ id: `${platform.actionType}-${unit.id}`,
1031
+ type: platform.actionType,
1032
+ adapter: platform.adapter,
949
1033
  unitId: unit.id,
950
1034
  parameters: {
951
- consumer: 'codex',
952
- plugin: identity.plugin,
953
- marketplace: identity.marketplace,
1035
+ consumer: platform.id,
1036
+ plugin: dist.plugin,
1037
+ ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
954
1038
  repo: unit.publicRepo,
955
1039
  version,
956
- entrySkill: identity.entrySkill,
957
- timeoutMs: codexTimeoutMs,
1040
+ entrySkill: dist.entrySkill,
1041
+ timeoutMs,
1042
+ // Payload verification contract for new plans (T1.3): installed
1043
+ // payload is verified by declared-manifest containment; host-added
1044
+ // files are recorded, not failed. Frozen plans without this marker
1045
+ // keep the legacy full-tree equality semantics byte-for-byte.
1046
+ payloadContract: 'declared-manifest-v1',
958
1047
  },
959
1048
  expected: {
960
1049
  installed: true,
961
- plugin: identity.plugin,
962
- marketplace: identity.marketplace,
1050
+ plugin: dist.plugin,
1051
+ ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
963
1052
  version,
964
- entrySkill: identity.entrySkill,
965
- },
966
- status: 'PENDING',
967
- });
968
- }
969
- const kimiDist = (unit.distributions ?? []).find((d) => d.type === 'kimi-plugin');
970
- if (kimiDist) {
971
- const identity = marketplaceIdentity(kimiDist);
972
- const kimiTimeoutMs = Number.isInteger(kimiDist.timeoutMs) ? kimiDist.timeoutMs : 300000;
973
- // Kimi Code has no non-interactive install/marketplace API, so the kimi
974
- // action carries no marketplace identity (MINOR-1). plugin + entrySkill
975
- // are the meaningful identity fields.
976
- actions.push({
977
- id: `kimi-marketplace-install-${unit.id}`,
978
- type: 'kimi-marketplace-install',
979
- adapter: 'plugin-marketplace',
980
- unitId: unit.id,
981
- parameters: {
982
- consumer: 'kimi',
983
- plugin: identity.plugin,
984
- repo: unit.publicRepo,
985
- version,
986
- entrySkill: identity.entrySkill,
987
- timeoutMs: kimiTimeoutMs,
988
- },
989
- expected: {
990
- installed: true,
991
- plugin: identity.plugin,
992
- version,
993
- entrySkill: identity.entrySkill,
1053
+ entrySkill: dist.entrySkill,
994
1054
  },
995
1055
  status: 'PENDING',
996
1056
  });
@@ -1136,109 +1196,48 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
1136
1196
  status: 'PENDING',
1137
1197
  });
1138
1198
 
1139
- // Consumer marketplace install actions (only when distribution declared)
1140
- const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
1141
- if (claudeDist) {
1142
- const identity = marketplaceIdentity(claudeDist);
1143
- const claudeTimeoutMs = Number.isInteger(claudeDist.timeoutMs) ? claudeDist.timeoutMs : 300000;
1199
+ // Consumer marketplace install actions (only when distribution
1200
+ // declared), driven by the platform registry (T2.2 step 3): mirrors the
1201
+ // non-production loop above plus the production-only bindings
1202
+ // (ref/snapshotPath/manifestDigest parameters; consumer/repo/ref/
1203
+ // entrySkillFound/manifestDigest expected). Marketplace identity follows
1204
+ // the registry's schema required fields — kimi carries none (MINOR-1).
1205
+ for (const platform of PLATFORMS) {
1206
+ const dist = (unit.distributions ?? []).find((d) => d.type === platform.distributionType);
1207
+ if (!dist) continue;
1208
+ const requiresMarketplace = platform.schemaRequiredFields.includes('marketplace');
1209
+ const timeoutMs = Number.isInteger(dist.timeoutMs) ? dist.timeoutMs : 300000;
1144
1210
  actions.push({
1145
- id: `claude-marketplace-install-${unit.id}`,
1146
- type: 'claude-marketplace-install',
1147
- adapter: 'plugin-marketplace',
1211
+ id: `${platform.actionType}-${unit.id}`,
1212
+ type: platform.actionType,
1213
+ adapter: platform.adapter,
1148
1214
  unitId: unit.id,
1149
1215
  parameters: {
1150
- consumer: 'claude',
1151
- plugin: identity.plugin,
1152
- marketplace: identity.marketplace,
1216
+ consumer: platform.id,
1217
+ plugin: dist.plugin,
1218
+ ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
1153
1219
  repo: unit.publicRepo,
1154
1220
  ref: resolvedTag,
1155
1221
  version: unitVersion,
1156
- entrySkill: identity.entrySkill,
1222
+ entrySkill: dist.entrySkill,
1157
1223
  snapshotPath: asset.snapshotPath,
1158
1224
  manifestDigest: asset.manifestDigest,
1159
- timeoutMs: claudeTimeoutMs,
1225
+ timeoutMs,
1226
+ // Payload verification contract for new plans (T1.3): installed
1227
+ // payload is verified by declared-manifest containment; host-added
1228
+ // files are recorded, not failed. Frozen plans without this marker
1229
+ // keep the legacy full-tree equality semantics byte-for-byte.
1230
+ payloadContract: 'declared-manifest-v1',
1160
1231
  },
1161
1232
  expected: {
1162
1233
  installed: true,
1163
- consumer: 'claude',
1164
- plugin: identity.plugin,
1165
- marketplace: identity.marketplace,
1234
+ consumer: platform.id,
1235
+ plugin: dist.plugin,
1236
+ ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
1166
1237
  repo: unit.publicRepo,
1167
1238
  version: unitVersion,
1168
1239
  ref: resolvedTag,
1169
- entrySkill: identity.entrySkill,
1170
- entrySkillFound: true,
1171
- manifestDigest: asset.manifestDigest,
1172
- },
1173
- status: 'PENDING',
1174
- });
1175
- }
1176
- const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
1177
- if (codexDist) {
1178
- const identity = marketplaceIdentity(codexDist);
1179
- const codexTimeoutMs = Number.isInteger(codexDist.timeoutMs) ? codexDist.timeoutMs : 300000;
1180
- actions.push({
1181
- id: `codex-marketplace-install-${unit.id}`,
1182
- type: 'codex-marketplace-install',
1183
- adapter: 'plugin-marketplace',
1184
- unitId: unit.id,
1185
- parameters: {
1186
- consumer: 'codex',
1187
- plugin: identity.plugin,
1188
- marketplace: identity.marketplace,
1189
- repo: unit.publicRepo,
1190
- ref: resolvedTag,
1191
- version: unitVersion,
1192
- entrySkill: identity.entrySkill,
1193
- snapshotPath: asset.snapshotPath,
1194
- manifestDigest: asset.manifestDigest,
1195
- timeoutMs: codexTimeoutMs,
1196
- },
1197
- expected: {
1198
- installed: true,
1199
- consumer: 'codex',
1200
- plugin: identity.plugin,
1201
- marketplace: identity.marketplace,
1202
- repo: unit.publicRepo,
1203
- version: unitVersion,
1204
- ref: resolvedTag,
1205
- entrySkill: identity.entrySkill,
1206
- entrySkillFound: true,
1207
- manifestDigest: asset.manifestDigest,
1208
- },
1209
- status: 'PENDING',
1210
- });
1211
- }
1212
- const kimiDist = (unit.distributions ?? []).find((d) => d.type === 'kimi-plugin');
1213
- if (kimiDist) {
1214
- const identity = marketplaceIdentity(kimiDist);
1215
- const kimiTimeoutMs = Number.isInteger(kimiDist.timeoutMs) ? kimiDist.timeoutMs : 300000;
1216
- // No marketplace identity for kimi (MINOR-1): Kimi Code has an interactive
1217
- // marketplace but no non-interactive install API.
1218
- actions.push({
1219
- id: `kimi-marketplace-install-${unit.id}`,
1220
- type: 'kimi-marketplace-install',
1221
- adapter: 'plugin-marketplace',
1222
- unitId: unit.id,
1223
- parameters: {
1224
- consumer: 'kimi',
1225
- plugin: identity.plugin,
1226
- repo: unit.publicRepo,
1227
- ref: resolvedTag,
1228
- version: unitVersion,
1229
- entrySkill: identity.entrySkill,
1230
- snapshotPath: asset.snapshotPath,
1231
- manifestDigest: asset.manifestDigest,
1232
- timeoutMs: kimiTimeoutMs,
1233
- },
1234
- expected: {
1235
- installed: true,
1236
- consumer: 'kimi',
1237
- plugin: identity.plugin,
1238
- repo: unit.publicRepo,
1239
- version: unitVersion,
1240
- ref: resolvedTag,
1241
- entrySkill: identity.entrySkill,
1240
+ entrySkill: dist.entrySkill,
1242
1241
  entrySkillFound: true,
1243
1242
  manifestDigest: asset.manifestDigest,
1244
1243
  },
@@ -1280,6 +1279,9 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
1280
1279
  * @param {Function} [options.runHookFn] - Hook runner passed to
1281
1280
  * runDeclaredHooks (default runHook); tests inject a spy that records call
1282
1281
  * order while delegating to the real implementation.
1282
+ * @param {boolean} [options.hookCache=true] - When false (CLI --no-hook-cache),
1283
+ * every declared hook runs in full and the incremental hook cache is neither
1284
+ * read nor written.
1283
1285
  *
1284
1286
  * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
1285
1287
  *
@@ -1486,7 +1488,9 @@ export async function prepareRelease(options) {
1486
1488
 
1487
1489
  // --- Step 3: Run declared hooks ---
1488
1490
  await evidence.append({ phase: 'hooks', status: 'started' });
1489
- await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook);
1491
+ await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
1492
+ hookCache: options.hookCache,
1493
+ });
1490
1494
  await evidence.append({ phase: 'hooks', status: 'completed' });
1491
1495
 
1492
1496
  // --- Step 3b: Re-check release-document freshness AFTER hooks ---
@@ -1822,16 +1826,25 @@ export async function prepareRelease(options) {
1822
1826
  // --- Step 7: Build plan object ---
1823
1827
  await evidence.append({ phase: 'plan-assembly', status: 'started' });
1824
1828
 
1825
- // Production plans sample their freeze timestamp exactly once, before the
1826
- // first frozen Git object exists. This single canonical value becomes
1827
- // GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's frozen commit,
1828
- // every unit's frozenSnapshot.commitTimestamp, and plan.createdAt. It is
1829
- // thereby bound by the plan digest and the approval record; publish,
1830
- // retry, and reconcile consume it from the frozen plan and never re-read
1831
- // the wall clock. A missing or invalid injected value fails closed here,
1832
- // before any Git write.
1829
+ // New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
1830
+ // §4.2/§7). Production freeze timestamps are derived deterministically
1831
+ // from the baseline headCommit's committer date, before the first frozen
1832
+ // Git object exists. This single canonical value becomes
1833
+ // GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's frozen commit and
1834
+ // every unit's frozenSnapshot.commitTimestamp; identical sources freeze
1835
+ // byte-identical Git objects on every re-prepare. The wall-clock sample
1836
+ // is still validated here (fail closed before any Git write) and becomes
1837
+ // plan.createdAt -- record-layer real clock behind the 24h approval
1838
+ // window, no longer equal to the freeze timestamp for v2 plans. The v1
1839
+ // legacy path used this same sample as the freeze timestamp itself
1840
+ // (commitTimestamp == createdAt). publish, retry, and reconcile consume
1841
+ // the frozen value from the plan and never re-read the wall clock or
1842
+ // re-derive it.
1843
+ const createdAtTimestamp = production
1844
+ ? normalizeGitTimestamp(clock ? clock() : new Date().toISOString(), 'plan createdAt timestamp')
1845
+ : null;
1833
1846
  const freezeTimestamp = production
1834
- ? normalizeGitTimestamp(clock ? clock() : new Date().toISOString(), 'plan freeze timestamp')
1847
+ ? await readHeadCommitTimestamp(realRoot, baseline.gitHead)
1835
1848
  : null;
1836
1849
 
1837
1850
  const productionAssets = production
@@ -1884,7 +1897,7 @@ export async function prepareRelease(options) {
1884
1897
  const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
1885
1898
 
1886
1899
  const plan = {
1887
- planVersion: 1,
1900
+ planVersion: 2,
1888
1901
  status: 'PREPARED',
1889
1902
  baseline: {
1890
1903
  gitTreeHash: baseline.gitTreeHash,
@@ -1905,7 +1918,7 @@ export async function prepareRelease(options) {
1905
1918
  } : {}),
1906
1919
  units,
1907
1920
  externalActions,
1908
- createdAt: production ? freezeTimestamp : (clock ? clock() : new Date().toISOString()),
1921
+ createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
1909
1922
  };
1910
1923
 
1911
1924
  await evidence.append({