release-skill 0.9.1 → 0.9.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +78 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +56 -26
  10. package/README.zh-CN.md +47 -23
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/consumer-contract-vectors.json +7 -7
  14. package/adapters/claude/bin/error-codes.json +1 -1
  15. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  16. package/adapters/claude/bin/registry.json +22 -1
  17. package/adapters/claude/bin/release-skill.bundle.mjs +4564 -1997
  18. package/adapters/claude/bin/rules.json +1 -1
  19. package/adapters/claude/schemas/release-project.schema.json +4 -0
  20. package/adapters/claude/schemas/release-run.schema.json +41 -1
  21. package/adapters/claude/skills/release-config/SKILL.md +5 -3
  22. package/adapters/claude/skills/release-docs/SKILL.md +4 -2
  23. package/adapters/claude/skills/release-finish/SKILL.md +2 -0
  24. package/adapters/claude/skills/release-help/SKILL.md +18 -3
  25. package/adapters/claude/skills/release-marketplace/SKILL.md +3 -1
  26. package/adapters/claude/skills/release-prepare/SKILL.md +12 -2
  27. package/adapters/claude/skills/release-publish/SKILL.md +17 -10
  28. package/adapters/claude/skills/release-verify/SKILL.md +2 -0
  29. package/adapters/claude/src/schemas/executable-identity-observation.schema.json +162 -0
  30. package/adapters/claude/src/schemas/filesystem-tree-observation.schema.json +18 -2
  31. package/adapters/claude/src/schemas/plugin-verification-request.schema.json +147 -22
  32. package/adapters/claude/src/schemas/plugin-verification-result.schema.json +436 -18
  33. package/adapters/claude/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  34. package/adapters/claude/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  35. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  36. package/adapters/codex/bin/consumer-contract-vectors.json +7 -7
  37. package/adapters/codex/bin/error-codes.json +1 -1
  38. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  39. package/adapters/codex/bin/registry.json +22 -1
  40. package/adapters/codex/bin/release-skill.bundle.mjs +4564 -1997
  41. package/adapters/codex/bin/rules.json +1 -1
  42. package/adapters/codex/schemas/release-project.schema.json +4 -0
  43. package/adapters/codex/schemas/release-run.schema.json +41 -1
  44. package/adapters/codex/skills/release-config/SKILL.md +5 -3
  45. package/adapters/codex/skills/release-docs/SKILL.md +4 -2
  46. package/adapters/codex/skills/release-finish/SKILL.md +2 -0
  47. package/adapters/codex/skills/release-help/SKILL.md +18 -3
  48. package/adapters/codex/skills/release-marketplace/SKILL.md +3 -1
  49. package/adapters/codex/skills/release-prepare/SKILL.md +12 -2
  50. package/adapters/codex/skills/release-publish/SKILL.md +17 -10
  51. package/adapters/codex/skills/release-verify/SKILL.md +2 -0
  52. package/adapters/codex/src/schemas/executable-identity-observation.schema.json +162 -0
  53. package/adapters/codex/src/schemas/filesystem-tree-observation.schema.json +18 -2
  54. package/adapters/codex/src/schemas/plugin-verification-request.schema.json +147 -22
  55. package/adapters/codex/src/schemas/plugin-verification-result.schema.json +436 -18
  56. package/adapters/codex/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  57. package/adapters/codex/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  58. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  59. package/adapters/kimi/bin/consumer-contract-vectors.json +7 -7
  60. package/adapters/kimi/bin/error-codes.json +1 -1
  61. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  62. package/adapters/kimi/bin/registry.json +22 -1
  63. package/adapters/kimi/bin/release-skill.bundle.mjs +4564 -1997
  64. package/adapters/kimi/bin/rules.json +1 -1
  65. package/adapters/kimi/schemas/release-project.schema.json +4 -0
  66. package/adapters/kimi/schemas/release-run.schema.json +41 -1
  67. package/adapters/kimi/skills/release-config/SKILL.md +5 -3
  68. package/adapters/kimi/skills/release-docs/SKILL.md +4 -2
  69. package/adapters/kimi/skills/release-finish/SKILL.md +2 -0
  70. package/adapters/kimi/skills/release-help/SKILL.md +18 -3
  71. package/adapters/kimi/skills/release-marketplace/SKILL.md +3 -1
  72. package/adapters/kimi/skills/release-prepare/SKILL.md +12 -2
  73. package/adapters/kimi/skills/release-publish/SKILL.md +17 -10
  74. package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
  75. package/adapters/kimi/src/schemas/executable-identity-observation.schema.json +162 -0
  76. package/adapters/kimi/src/schemas/filesystem-tree-observation.schema.json +18 -2
  77. package/adapters/kimi/src/schemas/plugin-verification-request.schema.json +147 -22
  78. package/adapters/kimi/src/schemas/plugin-verification-result.schema.json +436 -18
  79. package/adapters/kimi/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  80. package/adapters/kimi/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  81. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  82. package/adapters/workbuddy/bin/consumer-contract-vectors.json +7 -7
  83. package/adapters/workbuddy/bin/error-codes.json +1 -1
  84. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  85. package/adapters/workbuddy/bin/registry.json +22 -1
  86. package/adapters/workbuddy/bin/release-skill.bundle.mjs +4564 -1997
  87. package/adapters/workbuddy/bin/rules.json +1 -1
  88. package/adapters/workbuddy/schemas/release-project.schema.json +4 -0
  89. package/adapters/workbuddy/schemas/release-run.schema.json +41 -1
  90. package/adapters/workbuddy/skills/release-config/SKILL.md +5 -3
  91. package/adapters/workbuddy/skills/release-docs/SKILL.md +4 -2
  92. package/adapters/workbuddy/skills/release-finish/SKILL.md +2 -0
  93. package/adapters/workbuddy/skills/release-help/SKILL.md +18 -3
  94. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +3 -1
  95. package/adapters/workbuddy/skills/release-prepare/SKILL.md +12 -2
  96. package/adapters/workbuddy/skills/release-publish/SKILL.md +17 -10
  97. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
  98. package/adapters/workbuddy/src/schemas/executable-identity-observation.schema.json +162 -0
  99. package/adapters/workbuddy/src/schemas/filesystem-tree-observation.schema.json +18 -2
  100. package/adapters/workbuddy/src/schemas/plugin-verification-request.schema.json +147 -22
  101. package/adapters/workbuddy/src/schemas/plugin-verification-result.schema.json +436 -18
  102. package/adapters/workbuddy/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  103. package/adapters/workbuddy/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  104. package/bin/consumer-contract-vectors.json +7 -7
  105. package/bin/error-codes.json +1 -1
  106. package/bin/foundation-resource-binding.json +1 -1
  107. package/bin/registry.json +22 -1
  108. package/bin/release-skill-cli.mjs +58 -2
  109. package/bin/release-skill.bundle.mjs +4564 -1997
  110. package/bin/rules.json +1 -1
  111. package/package.json +5 -5
  112. package/platform-manifest.json +4 -4
  113. package/references/.render-manifest.json +4 -4
  114. package/references/01-state-machine.md +34 -1
  115. package/references/02-project-config.md +20 -0
  116. package/references/05-evidence-and-errors.md +2 -2
  117. package/schemas/release-project.schema.json +4 -0
  118. package/schemas/release-run.schema.json +41 -1
  119. package/skills/release-config/SKILL.md +5 -3
  120. package/skills/release-docs/SKILL.md +4 -2
  121. package/skills/release-finish/SKILL.md +2 -0
  122. package/skills/release-help/SKILL.md +18 -3
  123. package/skills/release-marketplace/SKILL.md +3 -1
  124. package/skills/release-prepare/SKILL.md +12 -2
  125. package/skills/release-publish/SKILL.md +17 -10
  126. package/skills/release-verify/SKILL.md +2 -0
  127. package/skills-src/release-config/SKILL.md +5 -3
  128. package/skills-src/release-docs/SKILL.md +4 -2
  129. package/skills-src/release-finish/SKILL.md +2 -0
  130. package/skills-src/release-help/SKILL.md +18 -3
  131. package/skills-src/release-marketplace/SKILL.md +3 -1
  132. package/skills-src/release-prepare/SKILL.md +12 -2
  133. package/skills-src/release-publish/SKILL.md +17 -10
  134. package/skills-src/release-verify/SKILL.md +2 -0
  135. package/src/commands/post-release-local.mjs +169 -16
  136. package/src/commands/prepare.mjs +321 -11
  137. package/src/commands/route.mjs +597 -18
  138. package/src/commands/setup.mjs +2 -0
  139. package/src/commands/ship.mjs +183 -2
  140. package/src/commands/verify.mjs +21 -0
  141. package/src/core/adoption-assessment.mjs +59 -0
  142. package/src/core/foundation-plugin-verification.mjs +233 -1
  143. package/src/core/hook-cache.mjs +318 -26
  144. package/src/core/hooks.mjs +8 -1
  145. package/src/producers/foundation-resource-projection.mjs +3 -3
  146. package/src/schemas/executable-identity-observation.schema.json +162 -0
  147. package/src/schemas/filesystem-tree-observation.schema.json +18 -2
  148. package/src/schemas/plugin-verification-request.schema.json +147 -22
  149. package/src/schemas/plugin-verification-result.schema.json +436 -18
  150. package/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  151. package/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
@@ -46,6 +46,7 @@ import {
46
46
  deriveGateSuggestions,
47
47
  deriveHookDurations,
48
48
  deriveLongHookSuggestions,
49
+ derivePreHookPublicSurfaceFinding,
49
50
  scanGateDeclarationFindings,
50
51
  FINDING_CATEGORY,
51
52
  ASSESSMENT_STATUS,
@@ -3209,6 +3210,7 @@ export async function assessAdoption({ root } = {}) {
3209
3210
  currentProducerVersion: resolveProducerVersion(),
3210
3211
  });
3211
3212
  findings.push(...deriveLongHookSuggestions(hookDurations));
3213
+ findings.push(derivePreHookPublicSurfaceFinding({ config, hookDurations }));
3212
3214
  findings.push(...deriveCheckOnlySuggestions(config.hooks));
3213
3215
 
3214
3216
  // --- Gate suggestions (scenarios 5 & 6) ---
@@ -15,11 +15,92 @@ import {
15
15
  } from './post-release-local.mjs';
16
16
  import {
17
17
  ReleaseError,
18
+ CONFIG_INVALID,
18
19
  GATE_FAILED,
19
20
  PLAN_DIGEST_MISMATCH,
20
21
  CONSUMER_VERIFICATION_DEFERRED,
21
22
  } from '../core/errors.mjs';
22
23
 
24
+ function normalizeNewShipUnitIds(releaseUnits, requestedUnitIds) {
25
+ if (requestedUnitIds === undefined) return undefined;
26
+ const availableUnitIds = (releaseUnits ?? []).map((unit) => unit.id);
27
+ if (!Array.isArray(requestedUnitIds) || requestedUnitIds.length === 0) {
28
+ throw new ReleaseError(
29
+ CONFIG_INVALID,
30
+ 'explicit ship release-unit selection must contain at least one unit id',
31
+ { requestedUnitIds, availableUnitIds },
32
+ );
33
+ }
34
+ const requestedSet = new Set(requestedUnitIds);
35
+ const invalid = requestedUnitIds.filter((unitId) => typeof unitId !== 'string' || unitId.length === 0);
36
+ const unknown = requestedUnitIds.filter((unitId) => !availableUnitIds.includes(unitId));
37
+ if (invalid.length > 0 || requestedSet.size !== requestedUnitIds.length || unknown.length > 0) {
38
+ throw new ReleaseError(
39
+ CONFIG_INVALID,
40
+ 'ship release-unit selection contains empty, duplicate, or unknown unit ids',
41
+ { requestedUnitIds, availableUnitIds },
42
+ );
43
+ }
44
+ return availableUnitIds.filter((unitId) => requestedSet.has(unitId));
45
+ }
46
+
47
+ function sameUnitSelection(left, right) {
48
+ return Array.isArray(left)
49
+ && Array.isArray(right)
50
+ && left.length === right.length
51
+ && new Set(left).size === left.length
52
+ && new Set(right).size === right.length
53
+ && left.every((unitId) => right.includes(unitId));
54
+ }
55
+
56
+ function sameUnitOrder(left, right) {
57
+ return Array.isArray(left)
58
+ && Array.isArray(right)
59
+ && left.length === right.length
60
+ && left.every((unitId, index) => unitId === right[index]);
61
+ }
62
+
63
+ function unitScopeMismatch(statePath, stateUnitIds, requestedUnitIds, message) {
64
+ return new ReleaseError(
65
+ GATE_FAILED,
66
+ message,
67
+ {
68
+ reason: 'SHIP_STATE_UNIT_SCOPE_MISMATCH',
69
+ statePath,
70
+ stateUnitIds: stateUnitIds ?? null,
71
+ requestedUnitIds: requestedUnitIds ?? null,
72
+ },
73
+ );
74
+ }
75
+
76
+ function assertFrozenPlanUnitScope({ statePath, stateUnitIds, expectedUnitIds, plan }) {
77
+ const planUnitIds = (plan.units ?? []).map((unit) => unit.id);
78
+ const exactMatch = Array.isArray(expectedUnitIds)
79
+ && planUnitIds.length === expectedUnitIds.length
80
+ && planUnitIds.every((unitId, index) => unitId === expectedUnitIds[index]);
81
+ if (!exactMatch) {
82
+ throw unitScopeMismatch(
83
+ statePath,
84
+ stateUnitIds,
85
+ planUnitIds,
86
+ 'ship state release-unit scope does not match frozen plan.units',
87
+ );
88
+ }
89
+ }
90
+
91
+ async function readFrozenPlanForUnitScope(planPath, statePath, stateUnitIds) {
92
+ try {
93
+ return JSON.parse(await readFile(planPath, 'utf8'));
94
+ } catch (error) {
95
+ throw unitScopeMismatch(
96
+ statePath,
97
+ stateUnitIds,
98
+ null,
99
+ `cannot verify ship state release-unit scope against frozen plan: ${error.message}`,
100
+ );
101
+ }
102
+ }
103
+
23
104
  async function writeJsonAtomic(path, value) {
24
105
  await mkdir(dirname(path), { recursive: true });
25
106
  const { stateDigest: _oldDigest, ...body } = value;
@@ -102,6 +183,7 @@ function publicState(state) {
102
183
  ...(state.hooks && state.hooks.length > 0 ? {
103
184
  hooks: state.hooks,
104
185
  } : {}),
186
+ ...(state.selectedUnitIds ? { selectedUnitIds: state.selectedUnitIds } : {}),
105
187
  ...(state.planPath ? {
106
188
  planPath: state.planPath,
107
189
  planDigest: state.planDigest,
@@ -315,6 +397,43 @@ export async function advanceShip(options = {}, injected = {}) {
315
397
  ? injected
316
398
  : await defaultDependencies();
317
399
  let state = await readState(statePath);
400
+ const requestedTargetVersion = options.targetVersion ?? null;
401
+ if (state && requestedTargetVersion !== null) {
402
+ const stateTargetVersion = state.targetVersion ?? null;
403
+ if (stateTargetVersion === null || stateTargetVersion !== requestedTargetVersion) {
404
+ const suffix = stateTargetVersion === null
405
+ ? 'state targetVersion is missing; provide a new --state path to start the requested version'
406
+ : `state targetVersion ${stateTargetVersion} does not match requested targetVersion ${requestedTargetVersion}`;
407
+ throw new ReleaseError(
408
+ GATE_FAILED,
409
+ `ship state target version mismatch: ${suffix}`,
410
+ {
411
+ reason: 'SHIP_STATE_TARGET_VERSION_MISMATCH',
412
+ statePath,
413
+ stateTargetVersion,
414
+ requestedTargetVersion,
415
+ },
416
+ );
417
+ }
418
+ }
419
+ if (state && options.unitIds !== undefined) {
420
+ if (!Array.isArray(state.selectedUnitIds)) {
421
+ throw unitScopeMismatch(
422
+ statePath,
423
+ null,
424
+ options.unitIds,
425
+ 'an existing full-scope or legacy ship state cannot acquire --unit; provide a new --state path',
426
+ );
427
+ }
428
+ if (!sameUnitSelection(state.selectedUnitIds, options.unitIds)) {
429
+ throw unitScopeMismatch(
430
+ statePath,
431
+ state.selectedUnitIds,
432
+ options.unitIds,
433
+ 'ship state release-unit scope does not match the requested --unit values',
434
+ );
435
+ }
436
+ }
318
437
  if (state?.gitTransport) {
319
438
  process.env.RELEASE_SKILL_GIT_TRANSPORT = state.gitTransport;
320
439
  }
@@ -322,6 +441,7 @@ export async function advanceShip(options = {}, injected = {}) {
322
441
  if (!state) {
323
442
  const loaded = await deps.loadProjectConfig({ root });
324
443
  const hooks = Object.keys(loaded.config.hooks ?? {}).sort();
444
+ const selectedUnitIds = normalizeNewShipUnitIds(loaded.config.releaseUnits, options.unitIds);
325
445
  state = {
326
446
  schemaVersion: 2,
327
447
  root,
@@ -329,6 +449,7 @@ export async function advanceShip(options = {}, injected = {}) {
329
449
  targetVersion: options.targetVersion ?? null,
330
450
  configDigest: loaded.configDigest,
331
451
  hooks,
452
+ ...(selectedUnitIds ? { selectedUnitIds } : {}),
332
453
  status: 'NEW',
333
454
  createdAt: new Date().toISOString(),
334
455
  updatedAt: new Date().toISOString(),
@@ -345,8 +466,53 @@ export async function advanceShip(options = {}, injected = {}) {
345
466
  await writeJsonAtomic(statePath, state);
346
467
  }
347
468
 
469
+ if (state.planPath && Array.isArray(state.selectedUnitIds)) {
470
+ const frozenPlan = await readFrozenPlanForUnitScope(
471
+ state.planPath,
472
+ statePath,
473
+ state.selectedUnitIds,
474
+ );
475
+ assertFrozenPlanUnitScope({
476
+ statePath,
477
+ stateUnitIds: state.selectedUnitIds,
478
+ expectedUnitIds: state.selectedUnitIds,
479
+ plan: frozenPlan,
480
+ });
481
+ }
482
+
348
483
  if (state.status === 'NEW') {
349
484
  const loaded = await deps.loadProjectConfig({ root });
485
+ let stateChanged = false;
486
+ if (Array.isArray(state.selectedUnitIds)) {
487
+ const normalizedStateUnitIds = normalizeNewShipUnitIds(
488
+ loaded.config.releaseUnits,
489
+ state.selectedUnitIds,
490
+ );
491
+ if (!sameUnitSelection(normalizedStateUnitIds, state.selectedUnitIds)) {
492
+ throw unitScopeMismatch(
493
+ statePath,
494
+ state.selectedUnitIds,
495
+ normalizedStateUnitIds,
496
+ 'ship state release-unit scope no longer matches project configuration',
497
+ );
498
+ }
499
+ if (!sameUnitOrder(normalizedStateUnitIds, state.selectedUnitIds)) {
500
+ if (state.planPath) {
501
+ throw unitScopeMismatch(
502
+ statePath,
503
+ state.selectedUnitIds,
504
+ normalizedStateUnitIds,
505
+ 'frozen ship state release-unit order no longer matches project configuration',
506
+ );
507
+ }
508
+ state = {
509
+ ...state,
510
+ selectedUnitIds: normalizedStateUnitIds,
511
+ updatedAt: new Date().toISOString(),
512
+ };
513
+ stateChanged = true;
514
+ }
515
+ }
350
516
  if (loaded.configDigest !== state.configDigest) {
351
517
  const hooks = Object.keys(loaded.config.hooks ?? {}).sort();
352
518
  state = {
@@ -356,8 +522,9 @@ export async function advanceShip(options = {}, injected = {}) {
356
522
  status: 'NEW',
357
523
  updatedAt: new Date().toISOString(),
358
524
  };
359
- await writeJsonAtomic(statePath, state);
525
+ stateChanged = true;
360
526
  }
527
+ if (stateChanged) await writeJsonAtomic(statePath, state);
361
528
  const prepared = await deps.prepareRelease({
362
529
  root,
363
530
  version: state.targetVersion ?? undefined,
@@ -366,10 +533,24 @@ export async function advanceShip(options = {}, injected = {}) {
366
533
  hooksAuthorized: true,
367
534
  verificationGatesAuthorized: true,
368
535
  hookCache: true,
536
+ ...(state.selectedUnitIds ? { unitIds: state.selectedUnitIds } : {}),
369
537
  });
538
+ let frozenPlan = null;
539
+ if (Array.isArray(state.selectedUnitIds) || deps.preflightGitTransports) {
540
+ frozenPlan = Array.isArray(state.selectedUnitIds)
541
+ ? await readFrozenPlanForUnitScope(prepared.planPath, statePath, state.selectedUnitIds)
542
+ : JSON.parse(await readFile(prepared.planPath, 'utf8'));
543
+ }
544
+ if (Array.isArray(state.selectedUnitIds)) {
545
+ assertFrozenPlanUnitScope({
546
+ statePath,
547
+ stateUnitIds: state.selectedUnitIds,
548
+ expectedUnitIds: state.selectedUnitIds,
549
+ plan: frozenPlan,
550
+ });
551
+ }
370
552
  let transportPreflight = null;
371
553
  if (deps.preflightGitTransports) {
372
- const frozenPlan = JSON.parse(await readFile(prepared.planPath, 'utf8'));
373
554
  transportPreflight = await deps.preflightGitTransports(frozenPlan);
374
555
  process.env.RELEASE_SKILL_GIT_TRANSPORT = transportPreflight.transport;
375
556
  }
@@ -1045,6 +1045,27 @@ export async function verifyRelease(options) {
1045
1045
  requireDigest: Boolean(plan.production),
1046
1046
  ...(plan.production ? { authorityPlanPath: planPath } : {}),
1047
1047
  });
1048
+
1049
+ // `distribute` runs are deliberately not valid verify sources. Report the
1050
+ // actionable lineage correction before the generic lineage validator's
1051
+ // unsupported-command error, while keeping the existing source authority
1052
+ // and automatic distribute discovery semantics unchanged.
1053
+ if (sourceRun.command === 'distribute') {
1054
+ throw new ReleaseError(
1055
+ GATE_FAILED,
1056
+ 'verify --run must point to a PUBLISHED publish or reconcile run from the same plan; distribute runs are automatically discovered by verify and do not become the verify authority',
1057
+ {
1058
+ sourceRunCommand: sourceRun.command,
1059
+ sourceRunId: sourceRun.runId,
1060
+ sourceRunStatus: sourceRun.status,
1061
+ expectedSourceCommands: ['publish', 'reconcile'],
1062
+ requiredSourceStatus: 'PUBLISHED',
1063
+ distributeRunsAutoDiscovered: true,
1064
+ verifyAuthority: 'publish-or-reconcile-source-run',
1065
+ },
1066
+ );
1067
+ }
1068
+
1048
1069
  await validateRunLineage(sourceRun, {
1049
1070
  plan,
1050
1071
  planPath,
@@ -506,6 +506,65 @@ export function deriveLongHookSuggestions(hookDurations) {
506
506
  return findings;
507
507
  }
508
508
 
509
+ /**
510
+ * Report the opt-in R93-01 pre-hook public-surface check without changing the
511
+ * base adoption conclusion. A candidate is based only on hookDurations that
512
+ * already passed the current-producer, command/cwd-matching and real
513
+ * started/completed-pair checks above; timeout, cache hits and labels are not
514
+ * consulted here.
515
+ *
516
+ * @param {object} options
517
+ * @param {object} options.config - Loaded project configuration.
518
+ * @param {Array<object>} options.hookDurations - Trusted duration facts.
519
+ * @returns {object} One adoption finding for the policy field.
520
+ */
521
+ export function derivePreHookPublicSurfaceFinding({ config, hookDurations }) {
522
+ const fieldPath = 'policy.preHookPublicSurfaceCheck';
523
+ if (config?.policy?.preHookPublicSurfaceCheck === true) {
524
+ return createFinding({
525
+ category: FINDING_CATEGORY.SATISFIED,
526
+ code: 'PRE_HOOK_PUBLIC_SURFACE_CHECK_ADOPTED',
527
+ fieldPath,
528
+ message: '已启用 Hook 前公开表面检查;prepare 会在首个 Hook 前验证候选公开表面,并在 Hook 后重新执行正式检查。',
529
+ evidence: { enabled: true },
530
+ });
531
+ }
532
+
533
+ const candidate = (hookDurations ?? [])
534
+ .filter((duration) => (
535
+ duration?.basis === 'evidence'
536
+ && Number.isFinite(duration.durationMs)
537
+ && duration.durationMs >= LONG_HOOK_THRESHOLD_MS
538
+ && duration.realRuns >= 1
539
+ ))
540
+ .sort((a, b) => (
541
+ (b.durationMs - a.durationMs) || a.hookName.localeCompare(b.hookName)
542
+ ))[0];
543
+ if (candidate) {
544
+ return createFinding({
545
+ category: FINDING_CATEGORY.OPTIONAL_SUGGESTION,
546
+ code: 'PRE_HOOK_PUBLIC_SURFACE_CHECK_CANDIDATE',
547
+ fieldPath,
548
+ message: `当前 hook "${candidate.hookName}" 有可信的实际运行证据(至少 30 分钟);可人工评估是否启用 Hook 前公开表面检查。`,
549
+ evidence: {
550
+ hookName: candidate.hookName,
551
+ durationMs: candidate.durationMs,
552
+ realRuns: candidate.realRuns,
553
+ lastObservedAt: candidate.lastObservedAt,
554
+ },
555
+ action: '确认公开文件不会由 Hook 生成或修复后,再在配置中将 policy.preHookPublicSurfaceCheck 设为 true;评估不会自动修改配置。',
556
+ severity: 'suggestion',
557
+ });
558
+ }
559
+
560
+ return createFinding({
561
+ category: FINDING_CATEGORY.NOT_APPLICABLE,
562
+ code: 'PRE_HOOK_PUBLIC_SURFACE_CHECK_NOT_APPLICABLE',
563
+ fieldPath,
564
+ message: '当前没有可信且匹配当前 Hook 的至少 30 分钟实际运行证据,暂不建议启用 Hook 前公开表面检查。',
565
+ });
566
+ }
567
+
509
568
  const STATUS_LABEL = Object.freeze({
510
569
  NOT_CONFIGURED: '尚未配置',
511
570
  PARTIALLY_ADOPTED: '部分接入(存在必选缺口)',
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Release-domain thin adapter for Foundation 0.13 complete-plugin observation.
2
+ * Release-domain thin adapter for Foundation 0.15 complete-plugin observation.
3
3
  *
4
4
  * The frozen snapshot remains the only payload authority. This module derives
5
5
  * transient Foundation inputs from that tree, calls the released Kit API, and
@@ -19,6 +19,8 @@ import {
19
19
  } from 'skill-family-engineering-kit';
20
20
  import {
21
21
  createFilesystemRootBinding,
22
+ digestBytes,
23
+ observeFilesystemTree,
22
24
  readFileBound,
23
25
  } from 'skill-family-harness-node';
24
26
 
@@ -30,6 +32,196 @@ import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
30
32
  // contract at 1.0.0. The real API integration test intentionally exercises
31
33
  // this value so an upstream contract change fails closed during adoption.
32
34
  const FOUNDATION_PLUGIN_DRIVER_VERSION = '1.0.0';
35
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
36
+
37
+ function isSafeRelativePath(value) {
38
+ return typeof value === 'string'
39
+ && value.length > 0
40
+ && !value.startsWith('/')
41
+ && !value.includes('\\')
42
+ && !value.includes(':')
43
+ && !value.includes('\0')
44
+ && value.split('/').every((part) => part.length > 0 && part !== '.' && part !== '..');
45
+ }
46
+
47
+ function decodeCanonicalBase64(value) {
48
+ if (typeof value !== 'string' || value.length % 4 !== 0) return null;
49
+ let paddingStart = -1;
50
+ for (let index = 0; index < value.length; index += 1) {
51
+ const code = value.charCodeAt(index);
52
+ if (code === 61) {
53
+ if (paddingStart === -1) paddingStart = index;
54
+ continue;
55
+ }
56
+ if (paddingStart !== -1 || !(
57
+ code >= 65 && code <= 90
58
+ || code >= 97 && code <= 122
59
+ || code >= 48 && code <= 57
60
+ || code === 43
61
+ || code === 47
62
+ )) return null;
63
+ }
64
+ if (
65
+ paddingStart !== -1
66
+ && value.length - paddingStart > 2
67
+ || paddingStart !== -1
68
+ && paddingStart % 4 < 2
69
+ ) return null;
70
+ const decoded = Buffer.from(value, 'base64');
71
+ return decoded.toString('base64') === value ? decoded : null;
72
+ }
73
+
74
+ function hasExactKeys(value, keys) {
75
+ return Object.keys(value).sort().join(',') === keys.join(',');
76
+ }
77
+
78
+ function compareUnicodeCodePoints(left, right) {
79
+ const leftPoints = Array.from(left, (character) => character.codePointAt(0));
80
+ const rightPoints = Array.from(right, (character) => character.codePointAt(0));
81
+ const length = Math.min(leftPoints.length, rightPoints.length);
82
+ for (let index = 0; index < length; index += 1) {
83
+ if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
84
+ }
85
+ return leftPoints.length - rightPoints.length;
86
+ }
87
+
88
+ function installCommandExited(result) {
89
+ const commands = result?.facts?.install?.commands;
90
+ if (!Array.isArray(commands)) return false;
91
+ return commands.every((command) => {
92
+ const execution = command?.execution;
93
+ return execution?.exitStatus === 0 && execution.processStatus === 'exited';
94
+ });
95
+ }
96
+
97
+ function assertStableInstallTreeState(state) {
98
+ if (state === undefined) return;
99
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
100
+ throw fail('Foundation install tree state is invalid');
101
+ }
102
+ if (
103
+ state.commandExited === false
104
+ || state.isolated === false
105
+ || state.stable === false
106
+ || state.namespaceWriterActive === true
107
+ ) {
108
+ throw fail('Foundation install tree is not a stable isolated post-command tree', {
109
+ commandExited: state.commandExited ?? null,
110
+ isolated: state.isolated ?? null,
111
+ stable: state.stable ?? null,
112
+ namespaceWriterActive: state.namespaceWriterActive ?? null,
113
+ });
114
+ }
115
+ }
116
+
117
+ function compareRecordedInstallTree(observation, expectedMembers) {
118
+ if (
119
+ !observation
120
+ || typeof observation !== 'object'
121
+ || observation.schemaVersion !== 1
122
+ || observation.kind !== 'skill-family.filesystem-tree-observation'
123
+ || !/^[0-9a-f]{64}$/u.test(observation.membersDigest ?? '')
124
+ || !Array.isArray(observation.members)
125
+ ) {
126
+ throw fail('Foundation install tree observation has an invalid shape');
127
+ }
128
+ if (digestDocument(observation.members) !== observation.membersDigest) {
129
+ throw fail('Foundation install tree observation digest does not match its members');
130
+ }
131
+ const actual = new Map();
132
+ const foldedPaths = new Set();
133
+ let previousPath = null;
134
+ for (const member of observation.members) {
135
+ const foldedPath = member?.path?.toLowerCase();
136
+ if (
137
+ !member
138
+ || !isSafeRelativePath(member.path)
139
+ || !['directory', 'file', 'symlink'].includes(member.type)
140
+ || actual.has(member.path)
141
+ || foldedPaths.has(foldedPath)
142
+ || previousPath !== null && compareUnicodeCodePoints(previousPath, member.path) >= 0
143
+ ) {
144
+ throw fail('Foundation install tree observation contains duplicate or invalid paths');
145
+ }
146
+ actual.set(member.path, member);
147
+ foldedPaths.add(foldedPath);
148
+ const requiredKeys = member.type === 'directory'
149
+ ? ['path', 'statMode', 'type']
150
+ : member.type === 'file'
151
+ ? ['bytes', 'contentBase64', 'path', 'sha256', 'statMode', 'type']
152
+ : ['bytes', 'path', 'statMode', 'targetBase64', 'type'];
153
+ if (!hasExactKeys(member, requiredKeys)) {
154
+ throw fail('Foundation install tree member has unknown or missing fields', { path: member.path });
155
+ }
156
+ previousPath = member.path;
157
+ if (
158
+ typeof member.statMode !== 'number'
159
+ || !Number.isInteger(member.statMode)
160
+ || member.statMode < 0
161
+ || member.statMode > 0xffff
162
+ ) {
163
+ throw fail('Foundation install tree member mode is invalid', { path: member.path });
164
+ }
165
+ if (member.type === 'file') {
166
+ const content = decodeCanonicalBase64(member.contentBase64);
167
+ if (
168
+ !content
169
+ || typeof member.sha256 !== 'string'
170
+ || !SHA256_PATTERN.test(member.sha256)
171
+ || !Number.isSafeInteger(member.bytes)
172
+ || member.bytes < 0
173
+ || member.bytes !== content.length
174
+ || member.sha256 !== digestBytes(content)
175
+ ) {
176
+ throw fail('Foundation install tree file record is invalid', { path: member.path });
177
+ }
178
+ }
179
+ if (member.type === 'symlink') {
180
+ const target = decodeCanonicalBase64(member.targetBase64);
181
+ if (
182
+ !target
183
+ || target.length === 0
184
+ || !Number.isSafeInteger(member.bytes)
185
+ || member.bytes < 1
186
+ || member.bytes !== target.length
187
+ ) {
188
+ throw fail('Foundation install tree link record is invalid', { path: member.path });
189
+ }
190
+ }
191
+ }
192
+ const expected = new Map(expectedMembers.map((member) => [member.path, member]));
193
+ for (const [path, expectedMember] of expected) {
194
+ const member = actual.get(path);
195
+ if (!member || member.type === 'symlink' || member.type !== expectedMember.type) {
196
+ throw fail('declared install tree member is missing or unsafe', { path });
197
+ }
198
+ if (member.type === 'file' && (
199
+ member.sha256 !== expectedMember.sha256
200
+ || member.bytes !== expectedMember.bytes
201
+ || ((member.statMode & 0o111) !== 0) !== expectedMember.executable
202
+ )) {
203
+ throw fail('declared install tree member drifted from the frozen payload', { path });
204
+ }
205
+ }
206
+ const extras = [...actual.values()].filter((member) => !expected.has(member.path));
207
+ const extraInstalledPaths = extras
208
+ .map((member) => member.path)
209
+ .sort(compareUnicodeCodePoints);
210
+ const extraInstalledLinks = extras
211
+ .filter((member) => member.type === 'symlink')
212
+ .map((member) => {
213
+ // Keep the target bytes supplied by Foundation. No path resolution or
214
+ // target read occurs here, so an added link can never be followed.
215
+ return {
216
+ path: member.path,
217
+ targetBase64: member.targetBase64,
218
+ bytes: member.bytes,
219
+ statMode: member.statMode,
220
+ };
221
+ })
222
+ .sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
223
+ return { extraInstalledPaths, extraInstalledLinks };
224
+ }
33
225
 
34
226
  function fail(message, details = {}) {
35
227
  return new ReleaseError(POST_PUBLISH_VERIFY_FAILED, message, details);
@@ -135,6 +327,8 @@ export async function verifyFrozenPluginWithFoundation({
135
327
  root,
136
328
  runDir,
137
329
  runPluginVerificationFn = runPluginVerification,
330
+ observeFilesystemTreeFn = observeFilesystemTree,
331
+ installTreeState,
138
332
  clock = () => new Date().toISOString(),
139
333
  } = {}) {
140
334
  const unit = (plan?.units ?? []).find((candidate) => candidate.id === action?.unitId);
@@ -231,6 +425,38 @@ export async function verifyFrozenPluginWithFoundation({
231
425
  const result = await runPluginVerificationFn({ request, bindings, hostsRoot });
232
426
  assertResultMatchesRequest(result, request);
233
427
 
428
+ // A3: the installation command (if Foundation reports one) must have
429
+ // exited before the isolated tree is observed. The install-only Foundation
430
+ // path reports no commands, which is the completed local materialization.
431
+ if (!installCommandExited(result)) {
432
+ throw fail('Foundation install tree observation requires an exited install command');
433
+ }
434
+ assertStableInstallTreeState(installTreeState);
435
+
436
+ // Foundation's local install-only contract materializes the payload at the
437
+ // stable child of the isolated install container. Observe that payload root
438
+ // rather than the container, whose private siblings are not plugin bytes.
439
+ const installRoot = join(runDir, 'install', 'payload');
440
+ let treeObservation;
441
+ try {
442
+ const rootBinding = await createFilesystemRootBinding(installRoot);
443
+ treeObservation = await observeFilesystemTreeFn({
444
+ root: installRoot,
445
+ rootBinding,
446
+ symlinkPolicy: { mode: 'record' },
447
+ });
448
+ if (treeObservation?.rootBinding?.digest !== rootBinding.digest) {
449
+ throw fail('Foundation install tree observation root binding drifted');
450
+ }
451
+ } catch (error) {
452
+ if (error instanceof ReleaseError) throw error;
453
+ throw fail('Foundation install tree observation failed closed', {
454
+ cause: error?.message ?? String(error),
455
+ code: error?.code ?? null,
456
+ });
457
+ }
458
+ const extraInstallAudit = compareRecordedInstallTree(treeObservation, sourceMembers);
459
+
234
460
  return {
235
461
  planDigest: plan.digest,
236
462
  actionId: action.id,
@@ -243,5 +469,11 @@ export async function verifyFrozenPluginWithFoundation({
243
469
  payloadMatches: result.facts.install.payloadMatches,
244
470
  observationDigest: result.facts.install.observationDigest,
245
471
  observedAt: clock(),
472
+ ...(extraInstallAudit.extraInstalledPaths.length > 0
473
+ ? { extraInstalledPaths: extraInstallAudit.extraInstalledPaths }
474
+ : {}),
475
+ ...(extraInstallAudit.extraInstalledLinks.length > 0
476
+ ? { extraInstalledLinks: extraInstallAudit.extraInstalledLinks }
477
+ : {}),
246
478
  };
247
479
  }