release-skill 0.1.9 → 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 (59) 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 +53 -0
  6. package/INSTALL.md +4 -4
  7. package/INSTALL.zh-CN.md +4 -4
  8. package/README.md +18 -33
  9. package/README.zh-CN.md +17 -23
  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 +2740 -1844
  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 +2740 -1844
  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 +2740 -1844
  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 +2740 -1844
  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 -730
  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 +16 -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
  59. package/src/snapshot/frozen.mjs +29 -5
@@ -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
 
@@ -17,6 +17,16 @@ const execFile = promisify(execFileCb);
17
17
  * use reserved prefixes; immutable plans and approvals use exact digest-shaped
18
18
  * paths so arbitrary files under similarly named directories remain visible.
19
19
  *
20
+ * `kimi-attestations` holds Kimi's closure-protocol lifecycle artifacts:
21
+ * the manual installation requirement that publish/reconcile itself emits
22
+ * for a PARTIAL kimi checkpoint, and the human-written attestation that is
23
+ * the designed closure input (independently bound to planDigest,
24
+ * payloadDigest, exact version/tag, install path, responsible person, and
25
+ * expiry). Neither is publishable source or project configuration — they
26
+ * never enter the frozen snapshot — so excluding them keeps reconcile's own
27
+ * requirement output and the flow-required attestation from invalidating
28
+ * the baseline of every subsequent reconcile.
29
+ *
20
30
  * `project.yaml` is intentionally **not** listed — changes to project
21
31
  * configuration must always cause a baseline drift.
22
32
  */
@@ -27,6 +37,12 @@ const CONTROL_PLANE_PREFIXES = [
27
37
  '.release-skill/lock-audit',
28
38
  '.release-skill/runs',
29
39
  '.release-skill/transactions',
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',
30
46
  ];
31
47
  const RESERVED_CONTROL_PREFIXES = [
32
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);