release-skill 0.9.37 → 0.9.38

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 (83) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codebuddy-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.qoder-plugin/plugin.json +1 -1
  7. package/CHANGELOG.md +32 -0
  8. package/INSTALL.md +2 -2
  9. package/INSTALL.zh-CN.md +2 -2
  10. package/README.md +34 -14
  11. package/README.zh-CN.md +37 -14
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +1912 -1446
  14. package/adapters/claude/schemas/release-plan.schema.json +1 -0
  15. package/adapters/claude/schemas/test-intake.schema.json +1 -0
  16. package/adapters/claude/skills/release-help/SKILL.md +36 -41
  17. package/adapters/claude/skills/release-prepare/SKILL.md +21 -1
  18. package/adapters/claude/skills/release-publish/SKILL.md +11 -1
  19. package/adapters/claude/skills/release-reconcile/SKILL.md +14 -0
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/release-skill.bundle.mjs +1912 -1446
  22. package/adapters/codex/schemas/release-plan.schema.json +1 -0
  23. package/adapters/codex/schemas/test-intake.schema.json +1 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +36 -41
  25. package/adapters/codex/skills/release-prepare/SKILL.md +21 -1
  26. package/adapters/codex/skills/release-publish/SKILL.md +11 -1
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +14 -0
  28. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  29. package/adapters/cursor/bin/release-skill.bundle.mjs +1912 -1446
  30. package/adapters/cursor/schemas/release-plan.schema.json +1 -0
  31. package/adapters/cursor/schemas/test-intake.schema.json +1 -0
  32. package/adapters/cursor/skills/release-help/SKILL.md +36 -41
  33. package/adapters/cursor/skills/release-prepare/SKILL.md +21 -1
  34. package/adapters/cursor/skills/release-publish/SKILL.md +11 -1
  35. package/adapters/cursor/skills/release-reconcile/SKILL.md +14 -0
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/release-skill.bundle.mjs +1912 -1446
  38. package/adapters/kimi/schemas/release-plan.schema.json +1 -0
  39. package/adapters/kimi/schemas/test-intake.schema.json +1 -0
  40. package/adapters/kimi/skills/release-help/SKILL.md +36 -41
  41. package/adapters/kimi/skills/release-prepare/SKILL.md +21 -1
  42. package/adapters/kimi/skills/release-publish/SKILL.md +11 -1
  43. package/adapters/kimi/skills/release-reconcile/SKILL.md +14 -0
  44. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  45. package/adapters/qoder/bin/release-skill.bundle.mjs +1912 -1446
  46. package/adapters/qoder/schemas/release-plan.schema.json +1 -0
  47. package/adapters/qoder/schemas/test-intake.schema.json +1 -0
  48. package/adapters/qoder/skills/release-help/SKILL.md +36 -41
  49. package/adapters/qoder/skills/release-prepare/SKILL.md +21 -1
  50. package/adapters/qoder/skills/release-publish/SKILL.md +11 -1
  51. package/adapters/qoder/skills/release-reconcile/SKILL.md +14 -0
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1912 -1446
  54. package/adapters/workbuddy/schemas/release-plan.schema.json +1 -0
  55. package/adapters/workbuddy/schemas/test-intake.schema.json +1 -0
  56. package/adapters/workbuddy/skills/release-help/SKILL.md +36 -41
  57. package/adapters/workbuddy/skills/release-prepare/SKILL.md +21 -1
  58. package/adapters/workbuddy/skills/release-publish/SKILL.md +11 -1
  59. package/adapters/workbuddy/skills/release-reconcile/SKILL.md +14 -0
  60. package/bin/release-skill-cli.mjs +5 -0
  61. package/bin/release-skill.bundle.mjs +1912 -1446
  62. package/package.json +1 -1
  63. package/platform-manifest.json +4 -4
  64. package/references/01-state-machine.md +26 -0
  65. package/references/02-project-config.md +20 -0
  66. package/schemas/release-plan.schema.json +1 -0
  67. package/schemas/test-intake.schema.json +1 -0
  68. package/skills/release-help/SKILL.md +36 -41
  69. package/skills/release-prepare/SKILL.md +21 -1
  70. package/skills/release-publish/SKILL.md +11 -1
  71. package/skills/release-reconcile/SKILL.md +14 -0
  72. package/skills-src/release-help/SKILL.md +36 -41
  73. package/skills-src/release-prepare/SKILL.md +21 -1
  74. package/skills-src/release-publish/SKILL.md +11 -1
  75. package/skills-src/release-reconcile/SKILL.md +14 -0
  76. package/src/commands/approve.mjs +33 -24
  77. package/src/commands/prepare.mjs +9 -2
  78. package/src/commands/publish.mjs +10 -2
  79. package/src/commands/reconcile.mjs +2 -0
  80. package/src/commands/route.mjs +2 -159
  81. package/src/core/plan-replacement.mjs +371 -0
  82. package/src/core/pre-authority-failure.mjs +241 -0
  83. package/src/core/test-candidate.mjs +15 -67
@@ -0,0 +1,371 @@
1
+ import { readdir, lstat, realpath } from 'node:fs/promises';
2
+ import { realpathSync } from 'node:fs';
3
+ import Ajv from 'ajv';
4
+ import addFormats from 'ajv-formats';
5
+ import { readTrustedPackageResourceSync } from './trusted-resource.mjs';
6
+ import { resolve, relative, sep, join } from 'node:path';
7
+ import { readFileStrict } from 'skill-family-harness-node';
8
+ import { validatePlan, computePlanDigest, assertImmutablePlanAuthority } from './plan.mjs';
9
+ import { canonicalJson } from './digest.mjs';
10
+ import { verifyTestAcceptance, normalizeIntakePath } from './test-intake.mjs';
11
+ import { loadRun, validateRunLineage, validateRunCheckpointMapping } from './run.mjs';
12
+ import { readRunRecovery } from './recovery.mjs';
13
+ import { isMarketplaceAction } from './checkpoints.mjs';
14
+ import { isProvenPreAuthorityFailure } from './pre-authority-failure.mjs';
15
+ import { ReleaseError, GATE_FAILED, CONFIG_INVALID } from './errors.mjs';
16
+
17
+ const unitIdSchema = JSON.parse(readTrustedPackageResourceSync('schemas/release-plan.schema.json').toString('utf8')).properties.units.items.properties.id;
18
+ const historyAjv = new Ajv({ strict: false });
19
+ addFormats(historyAjv);
20
+ const validUnitId = historyAjv.compile(unitIdSchema);
21
+ const validHistoryEvent = historyAjv.compile(JSON.parse(readTrustedPackageResourceSync('schemas/evidence-event-v2.schema.json').toString('utf8')));
22
+
23
+ function refused(message, details = {}) {
24
+ throw new ReleaseError(GATE_FAILED, message, { reason: 'UNPUBLISHED_PLAN_REPLACEMENT_REFUSED', ...details });
25
+ }
26
+
27
+ export function canonicalFormalPlanPath(root, input) {
28
+ if (typeof input !== 'string' || !input) refused('replacement requires an immutable plan path');
29
+ // Resolve only the caller's project root. Project-owned authority components
30
+ // are still read through readFileStrict and must never be realpath-washed.
31
+ const physicalRoot = realpathSync(resolve(root));
32
+ const selected = resolve(root, input);
33
+ const anchor = `${sep}.release-skill${sep}`;
34
+ const index = selected.lastIndexOf(anchor);
35
+ if (index < 0 || realpathSync(selected.slice(0, index) || sep) !== physicalRoot) {
36
+ refused('replacement plan must belong to this project immutable plans directory');
37
+ }
38
+ const rel = selected.slice(index + 1).split(sep).join('/');
39
+ if (!/^\.release-skill\/plans\/[a-f0-9]{64}\.json$/.test(rel)) {
40
+ refused('replacement plan must belong to this project immutable plans directory');
41
+ }
42
+ return rel;
43
+ }
44
+
45
+ async function readPlan(root, rel, currentSchema = true) {
46
+ try {
47
+ const entry = await lstat(resolve(root, rel));
48
+ if (entry.isSymbolicLink() || !entry.isFile()) refused('formal plan authority must be an ordinary file', { path: rel });
49
+ const receipt = await readFileStrict(root, rel, { encoding: 'utf8' });
50
+ const plan = JSON.parse(receipt.content);
51
+ targetVersions(plan);
52
+ if (currentSchema) validatePlan(plan);
53
+ assertImmutablePlanAuthority(resolve(root, rel), plan);
54
+ if (rel !== `.release-skill/plans/${plan.digest}.json` || computePlanDigest(plan) !== plan.digest) refused('formal plan digest is invalid', { path: rel });
55
+ return plan;
56
+ } catch (error) {
57
+ throw new ReleaseError(CONFIG_INVALID, 'formal plan authority is invalid', {
58
+ reason: 'TEST_CANDIDATE_AUTHORITY_INVALID', path: rel, cause: error.code ?? error.message,
59
+ });
60
+ }
61
+ }
62
+
63
+ // This is target attribution only. It never certifies a legacy plan against a
64
+ // guessed historical schema or grants an unrelated record release authority.
65
+ function targetVersions(plan) {
66
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan) || !Array.isArray(plan.units) || plan.units.length === 0) {
67
+ refused('formal plan target data is incomplete');
68
+ }
69
+ const byUnit = new Map();
70
+ for (const unit of plan.units) {
71
+ if (!unit || typeof unit !== 'object' || Array.isArray(unit)
72
+ || !validUnitId(unit.id)
73
+ || typeof unit.targetVersion !== 'string' || !unit.targetVersion || unit.targetVersion.trim() !== unit.targetVersion
74
+ || byUnit.has(unit.id) || (Object.hasOwn(unit, 'version') && unit.version !== unit.targetVersion)) {
75
+ refused('formal plan target data is malformed, duplicate or conflicting');
76
+ }
77
+ byUnit.set(unit.id, unit.targetVersion);
78
+ }
79
+ const versions = [...new Set(byUnit.values())].sort();
80
+ if ((Object.hasOwn(plan, 'targetVersion') && (versions.length !== 1 || plan.targetVersion !== versions[0]))
81
+ || (Object.hasOwn(plan, 'targetVersions') && (!Array.isArray(plan.targetVersions)
82
+ || canonicalJson([...plan.targetVersions].sort()) !== canonicalJson(versions)))) {
83
+ refused('formal plan explicit target versions conflict');
84
+ }
85
+ if (Object.hasOwn(plan, 'externalActions')) {
86
+ if (!Array.isArray(plan.externalActions)) refused('formal plan external target data is malformed');
87
+ for (const action of plan.externalActions) {
88
+ if (!action || typeof action !== 'object' || Array.isArray(action)) refused('formal plan external target data is malformed');
89
+ for (const value of [action.parameters, action.expected]) {
90
+ if (value && Object.hasOwn(value, 'version') && value.version !== byUnit.get(action.unitId)) {
91
+ refused('formal plan external target version conflicts with its unit');
92
+ }
93
+ }
94
+ }
95
+ }
96
+ return versions;
97
+ }
98
+
99
+ export async function readFormalPlans(root, currentPlan) {
100
+ root = await realpath(root);
101
+ let names;
102
+ try {
103
+ for (const directory of ['.release-skill', '.release-skill/plans']) {
104
+ const entry = await lstat(resolve(root, directory));
105
+ if (entry.isSymbolicLink() || !entry.isDirectory()) refused('formal plan authority contains a symlink or non-directory');
106
+ }
107
+ names = await readdir(resolve(root, '.release-skill/plans'));
108
+ }
109
+ catch (error) {
110
+ if (error.code === 'ENOENT') return [];
111
+ throw new ReleaseError(CONFIG_INVALID, 'formal plans directory is invalid', { reason: 'TEST_CANDIDATE_AUTHORITY_INVALID', cause: error.code });
112
+ }
113
+ const plans = [];
114
+ for (const name of names) {
115
+ if (!name.endsWith('.json')) continue;
116
+ let rel;
117
+ try { rel = canonicalFormalPlanPath(root, `.release-skill/plans/${name}`); }
118
+ catch (error) { throw new ReleaseError(CONFIG_INVALID, 'formal plan filename is invalid', { reason: 'TEST_CANDIDATE_AUTHORITY_INVALID', cause: error.message }); }
119
+ const plan = await readPlan(root, rel, false);
120
+ plans.push({ planPath: rel, planDigest: plan.digest, status: plan.status,
121
+ subjectDigest: plan.testAcceptance?.subjectDigest ?? null,
122
+ targetVersions: targetVersions(plan), plan });
123
+ }
124
+ const byDigest = new Map(plans.map((p) => [p.planDigest, p]));
125
+ const currentTargets = currentPlan ? targetVersions(currentPlan) : [];
126
+ const required = new Set();
127
+ for (const entry of plans) {
128
+ if (Object.hasOwn(entry.plan, 'testAcceptance') || Object.hasOwn(entry.plan, 'replacesPlanDigest')
129
+ || entry.targetVersions.some((version) => currentTargets.includes(version))) required.add(entry.planDigest);
130
+ if (Object.hasOwn(entry.plan, 'replacesPlanDigest')) required.add(entry.plan.replacesPlanDigest);
131
+ }
132
+ for (const digest of required) {
133
+ const entry = byDigest.get(digest);
134
+ if (entry) validatePlan(entry.plan);
135
+ }
136
+ const successors = new Map();
137
+ for (const entry of plans) {
138
+ const parent = entry.plan.replacesPlanDigest;
139
+ if (!parent) continue;
140
+ if (parent === entry.planDigest || !byDigest.has(parent) || successors.has(parent)) {
141
+ refused('formal replacement relationship is missing, self-referential or forked');
142
+ }
143
+ if (targetIdentity(entry.plan) !== targetIdentity(byDigest.get(parent).plan)) {
144
+ refused('formal replacement changes release targets');
145
+ }
146
+ successors.set(parent, entry.planDigest);
147
+ }
148
+ for (const entry of plans) {
149
+ const seen = new Set(); let cursor = entry.planDigest;
150
+ while (cursor) {
151
+ if (seen.has(cursor)) refused('formal replacement relationship contains a cycle');
152
+ seen.add(cursor); cursor = byDigest.get(cursor)?.plan.replacesPlanDigest;
153
+ }
154
+ }
155
+ return plans;
156
+ }
157
+
158
+ export async function assertPlanNotReplaced(root, plan, planPath) {
159
+ if (planPath && plan.production) canonicalFormalPlanPath(root, planPath);
160
+ const plans = await readFormalPlans(root, plan);
161
+ if (plans.some((p) => p.plan.replacesPlanDigest === plan.digest)) {
162
+ refused('release plan has been replaced', { planDigest: plan.digest });
163
+ }
164
+ return plans;
165
+ }
166
+
167
+ function targetIdentity(plan) {
168
+ const destinationKeys = ['publicRepo', 'repo', 'branch', 'ref', 'tag', 'tagTemplate', 'package', 'registry',
169
+ 'version', 'marketplaceRepo', 'marketplaceBranch', 'marketplace', 'pluginName', 'platform', 'host',
170
+ 'publisher', 'access', 'provenance', 'githubHost', 'oldBranch', 'newBranch', 'consumer', 'plugin',
171
+ 'marketplaceSource', 'marketplaceSourceType', 'marketplaceForm'];
172
+ const destinations = (value = {}) => Object.fromEntries(destinationKeys.filter((k) => value[k] !== undefined).map((k) => [k, value[k]]));
173
+ return canonicalJson({
174
+ units: plan.units.map((u) => ({ id: u.id, targetVersion: u.targetVersion,
175
+ ...destinations(u), distributions: (u.distributions ?? []).map((d) => ({ type: d.type, ...destinations(d) })) })).sort((a, b) => a.id.localeCompare(b.id)),
176
+ actions: (plan.externalActions ?? []).map((a) => ({ id: a.id, type: a.type, ...destinations(a.parameters) })).sort((a, b) => a.id.localeCompare(b.id)),
177
+ source: plan.sourceAuthority ? { sourceRepository: plan.sourceAuthority.sourceRepository, defaultBranch: plan.sourceAuthority.defaultBranch } : null,
178
+ });
179
+ }
180
+
181
+ function overlaps(left, right) {
182
+ return left.units.some((a) => right.units.some((b) => a.id === b.id && a.targetVersion === b.targetVersion));
183
+ }
184
+
185
+ async function exists(path) {
186
+ try { await lstat(path); return true; }
187
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
188
+ }
189
+
190
+ async function validateBoundRun(root, path, oldPlan) {
191
+ const initial = await loadRun(path, { requireDigest: true });
192
+ const rel = canonicalFormalPlanPath(root, initial.planPath);
193
+ const plan = await readPlan(root, rel);
194
+ // Attribution consumes immutable run/plan lineage, not permission to retry
195
+ // an unrelated historical release with an approval that may have expired.
196
+ let run = initial;
197
+ if (['publish', 'reconcile', 'verify'].includes(initial.command)) {
198
+ await validateRunLineage(initial, { plan, planPath: initial.planPath, runPath: path, production: Boolean(plan.production) });
199
+ let cursor = initial;
200
+ for (;;) {
201
+ validateRunCheckpointMapping(cursor, plan.externalActions ?? []);
202
+ if (cursor.command === 'publish') break;
203
+ cursor = await loadRun(cursor.sourceRunPath, { requireDigest: true });
204
+ }
205
+ } else {
206
+ const recovery = initial.command === 'postverify'
207
+ ? await (await import('../commands/route.mjs')).readLegacyPostverifyRecovery(path)
208
+ : await readRunRecovery(path);
209
+ if (!recovery.run || !recovery.plan || recovery.diagnostic) refused('run authority or lineage is invalid', { runPath: path, cause: recovery.diagnostic });
210
+ run = recovery.run;
211
+ }
212
+ if (overlaps(plan, oldPlan)) refused('a release run authority already exists for the replacement target', { runPath: path });
213
+ return { run, plan };
214
+ }
215
+
216
+ // These four failed writers have no final child authority. Their sealed
217
+ // prefixes attribute them to a complete, unrelated parent; this is not the
218
+ // publish pre-authority zero-write proof (verify may install an isolated consumer).
219
+ async function attributeHistoricalChild(root, name, events, summary, oldPlan) {
220
+ const command = events[0]?.command;
221
+ const producer = events[0]?.producer;
222
+ const versions = command === 'reconcile' ? ['0.9.22', '0.9.27']
223
+ : command === 'verify' ? ['0.9.7', '0.9.12'] : [];
224
+ if (!versions.includes(producer?.version) || producer.name !== 'release-skill') return false;
225
+ const terminal = events.at(-1);
226
+ const failure = events.find((event) => event.status === 'failed');
227
+ const recovery = command === 'reconcile' ? 'RECONCILE' : 'VERIFY';
228
+ const summaryKeys = ['status', 'recoveryActionCode', 'error', 'details', 'failedAt', 'stablePhase', 'failedUnitId', 'failedHook', 'failedAction', 'evidenceSequence', 'evidencePath', 'producer'];
229
+ if (!summary || Object.keys(summary).some((key) => !summaryKeys.includes(key))
230
+ || summary.status !== 'FAILED' || summary.evidencePath !== 'evidence.jsonl'
231
+ || summary.evidenceSequence !== failure?.sequence || summary.stablePhase !== failure?.phase
232
+ || !Number.isFinite(Date.parse(summary.failedAt)) || Date.parse(summary.failedAt) < Date.parse(terminal?.timestamp) || !summary.error || typeof summary.error.code !== 'string'
233
+ || typeof summary.error.message !== 'string' || canonicalJson(summary.producer) !== canonicalJson(producer)
234
+ || canonicalJson(summary.details) !== canonicalJson({ recoveryActionCode: recovery })
235
+ || summary.recoveryActionCode !== recovery || terminal?.phase !== command || terminal.status !== 'failed'
236
+ || terminal.details?.recoveryActionCode !== recovery || canonicalJson(terminal.error) !== canonicalJson(summary.error)
237
+ || !events.every((event, index) => validHistoryEvent(event) && event.command === command && event.runId === name
238
+ && event.sequence === index + 1 && canonicalJson(event.producer) === canonicalJson(producer))) return false;
239
+ const sourceLoads = events.filter((event) => event.phase === (command === 'verify' ? 'verify' : 'safety-gate')
240
+ && event.status === 'passed' && (event.details?.step ?? event.details?.gate) === 'source-run-load');
241
+ if (sourceLoads.length !== 1 || !/^[A-Za-z0-9._-]+$/.test(sourceLoads[0].details.sourceRunId)
242
+ || ['.', '..'].includes(sourceLoads[0].details.sourceRunId)) return false;
243
+ const sourceId = sourceLoads[0].details.sourceRunId;
244
+ const { run: source, plan } = await validateBoundRun(root, join(root, '.release-skill/runs', sourceId, 'release-run.json'), oldPlan);
245
+ if (source.runId !== sourceId) return false;
246
+ const actions = new Map(plan.externalActions.map((action) => [action.id, action]));
247
+ if (!events.every((event) => {
248
+ const details = event.details ?? {};
249
+ if (Object.hasOwn(details, 'sourceRunId') && details.sourceRunId !== sourceId) return false;
250
+ if (Object.hasOwn(details, 'sourceRunDigest') && (command === 'verify' || details.sourceRunDigest !== source.runDigest)) return false;
251
+ if (Object.hasOwn(details, 'actionId') || Object.hasOwn(details, 'actionType')) {
252
+ const action = actions.get(details.actionId);
253
+ return Boolean(action) && action.type === details.actionType;
254
+ }
255
+ return true;
256
+ })) return false;
257
+ let cursor = 0;
258
+ const take = (phase, status, field, value) => {
259
+ const event = events[cursor];
260
+ if (!event || event.phase !== phase || event.status !== status || (field && event.details?.[field] !== value)) return false;
261
+ cursor += 1; return true;
262
+ };
263
+ if (command === 'verify') {
264
+ if (source.command !== 'publish' || source.status !== 'PUBLISHED'
265
+ || !source.checkpoints.every((checkpoint) => ['succeeded', 'skipped'].includes(checkpoint.status)
266
+ || isMarketplaceAction(checkpoint.actionType) && ['failed', 'deferred'].includes(checkpoint.status))
267
+ || !take('verify', 'started', 'step', 'plan-load') || !take('verify', 'passed', 'step', 'plan-load')
268
+ || !take('verify', 'started', 'step', 'source-run-load')
269
+ || !take('source-authority-receipt', 'started') || !take('source-authority-receipt', 'passed')
270
+ || !take('verify', 'passed', 'step', 'source-run-load') || !take('verify', 'started', 'step', 'adapter-verify')) return false;
271
+ return events.slice(cursor, -1).length > 0 && events.slice(cursor, -1).every((event) =>
272
+ Object.hasOwn(event.details ?? {}, 'actionId') && (
273
+ event.phase === 'verify-marketplace-bootstrap-index' && ['observed', 'needs-input'].includes(event.status)
274
+ || event.phase === 'verify-adapter' && event.status === 'PASSED_AUTOMATIC'
275
+ || event.phase === 'verify-marketplace' && event.status === 'FAILED'));
276
+ }
277
+ for (const [gate, status] of [['plan-load', 'started'], ['plan-schema', 'passed'], ['plan-digest', 'started'],
278
+ ['plan-digest', 'passed'], ['action-completeness', 'started'], ['action-completeness', 'passed']]) {
279
+ if (!take('safety-gate', status, 'gate', gate)) return false;
280
+ }
281
+ for (const unit of plan.units.filter((unit) => unit.distributions?.some((dist) => dist.type === 'npm'))) {
282
+ for (const status of ['started', 'passed']) {
283
+ if (events[cursor]?.details?.unitId !== unit.id || !take('safety-gate', status, 'gate', 'npm-entry-closure')) return false;
284
+ }
285
+ }
286
+ if (!take('safety-gate', 'started', 'gate', 'source-run-load') || !take('safety-gate', 'passed', 'gate', 'source-run-load')
287
+ || !take('safety-gate', 'started', 'gate', 'baseline-check')) return false;
288
+ if (events[cursor]?.phase === 'safety-gate' && events[cursor]?.status === 'warning') {
289
+ if (!take('safety-gate', 'warning', 'gate', 'baseline-check')) return false;
290
+ }
291
+ if (!take('safety-gate', 'passed', 'gate', 'baseline-check')) return false;
292
+ for (const unit of plan.units.filter((unit) => unit.previousPublicBaseline?.mode === 'bound')) {
293
+ const event = events[cursor]; const baseline = unit.previousPublicBaseline;
294
+ if (event?.details?.repo !== baseline.repo || event.details.ref !== baseline.ref
295
+ || event.details.baselineCommit !== baseline.commit
296
+ || !take('re-observe-previous-public-baseline', 'consistent-planned-successor')) return false;
297
+ }
298
+ if (!take('safety-gate', 'started', 'gate', 'adapter-availability') || !take('safety-gate', 'passed', 'gate', 'adapter-availability')
299
+ || events[cursor]?.details?.actionCount !== actions.size
300
+ || events[cursor]?.details?.sourceRunDigest !== source.runDigest || !take('reconcile', 'started')) return false;
301
+ return events.slice(cursor, -1).length > 0 && events.slice(cursor, -1).every((event) =>
302
+ Object.hasOwn(event.details ?? {}, 'actionId') && (
303
+ event.phase === 'reconcile-observe' && event.status === 'observed'
304
+ || event.phase === 'reconcile-observe-retry' && ['resolved', 'propagating'].includes(event.status)));
305
+ }
306
+
307
+ async function verifyHistory(root, oldPlan) {
308
+ const runs = resolve(root, '.release-skill/runs');
309
+ let names;
310
+ try { names = await readdir(runs); }
311
+ catch (error) { if (error.code === 'ENOENT') refused('old test candidate originals are missing'); throw error; }
312
+ let originalCandidate = false;
313
+ for (const name of names) {
314
+ const dir = join(runs, name);
315
+ const kind = await lstat(dir);
316
+ if (kind.isSymbolicLink()) refused('run history contains a symbolic link', { runDirectory: name });
317
+ if (!kind.isDirectory()) continue;
318
+ if (await exists(join(dir, 'test-candidate.json'))) {
319
+ const { readTestCandidate, readCandidateEvidenceSha256 } = await import('./test-candidate.mjs');
320
+ const loaded = await readTestCandidate({ root, candidatePath: join(dir, 'test-candidate.json') });
321
+ if (computePlanDigest(loaded.candidate.prepared) === oldPlan.testAcceptance.subjectDigest) {
322
+ const evidenceDigest = await readCandidateEvidenceSha256(root, loaded.candidatePath);
323
+ if (evidenceDigest !== loaded.sha256
324
+ || canonicalJson(loaded.candidate.requirements) !== canonicalJson(oldPlan.testAcceptance.requirements)) {
325
+ refused('old test candidate original or evidence has changed');
326
+ }
327
+ originalCandidate = true;
328
+ }
329
+ }
330
+ const final = join(dir, 'release-run.json');
331
+ if (await exists(final)) { await validateBoundRun(root, final, oldPlan); continue; }
332
+ const states = join(dir, 'states');
333
+ if (await exists(states)) {
334
+ const slots = await readdir(states);
335
+ if (!slots.length || slots.some((s) => !/^\d{6}\.json$/.test(s))) refused('unknown or incomplete run state authority');
336
+ await validateBoundRun(root, join(states, slots.sort().at(-1)), oldPlan); continue;
337
+ }
338
+ if (!(await exists(join(dir, 'evidence.jsonl'))) && !(await exists(join(dir, 'summary.json')))) continue;
339
+ const receipt = await readFileStrict(root, relative(root, join(dir, 'evidence.jsonl')).split(sep).join('/'), { encoding: 'utf8' });
340
+ const events = receipt.content.trim().split('\n').filter(Boolean).map((s) => JSON.parse(s));
341
+ if (events.length && events.every((e) => ['prepare', 'assess', 'hooks-validate', 'discover'].includes(e.command))) continue;
342
+ // Existing candidate-only test streams have no release authority.
343
+ if (events.length && events.every((e) => e.phase === 'test-candidate') && await exists(join(dir, 'test-candidate.json'))) continue;
344
+ if (await isProvenPreAuthorityFailure(dir, name, { strictHistory: true })) continue;
345
+ const summaryReceipt = await readFileStrict(root, relative(root, join(dir, 'summary.json')).split(sep).join('/'), { encoding: 'utf8' });
346
+ const summary = JSON.parse(summaryReceipt.content);
347
+ if (await attributeHistoricalChild(root, name, events, summary, oldPlan)) continue;
348
+ refused('unbound run history cannot establish a safe replacement boundary', { runDirectory: name });
349
+ }
350
+ if (!originalCandidate) refused('old test candidate original is unavailable');
351
+ }
352
+
353
+ export async function bindReplacement({ root, prepared, replaceUnpublishedPlan, resultFile }) {
354
+ const rel = replaceUnpublishedPlan !== undefined
355
+ ? canonicalFormalPlanPath(root, replaceUnpublishedPlan)
356
+ : prepared.replacesPlanDigest ? `.release-skill/plans/${prepared.replacesPlanDigest}.json` : null;
357
+ if (!rel) return prepared;
358
+ root = await realpath(root);
359
+ const plans = await readFormalPlans(root, prepared);
360
+ const previous = plans.find((p) => p.planPath === rel);
361
+ if (!previous) refused('replacement predecessor is unavailable');
362
+ if (prepared.replacesPlanDigest && prepared.replacesPlanDigest !== previous.planDigest) refused('replacement selection differs from frozen candidate');
363
+ const body = { ...prepared, replacesPlanDigest: previous.planDigest };
364
+ if (targetIdentity(previous.plan) !== targetIdentity(body)) refused('replacement must preserve release units, versions and external targets');
365
+ if (previous.status !== 'PREPARED' || !previous.plan.testAcceptance) refused('only an accepted PREPARED plan can be replaced');
366
+ if (plans.some((p) => p.plan.replacesPlanDigest === previous.planDigest)) refused('predecessor plan is already replaced');
367
+ if (!resultFile || normalizeIntakePath(resultFile) === normalizeIntakePath(previous.plan.testAcceptance.result.path)) refused('replacement requires a different fixed resultFile');
368
+ await verifyTestAcceptance({ root, plan: previous.plan });
369
+ await verifyHistory(root, previous.plan);
370
+ return body;
371
+ }
@@ -0,0 +1,241 @@
1
+ import { readFile, lstat } from 'node:fs/promises';
2
+ import { join, basename } from 'node:path';
3
+ import { resolveProducerVersion } from './evidence.mjs';
4
+
5
+ export async function isProvenPreAuthorityFailure(runDir, runName, { strictHistory = false } = {}) {
6
+ if (!/^publish-/.test(runName)) return false;
7
+ const summaryPath = join(runDir, 'summary.json');
8
+ const evidencePath = join(runDir, 'evidence.jsonl');
9
+ let summary;
10
+ let events;
11
+ try {
12
+ summary = JSON.parse(await readFile(summaryPath, 'utf8'));
13
+ events = (await readFile(evidencePath, 'utf8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
14
+ } catch {
15
+ return false;
16
+ }
17
+
18
+ // The v1 writer (used before the producer-bound v2 envelope) created the
19
+ // run directory and appended safety-gate events before its first
20
+ // `release-run.json` state. A failed global preflight then appended only a
21
+ // generic `publish` failure and sealed a summary. This branch deliberately
22
+ // accepts that historical shape only when every byte still proves the same
23
+ // pre-authority boundary. It never infers safety from a directory name,
24
+ // mtime, summary alone, or a loosely similar phase.
25
+ const legacyPreAuthorityPhases = new Set([
26
+ 'safety-gate',
27
+ 'global-preflight-arbitration',
28
+ 're-observe-previous-public-baseline',
29
+ ]);
30
+ const legacyInputFailure = /(?:global preflight failed|source authority .*failed|cannot read (?:release plan|approval record)|production (?:publish|commands) require|plan (?:digest mismatch|action completeness gate failed|is not valid|requires)|baseline (?:changed|check failed)|previous-public-baseline|adapter .*failed)/i;
31
+ const legacyUnsafeFailure = /(?:remote (?:tag|branch|release) .*already exists|human intervention required|remote conflict|external write)/i;
32
+ const legacyPreAuthorityGates = new Set([
33
+ 'plan-load',
34
+ 'plan-schema',
35
+ 'plan-digest',
36
+ 'action-completeness',
37
+ 'approval-load',
38
+ 'adapter-availability',
39
+ 'baseline-check',
40
+ 'source-authority',
41
+ ]);
42
+ const validLegacyTimestamp = (value) => typeof value === 'string'
43
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
44
+ && Number.isFinite(Date.parse(value));
45
+ const validLegacyError = (error) => error && typeof error === 'object' && !Array.isArray(error)
46
+ && typeof error.code === 'string' && error.code.length > 0
47
+ && (error.message === undefined || typeof error.message === 'string');
48
+ const validLegacyEvent = (event, runId) => event && typeof event === 'object' && !Array.isArray(event)
49
+ && event.schemaVersion === 1
50
+ && !Object.hasOwn(event, 'producer')
51
+ && typeof event.runId === 'string' && event.runId === runId
52
+ && Number.isSafeInteger(event.sequence) && event.sequence >= 1
53
+ && validLegacyTimestamp(event.timestamp)
54
+ && event.command === 'publish'
55
+ && typeof event.phase === 'string'
56
+ && (legacyPreAuthorityPhases.has(event.phase) || event.phase === 'publish')
57
+ && typeof event.status === 'string' && event.status.length > 0
58
+ && (event.error === undefined || event.error === null || validLegacyError(event.error))
59
+ && (event.duration === undefined || Number.isSafeInteger(event.duration) && event.duration >= 0)
60
+ && (event.details === undefined || event.details !== null
61
+ && typeof event.details === 'object' && !Array.isArray(event.details));
62
+ const legacyRunId = basename(runDir);
63
+ const legacyTerminal = events.at(-1);
64
+ const legacyValid = summary && summary.status === 'FAILED'
65
+ && validLegacyError(summary.error)
66
+ && typeof summary.error.message === 'string' && legacyInputFailure.test(summary.error.message)
67
+ && !legacyUnsafeFailure.test(summary.error.message)
68
+ && (summary.recoveryActionCode === undefined || summary.recoveryActionCode === 'RETRY_COMMAND')
69
+ && summary.runPath === undefined
70
+ && summary.finalRunDigest === undefined
71
+ && summary.latestStatePath === undefined
72
+ && summary.checkpointStatuses === undefined
73
+ && Array.isArray(events) && events.length >= 2
74
+ && events.every((event, index) => validLegacyEvent(event, legacyRunId)
75
+ && event.sequence === index + 1)
76
+ && legacyTerminal.phase === 'publish'
77
+ && legacyTerminal.status === 'failed'
78
+ && validLegacyError(legacyTerminal.error)
79
+ && legacyTerminal.error.code === summary.error.code
80
+ && events.slice(0, -1).filter((event) => event.status === 'failed').every((event) =>
81
+ event.phase === 'safety-gate'
82
+ && legacyPreAuthorityGates.has(event.gate)
83
+ && validLegacyError(event.error))
84
+ && !events.some((event) => event.phase === 'global-preflight-arbitration'
85
+ && event.status !== 'pre-observe')
86
+ && !events.slice(0, -1).some((event) => event.phase === 'publish'
87
+ || /checkpoint|execute|postpublish/i.test(event.phase)
88
+ || Object.hasOwn(event, 'prePersistedRunPath')
89
+ || Object.hasOwn(event, 'checkpointCount')
90
+ || Object.hasOwn(event, 'checkpointStatuses'));
91
+ if (legacyValid) return !strictHistory && await hasNoState(runDir);
92
+
93
+ const producerVersion = summary?.producer?.version;
94
+ const historical = ['0.9.3', '0.9.14', '0.9.17', '0.9.33', '0.9.37'].includes(producerVersion);
95
+ if (!historical && producerVersion !== resolveProducerVersion()) return false;
96
+ const sameProducer = (value) => value && typeof value === 'object' && !Array.isArray(value)
97
+ && Object.keys(value).length === 2
98
+ && value.name === 'release-skill' && value.version === producerVersion;
99
+ const envelopeKeys = new Set([
100
+ 'schemaVersion', 'runId', 'sequence', 'timestamp', 'command', 'producer',
101
+ 'phase', 'status', 'error', 'duration', 'details',
102
+ ]);
103
+ const validTimestamp = (value) => typeof value === 'string'
104
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
105
+ && Number.isFinite(Date.parse(value));
106
+ const detailValidators = {
107
+ unitId: (value) => typeof value === 'string',
108
+ gate: (value) => typeof value === 'string',
109
+ hookName: (value) => typeof value === 'string',
110
+ actionId: (value) => typeof value === 'string',
111
+ step: (value) => typeof value === 'string',
112
+ hookId: (value) => typeof value === 'string',
113
+ tagCommit: (value) => typeof value === 'string',
114
+ cached: (value) => typeof value === 'boolean',
115
+ testSelection: (value) => typeof value === 'string',
116
+ cacheKey: (value) => typeof value === 'string',
117
+ durationMs: (value) => value === null || Number.isSafeInteger(value) && value >= 0,
118
+ exitCode: (value) => Number.isSafeInteger(value),
119
+ findingCount: (value) => Number.isSafeInteger(value) && value >= 0,
120
+ };
121
+ const validDetails = (value) => value === undefined
122
+ || value !== null && typeof value === 'object' && !Array.isArray(value)
123
+ && Object.entries(value).every(([key, detail]) => !detailValidators[key] || detailValidators[key](detail));
124
+ const validEnvelope = (event) => event && typeof event === 'object'
125
+ && Object.keys(event).every((key) => envelopeKeys.has(key))
126
+ && event.schemaVersion === 2
127
+ && typeof event.runId === 'string' && event.runId === basename(runDir)
128
+ && Number.isSafeInteger(event.sequence) && event.sequence >= 1
129
+ && validTimestamp(event.timestamp)
130
+ && event.command === 'publish'
131
+ && typeof event.phase === 'string' && event.phase.length > 0
132
+ && typeof event.status === 'string' && event.status.length > 0
133
+ && sameProducer(event.producer)
134
+ && (event.error === undefined || event.error === null
135
+ || (typeof event.error === 'object' && !Array.isArray(event.error)
136
+ && Object.keys(event.error).every((key) => key === 'code' || key === 'message')
137
+ && typeof event.error.code === 'string' && event.error.code.length > 0
138
+ && (event.error.message === undefined || typeof event.error.message === 'string')))
139
+ && (event.duration === undefined || Number.isSafeInteger(event.duration) && event.duration >= 0)
140
+ && validDetails(event.details);
141
+ const summaryKeys = new Set(['status', 'recoveryActionCode', 'error', 'details', 'failedAt', 'stablePhase', 'failedUnitId', 'failedHook', 'failedAction', 'evidenceSequence', 'evidencePath', 'producer']);
142
+ if (!summary || Object.keys(summary).some((key) => !summaryKeys.has(key))
143
+ || !summary.error || typeof summary.error.code !== 'string' || !summary.error.code
144
+ || typeof summary.error.message !== 'string'
145
+ || Object.keys(summary.error).some((key) => !['code', 'message'].includes(key))
146
+ || !summary.details || Object.keys(summary.details).length !== 1
147
+ || summary.status !== 'FAILED' || !['RETRY_COMMAND', 'DIAGNOSE'].includes(summary.recoveryActionCode)
148
+ || summary.evidencePath !== 'evidence.jsonl' || !sameProducer(summary.producer)
149
+ || !Array.isArray(events) || events.length === 0) return false;
150
+ let previousSequence = 0;
151
+ let firstFailure = null;
152
+ let genericFailure = null;
153
+ for (const event of events) {
154
+ if (!validEnvelope(event) || event.sequence !== previousSequence + 1) return false;
155
+ previousSequence = event.sequence;
156
+ if (event.status === 'failed' && firstFailure === null) firstFailure = event;
157
+ if (event.phase === 'publish') {
158
+ if (event.status !== 'failed' || genericFailure !== null || event.details?.prePersistedRunPath
159
+ || event.details?.recoveryActionCode !== summary.recoveryActionCode) return false;
160
+ genericFailure = event;
161
+ } else if (!['safety-gate', 'global-preflight-arbitration', 're-observe-previous-public-baseline'].includes(event.phase)) {
162
+ return false;
163
+ }
164
+ }
165
+ if (!firstFailure || !genericFailure || events.at(-1) !== genericFailure
166
+ || summary.evidenceSequence !== firstFailure.sequence
167
+ || summary.stablePhase !== firstFailure.phase
168
+ || summary.failedAction !== (firstFailure.details?.actionId ?? firstFailure.details?.gate ?? 'unknown')
169
+ || summary.failedUnitId !== (firstFailure.details?.unitId ?? 'unknown')
170
+ || summary.failedHook !== (firstFailure.details?.hookName ?? 'unknown')) return false;
171
+ return Number.isFinite(Date.parse(summary.failedAt))
172
+ && !events.some((event) => ['prePersistedRunPath', 'checkpointCount', 'checkpointStatuses'].some((key) => Object.hasOwn(event.details ?? {}, key)))
173
+ && await hasNoState(runDir)
174
+ && ((!strictHistory && producerVersion === resolveProducerVersion()) || closedPublishPrefix(events, summary, producerVersion));
175
+ }
176
+
177
+
178
+ async function hasNoState(runDir) {
179
+ for (const name of ['states', 'release-run.json']) {
180
+ try { await lstat(join(runDir, name)); return false; }
181
+ catch (error) { if (error.code !== 'ENOENT') return false; }
182
+ }
183
+ return true;
184
+ }
185
+
186
+ // A finite prefix of the audited publish writer, not a target inference.
187
+ function closedPublishPrefix(events, summary, version) {
188
+ let i = 0;
189
+ const take = (phase, status, gate) => {
190
+ const e = events[i];
191
+ if (!e || e.phase !== phase || e.status !== status || e.details?.gate !== gate) return false;
192
+ i += 1; return true;
193
+ };
194
+ const gate = (name, status) => take('safety-gate', status, name);
195
+ for (const [name, status] of [
196
+ ['plan-load', 'started'], ['plan-schema', 'passed'],
197
+ ['plan-digest', 'started'], ['plan-digest', 'passed'],
198
+ ['action-completeness', 'started'], ['action-completeness', 'passed'],
199
+ ]) if (!gate(name, status)) return false;
200
+ if (version !== '0.9.38' || events[i]?.details?.gate === 'frozen-artifacts') {
201
+ if (!gate('frozen-artifacts', 'started') || !gate('frozen-artifacts', 'passed')) return false;
202
+ }
203
+ if (version !== '0.9.38' || events[i]?.details?.gate === 'skill-resource-closure') {
204
+ if (!gate('skill-resource-closure', 'started') || !gate('skill-resource-closure', 'passed')) return false;
205
+ }
206
+ if (!gate('approval-load', 'started')) return false;
207
+ const terminal = () => {
208
+ const e = events[i];
209
+ return i === events.length - 1 && e?.phase === 'publish' && e.status === 'failed'
210
+ && JSON.stringify(e.error) === JSON.stringify(summary.error)
211
+ && summary.details?.recoveryActionCode === summary.recoveryActionCode;
212
+ };
213
+ if (['0.9.33', '0.9.38'].includes(version) && summary.recoveryActionCode === 'DIAGNOSE') return terminal();
214
+ if (summary.recoveryActionCode !== 'RETRY_COMMAND' || !gate('approval-validated', 'passed')) return false;
215
+ for (const name of ['adapter-availability', 'baseline-check']) {
216
+ if (!gate(name, 'started') || !gate(name, 'passed')) return false;
217
+ }
218
+ while (events[i]?.details?.gate === 'previous-public-baseline') {
219
+ const unit = events[i].details.unitId;
220
+ if (version === '0.9.38' && events[i].status === 'passed' && events[i].details.reason === 'fresh repository') {
221
+ if (typeof unit !== 'string' || !gate('previous-public-baseline', 'passed')) return false;
222
+ continue;
223
+ }
224
+ if (typeof unit !== 'string' || !gate('previous-public-baseline', 'started')
225
+ || !take('re-observe-previous-public-baseline', 'consistent', undefined)
226
+ || events[i]?.details?.unitId !== unit || !gate('previous-public-baseline', 'passed')) return false;
227
+ }
228
+ const sourceGate = events[i]?.details?.gate === 'source-authority';
229
+ if ((sourceGate || version !== '0.9.38') && !gate('source-authority', 'started')) return false;
230
+ if (['0.9.3', '0.9.14', '0.9.33', '0.9.37', '0.9.38'].includes(version)
231
+ && events[i]?.status === 'failed') {
232
+ return gate('source-authority', 'failed') && terminal();
233
+ }
234
+ if (!['0.9.17', '0.9.38'].includes(version) || (sourceGate && !gate('source-authority', 'passed')) || !gate('global-preflight', 'started')) return false;
235
+ let observed = 0;
236
+ while (events[i]?.phase === 'global-preflight-arbitration') {
237
+ if (!take('global-preflight-arbitration', 'pre-observe', undefined)) return false;
238
+ observed += 1;
239
+ }
240
+ return (version === '0.9.38' || observed > 0) && terminal();
241
+ }