release-skill 0.1.10 → 0.2.1

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +33 -0
  7. package/INSTALL.md +24 -4
  8. package/INSTALL.zh-CN.md +22 -4
  9. package/README.md +19 -32
  10. package/README.zh-CN.md +14 -25
  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.bundle.mjs +2721 -1843
  14. package/adapters/claude/schemas/.render-manifest.json +8 -8
  15. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  16. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  17. package/adapters/claude/schemas/release-project.schema.json +14 -0
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
  20. package/adapters/codex/schemas/.render-manifest.json +8 -8
  21. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  22. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  23. package/adapters/codex/schemas/release-project.schema.json +14 -0
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
  26. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  27. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  28. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  29. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +10 -0
  31. package/adapters/workbuddy/bin/release-skill.bundle.mjs +85363 -0
  32. package/adapters/workbuddy/bin/release-skill.mjs +54 -0
  33. package/adapters/workbuddy/native/safe-write/binding.gyp +41 -0
  34. package/adapters/workbuddy/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  35. package/adapters/workbuddy/native/safe-write/prebuilds.json +24 -0
  36. package/adapters/workbuddy/native/safe-write/src/safe_write.cc +2032 -0
  37. package/adapters/workbuddy/schemas/.render-manifest.json +37 -0
  38. package/adapters/workbuddy/schemas/approval-record.schema.json +115 -0
  39. package/adapters/workbuddy/schemas/artifact-lock.schema.json +111 -0
  40. package/adapters/workbuddy/schemas/artifact-plan.schema.json +52 -0
  41. package/adapters/workbuddy/schemas/artifact-policy.schema.json +76 -0
  42. package/adapters/workbuddy/schemas/evidence-event.schema.json +89 -0
  43. package/adapters/workbuddy/schemas/release-plan.schema.json +882 -0
  44. package/adapters/workbuddy/schemas/release-project.schema.json +909 -0
  45. package/adapters/workbuddy/schemas/release-run.schema.json +343 -0
  46. package/adapters/workbuddy/skills/release-assess/SKILL.md +51 -0
  47. package/adapters/workbuddy/skills/release-help/SKILL.md +77 -0
  48. package/adapters/workbuddy/skills/release-prepare/SKILL.md +92 -0
  49. package/adapters/workbuddy/skills/release-publish/SKILL.md +57 -0
  50. package/adapters/workbuddy/skills/release-reconcile/SKILL.md +73 -0
  51. package/adapters/workbuddy/skills/release-setup/SKILL.md +95 -0
  52. package/adapters/workbuddy/skills/release-verify/SKILL.md +70 -0
  53. package/bin/release-skill-cli.mjs +3 -0
  54. package/bin/release-skill.bundle.mjs +2721 -1843
  55. package/package.json +9 -2
  56. package/references/.render-manifest.json +8 -8
  57. package/references/01-state-machine.md +5 -5
  58. package/references/02-project-config.md +1 -1
  59. package/references/05-evidence-and-errors.md +1 -1
  60. package/references/06-adapter-contract.md +41 -1
  61. package/schemas/.render-manifest.json +8 -8
  62. package/schemas/approval-record.schema.json +1 -1
  63. package/schemas/release-plan.schema.json +6 -2
  64. package/schemas/release-project.schema.json +14 -0
  65. package/scripts/sync-public-files.mjs +481 -0
  66. package/src/adapters/contract.mjs +60 -0
  67. package/src/adapters/plugin-marketplace.mjs +289 -736
  68. package/src/commands/prepare.mjs +195 -182
  69. package/src/commands/publish.mjs +438 -122
  70. package/src/commands/reconcile.mjs +369 -191
  71. package/src/commands/verify.mjs +13 -2
  72. package/src/core/approval.mjs +72 -45
  73. package/src/core/baseline.mjs +5 -0
  74. package/src/core/checkpoints.mjs +143 -0
  75. package/src/core/evidence.mjs +30 -3
  76. package/src/core/hook-cache.mjs +254 -0
  77. package/src/core/hooks.mjs +37 -1
  78. package/src/core/observe-retry.mjs +223 -0
  79. package/src/core/plan.mjs +162 -253
  80. package/src/platforms/kimi.mjs +514 -0
  81. package/src/platforms/registry.mjs +393 -0
  82. package/src/producers/build-adapters.mjs +49 -23
@@ -120,7 +120,10 @@ function matchesSubset(actual, expected) {
120
120
  * - When smokeBin is configured: the specified bin is resolved, validated
121
121
  * against path-escape/symlink/non-regular-file guards, and executed with
122
122
  * smokeArgs; output is validated against smokeExpectedJson (recursive
123
- * subset match) when present.
123
+ * subset match) when present. The expected `version` field is injected at
124
+ * runtime from the unit's resolved targetVersion and overrides any
125
+ * config-declared value, so the version check never depends on a
126
+ * hand-written config version (T2.1 §4.3).
124
127
  * - When smokeBin is not configured: install + name/version check passes
125
128
  * immediately; runBin is never called; result records
126
129
  * cliSmoke: "not-configured".
@@ -162,7 +165,15 @@ export async function runSmokeTest(plan, root, options = {}) {
162
165
  unitId: unit.id,
163
166
  smokeBin: dist.smokeBin,
164
167
  smokeArgs: dist.smokeArgs ?? [],
165
- smokeExpectedJson: dist.smokeExpectedJson,
168
+ // The expected `version` is always the unit's resolved
169
+ // targetVersion (whose source is version.source → package.json),
170
+ // injected at runtime. A config-declared smokeExpectedJson.version
171
+ // is redundant and overridden. This keeps the version check strong
172
+ // without hand-writing the version into project config, so a
173
+ // version bump never churns configDigest (T2.1 §4.3).
174
+ smokeExpectedJson: dist.smokeExpectedJson
175
+ ? { ...dist.smokeExpectedJson, version: unit.targetVersion }
176
+ : dist.smokeExpectedJson,
166
177
  });
167
178
  }
168
179
  }
@@ -4,13 +4,21 @@
4
4
  * Centralises the safety gates that an approval record must pass before
5
5
  * any external write actions can proceed:
6
6
  * - planDigest matches the computed plan digest
7
- * - baseline.gitTreeHash matches the plan baseline
7
+ * - baseline.gitTreeHash matches the plan baseline (v1 plans only)
8
8
  * - targetVersion matches the plan's first unit target version
9
9
  * - approvedActions exactly equals plan external action ids (no superset, no subset)
10
10
  * - approval has not expired
11
11
  * - approval duration does not exceed 24 hours
12
12
  * - approvedAt is not in the future (beyond 5-minute clock skew tolerance)
13
13
  *
14
+ * The planVersion fork (design: t1-2-digest-decoupling.md §4.3) is
15
+ * centralized here: for planVersion 2 plans the baseline is record-layer
16
+ * data -- gitTreeHash/workspaceDigest equality and the production workspace
17
+ * digest algorithm double-check are NOT invalidation conditions (artifact
18
+ * integrity is sealed by the frozen-artifact re-verification at publish).
19
+ * Version, action-list, planDigest, and time-window bindings are preserved
20
+ * for every plan version. v1 plans keep the full legacy path untouched.
21
+ *
14
22
  * @module core/approval
15
23
  */
16
24
 
@@ -89,7 +97,19 @@ export function validateApproval(plan, approval, options = {}) {
89
97
  );
90
98
  }
91
99
 
92
- if (!approval.planDigest || !approval.baseline?.gitTreeHash || !approval.expiresAt) {
100
+ // planVersion fork (centralized here; see module header): v2 plans treat
101
+ // the baseline as optional record-layer data.
102
+ const planV2 = plan?.planVersion === 2;
103
+
104
+ if (planV2) {
105
+ if (!approval.planDigest || !approval.expiresAt) {
106
+ throw new ReleaseError(
107
+ GATE_FAILED,
108
+ 'approval record missing required fields: planDigest or expiresAt',
109
+ { approval },
110
+ );
111
+ }
112
+ } else if (!approval.planDigest || !approval.baseline?.gitTreeHash || !approval.expiresAt) {
93
113
  throw new ReleaseError(
94
114
  GATE_FAILED,
95
115
  'approval record missing required fields: planDigest, baseline.gitTreeHash, or expiresAt',
@@ -97,35 +117,37 @@ export function validateApproval(plan, approval, options = {}) {
97
117
  );
98
118
  }
99
119
 
100
- if (plan.production?.mode === 'github-npm-v1') {
101
- if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
102
- throw new ReleaseError(
103
- GATE_FAILED,
104
- `production plan workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
105
- { expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
106
- );
120
+ if (!planV2) {
121
+ if (plan.production?.mode === 'github-npm-v1') {
122
+ if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
123
+ throw new ReleaseError(
124
+ GATE_FAILED,
125
+ `production plan workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
126
+ { expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
127
+ );
128
+ }
129
+ if (approval.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
130
+ throw new ReleaseError(
131
+ GATE_FAILED,
132
+ `production approval workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
133
+ { expected: WORKSPACE_DIGEST_ALGORITHM, actual: approval.baseline?.workspaceDigestAlgorithm ?? null },
134
+ );
135
+ }
107
136
  }
108
- if (approval.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
137
+ if (
138
+ plan.baseline?.workspaceDigestAlgorithm &&
139
+ approval.baseline?.workspaceDigestAlgorithm !== plan.baseline.workspaceDigestAlgorithm
140
+ ) {
109
141
  throw new ReleaseError(
110
142
  GATE_FAILED,
111
- `production approval workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
112
- { expected: WORKSPACE_DIGEST_ALGORITHM, actual: approval.baseline?.workspaceDigestAlgorithm ?? null },
143
+ 'approval workspace digest algorithm does not match the frozen plan',
144
+ {
145
+ planAlgorithm: plan.baseline.workspaceDigestAlgorithm,
146
+ approvalAlgorithm: approval.baseline?.workspaceDigestAlgorithm ?? null,
147
+ },
113
148
  );
114
149
  }
115
150
  }
116
- if (
117
- plan.baseline?.workspaceDigestAlgorithm &&
118
- approval.baseline?.workspaceDigestAlgorithm !== plan.baseline.workspaceDigestAlgorithm
119
- ) {
120
- throw new ReleaseError(
121
- GATE_FAILED,
122
- 'approval workspace digest algorithm does not match the frozen plan',
123
- {
124
- planAlgorithm: plan.baseline.workspaceDigestAlgorithm,
125
- approvalAlgorithm: approval.baseline?.workspaceDigestAlgorithm ?? null,
126
- },
127
- );
128
- }
129
151
 
130
152
  // --- planDigest match ---
131
153
  const actualDigest = computePlanDigest(plan);
@@ -137,30 +159,35 @@ export function validateApproval(plan, approval, options = {}) {
137
159
  );
138
160
  }
139
161
 
140
- // --- baseline.gitTreeHash match ---
141
- if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
142
- throw new ReleaseError(
143
- GATE_FAILED,
144
- `approval baseline mismatch: approval says ${approval.baseline.gitTreeHash}, plan says ${plan.baseline?.gitTreeHash}`,
145
- { approvalTreeHash: approval.baseline.gitTreeHash, planTreeHash: plan.baseline?.gitTreeHash },
146
- );
147
- }
148
-
149
- // --- baseline.workspaceDigest match ---
150
- if (plan.baseline?.workspaceDigest) {
151
- if (!approval.baseline?.workspaceDigest) {
162
+ // --- baseline equality checks (v1 plans only) ---
163
+ // For planVersion 2 plans the baseline is record-layer data: it stays in
164
+ // the plan/approval files for audit but is not an invalidation condition.
165
+ if (!planV2) {
166
+ // --- baseline.gitTreeHash match ---
167
+ if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
152
168
  throw new ReleaseError(
153
169
  GATE_FAILED,
154
- 'approval record missing baseline.workspaceDigest (plan has workspaceDigest)',
155
- { planWorkspaceDigest: plan.baseline.workspaceDigest },
170
+ `approval baseline mismatch: approval says ${approval.baseline.gitTreeHash}, plan says ${plan.baseline?.gitTreeHash}`,
171
+ { approvalTreeHash: approval.baseline.gitTreeHash, planTreeHash: plan.baseline?.gitTreeHash },
156
172
  );
157
173
  }
158
- if (approval.baseline.workspaceDigest !== plan.baseline.workspaceDigest) {
159
- throw new ReleaseError(
160
- GATE_FAILED,
161
- `approval workspaceDigest mismatch: approval says ${approval.baseline.workspaceDigest}, plan says ${plan.baseline.workspaceDigest}`,
162
- { approvalWorkspaceDigest: approval.baseline.workspaceDigest, planWorkspaceDigest: plan.baseline.workspaceDigest },
163
- );
174
+
175
+ // --- baseline.workspaceDigest match ---
176
+ if (plan.baseline?.workspaceDigest) {
177
+ if (!approval.baseline?.workspaceDigest) {
178
+ throw new ReleaseError(
179
+ GATE_FAILED,
180
+ 'approval record missing baseline.workspaceDigest (plan has workspaceDigest)',
181
+ { planWorkspaceDigest: plan.baseline.workspaceDigest },
182
+ );
183
+ }
184
+ if (approval.baseline.workspaceDigest !== plan.baseline.workspaceDigest) {
185
+ throw new ReleaseError(
186
+ GATE_FAILED,
187
+ `approval workspaceDigest mismatch: approval says ${approval.baseline.workspaceDigest}, plan says ${plan.baseline.workspaceDigest}`,
188
+ { approvalWorkspaceDigest: approval.baseline.workspaceDigest, planWorkspaceDigest: plan.baseline.workspaceDigest },
189
+ );
190
+ }
164
191
  }
165
192
  }
166
193
 
@@ -38,6 +38,11 @@ const CONTROL_PLANE_PREFIXES = [
38
38
  '.release-skill/runs',
39
39
  '.release-skill/transactions',
40
40
  '.release-skill/kimi-attestations',
41
+ // T3.2 incremental hook cache: a pure local optimisation written by prepare.
42
+ // Excluding it keeps cache records from destabilising workspaceDigest on
43
+ // every prepare (and hook-cache.mjs also skips this prefix when fingerprinting
44
+ // inputs, so records never hash themselves).
45
+ '.release-skill/cache',
41
46
  ];
42
47
  const RESERVED_CONTROL_PREFIXES = [
43
48
  ...CONTROL_PLANE_PREFIXES,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Shared checkpoint ordering and dependency-tier constants for the publish
3
+ * and reconcile sagas.
4
+ *
5
+ * These were previously duplicated byte-for-byte in `commands/publish.mjs`
6
+ * and `commands/reconcile.mjs` (the reconcile copy carried a `Must match
7
+ * publish.mjs` comment). They live here as the single source of truth so the
8
+ * two commands cannot drift apart (T3.1 §4.7).
9
+ *
10
+ * The tier table is a HARD-CODED dependency layering. It is never derived at
11
+ * runtime (no topological sort, no dynamic inference): every dependency it
12
+ * encodes is backed by concrete code evidence (see t3-1-parallel-checkpoints.md
13
+ * §3). Action types absent from every tier fail closed; they are never
14
+ * silently appended to the last tier.
15
+ *
16
+ * @module core/checkpoints
17
+ */
18
+
19
+ /**
20
+ * Checkpoint order for the publish/reconcile sagas.
21
+ *
22
+ * Used to sort a plan's external actions into a deterministic execution
23
+ * order. Action types not present here sort to the end (index 999), matching
24
+ * the legacy inline comparator in publish.mjs.
25
+ */
26
+ export const CHECKPOINT_ORDER = [
27
+ 'push-commit',
28
+ 'push-snapshot',
29
+ 'set-default-branch',
30
+ 'create-tag',
31
+ 'npm-publish',
32
+ 'github-release',
33
+ 'claude-marketplace-install',
34
+ 'codex-marketplace-install',
35
+ 'kimi-marketplace-install',
36
+ ];
37
+
38
+ /**
39
+ * Map plan action type to adapter ActionType.
40
+ *
41
+ * Plan uses `push-commit`, `push-snapshot`, `create-tag`, `npm-publish`,
42
+ * `github-release`. The adapter contract uses `git-push`, `git-tag`,
43
+ * `npm-publish`, `github-release`.
44
+ */
45
+ export const ADAPTER_ACTION_TYPE_MAP = {
46
+ 'push-commit': 'git-push',
47
+ 'push-snapshot': 'push-snapshot',
48
+ 'set-default-branch': 'set-default-branch',
49
+ 'create-tag': 'git-tag',
50
+ 'npm-publish': 'npm-publish',
51
+ 'github-release': 'github-release',
52
+ 'claude-marketplace-install': 'claude-marketplace-install',
53
+ 'codex-marketplace-install': 'codex-marketplace-install',
54
+ 'kimi-marketplace-install': 'kimi-marketplace-install',
55
+ };
56
+
57
+ /**
58
+ * Hard-coded dependency tiers for parallel checkpoint execution (T3.1 §4.1).
59
+ *
60
+ * Tiers execute strictly serially (a whole tier completes before the next
61
+ * begins); the actions within a tier are independent and run concurrently.
62
+ * Each entry's dependency is backed by code evidence:
63
+ * - Tier 1 `set-default-branch` / `create-tag` depend on Tier 0
64
+ * `push-snapshot` (the frozen commit must exist on the remote before a tag
65
+ * or branch tip can point at it). `npm-publish` has no git dependency and is
66
+ * placed in Tier 1 only for conservative scheduling.
67
+ * - Tier 2 `github-release` and the claude/codex marketplace installs depend
68
+ * on Tier 1 `create-tag` (release `--verify-tag`; install ref is the tag).
69
+ * - Tier 3 `kimi-marketplace-install` depends on Tier 2 `github-release`
70
+ * (its install URL points at the Release page).
71
+ *
72
+ * Action types not listed in any tier are unknown to the scheduler and fail
73
+ * closed (see groupActionsByTier); they are never silently scheduled.
74
+ */
75
+ export const TIER_TABLE = [
76
+ ['push-commit', 'push-snapshot'], // Tier 0
77
+ ['set-default-branch', 'create-tag', 'npm-publish'], // Tier 1
78
+ ['github-release', 'claude-marketplace-install', 'codex-marketplace-install'], // Tier 2
79
+ ['kimi-marketplace-install'], // Tier 3
80
+ ];
81
+
82
+ /** Fast reverse lookup: action type -> tier index (-1 when unknown). */
83
+ const TIER_OF = new Map();
84
+ TIER_TABLE.forEach((tierTypes, tierIndex) => {
85
+ for (const type of tierTypes) {
86
+ TIER_OF.set(type, tierIndex);
87
+ }
88
+ });
89
+
90
+ /**
91
+ * Return the tier index for an action type, or -1 if the type is not present
92
+ * in any tier (i.e. unknown to the scheduler and must fail closed).
93
+ *
94
+ * @param {string} actionType - The plan action type.
95
+ * @returns {number} Tier index (0-based) or -1.
96
+ */
97
+ export function tierOfActionType(actionType) {
98
+ return TIER_OF.has(actionType) ? TIER_OF.get(actionType) : -1;
99
+ }
100
+
101
+ /**
102
+ * Sort external actions by CHECKPOINT_ORDER.
103
+ *
104
+ * Action types not in CHECKPOINT_ORDER sort to the end (index 999), matching
105
+ * the legacy inline comparator. Returns a new array; the input is not mutated.
106
+ *
107
+ * @param {Object[]} actions - External actions (each has a `type`).
108
+ * @returns {Object[]} A new sorted array.
109
+ */
110
+ export function sortActionsByCheckpointOrder(actions) {
111
+ return (actions ?? []).slice().sort((a, b) => {
112
+ const ai = CHECKPOINT_ORDER.indexOf(a.type);
113
+ const bi = CHECKPOINT_ORDER.indexOf(b.type);
114
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Group an ordered list of external actions into dependency tiers.
120
+ *
121
+ * Actions are bucketed by TIER_TABLE; within a tier the input order is
122
+ * preserved (callers pass CHECKPOINT_ORDER-sorted actions). Actions whose type
123
+ * is absent from every tier are collected in `unknown` so the caller can fail
124
+ * closed instead of silently scheduling an unrecognized external write.
125
+ *
126
+ * @param {Object[]} orderedActions - CHECKPOINT_ORDER-sorted external actions.
127
+ * @returns {{ tiers: Object[][], unknown: Object[] }}
128
+ * `tiers[i]` is the array of actions in tier i (possibly empty);
129
+ * `unknown` holds actions whose type is not in any tier.
130
+ */
131
+ export function groupActionsByTier(orderedActions) {
132
+ const tiers = TIER_TABLE.map(() => []);
133
+ const unknown = [];
134
+ for (const action of orderedActions ?? []) {
135
+ const tierIndex = tierOfActionType(action.type);
136
+ if (tierIndex === -1) {
137
+ unknown.push(action);
138
+ } else {
139
+ tiers[tierIndex].push(action);
140
+ }
141
+ }
142
+ return { tiers, unknown };
143
+ }
@@ -95,12 +95,22 @@ export function createEvidenceWriter({ runDir, command, clock }) {
95
95
  const evidencePath = `${runDir}/evidence.jsonl`;
96
96
  const summaryPath = `${runDir}/summary.json`;
97
97
 
98
- let sequence = 0;
98
+ // Sequences start at 1, matching schemas/evidence-event.schema.json
99
+ // (`sequence.minimum: 1`). The historical implementation started at 0; that
100
+ // was an implementation/schema drift, corrected here (T3.1 §4.4).
101
+ let sequence = 1;
99
102
  let handle = null;
103
+ // Mutex chain serializing every append. T3.1 runs same-tier checkpoints
104
+ // concurrently, so multiple `append` calls can be in flight at once; even
105
+ // under JS single-threading their awaits would interleave and could tear a
106
+ // line. Chaining each append behind the previous one guarantees a complete
107
+ // line is written before the next event starts.
108
+ let appendChain = Promise.resolve();
100
109
 
101
110
  /**
102
111
  * Lazily open the evidence file for appending.
103
112
  * Creates the run directory if it does not exist.
113
+ * Must only be called from inside the serialized append chain.
104
114
  */
105
115
  async function ensureHandle() {
106
116
  if (handle === null) {
@@ -115,16 +125,23 @@ export function createEvidenceWriter({ runDir, command, clock }) {
115
125
  * The event is enriched with automatic metadata:
116
126
  * - `schemaVersion`: always 1
117
127
  * - `runId`: extracted from the run directory name
118
- * - `sequence`: auto-incrementing integer starting at 0
128
+ * - `sequence`: auto-incrementing integer starting at 1
119
129
  * - `timestamp`: ISO-8601 string from the clock
120
130
  * - `command`: the command passed at creation time
121
131
  *
122
132
  * The entire event object is redacted before writing.
123
133
  *
134
+ * Ordering semantics (T3.1 §4.4): `sequence` is guaranteed monotonic but is
135
+ * NOT guaranteed to match real-world completion order. Same-tier checkpoints
136
+ * run concurrently, so their events are serialized in whichever order reaches
137
+ * the mutex first. Callers that need layer context attach it as `details.tier`
138
+ * (no top-level field is added; the evidence schema is `additionalProperties:
139
+ * false` at the top level).
140
+ *
124
141
  * @param {Object} event - The event data. Must include `phase` and `status`;
125
142
  * may include `error` and any other fields.
126
143
  */
127
- async function append(event) {
144
+ async function appendOnce(event) {
128
145
  await ensureHandle();
129
146
 
130
147
  const enriched = {
@@ -144,6 +161,14 @@ export function createEvidenceWriter({ runDir, command, clock }) {
144
161
  await handle.write(`${line}\n`, null, 'utf8');
145
162
  }
146
163
 
164
+ function append(event) {
165
+ const result = appendChain.then(() => appendOnce(event));
166
+ // Keep the chain alive even if one append rejects; the caller still
167
+ // receives that rejection through `result`.
168
+ appendChain = result.then(() => undefined, () => undefined);
169
+ return result;
170
+ }
171
+
147
172
  /**
148
173
  * Write the final summary file and close the evidence stream.
149
174
  *
@@ -152,6 +177,8 @@ export function createEvidenceWriter({ runDir, command, clock }) {
152
177
  * @param {Object} summary - The run summary object.
153
178
  */
154
179
  async function finish(summary) {
180
+ // Drain any in-flight appends before closing the handle.
181
+ await appendChain;
155
182
  await ensureHandle();
156
183
 
157
184
  const redacted = redact(summary);
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Incremental hook result cache (T3.2).
3
+ *
4
+ * A hook that opts in with `cacheable: true` and a `cacheInputs` glob list is
5
+ * keyed by the fingerprint of its full configuration plus the content of every
6
+ * file its inputs match. When the key is unchanged and the last run succeeded,
7
+ * prepare replays the cached outcome instead of re-executing the hook.
8
+ *
9
+ * Safety contract (see t3-2-incremental-hooks.md §4.8):
10
+ * - Failures are never cached. Only an `exitCode === 0` result is written; a
11
+ * non-zero exit or HOOK_TIMEOUT leaves no record, so the next run re-executes.
12
+ * - The cache only ever skips execution. It runs AFTER the hook authorization
13
+ * gate and never bypasses any GATE; hook order and failure semantics are
14
+ * untouched.
15
+ * - Fail-closed inputs: if any declared `cacheInputs` glob matches no file, the
16
+ * input set is considered a declaration error and caching aborts with
17
+ * GATE_FAILED before the hook runs (no execution, no cache).
18
+ * - Default zero change: a hook without `cacheable: true` never touches the
19
+ * cache directory at all.
20
+ *
21
+ * The cache is a pure local optimisation under `.release-skill/cache` (a
22
+ * registered control-plane prefix, excluded from workspaceDigest and
23
+ * .gitignore). Deleting it is equivalent to a cold miss for every hook.
24
+ *
25
+ * @module hook-cache
26
+ */
27
+
28
+ import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
29
+ import { join } from 'node:path';
30
+ import { canonicalJson, sha256Hex } from './digest.mjs';
31
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
32
+
33
+ /** Control-plane location of hook cache records: `.release-skill/cache/hooks`. */
34
+ const CACHE_BASE = ['.release-skill', 'cache', 'hooks'];
35
+
36
+ /** Bounded tail length stored per stream (matches prepare's evidence tails). */
37
+ const TAIL_LENGTH = 4000;
38
+
39
+ /**
40
+ * Directory names never walked when enumerating hook inputs. `.git` and
41
+ * `node_modules` are VCS/dependency internals; `.release-skill` is the control
42
+ * plane and MUST stay excluded so cache records never fingerprint themselves
43
+ * (which would destabilise every subsequent key).
44
+ */
45
+ const SKIPPED_DIRS = new Set(['.git', 'node_modules', '.release-skill']);
46
+
47
+ /**
48
+ * Translate a `cacheInputs` glob into an anchored RegExp.
49
+ *
50
+ * Supported syntax (sufficient for input declarations):
51
+ * - `**` matches any run of characters, including `/` (crosses directories)
52
+ * - `*` matches any run of characters except `/`
53
+ * - `?` matches a single character except `/`
54
+ * - every other character matches literally (regex metacharacters escaped)
55
+ *
56
+ * @param {string} glob
57
+ * @returns {RegExp}
58
+ */
59
+ function globToRegExp(glob) {
60
+ let source = '';
61
+ let i = 0;
62
+ while (i < glob.length) {
63
+ const c = glob[i];
64
+ if (c === '*') {
65
+ if (glob[i + 1] === '*') {
66
+ source += '.*';
67
+ i += 2;
68
+ } else {
69
+ source += '[^/]*';
70
+ i += 1;
71
+ }
72
+ } else if (c === '?') {
73
+ source += '[^/]';
74
+ i += 1;
75
+ } else {
76
+ source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
77
+ i += 1;
78
+ }
79
+ }
80
+ return new RegExp(`^${source}$`);
81
+ }
82
+
83
+ /**
84
+ * Recursively list every regular file under `root` as a `/`-separated relative
85
+ * path, skipping VCS/dependency/control-plane directories and symlinks (inputs
86
+ * are real files; symlink handling stays deterministic by not following them).
87
+ *
88
+ * @param {string} root - Absolute project root.
89
+ * @returns {Promise<string[]>} Sorted relative paths.
90
+ */
91
+ async function listInputFiles(root) {
92
+ const out = [];
93
+
94
+ async function walk(dirAbs, dirRel) {
95
+ let entries;
96
+ try {
97
+ entries = await readdir(dirAbs, { withFileTypes: true });
98
+ } catch {
99
+ return; // Unreadable directory: treat as no inputs there.
100
+ }
101
+ for (const entry of entries) {
102
+ if (entry.isDirectory()) {
103
+ if (SKIPPED_DIRS.has(entry.name)) continue;
104
+ const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
105
+ await walk(join(dirAbs, entry.name), rel);
106
+ } else if (entry.isFile()) {
107
+ out.push(dirRel ? `${dirRel}/${entry.name}` : entry.name);
108
+ }
109
+ }
110
+ }
111
+
112
+ await walk(root, '');
113
+ out.sort();
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Compute the cache key for a cacheable hook.
119
+ *
120
+ * `cacheKey = sha256( canonicalJSON(hook config) + canonicalJSON(sorted matched
121
+ * files [{ path, sha256(content) }]) )`
122
+ *
123
+ * The full hook configuration (command/cwd/timeoutMs/envAllowlist/cacheInputs/
124
+ * cacheable) is part of the key, so any config change switches the key. Matched
125
+ * files are sorted by path before hashing for determinism.
126
+ *
127
+ * @param {Object} hook - A hook descriptor with a non-empty `cacheInputs`.
128
+ * @param {string} root - Absolute project root.
129
+ * @returns {Promise<{ cacheKey: string, matchedFiles: string[] }>}
130
+ * @throws {ReleaseError} GATE_FAILED when any declared glob matches no file.
131
+ */
132
+ export async function computeHookCacheKey(hook, root) {
133
+ const globs = Array.isArray(hook.cacheInputs) ? hook.cacheInputs : [];
134
+ const matchers = globs.map((glob) => ({ glob, re: globToRegExp(glob) }));
135
+
136
+ const allFiles = await listInputFiles(root);
137
+ const matched = [];
138
+ const hitPerGlob = matchers.map(() => false);
139
+ for (const relPath of allFiles) {
140
+ let hit = false;
141
+ for (let i = 0; i < matchers.length; i += 1) {
142
+ if (matchers[i].re.test(relPath)) {
143
+ hitPerGlob[i] = true;
144
+ hit = true;
145
+ }
146
+ }
147
+ if (hit) matched.push(relPath);
148
+ }
149
+
150
+ // Fail-closed: a glob that matches nothing is a declaration error (a typo or
151
+ // a missing input). Refuse to cache rather than risk a false hit.
152
+ for (let i = 0; i < matchers.length; i += 1) {
153
+ if (!hitPerGlob[i]) {
154
+ throw new ReleaseError(
155
+ GATE_FAILED,
156
+ `hook cacheInputs glob "${matchers[i].glob}" matched no files; refusing to cache`,
157
+ { glob: matchers[i].glob },
158
+ );
159
+ }
160
+ }
161
+
162
+ matched.sort();
163
+ const fileEntries = [];
164
+ for (const relPath of matched) {
165
+ const content = await readFile(join(root, relPath));
166
+ fileEntries.push({ path: relPath, sha256: sha256Hex(content) });
167
+ }
168
+
169
+ const cacheKey = sha256Hex(canonicalJson(hook) + canonicalJson(fileEntries));
170
+ return { cacheKey, matchedFiles: matched };
171
+ }
172
+
173
+ /**
174
+ * Resolve the cache directory for a named hook.
175
+ *
176
+ * @param {string} root
177
+ * @param {string} hookName
178
+ * @returns {string} Absolute path to `.release-skill/cache/hooks/<hookName>`.
179
+ */
180
+ export function hookCacheDir(root, hookName) {
181
+ return join(root, ...CACHE_BASE, hookName);
182
+ }
183
+
184
+ /**
185
+ * Resolve the cache record path for a hook + key.
186
+ *
187
+ * @param {string} root
188
+ * @param {string} hookName
189
+ * @param {string} cacheKey
190
+ * @returns {string} Absolute path to the `<cacheKey>.json` record.
191
+ */
192
+ export function hookCachePath(root, hookName, cacheKey) {
193
+ return join(hookCacheDir(root, hookName), `${cacheKey}.json`);
194
+ }
195
+
196
+ /**
197
+ * Read a cached hook result. Returns the record only when it exists, its key
198
+ * matches, and it recorded a successful (`exitCode === 0`) run; anything else
199
+ * (missing, corrupt, or non-zero) is a miss.
200
+ *
201
+ * @param {string} root
202
+ * @param {string} hookName
203
+ * @param {string} cacheKey
204
+ * @returns {Promise<Object | null>}
205
+ */
206
+ export async function readHookCache(root, hookName, cacheKey) {
207
+ try {
208
+ const raw = await readFile(hookCachePath(root, hookName, cacheKey), 'utf8');
209
+ const record = JSON.parse(raw);
210
+ if (record && record.cacheKey === cacheKey && record.exitCode === 0) {
211
+ return record;
212
+ }
213
+ return null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Write a successful hook result to the cache. Never throws: a write failure
221
+ * returns `{ ok: false, error }` so the caller can record a warning without
222
+ * aborting prepare (the cache is an optimisation, not a gate).
223
+ *
224
+ * @param {string} root
225
+ * @param {string} hookName
226
+ * @param {string} cacheKey
227
+ * @param {Object} result
228
+ * @param {number} result.exitCode - Must be 0; non-zero results are not cached.
229
+ * @param {string} [result.stdoutTail] - Already truncated to TAIL_LENGTH.
230
+ * @param {string} [result.stderrTail] - Already truncated to TAIL_LENGTH.
231
+ * @param {string} [result.createdAt] - ISO timestamp (defaults to now).
232
+ * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
233
+ */
234
+ export async function writeHookCache(root, hookName, cacheKey, result) {
235
+ // Defence in depth: a failure must never be persisted, even if a caller
236
+ // mistakenly passes a non-zero exit code.
237
+ if (!result || result.exitCode !== 0) {
238
+ return { ok: false, error: 'refusing to cache a non-zero exit result' };
239
+ }
240
+ try {
241
+ await mkdir(hookCacheDir(root, hookName), { recursive: true });
242
+ const record = {
243
+ cacheKey,
244
+ exitCode: 0,
245
+ stdoutTail: String(result.stdoutTail ?? '').slice(-TAIL_LENGTH),
246
+ stderrTail: String(result.stderrTail ?? '').slice(-TAIL_LENGTH),
247
+ createdAt: result.createdAt ?? new Date().toISOString(),
248
+ };
249
+ await writeFile(hookCachePath(root, hookName, cacheKey), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
250
+ return { ok: true };
251
+ } catch (err) {
252
+ return { ok: false, error: err.message };
253
+ }
254
+ }