release-skill 0.9.33 → 0.9.35

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 (94) 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 +48 -0
  8. package/INSTALL.md +2 -2
  9. package/INSTALL.zh-CN.md +2 -2
  10. package/README.md +25 -15
  11. package/README.zh-CN.md +11 -15
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +5878 -4056
  14. package/adapters/claude/schemas/.render-manifest.json +8 -4
  15. package/adapters/claude/schemas/release-plan.schema.json +85 -0
  16. package/adapters/claude/schemas/release-project.schema.json +18 -0
  17. package/adapters/claude/schemas/test-intake.schema.json +217 -0
  18. package/adapters/claude/skills/release-finish/SKILL.md +5 -1
  19. package/adapters/claude/skills/release-help/SKILL.md +4 -1
  20. package/adapters/claude/skills/release-prepare/SKILL.md +9 -2
  21. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  22. package/adapters/codex/bin/release-skill.bundle.mjs +5878 -4056
  23. package/adapters/codex/schemas/.render-manifest.json +8 -4
  24. package/adapters/codex/schemas/release-plan.schema.json +85 -0
  25. package/adapters/codex/schemas/release-project.schema.json +18 -0
  26. package/adapters/codex/schemas/test-intake.schema.json +217 -0
  27. package/adapters/codex/skills/release-finish/SKILL.md +5 -1
  28. package/adapters/codex/skills/release-help/SKILL.md +4 -1
  29. package/adapters/codex/skills/release-prepare/SKILL.md +9 -2
  30. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  31. package/adapters/cursor/bin/release-skill.bundle.mjs +5878 -4056
  32. package/adapters/cursor/schemas/.render-manifest.json +8 -4
  33. package/adapters/cursor/schemas/release-plan.schema.json +85 -0
  34. package/adapters/cursor/schemas/release-project.schema.json +18 -0
  35. package/adapters/cursor/schemas/test-intake.schema.json +217 -0
  36. package/adapters/cursor/skills/release-finish/SKILL.md +5 -1
  37. package/adapters/cursor/skills/release-help/SKILL.md +4 -1
  38. package/adapters/cursor/skills/release-prepare/SKILL.md +9 -2
  39. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  40. package/adapters/kimi/bin/release-skill.bundle.mjs +5878 -4056
  41. package/adapters/kimi/schemas/.render-manifest.json +8 -4
  42. package/adapters/kimi/schemas/release-plan.schema.json +85 -0
  43. package/adapters/kimi/schemas/release-project.schema.json +18 -0
  44. package/adapters/kimi/schemas/test-intake.schema.json +217 -0
  45. package/adapters/kimi/skills/release-finish/SKILL.md +5 -1
  46. package/adapters/kimi/skills/release-help/SKILL.md +4 -1
  47. package/adapters/kimi/skills/release-prepare/SKILL.md +9 -2
  48. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  49. package/adapters/qoder/bin/release-skill.bundle.mjs +5878 -4056
  50. package/adapters/qoder/schemas/.render-manifest.json +8 -4
  51. package/adapters/qoder/schemas/release-plan.schema.json +85 -0
  52. package/adapters/qoder/schemas/release-project.schema.json +18 -0
  53. package/adapters/qoder/schemas/test-intake.schema.json +217 -0
  54. package/adapters/qoder/skills/release-finish/SKILL.md +5 -1
  55. package/adapters/qoder/skills/release-help/SKILL.md +4 -1
  56. package/adapters/qoder/skills/release-prepare/SKILL.md +9 -2
  57. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  58. package/adapters/workbuddy/bin/release-skill.bundle.mjs +5878 -4056
  59. package/adapters/workbuddy/schemas/.render-manifest.json +8 -4
  60. package/adapters/workbuddy/schemas/release-plan.schema.json +85 -0
  61. package/adapters/workbuddy/schemas/release-project.schema.json +18 -0
  62. package/adapters/workbuddy/schemas/test-intake.schema.json +217 -0
  63. package/adapters/workbuddy/skills/release-finish/SKILL.md +5 -1
  64. package/adapters/workbuddy/skills/release-help/SKILL.md +4 -1
  65. package/adapters/workbuddy/skills/release-prepare/SKILL.md +9 -2
  66. package/bin/release-skill-cli.mjs +37 -2
  67. package/bin/release-skill.bundle.mjs +5878 -4056
  68. package/package.json +1 -1
  69. package/platform-manifest.json +4 -4
  70. package/references/.render-manifest.json +6 -6
  71. package/references/01-state-machine.md +12 -1
  72. package/references/02-project-config.md +23 -1
  73. package/references/05-evidence-and-errors.md +30 -0
  74. package/schemas/.render-manifest.json +8 -4
  75. package/schemas/release-plan.schema.json +85 -0
  76. package/schemas/release-project.schema.json +18 -0
  77. package/schemas/test-intake.schema.json +217 -0
  78. package/skills/release-finish/SKILL.md +5 -1
  79. package/skills/release-help/SKILL.md +4 -1
  80. package/skills/release-prepare/SKILL.md +9 -2
  81. package/skills-src/release-finish/SKILL.md +5 -1
  82. package/skills-src/release-help/SKILL.md +4 -1
  83. package/skills-src/release-prepare/SKILL.md +9 -2
  84. package/src/commands/approve.mjs +13 -0
  85. package/src/commands/post-release-local.mjs +33 -15
  86. package/src/commands/prepare.mjs +501 -5
  87. package/src/commands/publish.mjs +2 -0
  88. package/src/commands/reconcile.mjs +2 -0
  89. package/src/commands/route.mjs +32 -7
  90. package/src/commands/ship.mjs +168 -5
  91. package/src/core/config.mjs +5 -0
  92. package/src/core/plan.mjs +3 -1
  93. package/src/core/test-candidate.mjs +649 -0
  94. package/src/core/test-intake.mjs +645 -0
@@ -0,0 +1,649 @@
1
+ /**
2
+ * One test-intake candidate: exclusive save, strict read, and the no-git
3
+ * handoff decision shared by prepare and ship.
4
+ *
5
+ * Frozen git verification stays in prepare. This module does not run git.
6
+ *
7
+ * @module core/test-candidate
8
+ */
9
+
10
+ import { lstat, readdir, realpath } from 'node:fs/promises';
11
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
12
+ import Ajv from 'ajv';
13
+ import addFormats from 'ajv-formats';
14
+ import {
15
+ publishFileExclusive,
16
+ readFileStrict,
17
+ HARNESS_ERROR_KINDS,
18
+ } from 'skill-family-harness-node';
19
+ import { readTrustedPackageResourceSync } from './trusted-resource.mjs';
20
+ import { ReleaseError, CONFIG_INVALID, GATE_FAILED } from './errors.mjs';
21
+ import { computePlanDigest } from './plan.mjs';
22
+ import { acceptTestResults } from './test-intake.mjs';
23
+
24
+ const CANDIDATE_REL = /^\.release-skill\/runs\/[^/]+\/test-candidate\.json$/;
25
+ const TERMINAL_SHIP_STATUSES = new Set(['PARTIAL', 'PUBLISHED', 'VERIFIED']);
26
+
27
+ function writerVersion() {
28
+ // Adapter closures do not ship package.json; use the package identity
29
+ // injected by the bundle banner. Source mode still reads the trusted file.
30
+ if (typeof __bundlePkg !== 'undefined' && __bundlePkg
31
+ && typeof __bundlePkg.version === 'string' && __bundlePkg.version.length > 0) {
32
+ return __bundlePkg.version;
33
+ }
34
+ const packageJson = JSON.parse(readTrustedPackageResourceSync('package.json').toString('utf8'));
35
+ return packageJson.version;
36
+ }
37
+
38
+ export const WRITER_VERSION = writerVersion();
39
+
40
+ const INTAKE_SCHEMA = JSON.parse(readTrustedPackageResourceSync(
41
+ 'schemas/test-intake.schema.json',
42
+ ).toString('utf8'));
43
+ const ajv = new Ajv({ allErrors: true, strict: false });
44
+ addFormats(ajv);
45
+ ajv.addSchema(INTAKE_SCHEMA);
46
+ const validateCandidate = ajv.compile({ $ref: `${INTAKE_SCHEMA.$id}#/definitions/candidate` });
47
+
48
+ /**
49
+ * Final plan content without release qualification fields.
50
+ *
51
+ * @param {object} plan
52
+ * @returns {object}
53
+ */
54
+ export function projectPrepared(plan) {
55
+ const {
56
+ status: _status,
57
+ digest: _digest,
58
+ testAcceptance: _testAcceptance,
59
+ ...rest
60
+ } = plan;
61
+ return rest;
62
+ }
63
+
64
+ function forbidPreparedKeys(prepared) {
65
+ for (const key of ['status', 'digest', 'testAcceptance']) {
66
+ if (prepared && Object.prototype.hasOwnProperty.call(prepared, key)) {
67
+ throw new ReleaseError(
68
+ CONFIG_INVALID,
69
+ `test candidate prepared must not include ${key}`,
70
+ { field: 'prepared', reason: 'TEST_INTAKE_PATH' },
71
+ );
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {string} root
78
+ * @param {string} candidatePath
79
+ * @returns {string}
80
+ */
81
+ export function canonicalCandidatePath(root, candidatePath) {
82
+ if (typeof root !== 'string' || root.length === 0 || typeof candidatePath !== 'string') {
83
+ throw new ReleaseError(CONFIG_INVALID, 'candidate path must be .release-skill/runs/<run>/test-candidate.json', {
84
+ reason: 'TEST_INTAKE_PATH',
85
+ field: 'candidatePath',
86
+ });
87
+ }
88
+ const absRoot = resolve(root);
89
+ const abs = isAbsolute(candidatePath) ? resolve(candidatePath) : resolve(absRoot, candidatePath);
90
+ const rel = relative(absRoot, abs).split(sep).join('/');
91
+ if (!CANDIDATE_REL.test(rel)) {
92
+ throw new ReleaseError(CONFIG_INVALID, 'candidate path must be .release-skill/runs/<run>/test-candidate.json', {
93
+ reason: 'TEST_INTAKE_PATH',
94
+ field: 'candidatePath',
95
+ });
96
+ }
97
+ return rel;
98
+ }
99
+
100
+ function schemaFailure(validator) {
101
+ const errors = validator.errors ?? [];
102
+ const summary = errors.map((entry) => `${entry.instancePath || '/'} ${entry.message}`).join('; ');
103
+ throw new ReleaseError(CONFIG_INVALID, `test candidate schema validation failed: ${summary}`, {
104
+ field: 'test-candidate',
105
+ validationErrors: errors.map((entry) => ({
106
+ instancePath: entry.instancePath,
107
+ message: entry.message,
108
+ })),
109
+ });
110
+ }
111
+
112
+ function fileRefOf(requirements) {
113
+ if (requirements?.file?.path && requirements?.file?.sha256) {
114
+ return { path: requirements.file.path, sha256: requirements.file.sha256 };
115
+ }
116
+ if (requirements?.path && requirements?.sha256) {
117
+ return { path: requirements.path, sha256: requirements.sha256 };
118
+ }
119
+ throw new ReleaseError(CONFIG_INVALID, 'test candidate requirements file reference is missing', {
120
+ reason: 'TEST_INTAKE_PATH',
121
+ field: 'requirements',
122
+ });
123
+ }
124
+
125
+ function harnessKind(err) {
126
+ return err?.details?.kind ?? null;
127
+ }
128
+
129
+ /**
130
+ * @param {{ root: string, runDir: string, prepared: object, requirements: object, prepareOptions: object }} options
131
+ */
132
+ export async function writeTestCandidate({ root, runDir, prepared, requirements, prepareOptions }) {
133
+ const preparedBody = projectPrepared(prepared);
134
+ forbidPreparedKeys(preparedBody);
135
+ const runRel = relative(resolve(root), resolve(runDir)).split(sep).join('/');
136
+ if (!runRel || runRel.startsWith('..') || runRel.split('/').includes('..')) {
137
+ throw new ReleaseError(CONFIG_INVALID, 'test candidate run directory escapes the project root', {
138
+ reason: 'TEST_INTAKE_PATH',
139
+ field: 'runDir',
140
+ });
141
+ }
142
+ const candidatePath = `${runRel}/test-candidate.json`;
143
+ if (!CANDIDATE_REL.test(candidatePath)) {
144
+ throw new ReleaseError(CONFIG_INVALID, 'test candidate path is not under .release-skill/runs/<run>/', {
145
+ reason: 'TEST_INTAKE_PATH',
146
+ field: 'candidatePath',
147
+ });
148
+ }
149
+ const candidate = {
150
+ candidateVersion: 1,
151
+ writerVersion: WRITER_VERSION,
152
+ prepareOptions,
153
+ requirements: fileRefOf(requirements),
154
+ prepared: preparedBody,
155
+ };
156
+ if (!validateCandidate(candidate)) schemaFailure(validateCandidate);
157
+ let receipt;
158
+ try {
159
+ receipt = await publishFileExclusive(
160
+ root,
161
+ candidatePath,
162
+ `${JSON.stringify(candidate, null, 2)}\n`,
163
+ { mode: 0o600, createParents: false },
164
+ );
165
+ } catch (err) {
166
+ const kind = harnessKind(err);
167
+ if (kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
168
+ throw new ReleaseError(GATE_FAILED, 'test candidate already exists and was not overwritten', {
169
+ reason: 'TEST_CANDIDATE_REPREPARE',
170
+ field: 'candidatePath',
171
+ cause: kind,
172
+ });
173
+ }
174
+ throw new ReleaseError(GATE_FAILED, 'test candidate could not be saved', {
175
+ reason: kind ?? 'unknown',
176
+ field: 'candidatePath',
177
+ cause: kind ?? 'unknown',
178
+ });
179
+ }
180
+ return { candidate, sha256: receipt.sha256, candidatePath };
181
+ }
182
+
183
+ /**
184
+ * @param {{ root: string, candidatePath: string }} options
185
+ */
186
+ export async function readTestCandidate({ root, candidatePath }) {
187
+ const rel = canonicalCandidatePath(root, candidatePath);
188
+ let receipt;
189
+ try {
190
+ receipt = await readFileStrict(root, rel, { encoding: 'utf8' });
191
+ } catch (err) {
192
+ const kind = harnessKind(err);
193
+ const reason = kind === HARNESS_ERROR_KINDS.MISSING_RESOURCE
194
+ ? 'TEST_ACCEPTANCE_MISSING'
195
+ : (kind === HARNESS_ERROR_KINDS.CONTENT_GUARD_REJECTED || kind === HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY)
196
+ ? 'TEST_ACCEPTANCE_TAMPERED'
197
+ : (kind ?? 'unknown');
198
+ throw new ReleaseError(GATE_FAILED, 'test candidate could not be read', {
199
+ reason,
200
+ field: 'candidatePath',
201
+ cause: kind ?? 'unknown',
202
+ });
203
+ }
204
+ let candidate;
205
+ try {
206
+ candidate = JSON.parse(receipt.content);
207
+ } catch {
208
+ throw new ReleaseError(CONFIG_INVALID, 'test candidate is not valid JSON', {
209
+ field: 'candidatePath',
210
+ });
211
+ }
212
+ if (!validateCandidate(candidate)) schemaFailure(validateCandidate);
213
+ forbidPreparedKeys(candidate.prepared);
214
+ return { candidate, sha256: receipt.sha256, candidatePath: rel };
215
+ }
216
+
217
+ /**
218
+ * Read the first run's candidate digest from evidence.jsonl.
219
+ * Missing evidence returns null. This reader does not run git.
220
+ *
221
+ * @param {string} root
222
+ * @param {string} candidatePath
223
+ * @returns {Promise<string|null>}
224
+ */
225
+ export async function readCandidateEvidenceSha256(root, candidatePath) {
226
+ const rel = canonicalCandidatePath(root, candidatePath);
227
+ const evidenceRel = rel.replace(/\/test-candidate\.json$/, '/evidence.jsonl');
228
+ let receipt;
229
+ try {
230
+ receipt = await readFileStrict(root, evidenceRel, { encoding: 'utf8' });
231
+ } catch (err) {
232
+ if (harnessKind(err) === HARNESS_ERROR_KINDS.MISSING_RESOURCE) return null;
233
+ throw new ReleaseError(GATE_FAILED, 'test candidate evidence could not be read', {
234
+ reason: 'TEST_CANDIDATE_EVIDENCE_MISSING',
235
+ cause: harnessKind(err) ?? 'unknown',
236
+ });
237
+ }
238
+ let sha = null;
239
+ for (const line of receipt.content.split('\n')) {
240
+ if (!line.trim()) continue;
241
+ let event;
242
+ try {
243
+ event = JSON.parse(line);
244
+ } catch {
245
+ continue;
246
+ }
247
+ if (event?.phase === 'test-candidate' && event?.status === 'saved') {
248
+ const found = event.details?.candidateSha256;
249
+ if (typeof found === 'string' && found.length > 0) sha = found;
250
+ }
251
+ }
252
+ return sha;
253
+ }
254
+
255
+ function sameStringList(left, right) {
256
+ const a = [...(left ?? [])].sort();
257
+ const b = [...(right ?? [])].sort();
258
+ return a.length === b.length && a.every((value, index) => value === b[index]);
259
+ }
260
+
261
+ function sameVersions(left, right) {
262
+ const leftKeys = Object.keys(left ?? {}).sort();
263
+ const rightKeys = Object.keys(right ?? {}).sort();
264
+ if (leftKeys.join('\0') !== rightKeys.join('\0')) return false;
265
+ return leftKeys.every((key) => left[key] === right[key]);
266
+ }
267
+
268
+ function targetVersionsOf(plan) {
269
+ return [...new Set((Array.isArray(plan?.units) ? plan.units : [])
270
+ .map((unit) => unit?.targetVersion)
271
+ .filter((version) => typeof version === 'string' && version.length > 0))];
272
+ }
273
+
274
+ function sameTargetVersions(left, right) {
275
+ return left.length === right.length && left.every((version) => right.includes(version));
276
+ }
277
+
278
+ /**
279
+ * Formal plans under `.release-skill/plans`. The convenience pointer is not
280
+ * an acceptance basis. Does not run git.
281
+ *
282
+ * @param {string} root
283
+ */
284
+ async function readFormalPlans(root) {
285
+ const plansPath = resolve(root, '.release-skill', 'plans');
286
+ let names;
287
+ try {
288
+ names = await readdir(plansPath);
289
+ } catch (err) {
290
+ if (err?.code === 'ENOENT') {
291
+ try {
292
+ await lstat(plansPath);
293
+ } catch (statError) {
294
+ if (statError?.code === 'ENOENT') return [];
295
+ throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plans could not be read', {
296
+ reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
297
+ field: 'plansPath',
298
+ cause: statError?.code ?? 'unknown',
299
+ });
300
+ }
301
+ }
302
+ throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plans could not be read', {
303
+ reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
304
+ field: 'plansPath',
305
+ cause: err?.code ?? 'unknown',
306
+ });
307
+ }
308
+ const plans = [];
309
+ for (const name of names) {
310
+ if (!name.endsWith('.json')) continue;
311
+ const rel = `.release-skill/plans/${name}`;
312
+ let receipt;
313
+ try {
314
+ receipt = await readFileStrict(root, rel, { encoding: 'utf8' });
315
+ } catch (err) {
316
+ const cause = harnessKind(err) ?? 'unknown';
317
+ throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plan could not be read', {
318
+ reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
319
+ field: 'planPath',
320
+ path: rel,
321
+ cause,
322
+ });
323
+ }
324
+ let plan;
325
+ try {
326
+ plan = JSON.parse(receipt.content);
327
+ } catch {
328
+ throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plan is not valid JSON', {
329
+ reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
330
+ field: 'planPath',
331
+ path: rel,
332
+ });
333
+ }
334
+ plans.push({
335
+ planPath: rel,
336
+ planDigest: typeof plan?.digest === 'string' ? plan.digest : name.slice(0, -'.json'.length),
337
+ status: plan?.status,
338
+ subjectDigest: typeof plan?.testAcceptance?.subjectDigest === 'string'
339
+ ? plan.testAcceptance.subjectDigest
340
+ : null,
341
+ targetVersions: targetVersionsOf(plan),
342
+ });
343
+ }
344
+ return plans;
345
+ }
346
+
347
+ async function sameAuthorityFile(left, right) {
348
+ const identity = async (value) => {
349
+ const absolute = resolve(value);
350
+ try {
351
+ return await realpath(absolute);
352
+ } catch {
353
+ return absolute;
354
+ }
355
+ };
356
+ return (await identity(left)) === (await identity(right));
357
+ }
358
+
359
+ async function authoritativeRunStatus(root, hit, targetVersions) {
360
+ const targetVersion = targetVersions.length === 1 ? targetVersions[0] : undefined;
361
+ let routing;
362
+ try {
363
+ const { readRunRouting } = await import('../commands/route.mjs');
364
+ routing = await readRunRouting(root, targetVersion ? { targetVersion } : {});
365
+ } catch {
366
+ return hit.status;
367
+ }
368
+ const formal = resolve(root, hit.planPath);
369
+ const bound = [];
370
+ for (const run of routing?.observedRuns ?? []) {
371
+ if (!run?.planPath || !run.status) continue;
372
+ if (await sameAuthorityFile(run.planPath, formal)) bound.push(run);
373
+ }
374
+ // A finished verify sits beside its PUBLISHED parent. The parent is lineage,
375
+ // not a second acceptance status. postverify is a later local finishing
376
+ // fact: a completed DISTRIBUTED leaf must not replace VERIFIED or ask for
377
+ // a new approval, and a failed or unfinished postverify must not be
378
+ // reported as DISTRIBUTED. It is not a ship terminal status.
379
+ const releaseRuns = bound.filter((run) => run.command !== 'postverify');
380
+ const leaves = [];
381
+ for (const run of releaseRuns) {
382
+ let ancestor = false;
383
+ for (const child of releaseRuns) {
384
+ if (child === run || !child.sourceRunPath || !run.runPath) continue;
385
+ if (await sameAuthorityFile(child.sourceRunPath, run.runPath)) {
386
+ ancestor = true;
387
+ break;
388
+ }
389
+ }
390
+ if (!ancestor) leaves.push(run.status);
391
+ }
392
+ const statuses = [...new Set(leaves)];
393
+ if (statuses.length > 1) {
394
+ throw new ReleaseError(GATE_FAILED, 'formal plan runs disagree on the authoritative status', {
395
+ reason: 'TEST_CANDIDATE_REPREPARE',
396
+ drifts: ['acceptance-basis'],
397
+ statuses,
398
+ });
399
+ }
400
+ return statuses.length === 1 ? statuses[0] : hit.status;
401
+ }
402
+
403
+ /**
404
+ * Plans already accepted for this subject. Does not run git.
405
+ *
406
+ * @param {string} root
407
+ * @param {string} subjectDigest
408
+ * @returns {Promise<Array<{ planPath: string, planDigest: string, status: string }>>}
409
+ */
410
+ export async function locateAcceptedPlans(root, subjectDigest) {
411
+ const plans = await readFormalPlans(root);
412
+ return plans
413
+ .filter((plan) => plan.subjectDigest === subjectDigest)
414
+ .map(({ planPath, planDigest, status }) => ({ planPath, planDigest, status }));
415
+ }
416
+
417
+ /**
418
+ * Pure handoff over already loaded documents. Does not run git and does not
419
+ * allocate a second candidate on reattach.
420
+ */
421
+ export async function decideTestCandidateHandoff({
422
+ root,
423
+ runDir,
424
+ config,
425
+ production,
426
+ candidatePath,
427
+ prepared,
428
+ requirements,
429
+ prepareOptions,
430
+ evidenceCandidateSha256,
431
+ expectedCandidateSha256,
432
+ expectedSubjectDigest,
433
+ configDigest,
434
+ extraDrifts = [],
435
+ }) {
436
+ if (!config?.testIntake) {
437
+ if (candidatePath) {
438
+ throw new ReleaseError(CONFIG_INVALID, 'candidate path requires testIntake', {
439
+ reason: 'TEST_CANDIDATE_WITHOUT_INTAKE',
440
+ field: 'candidatePath',
441
+ });
442
+ }
443
+ return { kind: 'legacy', writePlan: true, rebuild: true };
444
+ }
445
+ if (production !== true) {
446
+ throw new ReleaseError(CONFIG_INVALID, 'testIntake is unsupported outside production prepare', {
447
+ reason: 'TEST_INTAKE_NON_PRODUCTION',
448
+ field: 'testIntake',
449
+ });
450
+ }
451
+ if (!candidatePath) {
452
+ const saved = await writeTestCandidate({ root, runDir, prepared, requirements, prepareOptions });
453
+ const subjectDigest = computePlanDigest(saved.candidate.prepared);
454
+ return {
455
+ kind: 'save-candidate',
456
+ status: 'NEEDS_INPUT',
457
+ writePlan: false,
458
+ rebuild: false,
459
+ code: 'GATE_FAILED',
460
+ candidatePath: saved.candidatePath,
461
+ candidateSha256: saved.sha256,
462
+ subjectDigest,
463
+ };
464
+ }
465
+
466
+ const loaded = await readTestCandidate({ root, candidatePath });
467
+ if (expectedCandidateSha256 && expectedCandidateSha256 !== loaded.sha256) {
468
+ throw new ReleaseError(GATE_FAILED, 'test candidate digest does not match the ship record', {
469
+ reason: 'TEST_CANDIDATE_DIGEST_MISMATCH',
470
+ field: 'candidatePath',
471
+ });
472
+ }
473
+ if (!evidenceCandidateSha256) {
474
+ throw new ReleaseError(GATE_FAILED, 'test candidate evidence digest is missing', {
475
+ reason: 'TEST_CANDIDATE_EVIDENCE_MISSING',
476
+ field: 'candidatePath',
477
+ });
478
+ }
479
+ if (evidenceCandidateSha256 !== loaded.sha256) {
480
+ throw new ReleaseError(GATE_FAILED, 'test candidate evidence digest does not match the candidate bytes', {
481
+ reason: 'TEST_CANDIDATE_DIGEST_MISMATCH',
482
+ field: 'candidatePath',
483
+ });
484
+ }
485
+
486
+ const options = loaded.candidate.prepareOptions;
487
+ const drifts = [...extraDrifts];
488
+ if (loaded.candidate.writerVersion !== WRITER_VERSION) drifts.push('writerVersion');
489
+ if (loaded.candidate.prepared?.configDigest !== configDigest) drifts.push('configDigest');
490
+ const requirementsRef = fileRefOf(requirements);
491
+ if (requirementsRef.sha256 !== loaded.candidate.requirements.sha256) drifts.push('requirements.sha256');
492
+ if (requirementsRef.path !== loaded.candidate.requirements.path) drifts.push('requirements.path');
493
+ if (prepareOptions.workflow !== options.workflow) drifts.push('prepareOptions.workflow');
494
+ if (prepareOptions.offline !== options.offline) drifts.push('prepareOptions.offline');
495
+ if (prepareOptions.production !== options.production) drifts.push('prepareOptions.production');
496
+ if (!sameStringList(prepareOptions.unitIds, options.unitIds)) drifts.push('prepareOptions.unitIds');
497
+ if (!sameVersions(prepareOptions.unitVersions, options.unitVersions)) drifts.push('prepareOptions.unitVersions');
498
+ if (drifts.length > 0) {
499
+ throw new ReleaseError(GATE_FAILED, `test candidate requires a new prepare: ${drifts.join(', ')}`, {
500
+ reason: 'TEST_CANDIDATE_REPREPARE',
501
+ drifts,
502
+ });
503
+ }
504
+
505
+ const subjectDigest = computePlanDigest(loaded.candidate.prepared);
506
+ if (expectedSubjectDigest && expectedSubjectDigest !== subjectDigest) {
507
+ throw new ReleaseError(GATE_FAILED, 'test candidate subjectDigest does not match the ship record', {
508
+ reason: 'TEST_CANDIDATE_DIGEST_MISMATCH',
509
+ field: 'subjectDigest',
510
+ });
511
+ }
512
+
513
+ const formalPlans = await readFormalPlans(root);
514
+ const targetVersions = targetVersionsOf(loaded.candidate.prepared);
515
+ const differentBasis = formalPlans.filter((plan) => (
516
+ sameTargetVersions(plan.targetVersions, targetVersions) && plan.subjectDigest !== subjectDigest
517
+ ));
518
+ if (differentBasis.length > 0) {
519
+ throw new ReleaseError(GATE_FAILED, 'a different formal acceptance basis already exists for this target', {
520
+ reason: 'TEST_CANDIDATE_REPREPARE',
521
+ drifts: ['acceptance-basis'],
522
+ planPaths: differentBasis.map((plan) => plan.planPath),
523
+ });
524
+ }
525
+ const hits = formalPlans.filter((plan) => plan.subjectDigest === subjectDigest);
526
+ if (hits.length > 1) {
527
+ throw new ReleaseError(GATE_FAILED, 'multiple formal plans match this test candidate subject', {
528
+ reason: 'TEST_CANDIDATE_REPREPARE',
529
+ drifts: ['multiple-accepted-plans'],
530
+ });
531
+ }
532
+ if (hits.length === 1) {
533
+ return {
534
+ kind: 'already-accepted',
535
+ status: await authoritativeRunStatus(root, hits[0], targetVersions),
536
+ writePlan: false,
537
+ rebuild: false,
538
+ planPath: hits[0].planPath,
539
+ planDigest: hits[0].planDigest,
540
+ candidatePath: loaded.candidatePath,
541
+ candidateSha256: loaded.sha256,
542
+ subjectDigest,
543
+ };
544
+ }
545
+
546
+ const acceptance = await acceptTestResults({
547
+ root,
548
+ requirements,
549
+ prepared: loaded.candidate.prepared,
550
+ });
551
+ if (!acceptance.accepted) {
552
+ return {
553
+ kind: 'await-results',
554
+ status: 'NEEDS_INPUT',
555
+ writePlan: false,
556
+ rebuild: false,
557
+ code: 'GATE_FAILED',
558
+ candidatePath: loaded.candidatePath,
559
+ candidateSha256: loaded.sha256,
560
+ subjectDigest,
561
+ gaps: acceptance.gaps,
562
+ };
563
+ }
564
+ return {
565
+ kind: 'accept',
566
+ status: 'PREPARED',
567
+ writePlan: true,
568
+ rebuild: false,
569
+ testAcceptance: acceptance.testAcceptance,
570
+ prepared: loaded.candidate.prepared,
571
+ candidatePath: loaded.candidatePath,
572
+ candidateSha256: loaded.sha256,
573
+ subjectDigest,
574
+ };
575
+ }
576
+
577
+ /**
578
+ * Ship continuation for a persisted NEW record. Terminal external successes
579
+ * are never reattached.
580
+ */
581
+ export function resolveShipCandidateContinuation({ state, loadedConfigDigest, requestedUnitIds }) {
582
+ if (TERMINAL_SHIP_STATUSES.has(state?.status)) {
583
+ return { kind: 'leave-external-success', rebuild: false };
584
+ }
585
+ if (state?.status !== 'NEW' || !state.testCandidate) {
586
+ return { kind: 'legacy-new', rebuild: true, refreshConfigDigest: true };
587
+ }
588
+ if (state.configDigest !== loadedConfigDigest) {
589
+ return { kind: 'conflict', rebuild: false, reason: 'TEST_CANDIDATE_CONFIG_CONFLICT' };
590
+ }
591
+ if (requestedUnitIds !== undefined) {
592
+ if (!sameStringList(requestedUnitIds, state.selectedUnitIds ?? [])) {
593
+ return { kind: 'conflict', rebuild: false, reason: 'TEST_CANDIDATE_UNIT_CONFLICT' };
594
+ }
595
+ }
596
+ return {
597
+ kind: 'reattach',
598
+ rebuild: false,
599
+ refreshConfigDigest: false,
600
+ candidatePath: state.testCandidate.path,
601
+ candidateSha256: state.testCandidate.sha256,
602
+ subjectDigest: state.testCandidate.subjectDigest,
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Map a prepare return onto the persisted ship record.
608
+ * NEEDS_INPUT stays NEW and keeps the same candidate.
609
+ * PARTIAL, PUBLISHED, and VERIFIED keep that external status and the candidate.
610
+ */
611
+ export function applyShipPrepareOutcome(state, prepared) {
612
+ if (prepared?.status === 'NEEDS_INPUT') {
613
+ const next = {
614
+ ...state,
615
+ status: 'NEW',
616
+ testCandidate: prepared.testCandidate ?? state.testCandidate ?? {
617
+ path: prepared.candidatePath,
618
+ sha256: prepared.candidateSha256,
619
+ subjectDigest: prepared.subjectDigest,
620
+ evidenceDir: prepared.evidenceDir,
621
+ },
622
+ pendingTestRequirements: prepared.requirements ?? prepared.pendingTestRequirements ?? state.pendingTestRequirements,
623
+ };
624
+ delete next.planPath;
625
+ delete next.planDigest;
626
+ return next;
627
+ }
628
+ if (TERMINAL_SHIP_STATUSES.has(prepared?.status)) {
629
+ return {
630
+ ...state,
631
+ status: prepared.status,
632
+ planPath: prepared.planPath,
633
+ planDigest: prepared.planDigest,
634
+ evidenceDir: prepared.evidenceDir ?? state.evidenceDir,
635
+ warnings: prepared.warnings ?? state.warnings,
636
+ };
637
+ }
638
+ const next = {
639
+ ...state,
640
+ status: 'NEEDS_PLAN_APPROVAL',
641
+ planPath: prepared.planPath,
642
+ planDigest: prepared.planDigest,
643
+ evidenceDir: prepared.evidenceDir ?? state.evidenceDir,
644
+ warnings: prepared.warnings ?? state.warnings,
645
+ };
646
+ delete next.testCandidate;
647
+ delete next.pendingTestRequirements;
648
+ return next;
649
+ }