release-skill 0.1.3 → 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 (95) 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 +29 -0
  5. package/INSTALL.md +69 -32
  6. package/INSTALL.zh-CN.md +64 -26
  7. package/README.md +101 -37
  8. package/README.zh-CN.md +87 -28
  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 +11 -18
  28. package/adapters/claude/skills/release-prepare/SKILL.md +6 -6
  29. package/adapters/claude/skills/release-publish/SKILL.md +1 -4
  30. package/adapters/claude/skills/release-reconcile/SKILL.md +5 -5
  31. package/adapters/claude/skills/release-setup/SKILL.md +64 -80
  32. package/adapters/claude/skills/release-verify/SKILL.md +4 -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 +18 -18
  51. package/adapters/codex/skills/release-prepare/SKILL.md +13 -6
  52. package/adapters/codex/skills/release-publish/SKILL.md +8 -4
  53. package/adapters/codex/skills/release-reconcile/SKILL.md +12 -5
  54. package/adapters/codex/skills/release-setup/SKILL.md +71 -80
  55. package/adapters/codex/skills/release-verify/SKILL.md +11 -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 -788
  59. package/package.json +6 -2
  60. package/references/02-project-config.md +2 -2
  61. package/schemas/release-plan.schema.json +7 -1
  62. package/schemas/release-project.schema.json +8 -0
  63. package/scripts/build-bundle.mjs +133 -0
  64. package/skills/release-assess/SKILL.md +5 -6
  65. package/skills/release-help/SKILL.md +11 -18
  66. package/skills/release-prepare/SKILL.md +6 -6
  67. package/skills/release-publish/SKILL.md +1 -4
  68. package/skills/release-reconcile/SKILL.md +5 -5
  69. package/skills/release-setup/SKILL.md +64 -80
  70. package/skills/release-verify/SKILL.md +4 -7
  71. package/skills-src/release-assess/SKILL.md +5 -6
  72. package/skills-src/release-help/SKILL.md +11 -18
  73. package/skills-src/release-prepare/SKILL.md +6 -6
  74. package/skills-src/release-publish/SKILL.md +1 -4
  75. package/skills-src/release-reconcile/SKILL.md +5 -5
  76. package/skills-src/release-setup/SKILL.md +64 -80
  77. package/skills-src/release-verify/SKILL.md +4 -7
  78. package/src/adapters/npm.mjs +5 -13
  79. package/src/adapters/plugin-marketplace.mjs +67 -31
  80. package/src/artifacts/policy.mjs +4 -7
  81. package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
  82. package/src/commands/prepare.mjs +22 -1
  83. package/src/commands/publish.mjs +1 -0
  84. package/src/commands/setup.mjs +667 -28
  85. package/src/core/approval.mjs +4 -6
  86. package/src/core/config.mjs +8 -8
  87. package/src/core/pkg-root.mjs +22 -0
  88. package/src/core/plan.mjs +9 -4
  89. package/src/core/run.mjs +4 -4
  90. package/src/core/trusted-resource.mjs +96 -0
  91. package/src/docs/version-gate.mjs +164 -0
  92. package/src/producers/build-adapters.mjs +512 -55
  93. package/src/snapshot/frozen.mjs +133 -3
  94. package/src/snapshot/public-map.mjs +7 -4
  95. package/src/snapshot/scan.mjs +2 -1
@@ -17,7 +17,6 @@ import {
17
17
  realpath,
18
18
  } from 'node:fs/promises';
19
19
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
20
  import YAML from 'yaml';
22
21
  import Ajv from 'ajv';
23
22
  import addFormats from 'ajv-formats';
@@ -30,6 +29,7 @@ import {
30
29
  ReleaseError,
31
30
  SETUP_DIGEST_MISMATCH,
32
31
  } from '../core/errors.mjs';
32
+ import { readTrustedPackageResource } from '../core/trusted-resource.mjs';
33
33
 
34
34
  const execFile = promisify(execFileCb);
35
35
  const SKIP_DIRS = new Set([
@@ -39,8 +39,9 @@ const SKIP_DIRS = new Set([
39
39
  'runs', 'test', 'tests', 'test-fixtures', 'fixtures', 'examples',
40
40
  ]);
41
41
  const MAX_JSON_BYTES = 1024 * 1024;
42
- const __dirname = dirname(fileURLToPath(import.meta.url));
43
- const schema = JSON.parse(await readFile(resolve(__dirname, '..', '..', 'schemas', 'release-project.schema.json'), 'utf8'));
42
+ const schema = JSON.parse((await readTrustedPackageResource(
43
+ 'schemas/release-project.schema.json',
44
+ )).toString('utf8'));
44
45
  const ajv = new Ajv({ allErrors: true, strict: false });
45
46
  addFormats(ajv);
46
47
  const validateProjectConfig = ajv.compile(schema);
@@ -66,7 +67,7 @@ async function readJsonBounded(path, label) {
66
67
  }
67
68
  }
68
69
 
69
- async function walkDiscoveryFiles(root, maxDepth = 5) {
70
+ async function walkDiscoveryFiles(root, maxDepth = 8) {
70
71
  const found = [];
71
72
  async function walk(directory, depth) {
72
73
  if (depth > maxDepth) return;
@@ -81,6 +82,7 @@ async function walkDiscoveryFiles(root, maxDepth = 5) {
81
82
  child.isFile() &&
82
83
  (child.name === 'package.json' ||
83
84
  child.name === 'public-release.json' ||
85
+ child.name === 'SKILL.md' ||
84
86
  /^README(?:\.|$)/i.test(child.name) ||
85
87
  /^LICENSE(?:\.|$)/i.test(child.name) ||
86
88
  /^CHANGELOG(?:\.|$)/i.test(child.name) ||
@@ -213,15 +215,73 @@ function normalizeLegacyCommand(value) {
213
215
  }
214
216
 
215
217
  function classifyScript(name, command, unitId, distributionTypes) {
216
- const normalized = `${name} ${command}`.toLowerCase();
217
- const highCost = /(llm|real[-_: ]?smoke|end[-_: ]?to[-_: ]?end|\be2e\b|integration)/.test(normalized);
218
- const mayWrite = /(build|generate|update|fix|format|codegen)/.test(normalized);
219
- const networkLikely = /(llm|network|online|publish|release|deploy)/.test(normalized);
220
- const isSmoke = /smoke/.test(normalized);
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;
221
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
+
222
281
  return {
223
282
  id: `${unitId}-script-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`,
224
283
  script: name,
284
+ inspectedArgv,
225
285
  command: ['npm', 'run', name],
226
286
  recommendedPhase: isSmoke ? 'consumer-verify' : 'snapshot-verify',
227
287
  scope: {
@@ -233,12 +293,15 @@ function classifyScript(name, command, unitId, distributionTypes) {
233
293
  ? { distributionCandidates: [...distributionTypes] }
234
294
  : {}
235
295
  ),
236
- cost: highCost ? 'high' : /test|smoke/.test(normalized) ? 'medium' : 'low',
296
+ cost: highCost ? 'high' : /test|smoke/.test(normalizedName) ? 'medium' : 'low',
237
297
  sideEffects: {
238
298
  mayWriteFiles: mayWrite,
239
299
  networkLikely,
300
+ interactive,
240
301
  unsandboxed: true,
241
302
  },
303
+ eligibleForRecommendation,
304
+ ...(ineligibilityReason ? { ineligibilityReason } : {}),
242
305
  reason: isSmoke
243
306
  ? '脚本名称表明它可能验证安装后的实际使用;必须人工确认后才能注册。'
244
307
  : '项目已声明质量脚本,可在冻结快照副本上复用;不会自动注册。',
@@ -272,6 +335,86 @@ async function discoverGit(root) {
272
335
  head: await run(['rev-parse', 'HEAD']) || null,
273
336
  tags: (await run(['tag', '--list'])).split('\n').filter(Boolean).sort(),
274
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
+ },
275
418
  };
276
419
  }
277
420
 
@@ -328,12 +471,181 @@ async function discoverFacts(root) {
328
471
  legacyReleaseConfigs.push(summarizeLegacyReleaseConfig(value, safeRelative(root, path)));
329
472
  }
330
473
 
331
- return { git: await discoverGit(root), packages, manifests, legacyReleaseConfigs, fileDigests };
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));
332
643
  }
333
644
 
334
645
  function buildCandidates(facts) {
335
646
  const gitRepos = facts.git.remotes.map((remote) => remote.repo).filter(Boolean);
336
647
  const uniqueGitRepos = [...new Set(gitRepos)];
648
+ const unitGit = facts.unitGit ?? {};
337
649
  const units = [];
338
650
  const gates = [];
339
651
  const ids = new Set();
@@ -376,28 +688,76 @@ function buildCandidates(facts) {
376
688
  const npmExplicitlyForbidden = matchingLegacyUnits.some((unit) => (
377
689
  unit.npmPackageDeclared && unit.npmPackage === null
378
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.
379
694
  if (
380
695
  !pkg.private &&
381
696
  pkg.name &&
382
- !npmExplicitlyForbidden &&
383
- (!legacyChannelsAreAuthoritative || npmExplicitlyDeclared)
697
+ !npmExplicitlyForbidden
384
698
  ) distributions.push('npm');
385
699
  if (pluginHosts.includes('claude')) distributions.push('claude-plugin');
386
700
  if (pluginHosts.includes('codex')) distributions.push('codex-plugin');
387
701
  if (pkg.private && matchingLegacyUnits.length === 0 && facts.legacyReleaseConfigs.length > 0) continue;
388
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;
389
740
  const repositoryCandidates = [...new Set([
390
- pkg.repository,
391
- ...matchingLegacyUnits.map((unit) => unit.publicRepo),
392
- ...uniqueGitRepos,
741
+ ...allAuthorityRepos,
742
+ ...fallbackGitRepos,
393
743
  ].filter(Boolean))];
394
744
  const legacyTagTemplates = matchingLegacyUnits
395
745
  .map((unit) => unit.tagPrefix ? `${unit.tagPrefix}{version}` : null)
396
746
  .filter(Boolean);
397
747
  const branchCandidates = [...new Set([
398
748
  ...legacyDefaultBranches,
399
- facts.git.branch,
749
+ unitGitEntry?.branch,
400
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();
401
761
  units.push({
402
762
  id,
403
763
  source: pkg.directory,
@@ -407,8 +767,8 @@ function buildCandidates(facts) {
407
767
  distributionCandidates: distributions,
408
768
  tagTemplateCandidates: [...new Set([
409
769
  ...legacyTagTemplates,
410
- ...(facts.git.tags.some((tag) => pkg.version && tag === `v${pkg.version}`) ? ['v{version}'] : []),
411
- ...(facts.git.tags.some((tag) => pkg.version && tag === `${id}-v${pkg.version}`)
770
+ ...(unitTags.some((tag) => pkg.version && tag === `v${pkg.version}`) ? ['v{version}'] : []),
771
+ ...(unitTags.some((tag) => pkg.version && tag === `${id}-v${pkg.version}`)
412
772
  ? [`${id}-v{version}`]
413
773
  : []),
414
774
  ])],
@@ -418,7 +778,7 @@ function buildCandidates(facts) {
418
778
  : [],
419
779
  previousPublicBaselineStatus: repositoryCandidates.length === 0
420
780
  ? 'CHANNEL_MISSING'
421
- : facts.git.tags.length > 0
781
+ : unitTags.length > 0
422
782
  ? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
423
783
  : 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION',
424
784
  publicFileCandidates: [
@@ -432,6 +792,18 @@ function buildCandidates(facts) {
432
792
  ].filter((value, index, array) => array.indexOf(value) === index && knownFiles.has(value)).sort(),
433
793
  legacyPublicFileHints: matchingLegacyUnits.flatMap((unit) => unit.requiredPathCandidates).sort(),
434
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
+ } : {}),
435
807
  });
436
808
  for (const [script, command] of Object.entries(pkg.scripts)) {
437
809
  if (/^(docs|build|test|typecheck|lint|check|validate|verify|smoke)(?:$|[:_-])/.test(script)) {
@@ -450,6 +822,10 @@ function buildCandidates(facts) {
450
822
  cost: 'medium',
451
823
  sideEffects: { mayWriteFiles: true, networkLikely: false, unsandboxed: true },
452
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',
453
829
  reason: '旧发布配置声明了快照校验;迁移为 gate 前必须人工确认命令数组、副作用和耗时。',
454
830
  });
455
831
  });
@@ -469,6 +845,11 @@ function buildCandidates(facts) {
469
845
  let suffix = 2;
470
846
  while (ids.has(id)) id = `${baseId}-${suffix++}`;
471
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}/`;
472
853
  units.push({
473
854
  id,
474
855
  source: pluginRoot,
@@ -486,10 +867,20 @@ function buildCandidates(facts) {
486
867
  ? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
487
868
  : 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION')
488
869
  : 'CHANNEL_MISSING',
489
- publicFileCandidates: [...knownFiles]
490
- .filter((path) => pluginRoot === '.' || path.startsWith(`${pluginRoot}/`))
491
- .filter((path) => /(?:README|LICENSE|CHANGELOG|plugin\.json|marketplace\.json)/i.test(path))
492
- .sort(),
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(),
493
884
  });
494
885
  }
495
886
  units.sort((a, b) => a.id.localeCompare(b.id));
@@ -497,6 +888,183 @@ function buildCandidates(facts) {
497
888
  return { units, gates };
498
889
  }
499
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
+
500
1068
  function buildDecisionsRequired(candidates, localOnly) {
501
1069
  const decisions = [];
502
1070
  if (localOnly) {
@@ -532,6 +1100,50 @@ function buildDecisionsRequired(candidates, localOnly) {
532
1100
  return decisions;
533
1101
  }
534
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
+
535
1147
  function validateAnswers(answers, gateCandidates) {
536
1148
  if (!answers || typeof answers !== 'object' || Array.isArray(answers)) {
537
1149
  throw setupError(CONFIG_INVALID, 'setup answers must be a JSON object');
@@ -728,7 +1340,7 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
728
1340
  .map((gate) => gate.id)
729
1341
  .filter((id) => !configuredGateIds.includes(id))
730
1342
  .sort();
731
- return {
1343
+ const existingReport = {
732
1344
  setupVersion: 1,
733
1345
  status: 'ALREADY_CONFIGURED',
734
1346
  configPath,
@@ -756,8 +1368,14 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
756
1368
  : []),
757
1369
  ],
758
1370
  },
1371
+ recommendedGateIds: [],
1372
+ proposalConflicts: [],
1373
+ proposalAssumptions: [],
1374
+ productionReadiness: 'ASSESS_REQUIRED',
759
1375
  next: '运行 release-skill assess 审计已有配置;需要调整时依据建议人工增量编辑。',
760
1376
  };
1377
+ existingReport.compactSummary = buildCompactSummary(existingReport);
1378
+ return existingReport;
761
1379
  }
762
1380
 
763
1381
  const facts = await discoverFacts(rootReal);
@@ -778,18 +1396,33 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
778
1396
  projectConfig: answers?.projectConfig ?? null,
779
1397
  };
780
1398
  const setupDigest = sha256Hex(canonicalJson(digestAuthority));
781
- const hasDiscoveredRemoteChannel = facts.git.remotes.some((remote) => remote.repo) ||
782
- facts.packages.some((pkg) => pkg.publishRegistry);
1399
+ const hasDiscoveredRemoteChannel = candidates.units.some((unit) => (
1400
+ unit.publicRepoCandidates.length > 0
1401
+ )) || facts.packages.some((pkg) => pkg.publishRegistry);
783
1402
  const status = answers
784
1403
  ? 'READY_TO_WRITE'
785
1404
  : hasDiscoveredRemoteChannel
786
1405
  ? 'NEEDS_INPUT'
787
1406
  : 'LOCAL_ONLY_DETECTED';
788
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);
789
1418
  const report = {
790
1419
  ...digestAuthority,
791
1420
  status,
792
1421
  setupDigest,
1422
+ recommendedGateIds,
1423
+ recommendedAnswers: recommendedProposal.answers,
1424
+ proposalConflicts: recommendedProposal.conflicts,
1425
+ proposalAssumptions: recommendedProposal.assumptions,
793
1426
  productionReadiness: status === 'LOCAL_ONLY_DETECTED'
794
1427
  ? 'LOCAL_ONLY'
795
1428
  : answers
@@ -803,6 +1436,9 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
803
1436
  overwrite: false,
804
1437
  },
805
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);
806
1442
 
807
1443
  if (!write) return report;
808
1444
  if (!answers) throw setupError(CONFIG_INVALID, 'setup --write requires --answers <json>');
@@ -873,9 +1509,10 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
873
1509
  } finally {
874
1510
  await lock.release();
875
1511
  }
876
- return {
1512
+ const createdReport = {
877
1513
  ...report,
878
1514
  status: 'CONFIG_CREATED',
1515
+ productionReadiness: 'ASSESS_REQUIRED',
879
1516
  configPath: committedConfig.path,
880
1517
  configSha256: committedConfig.configSha256,
881
1518
  committedSetupDigest: committedConfig.commitAuthority.setupDigest,
@@ -883,4 +1520,6 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
883
1520
  committedAnswersDigest: committedConfig.commitAuthority.answersDigest,
884
1521
  next: '运行 release-skill assess;再根据 gate 副作用决定 prepare/verify 的显式授权。',
885
1522
  };
1523
+ createdReport.compactSummary = buildCompactSummary(createdReport);
1524
+ return createdReport;
886
1525
  }