release-skill 0.9.37 → 0.10.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.qoder-plugin/plugin.json +1 -1
- package/CHANGELOG.md +56 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +26 -14
- package/README.zh-CN.md +26 -14
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +1918 -1450
- package/adapters/claude/schemas/release-plan.schema.json +1 -0
- package/adapters/claude/schemas/test-intake.schema.json +1 -0
- package/adapters/claude/skills/release-help/SKILL.md +36 -41
- package/adapters/claude/skills/release-prepare/SKILL.md +35 -3
- package/adapters/claude/skills/release-publish/SKILL.md +11 -1
- package/adapters/claude/skills/release-reconcile/SKILL.md +14 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +1918 -1450
- package/adapters/codex/schemas/release-plan.schema.json +1 -0
- package/adapters/codex/schemas/test-intake.schema.json +1 -0
- package/adapters/codex/skills/release-help/SKILL.md +36 -41
- package/adapters/codex/skills/release-prepare/SKILL.md +35 -3
- package/adapters/codex/skills/release-publish/SKILL.md +11 -1
- package/adapters/codex/skills/release-reconcile/SKILL.md +14 -0
- package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
- package/adapters/cursor/bin/release-skill.bundle.mjs +1918 -1450
- package/adapters/cursor/schemas/release-plan.schema.json +1 -0
- package/adapters/cursor/schemas/test-intake.schema.json +1 -0
- package/adapters/cursor/skills/release-help/SKILL.md +36 -41
- package/adapters/cursor/skills/release-prepare/SKILL.md +35 -3
- package/adapters/cursor/skills/release-publish/SKILL.md +11 -1
- package/adapters/cursor/skills/release-reconcile/SKILL.md +14 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +1918 -1450
- package/adapters/kimi/schemas/release-plan.schema.json +1 -0
- package/adapters/kimi/schemas/test-intake.schema.json +1 -0
- package/adapters/kimi/skills/release-help/SKILL.md +36 -41
- package/adapters/kimi/skills/release-prepare/SKILL.md +35 -3
- package/adapters/kimi/skills/release-publish/SKILL.md +11 -1
- package/adapters/kimi/skills/release-reconcile/SKILL.md +14 -0
- package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
- package/adapters/qoder/bin/release-skill.bundle.mjs +1918 -1450
- package/adapters/qoder/schemas/release-plan.schema.json +1 -0
- package/adapters/qoder/schemas/test-intake.schema.json +1 -0
- package/adapters/qoder/skills/release-help/SKILL.md +36 -41
- package/adapters/qoder/skills/release-prepare/SKILL.md +35 -3
- package/adapters/qoder/skills/release-publish/SKILL.md +11 -1
- package/adapters/qoder/skills/release-reconcile/SKILL.md +14 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +1918 -1450
- package/adapters/workbuddy/schemas/release-plan.schema.json +1 -0
- package/adapters/workbuddy/schemas/test-intake.schema.json +1 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +36 -41
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +35 -3
- package/adapters/workbuddy/skills/release-publish/SKILL.md +11 -1
- package/adapters/workbuddy/skills/release-reconcile/SKILL.md +14 -0
- package/bin/release-skill-cli.mjs +5 -0
- package/bin/release-skill.bundle.mjs +1918 -1450
- package/package.json +3 -2
- package/platform-manifest.json +4 -4
- package/references/01-state-machine.md +26 -0
- package/references/02-project-config.md +20 -0
- package/schemas/release-plan.schema.json +1 -0
- package/schemas/test-intake.schema.json +1 -0
- package/skills/release-help/SKILL.md +36 -41
- package/skills/release-prepare/SKILL.md +35 -3
- package/skills/release-publish/SKILL.md +11 -1
- package/skills/release-reconcile/SKILL.md +14 -0
- package/skills-src/release-help/SKILL.md +36 -41
- package/skills-src/release-prepare/SKILL.md +35 -3
- package/skills-src/release-publish/SKILL.md +11 -1
- package/skills-src/release-reconcile/SKILL.md +14 -0
- package/src/commands/approve.mjs +33 -24
- package/src/commands/post-release-finish.mjs +9 -0
- package/src/commands/prepare.mjs +9 -2
- package/src/commands/publish.mjs +10 -2
- package/src/commands/reconcile.mjs +2 -0
- package/src/commands/route.mjs +2 -159
- package/src/core/plan-replacement.mjs +371 -0
- package/src/core/pre-authority-failure.mjs +242 -0
- package/src/core/test-candidate.mjs +15 -71
|
@@ -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
|
+
export 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,242 @@
|
|
|
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', '0.9.38'].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
|
+
const auditedCurrentPrefix = ['0.9.38', '0.10.0'].includes(version);
|
|
189
|
+
let i = 0;
|
|
190
|
+
const take = (phase, status, gate) => {
|
|
191
|
+
const e = events[i];
|
|
192
|
+
if (!e || e.phase !== phase || e.status !== status || e.details?.gate !== gate) return false;
|
|
193
|
+
i += 1; return true;
|
|
194
|
+
};
|
|
195
|
+
const gate = (name, status) => take('safety-gate', status, name);
|
|
196
|
+
for (const [name, status] of [
|
|
197
|
+
['plan-load', 'started'], ['plan-schema', 'passed'],
|
|
198
|
+
['plan-digest', 'started'], ['plan-digest', 'passed'],
|
|
199
|
+
['action-completeness', 'started'], ['action-completeness', 'passed'],
|
|
200
|
+
]) if (!gate(name, status)) return false;
|
|
201
|
+
if (!auditedCurrentPrefix || events[i]?.details?.gate === 'frozen-artifacts') {
|
|
202
|
+
if (!gate('frozen-artifacts', 'started') || !gate('frozen-artifacts', 'passed')) return false;
|
|
203
|
+
}
|
|
204
|
+
if (!auditedCurrentPrefix || events[i]?.details?.gate === 'skill-resource-closure') {
|
|
205
|
+
if (!gate('skill-resource-closure', 'started') || !gate('skill-resource-closure', 'passed')) return false;
|
|
206
|
+
}
|
|
207
|
+
if (!gate('approval-load', 'started')) return false;
|
|
208
|
+
const terminal = () => {
|
|
209
|
+
const e = events[i];
|
|
210
|
+
return i === events.length - 1 && e?.phase === 'publish' && e.status === 'failed'
|
|
211
|
+
&& JSON.stringify(e.error) === JSON.stringify(summary.error)
|
|
212
|
+
&& summary.details?.recoveryActionCode === summary.recoveryActionCode;
|
|
213
|
+
};
|
|
214
|
+
if ((version === '0.9.33' || auditedCurrentPrefix) && summary.recoveryActionCode === 'DIAGNOSE') return terminal();
|
|
215
|
+
if (summary.recoveryActionCode !== 'RETRY_COMMAND' || !gate('approval-validated', 'passed')) return false;
|
|
216
|
+
for (const name of ['adapter-availability', 'baseline-check']) {
|
|
217
|
+
if (!gate(name, 'started') || !gate(name, 'passed')) return false;
|
|
218
|
+
}
|
|
219
|
+
while (events[i]?.details?.gate === 'previous-public-baseline') {
|
|
220
|
+
const unit = events[i].details.unitId;
|
|
221
|
+
if (auditedCurrentPrefix && events[i].status === 'passed' && events[i].details.reason === 'fresh repository') {
|
|
222
|
+
if (typeof unit !== 'string' || !gate('previous-public-baseline', 'passed')) return false;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (typeof unit !== 'string' || !gate('previous-public-baseline', 'started')
|
|
226
|
+
|| !take('re-observe-previous-public-baseline', 'consistent', undefined)
|
|
227
|
+
|| events[i]?.details?.unitId !== unit || !gate('previous-public-baseline', 'passed')) return false;
|
|
228
|
+
}
|
|
229
|
+
const sourceGate = events[i]?.details?.gate === 'source-authority';
|
|
230
|
+
if ((sourceGate || !auditedCurrentPrefix) && !gate('source-authority', 'started')) return false;
|
|
231
|
+
if ((['0.9.3', '0.9.14', '0.9.33', '0.9.37'].includes(version) || auditedCurrentPrefix)
|
|
232
|
+
&& events[i]?.status === 'failed') {
|
|
233
|
+
return gate('source-authority', 'failed') && terminal();
|
|
234
|
+
}
|
|
235
|
+
if (!(version === '0.9.17' || auditedCurrentPrefix) || (sourceGate && !gate('source-authority', 'passed')) || !gate('global-preflight', 'started')) return false;
|
|
236
|
+
let observed = 0;
|
|
237
|
+
while (events[i]?.phase === 'global-preflight-arbitration') {
|
|
238
|
+
if (!take('global-preflight-arbitration', 'pre-observe', undefined)) return false;
|
|
239
|
+
observed += 1;
|
|
240
|
+
}
|
|
241
|
+
return (auditedCurrentPrefix || observed > 0) && terminal();
|
|
242
|
+
}
|