release-skill 0.9.1 → 0.9.3

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 (148) 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 +49 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +24 -26
  10. package/README.zh-CN.md +20 -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 +4068 -1962
  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 +11 -3
  25. package/adapters/claude/skills/release-marketplace/SKILL.md +3 -1
  26. package/adapters/claude/skills/release-prepare/SKILL.md +2 -0
  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 +4068 -1962
  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 +11 -3
  48. package/adapters/codex/skills/release-marketplace/SKILL.md +3 -1
  49. package/adapters/codex/skills/release-prepare/SKILL.md +2 -0
  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 +4068 -1962
  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 +11 -3
  71. package/adapters/kimi/skills/release-marketplace/SKILL.md +3 -1
  72. package/adapters/kimi/skills/release-prepare/SKILL.md +2 -0
  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 +4068 -1962
  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 +11 -3
  94. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +3 -1
  95. package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -0
  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 +32 -0
  109. package/bin/release-skill.bundle.mjs +4068 -1962
  110. package/bin/rules.json +1 -1
  111. package/package.json +5 -5
  112. package/platform-manifest.json +4 -4
  113. package/references/01-state-machine.md +16 -1
  114. package/references/05-evidence-and-errors.md +2 -2
  115. package/schemas/release-project.schema.json +4 -0
  116. package/schemas/release-run.schema.json +41 -1
  117. package/skills/release-config/SKILL.md +5 -3
  118. package/skills/release-docs/SKILL.md +4 -2
  119. package/skills/release-finish/SKILL.md +2 -0
  120. package/skills/release-help/SKILL.md +11 -3
  121. package/skills/release-marketplace/SKILL.md +3 -1
  122. package/skills/release-prepare/SKILL.md +2 -0
  123. package/skills/release-publish/SKILL.md +17 -10
  124. package/skills/release-verify/SKILL.md +2 -0
  125. package/skills-src/release-config/SKILL.md +5 -3
  126. package/skills-src/release-docs/SKILL.md +4 -2
  127. package/skills-src/release-finish/SKILL.md +2 -0
  128. package/skills-src/release-help/SKILL.md +11 -3
  129. package/skills-src/release-marketplace/SKILL.md +3 -1
  130. package/skills-src/release-prepare/SKILL.md +2 -0
  131. package/skills-src/release-publish/SKILL.md +17 -10
  132. package/skills-src/release-verify/SKILL.md +2 -0
  133. package/src/commands/prepare.mjs +139 -5
  134. package/src/commands/route.mjs +597 -18
  135. package/src/commands/setup.mjs +2 -0
  136. package/src/commands/ship.mjs +19 -0
  137. package/src/commands/verify.mjs +21 -0
  138. package/src/core/adoption-assessment.mjs +59 -0
  139. package/src/core/foundation-plugin-verification.mjs +213 -1
  140. package/src/core/hook-cache.mjs +318 -26
  141. package/src/core/hooks.mjs +8 -1
  142. package/src/producers/foundation-resource-projection.mjs +3 -3
  143. package/src/schemas/executable-identity-observation.schema.json +162 -0
  144. package/src/schemas/filesystem-tree-observation.schema.json +18 -2
  145. package/src/schemas/plugin-verification-request.schema.json +147 -22
  146. package/src/schemas/plugin-verification-result.schema.json +436 -18
  147. package/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  148. 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) ---
@@ -315,6 +315,25 @@ export async function advanceShip(options = {}, injected = {}) {
315
315
  ? injected
316
316
  : await defaultDependencies();
317
317
  let state = await readState(statePath);
318
+ const requestedTargetVersion = options.targetVersion ?? null;
319
+ if (state && requestedTargetVersion !== null) {
320
+ const stateTargetVersion = state.targetVersion ?? null;
321
+ if (stateTargetVersion === null || stateTargetVersion !== requestedTargetVersion) {
322
+ const suffix = stateTargetVersion === null
323
+ ? 'state targetVersion is missing; provide a new --state path to start the requested version'
324
+ : `state targetVersion ${stateTargetVersion} does not match requested targetVersion ${requestedTargetVersion}`;
325
+ throw new ReleaseError(
326
+ GATE_FAILED,
327
+ `ship state target version mismatch: ${suffix}`,
328
+ {
329
+ reason: 'SHIP_STATE_TARGET_VERSION_MISMATCH',
330
+ statePath,
331
+ stateTargetVersion,
332
+ requestedTargetVersion,
333
+ },
334
+ );
335
+ }
336
+ }
318
337
  if (state?.gitTransport) {
319
338
  process.env.RELEASE_SKILL_GIT_TRANSPORT = state.gitTransport;
320
339
  }
@@ -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,176 @@ 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
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
37
+
38
+ function isSafeRelativePath(value) {
39
+ return typeof value === 'string'
40
+ && value.length > 0
41
+ && !value.startsWith('/')
42
+ && !value.includes('\\')
43
+ && !value.includes(':')
44
+ && !value.includes('\0')
45
+ && value.split('/').every((part) => part.length > 0 && part !== '.' && part !== '..');
46
+ }
47
+
48
+ function decodeCanonicalBase64(value) {
49
+ if (typeof value !== 'string' || !BASE64_PATTERN.test(value)) return null;
50
+ const decoded = Buffer.from(value, 'base64');
51
+ return decoded.toString('base64') === value ? decoded : null;
52
+ }
53
+
54
+ function hasExactKeys(value, keys) {
55
+ return Object.keys(value).sort().join(',') === keys.join(',');
56
+ }
57
+
58
+ function compareUnicodeCodePoints(left, right) {
59
+ const leftPoints = Array.from(left, (character) => character.codePointAt(0));
60
+ const rightPoints = Array.from(right, (character) => character.codePointAt(0));
61
+ const length = Math.min(leftPoints.length, rightPoints.length);
62
+ for (let index = 0; index < length; index += 1) {
63
+ if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
64
+ }
65
+ return leftPoints.length - rightPoints.length;
66
+ }
67
+
68
+ function installCommandExited(result) {
69
+ const commands = result?.facts?.install?.commands;
70
+ if (!Array.isArray(commands)) return false;
71
+ return commands.every((command) => {
72
+ const execution = command?.execution;
73
+ return execution?.exitStatus === 0 && execution.processStatus === 'exited';
74
+ });
75
+ }
76
+
77
+ function assertStableInstallTreeState(state) {
78
+ if (state === undefined) return;
79
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
80
+ throw fail('Foundation install tree state is invalid');
81
+ }
82
+ if (
83
+ state.commandExited === false
84
+ || state.isolated === false
85
+ || state.stable === false
86
+ || state.namespaceWriterActive === true
87
+ ) {
88
+ throw fail('Foundation install tree is not a stable isolated post-command tree', {
89
+ commandExited: state.commandExited ?? null,
90
+ isolated: state.isolated ?? null,
91
+ stable: state.stable ?? null,
92
+ namespaceWriterActive: state.namespaceWriterActive ?? null,
93
+ });
94
+ }
95
+ }
96
+
97
+ function compareRecordedInstallTree(observation, expectedMembers) {
98
+ if (
99
+ !observation
100
+ || typeof observation !== 'object'
101
+ || observation.schemaVersion !== 1
102
+ || observation.kind !== 'skill-family.filesystem-tree-observation'
103
+ || !/^[0-9a-f]{64}$/u.test(observation.membersDigest ?? '')
104
+ || !Array.isArray(observation.members)
105
+ ) {
106
+ throw fail('Foundation install tree observation has an invalid shape');
107
+ }
108
+ if (digestDocument(observation.members) !== observation.membersDigest) {
109
+ throw fail('Foundation install tree observation digest does not match its members');
110
+ }
111
+ const actual = new Map();
112
+ const foldedPaths = new Set();
113
+ let previousPath = null;
114
+ for (const member of observation.members) {
115
+ const foldedPath = member?.path?.toLowerCase();
116
+ if (
117
+ !member
118
+ || !isSafeRelativePath(member.path)
119
+ || !['directory', 'file', 'symlink'].includes(member.type)
120
+ || actual.has(member.path)
121
+ || foldedPaths.has(foldedPath)
122
+ || previousPath !== null && compareUnicodeCodePoints(previousPath, member.path) >= 0
123
+ ) {
124
+ throw fail('Foundation install tree observation contains duplicate or invalid paths');
125
+ }
126
+ actual.set(member.path, member);
127
+ foldedPaths.add(foldedPath);
128
+ const requiredKeys = member.type === 'directory'
129
+ ? ['path', 'statMode', 'type']
130
+ : member.type === 'file'
131
+ ? ['bytes', 'contentBase64', 'path', 'sha256', 'statMode', 'type']
132
+ : ['bytes', 'path', 'statMode', 'targetBase64', 'type'];
133
+ if (!hasExactKeys(member, requiredKeys)) {
134
+ throw fail('Foundation install tree member has unknown or missing fields', { path: member.path });
135
+ }
136
+ previousPath = member.path;
137
+ if (
138
+ typeof member.statMode !== 'number'
139
+ || !Number.isInteger(member.statMode)
140
+ || member.statMode < 0
141
+ || member.statMode > 0xffff
142
+ ) {
143
+ throw fail('Foundation install tree member mode is invalid', { path: member.path });
144
+ }
145
+ if (member.type === 'file') {
146
+ const content = decodeCanonicalBase64(member.contentBase64);
147
+ if (
148
+ !content
149
+ || typeof member.sha256 !== 'string'
150
+ || !SHA256_PATTERN.test(member.sha256)
151
+ || !Number.isSafeInteger(member.bytes)
152
+ || member.bytes < 0
153
+ || member.bytes !== content.length
154
+ || member.sha256 !== digestBytes(content)
155
+ ) {
156
+ throw fail('Foundation install tree file record is invalid', { path: member.path });
157
+ }
158
+ }
159
+ if (member.type === 'symlink') {
160
+ const target = decodeCanonicalBase64(member.targetBase64);
161
+ if (
162
+ !target
163
+ || target.length === 0
164
+ || !Number.isSafeInteger(member.bytes)
165
+ || member.bytes < 1
166
+ || member.bytes !== target.length
167
+ ) {
168
+ throw fail('Foundation install tree link record is invalid', { path: member.path });
169
+ }
170
+ }
171
+ }
172
+ const expected = new Map(expectedMembers.map((member) => [member.path, member]));
173
+ for (const [path, expectedMember] of expected) {
174
+ const member = actual.get(path);
175
+ if (!member || member.type === 'symlink' || member.type !== expectedMember.type) {
176
+ throw fail('declared install tree member is missing or unsafe', { path });
177
+ }
178
+ if (member.type === 'file' && (
179
+ member.sha256 !== expectedMember.sha256
180
+ || member.bytes !== expectedMember.bytes
181
+ || ((member.statMode & 0o111) !== 0) !== expectedMember.executable
182
+ )) {
183
+ throw fail('declared install tree member drifted from the frozen payload', { path });
184
+ }
185
+ }
186
+ const extras = [...actual.values()].filter((member) => !expected.has(member.path));
187
+ const extraInstalledPaths = extras
188
+ .map((member) => member.path)
189
+ .sort(compareUnicodeCodePoints);
190
+ const extraInstalledLinks = extras
191
+ .filter((member) => member.type === 'symlink')
192
+ .map((member) => {
193
+ // Keep the target bytes supplied by Foundation. No path resolution or
194
+ // target read occurs here, so an added link can never be followed.
195
+ return {
196
+ path: member.path,
197
+ targetBase64: member.targetBase64,
198
+ bytes: member.bytes,
199
+ statMode: member.statMode,
200
+ };
201
+ })
202
+ .sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
203
+ return { extraInstalledPaths, extraInstalledLinks };
204
+ }
33
205
 
34
206
  function fail(message, details = {}) {
35
207
  return new ReleaseError(POST_PUBLISH_VERIFY_FAILED, message, details);
@@ -135,6 +307,8 @@ export async function verifyFrozenPluginWithFoundation({
135
307
  root,
136
308
  runDir,
137
309
  runPluginVerificationFn = runPluginVerification,
310
+ observeFilesystemTreeFn = observeFilesystemTree,
311
+ installTreeState,
138
312
  clock = () => new Date().toISOString(),
139
313
  } = {}) {
140
314
  const unit = (plan?.units ?? []).find((candidate) => candidate.id === action?.unitId);
@@ -231,6 +405,38 @@ export async function verifyFrozenPluginWithFoundation({
231
405
  const result = await runPluginVerificationFn({ request, bindings, hostsRoot });
232
406
  assertResultMatchesRequest(result, request);
233
407
 
408
+ // A3: the installation command (if Foundation reports one) must have
409
+ // exited before the isolated tree is observed. The install-only Foundation
410
+ // path reports no commands, which is the completed local materialization.
411
+ if (!installCommandExited(result)) {
412
+ throw fail('Foundation install tree observation requires an exited install command');
413
+ }
414
+ assertStableInstallTreeState(installTreeState);
415
+
416
+ // Foundation's local install-only contract materializes the payload at the
417
+ // stable child of the isolated install container. Observe that payload root
418
+ // rather than the container, whose private siblings are not plugin bytes.
419
+ const installRoot = join(runDir, 'install', 'payload');
420
+ let treeObservation;
421
+ try {
422
+ const rootBinding = await createFilesystemRootBinding(installRoot);
423
+ treeObservation = await observeFilesystemTreeFn({
424
+ root: installRoot,
425
+ rootBinding,
426
+ symlinkPolicy: { mode: 'record' },
427
+ });
428
+ if (treeObservation?.rootBinding?.digest !== rootBinding.digest) {
429
+ throw fail('Foundation install tree observation root binding drifted');
430
+ }
431
+ } catch (error) {
432
+ if (error instanceof ReleaseError) throw error;
433
+ throw fail('Foundation install tree observation failed closed', {
434
+ cause: error?.message ?? String(error),
435
+ code: error?.code ?? null,
436
+ });
437
+ }
438
+ const extraInstallAudit = compareRecordedInstallTree(treeObservation, sourceMembers);
439
+
234
440
  return {
235
441
  planDigest: plan.digest,
236
442
  actionId: action.id,
@@ -243,5 +449,11 @@ export async function verifyFrozenPluginWithFoundation({
243
449
  payloadMatches: result.facts.install.payloadMatches,
244
450
  observationDigest: result.facts.install.observationDigest,
245
451
  observedAt: clock(),
452
+ ...(extraInstallAudit.extraInstalledPaths.length > 0
453
+ ? { extraInstalledPaths: extraInstallAudit.extraInstalledPaths }
454
+ : {}),
455
+ ...(extraInstallAudit.extraInstalledLinks.length > 0
456
+ ? { extraInstalledLinks: extraInstallAudit.extraInstalledLinks }
457
+ : {}),
246
458
  };
247
459
  }