release-skill 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -3
  4. package/CHANGELOG.md +89 -0
  5. package/INSTALL.md +216 -5
  6. package/INSTALL.zh-CN.md +358 -0
  7. package/README.md +411 -67
  8. package/README.zh-CN.md +377 -59
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +79284 -0
  12. package/adapters/claude/bin/release-skill.mjs +34 -0
  13. package/adapters/claude/native/safe-write/binding.gyp +40 -0
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +24 -0
  16. package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
  17. package/adapters/claude/schemas/.render-manifest.json +37 -0
  18. package/adapters/claude/schemas/approval-record.schema.json +115 -0
  19. package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
  20. package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
  21. package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
  22. package/adapters/claude/schemas/evidence-event.schema.json +89 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +860 -0
  24. package/adapters/claude/schemas/release-project.schema.json +736 -0
  25. package/adapters/claude/schemas/release-run.schema.json +342 -0
  26. package/adapters/claude/skills/release-assess/SKILL.md +5 -6
  27. package/adapters/claude/skills/release-help/SKILL.md +14 -18
  28. package/adapters/claude/skills/release-prepare/SKILL.md +16 -6
  29. package/adapters/claude/skills/release-publish/SKILL.md +7 -7
  30. package/adapters/claude/skills/release-reconcile/SKILL.md +6 -6
  31. package/adapters/claude/skills/release-setup/SKILL.md +95 -0
  32. package/adapters/claude/skills/release-verify/SKILL.md +7 -7
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/release-skill.bundle.mjs +79284 -0
  35. package/adapters/codex/bin/release-skill.mjs +34 -0
  36. package/adapters/codex/native/safe-write/binding.gyp +40 -0
  37. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  38. package/adapters/codex/native/safe-write/prebuilds.json +24 -0
  39. package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
  40. package/adapters/codex/schemas/.render-manifest.json +37 -0
  41. package/adapters/codex/schemas/approval-record.schema.json +115 -0
  42. package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
  43. package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
  44. package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
  45. package/adapters/codex/schemas/evidence-event.schema.json +89 -0
  46. package/adapters/codex/schemas/release-plan.schema.json +860 -0
  47. package/adapters/codex/schemas/release-project.schema.json +736 -0
  48. package/adapters/codex/schemas/release-run.schema.json +342 -0
  49. package/adapters/codex/skills/release-assess/SKILL.md +12 -6
  50. package/adapters/codex/skills/release-help/SKILL.md +21 -18
  51. package/adapters/codex/skills/release-prepare/SKILL.md +23 -6
  52. package/adapters/codex/skills/release-publish/SKILL.md +14 -7
  53. package/adapters/codex/skills/release-reconcile/SKILL.md +13 -6
  54. package/adapters/codex/skills/release-setup/SKILL.md +102 -0
  55. package/adapters/codex/skills/release-verify/SKILL.md +14 -7
  56. package/bin/release-skill-cli.mjs +807 -0
  57. package/bin/release-skill.bundle.mjs +79284 -0
  58. package/bin/release-skill.mjs +23 -732
  59. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  60. package/native/safe-write/prebuilds.json +22 -2
  61. package/native/safe-write/src/safe_write.cc +11 -2
  62. package/package.json +8 -2
  63. package/references/02-project-config.md +55 -4
  64. package/references/05-evidence-and-errors.md +6 -2
  65. package/schemas/release-plan.schema.json +556 -65
  66. package/schemas/release-project.schema.json +406 -29
  67. package/schemas/release-run.schema.json +165 -18
  68. package/scripts/build-bundle.mjs +133 -0
  69. package/skills/release-assess/SKILL.md +5 -6
  70. package/skills/release-help/SKILL.md +14 -18
  71. package/skills/release-prepare/SKILL.md +16 -6
  72. package/skills/release-publish/SKILL.md +7 -7
  73. package/skills/release-reconcile/SKILL.md +6 -6
  74. package/skills/release-setup/SKILL.md +95 -0
  75. package/skills/release-verify/SKILL.md +7 -7
  76. package/skills-src/release-assess/SKILL.md +5 -6
  77. package/skills-src/release-help/SKILL.md +14 -18
  78. package/skills-src/release-prepare/SKILL.md +16 -6
  79. package/skills-src/release-publish/SKILL.md +7 -7
  80. package/skills-src/release-reconcile/SKILL.md +6 -6
  81. package/skills-src/release-setup/SKILL.md +95 -0
  82. package/skills-src/release-verify/SKILL.md +7 -7
  83. package/src/adapters/contract.mjs +3 -0
  84. package/src/adapters/git-github.mjs +84 -2
  85. package/src/adapters/npm.mjs +5 -13
  86. package/src/adapters/plugin-marketplace.mjs +132 -52
  87. package/src/adapters/push-snapshot.mjs +84 -17
  88. package/src/artifacts/policy.mjs +4 -7
  89. package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
  90. package/src/commands/prepare.mjs +244 -20
  91. package/src/commands/publish.mjs +46 -0
  92. package/src/commands/reconcile.mjs +152 -0
  93. package/src/commands/setup.mjs +1525 -0
  94. package/src/commands/verify.mjs +122 -26
  95. package/src/core/approval.mjs +4 -6
  96. package/src/core/config.mjs +42 -8
  97. package/src/core/errors.mjs +4 -0
  98. package/src/core/pkg-root.mjs +22 -0
  99. package/src/core/plan.mjs +132 -4
  100. package/src/core/previous-public-baseline.mjs +21 -1
  101. package/src/core/run.mjs +4 -4
  102. package/src/core/trusted-resource.mjs +96 -0
  103. package/src/core/verification-gates.mjs +451 -0
  104. package/src/docs/version-gate.mjs +164 -0
  105. package/src/producers/build-adapters.mjs +512 -55
  106. package/src/snapshot/frozen.mjs +221 -7
  107. package/src/snapshot/public-map.mjs +7 -4
  108. package/src/snapshot/scan.mjs +2 -1
@@ -0,0 +1,1525 @@
1
+ /**
2
+ * First-use setup discovery and create-once configuration bootstrap.
3
+ *
4
+ * Dry-run is the default. Human-owned files are never regenerated: write
5
+ * mode can only create an absent `.release-skill/project.yaml` after the
6
+ * caller confirms the exact digest of the current facts and answers.
7
+ */
8
+
9
+ import { execFile as execFileCb } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { createReadStream } from 'node:fs';
12
+ import { promisify } from 'node:util';
13
+ import {
14
+ lstat,
15
+ readFile,
16
+ readdir,
17
+ realpath,
18
+ } from 'node:fs/promises';
19
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
20
+ import YAML from 'yaml';
21
+ import Ajv from 'ajv';
22
+ import addFormats from 'ajv-formats';
23
+
24
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
25
+ import { acquireProjectLock } from '../artifacts/project-lock.mjs';
26
+ import {
27
+ CONFIG_EXISTS,
28
+ CONFIG_INVALID,
29
+ ReleaseError,
30
+ SETUP_DIGEST_MISMATCH,
31
+ } from '../core/errors.mjs';
32
+ import { readTrustedPackageResource } from '../core/trusted-resource.mjs';
33
+
34
+ const execFile = promisify(execFileCb);
35
+ const SKIP_DIRS = new Set([
36
+ '.git', '.release-skill', '.worktrees', '.claude', '.codex', '.cache', '.tmp',
37
+ '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', '.venv', 'venv',
38
+ 'node_modules', 'dist', 'coverage', 'build', 'out', 'tmp', 'temp',
39
+ 'runs', 'test', 'tests', 'test-fixtures', 'fixtures', 'examples',
40
+ ]);
41
+ const MAX_JSON_BYTES = 1024 * 1024;
42
+ const schema = JSON.parse((await readTrustedPackageResource(
43
+ 'schemas/release-project.schema.json',
44
+ )).toString('utf8'));
45
+ const ajv = new Ajv({ allErrors: true, strict: false });
46
+ addFormats(ajv);
47
+ const validateProjectConfig = ajv.compile(schema);
48
+
49
+ function setupError(code, message, details = {}) {
50
+ return new ReleaseError(code, message, details);
51
+ }
52
+
53
+ function safeRelative(root, path) {
54
+ const rel = relative(root, path).split('\\').join('/');
55
+ return rel || '.';
56
+ }
57
+
58
+ async function readJsonBounded(path, label) {
59
+ const stat = await lstat(path);
60
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_JSON_BYTES) {
61
+ throw setupError(CONFIG_INVALID, `${label} must be a regular JSON file no larger than 1 MiB`, { path });
62
+ }
63
+ try {
64
+ return JSON.parse(await readFile(path, 'utf8'));
65
+ } catch (error) {
66
+ throw setupError(CONFIG_INVALID, `${label} is not valid JSON: ${error.message}`, { path });
67
+ }
68
+ }
69
+
70
+ async function walkDiscoveryFiles(root, maxDepth = 8) {
71
+ const found = [];
72
+ async function walk(directory, depth) {
73
+ if (depth > maxDepth) return;
74
+ const children = await readdir(directory, { withFileTypes: true });
75
+ children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
76
+ for (const child of children) {
77
+ if (child.isSymbolicLink()) continue;
78
+ const absolute = join(directory, child.name);
79
+ if (child.isDirectory()) {
80
+ if (!SKIP_DIRS.has(child.name)) await walk(absolute, depth + 1);
81
+ } else if (
82
+ child.isFile() &&
83
+ (child.name === 'package.json' ||
84
+ child.name === 'public-release.json' ||
85
+ child.name === 'SKILL.md' ||
86
+ /^README(?:\.|$)/i.test(child.name) ||
87
+ /^LICENSE(?:\.|$)/i.test(child.name) ||
88
+ /^CHANGELOG(?:\.|$)/i.test(child.name) ||
89
+ absolute.endsWith('/.claude-plugin/plugin.json') ||
90
+ absolute.endsWith('/.codex-plugin/plugin.json') ||
91
+ absolute.endsWith('/.claude-plugin/marketplace.json') ||
92
+ absolute.endsWith('/.codex-plugin/marketplace.json'))
93
+ ) {
94
+ found.push(absolute);
95
+ }
96
+ }
97
+ }
98
+ await walk(root, 0);
99
+ return found;
100
+ }
101
+
102
+ async function digestFile(path) {
103
+ const before = await lstat(path);
104
+ if (!before.isFile() || before.isSymbolicLink()) throw setupError(CONFIG_INVALID, 'discovered file must be regular', { path });
105
+ const hash = createHash('sha256');
106
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
107
+ const after = await lstat(path);
108
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== after.ino) {
109
+ throw setupError(CONFIG_INVALID, 'discovered file changed while setup was reading it', { path });
110
+ }
111
+ return { size: after.size, sha256: hash.digest('hex') };
112
+ }
113
+
114
+ function parseGithubRepo(value) {
115
+ if (!value) return null;
116
+ const raw = typeof value === 'string' ? value : value.url;
117
+ if (typeof raw !== 'string') return null;
118
+ const match = raw.match(/github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?(?:#.*)?$/);
119
+ return match ? `${match[1]}/${match[2]}` : null;
120
+ }
121
+
122
+ function safeUnitId(pkg, relDir) {
123
+ const fromName = typeof pkg.name === 'string' ? pkg.name.replace(/^@[^/]+\//, '') : '';
124
+ const fallback = relDir === '.' ? 'root' : basename(relDir);
125
+ const candidate = (fromName || fallback).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
126
+ return candidate || 'release-unit';
127
+ }
128
+
129
+ function optionalString(value) {
130
+ return typeof value === 'string' && value.length > 0 ? value : null;
131
+ }
132
+
133
+ function stringList(value) {
134
+ return Array.isArray(value)
135
+ ? value.filter((item) => typeof item === 'string' && item.length > 0)
136
+ : [];
137
+ }
138
+
139
+ function summarizeLegacyReleaseConfig(value, path) {
140
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
141
+ throw setupError(CONFIG_INVALID, 'public-release.json must contain a JSON object', { path });
142
+ }
143
+ const topLevelRepo = optionalString(value.repoId) ?? parseGithubRepo(value.publicRepoUrl);
144
+ const topLevelSource = optionalString(value.publicSourceDir) ?? stringList(value.publicRoots)[0] ?? '.';
145
+ const declaredRepos = Array.isArray(value.repos) ? value.repos : [];
146
+ const releaseUnits = declaredRepos
147
+ .filter((repo) => repo && typeof repo === 'object' && !Array.isArray(repo))
148
+ .map((repo, index) => ({
149
+ id: optionalString(repo.id) ?? optionalString(repo.name) ?? `legacy-unit-${index + 1}`,
150
+ source: optionalString(repo.source) ?? '.',
151
+ publicRepo: optionalString(repo.publicRepo),
152
+ tagPrefix: optionalString(repo.tagPrefix),
153
+ npmPackage: optionalString(repo.npmPackage),
154
+ npmPackageDeclared: Object.hasOwn(repo, 'npmPackage'),
155
+ docsSource: optionalString(repo.docsSource),
156
+ requiredPathCandidates: stringList(repo.requiredPackagePaths),
157
+ snapshotCommands: Array.isArray(repo.snapshotCommands) ? repo.snapshotCommands : [],
158
+ }));
159
+ if (releaseUnits.length === 0 && (topLevelRepo || value.plugins || value.snapshotCommands)) {
160
+ const plugins = Array.isArray(value.plugins) ? value.plugins : [];
161
+ const pluginName = plugins
162
+ .filter((plugin) => plugin && typeof plugin === 'object' && !Array.isArray(plugin))
163
+ .map((plugin) => optionalString(plugin.name))
164
+ .find(Boolean);
165
+ releaseUnits.push({
166
+ id: pluginName ?? basename(topLevelSource),
167
+ source: topLevelSource,
168
+ publicRepo: topLevelRepo,
169
+ tagPrefix: optionalString(value.tagPrefix),
170
+ npmPackage: plugins
171
+ .filter((plugin) => plugin && typeof plugin === 'object' && !Array.isArray(plugin))
172
+ .map((plugin) => optionalString(plugin.npmPackage))
173
+ .find(Boolean) ?? null,
174
+ npmPackageDeclared: plugins.some((plugin) => (
175
+ plugin && typeof plugin === 'object' && !Array.isArray(plugin) && Object.hasOwn(plugin, 'npmPackage')
176
+ )),
177
+ docsSource: null,
178
+ requiredPathCandidates: stringList(value.requiredPaths),
179
+ snapshotCommands: Array.isArray(value.snapshotCommands) ? value.snapshotCommands : [],
180
+ });
181
+ }
182
+ return {
183
+ path,
184
+ owner: optionalString(value.owner),
185
+ defaultBranch: optionalString(value.defaultBranch),
186
+ parentRepo: optionalString(value.parentRepo),
187
+ releaseUnits,
188
+ sharedFileCandidates: Array.isArray(value.sharedFiles)
189
+ ? value.sharedFiles
190
+ .filter((item) => item && typeof item === 'object' && !Array.isArray(item))
191
+ .map((item) => ({ source: optionalString(item.source), target: optionalString(item.target) }))
192
+ .filter((item) => item.source && item.target)
193
+ : [],
194
+ docFileCandidates: stringList(value.docFiles),
195
+ forbiddenPathCandidates: [
196
+ ...stringList(value.forbiddenPublicPaths),
197
+ ...stringList(value.forbiddenPaths),
198
+ ].sort(),
199
+ forbiddenContentPatternCandidates: stringList(value.forbiddenContentPatterns).sort(),
200
+ };
201
+ }
202
+
203
+ function normalizeLegacyCommand(value) {
204
+ if (Array.isArray(value) && typeof value[0] === 'string') {
205
+ if (Array.isArray(value[1]) && value[1].every((item) => typeof item === 'string')) {
206
+ return [value[0], ...value[1]];
207
+ }
208
+ if (value.every((item) => typeof item === 'string')) return [...value];
209
+ }
210
+ if (typeof value === 'string' && !/[|&;<>`$'"\\]/.test(value)) {
211
+ const tokens = value.trim().split(/\s+/).filter(Boolean);
212
+ return tokens.length > 0 ? tokens : null;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function classifyScript(name, command, unitId, distributionTypes) {
218
+ const normalizedName = name.toLowerCase();
219
+ const inspectedArgv = normalizeLegacyCommand(command);
220
+ const argv = inspectedArgv?.map((token) => token.toLowerCase()) ?? [];
221
+ const executable = basename(argv[0] ?? '');
222
+ const subcommand = argv[1] ?? '';
223
+
224
+ // Script names only express purpose/cost. Network and interactive behavior
225
+ // is derived from parsed argv so repository names such as "release-notes"
226
+ // and paths containing "development" cannot trigger false positives.
227
+ const isSmoke = /smoke/.test(normalizedName);
228
+ const llmLikely = /(?:^|[:_-])llm(?:$|[:_-])/.test(normalizedName) ||
229
+ argv.some((token) => /(?:^|[-_/])(?:llm|claude|openai)(?:[-_.\/]|$)/.test(token));
230
+ const highCost = /(?:^|[:_-])(integration|e2e|browser|real|self-iteration)(?:$|[:_-])/.test(normalizedName) || llmLikely;
231
+ const packagePublish = ['npm', 'pnpm', 'yarn'].includes(executable) && subcommand === 'publish';
232
+ const githubRelease = executable === 'gh' && subcommand === 'release';
233
+ const networkLikely = packagePublish || githubRelease || ['curl', 'wget', 'npx'].includes(executable) ||
234
+ (executable === 'git' && ['push', 'fetch', 'pull'].includes(subcommand)) || llmLikely;
235
+ const interactive = /(?:^|[:_-])(watch|dev|serve)(?:$|[:_-])/.test(normalizedName) ||
236
+ argv.some((token) => token === '--watch' || token.startsWith('--watch=')) ||
237
+ ['nodemon', 'vite'].includes(executable) ||
238
+ subcommand === 'serve' || (executable === 'next' && subcommand === 'dev');
239
+ const mayWrite = /(?:^|[:_-])(build|generate|update|fix|format|codegen)(?:$|[:_-])/.test(normalizedName) ||
240
+ argv.includes('--fix') || packagePublish || githubRelease;
241
+ const distribution = distributionTypes.length === 1 ? distributionTypes[0] : null;
242
+ const consumerContextUnproven = isSmoke;
243
+
244
+ // Indirect execution: script interpreters with local script paths, or
245
+ // package manager run/test commands. Their actual behavior cannot be
246
+ // proven from argv alone, so they must fail closed.
247
+ // For node/python/python3/bash/sh: detect when an argument looks like a
248
+ // script path (contains / or .) or when node is invoked as a test runner
249
+ // (--test flag). Exclude pure flags (starting with -) for non-node interpreters.
250
+ // A package script is an indirection boundary regardless of its first argv.
251
+ // It may execute a relative shebang file, load plugins, source another file,
252
+ // or delegate through an unrecognised interpreter. Discovery therefore never
253
+ // proves side effects; only explicit human selection may register it as a gate.
254
+ const sideEffectsUnproven = inspectedArgv !== null &&
255
+ !networkLikely && !interactive && !highCost && !mayWrite;
256
+
257
+ const eligibleForRecommendation =
258
+ inspectedArgv !== null &&
259
+ !networkLikely &&
260
+ !interactive &&
261
+ !highCost &&
262
+ !mayWrite &&
263
+ !consumerContextUnproven &&
264
+ !sideEffectsUnproven;
265
+ const ineligibilityReason = networkLikely
266
+ ? 'NETWORK_LIKELY'
267
+ : interactive
268
+ ? 'INTERACTIVE'
269
+ : highCost
270
+ ? 'HIGH_COST'
271
+ : mayWrite
272
+ ? 'MAY_WRITE_FILES'
273
+ : inspectedArgv === null
274
+ ? 'UNPARSEABLE_COMMAND'
275
+ : sideEffectsUnproven
276
+ ? 'SIDE_EFFECTS_UNPROVEN'
277
+ : consumerContextUnproven
278
+ ? 'CONSUMER_CONTEXT_UNPROVEN'
279
+ : null;
280
+
281
+ return {
282
+ id: `${unitId}-script-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`,
283
+ script: name,
284
+ inspectedArgv,
285
+ command: ['npm', 'run', name],
286
+ recommendedPhase: isSmoke ? 'consumer-verify' : 'snapshot-verify',
287
+ scope: {
288
+ unit: unitId,
289
+ ...(isSmoke && distribution ? { distribution } : {}),
290
+ },
291
+ ...(
292
+ isSmoke && !distribution && distributionTypes.length > 1
293
+ ? { distributionCandidates: [...distributionTypes] }
294
+ : {}
295
+ ),
296
+ cost: highCost ? 'high' : /test|smoke/.test(normalizedName) ? 'medium' : 'low',
297
+ sideEffects: {
298
+ mayWriteFiles: mayWrite,
299
+ networkLikely,
300
+ interactive,
301
+ unsandboxed: true,
302
+ },
303
+ eligibleForRecommendation,
304
+ ...(ineligibilityReason ? { ineligibilityReason } : {}),
305
+ reason: isSmoke
306
+ ? '脚本名称表明它可能验证安装后的实际使用;必须人工确认后才能注册。'
307
+ : '项目已声明质量脚本,可在冻结快照副本上复用;不会自动注册。',
308
+ };
309
+ }
310
+
311
+ async function discoverGit(root) {
312
+ const run = async (args) => {
313
+ try {
314
+ const { stdout } = await execFile('git', args, { cwd: root, shell: false, encoding: 'utf8', timeout: 5000 });
315
+ return stdout.trim();
316
+ } catch {
317
+ return '';
318
+ }
319
+ };
320
+ const remoteLines = (await run(['remote', '-v'])).split('\n').filter(Boolean);
321
+ const remotes = [];
322
+ const seen = new Set();
323
+ for (const line of remoteLines) {
324
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
325
+ if (!match) continue;
326
+ const key = `${match[1]}\0${match[2]}`;
327
+ if (seen.has(key)) continue;
328
+ seen.add(key);
329
+ remotes.push({ name: match[1], url: match[2], repo: parseGithubRepo(match[2]) });
330
+ }
331
+ remotes.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
332
+ return {
333
+ repository: Boolean(await run(['rev-parse', '--git-dir'])),
334
+ branch: await run(['branch', '--show-current']) || null,
335
+ head: await run(['rev-parse', 'HEAD']) || null,
336
+ tags: (await run(['tag', '--list'])).split('\n').filter(Boolean).sort(),
337
+ remotes,
338
+ trackedFiles: (await run(['ls-files'])).split('\n').filter(Boolean).sort(),
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Discover Git information for a release unit's source directory.
344
+ *
345
+ * Returns a JSON-serializable evidence object per unit including git root,
346
+ * independence status, remotes, branch, HEAD, tags, and tracked files within
347
+ * the unit directory. For independent sub-repos, branch/head/tags come from
348
+ * the sub-repo; for shared-repo units they come from the parent.
349
+ *
350
+ * @param {string} unitAbsDir - Absolute path to the unit source directory.
351
+ * @param {string} parentRoot - Absolute path to the parent workspace root.
352
+ * @param {string} unitRelDir - Relative directory of the unit (for tracked files filtering).
353
+ * @returns {Promise<object>} JSON-serializable per-unit Git evidence.
354
+ */
355
+ async function discoverUnitGit(unitAbsDir, parentRoot) {
356
+ const run = async (cwd, args) => {
357
+ try {
358
+ const { stdout } = await execFile('git', args, { cwd, shell: false, encoding: 'utf8', timeout: 5000 });
359
+ return stdout.trim();
360
+ } catch {
361
+ return '';
362
+ }
363
+ };
364
+ // Check if this unit's source dir has its own Git root
365
+ const unitGitRoot = await run(unitAbsDir, ['rev-parse', '--show-toplevel']);
366
+ if (!unitGitRoot) {
367
+ return { gitRoot: null, ownRepo: false, ownRemotes: [], branch: null, head: null, tags: [], trackedFiles: [] };
368
+ }
369
+
370
+ const parentGitRoot = await run(parentRoot, ['rev-parse', '--show-toplevel']);
371
+ const isIndependent = unitGitRoot !== parentGitRoot;
372
+
373
+ // Run from the unit directory. Git selects the correct enclosing repo and
374
+ // `ls-files -- .` then returns paths relative to the unit, for both nested
375
+ // independent repositories and monorepo subdirectories.
376
+ const branch = await run(unitAbsDir, ['branch', '--show-current']) || null;
377
+ const head = await run(unitAbsDir, ['rev-parse', 'HEAD']) || null;
378
+ const tags = (await run(unitAbsDir, ['tag', '--list'])).split('\n').filter(Boolean).sort();
379
+
380
+ // Discover remotes from the effective git directory
381
+ const remoteLines = (await run(unitAbsDir, ['remote', '-v'])).split('\n').filter(Boolean);
382
+ const ownRemotes = [];
383
+ const seen = new Set();
384
+ for (const line of remoteLines) {
385
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
386
+ if (!match) continue;
387
+ const key = `${match[1]}\0${match[2]}`;
388
+ if (seen.has(key)) continue;
389
+ seen.add(key);
390
+ ownRemotes.push({ name: match[1], url: match[2], repo: parseGithubRepo(match[2]) });
391
+ }
392
+ ownRemotes.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
393
+
394
+ // Tracked files within the unit directory
395
+ const lsOutput = await run(unitAbsDir, ['ls-files', '--', '.']);
396
+ const trackedFiles = lsOutput ? lsOutput.split('\n').filter(Boolean).sort() : [];
397
+ const upstream = await run(unitAbsDir, ['rev-parse', '--abbrev-ref', '@{upstream}']) || null;
398
+ const aheadBehind = upstream
399
+ ? (await run(unitAbsDir, ['rev-list', '--left-right', '--count', `HEAD...${upstream}`]))
400
+ .split(/\s+/).map(Number)
401
+ : [];
402
+
403
+ return {
404
+ gitRoot: safeRelative(parentRoot, unitGitRoot),
405
+ ownRepo: isIndependent,
406
+ ownRemotes,
407
+ branch,
408
+ head,
409
+ tags,
410
+ trackedFiles,
411
+ remoteTracking: {
412
+ upstream,
413
+ ahead: Number.isFinite(aheadBehind[0]) ? aheadBehind[0] : null,
414
+ behind: Number.isFinite(aheadBehind[1]) ? aheadBehind[1] : null,
415
+ provenance: 'local-git-observation',
416
+ networkFreshness: 'not-checked',
417
+ },
418
+ };
419
+ }
420
+
421
+ async function discoverFacts(root) {
422
+ const files = await walkDiscoveryFiles(root);
423
+ const packageFiles = files.filter((path) => (
424
+ basename(path) === 'package.json' &&
425
+ !/[\\/]adapters[\\/](?:claude|codex)[\\/]package\.json$/.test(path)
426
+ ));
427
+ const pluginFiles = files.filter((path) => path.endsWith('/plugin.json'));
428
+ const marketplaceFiles = files.filter((path) => path.endsWith('/marketplace.json'));
429
+ const legacyReleaseFiles = files.filter((path) => basename(path) === 'public-release.json');
430
+ const fileDigests = [];
431
+ for (const path of files) {
432
+ fileDigests.push({ path: safeRelative(root, path), ...await digestFile(path) });
433
+ }
434
+ fileDigests.sort((a, b) => a.path.localeCompare(b.path));
435
+ const packages = [];
436
+ for (const path of packageFiles) {
437
+ const pkg = await readJsonBounded(path, 'discovered package.json');
438
+ const relPath = safeRelative(root, path);
439
+ const relDir = safeRelative(root, dirname(path));
440
+ packages.push({
441
+ path: relPath,
442
+ directory: relDir,
443
+ name: typeof pkg.name === 'string' ? pkg.name : null,
444
+ version: typeof pkg.version === 'string' ? pkg.version : null,
445
+ private: pkg.private === true,
446
+ repository: parseGithubRepo(pkg.repository),
447
+ publishRegistry: typeof pkg.publishConfig?.registry === 'string' ? pkg.publishConfig.registry : null,
448
+ files: Array.isArray(pkg.files) ? pkg.files.filter((item) => typeof item === 'string').sort() : [],
449
+ scripts: Object.fromEntries(Object.entries(pkg.scripts ?? {})
450
+ .filter(([, value]) => typeof value === 'string')
451
+ .sort(([a], [b]) => a.localeCompare(b))),
452
+ });
453
+ }
454
+ packages.sort((a, b) => a.path.localeCompare(b.path));
455
+
456
+ const manifests = [];
457
+ for (const path of [...pluginFiles, ...marketplaceFiles].sort()) {
458
+ const value = await readJsonBounded(path, 'discovered plugin manifest');
459
+ manifests.push({
460
+ path: safeRelative(root, path),
461
+ host: path.includes('/.claude-plugin/') ? 'claude' : 'codex',
462
+ kind: path.endsWith('/marketplace.json') ? 'marketplace' : 'plugin',
463
+ name: typeof value.name === 'string' ? value.name : null,
464
+ version: typeof value.version === 'string' ? value.version : null,
465
+ });
466
+ }
467
+
468
+ const legacyReleaseConfigs = [];
469
+ for (const path of legacyReleaseFiles.sort()) {
470
+ const value = await readJsonBounded(path, 'discovered public-release.json');
471
+ legacyReleaseConfigs.push(summarizeLegacyReleaseConfig(value, safeRelative(root, path)));
472
+ }
473
+
474
+ const git = await discoverGit(root);
475
+ const skills = files
476
+ .filter((path) => basename(path) === 'SKILL.md')
477
+ .map((path) => {
478
+ const relPath = safeRelative(root, path);
479
+ const segments = relPath.split('/');
480
+ const skillIndex = segments.lastIndexOf('skills');
481
+ return {
482
+ path: relPath,
483
+ name: skillIndex >= 0 ? segments[skillIndex + 1] ?? null : null,
484
+ host: relPath.includes('/adapters/claude/') ? 'claude'
485
+ : relPath.includes('/adapters/codex/') ? 'codex'
486
+ : 'shared',
487
+ };
488
+ })
489
+ .filter((item) => item.name)
490
+ .sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
491
+
492
+ // Per-unit Git discovery: detect independent sub-repos for each package
493
+ const unitGit = {};
494
+ for (const pkg of packages) {
495
+ const unitAbsDir = resolve(root, pkg.directory);
496
+ unitGit[pkg.directory] = await discoverUnitGit(unitAbsDir, root);
497
+ }
498
+
499
+ return { git, packages, manifests, skills, legacyReleaseConfigs, fileDigests, unitGit };
500
+ }
501
+
502
+ /**
503
+ * Build publicFileMappingCandidates for a unit, merging from all sources.
504
+ *
505
+ * Each mapping is { from, to, mode: 'preserve', sources: string[] }.
506
+ * When multiple sources map to the same `to`, all sources are merged into a
507
+ * single entry. If two different `from` paths map to the same `to`, that
508
+ * is a conflict and must be reported.
509
+ */
510
+ function packagePatternMatches(pattern, relativePath) {
511
+ const normalized = pattern.replace(/^\.\//, '').replace(/\/$/, '');
512
+ if (!normalized) return false;
513
+ if (!/[?*]/.test(normalized)) {
514
+ return relativePath === normalized || relativePath.startsWith(`${normalized}/`);
515
+ }
516
+ const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, '\\$&');
517
+ const regexSource = escaped
518
+ .replace(/\*\*/g, '\u0000')
519
+ .replace(/\*/g, '[^/]*')
520
+ .replace(/\?/g, '[^/]')
521
+ .replace(/\u0000/g, '.*');
522
+ return new RegExp(`^${regexSource}(?:/.*)?$`).test(relativePath);
523
+ }
524
+
525
+ function buildPublicFileMappingCandidates(pkg, matchingLegacyUnits, manifestOwners, facts, knownFiles) {
526
+ const unitDir = pkg.directory;
527
+ const prefix = unitDir === '.' ? '' : `${unitDir}/`;
528
+ const toFromMap = new Map();
529
+ const unitTrackedFiles = facts.unitGit?.[unitDir]?.trackedFiles ?? [];
530
+ const availableFiles = new Set([
531
+ ...knownFiles,
532
+ ...(facts.git.trackedFiles ?? []),
533
+ ...unitTrackedFiles.map((path) => `${prefix}${path}`),
534
+ ]);
535
+
536
+ function addMapping(from, to, source, priority = 1) {
537
+ if (!availableFiles.has(from)) return;
538
+ const existing = toFromMap.get(to);
539
+ if (existing) {
540
+ if (existing.from === from) {
541
+ existing.sources.add(source);
542
+ } else if (priority > existing.priority) {
543
+ toFromMap.set(to, {
544
+ from,
545
+ priority,
546
+ sources: new Set([source, ...existing.sources]),
547
+ });
548
+ } else if (priority === existing.priority) {
549
+ existing.conflictingFrom = from;
550
+ existing.sources.add(source);
551
+ } else {
552
+ existing.sources.add(`${source}:superseded`);
553
+ }
554
+ } else {
555
+ toFromMap.set(to, { from, priority, sources: new Set([source]) });
556
+ }
557
+ }
558
+
559
+ // Explicit legacy sources are authoritative over generic package inference.
560
+ for (const config of facts.legacyReleaseConfigs) {
561
+ for (const shared of config.sharedFileCandidates) {
562
+ addMapping(shared.source, shared.target, 'legacy-shared-file', 3);
563
+ }
564
+ }
565
+ for (const legacy of matchingLegacyUnits) {
566
+ if (legacy.docsSource) {
567
+ const config = facts.legacyReleaseConfigs.find((item) => item.releaseUnits.includes(legacy));
568
+ for (const name of config?.docFileCandidates ?? []) {
569
+ addMapping(`${legacy.docsSource}/${name}`, name, 'legacy-doc-source', 3);
570
+ }
571
+ }
572
+ }
573
+
574
+ addMapping(pkg.path, 'package.json', 'package-manifest', 2);
575
+
576
+ const STANDARD_NAMES = [
577
+ 'README.md', 'README.zh-CN.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md',
578
+ 'INSTALL.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md',
579
+ ];
580
+ for (const name of STANDARD_NAMES) {
581
+ addMapping(`${prefix}${name}`, name, 'standard-human-file', 1);
582
+ }
583
+
584
+ for (const manifest of facts.manifests) {
585
+ if (manifestOwners.get(manifest.path) === pkg.path) {
586
+ const to = manifest.path.startsWith(prefix) ? manifest.path.slice(prefix.length) : manifest.path;
587
+ addMapping(manifest.path, to, 'plugin-manifest', 2);
588
+ }
589
+ }
590
+
591
+ for (const legacy of matchingLegacyUnits) {
592
+ for (const reqPath of legacy.requiredPathCandidates) {
593
+ addMapping(`${prefix}${reqPath}`, reqPath, 'legacy-required-path', 2);
594
+ }
595
+ }
596
+
597
+ for (const pattern of pkg.files) {
598
+ for (const tracked of unitTrackedFiles) {
599
+ if (packagePatternMatches(pattern, tracked)) {
600
+ addMapping(`${prefix}${tracked}`, tracked, 'package-files', 1);
601
+ }
602
+ }
603
+ }
604
+
605
+ const mappings = [];
606
+ const conflicts = [];
607
+ for (const [to, entry] of toFromMap) {
608
+ if (entry.conflictingFrom) {
609
+ conflicts.push({
610
+ to,
611
+ from: entry.from,
612
+ conflictingFrom: entry.conflictingFrom,
613
+ sources: [...entry.sources].sort(),
614
+ });
615
+ } else {
616
+ mappings.push({
617
+ from: entry.from,
618
+ to,
619
+ mode: 'preserve',
620
+ sourceScope: unitDir === '.' || entry.from.startsWith(prefix) ? 'unit' : 'workspace',
621
+ sources: [...entry.sources].sort(),
622
+ });
623
+ }
624
+ }
625
+ mappings.sort((a, b) => a.to.localeCompare(b.to));
626
+ conflicts.sort((a, b) => a.to.localeCompare(b.to));
627
+ return { mappings, conflicts };
628
+ }
629
+
630
+ function entrySkillCandidatesForUnit(facts, pkg, id) {
631
+ const prefix = pkg.directory === '.' ? '' : `${pkg.directory}/`;
632
+ const names = [...new Set((facts.skills ?? [])
633
+ .filter((skill) => skill.path.startsWith(prefix))
634
+ .map((skill) => skill.name))];
635
+ const priority = (name) => {
636
+ if (/(?:^|-)help$/.test(name)) return 0;
637
+ if (/(?:^|-)initial$/.test(name)) return 1;
638
+ if (/(?:^|-)setup$/.test(name)) return 2;
639
+ if (name === id) return 3;
640
+ return 4;
641
+ };
642
+ return names.sort((a, b) => priority(a) - priority(b) || a.localeCompare(b));
643
+ }
644
+
645
+ function buildCandidates(facts) {
646
+ const gitRepos = facts.git.remotes.map((remote) => remote.repo).filter(Boolean);
647
+ const uniqueGitRepos = [...new Set(gitRepos)];
648
+ const unitGit = facts.unitGit ?? {};
649
+ const units = [];
650
+ const gates = [];
651
+ const ids = new Set();
652
+ const knownFiles = new Set(facts.fileDigests.map((file) => file.path));
653
+ const manifestRoots = facts.manifests.map((manifest) => {
654
+ const match = manifest.path.match(/^(.*?)(?:\/)?(?:\.claude-plugin|\.codex-plugin)\/(?:plugin|marketplace)\.json$/);
655
+ return { ...manifest, root: match?.[1] || '.' };
656
+ });
657
+ const manifestOwners = new Map();
658
+ const legacyUnits = facts.legacyReleaseConfigs.flatMap((config) => config.releaseUnits);
659
+ const legacyDefaultBranches = [...new Set(facts.legacyReleaseConfigs
660
+ .map((config) => config.defaultBranch)
661
+ .filter(Boolean))];
662
+ for (const manifest of manifestRoots) {
663
+ const owners = facts.packages
664
+ .filter((pkg) => pkg.directory === '.' || manifest.root === pkg.directory || manifest.root.startsWith(`${pkg.directory}/`))
665
+ .sort((a, b) => b.directory.length - a.directory.length);
666
+ if (owners[0]) manifestOwners.set(manifest.path, owners[0].path);
667
+ }
668
+
669
+ for (const pkg of facts.packages) {
670
+ const sourceMatchedLegacyUnits = legacyUnits.filter((unit) => unit.source === pkg.directory);
671
+ const preferredLegacyId = sourceMatchedLegacyUnits.map((unit) => unit.id).find(Boolean);
672
+ let id = preferredLegacyId ?? safeUnitId(pkg, pkg.directory);
673
+ let suffix = 2;
674
+ while (ids.has(id)) id = `${safeUnitId(pkg, pkg.directory)}-${suffix++}`;
675
+ ids.add(id);
676
+ const matchingLegacyUnits = legacyUnits.filter((unit) => (
677
+ unit.id === id || unit.source === pkg.directory || unit.source === dirname(pkg.path)
678
+ ));
679
+ const pluginHosts = facts.manifests
680
+ .filter((manifest) => manifestOwners.get(manifest.path) === pkg.path)
681
+ .filter((manifest) => manifest.kind === 'plugin')
682
+ .map((manifest) => manifest.host);
683
+ const distributions = [];
684
+ const legacyChannelsAreAuthoritative = matchingLegacyUnits.length > 0;
685
+ const npmExplicitlyDeclared = matchingLegacyUnits.some((unit) => (
686
+ unit.npmPackageDeclared && unit.npmPackage !== null
687
+ ));
688
+ const npmExplicitlyForbidden = matchingLegacyUnits.some((unit) => (
689
+ unit.npmPackageDeclared && unit.npmPackage === null
690
+ ));
691
+ // npm channel rule: only suppress npm when the legacy config explicitly
692
+ // sets npmPackage: null. Field absence must not override package.json
693
+ // metadata that proves the package is publishable.
694
+ if (
695
+ !pkg.private &&
696
+ pkg.name &&
697
+ !npmExplicitlyForbidden
698
+ ) distributions.push('npm');
699
+ if (pluginHosts.includes('claude')) distributions.push('claude-plugin');
700
+ if (pluginHosts.includes('codex')) distributions.push('codex-plugin');
701
+ if (pkg.private && matchingLegacyUnits.length === 0 && facts.legacyReleaseConfigs.length > 0) continue;
702
+ if (pkg.private && distributions.length === 0) continue;
703
+
704
+ // Per-unit Git: if this unit's source directory is inside a separate Git
705
+ // repo, use that repo's remotes instead of the parent workspace remotes.
706
+ const unitGitEntry = unitGit[pkg.directory];
707
+ // The root package's Git remote is unit-level evidence even though its Git
708
+ // root is also the workspace root. Nested shared-repo packages still use
709
+ // the parent remote only as a fallback.
710
+ const unitOwnRemotes = ((pkg.directory === '.' || unitGitEntry?.ownRepo)
711
+ ? unitGitEntry?.ownRemotes ?? []
712
+ : [])
713
+ .map((r) => r.repo)
714
+ .filter(Boolean);
715
+ // Authority-priority collapsing: package.json repository and legacy
716
+ // publicRepo are authoritative. Parent workspace remotes are only a
717
+ // fallback when no unit-level repo evidence exists.
718
+ const legacyRepos = matchingLegacyUnits.map((unit) => unit.publicRepo).filter(Boolean);
719
+ const packageRepos = pkg.repository ? [pkg.repository] : [];
720
+ // Collect all non-fallback authority sources for conflict detection.
721
+ // Each source is { source: string, repos: string[] }.
722
+ const authoritySources = [];
723
+ if (legacyRepos.length > 0) authoritySources.push({ source: 'legacy-publicRepo', repos: [...new Set(legacyRepos)] });
724
+ if (packageRepos.length > 0) authoritySources.push({ source: 'package.json-repository', repos: [...new Set(packageRepos)] });
725
+ if (unitOwnRemotes.length > 0) authoritySources.push({ source: 'git-remote', repos: [...new Set(unitOwnRemotes)] });
726
+ const unitLevelRepos = legacyRepos.length > 0
727
+ ? legacyRepos
728
+ : packageRepos.length > 0
729
+ ? packageRepos
730
+ : unitOwnRemotes;
731
+ const hasUnitLevelRepo = unitLevelRepos.length > 0;
732
+ // Only include parent workspace Git repos when the unit has no repo
733
+ // candidates from package.json, legacy config, or independent remotes.
734
+ const fallbackGitRepos = hasUnitLevelRepo ? [] : uniqueGitRepos;
735
+ // Detect authority conflict: when two or more non-fallback sources
736
+ // give different repos, all unique candidates are preserved and a
737
+ // PUBLIC_REPO_AUTHORITY_CONFLICT is recorded.
738
+ const allAuthorityRepos = [...new Set(authoritySources.flatMap((s) => s.repos))];
739
+ const hasAuthorityConflict = authoritySources.length >= 2 && allAuthorityRepos.length >= 2;
740
+ const repositoryCandidates = [...new Set([
741
+ ...allAuthorityRepos,
742
+ ...fallbackGitRepos,
743
+ ].filter(Boolean))];
744
+ const legacyTagTemplates = matchingLegacyUnits
745
+ .map((unit) => unit.tagPrefix ? `${unit.tagPrefix}{version}` : null)
746
+ .filter(Boolean);
747
+ const branchCandidates = [...new Set([
748
+ ...legacyDefaultBranches,
749
+ unitGitEntry?.branch,
750
+ ].filter(Boolean))];
751
+ const unitTags = unitGitEntry?.tags ?? [];
752
+ const mappingResult = buildPublicFileMappingCandidates(
753
+ pkg, matchingLegacyUnits, manifestOwners, facts, knownFiles,
754
+ );
755
+ const requiredPublicFileCandidates = [...new Set([
756
+ ...matchingLegacyUnits.flatMap((unit) => unit.requiredPathCandidates),
757
+ ...mappingResult.mappings
758
+ .map((mapping) => mapping.to)
759
+ .filter((path) => /^(?:package\.json|README(?:\.|$)|LICENSE(?:\.|$))/i.test(path)),
760
+ ])].filter((path) => mappingResult.mappings.some((mapping) => mapping.to === path)).sort();
761
+ units.push({
762
+ id,
763
+ source: pkg.directory,
764
+ packagePath: pkg.path,
765
+ version: pkg.version,
766
+ publicRepoCandidates: repositoryCandidates,
767
+ distributionCandidates: distributions,
768
+ tagTemplateCandidates: [...new Set([
769
+ ...legacyTagTemplates,
770
+ ...(unitTags.some((tag) => pkg.version && tag === `v${pkg.version}`) ? ['v{version}'] : []),
771
+ ...(unitTags.some((tag) => pkg.version && tag === `${id}-v${pkg.version}`)
772
+ ? [`${id}-v{version}`]
773
+ : []),
774
+ ])],
775
+ branchCandidates,
776
+ branchStrategyCandidates: repositoryCandidates.length > 0
777
+ ? ['advance-existing-branch', 'create-release-branch', 'initialize-default-branch']
778
+ : [],
779
+ previousPublicBaselineStatus: repositoryCandidates.length === 0
780
+ ? 'CHANNEL_MISSING'
781
+ : unitTags.length > 0
782
+ ? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
783
+ : 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION',
784
+ publicFileCandidates: [
785
+ pkg.path,
786
+ pkg.directory === '.' ? 'README.md' : `${pkg.directory}/README.md`,
787
+ pkg.directory === '.' ? 'README.zh-CN.md' : `${pkg.directory}/README.zh-CN.md`,
788
+ pkg.directory === '.' ? 'LICENSE' : `${pkg.directory}/LICENSE`,
789
+ ...facts.manifests
790
+ .filter((manifest) => manifestOwners.get(manifest.path) === pkg.path)
791
+ .map((manifest) => manifest.path),
792
+ ].filter((value, index, array) => array.indexOf(value) === index && knownFiles.has(value)).sort(),
793
+ legacyPublicFileHints: matchingLegacyUnits.flatMap((unit) => unit.requiredPathCandidates).sort(),
794
+ packageFilePatternCandidates: [...pkg.files],
795
+ publicFileMappingCandidates: mappingResult.mappings,
796
+ publicFileMappingConflicts: mappingResult.conflicts,
797
+ requiredPublicFileCandidates,
798
+ entrySkillCandidates: entrySkillCandidatesForUnit(facts, pkg, id),
799
+ ...(hasAuthorityConflict ? {
800
+ authorityConflict: {
801
+ code: 'PUBLIC_REPO_AUTHORITY_CONFLICT',
802
+ unit: id,
803
+ evidence: authoritySources.map((s) => ({ source: s.source, repos: s.repos.sort() }))
804
+ .sort((a, b) => a.source.localeCompare(b.source)),
805
+ },
806
+ } : {}),
807
+ });
808
+ for (const [script, command] of Object.entries(pkg.scripts)) {
809
+ if (/^(docs|build|test|typecheck|lint|check|validate|verify|smoke)(?:$|[:_-])/.test(script)) {
810
+ gates.push(classifyScript(script, command, id, distributions));
811
+ }
812
+ }
813
+ for (const legacy of matchingLegacyUnits) {
814
+ legacy.snapshotCommands.forEach((rawCommand, index) => {
815
+ const command = normalizeLegacyCommand(rawCommand);
816
+ gates.push({
817
+ id: `${id}-legacy-snapshot-${index + 1}`,
818
+ source: 'public-release.json snapshotCommands',
819
+ command,
820
+ recommendedPhase: 'snapshot-verify',
821
+ scope: { unit: id },
822
+ cost: 'medium',
823
+ sideEffects: { mayWriteFiles: true, networkLikely: false, unsandboxed: true },
824
+ requiresManualCommandArray: !command,
825
+ // Legacy snapshot commands are never auto-recommended: their
826
+ // side-effect profile has not been independently verified.
827
+ eligibleForRecommendation: false,
828
+ ineligibilityReason: command === null ? 'UNPARSEABLE_COMMAND' : 'SIDE_EFFECTS_UNPROVEN',
829
+ reason: '旧发布配置声明了快照校验;迁移为 gate 前必须人工确认命令数组、副作用和耗时。',
830
+ });
831
+ });
832
+ }
833
+ }
834
+
835
+ // A skill/plugin repository may intentionally have no package.json. Keep
836
+ // it discoverable as a plugin-only candidate instead of inventing npm.
837
+ const unownedPluginRoots = [...new Set(manifestRoots
838
+ .filter((manifest) => manifest.kind === 'plugin' && !manifestOwners.has(manifest.path))
839
+ .map((manifest) => manifest.root))];
840
+ for (const pluginRoot of unownedPluginRoots.sort()) {
841
+ const rootManifests = manifestRoots.filter((manifest) => manifest.root === pluginRoot && manifest.kind === 'plugin');
842
+ const name = rootManifests.map((manifest) => manifest.name).find(Boolean) || basename(pluginRoot);
843
+ const baseId = String(name).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'plugin';
844
+ let id = baseId;
845
+ let suffix = 2;
846
+ while (ids.has(id)) id = `${baseId}-${suffix++}`;
847
+ ids.add(id);
848
+ const publicFileCandidates = [...knownFiles]
849
+ .filter((path) => pluginRoot === '.' || path.startsWith(`${pluginRoot}/`))
850
+ .filter((path) => /(?:README|LICENSE|CHANGELOG|plugin\.json|marketplace\.json)/i.test(path))
851
+ .sort();
852
+ const pluginPrefix = pluginRoot === '.' ? '' : `${pluginRoot}/`;
853
+ units.push({
854
+ id,
855
+ source: pluginRoot,
856
+ packagePath: null,
857
+ version: rootManifests.map((manifest) => manifest.version).find(Boolean) ?? null,
858
+ publicRepoCandidates: [...uniqueGitRepos],
859
+ distributionCandidates: [...new Set(rootManifests.map((manifest) => `${manifest.host}-plugin`))].sort(),
860
+ tagTemplateCandidates: [],
861
+ branchCandidates: [...new Set([...legacyDefaultBranches, facts.git.branch].filter(Boolean))],
862
+ branchStrategyCandidates: uniqueGitRepos.length > 0
863
+ ? ['advance-existing-branch', 'create-release-branch', 'initialize-default-branch']
864
+ : [],
865
+ previousPublicBaselineStatus: uniqueGitRepos.length > 0
866
+ ? (facts.git.tags.length > 0
867
+ ? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
868
+ : 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION')
869
+ : 'CHANNEL_MISSING',
870
+ publicFileCandidates,
871
+ publicFileMappingCandidates: publicFileCandidates.map((path) => ({
872
+ from: path,
873
+ to: path.startsWith(pluginPrefix) ? path.slice(pluginPrefix.length) : path,
874
+ mode: 'preserve',
875
+ sources: ['plugin-only-discovery'],
876
+ })),
877
+ publicFileMappingConflicts: [],
878
+ requiredPublicFileCandidates: publicFileCandidates
879
+ .map((path) => path.startsWith(pluginPrefix) ? path.slice(pluginPrefix.length) : path)
880
+ .filter((path) => /^(?:README(?:\.|$)|LICENSE(?:\.|$))/i.test(path)),
881
+ entrySkillCandidates: [...new Set((facts.skills ?? [])
882
+ .filter((skill) => skill.path.startsWith(pluginPrefix))
883
+ .map((skill) => skill.name))].sort(),
884
+ });
885
+ }
886
+ units.sort((a, b) => a.id.localeCompare(b.id));
887
+ gates.sort((a, b) => a.id.localeCompare(b.id));
888
+ return { units, gates };
889
+ }
890
+
891
+ /**
892
+ * Build a complete recommendedAnswers from candidates, or null when
893
+ * conflicts prevent safe automatic recommendation.
894
+ *
895
+ * The recommendedAnswers is explicitly a proposal pending human confirmation.
896
+ * It only includes gates with eligibleForRecommendation=true.
897
+ */
898
+ function buildRecommendedProposal(facts, candidates) {
899
+ const assumptions = [];
900
+ const legacyOwner = facts.legacyReleaseConfigs
901
+ .map((c) => c.owner)
902
+ .find(Boolean);
903
+
904
+ const units = [];
905
+ for (const unit of candidates.units) {
906
+ // Authority conflict: multiple non-fallback sources disagree on repo
907
+ if (unit.authorityConflict) {
908
+ return {
909
+ answers: null,
910
+ conflicts: [unit.authorityConflict],
911
+ assumptions,
912
+ };
913
+ }
914
+ // Require exactly one public repo candidate for auto-recommendation
915
+ if (unit.publicRepoCandidates.length !== 1) {
916
+ return {
917
+ answers: null,
918
+ conflicts: [{ code: 'PUBLIC_REPO_AMBIGUOUS', unit: unit.id, candidates: unit.publicRepoCandidates }],
919
+ assumptions,
920
+ };
921
+ }
922
+ const publicRepo = unit.publicRepoCandidates[0];
923
+
924
+ // Mapping conflicts prevent auto-recommendation
925
+ if ((unit.publicFileMappingConflicts ?? []).length > 0) {
926
+ return {
927
+ answers: null,
928
+ conflicts: [{ code: 'PUBLIC_FILE_MAPPING_CONFLICT', unit: unit.id, conflicts: unit.publicFileMappingConflicts }],
929
+ assumptions,
930
+ };
931
+ }
932
+ if ((unit.publicFileMappingCandidates ?? []).length === 0) {
933
+ return {
934
+ answers: null,
935
+ conflicts: [{ code: 'PUBLIC_FILE_BOUNDARY_EMPTY', unit: unit.id }],
936
+ assumptions,
937
+ };
938
+ }
939
+
940
+ // Infer npm publisher: prefer legacy owner, then parse from repo slug
941
+ const repoOwner = publicRepo.includes('/') ? publicRepo.split('/')[0] : null;
942
+ const npmPublisher = legacyOwner ?? repoOwner ?? null;
943
+ const pkg = facts.packages.find((item) => item.directory === unit.source);
944
+ if (unit.distributionCandidates.includes('npm') && (!pkg?.name || !npmPublisher)) {
945
+ return {
946
+ answers: null,
947
+ conflicts: [{ code: 'NPM_IDENTITY_INCOMPLETE', unit: unit.id }],
948
+ assumptions,
949
+ };
950
+ }
951
+ if (
952
+ unit.distributionCandidates.some((type) => type.endsWith('-plugin')) &&
953
+ !unit.entrySkillCandidates?.[0]
954
+ ) {
955
+ return {
956
+ answers: null,
957
+ conflicts: [{ code: 'PLUGIN_ENTRY_SKILL_MISSING', unit: unit.id }],
958
+ assumptions,
959
+ };
960
+ }
961
+
962
+ const distributions = unit.distributionCandidates.map((type) => {
963
+ if (type === 'npm') {
964
+ return {
965
+ type: 'npm',
966
+ package: pkg.name,
967
+ registry: pkg.publishRegistry ?? 'https://registry.npmjs.org',
968
+ ...(npmPublisher ? { publisher: npmPublisher } : {}),
969
+ };
970
+ }
971
+ const entrySkill = unit.entrySkillCandidates?.[0];
972
+ return {
973
+ type,
974
+ plugin: unit.id,
975
+ marketplace: unit.id,
976
+ entrySkill,
977
+ };
978
+ }).filter(Boolean);
979
+
980
+ // If any distribution failed to construct, bail
981
+ if (distributions.length !== unit.distributionCandidates.length) return null;
982
+
983
+ const tagTemplate = unit.tagTemplateCandidates[0] ?? 'v{version}';
984
+ const unitPrefix = unit.source === '.' ? '' : `${unit.source}/`;
985
+ const versionSource = unit.packagePath?.startsWith(unitPrefix)
986
+ ? unit.packagePath.slice(unitPrefix.length)
987
+ : unit.packagePath;
988
+
989
+ units.push({
990
+ id: unit.id,
991
+ source: unit.source,
992
+ publicRepo,
993
+ version: { source: versionSource, tagTemplate },
994
+ distributions,
995
+ publicFiles: unit.publicFileMappingCandidates.map((m) => ({
996
+ from: m.from,
997
+ to: m.to,
998
+ mode: m.mode,
999
+ ...(m.sourceScope === 'workspace' ? { sourceScope: 'workspace' } : {}),
1000
+ })),
1001
+ requiredPublicFiles: unit.requiredPublicFileCandidates ?? [],
1002
+ previousPublicBaseline: { mode: 'none' },
1003
+ });
1004
+ assumptions.push({
1005
+ code: 'PREVIOUS_PUBLIC_BASELINE_REQUIRES_CONFIRMATION',
1006
+ unit: unit.id,
1007
+ proposedMode: 'none',
1008
+ observedStatus: unit.previousPublicBaselineStatus,
1009
+ });
1010
+ }
1011
+
1012
+ const selectedGateIds = candidates.gates
1013
+ .filter((gate) => gate.eligibleForRecommendation)
1014
+ .map((gate) => gate.id);
1015
+
1016
+ const projectConfig = {
1017
+ apiVersion: 'release-skill/v1',
1018
+ kind: 'ReleaseProject',
1019
+ project: {
1020
+ name: facts.packages[0]?.name ?? 'project',
1021
+ defaultBranch: facts.legacyReleaseConfigs[0]?.defaultBranch ?? facts.git.branch ?? 'main',
1022
+ },
1023
+ releaseUnits: units,
1024
+ ...(selectedGateIds.length > 0 ? {
1025
+ verificationGates: candidates.gates
1026
+ .filter((gate) => gate.eligibleForRecommendation)
1027
+ .map((gate) => ({
1028
+ id: gate.id,
1029
+ phase: gate.recommendedPhase,
1030
+ scope: gate.scope,
1031
+ command: gate.command,
1032
+ cwd: candidates.units.find((unit) => unit.id === gate.scope.unit)?.source ?? '.',
1033
+ timeoutMs: gate.cost === 'medium' ? 600_000 : 120_000,
1034
+ envAllowlist: [],
1035
+ })),
1036
+ } : {}),
1037
+ };
1038
+
1039
+ const forbiddenPaths = [...new Set(facts.legacyReleaseConfigs
1040
+ .flatMap((config) => config.forbiddenPathCandidates))].sort();
1041
+ const forbiddenContentPatterns = [...new Set(facts.legacyReleaseConfigs
1042
+ .flatMap((config) => config.forbiddenContentPatternCandidates))].sort();
1043
+ if (forbiddenPaths.length > 0 || forbiddenContentPatterns.length > 0) {
1044
+ projectConfig.policy = { forbiddenPaths, forbiddenContentPatterns };
1045
+ }
1046
+ if (!validateProjectConfig(projectConfig)) {
1047
+ return {
1048
+ answers: null,
1049
+ conflicts: [{
1050
+ code: 'RECOMMENDED_CONFIG_SCHEMA_INVALID',
1051
+ validationErrors: (validateProjectConfig.errors ?? []).map((error) => ({
1052
+ instancePath: error.instancePath,
1053
+ keyword: error.keyword,
1054
+ message: error.message,
1055
+ })),
1056
+ }],
1057
+ assumptions,
1058
+ };
1059
+ }
1060
+
1061
+ return {
1062
+ answers: { projectConfig, selectedGateIds },
1063
+ conflicts: [],
1064
+ assumptions,
1065
+ };
1066
+ }
1067
+
1068
+ function buildDecisionsRequired(candidates, localOnly) {
1069
+ const decisions = [];
1070
+ if (localOnly) {
1071
+ decisions.push({
1072
+ id: 'remote-channel',
1073
+ description: '未发现 GitHub/npm 远端渠道;决定建立真实渠道,或保持 local-only 并暂停生产发布配置。',
1074
+ });
1075
+ }
1076
+ for (const unit of candidates.units) {
1077
+ decisions.push({
1078
+ id: `unit:${unit.id}:public-repo`,
1079
+ description: unit.publicRepoCandidates.length === 1
1080
+ ? `确认公开仓候选 ${unit.publicRepoCandidates[0]},不得因唯一候选而跳过人工确认。`
1081
+ : `从 ${JSON.stringify(unit.publicRepoCandidates)} 中选择公开仓;空列表表示必须先建立渠道。`,
1082
+ });
1083
+ decisions.push({
1084
+ id: `unit:${unit.id}:tag-and-branch`,
1085
+ description: `确认 tag 模板、目标分支和 branchStrategy;候选 tag=${JSON.stringify(unit.tagTemplateCandidates)},branch=${JSON.stringify(unit.branchCandidates)}。`,
1086
+ });
1087
+ decisions.push({
1088
+ id: `unit:${unit.id}:previous-public-baseline`,
1089
+ description: `当前状态 ${unit.previousPublicBaselineStatus};已有公开版本必须在线绑定精确 repo/ref/commit,只有确认不存在前序版本才使用 mode=none。`,
1090
+ });
1091
+ decisions.push({
1092
+ id: `unit:${unit.id}:distributions-and-files`,
1093
+ description: `逐项确认渠道 ${JSON.stringify(unit.distributionCandidates)}、公开文件边界和 requiredPublicFiles;候选不是授权。`,
1094
+ });
1095
+ }
1096
+ decisions.push({
1097
+ id: 'verification-gates',
1098
+ description: '逐项选择要注册的 gate;发现脚本不等于授权,未选择时必须显式使用 selectedGateIds: []。',
1099
+ });
1100
+ return decisions;
1101
+ }
1102
+
1103
+ /**
1104
+ * Build a deterministic compact summary from the final report.
1105
+ *
1106
+ * Only includes fields needed for human review. Does not repeat
1107
+ * facts.fileDigests, full mapping arrays, or full answers.
1108
+ */
1109
+ function buildCompactSummary(report) {
1110
+ return {
1111
+ status: report.status,
1112
+ setupDigest: report.setupDigest ?? null,
1113
+ releaseUnitCandidates: (report.releaseUnitCandidates ?? []).map((unit) => ({
1114
+ id: unit.id,
1115
+ source: unit.source,
1116
+ publicRepoCandidates: [...unit.publicRepoCandidates].sort(),
1117
+ branchCandidates: [...unit.branchCandidates].sort(),
1118
+ distributionCandidates: [...unit.distributionCandidates].sort(),
1119
+ publicFileMappingCount: (unit.publicFileMappingCandidates ?? []).length,
1120
+ requiredPublicFileCount: (unit.requiredPublicFileCandidates ?? []).length,
1121
+ })),
1122
+ gateCandidates: (report.gateCandidates ?? []).map((gate) => ({
1123
+ id: gate.id,
1124
+ script: gate.script ?? null,
1125
+ command: gate.command ?? null,
1126
+ eligibleForRecommendation: gate.eligibleForRecommendation,
1127
+ ineligibilityReason: gate.ineligibilityReason ?? null,
1128
+ sideEffects: gate.sideEffects ?? null,
1129
+ })),
1130
+ recommendedGateIds: [...(report.recommendedGateIds ?? [])].sort(),
1131
+ proposalConflicts: report.proposalConflicts ?? [],
1132
+ proposalAssumptions: report.proposalAssumptions ?? [],
1133
+ productionReadiness: report.productionReadiness ?? null,
1134
+ ...(report.audit ? {
1135
+ audit: {
1136
+ configuredUnitIds: [...(report.audit.configuredUnitIds ?? [])].sort(),
1137
+ discoveredUnitIds: [...(report.audit.discoveredUnitIds ?? [])].sort(),
1138
+ configuredGateIds: [...(report.audit.configuredGateIds ?? [])].sort(),
1139
+ unconfiguredGateCandidateIds: [...(report.audit.unconfiguredGateCandidateIds ?? [])].sort(),
1140
+ ...(report.audit.parseError ? { parseError: report.audit.parseError } : {}),
1141
+ validationErrorCount: (report.audit.validationErrors ?? []).length,
1142
+ },
1143
+ } : {}),
1144
+ };
1145
+ }
1146
+
1147
+ function validateAnswers(answers, gateCandidates) {
1148
+ if (!answers || typeof answers !== 'object' || Array.isArray(answers)) {
1149
+ throw setupError(CONFIG_INVALID, 'setup answers must be a JSON object');
1150
+ }
1151
+ if (!answers.projectConfig || typeof answers.projectConfig !== 'object') {
1152
+ throw setupError(CONFIG_INVALID, 'setup answers must contain projectConfig');
1153
+ }
1154
+ if (!Array.isArray(answers.selectedGateIds)) {
1155
+ throw setupError(CONFIG_INVALID, 'setup answers must contain selectedGateIds array (use [] to select none)');
1156
+ }
1157
+ const selected = new Set(answers.selectedGateIds);
1158
+ if (selected.size !== answers.selectedGateIds.length) {
1159
+ throw setupError(CONFIG_INVALID, 'selectedGateIds must be unique');
1160
+ }
1161
+ const candidateIds = new Set(gateCandidates.map((gate) => gate.id));
1162
+ for (const id of selected) {
1163
+ if (!candidateIds.has(id)) throw setupError(CONFIG_INVALID, `selectedGateIds contains unknown candidate "${id}"`);
1164
+ }
1165
+ const configuredIds = (answers.projectConfig.verificationGates ?? []).map((gate) => gate.id).sort();
1166
+ if (JSON.stringify([...selected].sort()) !== JSON.stringify(configuredIds)) {
1167
+ throw setupError(
1168
+ CONFIG_INVALID,
1169
+ 'selectedGateIds must exactly match projectConfig.verificationGates[].id',
1170
+ { selectedGateIds: [...selected].sort(), configuredGateIds: configuredIds },
1171
+ );
1172
+ }
1173
+ if (!validateProjectConfig(answers.projectConfig)) {
1174
+ const errors = validateProjectConfig.errors ?? [];
1175
+ throw setupError(
1176
+ CONFIG_INVALID,
1177
+ `projectConfig in setup answers is invalid: ${errors.map((error) => `${error.instancePath || '/'} ${error.message}`).join('; ')}`,
1178
+ { validationErrors: errors },
1179
+ );
1180
+ }
1181
+ }
1182
+
1183
+ function directoryIdentity(entry, label) {
1184
+ if (
1185
+ !entry || entry.type !== 'directory' ||
1186
+ !Number.isInteger(entry.dev) || !Number.isInteger(entry.ino)
1187
+ ) {
1188
+ throw setupError(CONFIG_INVALID, `${label} must be an identity-bound real directory`);
1189
+ }
1190
+ return { dev: entry.dev, ino: entry.ino };
1191
+ }
1192
+
1193
+ function sameDirectoryIdentity(left, right) {
1194
+ return left.dev === right.dev && left.ino === right.ino;
1195
+ }
1196
+
1197
+ async function openBoundConfigDirectory(root, safeFs) {
1198
+ const rootHandle = await safeFs.openRoot(root);
1199
+ let releaseHandle;
1200
+ try {
1201
+ const rootIdentity = directoryIdentity(await rootHandle.readEntry('.'), 'project root');
1202
+ let releaseEntry = await rootHandle.readEntry('.release-skill');
1203
+ if (releaseEntry === null) {
1204
+ await rootHandle.mkdir('.release-skill', 0o700);
1205
+ releaseEntry = await rootHandle.readEntry('.release-skill');
1206
+ }
1207
+ const linkedIdentity = directoryIdentity(releaseEntry, '.release-skill');
1208
+ releaseHandle = await rootHandle.openDir('.release-skill');
1209
+ const openedIdentity = directoryIdentity(await releaseHandle.readEntry('.'), '.release-skill handle');
1210
+ if (!sameDirectoryIdentity(linkedIdentity, openedIdentity)) {
1211
+ throw setupError(CONFIG_INVALID, '.release-skill identity changed while setup opened it');
1212
+ }
1213
+ return { rootHandle, releaseHandle, rootIdentity, releaseIdentity: openedIdentity };
1214
+ } catch (error) {
1215
+ await releaseHandle?.close().catch(() => {});
1216
+ await rootHandle.close().catch(() => {});
1217
+ throw error;
1218
+ }
1219
+ }
1220
+
1221
+ async function assertConfigDirectoryStillBound(root, safeFs, expected) {
1222
+ const current = await openBoundConfigDirectory(root, safeFs);
1223
+ try {
1224
+ if (
1225
+ !sameDirectoryIdentity(current.rootIdentity, expected.rootIdentity) ||
1226
+ !sameDirectoryIdentity(current.releaseIdentity, expected.releaseIdentity)
1227
+ ) {
1228
+ throw setupError(
1229
+ CONFIG_INVALID,
1230
+ 'project root or .release-skill identity changed immediately before config creation',
1231
+ );
1232
+ }
1233
+ } finally {
1234
+ await current.releaseHandle.close().catch(() => {});
1235
+ await current.rootHandle.close().catch(() => {});
1236
+ }
1237
+ }
1238
+
1239
+ async function createConfigOnce(root, config, { beforeRename } = {}) {
1240
+ const { loadSafeFs } = await import('../artifacts/safe-fs.mjs');
1241
+ const safeFs = await loadSafeFs();
1242
+ const releaseDir = join(root, '.release-skill');
1243
+ const target = join(releaseDir, 'project.yaml');
1244
+ const bound = await openBoundConfigDirectory(root, safeFs);
1245
+ let tempToken;
1246
+ const bytes = Buffer.from(YAML.stringify(config, { lineWidth: 0 }), 'utf8');
1247
+ try {
1248
+ const existing = await bound.releaseHandle.readEntry('project.yaml');
1249
+ if (existing !== null) {
1250
+ throw setupError(CONFIG_EXISTS, 'configuration was created concurrently; setup did not overwrite it', { configPath: target });
1251
+ }
1252
+ tempToken = await bound.releaseHandle.createTemp('project.yaml', 0o600, bytes);
1253
+ const commitAuthority = beforeRename ? await beforeRename() : null;
1254
+ await assertConfigDirectoryStillBound(root, safeFs, bound);
1255
+ try {
1256
+ await bound.releaseHandle.rename(tempToken, 'project.yaml');
1257
+ tempToken = null;
1258
+ } catch (error) {
1259
+ if (await bound.releaseHandle.readEntry('project.yaml') !== null) {
1260
+ throw setupError(CONFIG_EXISTS, 'configuration was created concurrently; setup did not overwrite it', { configPath: target });
1261
+ }
1262
+ throw error;
1263
+ }
1264
+ await bound.releaseHandle.fsync();
1265
+ await bound.rootHandle.fsync();
1266
+ try {
1267
+ await assertConfigDirectoryStillBound(root, safeFs, bound);
1268
+ } catch (error) {
1269
+ const created = await bound.releaseHandle.readFile('project.yaml').catch(() => null);
1270
+ if (created?.bytes?.equals(bytes)) {
1271
+ await bound.releaseHandle.unlink('project.yaml').catch(() => {});
1272
+ await bound.releaseHandle.fsync().catch(() => {});
1273
+ }
1274
+ throw error;
1275
+ }
1276
+ const canonical = await bound.releaseHandle.readFile('project.yaml');
1277
+ if (!canonical?.bytes?.equals(bytes)) {
1278
+ throw setupError(CONFIG_INVALID, 'created configuration bytes do not match the confirmed setup answers');
1279
+ }
1280
+ return {
1281
+ path: target,
1282
+ configSha256: sha256Hex(bytes),
1283
+ commitAuthority,
1284
+ };
1285
+ } finally {
1286
+ if (tempToken) await bound.releaseHandle.abortTemp(tempToken).catch(() => {});
1287
+ await bound.releaseHandle.close().catch(() => {});
1288
+ await bound.rootHandle.close().catch(() => {});
1289
+ }
1290
+ }
1291
+
1292
+ /** Run deterministic first-use discovery or create the confirmed config. */
1293
+ export async function setupProject({ root, answersPath, write = false, confirmSetup, faultInjector } = {}) {
1294
+ if (!root || typeof root !== 'string' || !isAbsolute(root)) {
1295
+ throw setupError(CONFIG_INVALID, 'setup root must be an absolute path');
1296
+ }
1297
+ const rootReal = await realpath(root).catch((error) => {
1298
+ throw setupError(CONFIG_INVALID, `cannot resolve setup root: ${error.message}`);
1299
+ });
1300
+ const configPath = join(rootReal, '.release-skill', 'project.yaml');
1301
+ let configExists = false;
1302
+ try {
1303
+ const stat = await lstat(configPath);
1304
+ configExists = true;
1305
+ if (!stat.isFile() || stat.isSymbolicLink()) {
1306
+ throw setupError(CONFIG_INVALID, 'existing project.yaml must be a regular file');
1307
+ }
1308
+ } catch (error) {
1309
+ if (error.code !== 'ENOENT') throw error;
1310
+ }
1311
+ if (configExists) {
1312
+ if (write) throw setupError(CONFIG_EXISTS, 'configuration already exists; setup never overwrites it', { configPath });
1313
+ const [facts, configBytes] = await Promise.all([
1314
+ discoverFacts(rootReal),
1315
+ readFile(configPath, 'utf8'),
1316
+ ]);
1317
+ const candidates = buildCandidates(facts);
1318
+ let configuredUnitIds = [];
1319
+ let configuredGateIds = [];
1320
+ let parseError = null;
1321
+ let validationErrors = [];
1322
+ try {
1323
+ const existing = YAML.parse(configBytes);
1324
+ configuredUnitIds = (existing?.releaseUnits ?? []).map((unit) => unit?.id).filter(Boolean).sort();
1325
+ configuredGateIds = (existing?.verificationGates ?? []).map((gate) => gate?.id).filter(Boolean).sort();
1326
+ if (!validateProjectConfig(existing)) {
1327
+ validationErrors = (validateProjectConfig.errors ?? []).map((error) => ({
1328
+ instancePath: error.instancePath,
1329
+ schemaPath: error.schemaPath,
1330
+ keyword: error.keyword,
1331
+ params: error.params,
1332
+ message: error.message,
1333
+ }));
1334
+ }
1335
+ } catch (error) {
1336
+ parseError = error.message;
1337
+ }
1338
+ const discoveredUnitIds = candidates.units.map((unit) => unit.id).sort();
1339
+ const unconfiguredGateCandidateIds = candidates.gates
1340
+ .map((gate) => gate.id)
1341
+ .filter((id) => !configuredGateIds.includes(id))
1342
+ .sort();
1343
+ const existingReport = {
1344
+ setupVersion: 1,
1345
+ status: 'ALREADY_CONFIGURED',
1346
+ configPath,
1347
+ existingConfigSha256: sha256Hex(configBytes),
1348
+ facts,
1349
+ releaseUnitCandidates: candidates.units,
1350
+ gateCandidates: candidates.gates,
1351
+ audit: {
1352
+ configuredUnitIds,
1353
+ discoveredUnitIds,
1354
+ configuredGateIds,
1355
+ unconfiguredGateCandidateIds,
1356
+ ...(parseError ? { parseError } : {}),
1357
+ ...(validationErrors.length > 0 ? { validationErrors } : {}),
1358
+ patchSuggestions: [
1359
+ ...(parseError ? ['已有配置无法解析;先人工修复,再运行 release-assess。'] : []),
1360
+ ...(validationErrors.length > 0
1361
+ ? ['已有配置不符合 release-project schema;按 validationErrors 人工增量修复,不重新生成。']
1362
+ : []),
1363
+ ...(canonicalJson(configuredUnitIds) !== canonicalJson(discoveredUnitIds)
1364
+ ? ['发现的发布单元与已有配置不同;人工比较后仅做增量编辑,不重新生成。']
1365
+ : []),
1366
+ ...(unconfiguredGateCandidateIds.length > 0
1367
+ ? ['存在未配置的验证候选;逐项审阅副作用后决定是否人工注册。']
1368
+ : []),
1369
+ ],
1370
+ },
1371
+ recommendedGateIds: [],
1372
+ proposalConflicts: [],
1373
+ proposalAssumptions: [],
1374
+ productionReadiness: 'ASSESS_REQUIRED',
1375
+ next: '运行 release-skill assess 审计已有配置;需要调整时依据建议人工增量编辑。',
1376
+ };
1377
+ existingReport.compactSummary = buildCompactSummary(existingReport);
1378
+ return existingReport;
1379
+ }
1380
+
1381
+ const facts = await discoverFacts(rootReal);
1382
+ const candidates = buildCandidates(facts);
1383
+ let answers = null;
1384
+ if (answersPath) {
1385
+ const resolvedAnswers = isAbsolute(answersPath) ? answersPath : resolve(rootReal, answersPath);
1386
+ answers = await readJsonBounded(resolvedAnswers, 'setup answers');
1387
+ validateAnswers(answers, candidates.gates);
1388
+ }
1389
+ const selectedGateIds = answers?.selectedGateIds ?? [];
1390
+ const digestAuthority = {
1391
+ setupVersion: 1,
1392
+ facts,
1393
+ releaseUnitCandidates: candidates.units,
1394
+ gateCandidates: candidates.gates,
1395
+ selectedGateIds,
1396
+ projectConfig: answers?.projectConfig ?? null,
1397
+ };
1398
+ const setupDigest = sha256Hex(canonicalJson(digestAuthority));
1399
+ const hasDiscoveredRemoteChannel = candidates.units.some((unit) => (
1400
+ unit.publicRepoCandidates.length > 0
1401
+ )) || facts.packages.some((pkg) => pkg.publishRegistry);
1402
+ const status = answers
1403
+ ? 'READY_TO_WRITE'
1404
+ : hasDiscoveredRemoteChannel
1405
+ ? 'NEEDS_INPUT'
1406
+ : 'LOCAL_ONLY_DETECTED';
1407
+ const localOnly = status === 'LOCAL_ONLY_DETECTED';
1408
+ // Deterministic recommendation: gates eligible for automatic inclusion
1409
+ // without human review. Agents use this to build recommendedAnswers.
1410
+ const recommendedGateIds = candidates.gates
1411
+ .filter((gate) => gate.eligibleForRecommendation)
1412
+ .map((gate) => gate.id);
1413
+ // Build recommendedAnswers: a complete proposal for human review,
1414
+ // or null when conflicts prevent safe automatic recommendation.
1415
+ const recommendedProposal = answers
1416
+ ? { answers: null, conflicts: [], assumptions: [] }
1417
+ : buildRecommendedProposal(facts, candidates);
1418
+ const report = {
1419
+ ...digestAuthority,
1420
+ status,
1421
+ setupDigest,
1422
+ recommendedGateIds,
1423
+ recommendedAnswers: recommendedProposal.answers,
1424
+ proposalConflicts: recommendedProposal.conflicts,
1425
+ proposalAssumptions: recommendedProposal.assumptions,
1426
+ productionReadiness: status === 'LOCAL_ONLY_DETECTED'
1427
+ ? 'LOCAL_ONLY'
1428
+ : answers
1429
+ ? 'CONFIG_DRAFT_READY'
1430
+ : 'HUMAN_DECISIONS_REQUIRED',
1431
+ decisionsRequired: answers ? [] : buildDecisionsRequired(candidates, localOnly),
1432
+ writeContract: {
1433
+ default: 'dry-run',
1434
+ requires: ['--write', `--confirm-setup ${setupDigest}`, '--answers <json>'],
1435
+ target: '.release-skill/project.yaml',
1436
+ overwrite: false,
1437
+ },
1438
+ };
1439
+ // Derive compact summary from the final report to avoid state/digest/conflict
1440
+ // inconsistencies. Must not read or execute project scripts.
1441
+ report.compactSummary = buildCompactSummary(report);
1442
+
1443
+ if (!write) return report;
1444
+ if (!answers) throw setupError(CONFIG_INVALID, 'setup --write requires --answers <json>');
1445
+ if (confirmSetup !== setupDigest) {
1446
+ throw setupError(
1447
+ SETUP_DIGEST_MISMATCH,
1448
+ 'setup confirmation does not match the current facts and answers; rerun dry-run and review again',
1449
+ { expected: setupDigest, received: confirmSetup ?? null },
1450
+ );
1451
+ }
1452
+ const lock = await acquireProjectLock({ root: rootReal, command: 'setup', mode: 'exclusive' });
1453
+ let committedConfig;
1454
+ try {
1455
+ committedConfig = await lock.capture(async () => {
1456
+ if (faultInjector) await faultInjector('before-config-commit');
1457
+ const lockedFacts = await discoverFacts(rootReal);
1458
+ const lockedCandidates = buildCandidates(lockedFacts);
1459
+ const resolvedAnswers = isAbsolute(answersPath) ? answersPath : resolve(rootReal, answersPath);
1460
+ const lockedAnswers = await readJsonBounded(resolvedAnswers, 'setup answers');
1461
+ validateAnswers(lockedAnswers, lockedCandidates.gates);
1462
+ const lockedAuthority = {
1463
+ setupVersion: 1,
1464
+ facts: lockedFacts,
1465
+ releaseUnitCandidates: lockedCandidates.units,
1466
+ gateCandidates: lockedCandidates.gates,
1467
+ selectedGateIds: lockedAnswers.selectedGateIds,
1468
+ projectConfig: lockedAnswers.projectConfig,
1469
+ };
1470
+ const lockedDigest = sha256Hex(canonicalJson(lockedAuthority));
1471
+ if (lockedDigest !== confirmSetup) {
1472
+ throw setupError(
1473
+ SETUP_DIGEST_MISMATCH,
1474
+ 'project facts or setup answers changed immediately before config creation; rerun dry-run and review the new digest',
1475
+ { expected: lockedDigest, received: confirmSetup },
1476
+ );
1477
+ }
1478
+ return createConfigOnce(rootReal, lockedAnswers.projectConfig, {
1479
+ beforeRename: async () => {
1480
+ if (faultInjector) await faultInjector('before-config-link');
1481
+ const finalFacts = await discoverFacts(rootReal);
1482
+ const finalCandidates = buildCandidates(finalFacts);
1483
+ const finalAnswers = await readJsonBounded(resolvedAnswers, 'setup answers');
1484
+ validateAnswers(finalAnswers, finalCandidates.gates);
1485
+ const finalAuthority = {
1486
+ setupVersion: 1,
1487
+ facts: finalFacts,
1488
+ releaseUnitCandidates: finalCandidates.units,
1489
+ gateCandidates: finalCandidates.gates,
1490
+ selectedGateIds: finalAnswers.selectedGateIds,
1491
+ projectConfig: finalAnswers.projectConfig,
1492
+ };
1493
+ const finalDigest = sha256Hex(canonicalJson(finalAuthority));
1494
+ if (finalDigest !== confirmSetup) {
1495
+ throw setupError(
1496
+ SETUP_DIGEST_MISMATCH,
1497
+ 'project facts or setup answers changed in the final create-once window; rerun dry-run and review the new digest',
1498
+ { expected: finalDigest, received: confirmSetup },
1499
+ );
1500
+ }
1501
+ return {
1502
+ setupDigest: finalDigest,
1503
+ factsDigest: sha256Hex(canonicalJson(finalFacts)),
1504
+ answersDigest: sha256Hex(canonicalJson(finalAnswers)),
1505
+ };
1506
+ },
1507
+ });
1508
+ });
1509
+ } finally {
1510
+ await lock.release();
1511
+ }
1512
+ const createdReport = {
1513
+ ...report,
1514
+ status: 'CONFIG_CREATED',
1515
+ productionReadiness: 'ASSESS_REQUIRED',
1516
+ configPath: committedConfig.path,
1517
+ configSha256: committedConfig.configSha256,
1518
+ committedSetupDigest: committedConfig.commitAuthority.setupDigest,
1519
+ committedFactsDigest: committedConfig.commitAuthority.factsDigest,
1520
+ committedAnswersDigest: committedConfig.commitAuthority.answersDigest,
1521
+ next: '运行 release-skill assess;再根据 gate 副作用决定 prepare/verify 的显式授权。',
1522
+ };
1523
+ createdReport.compactSummary = buildCompactSummary(createdReport);
1524
+ return createdReport;
1525
+ }