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
@@ -2,9 +2,10 @@
2
2
  * Incremental hook result cache (T3.2).
3
3
  *
4
4
  * A hook that opts in with `cacheable: true` and a `cacheInputs` glob list is
5
- * keyed by the fingerprint of its full configuration plus the content of every
6
- * file its inputs match. When the key is unchanged and the last run succeeded,
7
- * prepare replays the cached outcome instead of re-executing the hook.
5
+ * keyed by a closed v2 receipt: its configuration and input closure, the
6
+ * allowlisted environment values, runtime and physical identities, plus the
7
+ * Foundation executable observation. When the key is unchanged and the last
8
+ * run succeeded, prepare replays the cached outcome instead of re-executing.
8
9
  *
9
10
  * Safety contract (see t3-2-incremental-hooks.md §4.8):
10
11
  * - Failures are never cached. Only an `exitCode === 0` result is written; a
@@ -25,10 +26,22 @@
25
26
  * @module hook-cache
26
27
  */
27
28
 
28
- import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
29
- import { join } from 'node:path';
29
+ import { readdir, realpath, stat } from 'node:fs/promises';
30
+ import { release as osRelease } from 'node:os';
31
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
32
+ import {
33
+ createFilesystemRootBinding,
34
+ observeExecutableIdentity,
35
+ readFileStrict,
36
+ readFileBound,
37
+ publishFileExclusive,
38
+ publishFileOrReplace,
39
+ HARNESS_ERROR_KINDS,
40
+ } from 'skill-family-harness-node';
30
41
  import { canonicalJson, sha256Hex } from './digest.mjs';
31
42
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
43
+ import { buildHookEnvironment } from './hooks.mjs';
44
+ import { readTrustedPackageResourceSync } from './trusted-resource.mjs';
32
45
 
33
46
  /** Control-plane location of hook cache records: `.release-skill/cache/hooks`. */
34
47
  const CACHE_BASE = ['.release-skill', 'cache', 'hooks'];
@@ -36,6 +49,20 @@ const CACHE_BASE = ['.release-skill', 'cache', 'hooks'];
36
49
  /** Bounded tail length stored per stream (matches prepare's evidence tails). */
37
50
  const TAIL_LENGTH = 4000;
38
51
 
52
+ const CACHE_SCHEMA_VERSION = 2;
53
+ const CACHE_KIND = 'release-skill.hook-cache';
54
+ const CACHE_ALGORITHM = 'release-skill-hook-cache-v2';
55
+ const CACHE_RECEIPT_VERSION = 1;
56
+ const CACHE_RECORD_KEYS = Object.freeze([
57
+ 'algorithm', 'cacheKey', 'createdAt', 'exitCode', 'kind', 'receipt',
58
+ 'receiptVersion', 'schemaVersion', 'stderrTail', 'stdoutTail',
59
+ ]);
60
+ const CACHE_RECEIPT_KEYS = Object.freeze([
61
+ 'algorithm', 'envDigest', 'executableObservationDigest', 'hookDigest',
62
+ 'hookCwdIdentity', 'inputsDigest', 'projectRootIdentity', 'receiptVersion',
63
+ 'releaseSkillVersion', 'runtime', 'schemaVersion',
64
+ ]);
65
+
39
66
  /**
40
67
  * Directory names never walked when enumerating hook inputs. `.git` and
41
68
  * `node_modules` are VCS/dependency internals; `.release-skill` is the control
@@ -88,30 +115,42 @@ function globToRegExp(glob) {
88
115
  * @param {string} root - Absolute project root.
89
116
  * @returns {Promise<string[]>} Sorted relative paths.
90
117
  */
91
- async function listInputFiles(root) {
118
+ async function listInputFiles(root, matchers) {
92
119
  const out = [];
120
+ let unsafe = false;
121
+
122
+ function matches(relPath) {
123
+ return matchers.some(({ re }) => re.test(relPath));
124
+ }
93
125
 
94
126
  async function walk(dirAbs, dirRel) {
95
127
  let entries;
96
128
  try {
97
129
  entries = await readdir(dirAbs, { withFileTypes: true });
98
130
  } catch {
99
- return; // Unreadable directory: treat as no inputs there.
131
+ unsafe = true;
132
+ return;
100
133
  }
101
134
  for (const entry of entries) {
135
+ const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
136
+ if (SKIPPED_DIRS.has(entry.name)) continue;
137
+ if (entry.isSymbolicLink()) {
138
+ if (matches(rel) || symlinkCouldCarryInput(rel, matchers)) unsafe = true;
139
+ continue;
140
+ }
102
141
  if (entry.isDirectory()) {
103
- if (SKIPPED_DIRS.has(entry.name)) continue;
104
- const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
105
142
  await walk(join(dirAbs, entry.name), rel);
106
143
  } else if (entry.isFile()) {
107
- out.push(dirRel ? `${dirRel}/${entry.name}` : entry.name);
144
+ out.push(rel);
145
+ } else if (matches(rel)) {
146
+ unsafe = true;
108
147
  }
109
148
  }
110
149
  }
111
150
 
112
151
  await walk(root, '');
113
152
  out.sort();
114
- return out;
153
+ return { files: out, unsafe };
115
154
  }
116
155
 
117
156
  /**
@@ -129,11 +168,186 @@ async function listInputFiles(root) {
129
168
  * @returns {Promise<{ cacheKey: string, matchedFiles: string[] }>}
130
169
  * @throws {ReleaseError} GATE_FAILED when any declared glob matches no file.
131
170
  */
132
- export async function computeHookCacheKey(hook, root) {
171
+ function hasPathSeparator(command) {
172
+ return typeof command === 'string' && (command.includes('/') || command.includes('\\'));
173
+ }
174
+
175
+ function staticGlobBase(glob) {
176
+ const wildcard = glob.search(/[?*]/);
177
+ const prefix = wildcard === -1 ? glob : glob.slice(0, wildcard);
178
+ const slash = prefix.lastIndexOf('/');
179
+ return slash === -1 ? '' : prefix.slice(0, slash);
180
+ }
181
+
182
+ function symlinkCouldCarryInput(relPath, matchers) {
183
+ return matchers.some(({ glob }) => {
184
+ const base = staticGlobBase(glob);
185
+ return base === '' || relPath === base || relPath.startsWith(`${base}/`);
186
+ });
187
+ }
188
+
189
+ function isWithin(root, candidate) {
190
+ const rel = relative(root, candidate);
191
+ return rel === '' || (rel !== '..' && !rel.startsWith('../') && !rel.startsWith('/'));
192
+ }
193
+
194
+ function nearestCommonDirectory(first, second) {
195
+ const firstParts = first.split('/').filter(Boolean);
196
+ const secondParts = second.split('/').filter(Boolean);
197
+ let length = 0;
198
+ while (length < firstParts.length && firstParts[length] === secondParts[length]) length += 1;
199
+ return length === 0 ? '/' : `/${firstParts.slice(0, length).join('/')}`;
200
+ }
201
+
202
+ async function executableBindingRoot(projectRoot, lexicalPath, targetPath, relativeCommand) {
203
+ if (relativeCommand) return projectRoot;
204
+ if (isWithin(projectRoot, lexicalPath) && isWithin(projectRoot, targetPath)) return projectRoot;
205
+ const common = nearestCommonDirectory(dirname(lexicalPath), dirname(targetPath));
206
+ if (common === '/') return null;
207
+ try {
208
+ const details = await stat(common);
209
+ if (!details.isDirectory()) return null;
210
+ const canonical = await realpath(common);
211
+ return canonical === '/' ? null : canonical;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ async function physicalIdentity(path) {
218
+ const canonical = await realpath(path);
219
+ const details = await stat(canonical);
220
+ return {
221
+ path: canonical,
222
+ device: String(details.dev),
223
+ inode: String(details.ino),
224
+ mode: details.mode,
225
+ type: details.isDirectory() ? 'directory' : details.isFile() ? 'file' : 'other',
226
+ };
227
+ }
228
+
229
+ async function observeHookExecutable(hook, root, options = {}) {
230
+ if (process.platform === 'win32') return { eligible: false, reason: 'WINDOWS' };
231
+ const command = Array.isArray(hook.command) ? hook.command[0] : undefined;
232
+ if (!hasPathSeparator(command) || /^[A-Za-z]:[\\/]/.test(command)) {
233
+ return { eligible: false, reason: 'AMBIENT_PATH_OR_UNSUPPORTED_COMMAND' };
234
+ }
235
+
236
+ let projectRoot;
237
+ let cwd;
238
+ let executablePath;
239
+ try {
240
+ projectRoot = await realpath(root);
241
+ const declaredCwd = typeof hook.cwd === 'string' && hook.cwd.length > 0 ? hook.cwd : '.';
242
+ cwd = await realpath(resolve(projectRoot, declaredCwd));
243
+ if (!isWithin(projectRoot, cwd)) return { eligible: false, reason: 'CWD_OUTSIDE_PROJECT' };
244
+ executablePath = isAbsolute(command) ? resolve(command) : resolve(cwd, command);
245
+ if (!isAbsolute(command) && !isWithin(projectRoot, executablePath)) {
246
+ return { eligible: false, reason: 'EXECUTABLE_OUTSIDE_PROJECT' };
247
+ }
248
+ const targetPath = await realpath(executablePath);
249
+ const executableRoot = await executableBindingRoot(
250
+ projectRoot,
251
+ executablePath,
252
+ targetPath,
253
+ !isAbsolute(command),
254
+ );
255
+ if (!executableRoot) return { eligible: false, reason: 'BOUND_ROOT_TOO_BROAD' };
256
+ // The executable observation receives one minimal root covering the
257
+ // lexical absolute entry and its physical target. The project root binding
258
+ // is separate and is used only for cache-record IO.
259
+ const executableBinding = await createFilesystemRootBinding(executableRoot);
260
+ const projectRootBinding = executableRoot === projectRoot
261
+ ? executableBinding : await createFilesystemRootBinding(projectRoot);
262
+ try {
263
+ const observe = options.observeExecutableIdentityFn ?? observeExecutableIdentity;
264
+ const observed = await observe({
265
+ boundRoots: [{ root: executableRoot, rootBinding: executableBinding }],
266
+ lookup: { mode: 'absolute-path', path: executablePath },
267
+ });
268
+ if (!observed || !isDigest(observed.observationDigest)) {
269
+ return { eligible: false, reason: 'OBSERVATION_INVALID' };
270
+ }
271
+ return {
272
+ eligible: true,
273
+ observationDigest: observed.observationDigest,
274
+ projectRoot: await physicalIdentity(projectRoot),
275
+ hookCwd: await physicalIdentity(cwd),
276
+ projectRootBinding,
277
+ };
278
+ } catch {
279
+ return { eligible: false, reason: 'OBSERVATION_FAILED' };
280
+ }
281
+ } catch {
282
+ return { eligible: false, reason: 'OBSERVATION_FAILED' };
283
+ }
284
+ }
285
+
286
+ function sortedObject(object) {
287
+ return Object.fromEntries(Object.keys(object).sort().map((key) => [key, object[key]]));
288
+ }
289
+
290
+ function hasExactKeys(value, keys) {
291
+ return value && typeof value === 'object' && !Array.isArray(value)
292
+ && Object.keys(value).sort().join('\0') === keys.slice().sort().join('\0');
293
+ }
294
+
295
+ function isDigest(value) {
296
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
297
+ }
298
+
299
+ function isValidIdentity(value) {
300
+ return hasExactKeys(value, ['device', 'inode', 'mode', 'path', 'type'])
301
+ && typeof value.path === 'string'
302
+ && typeof value.device === 'string'
303
+ && typeof value.inode === 'string'
304
+ && Number.isInteger(value.mode)
305
+ && ['directory', 'file', 'other'].includes(value.type);
306
+ }
307
+
308
+ function readCurrentReleaseSkillVersion() {
309
+ const validVersion = (version) => typeof version === 'string'
310
+ && version.length > 0 && version.length <= 128 && !/[\u0000-\u001f\u007f]/.test(version)
311
+ ? version : null;
312
+ if (typeof __bundlePkg !== 'undefined' && __bundlePkg && typeof __bundlePkg.version === 'string') {
313
+ return validVersion(__bundlePkg.version);
314
+ }
315
+ try {
316
+ const pkg = JSON.parse(readTrustedPackageResourceSync('package.json').toString('utf8'));
317
+ return validVersion(pkg.version);
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ function isValidReceipt(receipt, cacheKey, currentVersion) {
324
+ if (!hasExactKeys(receipt, CACHE_RECEIPT_KEYS)) return false;
325
+ if (receipt.schemaVersion !== CACHE_SCHEMA_VERSION
326
+ || receipt.algorithm !== CACHE_ALGORITHM
327
+ || receipt.receiptVersion !== CACHE_RECEIPT_VERSION
328
+ || receipt.releaseSkillVersion !== currentVersion
329
+ || !isDigest(receipt.hookDigest)
330
+ || !isDigest(receipt.inputsDigest)
331
+ || !isDigest(receipt.envDigest)
332
+ || !isDigest(receipt.executableObservationDigest)
333
+ || !isValidIdentity(receipt.projectRootIdentity)
334
+ || !isValidIdentity(receipt.hookCwdIdentity)
335
+ || !hasExactKeys(receipt.runtime, ['arch', 'node', 'os', 'platform'])
336
+ || typeof receipt.runtime.arch !== 'string'
337
+ || typeof receipt.runtime.node !== 'string'
338
+ || typeof receipt.runtime.os !== 'string'
339
+ || typeof receipt.runtime.platform !== 'string') return false;
340
+ return sha256Hex(canonicalJson(receipt)) === cacheKey;
341
+ }
342
+
343
+ export async function computeHookCacheKey(hook, root, options = {}) {
133
344
  const globs = Array.isArray(hook.cacheInputs) ? hook.cacheInputs : [];
134
345
  const matchers = globs.map((glob) => ({ glob, re: globToRegExp(glob) }));
135
346
 
136
- const allFiles = await listInputFiles(root);
347
+ const { files: allFiles, unsafe: unsafeInputs } = await listInputFiles(root, matchers);
348
+ if (unsafeInputs) {
349
+ return { cacheKey: null, matchedFiles: [], cacheable: false, reason: 'INPUT_CLOSURE_UNSAFE' };
350
+ }
137
351
  const matched = [];
138
352
  const hitPerGlob = matchers.map(() => false);
139
353
  for (const relPath of allFiles) {
@@ -162,15 +376,47 @@ export async function computeHookCacheKey(hook, root) {
162
376
  matched.sort();
163
377
  const fileEntries = [];
164
378
  for (const relPath of matched) {
165
- const content = await readFile(join(root, relPath));
166
- fileEntries.push({ path: relPath, sha256: sha256Hex(content) });
379
+ try {
380
+ const content = await readFileStrict(root, relPath);
381
+ if (!content || !Buffer.isBuffer(content.content) || !isDigest(content.sha256)) {
382
+ return { cacheKey: null, matchedFiles: matched, cacheable: false, reason: 'INPUT_READ_FAILED' };
383
+ }
384
+ fileEntries.push({ path: relPath, sha256: content.sha256 });
385
+ } catch {
386
+ return { cacheKey: null, matchedFiles: matched, cacheable: false, reason: 'INPUT_READ_FAILED' };
387
+ }
388
+ }
389
+
390
+ const observed = await observeHookExecutable(hook, root, options);
391
+ if (!observed.eligible) {
392
+ return { cacheKey: null, matchedFiles: matched, cacheable: false, reason: observed.reason };
167
393
  }
168
394
 
169
- // 摘要机制已委托 Foundation:canonicalJson(contracts 权威序列化 + 本地宽松
170
- // 输入域包装)与 sha256Hex(harness-node digestBytes)。拼接哈希组合无法用
171
- // digestDocument 表达,保持组合结构不变(迁移前后字节一致)。
172
- const cacheKey = sha256Hex(canonicalJson(hook) + canonicalJson(fileEntries));
173
- return { cacheKey, matchedFiles: matched };
395
+ const releaseSkillVersion = readCurrentReleaseSkillVersion();
396
+ if (releaseSkillVersion === null) {
397
+ return { cacheKey: null, matchedFiles: matched, cacheable: false, reason: 'VERSION_UNAVAILABLE' };
398
+ }
399
+ const selectedEnv = buildHookEnvironment(hook.envAllowlist ?? [], options.env ?? process.env);
400
+ const receipt = {
401
+ schemaVersion: CACHE_SCHEMA_VERSION,
402
+ algorithm: CACHE_ALGORITHM,
403
+ receiptVersion: CACHE_RECEIPT_VERSION,
404
+ hookDigest: sha256Hex(canonicalJson(hook)),
405
+ inputsDigest: sha256Hex(canonicalJson(fileEntries)),
406
+ envDigest: sha256Hex(canonicalJson(sortedObject(selectedEnv))),
407
+ releaseSkillVersion,
408
+ runtime: {
409
+ platform: process.platform,
410
+ arch: process.arch,
411
+ os: osRelease(),
412
+ node: process.versions.node,
413
+ },
414
+ projectRootIdentity: observed.projectRoot,
415
+ hookCwdIdentity: observed.hookCwd,
416
+ executableObservationDigest: observed.observationDigest,
417
+ };
418
+ const cacheKey = sha256Hex(canonicalJson(receipt));
419
+ return { cacheKey, matchedFiles: matched, cacheable: true, receipt };
174
420
  }
175
421
 
176
422
  /**
@@ -196,6 +442,10 @@ export function hookCachePath(root, hookName, cacheKey) {
196
442
  return join(hookCacheDir(root, hookName), `${cacheKey}.json`);
197
443
  }
198
444
 
445
+ function hookCacheRelativePath(hookName, cacheKey) {
446
+ return join(...CACHE_BASE, hookName, `${cacheKey}.json`);
447
+ }
448
+
199
449
  /**
200
450
  * Read a cached hook result. Returns the record only when it exists, its key
201
451
  * matches, and it recorded a successful (`exitCode === 0`) run; anything else
@@ -206,11 +456,36 @@ export function hookCachePath(root, hookName, cacheKey) {
206
456
  * @param {string} cacheKey
207
457
  * @returns {Promise<Object | null>}
208
458
  */
209
- export async function readHookCache(root, hookName, cacheKey) {
459
+ async function resolveCacheRoot(root, suppliedBinding) {
460
+ const projectRoot = await realpath(root);
461
+ const rootBinding = suppliedBinding ?? await createFilesystemRootBinding(projectRoot);
462
+ return { projectRoot, rootBinding };
463
+ }
464
+
465
+ export async function readHookCache(root, hookName, cacheKey, options = {}) {
210
466
  try {
211
- const raw = await readFile(hookCachePath(root, hookName, cacheKey), 'utf8');
467
+ const currentVersion = readCurrentReleaseSkillVersion();
468
+ if (currentVersion === null) return null;
469
+ const { projectRoot, rootBinding } = await resolveCacheRoot(root, options.rootBinding);
470
+ const rawReceipt = await readFileBound(projectRoot, hookCacheRelativePath(hookName, cacheKey), {
471
+ rootBinding,
472
+ encoding: 'utf8',
473
+ });
474
+ const raw = rawReceipt?.content;
475
+ if (typeof raw !== 'string') return null;
212
476
  const record = JSON.parse(raw);
213
- if (record && record.cacheKey === cacheKey && record.exitCode === 0) {
477
+ if (record && record.cacheKey === cacheKey && record.exitCode === 0
478
+ && hasExactKeys(record, CACHE_RECORD_KEYS)
479
+ && record.schemaVersion === CACHE_SCHEMA_VERSION
480
+ && record.kind === CACHE_KIND
481
+ && record.algorithm === CACHE_ALGORITHM
482
+ && record.receiptVersion === CACHE_RECEIPT_VERSION
483
+ && typeof record.createdAt === 'string'
484
+ && typeof record.stdoutTail === 'string'
485
+ && typeof record.stderrTail === 'string'
486
+ && record.stdoutTail.length <= TAIL_LENGTH
487
+ && record.stderrTail.length <= TAIL_LENGTH
488
+ && isValidReceipt(record.receipt, cacheKey, currentVersion)) {
214
489
  return record;
215
490
  }
216
491
  return null;
@@ -232,24 +507,41 @@ export async function readHookCache(root, hookName, cacheKey) {
232
507
  * @param {string} [result.stdoutTail] - Already truncated to TAIL_LENGTH.
233
508
  * @param {string} [result.stderrTail] - Already truncated to TAIL_LENGTH.
234
509
  * @param {string} [result.createdAt] - ISO timestamp (defaults to now).
510
+ * @param {Object} result.receipt - The v2 cache receipt produced with the key.
235
511
  * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
236
512
  */
237
- export async function writeHookCache(root, hookName, cacheKey, result) {
513
+ export async function writeHookCache(root, hookName, cacheKey, result, options = {}) {
238
514
  // Defence in depth: a failure must never be persisted, even if a caller
239
515
  // mistakenly passes a non-zero exit code.
240
516
  if (!result || result.exitCode !== 0) {
241
517
  return { ok: false, error: 'refusing to cache a non-zero exit result' };
242
518
  }
243
519
  try {
244
- await mkdir(hookCacheDir(root, hookName), { recursive: true });
520
+ const currentVersion = readCurrentReleaseSkillVersion();
521
+ if (!isValidReceipt(result.receipt, cacheKey, currentVersion)) {
522
+ return { ok: false, error: 'refusing to cache without a v2 receipt' };
523
+ }
245
524
  const record = {
525
+ schemaVersion: CACHE_SCHEMA_VERSION,
526
+ kind: CACHE_KIND,
527
+ algorithm: CACHE_ALGORITHM,
528
+ receiptVersion: CACHE_RECEIPT_VERSION,
246
529
  cacheKey,
247
530
  exitCode: 0,
248
531
  stdoutTail: String(result.stdoutTail ?? '').slice(-TAIL_LENGTH),
249
532
  stderrTail: String(result.stderrTail ?? '').slice(-TAIL_LENGTH),
250
533
  createdAt: result.createdAt ?? new Date().toISOString(),
534
+ receipt: result.receipt,
251
535
  };
252
- await writeFile(hookCachePath(root, hookName, cacheKey), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
536
+ const { projectRoot, rootBinding } = await resolveCacheRoot(root, options.rootBinding);
537
+ const relPath = hookCacheRelativePath(hookName, cacheKey);
538
+ const bytes = `${JSON.stringify(record, null, 2)}\n`;
539
+ try {
540
+ await publishFileExclusive(projectRoot, relPath, bytes, { mode: 0o644, createParents: true });
541
+ } catch (err) {
542
+ if (err?.details?.kind !== HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) throw err;
543
+ await publishFileOrReplace(projectRoot, relPath, bytes, { mode: 0o644 });
544
+ }
253
545
  return { ok: true };
254
546
  } catch (err) {
255
547
  return { ok: false, error: err.message };
@@ -161,7 +161,12 @@ export function validateHook(hook) {
161
161
  * @param {Record<string, string>} [contextEnv] - Caller-provided env map.
162
162
  * @returns {Record<string, string>}
163
163
  */
164
- function buildFilteredEnv(envAllowlist, contextEnv) {
164
+ /**
165
+ * Resolve the exact environment map delivered to a declared Hook. This is an
166
+ * internal shared seam for cache-key construction; it deliberately keeps the
167
+ * existing platform-required and allowlist precedence rules in one place.
168
+ */
169
+ export function buildHookEnvironment(envAllowlist = [], contextEnv) {
165
170
  const env = {};
166
171
 
167
172
  // 1. Platform-required variables
@@ -184,6 +189,8 @@ function buildFilteredEnv(envAllowlist, contextEnv) {
184
189
  return env;
185
190
  }
186
191
 
192
+ const buildFilteredEnv = buildHookEnvironment;
193
+
187
194
  // ---------------------------------------------------------------------------
188
195
  // Public API
189
196
  // ---------------------------------------------------------------------------
@@ -55,9 +55,9 @@ import { digestBytes, publishFileOrReplace, readFileStrict, withTemporaryWorkspa
55
55
  import { canonicalJson } from '../core/digest.mjs';
56
56
 
57
57
  export const FOUNDATION_PACKAGES = Object.freeze({
58
- 'skill-family-contracts': '0.14.0',
59
- 'skill-family-engineering-kit': '0.14.0',
60
- 'skill-family-harness-node': '0.14.0',
58
+ 'skill-family-contracts': '0.15.0',
59
+ 'skill-family-engineering-kit': '0.15.0',
60
+ 'skill-family-harness-node': '0.15.0',
61
61
  });
62
62
 
63
63
  export const BINDING_RECORD_PATH = 'bin/foundation-resource-binding.json';
@@ -0,0 +1,162 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.skill-family.example/v1/executable-identity-observation.json",
4
+ "title": "Executable identity observation",
5
+ "description": "A single, private observation of an executable lookup and its explicit launch image. Absolute roots and file bytes are intentionally represented only by the bound root and digest facts.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "kind", "lookup", "canonicalEntry", "interpreter", "launch", "observationDigest"],
9
+ "properties": {
10
+ "schemaVersion": { "const": 1 },
11
+ "kind": { "const": "skill-family.executable-identity-observation" },
12
+ "lookup": { "$ref": "#/$defs/lookup" },
13
+ "canonicalEntry": { "$ref": "#/$defs/canonicalEntry" },
14
+ "interpreter": {
15
+ "oneOf": [
16
+ { "type": "null" },
17
+ {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "required": ["request", "canonicalEntry", "shebangArgs"],
21
+ "properties": {
22
+ "request": {
23
+ "oneOf": [
24
+ { "$ref": "#/$defs/bareCommand" },
25
+ { "$ref": "#/$defs/absolutePath" }
26
+ ]
27
+ },
28
+ "canonicalEntry": {
29
+ "allOf": [
30
+ { "$ref": "#/$defs/canonicalEntry" },
31
+ { "type": "object", "properties": { "entryKind": { "const": "native-binary" } } }
32
+ ]
33
+ },
34
+ "shebangArgs": {
35
+ "type": "array",
36
+ "maxItems": 8,
37
+ "items": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000\\r\\n]+$" }
38
+ }
39
+ }
40
+ }
41
+ ]
42
+ },
43
+ "launch": {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "required": ["file", "argvPrefix"],
47
+ "properties": {
48
+ "file": { "$ref": "#/$defs/absolutePath" },
49
+ "argvPrefix": {
50
+ "type": "array",
51
+ "maxItems": 8,
52
+ "items": { "$ref": "#/$defs/absolutePath" }
53
+ }
54
+ }
55
+ },
56
+ "observationDigest": { "$ref": "#/$defs/sha256" }
57
+ },
58
+ "allOf": [
59
+ {
60
+ "if": {
61
+ "type": "object",
62
+ "properties": { "canonicalEntry": { "type": "object", "properties": { "entryKind": { "const": "interpreter-script" } }, "required": ["entryKind"] } },
63
+ "required": ["canonicalEntry"]
64
+ },
65
+ "then": {
66
+ "type": "object",
67
+ "properties": {
68
+ "interpreter": { "type": "object" },
69
+ "launch": { "type": "object", "properties": { "argvPrefix": { "type": "array", "minItems": 1 } } }
70
+ }
71
+ },
72
+ "else": {
73
+ "type": "object",
74
+ "properties": {
75
+ "interpreter": { "type": "null" },
76
+ "launch": { "type": "object", "properties": { "argvPrefix": { "type": "array", "maxItems": 0 } } }
77
+ }
78
+ }
79
+ }
80
+ ],
81
+ "$defs": {
82
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
83
+ "absolutePath": {
84
+ "type": "string",
85
+ "minLength": 1,
86
+ "maxLength": 4096,
87
+ "pattern": "^/(?:[^/\\\\\\u0000]+(?:/[^/\\\\\\u0000]+)*)?$",
88
+ "not": { "pattern": "(?:^|/)\\.{1,2}(?:/|$)" }
89
+ },
90
+ "relativePath": {
91
+ "type": "string",
92
+ "minLength": 1,
93
+ "maxLength": 4096,
94
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*(?:^|/)\\.(?:/|$))(?!.*\\\\)(?!.*\\u0000)[^/\\\\\\u0000]+(?:/[^/\\\\\\u0000]+)*$"
95
+ },
96
+ "bareCommand": {
97
+ "type": "string",
98
+ "minLength": 1,
99
+ "maxLength": 256,
100
+ "pattern": "^[^/\\\\\\u0000]+$"
101
+ },
102
+ "rootBinding": { "$ref": "https://contracts.skill-family.example/v1/filesystem-root-binding.json" },
103
+ "lookup": {
104
+ "type": "object",
105
+ "additionalProperties": false,
106
+ "required": ["requested", "mode", "selectedPath"],
107
+ "properties": {
108
+ "requested": { "type": "string", "minLength": 1, "maxLength": 4096 },
109
+ "mode": { "enum": ["absolute-path", "explicit-path-search"] },
110
+ "path": { "$ref": "#/$defs/absolutePath" },
111
+ "pathEntries": {
112
+ "type": "array",
113
+ "minItems": 1,
114
+ "maxItems": 64,
115
+ "uniqueItems": true,
116
+ "items": { "$ref": "#/$defs/absolutePath" }
117
+ },
118
+ "selectedPath": { "$ref": "#/$defs/absolutePath" }
119
+ },
120
+ "allOf": [
121
+ {
122
+ "if": { "type": "object", "properties": { "mode": { "const": "absolute-path" } }, "required": ["mode"] },
123
+ "then": {
124
+ "type": "object",
125
+ "required": ["path"],
126
+ "properties": { "path": {}, "pathEntries": false }
127
+ },
128
+ "else": {
129
+ "type": "object",
130
+ "required": ["pathEntries"],
131
+ "properties": { "pathEntries": {}, "path": false }
132
+ }
133
+ }
134
+ ]
135
+ },
136
+ "symlink": {
137
+ "type": "object",
138
+ "additionalProperties": false,
139
+ "required": ["path", "targetBase64", "bytes"],
140
+ "properties": {
141
+ "path": { "$ref": "#/$defs/absolutePath" },
142
+ "targetBase64": { "type": "string", "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" },
143
+ "bytes": { "type": "integer", "minimum": 0, "maximum": 1048576 }
144
+ }
145
+ },
146
+ "canonicalEntry": {
147
+ "type": "object",
148
+ "additionalProperties": false,
149
+ "required": ["path", "symlinkChain", "binding", "relPath", "sha256", "bytes", "statMode", "entryKind"],
150
+ "properties": {
151
+ "path": { "$ref": "#/$defs/absolutePath" },
152
+ "symlinkChain": { "type": "array", "maxItems": 40, "items": { "$ref": "#/$defs/symlink" } },
153
+ "binding": { "$ref": "#/$defs/rootBinding" },
154
+ "relPath": { "$ref": "#/$defs/relativePath" },
155
+ "sha256": { "$ref": "#/$defs/sha256" },
156
+ "bytes": { "type": "integer", "minimum": 0, "maximum": 1073741824 },
157
+ "statMode": { "type": "integer", "minimum": 0, "maximum": 4294967295 },
158
+ "entryKind": { "enum": ["native-binary", "interpreter-script", "opaque-executable"] }
159
+ }
160
+ }
161
+ }
162
+ }