release-skill 0.6.1 → 0.6.3
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 +40 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +129 -9
- package/README.zh-CN.md +111 -9
- 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 +6596 -1586
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +74 -1
- package/adapters/claude/schemas/release-project.schema.json +73 -1
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +6596 -1586
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +74 -1
- package/adapters/codex/schemas/release-project.schema.json +73 -1
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +6596 -1586
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +74 -1
- package/adapters/kimi/schemas/release-project.schema.json +73 -1
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +6596 -1586
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +74 -1
- package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/release-skill-cli.mjs +181 -3
- package/bin/release-skill.bundle.mjs +6596 -1586
- package/package.json +2 -1
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +74 -1
- package/schemas/release-project.schema.json +73 -1
- package/schemas/release-run.schema.json +11 -6
- package/src/commands/approve.mjs +167 -1
- package/src/commands/distribute.mjs +411 -33
- package/src/commands/lineage.mjs +101 -32
- package/src/commands/postverify.mjs +734 -0
- package/src/commands/prepare.mjs +339 -43
- package/src/commands/publish.mjs +10 -1
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +167 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +174 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +110 -0
- package/src/core/postpublish.mjs +424 -7
- package/src/core/preset-executor.mjs +156 -0
- package/src/core/preset-gitwrite.mjs +463 -0
- package/src/core/presets.mjs +706 -0
- package/src/core/proposal-inbox.mjs +630 -0
- package/src/core/run.mjs +91 -6
- package/src/core/skill-resource-closure.mjs +240 -10
- package/src/platforms/registry.mjs +12 -0
package/src/commands/prepare.mjs
CHANGED
|
@@ -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';
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
} from '../snapshot/frozen.mjs';
|
|
56
57
|
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
|
|
57
58
|
import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
|
|
59
|
+
import { assertAdapterFreshness, assertSelfBootstrapFacts } from '../core/derived-artifact-gates.mjs';
|
|
58
60
|
import { PKG_ROOT } from '../core/pkg-root.mjs';
|
|
59
61
|
import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
|
|
60
62
|
import {
|
|
@@ -70,7 +72,12 @@ import { createProductionPrepareRunDir } from '../core/run.mjs';
|
|
|
70
72
|
import { PLATFORMS } from '../platforms/registry.mjs';
|
|
71
73
|
import { validateMarketplaceSourceSelection, MARKETPLACE_SOURCE_TYPES, resolvePluginManifestFromMarketplaceEntrySource, resolveMarketplaceRoot } from '../adapters/plugin-marketplace.mjs';
|
|
72
74
|
import { buildInstallationContract, computeInstallationContractDigest, INSTALLATION_CONTRACT_ALGORITHM_VERSION } from '../core/installation-contract.mjs';
|
|
73
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
validatePostPublishDeclaration,
|
|
77
|
+
normalizePostPublishDeclaration,
|
|
78
|
+
orderNormalizedHooks,
|
|
79
|
+
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
80
|
+
} from '../core/postpublish.mjs';
|
|
74
81
|
|
|
75
82
|
// ---------------------------------------------------------------------------
|
|
76
83
|
// 安装契约常量
|
|
@@ -227,39 +234,15 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
227
234
|
// Hooks execution
|
|
228
235
|
// ---------------------------------------------------------------------------
|
|
229
236
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
* @param {string} [text] - Captured stdout/stderr text.
|
|
240
|
-
* @returns {string} The bounded tail ('' for empty/absent input).
|
|
241
|
-
*/
|
|
242
|
-
export function boundedOutputTail(text) {
|
|
243
|
-
if (typeof text !== 'string' || text.length === 0) return '';
|
|
244
|
-
let lines = text.split('\n');
|
|
245
|
-
// A trailing newline produces an empty final element; drop it so the line
|
|
246
|
-
// budget counts real output lines.
|
|
247
|
-
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
248
|
-
lines = lines.slice(0, -1);
|
|
249
|
-
}
|
|
250
|
-
let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
|
|
251
|
-
let joined = tail.join('\n');
|
|
252
|
-
while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
253
|
-
tail = tail.slice(1);
|
|
254
|
-
joined = tail.join('\n');
|
|
255
|
-
}
|
|
256
|
-
if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
257
|
-
// A single line exceeds the byte cap: keep the trailing bytes.
|
|
258
|
-
const buf = Buffer.from(joined, 'utf8');
|
|
259
|
-
joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
|
|
260
|
-
}
|
|
261
|
-
return joined;
|
|
262
|
-
}
|
|
237
|
+
// v0.6.3 R1 tail unification: the "last 50 lines / 8 KiB" tail authority now
|
|
238
|
+
// lives in core/bounded-output.mjs, shared by prepare and distribute. The
|
|
239
|
+
// re-export preserves prepare's historical public surface.
|
|
240
|
+
export {
|
|
241
|
+
boundedOutputTail,
|
|
242
|
+
HOOK_OUTPUT_TAIL_MAX_LINES,
|
|
243
|
+
HOOK_OUTPUT_TAIL_MAX_BYTES,
|
|
244
|
+
} from '../core/bounded-output.mjs';
|
|
245
|
+
import { boundedOutputTail } from '../core/bounded-output.mjs';
|
|
263
246
|
|
|
264
247
|
/**
|
|
265
248
|
* Run all declared project hooks in order: docs, build, test, typecheck.
|
|
@@ -907,6 +890,94 @@ function normalizedProductionConfig(unit) {
|
|
|
907
890
|
};
|
|
908
891
|
}
|
|
909
892
|
|
|
893
|
+
/**
|
|
894
|
+
* O5 (2026-08-18 release-cycle investigation §3.2): observe how the local
|
|
895
|
+
* workspace HEAD relates to `origin/<defaultBranch>` for an ONLINE production
|
|
896
|
+
* prepare. This is a WARNING-LEVEL, non-blocking pre-publish signal: in the
|
|
897
|
+
* 0.6.1 cycle a workspace 15 commits ahead of origin only surfaced when the
|
|
898
|
+
* publish source-authority gate rejected it. Pushing is a legitimate
|
|
899
|
+
* pre-publish action, so the observation must inform, never block the freeze.
|
|
900
|
+
*
|
|
901
|
+
* Read-only git plumbing only (argv arrays, no shell): rev-parse, ls-remote,
|
|
902
|
+
* cat-file, rev-list. Every failure degrades to a descriptive status object —
|
|
903
|
+
* this function NEVER throws, so it can never break a freeze.
|
|
904
|
+
*
|
|
905
|
+
* @param {object} options
|
|
906
|
+
* @param {string} options.root - Workspace (git repository) root.
|
|
907
|
+
* @param {string} options.defaultBranch - Default branch name on origin.
|
|
908
|
+
* @returns {Promise<{
|
|
909
|
+
* status: 'in-sync' | 'ahead' | 'behind' | 'diverged' | 'no-origin' | 'remote-ref-missing' | 'unknown',
|
|
910
|
+
* localHead?: string,
|
|
911
|
+
* remoteHead?: string,
|
|
912
|
+
* aheadCount?: number,
|
|
913
|
+
* behindCount?: number,
|
|
914
|
+
* error?: string,
|
|
915
|
+
* }>}
|
|
916
|
+
*/
|
|
917
|
+
export async function observeOriginAhead({ root, defaultBranch }) {
|
|
918
|
+
const runGit = (gitArgs) => execFile('git', ['-C', root, ...gitArgs], {
|
|
919
|
+
shell: false,
|
|
920
|
+
encoding: 'utf8',
|
|
921
|
+
timeout: 30000,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
let localHead;
|
|
925
|
+
try {
|
|
926
|
+
({ stdout: localHead } = await runGit(['rev-parse', 'HEAD']));
|
|
927
|
+
localHead = localHead.trim();
|
|
928
|
+
} catch (err) {
|
|
929
|
+
return { status: 'unknown', error: `rev-parse HEAD failed: ${err.message}` };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Distinguish "no origin remote" from a transient ls-remote failure.
|
|
933
|
+
try {
|
|
934
|
+
await runGit(['remote', 'get-url', 'origin']);
|
|
935
|
+
} catch {
|
|
936
|
+
return { status: 'no-origin', localHead };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let remoteHead;
|
|
940
|
+
try {
|
|
941
|
+
const { stdout } = await runGit(['ls-remote', 'origin', `refs/heads/${defaultBranch}`]);
|
|
942
|
+
const firstLine = stdout.trim().split('\n').filter((line) => line.length > 0)[0];
|
|
943
|
+
if (!firstLine) {
|
|
944
|
+
return { status: 'remote-ref-missing', localHead, defaultBranch };
|
|
945
|
+
}
|
|
946
|
+
remoteHead = firstLine.split('\t')[0];
|
|
947
|
+
} catch (err) {
|
|
948
|
+
return { status: 'unknown', localHead, error: `ls-remote origin failed: ${err.message}` };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (remoteHead === localHead) {
|
|
952
|
+
return { status: 'in-sync', localHead, remoteHead };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Ancestry is only computable when the remote head object exists locally
|
|
956
|
+
// (no implicit fetch — this observer is read-only on the network beyond the
|
|
957
|
+
// single ls-remote above). When it does not, report diverged with no counts.
|
|
958
|
+
try {
|
|
959
|
+
await runGit(['cat-file', '-e', `${remoteHead}^{commit}`]);
|
|
960
|
+
} catch {
|
|
961
|
+
return { status: 'diverged', localHead, remoteHead };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
const aheadRaw = await runGit(['rev-list', '--count', `${remoteHead}..HEAD`]);
|
|
966
|
+
const behindRaw = await runGit(['rev-list', '--count', `HEAD..${remoteHead}`]);
|
|
967
|
+
const aheadCount = Number.parseInt(aheadRaw.stdout.trim(), 10) || 0;
|
|
968
|
+
const behindCount = Number.parseInt(behindRaw.stdout.trim(), 10) || 0;
|
|
969
|
+
if (aheadCount > 0 && behindCount === 0) {
|
|
970
|
+
return { status: 'ahead', localHead, remoteHead, aheadCount };
|
|
971
|
+
}
|
|
972
|
+
if (behindCount > 0 && aheadCount === 0) {
|
|
973
|
+
return { status: 'behind', localHead, remoteHead, behindCount };
|
|
974
|
+
}
|
|
975
|
+
return { status: 'diverged', localHead, remoteHead, aheadCount, behindCount };
|
|
976
|
+
} catch (err) {
|
|
977
|
+
return { status: 'diverged', localHead, remoteHead, error: err.message };
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
910
981
|
/**
|
|
911
982
|
* Derive the deterministic freeze timestamp for planVersion 2 plans (design:
|
|
912
983
|
* t1-2-digest-decoupling.md §4.2): the baseline headCommit's committer date,
|
|
@@ -1210,7 +1281,8 @@ export async function resolveExternalMarketplaceFreezes({
|
|
|
1210
1281
|
if (offline) {
|
|
1211
1282
|
throw new ReleaseError(
|
|
1212
1283
|
GATE_FAILED,
|
|
1213
|
-
`unit "${unit.id}" ${dist.type} external marketplace form requires online production prepare to freeze the marketplace commit sha
|
|
1284
|
+
`unit "${unit.id}" ${dist.type} external marketplace form requires online production prepare to freeze the marketplace commit sha. ` +
|
|
1285
|
+
`Remediation: release-skill prepare --production --online`,
|
|
1214
1286
|
{ unitId: unit.id, marketplaceSourceType: dist.marketplaceSourceType },
|
|
1215
1287
|
);
|
|
1216
1288
|
}
|
|
@@ -1765,7 +1837,7 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
|
|
|
1765
1837
|
* every declared hook runs in full and the incremental hook cache is neither
|
|
1766
1838
|
* read nor written.
|
|
1767
1839
|
*
|
|
1768
|
-
* @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string, warnings: ReadonlyArray<object> }>}
|
|
1840
|
+
* @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string, warnings: ReadonlyArray<object>, nextSteps: ReadonlyArray<{ code: string, message: string }> }>}
|
|
1769
1841
|
*
|
|
1770
1842
|
* @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
|
|
1771
1843
|
*/
|
|
@@ -1924,6 +1996,9 @@ export async function prepareRelease(options) {
|
|
|
1924
1996
|
|
|
1925
1997
|
const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
|
|
1926
1998
|
const adoptionWarnings = collectExpectedPublicSurfaceAdoptionWarnings(config);
|
|
1999
|
+
// Mutable operator-facing warning list: seeded from the adoption warnings,
|
|
2000
|
+
// appended by later gates (O5 origin-ahead). Returned as `warnings`.
|
|
2001
|
+
const runWarnings = [...adoptionWarnings];
|
|
1927
2002
|
|
|
1928
2003
|
await evidence.append({
|
|
1929
2004
|
phase: 'config',
|
|
@@ -2025,6 +2100,70 @@ export async function prepareRelease(options) {
|
|
|
2025
2100
|
reasonTag: 'RELEASE_DOCS_STALE',
|
|
2026
2101
|
});
|
|
2027
2102
|
|
|
2103
|
+
// --- Step 1b-fast: version-sensitive derived-artifact fast pre-gates (O1) ---
|
|
2104
|
+
// 2026-08-18 investigation §3.2: adapter drift and stale self-bootstrap
|
|
2105
|
+
// fact pins used to surface only deep inside the ~80s full test hook.
|
|
2106
|
+
// Promote the two exact canonical checks (build-adapters --check and the
|
|
2107
|
+
// release-docs-self-bootstrap single-file test) to prepare's earliest
|
|
2108
|
+
// stage so the same drift fails closed in seconds, before any hook. Like
|
|
2109
|
+
// the bundle freshness gate this is artifact-integrity class: workflow
|
|
2110
|
+
// trimming never exempts it. Installed layouts record not-applicable.
|
|
2111
|
+
await evidence.append({ phase: 'adapter-freshness', status: 'started' });
|
|
2112
|
+
const adapterFreshnessFn = options.adapterFreshnessFn ?? assertAdapterFreshness;
|
|
2113
|
+
let adapterFreshness;
|
|
2114
|
+
try {
|
|
2115
|
+
adapterFreshness = await adapterFreshnessFn(PKG_ROOT);
|
|
2116
|
+
} catch (err) {
|
|
2117
|
+
await evidence.append({
|
|
2118
|
+
phase: 'adapter-freshness',
|
|
2119
|
+
status: 'blocking',
|
|
2120
|
+
reason: err.details?.reason ?? null,
|
|
2121
|
+
error: { code: err.code, message: err.message },
|
|
2122
|
+
});
|
|
2123
|
+
throw err;
|
|
2124
|
+
}
|
|
2125
|
+
if (adapterFreshness?.applicable === false) {
|
|
2126
|
+
await evidence.append({
|
|
2127
|
+
phase: 'adapter-freshness',
|
|
2128
|
+
status: 'not-applicable',
|
|
2129
|
+
reason: adapterFreshness.reason,
|
|
2130
|
+
});
|
|
2131
|
+
} else {
|
|
2132
|
+
await evidence.append({
|
|
2133
|
+
phase: 'adapter-freshness',
|
|
2134
|
+
status: 'completed',
|
|
2135
|
+
durationMs: adapterFreshness?.durationMs ?? null,
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
await evidence.append({ phase: 'self-bootstrap-facts', status: 'started' });
|
|
2140
|
+
const selfBootstrapFactsFn = options.selfBootstrapFactsFn ?? assertSelfBootstrapFacts;
|
|
2141
|
+
let selfBootstrapFacts;
|
|
2142
|
+
try {
|
|
2143
|
+
selfBootstrapFacts = await selfBootstrapFactsFn(PKG_ROOT);
|
|
2144
|
+
} catch (err) {
|
|
2145
|
+
await evidence.append({
|
|
2146
|
+
phase: 'self-bootstrap-facts',
|
|
2147
|
+
status: 'blocking',
|
|
2148
|
+
reason: err.details?.reason ?? null,
|
|
2149
|
+
error: { code: err.code, message: err.message },
|
|
2150
|
+
});
|
|
2151
|
+
throw err;
|
|
2152
|
+
}
|
|
2153
|
+
if (selfBootstrapFacts?.applicable === false) {
|
|
2154
|
+
await evidence.append({
|
|
2155
|
+
phase: 'self-bootstrap-facts',
|
|
2156
|
+
status: 'not-applicable',
|
|
2157
|
+
reason: selfBootstrapFacts.reason,
|
|
2158
|
+
});
|
|
2159
|
+
} else {
|
|
2160
|
+
await evidence.append({
|
|
2161
|
+
phase: 'self-bootstrap-facts',
|
|
2162
|
+
status: 'completed',
|
|
2163
|
+
durationMs: selfBootstrapFacts?.durationMs ?? null,
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2028
2167
|
// --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
|
|
2029
2168
|
// The per-unit postPublish block drives the post-publish distribute
|
|
2030
2169
|
// command. Validate it here, before any hook, baseline, snapshot, remote
|
|
@@ -2032,8 +2171,13 @@ export async function prepareRelease(options) {
|
|
|
2032
2171
|
// side effects. This is the runtime re-check on top of the config JSON
|
|
2033
2172
|
// schema: plans frozen by older schema versions must not be able to
|
|
2034
2173
|
// smuggle shell strings, option-like executables, or secret-ish env
|
|
2035
|
-
// keys through.
|
|
2036
|
-
//
|
|
2174
|
+
// keys through. R2: preset references resolve against the built-in
|
|
2175
|
+
// preset registry (per-preset config validation + requiresApproval
|
|
2176
|
+
// grading), and targets normalize onto preset hooks — the normalized
|
|
2177
|
+
// table is a deterministic projection of the digest-bound declaration,
|
|
2178
|
+
// so any list change changes the plan digest and voids approvals. A
|
|
2179
|
+
// plan binds exactly one declaration; multiple units declaring
|
|
2180
|
+
// postPublish is a hard gate failure.
|
|
2037
2181
|
const postPublishDeclarations = configUnits
|
|
2038
2182
|
.map((unit, index) => ({ unit, index }))
|
|
2039
2183
|
.filter(({ unit }) => unit.postPublish !== undefined);
|
|
@@ -2048,12 +2192,20 @@ export async function prepareRelease(options) {
|
|
|
2048
2192
|
if (postPublishDeclarations.length === 1) {
|
|
2049
2193
|
const { unit, index } = postPublishDeclarations[0];
|
|
2050
2194
|
validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
|
|
2195
|
+
// Normalized hook table (design §2.2): validate the dependency
|
|
2196
|
+
// topology at freeze time too, so a cyclic/dangling declaration can
|
|
2197
|
+
// never be frozen for distribute to trip over.
|
|
2198
|
+
const normalizedDeclaration = normalizePostPublishDeclaration(unit.postPublish);
|
|
2199
|
+
const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
|
|
2051
2200
|
postPublishDeclaration = { unit, index };
|
|
2052
2201
|
await evidence.append({
|
|
2053
2202
|
phase: 'postpublish-declaration',
|
|
2054
2203
|
status: 'validated',
|
|
2055
2204
|
unitId: unit.id,
|
|
2056
|
-
targetCount: unit.postPublish.targets.length,
|
|
2205
|
+
targetCount: (unit.postPublish.targets ?? []).length,
|
|
2206
|
+
hookCount: (unit.postPublish.hooks ?? []).length,
|
|
2207
|
+
normalizedHookCount: orderedNormalizedHooks.length,
|
|
2208
|
+
preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
|
|
2057
2209
|
});
|
|
2058
2210
|
}
|
|
2059
2211
|
|
|
@@ -2296,6 +2448,73 @@ export async function prepareRelease(options) {
|
|
|
2296
2448
|
inputDigest: sourceInputClosure.digest,
|
|
2297
2449
|
remoteObservation: offline ? 'unobserved-offline' : 'deferred-to-publish',
|
|
2298
2450
|
});
|
|
2451
|
+
|
|
2452
|
+
// --- Step 3c-ahead: local vs origin ahead observation (O5) ---
|
|
2453
|
+
// Online production prepare only. WARNING level, never blocking: pushing
|
|
2454
|
+
// is a legitimate pre-publish action, but the operator must know before
|
|
2455
|
+
// approval — the publish source-authority gate compares the frozen
|
|
2456
|
+
// source-input closure against the remote default branch and rejects
|
|
2457
|
+
// unpushed local commits (0.6.1 cycle: 15 commits ahead, discovered only
|
|
2458
|
+
// at publish). Offline prepare keeps the legacy zero-observation form.
|
|
2459
|
+
if (!offline) {
|
|
2460
|
+
if (!configDefaultBranch) {
|
|
2461
|
+
await evidence.append({
|
|
2462
|
+
phase: 'origin-ahead',
|
|
2463
|
+
status: 'skipped',
|
|
2464
|
+
reason: 'no project.defaultBranch configured',
|
|
2465
|
+
});
|
|
2466
|
+
} else {
|
|
2467
|
+
await evidence.append({ phase: 'origin-ahead', status: 'started' });
|
|
2468
|
+
const observeOriginAheadFn = options.observeOriginAheadFn ?? observeOriginAhead;
|
|
2469
|
+
let originObservation;
|
|
2470
|
+
try {
|
|
2471
|
+
originObservation = await observeOriginAheadFn({
|
|
2472
|
+
root: realRoot,
|
|
2473
|
+
defaultBranch: configDefaultBranch,
|
|
2474
|
+
});
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
originObservation = { status: 'unknown', error: err?.message ?? String(err) };
|
|
2477
|
+
}
|
|
2478
|
+
if (originObservation?.status === 'ahead') {
|
|
2479
|
+
await evidence.append({
|
|
2480
|
+
phase: 'origin-ahead',
|
|
2481
|
+
status: 'warning',
|
|
2482
|
+
defaultBranch: configDefaultBranch,
|
|
2483
|
+
localHead: originObservation.localHead ?? null,
|
|
2484
|
+
remoteHead: originObservation.remoteHead ?? null,
|
|
2485
|
+
aheadCount: originObservation.aheadCount ?? null,
|
|
2486
|
+
guidance: 'push the workspace before publish (git push); the publish source-authority gate compares against the remote default branch',
|
|
2487
|
+
});
|
|
2488
|
+
runWarnings.push({
|
|
2489
|
+
code: 'ORIGIN_AHEAD',
|
|
2490
|
+
defaultBranch: configDefaultBranch,
|
|
2491
|
+
aheadCount: originObservation.aheadCount ?? null,
|
|
2492
|
+
message:
|
|
2493
|
+
`local HEAD is ${originObservation.aheadCount ?? 'an unknown number of'} commit(s) ahead of origin/${configDefaultBranch}; ` +
|
|
2494
|
+
'the publish source-authority gate compares against the remote default branch — push the workspace (git push) before publish',
|
|
2495
|
+
});
|
|
2496
|
+
} else if (['in-sync', 'behind', 'diverged'].includes(originObservation?.status)) {
|
|
2497
|
+
await evidence.append({
|
|
2498
|
+
phase: 'origin-ahead',
|
|
2499
|
+
status: 'completed',
|
|
2500
|
+
observation: originObservation.status,
|
|
2501
|
+
defaultBranch: configDefaultBranch,
|
|
2502
|
+
localHead: originObservation.localHead ?? null,
|
|
2503
|
+
remoteHead: originObservation.remoteHead ?? null,
|
|
2504
|
+
aheadCount: originObservation.aheadCount ?? null,
|
|
2505
|
+
behindCount: originObservation.behindCount ?? null,
|
|
2506
|
+
});
|
|
2507
|
+
} else {
|
|
2508
|
+
await evidence.append({
|
|
2509
|
+
phase: 'origin-ahead',
|
|
2510
|
+
status: 'unobserved',
|
|
2511
|
+
reason: originObservation?.status ?? 'unknown',
|
|
2512
|
+
defaultBranch: configDefaultBranch,
|
|
2513
|
+
error: originObservation?.error ?? null,
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2299
2518
|
} else if (production) {
|
|
2300
2519
|
await evidence.append({
|
|
2301
2520
|
phase: 'source-authority',
|
|
@@ -2390,7 +2609,8 @@ export async function prepareRelease(options) {
|
|
|
2390
2609
|
if (offline) {
|
|
2391
2610
|
throw new ReleaseError(
|
|
2392
2611
|
GATE_FAILED,
|
|
2393
|
-
`unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare
|
|
2612
|
+
`unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare. ` +
|
|
2613
|
+
`Remediation: release-skill prepare --production --online`,
|
|
2394
2614
|
{ unitId: unit.id, branchStrategy },
|
|
2395
2615
|
);
|
|
2396
2616
|
}
|
|
@@ -2494,7 +2714,8 @@ export async function prepareRelease(options) {
|
|
|
2494
2714
|
throw new ReleaseError(
|
|
2495
2715
|
GATE_FAILED,
|
|
2496
2716
|
`unit "${unit.id}" has bound previousPublicBaseline but production prepare uses --offline. ` +
|
|
2497
|
-
`Must use --online to observe the previous public baseline before freezing a production plan
|
|
2717
|
+
`Must use --online to observe the previous public baseline before freezing a production plan. ` +
|
|
2718
|
+
`Remediation: release-skill prepare --production --online`,
|
|
2498
2719
|
{ unitId: unit.id, repo: ppbConfig.repo, ref: ppbConfig.ref },
|
|
2499
2720
|
);
|
|
2500
2721
|
}
|
|
@@ -2858,7 +3079,16 @@ export async function prepareRelease(options) {
|
|
|
2858
3079
|
host: 'root',
|
|
2859
3080
|
});
|
|
2860
3081
|
|
|
2861
|
-
|
|
3082
|
+
// G5: bind execution time + exit code into the frozen receipt.
|
|
3083
|
+
// preparedAt reuses this prepare's deterministic freeze timestamp
|
|
3084
|
+
// (production: the baseline HEAD commit committer date; otherwise
|
|
3085
|
+
// null) — never a wall-clock sample — so identical sources freeze
|
|
3086
|
+
// byte-identical receipts on every re-prepare.
|
|
3087
|
+
const receipt = createSkillResourceClosureReceipt(closureResult, {
|
|
3088
|
+
unitId: unit.id,
|
|
3089
|
+
preparedAt: freezeTimestamp ?? null,
|
|
3090
|
+
exitCode: 0,
|
|
3091
|
+
});
|
|
2862
3092
|
skillResourceClosureResults.push(receipt);
|
|
2863
3093
|
|
|
2864
3094
|
if (closureResult.findings.length > 0) {
|
|
@@ -2873,6 +3103,9 @@ export async function prepareRelease(options) {
|
|
|
2873
3103
|
reference: f.reference,
|
|
2874
3104
|
classification: f.classification,
|
|
2875
3105
|
code: f.code,
|
|
3106
|
+
// D4: RESOURCE_DRIFT findings localize via references (the
|
|
3107
|
+
// finding's own skill/line stay null for a cross-surface drift).
|
|
3108
|
+
...(f.references ? { references: f.references } : {}),
|
|
2876
3109
|
})),
|
|
2877
3110
|
});
|
|
2878
3111
|
throw new ReleaseError(
|
|
@@ -2886,6 +3119,47 @@ export async function prepareRelease(options) {
|
|
|
2886
3119
|
);
|
|
2887
3120
|
}
|
|
2888
3121
|
|
|
3122
|
+
// G4: every declared plugin distribution must be backed by a host
|
|
3123
|
+
// surface in the frozen snapshot with at least one skill. If
|
|
3124
|
+
// publicFiles drops an adapter tree, that host surface is silently
|
|
3125
|
+
// absent from the receipt — fail closed here instead of shipping a
|
|
3126
|
+
// unit whose declared host never entered the closure gate.
|
|
3127
|
+
// Expected host names are the adapter directory names declared by the
|
|
3128
|
+
// platform registry (buildAdapter.name, asserted non-empty by
|
|
3129
|
+
// assertRegistry); codebuddy-plugin keeps the historical `workbuddy`
|
|
3130
|
+
// adapter directory name there. npm-only units declare no plugin
|
|
3131
|
+
// hosts and skip.
|
|
3132
|
+
const expectedHosts = (unit.distributions ?? [])
|
|
3133
|
+
.map((distribution) => PLATFORMS.find((platform) => platform.distributionType === distribution.type))
|
|
3134
|
+
.filter(Boolean)
|
|
3135
|
+
.map((platform) => platform.buildAdapter.name);
|
|
3136
|
+
const hostCoverage = evaluateDeclaredHostSurfaceCoverage(
|
|
3137
|
+
expectedHosts,
|
|
3138
|
+
closureResult.surfaces,
|
|
3139
|
+
);
|
|
3140
|
+
if (!hostCoverage.passed) {
|
|
3141
|
+
await evidence.append({
|
|
3142
|
+
phase: 'skill-resource-closure',
|
|
3143
|
+
status: 'blocking',
|
|
3144
|
+
unitId: unit.id,
|
|
3145
|
+
reason: 'declared-host-surface-missing',
|
|
3146
|
+
missingHosts: hostCoverage.missing,
|
|
3147
|
+
});
|
|
3148
|
+
throw new ReleaseError(
|
|
3149
|
+
GATE_FAILED,
|
|
3150
|
+
`skill resource closure gate failed for unit "${unit.id}": declared host surface(s) missing or empty: ${hostCoverage.missing.map((item) => item.host).join(', ')}`,
|
|
3151
|
+
{
|
|
3152
|
+
unitId: unit.id,
|
|
3153
|
+
missingHosts: hostCoverage.missing,
|
|
3154
|
+
observedSurfaces: closureResult.surfaces.map((surface) => ({
|
|
3155
|
+
id: surface.id,
|
|
3156
|
+
host: surface.host,
|
|
3157
|
+
skillCount: surface.skillCount,
|
|
3158
|
+
})),
|
|
3159
|
+
},
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
|
|
2889
3163
|
await evidence.append({
|
|
2890
3164
|
phase: 'skill-resource-closure',
|
|
2891
3165
|
status: 'completed',
|
|
@@ -2895,6 +3169,10 @@ export async function prepareRelease(options) {
|
|
|
2895
3169
|
skillCount: receipt.skillCount,
|
|
2896
3170
|
referenceCount: closureResult.referenceCount,
|
|
2897
3171
|
sourceOnlyCount: closureResult.sourceOnlyCount,
|
|
3172
|
+
// D2: per-reference exemption detail for approval/audit review —
|
|
3173
|
+
// evidence-layer only; the receipt object (and its digest binding)
|
|
3174
|
+
// is intentionally left unchanged.
|
|
3175
|
+
sourceOnlyReferences: closureResult.sourceOnlyReferences,
|
|
2898
3176
|
findingCount: 0,
|
|
2899
3177
|
receiptDigest: closureResult.receiptDigest,
|
|
2900
3178
|
});
|
|
@@ -3436,11 +3714,29 @@ export async function prepareRelease(options) {
|
|
|
3436
3714
|
completedAt: (clock ? clock() : new Date().toISOString()),
|
|
3437
3715
|
});
|
|
3438
3716
|
|
|
3717
|
+
// --- O4 (2026-08-18 investigation §3.2): success-time guidance ---
|
|
3718
|
+
// A NON-PRODUCTION plan cannot be used by publish; historically operators
|
|
3719
|
+
// discovered that only after a full prepare+approve round-trip. Surface the
|
|
3720
|
+
// remediation at success time so the loop never starts. Production plans
|
|
3721
|
+
// carry no such warning. The prepare command's own defaults are unchanged —
|
|
3722
|
+
// this only enriches the result, never flips offline/production.
|
|
3723
|
+
const nextSteps = [];
|
|
3724
|
+
if (!production) {
|
|
3725
|
+
nextSteps.push({
|
|
3726
|
+
code: 'NON_PRODUCTION_PLAN_NOT_PUBLISHABLE',
|
|
3727
|
+
message:
|
|
3728
|
+
'This plan is NON-PRODUCTION and cannot be used by publish. ' +
|
|
3729
|
+
'To release, re-run: release-skill prepare --production --online — ' +
|
|
3730
|
+
'or use the guided happy end: release-skill ship --target-version <version>.',
|
|
3731
|
+
});
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3439
3734
|
return {
|
|
3440
3735
|
planPath: writtenPath,
|
|
3441
3736
|
planDigest,
|
|
3442
3737
|
evidenceDir,
|
|
3443
|
-
warnings:
|
|
3738
|
+
warnings: runWarnings,
|
|
3739
|
+
nextSteps,
|
|
3444
3740
|
};
|
|
3445
3741
|
} catch (err) {
|
|
3446
3742
|
// Record failure evidence
|
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
|
|