release-skill 0.6.0 → 0.6.2
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +12 -8
- package/README.zh-CN.md +12 -8
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +1028 -423
- package/adapters/claude/schemas/release-plan.schema.json +9 -0
- package/adapters/claude/schemas/release-project.schema.json +8 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +1028 -423
- package/adapters/codex/schemas/release-plan.schema.json +9 -0
- package/adapters/codex/schemas/release-project.schema.json +8 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +1028 -423
- package/adapters/kimi/schemas/release-plan.schema.json +9 -0
- package/adapters/kimi/schemas/release-project.schema.json +8 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +1028 -423
- package/adapters/workbuddy/schemas/release-plan.schema.json +9 -0
- package/adapters/workbuddy/schemas/release-project.schema.json +8 -0
- package/bin/release-skill-cli.mjs +11 -0
- package/bin/release-skill.bundle.mjs +1028 -423
- package/package.json +1 -1
- package/references/05-evidence-and-errors.md +1 -0
- package/schemas/release-plan.schema.json +9 -0
- package/schemas/release-project.schema.json +8 -0
- package/scripts/build-bundle.mjs +11 -2
- package/scripts/sync-public-files.mjs +4 -0
- package/src/commands/lineage.mjs +101 -32
- package/src/commands/prepare.mjs +273 -9
- package/src/commands/publish.mjs +10 -1
- package/src/commands/verify.mjs +22 -0
- package/src/core/bundle-freshness.mjs +236 -0
- package/src/core/errors.mjs +2 -0
- package/src/core/frozen-marker.mjs +97 -0
- package/src/core/hooks.mjs +12 -1
- package/src/core/skill-resource-closure.mjs +240 -10
- package/src/platforms/registry.mjs +12 -0
package/src/commands/prepare.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* @module commands/prepare
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
|
|
21
|
+
import { resolve, relative, isAbsolute, normalize, dirname, basename } from 'node:path';
|
|
22
22
|
import { readFile, mkdir, readdir, realpath } from 'node:fs/promises';
|
|
23
23
|
import { execFile as execFileCb } from 'node:child_process';
|
|
24
24
|
import { promisify } from 'node:util';
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
|
|
42
42
|
checkSkillResourceClosure,
|
|
43
43
|
createSkillResourceClosureReceipt,
|
|
44
|
+
evaluateDeclaredHostSurfaceCoverage,
|
|
44
45
|
} from '../core/skill-resource-closure.mjs';
|
|
45
46
|
import { buildPublicStaging } from '../snapshot/public-map.mjs';
|
|
46
47
|
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
@@ -53,7 +54,10 @@ import {
|
|
|
53
54
|
normalizeGitTimestamp,
|
|
54
55
|
sealFrozenSnapshot,
|
|
55
56
|
} from '../snapshot/frozen.mjs';
|
|
56
|
-
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT } from '../core/errors.mjs';
|
|
57
|
+
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
|
|
58
|
+
import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
|
|
59
|
+
import { PKG_ROOT } from '../core/pkg-root.mjs';
|
|
60
|
+
import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
|
|
57
61
|
import {
|
|
58
62
|
SOURCE_INPUT_ALGORITHM_VERSION,
|
|
59
63
|
computeSourceInputClosure,
|
|
@@ -224,6 +228,40 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
224
228
|
// Hooks execution
|
|
225
229
|
// ---------------------------------------------------------------------------
|
|
226
230
|
|
|
231
|
+
/** Maximum number of output lines preserved in a hook-failure tail. */
|
|
232
|
+
const HOOK_OUTPUT_TAIL_MAX_LINES = 50;
|
|
233
|
+
/** Maximum bytes preserved in a hook-failure tail. */
|
|
234
|
+
const HOOK_OUTPUT_TAIL_MAX_BYTES = 8 * 1024;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Bound a captured child-output stream to the tail that matters for triage:
|
|
238
|
+
* the last 50 lines, further capped at 8 KB — whichever is smaller.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} [text] - Captured stdout/stderr text.
|
|
241
|
+
* @returns {string} The bounded tail ('' for empty/absent input).
|
|
242
|
+
*/
|
|
243
|
+
export function boundedOutputTail(text) {
|
|
244
|
+
if (typeof text !== 'string' || text.length === 0) return '';
|
|
245
|
+
let lines = text.split('\n');
|
|
246
|
+
// A trailing newline produces an empty final element; drop it so the line
|
|
247
|
+
// budget counts real output lines.
|
|
248
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
249
|
+
lines = lines.slice(0, -1);
|
|
250
|
+
}
|
|
251
|
+
let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
|
|
252
|
+
let joined = tail.join('\n');
|
|
253
|
+
while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
254
|
+
tail = tail.slice(1);
|
|
255
|
+
joined = tail.join('\n');
|
|
256
|
+
}
|
|
257
|
+
if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
258
|
+
// A single line exceeds the byte cap: keep the trailing bytes.
|
|
259
|
+
const buf = Buffer.from(joined, 'utf8');
|
|
260
|
+
joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
|
|
261
|
+
}
|
|
262
|
+
return joined;
|
|
263
|
+
}
|
|
264
|
+
|
|
227
265
|
/**
|
|
228
266
|
* Run all declared project hooks in order: docs, build, test, typecheck.
|
|
229
267
|
*
|
|
@@ -236,6 +274,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
236
274
|
* Failures (non-zero exit or HOOK_TIMEOUT) are never cached. A `cacheInputs`
|
|
237
275
|
* glob that matches nothing fails closed before the hook runs.
|
|
238
276
|
*
|
|
277
|
+
* Failure output passthrough (2026-08-18 investigation §4.1): the executor
|
|
278
|
+
* already captures child stdout/stderr on non-zero exit; on failure this
|
|
279
|
+
* layer writes bounded tails into the hooks evidence event AND echoes them to
|
|
280
|
+
* the current process' stderr, so a failing hook is diagnosable on the
|
|
281
|
+
* terminal without opening evidence.jsonl. Success events carry no tails.
|
|
282
|
+
* Exit-code semantics are unchanged.
|
|
283
|
+
*
|
|
239
284
|
* @param {object} config - The loaded project config.
|
|
240
285
|
* @param {string} root - Absolute project root.
|
|
241
286
|
* @param {object} evidence - The evidence writer.
|
|
@@ -251,7 +296,9 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
251
296
|
* injectable. Defaults to process.env at the prepare call site, which makes
|
|
252
297
|
* allowlisted keys exported by the invoking shell reach the hook
|
|
253
298
|
* subprocess.
|
|
254
|
-
* @returns {Promise<
|
|
299
|
+
* @returns {Promise<Array<{ name: string, completed: boolean, cached: boolean, testSelection: string | undefined }>>}
|
|
300
|
+
* One record per declared hook that completed (fresh or cached replay).
|
|
301
|
+
* Failures throw instead of returning a record.
|
|
255
302
|
* @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
|
|
256
303
|
* throws, or declares a cacheInputs glob that matches no file.
|
|
257
304
|
*/
|
|
@@ -259,15 +306,24 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
259
306
|
const hookOrder = ['docs', 'build', 'test', 'typecheck'];
|
|
260
307
|
const hooks = config.hooks ?? {};
|
|
261
308
|
const cacheEnabled = options.hookCache !== false;
|
|
309
|
+
const records = [];
|
|
262
310
|
|
|
263
311
|
for (const name of hookOrder) {
|
|
264
312
|
const hook = hooks[name];
|
|
265
313
|
if (!hook) continue;
|
|
266
314
|
|
|
315
|
+
// Test-selection evidence (2026-08-18 investigation §4.4): the test hook
|
|
316
|
+
// records whether it ran the full suite. Absence of a declaration means
|
|
317
|
+
// 'full' — every existing config stays backward compatible.
|
|
318
|
+
const selectionField = name === 'test'
|
|
319
|
+
? { testSelection: hook.testSelection === 'incremental' ? 'incremental' : 'full' }
|
|
320
|
+
: {};
|
|
321
|
+
|
|
267
322
|
await evidence.append({
|
|
268
323
|
phase: 'hooks',
|
|
269
324
|
status: 'started',
|
|
270
325
|
hookName: name,
|
|
326
|
+
...selectionField,
|
|
271
327
|
});
|
|
272
328
|
|
|
273
329
|
// --- Incremental cache lookup (opt-in only; default zero change) ---
|
|
@@ -295,7 +351,9 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
295
351
|
hookName: name,
|
|
296
352
|
cached: true,
|
|
297
353
|
cacheKey,
|
|
354
|
+
...selectionField,
|
|
298
355
|
});
|
|
356
|
+
records.push({ name, completed: true, cached: true, testSelection: selectionField.testSelection });
|
|
299
357
|
continue;
|
|
300
358
|
}
|
|
301
359
|
}
|
|
@@ -312,6 +370,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
312
370
|
status: 'failed',
|
|
313
371
|
hookName: name,
|
|
314
372
|
error: { code: err.code, message: err.message },
|
|
373
|
+
...selectionField,
|
|
315
374
|
});
|
|
316
375
|
throw new ReleaseError(
|
|
317
376
|
GATE_FAILED,
|
|
@@ -321,16 +380,29 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
321
380
|
}
|
|
322
381
|
|
|
323
382
|
if (result.exitCode !== 0) {
|
|
383
|
+
const stdoutTail = boundedOutputTail(result.stdout);
|
|
384
|
+
const stderrTail = boundedOutputTail(result.stderr);
|
|
385
|
+
// Echo the captured tails to the current process' stderr so a failing
|
|
386
|
+
// hook is diagnosable on the terminal without opening evidence.jsonl
|
|
387
|
+
// (2026-08-18 investigation §4.1). Exit-code semantics are untouched.
|
|
388
|
+
process.stderr.write(`[release-skill] hook "${name}" failed with exit code ${result.exitCode}\n`);
|
|
389
|
+
if (stdoutTail) {
|
|
390
|
+
process.stderr.write(`[release-skill] hook "${name}" stdout tail:\n${stdoutTail}\n`);
|
|
391
|
+
}
|
|
392
|
+
if (stderrTail) {
|
|
393
|
+
process.stderr.write(`[release-skill] hook "${name}" stderr tail:\n${stderrTail}\n`);
|
|
394
|
+
}
|
|
324
395
|
await evidence.append({
|
|
325
396
|
phase: 'hooks',
|
|
326
397
|
status: 'failed',
|
|
327
398
|
hookName: name,
|
|
328
399
|
exitCode: result.exitCode,
|
|
329
400
|
// Test runners usually emit the actionable failure summary at the
|
|
330
|
-
// end. Preserve bounded tails
|
|
331
|
-
// compiler prelude at the beginning.
|
|
332
|
-
stdoutTail
|
|
333
|
-
stderrTail
|
|
401
|
+
// end. Preserve bounded tails (last 50 lines, capped at 8 KB) of both
|
|
402
|
+
// streams instead of the noisy compiler prelude at the beginning.
|
|
403
|
+
stdoutTail,
|
|
404
|
+
stderrTail,
|
|
405
|
+
...selectionField,
|
|
334
406
|
});
|
|
335
407
|
throw new ReleaseError(
|
|
336
408
|
GATE_FAILED,
|
|
@@ -364,8 +436,12 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
364
436
|
status: 'completed',
|
|
365
437
|
hookName: name,
|
|
366
438
|
exitCode: 0,
|
|
439
|
+
...selectionField,
|
|
367
440
|
});
|
|
441
|
+
records.push({ name, completed: true, cached: false, testSelection: selectionField.testSelection });
|
|
368
442
|
}
|
|
443
|
+
|
|
444
|
+
return records;
|
|
369
445
|
}
|
|
370
446
|
|
|
371
447
|
// ---------------------------------------------------------------------------
|
|
@@ -1744,6 +1820,7 @@ export async function prepareRelease(options) {
|
|
|
1744
1820
|
production = false,
|
|
1745
1821
|
workflow = 'full',
|
|
1746
1822
|
observePreviousPublicBaselineFn,
|
|
1823
|
+
testSelection = 'full',
|
|
1747
1824
|
} = options ?? {};
|
|
1748
1825
|
|
|
1749
1826
|
// --- Workflow profile (H5) ---
|
|
@@ -1761,6 +1838,24 @@ export async function prepareRelease(options) {
|
|
|
1761
1838
|
{ workflow },
|
|
1762
1839
|
);
|
|
1763
1840
|
}
|
|
1841
|
+
|
|
1842
|
+
// --- Test selection (2026-08-18 investigation §4.4, review §3.4) ---
|
|
1843
|
+
// Design decision: prepare IS the freeze, so incremental test selection is
|
|
1844
|
+
// rejected outright; the flag is reserved for a future preflight mode.
|
|
1845
|
+
if (testSelection === 'incremental') {
|
|
1846
|
+
throw new ReleaseError(
|
|
1847
|
+
GATE_FAILED,
|
|
1848
|
+
'incremental selection is not allowed at freeze time',
|
|
1849
|
+
{ testSelection, reservedFor: 'preflight mode' },
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
if (testSelection !== 'full') {
|
|
1853
|
+
throw new ReleaseError(
|
|
1854
|
+
CONFIG_INVALID,
|
|
1855
|
+
`unknown testSelection "${testSelection}"; expected "full" or "incremental"`,
|
|
1856
|
+
{ testSelection },
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1764
1859
|
const trimmedWorkflow = workflow !== 'full';
|
|
1765
1860
|
const skipDeclaredHooks = trimmedWorkflow;
|
|
1766
1861
|
const skipSnapshotVerifyGates = trimmedWorkflow;
|
|
@@ -1845,6 +1940,44 @@ export async function prepareRelease(options) {
|
|
|
1845
1940
|
});
|
|
1846
1941
|
}
|
|
1847
1942
|
|
|
1943
|
+
// --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
|
|
1944
|
+
// 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
|
|
1945
|
+
// used to surface only deep inside test hooks. Compare the deterministic
|
|
1946
|
+
// source digest embedded in the bundle at build time with the current
|
|
1947
|
+
// sources at the earliest stage — config loaded, before any hook.
|
|
1948
|
+
// This gate is artifact-integrity class, NOT a code-class gate:
|
|
1949
|
+
// docs/config/marketplace workflow trimming must never exempt it.
|
|
1950
|
+
await evidence.append({ phase: 'bundle-freshness', status: 'started' });
|
|
1951
|
+
const bundleFreshnessFn = options.bundleFreshnessFn ?? assertBundleFreshness;
|
|
1952
|
+
let bundleFreshness;
|
|
1953
|
+
try {
|
|
1954
|
+
bundleFreshness = await bundleFreshnessFn(PKG_ROOT);
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
await evidence.append({
|
|
1957
|
+
phase: 'bundle-freshness',
|
|
1958
|
+
status: 'blocking',
|
|
1959
|
+
reason: err.details?.reason ?? null,
|
|
1960
|
+
error: { code: err.code, message: err.message },
|
|
1961
|
+
});
|
|
1962
|
+
throw err;
|
|
1963
|
+
}
|
|
1964
|
+
if (bundleFreshness?.applicable === false) {
|
|
1965
|
+
// Installed distributions ship no mutable src/ next to the bundle;
|
|
1966
|
+
// staleness is a source-checkout concern only.
|
|
1967
|
+
await evidence.append({
|
|
1968
|
+
phase: 'bundle-freshness',
|
|
1969
|
+
status: 'not-applicable',
|
|
1970
|
+
reason: bundleFreshness.reason,
|
|
1971
|
+
});
|
|
1972
|
+
} else {
|
|
1973
|
+
await evidence.append({
|
|
1974
|
+
phase: 'bundle-freshness',
|
|
1975
|
+
status: 'completed',
|
|
1976
|
+
algorithm: bundleFreshness?.algorithm ?? null,
|
|
1977
|
+
sourceDigest: bundleFreshness?.sourceDigest ?? null,
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1848
1981
|
// --- Step 1a: Workflow configuration evidence ---
|
|
1849
1982
|
// Records the deterministic trim set for docs/config/marketplace
|
|
1850
1983
|
// workflows. The trim never weakens the retained gates; it only removes
|
|
@@ -1974,6 +2107,7 @@ export async function prepareRelease(options) {
|
|
|
1974
2107
|
}
|
|
1975
2108
|
|
|
1976
2109
|
// --- Step 3: Run declared hooks ---
|
|
2110
|
+
let hookRecords = [];
|
|
1977
2111
|
if (skipDeclaredHooks) {
|
|
1978
2112
|
await evidence.append({
|
|
1979
2113
|
phase: 'hooks',
|
|
@@ -1982,7 +2116,7 @@ export async function prepareRelease(options) {
|
|
|
1982
2116
|
});
|
|
1983
2117
|
} else {
|
|
1984
2118
|
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1985
|
-
await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
|
|
2119
|
+
hookRecords = await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
|
|
1986
2120
|
hookCache: options.hookCache,
|
|
1987
2121
|
// Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
|
|
1988
2122
|
// reads envAllowlist keys exclusively from context.env, so the invoking
|
|
@@ -2608,6 +2742,59 @@ export async function prepareRelease(options) {
|
|
|
2608
2742
|
// --- Step 7: Build plan object ---
|
|
2609
2743
|
await evidence.append({ phase: 'plan-assembly', status: 'started' });
|
|
2610
2744
|
|
|
2745
|
+
// --- Step 7-gate: Full-test freeze gate (hard gate) ---
|
|
2746
|
+
// 2026-08-18 investigation §4.4 / review §3.4: "full test suite before
|
|
2747
|
+
// freeze" is a HARD gate, not a convention. The plan digest may only be
|
|
2748
|
+
// computed after a completed FULL-mode test hook exists in THIS run's
|
|
2749
|
+
// evidence (fresh run or a cached replay of a successful full run).
|
|
2750
|
+
// Built-in and read-only — like secret-scan, plan-digest binding, and
|
|
2751
|
+
// approval, it cannot be disabled by project overlays. Workflows that
|
|
2752
|
+
// trim declared hooks record the trim; projects declaring no test hook
|
|
2753
|
+
// pass vacuously (nothing can run incrementally there).
|
|
2754
|
+
if (skipDeclaredHooks) {
|
|
2755
|
+
await evidence.append({
|
|
2756
|
+
phase: 'full-test-gate',
|
|
2757
|
+
status: 'skipped',
|
|
2758
|
+
reason: `workflow "${workflow}" trims declared hooks`,
|
|
2759
|
+
});
|
|
2760
|
+
} else if (!config.hooks?.test) {
|
|
2761
|
+
await evidence.append({
|
|
2762
|
+
phase: 'full-test-gate',
|
|
2763
|
+
status: 'not-declared',
|
|
2764
|
+
reason: 'no test hook declared; nothing can run incrementally',
|
|
2765
|
+
});
|
|
2766
|
+
} else {
|
|
2767
|
+
const testRecord = hookRecords.find((record) => record.name === 'test');
|
|
2768
|
+
const satisfied = Boolean(
|
|
2769
|
+
testRecord && testRecord.completed && testRecord.testSelection === 'full',
|
|
2770
|
+
);
|
|
2771
|
+
if (!satisfied) {
|
|
2772
|
+
await evidence.append({
|
|
2773
|
+
phase: 'full-test-gate',
|
|
2774
|
+
status: 'blocking',
|
|
2775
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
2776
|
+
cached: Boolean(testRecord?.cached),
|
|
2777
|
+
});
|
|
2778
|
+
throw new ReleaseError(
|
|
2779
|
+
GATE_FAILED,
|
|
2780
|
+
testRecord?.testSelection === 'incremental'
|
|
2781
|
+
? 'full-test freeze gate failed: incremental test selection cannot satisfy the freeze-time requirement of a completed full test run in this prepare run'
|
|
2782
|
+
: 'full-test freeze gate failed: prepare requires a completed full-mode test hook in this run before the plan digest is computed',
|
|
2783
|
+
{
|
|
2784
|
+
gate: 'full-test-freeze',
|
|
2785
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
2786
|
+
cached: Boolean(testRecord?.cached),
|
|
2787
|
+
},
|
|
2788
|
+
);
|
|
2789
|
+
}
|
|
2790
|
+
await evidence.append({
|
|
2791
|
+
phase: 'full-test-gate',
|
|
2792
|
+
status: 'completed',
|
|
2793
|
+
testSelection: 'full',
|
|
2794
|
+
cached: Boolean(testRecord.cached),
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2611
2798
|
// New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
|
|
2612
2799
|
// §4.2/§7). Production freeze timestamps are derived deterministically
|
|
2613
2800
|
// from the baseline headCommit's committer date, before the first frozen
|
|
@@ -2672,7 +2859,16 @@ export async function prepareRelease(options) {
|
|
|
2672
2859
|
host: 'root',
|
|
2673
2860
|
});
|
|
2674
2861
|
|
|
2675
|
-
|
|
2862
|
+
// G5: bind execution time + exit code into the frozen receipt.
|
|
2863
|
+
// preparedAt reuses this prepare's deterministic freeze timestamp
|
|
2864
|
+
// (production: the baseline HEAD commit committer date; otherwise
|
|
2865
|
+
// null) — never a wall-clock sample — so identical sources freeze
|
|
2866
|
+
// byte-identical receipts on every re-prepare.
|
|
2867
|
+
const receipt = createSkillResourceClosureReceipt(closureResult, {
|
|
2868
|
+
unitId: unit.id,
|
|
2869
|
+
preparedAt: freezeTimestamp ?? null,
|
|
2870
|
+
exitCode: 0,
|
|
2871
|
+
});
|
|
2676
2872
|
skillResourceClosureResults.push(receipt);
|
|
2677
2873
|
|
|
2678
2874
|
if (closureResult.findings.length > 0) {
|
|
@@ -2687,6 +2883,9 @@ export async function prepareRelease(options) {
|
|
|
2687
2883
|
reference: f.reference,
|
|
2688
2884
|
classification: f.classification,
|
|
2689
2885
|
code: f.code,
|
|
2886
|
+
// D4: RESOURCE_DRIFT findings localize via references (the
|
|
2887
|
+
// finding's own skill/line stay null for a cross-surface drift).
|
|
2888
|
+
...(f.references ? { references: f.references } : {}),
|
|
2690
2889
|
})),
|
|
2691
2890
|
});
|
|
2692
2891
|
throw new ReleaseError(
|
|
@@ -2700,6 +2899,47 @@ export async function prepareRelease(options) {
|
|
|
2700
2899
|
);
|
|
2701
2900
|
}
|
|
2702
2901
|
|
|
2902
|
+
// G4: every declared plugin distribution must be backed by a host
|
|
2903
|
+
// surface in the frozen snapshot with at least one skill. If
|
|
2904
|
+
// publicFiles drops an adapter tree, that host surface is silently
|
|
2905
|
+
// absent from the receipt — fail closed here instead of shipping a
|
|
2906
|
+
// unit whose declared host never entered the closure gate.
|
|
2907
|
+
// Expected host names are the adapter directory names declared by the
|
|
2908
|
+
// platform registry (buildAdapter.name, asserted non-empty by
|
|
2909
|
+
// assertRegistry); codebuddy-plugin keeps the historical `workbuddy`
|
|
2910
|
+
// adapter directory name there. npm-only units declare no plugin
|
|
2911
|
+
// hosts and skip.
|
|
2912
|
+
const expectedHosts = (unit.distributions ?? [])
|
|
2913
|
+
.map((distribution) => PLATFORMS.find((platform) => platform.distributionType === distribution.type))
|
|
2914
|
+
.filter(Boolean)
|
|
2915
|
+
.map((platform) => platform.buildAdapter.name);
|
|
2916
|
+
const hostCoverage = evaluateDeclaredHostSurfaceCoverage(
|
|
2917
|
+
expectedHosts,
|
|
2918
|
+
closureResult.surfaces,
|
|
2919
|
+
);
|
|
2920
|
+
if (!hostCoverage.passed) {
|
|
2921
|
+
await evidence.append({
|
|
2922
|
+
phase: 'skill-resource-closure',
|
|
2923
|
+
status: 'blocking',
|
|
2924
|
+
unitId: unit.id,
|
|
2925
|
+
reason: 'declared-host-surface-missing',
|
|
2926
|
+
missingHosts: hostCoverage.missing,
|
|
2927
|
+
});
|
|
2928
|
+
throw new ReleaseError(
|
|
2929
|
+
GATE_FAILED,
|
|
2930
|
+
`skill resource closure gate failed for unit "${unit.id}": declared host surface(s) missing or empty: ${hostCoverage.missing.map((item) => item.host).join(', ')}`,
|
|
2931
|
+
{
|
|
2932
|
+
unitId: unit.id,
|
|
2933
|
+
missingHosts: hostCoverage.missing,
|
|
2934
|
+
observedSurfaces: closureResult.surfaces.map((surface) => ({
|
|
2935
|
+
id: surface.id,
|
|
2936
|
+
host: surface.host,
|
|
2937
|
+
skillCount: surface.skillCount,
|
|
2938
|
+
})),
|
|
2939
|
+
},
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2703
2943
|
await evidence.append({
|
|
2704
2944
|
phase: 'skill-resource-closure',
|
|
2705
2945
|
status: 'completed',
|
|
@@ -2709,6 +2949,10 @@ export async function prepareRelease(options) {
|
|
|
2709
2949
|
skillCount: receipt.skillCount,
|
|
2710
2950
|
referenceCount: closureResult.referenceCount,
|
|
2711
2951
|
sourceOnlyCount: closureResult.sourceOnlyCount,
|
|
2952
|
+
// D2: per-reference exemption detail for approval/audit review —
|
|
2953
|
+
// evidence-layer only; the receipt object (and its digest binding)
|
|
2954
|
+
// is intentionally left unchanged.
|
|
2955
|
+
sourceOnlyReferences: closureResult.sourceOnlyReferences,
|
|
2712
2956
|
findingCount: 0,
|
|
2713
2957
|
receiptDigest: closureResult.receiptDigest,
|
|
2714
2958
|
});
|
|
@@ -3215,6 +3459,26 @@ export async function prepareRelease(options) {
|
|
|
3215
3459
|
planDigest,
|
|
3216
3460
|
});
|
|
3217
3461
|
|
|
3462
|
+
// --- Write the FROZEN governance marker (§4.5 option 1) ---
|
|
3463
|
+
// Mechanical maintenance only: the marker is a read-only gentlemen's-
|
|
3464
|
+
// agreement signal for cross-repo writers (e.g. skill-family治理 tasks)
|
|
3465
|
+
// that this workspace is mid-release. It is written ONLY after the plan
|
|
3466
|
+
// is frozen, overwritten by every successful prepare, and cleared by
|
|
3467
|
+
// verify only upon VERIFIED. A failed prepare never reaches this point.
|
|
3468
|
+
await writeFrozenMarker(releaseDir, {
|
|
3469
|
+
planDigest,
|
|
3470
|
+
targetVersions: Object.fromEntries(
|
|
3471
|
+
configUnits.map((unit, index) => [unit.id, resolvedVersions[index]]),
|
|
3472
|
+
),
|
|
3473
|
+
createdAt: plan.createdAt,
|
|
3474
|
+
runId: basename(runDir),
|
|
3475
|
+
});
|
|
3476
|
+
await evidence.append({
|
|
3477
|
+
phase: 'frozen-marker',
|
|
3478
|
+
status: 'written',
|
|
3479
|
+
markerPath: `.release-skill/${FROZEN_MARKER_FILENAME}`,
|
|
3480
|
+
});
|
|
3481
|
+
|
|
3218
3482
|
// --- Write summary ---
|
|
3219
3483
|
await evidence.finish({
|
|
3220
3484
|
status: 'PREPARED',
|
package/src/commands/publish.mjs
CHANGED
|
@@ -652,7 +652,16 @@ export async function publishRelease(options) {
|
|
|
652
652
|
{ unitId, findings: closureResult.findings },
|
|
653
653
|
);
|
|
654
654
|
}
|
|
655
|
-
|
|
655
|
+
// G5: preparedAt/exitCode are record-layer fields frozen by prepare
|
|
656
|
+
// (bound by the plan digest); they cannot be recomputed from the
|
|
657
|
+
// snapshot, so the recheck carries them forward from the expected
|
|
658
|
+
// receipt and the strict comparison below verifies every re-derivable
|
|
659
|
+
// field against the frozen snapshot.
|
|
660
|
+
const observed = createSkillResourceClosureReceipt(closureResult, {
|
|
661
|
+
unitId,
|
|
662
|
+
preparedAt: expected.preparedAt ?? null,
|
|
663
|
+
exitCode: expected.exitCode ?? 0,
|
|
664
|
+
});
|
|
656
665
|
assertSkillResourceClosureReceipt(expected, observed, `unit "${unitId}"`);
|
|
657
666
|
}
|
|
658
667
|
|
package/src/commands/verify.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
} from '../core/baseline-advance.mjs';
|
|
55
55
|
import { loadProjectConfig } from '../core/config.mjs';
|
|
56
56
|
import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
|
|
57
|
+
import { clearFrozenMarker } from '../core/frozen-marker.mjs';
|
|
57
58
|
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
58
59
|
import {
|
|
59
60
|
normalizeRegistry,
|
|
@@ -1802,6 +1803,27 @@ export async function verifyRelease(options) {
|
|
|
1802
1803
|
};
|
|
1803
1804
|
const persistedVerifyRun = await writeRunAtomic(verifyRunPath, verifyRunState);
|
|
1804
1805
|
|
|
1806
|
+
// =======================================================================
|
|
1807
|
+
// FROZEN marker retirement (2026-08-18 investigation §4.5 option 1):
|
|
1808
|
+
// the release reached VERIFIED, so the freeze signal is cleared. Only
|
|
1809
|
+
// this success path clears it — failed/PARTIAL verify runs never reach
|
|
1810
|
+
// here and keep the marker. Best-effort: a removal failure must never
|
|
1811
|
+
// demote VERIFIED.
|
|
1812
|
+
// =======================================================================
|
|
1813
|
+
try {
|
|
1814
|
+
const removed = await clearFrozenMarker(join(root, '.release-skill'));
|
|
1815
|
+
await evidence.append({
|
|
1816
|
+
phase: 'frozen-marker',
|
|
1817
|
+
status: removed ? 'cleared' : 'absent',
|
|
1818
|
+
});
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
await evidence.append({
|
|
1821
|
+
phase: 'frozen-marker',
|
|
1822
|
+
status: 'clear-failed',
|
|
1823
|
+
error: { code: err.code, message: err.message },
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1805
1827
|
// =======================================================================
|
|
1806
1828
|
// Baseline advance: move per-unit previousPublicBaseline to the commit
|
|
1807
1829
|
// that was just verified. Local bookkeeping only — no remote writes.
|