release-skill 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/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 +13 -21
- package/README.zh-CN.md +14 -22
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/registry.json +15 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +401 -2
- package/adapters/claude/schemas/release-project.schema.json +291 -0
- package/adapters/claude/schemas/release-run.schema.json +59 -6
- package/adapters/claude/skills/release-config/SKILL.md +140 -0
- package/adapters/claude/skills/release-docs/SKILL.md +106 -0
- package/adapters/claude/skills/release-help/SKILL.md +39 -0
- package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/claude/skills/release-publish/SKILL.md +25 -6
- package/adapters/claude/skills/release-verify/SKILL.md +10 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/registry.json +15 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +401 -2
- package/adapters/codex/schemas/release-project.schema.json +291 -0
- package/adapters/codex/schemas/release-run.schema.json +59 -6
- package/adapters/codex/skills/release-config/SKILL.md +147 -0
- package/adapters/codex/skills/release-docs/SKILL.md +113 -0
- package/adapters/codex/skills/release-help/SKILL.md +39 -0
- package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/codex/skills/release-publish/SKILL.md +25 -6
- package/adapters/codex/skills/release-verify/SKILL.md +10 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/registry.json +15 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +401 -2
- package/adapters/kimi/schemas/release-project.schema.json +291 -0
- package/adapters/kimi/schemas/release-run.schema.json +59 -6
- package/adapters/kimi/skills/release-config/SKILL.md +147 -0
- package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
- package/adapters/kimi/skills/release-help/SKILL.md +39 -0
- package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
- package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/registry.json +15 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
- package/adapters/workbuddy/schemas/release-project.schema.json +291 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
- package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
- package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
- package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
- package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/registry.json +15 -1
- package/bin/release-skill-cli.mjs +235 -7
- package/bin/release-skill.bundle.mjs +38147 -23284
- package/bin/rules.json +1 -1
- package/package.json +5 -4
- package/platform-manifest.json +359 -0
- package/references/.render-manifest.json +9 -9
- package/references/02-project-config.md +35 -0
- package/references/05-evidence-and-errors.md +44 -0
- package/references/06-adapter-contract.md +13 -0
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +401 -2
- package/schemas/release-project.schema.json +291 -0
- package/schemas/release-run.schema.json +59 -6
- package/skills/release-config/SKILL.md +140 -0
- package/skills/release-docs/SKILL.md +106 -0
- package/skills/release-help/SKILL.md +39 -0
- package/skills/release-marketplace/SKILL.md +147 -0
- package/skills/release-publish/SKILL.md +25 -6
- package/skills/release-verify/SKILL.md +10 -0
- package/skills-src/release-config/SKILL.md +140 -0
- package/skills-src/release-docs/SKILL.md +106 -0
- package/skills-src/release-help/SKILL.md +39 -0
- package/skills-src/release-marketplace/SKILL.md +147 -0
- package/skills-src/release-publish/SKILL.md +25 -6
- package/skills-src/release-verify/SKILL.md +10 -0
- package/src/adapters/contract.mjs +5 -0
- package/src/adapters/distribute-git.mjs +625 -0
- package/src/adapters/plugin-marketplace.mjs +48 -4
- package/src/adapters/push-snapshot.mjs +12 -4
- package/src/commands/assess.mjs +171 -0
- package/src/commands/distribute.mjs +1078 -0
- package/src/commands/hooks.mjs +6 -1
- package/src/commands/lineage.mjs +616 -0
- package/src/commands/prepare.mjs +413 -72
- package/src/commands/route.mjs +686 -0
- package/src/commands/ship.mjs +45 -1
- package/src/commands/verify.mjs +176 -0
- package/src/core/baseline-advance.mjs +185 -0
- package/src/core/baseline.mjs +11 -1
- package/src/core/checkpoints.mjs +25 -0
- package/src/core/foundation-inflight.mjs +7 -0
- package/src/core/plan.mjs +12 -2
- package/src/core/postpublish.mjs +315 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release-route command: Deterministic quickstart routing for workflow profiles.
|
|
3
|
+
*
|
|
4
|
+
* Implements handoff §4.3:
|
|
5
|
+
* - Input A: current worktree diff classification (git status --porcelain,
|
|
6
|
+
* including untracked files; deterministic path-prefix rules);
|
|
7
|
+
* - Input B: change surface vs the previous release (previousPublicBaseline
|
|
8
|
+
* bound commit tree vs current worktree; fail-closed when undeterminable);
|
|
9
|
+
* - Input C: targetVersion requested, remote-write authorization, PARTIAL
|
|
10
|
+
* run state.
|
|
11
|
+
*
|
|
12
|
+
* Decision table (fail-closed):
|
|
13
|
+
* 1. hasPartialRun → reconcile
|
|
14
|
+
* 2. A empty && no targetVersion → help (report no change)
|
|
15
|
+
* (判据说明:决策表 §4.3 字面为「B=none 且无 targetVersion → help」,
|
|
16
|
+
* 实现用「输入 A 空」判定——A 空但相对上一版本存在已提交未发布改动
|
|
17
|
+
* (B≠none)时仍报 help。help 不裁剪任何门禁,两者在安全方向上等价:
|
|
18
|
+
* A 空判定覆盖 B=none,且多余场景只会落到更保守的 help,无安全影响。)
|
|
19
|
+
* 3. B indeterminable → full-happy-end (never trim gates on unknown surface)
|
|
20
|
+
* 4. A=docs && B=docs → docs-only
|
|
21
|
+
* 5. A=config && B=config → config-only
|
|
22
|
+
* 6. A=marketplace && B=marketplace → marketplace-only
|
|
23
|
+
* 7. A=marketplace+docs / marketplace+config (no code) → chained
|
|
24
|
+
* marketplace-<other> recommendation
|
|
25
|
+
* 8. everything else (mixed, B≠A, code involvement) → full-happy-end
|
|
26
|
+
*
|
|
27
|
+
* Workflow Profiles (§4):
|
|
28
|
+
* - full-happy-end: longest path with all gates
|
|
29
|
+
* - docs-only: pure documentation changes (skips code-class gates/hooks)
|
|
30
|
+
* - config-only: pure configuration changes (schema gates; no publish path
|
|
31
|
+
* when public bytes are unchanged)
|
|
32
|
+
* - marketplace-only: pure marketplace index changes (delegates release to
|
|
33
|
+
* the workspace's own release skill)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
37
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
38
|
+
import { resolve } from 'node:path';
|
|
39
|
+
import { ReleaseError } from '../core/errors.mjs';
|
|
40
|
+
import { listReleaseTags } from './lineage.mjs';
|
|
41
|
+
|
|
42
|
+
const WORKFLOW_KINDS = Object.freeze([
|
|
43
|
+
'full-happy-end',
|
|
44
|
+
'docs-only',
|
|
45
|
+
'config-only',
|
|
46
|
+
'marketplace-only',
|
|
47
|
+
'marketplace-docs',
|
|
48
|
+
'marketplace-config',
|
|
49
|
+
'reconcile',
|
|
50
|
+
'help',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Classify a single path per the handoff §4.3 prefix table.
|
|
55
|
+
*
|
|
56
|
+
* Order matters: marketplace and config paths are checked before generic
|
|
57
|
+
* code-path rules (a `.mjs` file under `public-snapshot/` or a `plugins.mjs`
|
|
58
|
+
* must never be classified as code).
|
|
59
|
+
*
|
|
60
|
+
* @param {string} path - repository-relative path
|
|
61
|
+
* @returns {'code'|'docs'|'config'|'marketplace'|'version'|'other'|'ignore'}
|
|
62
|
+
* - ignore: evidence/run directories that are never part of a change surface
|
|
63
|
+
*/
|
|
64
|
+
export function classifyPath(path) {
|
|
65
|
+
if (typeof path !== 'string' || path.length === 0) return 'ignore';
|
|
66
|
+
const p = path.replace(/\\+/g, '/');
|
|
67
|
+
|
|
68
|
+
// Evidence/run directories: not part of any change surface.
|
|
69
|
+
if (
|
|
70
|
+
p.startsWith('.release-skill/runs/') ||
|
|
71
|
+
p.startsWith('.release-skill/plans/') ||
|
|
72
|
+
p.startsWith('.release-skill/evidence/')
|
|
73
|
+
) {
|
|
74
|
+
return 'ignore';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Marketplace index and snapshot bytes.
|
|
78
|
+
if (p === 'plugins.mjs' || p.endsWith('/plugins.mjs') || p.startsWith('public-snapshot/')) {
|
|
79
|
+
return 'marketplace';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Config: project config + schemas.
|
|
83
|
+
if (p === '.release-skill/project.yaml' || p.startsWith('schemas/')) {
|
|
84
|
+
return 'config';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Docs per §4.3: docs/public/site/, README*, CHANGELOG, release-notes/.
|
|
88
|
+
if (
|
|
89
|
+
p.startsWith('docs/public/site/') ||
|
|
90
|
+
/^README(\.|$)/i.test(p) ||
|
|
91
|
+
/^CHANGELOG(\.|$)/i.test(p) ||
|
|
92
|
+
p.startsWith('release-notes/')
|
|
93
|
+
) {
|
|
94
|
+
return 'docs';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Version sources (e.g. package.json) are their own category so input B can
|
|
98
|
+
// report "version-only"; for routing purposes they behave like code.
|
|
99
|
+
if (p === 'package.json' || p.endsWith('/package.json')) {
|
|
100
|
+
return 'version';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Code paths per §4.3: src/, test/, scripts/, native/.
|
|
104
|
+
if (
|
|
105
|
+
p.startsWith('src/') ||
|
|
106
|
+
p.startsWith('test/') ||
|
|
107
|
+
p.startsWith('scripts/') ||
|
|
108
|
+
p.startsWith('native/')
|
|
109
|
+
) {
|
|
110
|
+
return 'code';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Any other source/manifest file is treated as code.
|
|
114
|
+
if (p.endsWith('.mjs') || p.endsWith('.js') || p.endsWith('.cjs') || p.endsWith('.ts') || p.endsWith('.json')) {
|
|
115
|
+
return 'code';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Unclassified paths fail closed: treated as code (forces full-happy-end).
|
|
119
|
+
return 'other';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Bucket a list of paths into classification categories.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} paths
|
|
126
|
+
* @returns {{ code: string[], docs: string[], config: string[], marketplace: string[], version: string[], other: string[], mixed: boolean }}
|
|
127
|
+
*/
|
|
128
|
+
export function bucketPaths(paths) {
|
|
129
|
+
const buckets = { code: [], docs: [], config: [], marketplace: [], version: [], other: [] };
|
|
130
|
+
for (const path of paths) {
|
|
131
|
+
const kind = classifyPath(path);
|
|
132
|
+
if (kind === 'ignore') continue;
|
|
133
|
+
if (kind === 'code' || kind === 'other') {
|
|
134
|
+
buckets[kind].push(path);
|
|
135
|
+
} else {
|
|
136
|
+
buckets[kind].push(path);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const codeLikeCount = buckets.code.length > 0 || buckets.other.length > 0 ? 1 : 0;
|
|
140
|
+
const categoryCount =
|
|
141
|
+
codeLikeCount +
|
|
142
|
+
// version sources behave like code for mixing purposes: a version bump
|
|
143
|
+
// alongside docs/config/marketplace changes is never a pure workflow.
|
|
144
|
+
(buckets.version.length > 0 ? 1 : 0) +
|
|
145
|
+
(buckets.docs.length > 0 ? 1 : 0) +
|
|
146
|
+
(buckets.config.length > 0 ? 1 : 0) +
|
|
147
|
+
(buckets.marketplace.length > 0 ? 1 : 0);
|
|
148
|
+
return { ...buckets, mixed: categoryCount > 1 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Input A: classify the current worktree diff vs HEAD.
|
|
153
|
+
*
|
|
154
|
+
* Uses `git status --porcelain=v1 -z` (staged + unstaged + untracked; NUL
|
|
155
|
+
* separated, unquoted paths). `git diff HEAD..HEAD` is never used — it is
|
|
156
|
+
* always empty and would silently misreport a dirty worktree as "no change".
|
|
157
|
+
*
|
|
158
|
+
* @param {string} root - project root directory
|
|
159
|
+
* @returns {Promise<{ code: string[], docs: string[], config: string[], marketplace: string[], version: string[], other: string[], mixed: boolean }>}
|
|
160
|
+
*/
|
|
161
|
+
export async function classifyWorktreeDiff(root) {
|
|
162
|
+
const cwd = resolve(root);
|
|
163
|
+
let output;
|
|
164
|
+
try {
|
|
165
|
+
// --untracked-files=all: never let git collapse an untracked directory to
|
|
166
|
+
// `?? docs/` — every untracked file is listed individually so path-prefix
|
|
167
|
+
// classification sees the real file, not a directory stub.
|
|
168
|
+
output = execFileSync(
|
|
169
|
+
'git',
|
|
170
|
+
['status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
171
|
+
{ cwd, encoding: 'utf8', timeout: 30000, maxBuffer: 64 * 1024 * 1024 },
|
|
172
|
+
);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
throw new ReleaseError(
|
|
175
|
+
'GIT_STATUS_FAILED',
|
|
176
|
+
`failed to execute git status in ${cwd}: ${err.message}`,
|
|
177
|
+
{ reason: err.code ?? 'UNKNOWN_ERROR', root: cwd },
|
|
178
|
+
2,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// NUL-separated entries: "XY path" (renames: "XY old" then "new").
|
|
183
|
+
const entries = output.length > 0 ? output.split('\0').filter((e) => e.length > 0) : [];
|
|
184
|
+
const paths = [];
|
|
185
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
186
|
+
const entry = entries[i];
|
|
187
|
+
const statusPart = entry.slice(0, 2);
|
|
188
|
+
const pathPart = entry.slice(3);
|
|
189
|
+
if (pathPart.length === 0) continue;
|
|
190
|
+
paths.push(pathPart);
|
|
191
|
+
// Rename/copy entries have the destination as the next NUL field.
|
|
192
|
+
if ((statusPart[0] === 'R' || statusPart[0] === 'C') && entries[i + 1] !== undefined) {
|
|
193
|
+
paths.push(entries[i + 1]);
|
|
194
|
+
i += 1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return bucketPaths(paths);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve the previous release commit (input B data source).
|
|
203
|
+
*
|
|
204
|
+
* Order of resolution:
|
|
205
|
+
* 1. most recent frozen plan in `.release-skill/plans/` whose unit carries a
|
|
206
|
+
* bound previousPublicBaseline.commit — the same data source the
|
|
207
|
+
* chain-binding mechanism uses;
|
|
208
|
+
* 2. newest release tag present in the local object database.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} root - project root directory
|
|
211
|
+
* @returns {Promise<{ commit: string, source: 'plan' | 'tag', detail: string } | null>}
|
|
212
|
+
* null when no previous release commit can be determined.
|
|
213
|
+
*/
|
|
214
|
+
export async function resolvePreviousReleaseCommit(root) {
|
|
215
|
+
const cwd = resolve(root);
|
|
216
|
+
|
|
217
|
+
// 1. Latest frozen plan with a bound previous public baseline.
|
|
218
|
+
try {
|
|
219
|
+
const plansDir = resolve(cwd, '.release-skill', 'plans');
|
|
220
|
+
const planFiles = await readdir(plansDir).catch(() => []);
|
|
221
|
+
let best = null;
|
|
222
|
+
for (const file of planFiles) {
|
|
223
|
+
if (!file.endsWith('.json')) continue;
|
|
224
|
+
let plan;
|
|
225
|
+
try {
|
|
226
|
+
plan = JSON.parse(await readFile(resolve(plansDir, file), 'utf8'));
|
|
227
|
+
} catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!Array.isArray(plan.units)) continue;
|
|
231
|
+
const ppb = plan.units[0]?.previousPublicBaseline;
|
|
232
|
+
if (!ppb || ppb.mode !== 'bound' || typeof ppb.commit !== 'string') continue;
|
|
233
|
+
const createdAt = plan.createdAt ?? '';
|
|
234
|
+
if (!best || createdAt > best.createdAt) best = { createdAt, commit: ppb.commit, planFile: file };
|
|
235
|
+
}
|
|
236
|
+
if (best) {
|
|
237
|
+
const exists = await objectExistsLocally(cwd, best.commit);
|
|
238
|
+
if (exists) return { commit: best.commit, source: 'plan', detail: best.planFile };
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// fall through to tag resolution
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 2. Newest local release tag.
|
|
245
|
+
try {
|
|
246
|
+
const releases = await listReleaseTags(cwd);
|
|
247
|
+
if (releases.length > 0) {
|
|
248
|
+
const latest = releases[releases.length - 1];
|
|
249
|
+
return { commit: latest.commitSha, source: 'tag', detail: latest.name };
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// fall through
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function objectExistsLocally(root, sha) {
|
|
259
|
+
try {
|
|
260
|
+
execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: root, stdio: 'ignore' });
|
|
261
|
+
return true;
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Input B: classify the change surface since the previous release commit.
|
|
269
|
+
*
|
|
270
|
+
* Compares the previous release commit's tree with the current worktree
|
|
271
|
+
* (committed changes + uncommitted worktree state). Returns the category
|
|
272
|
+
* union; a surface containing only version sources reports 'version-only'.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} root - project root directory
|
|
275
|
+
* @param {string} prevCommit - previous release commit SHA
|
|
276
|
+
* @returns {Promise<{
|
|
277
|
+
* status: 'determinable',
|
|
278
|
+
* categories: { code: string[], docs: string[], config: string[], marketplace: string[], version: string[], other: string[] },
|
|
279
|
+
* kind: 'code'|'docs'|'config'|'marketplace'|'version-only'|'none'|'mixed',
|
|
280
|
+
* paths: string[]
|
|
281
|
+
* }>}
|
|
282
|
+
*/
|
|
283
|
+
export async function classifyBaselineSurface(root, prevCommit) {
|
|
284
|
+
const cwd = resolve(root);
|
|
285
|
+
let diffOutput = '';
|
|
286
|
+
try {
|
|
287
|
+
diffOutput = execFileSync(
|
|
288
|
+
'git',
|
|
289
|
+
['diff', '--name-only', prevCommit, 'HEAD'],
|
|
290
|
+
{ cwd, encoding: 'utf8', timeout: 30000, maxBuffer: 64 * 1024 * 1024 },
|
|
291
|
+
);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
throw new ReleaseError(
|
|
294
|
+
'GIT_DIFF_FAILED',
|
|
295
|
+
`failed to diff previous release commit ${prevCommit}: ${err.message}`,
|
|
296
|
+
{ reason: err.code ?? 'UNKNOWN_ERROR', prevCommit },
|
|
297
|
+
2,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const worktree = await classifyWorktreeDiff(cwd);
|
|
302
|
+
const committedPaths = diffOutput.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
|
303
|
+
// Only the path arrays join the surface; classifyWorktreeDiff also carries
|
|
304
|
+
// a `mixed` boolean that must never be treated as a path.
|
|
305
|
+
const allPaths = [...new Set([
|
|
306
|
+
...committedPaths,
|
|
307
|
+
...worktree.code,
|
|
308
|
+
...worktree.docs,
|
|
309
|
+
...worktree.config,
|
|
310
|
+
...worktree.marketplace,
|
|
311
|
+
...worktree.version,
|
|
312
|
+
...worktree.other,
|
|
313
|
+
])];
|
|
314
|
+
const buckets = bucketPaths(allPaths);
|
|
315
|
+
|
|
316
|
+
const hasCode = buckets.code.length > 0 || buckets.other.length > 0;
|
|
317
|
+
const hasDocs = buckets.docs.length > 0;
|
|
318
|
+
const hasConfig = buckets.config.length > 0;
|
|
319
|
+
const hasMarketplace = buckets.marketplace.length > 0;
|
|
320
|
+
const hasVersion = buckets.version.length > 0;
|
|
321
|
+
|
|
322
|
+
let kind;
|
|
323
|
+
if (allPaths.length === 0) {
|
|
324
|
+
kind = 'none';
|
|
325
|
+
} else if (!hasCode && !hasDocs && !hasConfig && !hasMarketplace && hasVersion) {
|
|
326
|
+
kind = 'version-only';
|
|
327
|
+
} else {
|
|
328
|
+
const categoryCount = [hasCode, hasDocs, hasConfig, hasMarketplace].filter(Boolean).length;
|
|
329
|
+
kind = categoryCount > 1 ? 'mixed' : hasCode ? 'code' : hasDocs ? 'docs' : hasConfig ? 'config' : 'marketplace';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { status: 'determinable', categories: buckets, kind, paths: allPaths };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Detect a PARTIAL run needing reconciliation (decision table rule 1).
|
|
337
|
+
*
|
|
338
|
+
* Reads the real run structure `runs/<phase>-<ts>/summary.json` (the actual
|
|
339
|
+
* shape produced by prepare/publish/verify; `run.json` does not exist).
|
|
340
|
+
*
|
|
341
|
+
* @param {string} root - project root directory
|
|
342
|
+
* @returns {Promise<boolean>}
|
|
343
|
+
*/
|
|
344
|
+
export async function hasPartialRun(root) {
|
|
345
|
+
const cwd = resolve(root);
|
|
346
|
+
const runsDir = resolve(cwd, '.release-skill', 'runs');
|
|
347
|
+
let runDirs;
|
|
348
|
+
try {
|
|
349
|
+
runDirs = await readdir(runsDir);
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
for (const runDir of runDirs) {
|
|
354
|
+
const summaryPath = resolve(runsDir, runDir, 'summary.json');
|
|
355
|
+
try {
|
|
356
|
+
const summary = JSON.parse(await readFile(summaryPath, 'utf8'));
|
|
357
|
+
if (summary?.status === 'PARTIAL') return true;
|
|
358
|
+
} catch {
|
|
359
|
+
// unreadable summary files are skipped; they cannot prove PARTIAL
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Recommend a workflow profile per the fail-closed decision table (§4.3).
|
|
367
|
+
*
|
|
368
|
+
* @param {{ code: string[], docs: string[], config: string[], marketplace: string[], version: string[], other: string[], mixed: boolean }} diffClassification
|
|
369
|
+
* Input A result (worktree diff).
|
|
370
|
+
* @param {{ status: 'determinable'|'indeterminable', kind?: string, categories?: object } | null} baselineSurface
|
|
371
|
+
* Input B result (change surface vs previous release). null/indeterminable
|
|
372
|
+
* fails closed to full-happy-end.
|
|
373
|
+
* @param {string|null} targetVersion - Input C: requested target version.
|
|
374
|
+
* @param {boolean} hasPartialRun - Input C: PARTIAL run needing reconcile.
|
|
375
|
+
* @param {{ publishAuthorized?: boolean }} [options] - Input C: remote-write
|
|
376
|
+
* authorization state (informational only; never changes routing).
|
|
377
|
+
* @returns {{ workflowKind: string, reason: string, firstCommand: string, requiresAuthorization?: boolean, routing: object }}
|
|
378
|
+
*/
|
|
379
|
+
export function recommendWorkflow(diffClassification, baselineSurface, targetVersion, hasPartialRun, options = {}) {
|
|
380
|
+
const { publishAuthorized = false } = options;
|
|
381
|
+
const { code, docs, config, marketplace, mixed } = diffClassification;
|
|
382
|
+
|
|
383
|
+
// Rule 1: PARTIAL run → reconcile first (highest priority).
|
|
384
|
+
if (hasPartialRun) {
|
|
385
|
+
return {
|
|
386
|
+
workflowKind: 'reconcile',
|
|
387
|
+
reason: '存在 PARTIAL run,必须先 reconcile 再开始任何新操作',
|
|
388
|
+
firstCommand: 'release-skill reconcile --plan <path> --run <path>',
|
|
389
|
+
routing: { rule: 1, hasPartialRun },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const hasAnyChange = code.length > 0 || docs.length > 0 || config.length > 0 || marketplace.length > 0 || diffClassification.version.length > 0;
|
|
394
|
+
|
|
395
|
+
// Rule 2: no worktree changes + no target version → help (report no change).
|
|
396
|
+
if (!hasAnyChange && !targetVersion) {
|
|
397
|
+
return {
|
|
398
|
+
workflowKind: 'help',
|
|
399
|
+
reason: '工作树无变化且未指定目标版本',
|
|
400
|
+
firstCommand: 'release-skill help',
|
|
401
|
+
routing: { rule: 2, hasAnyChange, targetVersion },
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const surfaceKind = baselineSurface?.status === 'determinable' ? baselineSurface.kind : null;
|
|
406
|
+
|
|
407
|
+
// Rule 3: B indeterminable → full-happy-end (fail-closed, never trim gates
|
|
408
|
+
// on an unknown change surface).
|
|
409
|
+
if (baselineSurface === null || baselineSurface.status !== 'determinable' || surfaceKind === null) {
|
|
410
|
+
return {
|
|
411
|
+
workflowKind: 'full-happy-end',
|
|
412
|
+
reason: '相比上一版本的改动面不可判定(无绑定的 previousPublicBaseline 且本地无发布 tag),失败关闭到 full-happy-end',
|
|
413
|
+
firstCommand: 'release-skill ship --target-version <version> --approve --actor <person-name>',
|
|
414
|
+
routing: { rule: 3, baselineStatus: baselineSurface?.status ?? 'missing' },
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const b = baselineSurface;
|
|
419
|
+
const hasBCode = (b.categories.code?.length ?? 0) > 0 || (b.categories.other?.length ?? 0) > 0;
|
|
420
|
+
const hasBDocs = (b.categories.docs?.length ?? 0) > 0;
|
|
421
|
+
const hasBConfig = (b.categories.config?.length ?? 0) > 0;
|
|
422
|
+
const hasBMarketplace = (b.categories.marketplace?.length ?? 0) > 0;
|
|
423
|
+
const bCategories = [hasBCode ? 'code' : null, hasBDocs ? 'docs' : null, hasBConfig ? 'config' : null, hasBMarketplace ? 'marketplace' : null].filter(Boolean);
|
|
424
|
+
|
|
425
|
+
// Rule 4: A=docs && B=docs → docs-only.
|
|
426
|
+
if (code.length === 0 && config.length === 0 && marketplace.length === 0 && docs.length > 0 && !mixed && bCategories.join(',') === 'docs') {
|
|
427
|
+
return {
|
|
428
|
+
workflowKind: 'docs-only',
|
|
429
|
+
reason: `纯文档变更(${docs.length} 个文件),且相比上一版本改动面仅为文档;跳过代码类 gate/hook 与 skill-resource-closure`,
|
|
430
|
+
firstCommand: 'release-docs',
|
|
431
|
+
requiresAuthorization: !publishAuthorized,
|
|
432
|
+
routing: { rule: 4, a: 'docs', b: bCategories },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Rule 5: A=config && B=config → config-only.
|
|
437
|
+
if (code.length === 0 && docs.length === 0 && marketplace.length === 0 && config.length > 0 && !mixed && bCategories.join(',') === 'config') {
|
|
438
|
+
return {
|
|
439
|
+
workflowKind: 'config-only',
|
|
440
|
+
reason: `纯配置变更(${config.length} 个文件),且相比上一版本改动面仅为配置;公开字节不变时无 publish 路径`,
|
|
441
|
+
firstCommand: 'release-config',
|
|
442
|
+
requiresAuthorization: !publishAuthorized,
|
|
443
|
+
routing: { rule: 5, a: 'config', b: bCategories },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Rule 6: A=marketplace && B=marketplace → marketplace-only.
|
|
448
|
+
if (code.length === 0 && docs.length === 0 && config.length === 0 && marketplace.length > 0 && !mixed && bCategories.join(',') === 'marketplace') {
|
|
449
|
+
return {
|
|
450
|
+
workflowKind: 'marketplace-only',
|
|
451
|
+
reason: `纯市场索引变更(${marketplace.length} 个文件),且相比上一版本改动面仅为市场索引;发布委托目标 workspace 专属发布流程`,
|
|
452
|
+
firstCommand: 'release-marketplace',
|
|
453
|
+
requiresAuthorization: !publishAuthorized,
|
|
454
|
+
routing: { rule: 6, a: 'marketplace', b: bCategories },
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Rule 7: marketplace + docs/config (no code, exactly one other category)
|
|
459
|
+
// → chained recommendation. `mixed` is intentionally NOT required here:
|
|
460
|
+
// marketplace+docs implies mixed=true by construction; the precise check is
|
|
461
|
+
// "no code, marketplace present, exactly one of docs/config present".
|
|
462
|
+
const bSubsetOk = (bCategories.every((c) => c === 'docs' || c === 'marketplace') || bCategories.every((c) => c === 'config' || c === 'marketplace')) && bCategories.length > 0;
|
|
463
|
+
if (
|
|
464
|
+
code.length === 0 &&
|
|
465
|
+
marketplace.length > 0 &&
|
|
466
|
+
(docs.length > 0) !== (config.length > 0) &&
|
|
467
|
+
bSubsetOk
|
|
468
|
+
) {
|
|
469
|
+
const other = docs.length > 0 ? 'docs' : 'config';
|
|
470
|
+
const kind = other === 'docs' ? 'marketplace-docs' : 'marketplace-config';
|
|
471
|
+
return {
|
|
472
|
+
workflowKind: kind,
|
|
473
|
+
reason: `市场索引与${other === 'docs' ? '文档' : '配置'}组合变更:先跑 release-marketplace 再串联 ${other === 'docs' ? 'release-docs' : 'release-config'}`,
|
|
474
|
+
firstCommand: other === 'docs' ? 'release-marketplace && release-docs' : 'release-marketplace && release-config',
|
|
475
|
+
requiresAuthorization: !publishAuthorized,
|
|
476
|
+
routing: { rule: 7, a: `marketplace+${other}`, b: bCategories },
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Rule 8: everything else → full-happy-end (fail-closed).
|
|
481
|
+
return {
|
|
482
|
+
workflowKind: 'full-happy-end',
|
|
483
|
+
reason: `混合或复杂变更(code=${code.length}, docs=${docs.length}, config=${config.length}, marketplace=${marketplace.length}, B=${bCategories.join('+') || surfaceKind})。失败关闭到 full-happy-end,不裁剪任何门禁`,
|
|
484
|
+
firstCommand: 'release-skill ship --target-version <version> --approve --actor <person-name>',
|
|
485
|
+
requiresAuthorization: !publishAuthorized,
|
|
486
|
+
routing: { rule: 8, a: mixed ? 'mixed' : 'other', b: bCategories },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Parse command-line arguments.
|
|
492
|
+
*
|
|
493
|
+
* release-skill route --root <path> [--target-version <ver>] [--publish-authorized] [--json]
|
|
494
|
+
*
|
|
495
|
+
* @param {string[]} args
|
|
496
|
+
* @returns {{ root: string, targetVersion: string|null, publishAuthorized: boolean, json: boolean, help: boolean }}
|
|
497
|
+
*/
|
|
498
|
+
export function parseArgs(args) {
|
|
499
|
+
const result = {
|
|
500
|
+
root: process.cwd(),
|
|
501
|
+
targetVersion: null,
|
|
502
|
+
publishAuthorized: false,
|
|
503
|
+
json: false,
|
|
504
|
+
help: false,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
let i = 0;
|
|
508
|
+
while (i < args.length) {
|
|
509
|
+
const arg = args[i];
|
|
510
|
+
if (arg === '--root' && i + 1 < args.length) {
|
|
511
|
+
result.root = args[++i];
|
|
512
|
+
} else if (arg === '--target-version' && i + 1 < args.length) {
|
|
513
|
+
result.targetVersion = args[++i];
|
|
514
|
+
} else if (arg === '--publish-authorized') {
|
|
515
|
+
result.publishAuthorized = true;
|
|
516
|
+
} else if (arg === '--json') {
|
|
517
|
+
result.json = true;
|
|
518
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
519
|
+
result.help = true;
|
|
520
|
+
}
|
|
521
|
+
i += 1;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return result;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Format classification output as human-readable text.
|
|
529
|
+
*/
|
|
530
|
+
function formatClassificationText(classification) {
|
|
531
|
+
const lines = [
|
|
532
|
+
'=== Diff Classification (input A) ===',
|
|
533
|
+
`Mixed: ${classification.mixed ? 'Yes' : 'No'}`,
|
|
534
|
+
'',
|
|
535
|
+
];
|
|
536
|
+
for (const [label, key] of [['Code', 'code'], ['Documentation', 'docs'], ['Configuration', 'config'], ['Marketplace', 'marketplace'], ['Version source', 'version'], ['Unclassified', 'other']]) {
|
|
537
|
+
if ((classification[key] ?? []).length > 0) {
|
|
538
|
+
lines.push(`${label} (${classification[key].length}):`);
|
|
539
|
+
for (const path of classification[key]) lines.push(` - ${path}`);
|
|
540
|
+
lines.push('');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return lines.join('\n');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Format recommendation output as human-readable text.
|
|
548
|
+
*/
|
|
549
|
+
function formatRecommendationText(recommendation) {
|
|
550
|
+
const lines = [
|
|
551
|
+
'',
|
|
552
|
+
'=== Workflow Recommendation ===',
|
|
553
|
+
'',
|
|
554
|
+
`Workflow: ${recommendation.workflowKind}`,
|
|
555
|
+
'',
|
|
556
|
+
'Reason:',
|
|
557
|
+
` ${recommendation.reason}`,
|
|
558
|
+
'',
|
|
559
|
+
'First Command:',
|
|
560
|
+
` ${recommendation.firstCommand}`,
|
|
561
|
+
'',
|
|
562
|
+
];
|
|
563
|
+
if (recommendation.requiresAuthorization) {
|
|
564
|
+
lines.push('Note: 远程写入(publish)未授权——prepare 只读冻结计划,publish 需另行授权。', '');
|
|
565
|
+
}
|
|
566
|
+
return lines.join('\n');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const HELP_TEXT = `release-route: Deterministic quickstart routing for workflow profiles
|
|
570
|
+
|
|
571
|
+
Usage:
|
|
572
|
+
release-skill route --root <path> [--target-version <ver>] [--publish-authorized] [--json]
|
|
573
|
+
|
|
574
|
+
Options:
|
|
575
|
+
--root <path> 项目根目录(默认当前目录)
|
|
576
|
+
--target-version <ver> 目标版本
|
|
577
|
+
--publish-authorized 声明远程写入已获授权(仅影响建议备注,不改变路由)
|
|
578
|
+
--json 输出 JSON
|
|
579
|
+
-h, --help 显示本帮助
|
|
580
|
+
|
|
581
|
+
Workflow Profiles:
|
|
582
|
+
full-happy-end 混合或不可判定变更(失败关闭,全门禁)
|
|
583
|
+
docs-only 纯文档变更(跳过代码类 gate/hook 与 skill-resource-closure)
|
|
584
|
+
config-only 纯配置变更(公开字节不变时无 publish 路径)
|
|
585
|
+
marketplace-only 纯市场索引变更(发布委托目标 workspace 专属流程)
|
|
586
|
+
marketplace-docs / marketplace-config 市场+文档/配置组合(串联)
|
|
587
|
+
reconcile 存在 PARTIAL run,先恢复
|
|
588
|
+
help 工作树无变化且未指定目标版本
|
|
589
|
+
`;
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Main entry point for the route command.
|
|
593
|
+
*
|
|
594
|
+
* @param {string[]} args
|
|
595
|
+
* @param {{ root?: string, json?: boolean, targetVersion?: string, publishAuthorized?: boolean }} [options]
|
|
596
|
+
* @returns {Promise<{ status: string, classification: object, recommendation: object, baseline: object }>}
|
|
597
|
+
*/
|
|
598
|
+
export default async function main(args = [], options = {}) {
|
|
599
|
+
const parsed = parseArgs(args);
|
|
600
|
+
const root = options.root ?? parsed.root;
|
|
601
|
+
const targetVersion = options.targetVersion ?? parsed.targetVersion;
|
|
602
|
+
const publishAuthorized = options.publishAuthorized ?? parsed.publishAuthorized;
|
|
603
|
+
const json = options.json ?? parsed.json;
|
|
604
|
+
|
|
605
|
+
if (parsed.help) {
|
|
606
|
+
if (json) {
|
|
607
|
+
console.log(JSON.stringify({
|
|
608
|
+
command: 'route',
|
|
609
|
+
description: 'Deterministic quickstart routing for workflow profiles',
|
|
610
|
+
usage: 'release-skill route --root <path> [--target-version <ver>] [--json]',
|
|
611
|
+
options: {
|
|
612
|
+
'--root': 'Project root directory (default: cwd)',
|
|
613
|
+
'--target-version': 'Target version being released',
|
|
614
|
+
'--publish-authorized': 'Declare remote writes authorized (informational)',
|
|
615
|
+
'--json': 'Output results as JSON',
|
|
616
|
+
},
|
|
617
|
+
workflowKinds: WORKFLOW_KINDS,
|
|
618
|
+
}, null, 2));
|
|
619
|
+
} else {
|
|
620
|
+
console.log(HELP_TEXT);
|
|
621
|
+
}
|
|
622
|
+
return { status: 'HELP_SHOWN', classification: {}, recommendation: {} };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
const classification = await classifyWorktreeDiff(root);
|
|
627
|
+
const baseline = await resolvePreviousReleaseCommit(root);
|
|
628
|
+
let baselineSurface = null;
|
|
629
|
+
if (baseline) {
|
|
630
|
+
baselineSurface = await classifyBaselineSurface(root, baseline.commit);
|
|
631
|
+
} else {
|
|
632
|
+
baselineSurface = { status: 'indeterminable', kind: null, categories: {}, paths: [] };
|
|
633
|
+
}
|
|
634
|
+
const partial = await hasPartialRun(root);
|
|
635
|
+
const recommendation = recommendWorkflow(
|
|
636
|
+
classification,
|
|
637
|
+
baselineSurface,
|
|
638
|
+
targetVersion,
|
|
639
|
+
partial,
|
|
640
|
+
{ publishAuthorized },
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
if (json) {
|
|
644
|
+
console.log(JSON.stringify({
|
|
645
|
+
classification,
|
|
646
|
+
baseline: baseline ? { ...baseline } : null,
|
|
647
|
+
baselineSurface,
|
|
648
|
+
hasPartialRun: partial,
|
|
649
|
+
recommendation,
|
|
650
|
+
}, null, 2));
|
|
651
|
+
} else {
|
|
652
|
+
console.log(formatClassificationText(classification));
|
|
653
|
+
console.log(formatRecommendationText(recommendation));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
status: 'SUCCESS',
|
|
658
|
+
classification,
|
|
659
|
+
baseline,
|
|
660
|
+
baselineSurface,
|
|
661
|
+
hasPartialRun: partial,
|
|
662
|
+
recommendation,
|
|
663
|
+
};
|
|
664
|
+
} catch (err) {
|
|
665
|
+
if (err instanceof ReleaseError) {
|
|
666
|
+
if (json) {
|
|
667
|
+
console.log(JSON.stringify({
|
|
668
|
+
error: err.code,
|
|
669
|
+
message: err.message,
|
|
670
|
+
details: err.details ?? {},
|
|
671
|
+
exitCode: err.exitCode ?? 1,
|
|
672
|
+
}, null, 2));
|
|
673
|
+
} else {
|
|
674
|
+
console.error(`Error [${err.code}]: ${err.message}`);
|
|
675
|
+
}
|
|
676
|
+
return { status: 'ERROR', error: err.code, message: err.message, exitCode: err.exitCode ?? 1 };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (json) {
|
|
680
|
+
console.log(JSON.stringify({ error: 'UNKNOWN_ERROR', message: err.message, details: {}, exitCode: 1 }, null, 2));
|
|
681
|
+
} else {
|
|
682
|
+
console.error(`Unexpected error: ${err.message}`);
|
|
683
|
+
}
|
|
684
|
+
return { status: 'ERROR', error: 'UNKNOWN_ERROR', message: err.message, exitCode: 1 };
|
|
685
|
+
}
|
|
686
|
+
}
|