release-skill 0.6.1 → 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 +17 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +12 -9
- package/README.zh-CN.md +12 -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 +326 -70
- package/adapters/claude/schemas/release-plan.schema.json +9 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +326 -70
- package/adapters/codex/schemas/release-plan.schema.json +9 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +326 -70
- package/adapters/kimi/schemas/release-plan.schema.json +9 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +326 -70
- package/adapters/workbuddy/schemas/release-plan.schema.json +9 -0
- package/bin/release-skill.bundle.mjs +326 -70
- package/package.json +1 -1
- package/schemas/release-plan.schema.json +9 -0
- package/src/commands/lineage.mjs +101 -32
- package/src/commands/prepare.mjs +59 -1
- package/src/commands/publish.mjs +10 -1
- package/src/core/skill-resource-closure.mjs +240 -10
- package/src/platforms/registry.mjs +12 -0
package/package.json
CHANGED
|
@@ -863,6 +863,13 @@
|
|
|
863
863
|
"findingCount": {
|
|
864
864
|
"const": 0
|
|
865
865
|
},
|
|
866
|
+
"preparedAt": {
|
|
867
|
+
"type": ["string", "null"],
|
|
868
|
+
"description": "Deterministic freeze timestamp of the prepare run that produced this receipt (production: the baseline HEAD commit committer date in canonical UTC form; non-production: null). Never a wall-clock sample, so identical sources freeze byte-identical receipts."
|
|
869
|
+
},
|
|
870
|
+
"exitCode": {
|
|
871
|
+
"const": 0
|
|
872
|
+
},
|
|
866
873
|
"receiptDigest": {
|
|
867
874
|
"type": "string",
|
|
868
875
|
"pattern": "^[a-f0-9]{64}$"
|
|
@@ -877,6 +884,8 @@
|
|
|
877
884
|
"referenceCount",
|
|
878
885
|
"sourceOnlyCount",
|
|
879
886
|
"findingCount",
|
|
887
|
+
"preparedAt",
|
|
888
|
+
"exitCode",
|
|
880
889
|
"receiptDigest"
|
|
881
890
|
]
|
|
882
891
|
}
|
package/src/commands/lineage.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* @module commands/lineage
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { execFile as execFileCb } from 'node:child_process';
|
|
30
|
+
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
31
31
|
import { promisify } from 'node:util';
|
|
32
32
|
|
|
33
33
|
const execFile = promisify(execFileCb);
|
|
@@ -132,6 +132,50 @@ async function git(root, args, options = {}) {
|
|
|
132
132
|
return stdout.trim();
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Run a git command and return RAW stdout (no trimming). Trailing bytes are
|
|
137
|
+
* significant for `cat-file commit` output: the message tail (trailing
|
|
138
|
+
* whitespace / blank lines) must survive untouched.
|
|
139
|
+
*/
|
|
140
|
+
async function gitRaw(root, args, options = {}) {
|
|
141
|
+
const { stdout } = await execFile('git', args, {
|
|
142
|
+
cwd: root,
|
|
143
|
+
encoding: 'utf8',
|
|
144
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
145
|
+
...options,
|
|
146
|
+
});
|
|
147
|
+
return stdout;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse a commit ident line — the value of an `author`/`committer` header in
|
|
152
|
+
* the raw commit object — of the canonical form `Name <email> ts tz`.
|
|
153
|
+
*
|
|
154
|
+
* The anchored regex uses a greedy `.*` so the LAST ` <email> ts tz` group at
|
|
155
|
+
* the end of the line wins: names containing spaces (or even '<'/'>' — legal
|
|
156
|
+
* in stored objects) still resolve correctly. This replaces the broken
|
|
157
|
+
* `split(/\s+>/)` parsing, which never matched a legal ident line (there is
|
|
158
|
+
* no whitespace before '>') and silently produced name=<entire line>,
|
|
159
|
+
* email='' — corrupting identities written by `commit-tree` during rebuild
|
|
160
|
+
* (SFA field report 2026-08-18).
|
|
161
|
+
*
|
|
162
|
+
* @param {string} line - e.g. `乌龙 <2505468+mzdbxqh@users.noreply.github.com> 1785824474 +0000`
|
|
163
|
+
* @returns {{ name: string, email: string, ts: string, tz: string }}
|
|
164
|
+
* @throws {Error} when the line is not a valid ident line. Fail-closed:
|
|
165
|
+
* rebuild must never fabricate or silently write an empty identity.
|
|
166
|
+
*/
|
|
167
|
+
export function parseCommitIdent(line) {
|
|
168
|
+
if (typeof line !== 'string') {
|
|
169
|
+
throw new Error(`commit ident line must be a string, got ${typeof line}`);
|
|
170
|
+
}
|
|
171
|
+
const match = line.match(/^(.*) <([^>]*)> (\d+) ([+-]\d{4})$/);
|
|
172
|
+
if (!match) {
|
|
173
|
+
throw new Error(`unparseable commit ident line (fail-closed): "${line}"`);
|
|
174
|
+
}
|
|
175
|
+
const [, name, email, ts, tz] = match;
|
|
176
|
+
return { name, email, ts, tz };
|
|
177
|
+
}
|
|
178
|
+
|
|
135
179
|
/**
|
|
136
180
|
* List all tags with their commit SHAs.
|
|
137
181
|
*
|
|
@@ -201,45 +245,44 @@ export async function objectExists(root, sha) {
|
|
|
201
245
|
/**
|
|
202
246
|
* Read the raw commit object and extract identity/date fields.
|
|
203
247
|
*
|
|
248
|
+
* The cat-file output is NOT trimmed: the header block and the message are
|
|
249
|
+
* split at the first blank line and the message tail bytes (trailing
|
|
250
|
+
* whitespace / blank lines) are preserved exactly, so a rebuilt commit can
|
|
251
|
+
* carry byte-identical message bytes.
|
|
252
|
+
*
|
|
204
253
|
* @param {string} root - repository root
|
|
205
254
|
* @param {string} commitSha - commit SHA
|
|
206
255
|
* @returns {Promise<{ message: string, author: { name: string, email: string, date: string }, committer: { name: string, email: string, date: string } }>}
|
|
256
|
+
* @throws {Error} when the object is malformed or an ident line does not
|
|
257
|
+
* parse (fail-closed — never fabricate or silently write empty identity).
|
|
207
258
|
*/
|
|
208
259
|
export async function readCommitMeta(root, commitSha) {
|
|
209
|
-
const raw = await
|
|
210
|
-
const
|
|
260
|
+
const raw = await gitRaw(root, ['cat-file', 'commit', commitSha]);
|
|
261
|
+
const separator = raw.indexOf('\n\n');
|
|
262
|
+
if (separator === -1) {
|
|
263
|
+
throw new Error(`malformed commit object ${commitSha}: missing header/message separator`);
|
|
264
|
+
}
|
|
265
|
+
const message = raw.slice(separator + 2);
|
|
211
266
|
const headers = {};
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
if (line === '') {
|
|
220
|
-
inBody = true;
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const colon = line.indexOf(' ');
|
|
224
|
-
if (colon === -1) continue;
|
|
225
|
-
const key = line.slice(0, colon);
|
|
226
|
-
const value = line.slice(colon + 1);
|
|
227
|
-
headers[key] = value;
|
|
267
|
+
for (const line of raw.slice(0, separator).split('\n')) {
|
|
268
|
+
if (line.startsWith(' ')) continue; // continuation line (e.g. gpgsig)
|
|
269
|
+
const space = line.indexOf(' ');
|
|
270
|
+
if (space === -1) continue;
|
|
271
|
+
headers[line.slice(0, space)] = line.slice(space + 1);
|
|
228
272
|
}
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
const committerParts = (headers.committer ?? '').split(/\s+>/);
|
|
273
|
+
const author = parseCommitIdent(headers.author ?? '');
|
|
274
|
+
const committer = parseCommitIdent(headers.committer ?? '');
|
|
232
275
|
return {
|
|
233
276
|
message,
|
|
234
277
|
author: {
|
|
235
|
-
name:
|
|
236
|
-
email:
|
|
237
|
-
date:
|
|
278
|
+
name: author.name,
|
|
279
|
+
email: author.email,
|
|
280
|
+
date: `${author.ts} ${author.tz}`,
|
|
238
281
|
},
|
|
239
282
|
committer: {
|
|
240
|
-
name:
|
|
241
|
-
email:
|
|
242
|
-
date:
|
|
283
|
+
name: committer.name,
|
|
284
|
+
email: committer.email,
|
|
285
|
+
date: `${committer.ts} ${committer.tz}`,
|
|
243
286
|
},
|
|
244
287
|
};
|
|
245
288
|
}
|
|
@@ -384,20 +427,25 @@ export async function analyzeLineage(root) {
|
|
|
384
427
|
* Create a rebuilt commit object with `commit-tree`, preserving the original
|
|
385
428
|
* author/committer identity and dates via environment variables.
|
|
386
429
|
*
|
|
430
|
+
* The message is delivered via stdin (`-F -`) rather than `-m`: `commit-tree
|
|
431
|
+
* -m` strips trailing blank lines, while stdin preserves the original message
|
|
432
|
+
* bytes exactly (including trailing whitespace), so a rebuilt node can be
|
|
433
|
+
* byte-identical to the original commit.
|
|
434
|
+
*
|
|
387
435
|
* Writes ONLY a commit object into the local object database. Never writes a
|
|
388
436
|
* ref and never pushes.
|
|
389
437
|
*
|
|
390
438
|
* @param {string} root - repository root
|
|
391
439
|
* @param {string} treeSha - tree hash for the rebuilt commit
|
|
392
440
|
* @param {string|null} parentSha - parent commit (null → chain root)
|
|
393
|
-
* @param {string} message - commit message
|
|
441
|
+
* @param {string} message - commit message (exact bytes)
|
|
394
442
|
* @param {{ author: {name: string, email: string, date: string}, committer: {name: string, email: string, date: string} }} identity
|
|
395
443
|
* @returns {Promise<string>} rebuilt commit SHA
|
|
396
444
|
*/
|
|
397
445
|
export async function createRebuiltCommit(root, treeSha, parentSha, message, identity) {
|
|
398
446
|
const args = ['commit-tree', treeSha];
|
|
399
447
|
if (parentSha) args.push('-p', parentSha);
|
|
400
|
-
args.push('-
|
|
448
|
+
args.push('-F', '-');
|
|
401
449
|
const env = {
|
|
402
450
|
...process.env,
|
|
403
451
|
GIT_AUTHOR_NAME: identity.author.name,
|
|
@@ -407,8 +455,29 @@ export async function createRebuiltCommit(root, treeSha, parentSha, message, ide
|
|
|
407
455
|
GIT_COMMITTER_EMAIL: identity.committer.email,
|
|
408
456
|
GIT_COMMITTER_DATE: identity.committer.date,
|
|
409
457
|
};
|
|
410
|
-
|
|
411
|
-
|
|
458
|
+
// Async execFile has no stdin-input support (options.input would be ignored
|
|
459
|
+
// and the child would hang waiting on an open stdin), so spawn is used to
|
|
460
|
+
// deliver the exact message bytes on stdin.
|
|
461
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
462
|
+
const child = spawn('git', args, { cwd: root, env });
|
|
463
|
+
let stdout = '';
|
|
464
|
+
let stderr = '';
|
|
465
|
+
child.stdout.setEncoding('utf8');
|
|
466
|
+
child.stderr.setEncoding('utf8');
|
|
467
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
468
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
469
|
+
child.on('error', rejectPromise);
|
|
470
|
+
child.on('close', (code) => {
|
|
471
|
+
if (code !== 0) {
|
|
472
|
+
rejectPromise(new Error(`git commit-tree failed (exit ${code}): ${stderr.trim()}`));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
resolvePromise(stdout.trim());
|
|
476
|
+
});
|
|
477
|
+
child.stdin.on('error', () => {}); // EPIPE after early exit is reported via close
|
|
478
|
+
child.stdin.write(message);
|
|
479
|
+
child.stdin.end();
|
|
480
|
+
});
|
|
412
481
|
}
|
|
413
482
|
|
|
414
483
|
/**
|
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';
|
|
@@ -2858,7 +2859,16 @@ export async function prepareRelease(options) {
|
|
|
2858
2859
|
host: 'root',
|
|
2859
2860
|
});
|
|
2860
2861
|
|
|
2861
|
-
|
|
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
|
+
});
|
|
2862
2872
|
skillResourceClosureResults.push(receipt);
|
|
2863
2873
|
|
|
2864
2874
|
if (closureResult.findings.length > 0) {
|
|
@@ -2873,6 +2883,9 @@ export async function prepareRelease(options) {
|
|
|
2873
2883
|
reference: f.reference,
|
|
2874
2884
|
classification: f.classification,
|
|
2875
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 } : {}),
|
|
2876
2889
|
})),
|
|
2877
2890
|
});
|
|
2878
2891
|
throw new ReleaseError(
|
|
@@ -2886,6 +2899,47 @@ export async function prepareRelease(options) {
|
|
|
2886
2899
|
);
|
|
2887
2900
|
}
|
|
2888
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
|
+
|
|
2889
2943
|
await evidence.append({
|
|
2890
2944
|
phase: 'skill-resource-closure',
|
|
2891
2945
|
status: 'completed',
|
|
@@ -2895,6 +2949,10 @@ export async function prepareRelease(options) {
|
|
|
2895
2949
|
skillCount: receipt.skillCount,
|
|
2896
2950
|
referenceCount: closureResult.referenceCount,
|
|
2897
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,
|
|
2898
2956
|
findingCount: 0,
|
|
2899
2957
|
receiptDigest: closureResult.receiptDigest,
|
|
2900
2958
|
});
|
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
|
|