release-skill 0.2.1 → 0.2.2

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 (54) 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 +21 -0
  7. package/INSTALL.md +183 -21
  8. package/INSTALL.zh-CN.md +160 -16
  9. package/README.md +112 -11
  10. package/README.zh-CN.md +73 -10
  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/release-skill.bundle.mjs +1718 -600
  14. package/adapters/claude/schemas/release-plan.schema.json +44 -2
  15. package/adapters/claude/schemas/release-project.schema.json +46 -4
  16. package/adapters/claude/schemas/release-run.schema.json +1 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +1718 -600
  19. package/adapters/codex/schemas/release-plan.schema.json +44 -2
  20. package/adapters/codex/schemas/release-project.schema.json +46 -4
  21. package/adapters/codex/schemas/release-run.schema.json +1 -0
  22. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  23. package/adapters/kimi/bin/release-skill.bundle.mjs +1718 -600
  24. package/adapters/kimi/schemas/release-plan.schema.json +44 -2
  25. package/adapters/kimi/schemas/release-project.schema.json +46 -4
  26. package/adapters/kimi/schemas/release-run.schema.json +1 -0
  27. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +4 -2
  28. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1718 -600
  29. package/adapters/workbuddy/schemas/release-plan.schema.json +44 -2
  30. package/adapters/workbuddy/schemas/release-project.schema.json +46 -4
  31. package/adapters/workbuddy/schemas/release-run.schema.json +1 -0
  32. package/bin/release-skill-cli.mjs +46 -4
  33. package/bin/release-skill.bundle.mjs +1718 -600
  34. package/package.json +1 -1
  35. package/references/02-project-config.md +7 -0
  36. package/references/06-adapter-contract.md +21 -2
  37. package/schemas/release-plan.schema.json +44 -2
  38. package/schemas/release-project.schema.json +46 -4
  39. package/schemas/release-run.schema.json +1 -0
  40. package/scripts/sync-public-files.mjs +8 -4
  41. package/src/adapters/contract.mjs +1 -0
  42. package/src/adapters/plugin-marketplace.mjs +536 -23
  43. package/src/commands/assess.mjs +50 -1
  44. package/src/commands/prepare.mjs +273 -9
  45. package/src/commands/publish.mjs +1 -0
  46. package/src/commands/reconcile.mjs +1 -0
  47. package/src/commands/setup.mjs +7 -3
  48. package/src/commands/verify.mjs +2 -0
  49. package/src/core/checkpoints.mjs +7 -2
  50. package/src/core/plan.mjs +97 -4
  51. package/src/core/verification-gates.mjs +1 -1
  52. package/src/platforms/codebuddy.mjs +618 -0
  53. package/src/platforms/registry.mjs +100 -5
  54. package/src/producers/build-adapters.mjs +21 -13
@@ -0,0 +1,618 @@
1
+ /**
2
+ * CodeBuddy / WorkBuddy platform protocol (human attestation closed loop).
3
+ *
4
+ * CodeBuddy (desktop product WorkBuddy) ships a `codebuddy` CLI that is
5
+ * structurally identical to claude-code, BUT its plugin install protocol cannot
6
+ * pin a frozen ref: `plugin marketplace add <source>` and `plugin install
7
+ * <plugin[@marketplace]>` have NO ref option, and the install tracks the
8
+ * marketplace default branch / latest (verified against the real CLI and its
9
+ * on-disk state model). An automated install checkpoint therefore cannot
10
+ * guarantee the frozen artifact's identity, which this project's security model
11
+ * requires. The install state DOES have a stable on-disk layout that serves as
12
+ * verification evidence.
13
+ *
14
+ * So codebuddy-marketplace-install is modeled exactly like kimi's protocol
15
+ * capability gap (a human attestation closed loop), with two differences driven
16
+ * by the verified CodeBuddy state model:
17
+ *
18
+ * - Install is from a unified marketplace (`artifact-skill-set`,
19
+ * https://github.com/ifoohoo/artifact-skill-set), NOT a release-tag URL.
20
+ * - Two install channels are accepted, both evidenced on disk:
21
+ * desktop: the WorkBuddy desktop app installs into
22
+ * `~/.workbuddy/plugins/marketplaces/<marketplace>/plugins/<plugin>/`
23
+ * cli: the bundled `codebuddy` CLI, run with an isolated
24
+ * `HOME=<codebuddyHome>`, installs into
25
+ * `<codebuddyHome>/.codebuddy/plugins/marketplaces/<marketplace>/plugins/<plugin>/`
26
+ * The attestation names the channel (`installChannel`) and the marketplace;
27
+ * the install path is validated per channel and fails closed otherwise.
28
+ *
29
+ * - execute NEVER execs the codebuddy CLI. It emits an actionable manual
30
+ * install requirement bound to the frozen plan digest + identity.
31
+ * - observe/verify consume a structured human attestation plus read-only
32
+ * verification of the installed copy. Missing/expired/mismatched/escaping
33
+ * proof fails closed, so a codebuddy unit can never reach VERIFIED without
34
+ * it — the same fail-closed severity as kimi ("post-publish verification
35
+ * cannot be waived").
36
+ *
37
+ * This module is the codebuddy half of the platform registry's strategy table
38
+ * (registry.mjs references executeCodeBuddyManualRequirement /
39
+ * readCodeBuddyManifest); the plugin-marketplace adapter consumes the
40
+ * attestation path from here through a codebuddy-specific branch that mirrors
41
+ * the kimi branch. The shared adapter primitives (safe-id pattern,
42
+ * frozen-timeout validation, atomic evidence writes) live in
43
+ * adapters/contract.mjs.
44
+ *
45
+ * NOTE on intentional duplication: the 64-hex digest pattern and the
46
+ * lifecycle plan normalization below mirror kimi.mjs's pure helpers instead of
47
+ * importing them. Importing from ./kimi.mjs would add a codebuddy -> kimi edge
48
+ * that, combined with the kimi -> plan -> registry -> kimi cycle, deadlocks the
49
+ * esbuild-generated module initializers in the self-contained bundle (the
50
+ * `prepare` dynamic import never settles). Inlining these two tiny pure
51
+ * functions keeps codebuddy's only cycle edge codebuddy -> plan -> registry
52
+ * (the same shape kimi already ships with) and the bundle evaluating cleanly.
53
+ *
54
+ * @module platforms/codebuddy
55
+ */
56
+
57
+ import { readFile, mkdir } from 'node:fs/promises';
58
+ import { join, resolve, relative, isAbsolute } from 'node:path';
59
+
60
+ import {
61
+ ActionType,
62
+ ActionStatus,
63
+ createResult,
64
+ resolveTimeoutMs,
65
+ SAFE_ID_RE,
66
+ writeEvidenceAtomic,
67
+ } from '../adapters/contract.mjs';
68
+ import { canonicalJson } from '../core/digest.mjs';
69
+ // NOTE: computePlanDigest (../core/plan.mjs) is imported LAZILY inside
70
+ // resolveCodeBuddyBoundPlanDigest, NOT statically here. A static import would
71
+ // add a registry -> codebuddy -> plan -> registry cycle edge; combined with the
72
+ // existing kimi -> plan -> registry -> kimi cycle, that deadlocks esbuild's
73
+ // generated module initializers in the self-contained bundle (the `prepare`
74
+ // dynamic import never settles). kimi.mjs predates this constraint; codebuddy
75
+ // keeps the bundle graph acyclic by loading plan.mjs at call time (by then
76
+ // every module is initialized, so the lazy import resolves immediately).
77
+
78
+ /** 64-char lowercase hex plan/payload digest pattern (mirrors kimi.mjs). */
79
+ const HEX_DIGEST_RE = /^[a-f0-9]{64}$/;
80
+
81
+ /**
82
+ * Normalize a plan back to its frozen form for digest comparison (mirrors the
83
+ * pure helper in kimi.mjs; inlined here to avoid a codebuddy -> kimi import
84
+ * edge — see the module note). Only lifecycle status fields are reset: the
85
+ * top-level `status` returns to "PREPARED" and every `externalActions[].status`
86
+ * returns to "PENDING"; every other field is preserved verbatim.
87
+ *
88
+ * @param {object} plan
89
+ * @returns {object} the lifecycle-normalized plan
90
+ */
91
+ function normalizePlanForDigest(plan) {
92
+ const normalized = { ...plan, status: 'PREPARED' };
93
+ if (Array.isArray(plan.externalActions)) {
94
+ normalized.externalActions = plan.externalActions.map((action) => (
95
+ action && typeof action === 'object' && !Array.isArray(action)
96
+ ? { ...action, status: 'PENDING' }
97
+ : action
98
+ ));
99
+ }
100
+ return normalized;
101
+ }
102
+
103
+ /** Structured manual-install requirement written by codebuddy execute. */
104
+ export const CODEBUDDY_REQUIREMENT_FILE = 'release-skill-codebuddy-manual-install.json';
105
+ /** Structured human attestation consumed by codebuddy observe/verify. */
106
+ export const CODEBUDDY_ATTESTATION_FILE = 'release-skill-codebuddy-attestation.json';
107
+ /** Maximum attestation validity window (mirrors the 24h approval expiry). */
108
+ export const CODEBUDDY_MAX_ATTESTATION_VALIDITY_MS = 24 * 60 * 60 * 1000;
109
+
110
+ /**
111
+ * Unified marketplace the WorkBuddy desktop app and the codebuddy CLI install
112
+ * release-skill from (verified fact). The attestation `marketplace` field must
113
+ * equal this value; the install path is validated against this marketplace
114
+ * segment in both channels.
115
+ */
116
+ export const CODEBUDDY_MARKETPLACE_NAME = 'artifact-skill-set';
117
+ /** Marketplace source URL used by the isolated-CLI install path. */
118
+ export const CODEBUDDY_MARKETPLACE_SOURCE = 'https://github.com/ifoohoo/artifact-skill-set';
119
+
120
+ /** Authoritative codebuddy plugin manifest (single candidate, no precedence). */
121
+ export const CODEBUDDY_PLUGIN_MANIFEST_RELATIVE = join('.codebuddy-plugin', 'plugin.json');
122
+
123
+ /** The only two install channels with verified on-disk evidence. */
124
+ const CODEBUDDY_INSTALL_CHANNELS = new Set(['desktop', 'cli']);
125
+
126
+ /** Well-known desktop install root (the WorkBuddy app's home dir name). */
127
+ const CODEBUDDY_DESKTOP_HOME_DIR = '.workbuddy';
128
+ /** CLI-side plugin state dir under the isolated HOME. */
129
+ const CODEBUDDY_CLI_STATE_DIR = '.codebuddy';
130
+
131
+ /**
132
+ * Resolve and verify the genuine frozen plan digest from the adapter context,
133
+ * for the codebuddy closed loop.
134
+ *
135
+ * Mirrors kimi's resolveBoundPlanDigest byte-for-byte in LOGIC (it reuses the
136
+ * same pure `normalizePlanForDigest`), but speaks in codebuddy terms so the
137
+ * codebuddy requirement/attestation error messages are codebuddy-worded. The
138
+ * carried `context.plan.digest` is recomputed from the lifecycle-normalized
139
+ * plan and must match exactly; any non-lifecycle tamper fails closed.
140
+ *
141
+ * @param {object} context - adapter context (must carry the frozen `plan`).
142
+ * @returns {Promise<string>} the verified frozen plan digest.
143
+ * @throws {Error} when the plan is absent or the digest does not match.
144
+ */
145
+ export async function resolveCodeBuddyBoundPlanDigest(context) {
146
+ const plan = context?.plan;
147
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
148
+ throw new Error('context.plan is required to bind the codebuddy plan digest');
149
+ }
150
+ const carried = plan.digest;
151
+ if (typeof carried !== 'string' || !HEX_DIGEST_RE.test(carried)) {
152
+ throw new Error('context.plan.digest must be a 64-char lowercase hex frozen plan digest');
153
+ }
154
+ // Lazy import keeps the static bundle graph acyclic (see module note).
155
+ const { computePlanDigest } = await import('../core/plan.mjs');
156
+ const normalized = normalizePlanForDigest(plan);
157
+ if (computePlanDigest(normalized) !== carried) {
158
+ throw new Error('context.plan.digest does not match the normalized frozen plan (a non-lifecycle field was tampered)');
159
+ }
160
+ return carried;
161
+ }
162
+
163
+ /**
164
+ * Authoritative, cross-run attestation directory for a codebuddy install.
165
+ *
166
+ * Lives at a stable root-fixed location keyed by the verified frozen plan
167
+ * digest and plugin id:
168
+ * <root>/.release-skill/codebuddy-attestations/<planDigest>/<plugin>/
169
+ *
170
+ * This survives the publish -> manual install -> reconcile -> verify chain,
171
+ * where each command otherwise uses a fresh runDir. Both the requirement and
172
+ * the human attestation live here. Segments are pre-validated (planDigest is
173
+ * 64-hex, plugin matches SAFE_ID_RE) and the resolved path is contained within
174
+ * the authority base, so no path escape is possible. (Path-escape validation is
175
+ * copied verbatim from kimiAuthorityDir, with codebuddy wording.)
176
+ *
177
+ * @param {object} context - adapter context (needs `root`).
178
+ * @param {string} planDigest - verified frozen plan digest (64-hex).
179
+ * @param {string} plugin - plugin id (SAFE_ID_RE).
180
+ * @returns {string} absolute authority directory.
181
+ */
182
+ export function codebuddyAuthorityDir(context, planDigest, plugin) {
183
+ if (!context?.root) {
184
+ throw new Error('context.root is required for the codebuddy attestation authority');
185
+ }
186
+ if (!HEX_DIGEST_RE.test(planDigest)) {
187
+ throw new Error('codebuddy attestation authority requires a 64-hex plan digest');
188
+ }
189
+ if (!SAFE_ID_RE.test(plugin)) {
190
+ throw new Error(`codebuddy attestation authority requires a safe plugin id: "${plugin}"`);
191
+ }
192
+ const base = resolve(context.root, '.release-skill', 'codebuddy-attestations');
193
+ const dir = resolve(base, planDigest, plugin);
194
+ const rel = relative(base, dir);
195
+ const sep = process.platform === 'win32' ? '\\' : '/';
196
+ if (
197
+ rel === '' || rel === '..' || isAbsolute(rel) || rel.startsWith(`..${sep}`)
198
+ || rel.split(sep).some((segment) => segment === '..' || segment === '')
199
+ ) {
200
+ throw new Error('codebuddy attestation authority path escapes its base');
201
+ }
202
+ return dir;
203
+ }
204
+
205
+ /**
206
+ * The isolated CodeBuddy CLI HOME for the cli install channel. The operator
207
+ * runs the bundled `codebuddy` CLI with `HOME=<codebuddyHome>` so the managed
208
+ * marketplace clone lands inside the plan-digest-keyed authority rather than
209
+ * the user's real `~/.codebuddy`. Identical across publish/reconcile/verify
210
+ * run dirs because it derives from the authority dir.
211
+ *
212
+ * @param {string} attestationDir - plan-digest-keyed authority dir.
213
+ * @returns {string}
214
+ */
215
+ export function codebuddyCliHome(attestationDir) {
216
+ return resolve(attestationDir, 'codebuddy-home');
217
+ }
218
+
219
+ /**
220
+ * The expected CLI-channel install root for a plugin under an isolated home.
221
+ * `<codebuddyHome>/.codebuddy/plugins/marketplaces/<marketplace>/plugins/<plugin>`
222
+ *
223
+ * @param {string} cliHome - isolated codebuddy HOME.
224
+ * @param {string} marketplace - marketplace name.
225
+ * @param {string} plugin - plugin id.
226
+ * @returns {string}
227
+ */
228
+ export function codebuddyCliInstallRoot(cliHome, marketplace, plugin) {
229
+ return resolve(cliHome, CODEBUDDY_CLI_STATE_DIR, 'plugins', 'marketplaces', marketplace, 'plugins', plugin);
230
+ }
231
+
232
+ /**
233
+ * Segment-level tail an attested desktop installPath must end with:
234
+ * `/.workbuddy/plugins/marketplaces/<marketplace>/plugins/<plugin>`
235
+ * (verified WorkBuddy desktop layout).
236
+ *
237
+ * @param {string} marketplace
238
+ * @param {string} plugin
239
+ * @returns {string[]}
240
+ */
241
+ function codebuddyDesktopTailSegments(marketplace, plugin) {
242
+ return [CODEBUDDY_DESKTOP_HOME_DIR, 'plugins', 'marketplaces', marketplace, 'plugins', plugin];
243
+ }
244
+
245
+ /**
246
+ * Segment-level check that a desktop installPath ends with the well-known
247
+ * WorkBuddy marketplace layout. Pure string check (no filesystem): the path's
248
+ * segments (split on `/` or `\`, dropping empty/`.` segments) must end with the
249
+ * desktop tail. Fails closed otherwise.
250
+ *
251
+ * @param {string} installPath
252
+ * @param {string} marketplace
253
+ * @param {string} plugin
254
+ * @returns {boolean}
255
+ */
256
+ function matchesDesktopLayout(installPath, marketplace, plugin) {
257
+ const segments = installPath.split(/[\\/]+/).filter((s) => s !== '' && s !== '.');
258
+ const tail = codebuddyDesktopTailSegments(marketplace, plugin);
259
+ if (segments.length < tail.length) return false;
260
+ const offset = segments.length - tail.length;
261
+ return tail.every((segment, index) => segments[offset + index] === segment);
262
+ }
263
+
264
+ /**
265
+ * Human-facing, actionable manual-install closed-loop instructions for
266
+ * CodeBuddy / WorkBuddy.
267
+ *
268
+ * @param {{plugin:string, version:string, ref:string, cliHome:string, attestationDir:string}} p
269
+ * @returns {string[]}
270
+ */
271
+ function buildCodeBuddyManualInstructions({ plugin, version, ref, cliHome, attestationDir }) {
272
+ const cliInstallRoot = codebuddyCliInstallRoot(cliHome, CODEBUDDY_MARKETPLACE_NAME, plugin);
273
+ const desktopInstallRoot = `~/${CODEBUDDY_DESKTOP_HOME_DIR}/plugins/marketplaces/${CODEBUDDY_MARKETPLACE_NAME}/plugins/${plugin}`;
274
+ return [
275
+ `CodeBuddy/WorkBuddy plugin install cannot pin a frozen ref (the codebuddy CLI marketplace add/install have no ref option and track the default branch), so installation is a manual step proven by a human attestation.`,
276
+ `1) publish fails closed at this codebuddy checkpoint and leaves the run PARTIAL (the automated Git branch/tag, npm, and GitHub Release writes still complete first).`,
277
+ `2) PRIMARY PATH (WorkBuddy desktop): install release-skill from the unified marketplace "${CODEBUDDY_MARKETPLACE_NAME}" (${CODEBUDDY_MARKETPLACE_SOURCE}). Confirm "~/.workbuddy/settings.json" enabledPlugins contains "${plugin}@${CODEBUDDY_MARKETPLACE_NAME}": true. The plugin lands at "${desktopInstallRoot}/".`,
278
+ `3) ALTERNATE PATH (bundled codebuddy CLI, isolatable): run the CLI with the ISOLATED home from this requirement so the clone lands inside it: HOME="${cliHome}" <codebuddy binary> plugin marketplace add ${CODEBUDDY_MARKETPLACE_SOURCE} ; then HOME="${cliHome}" <codebuddy binary> plugin install ${plugin}@${CODEBUDDY_MARKETPLACE_NAME}. The CLI ships with WorkBuddy.app (macOS known path /Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy). The plugin lands at "${cliInstallRoot}/".`,
279
+ `4) REF LIMITATION WARNING: a codebuddy install tracks the marketplace default branch and CANNOT be pinned to frozen ref "${ref}". Before writing the attestation you MUST confirm the installed plugin manifest version equals the frozen version ${version}; otherwise do NOT issue an attestation.`,
280
+ `5) Write the attestation JSON to: ${attestationDir}/${CODEBUDDY_ATTESTATION_FILE}. planDigest MUST be the frozen plan digest; payloadDigest MUST be the frozen snapshot payload digest; installChannel MUST be "desktop" or "cli"; marketplace MUST be "${CODEBUDDY_MARKETPLACE_NAME}"; installPath MUST be the actual installed plugin directory for the chosen channel. attestedAt must not be in the future and expiresAt must be within 24 hours of attestedAt.`,
281
+ ` Required fields: consumer="codebuddy", plugin, version, entrySkill, repo, ref, marketplace, installChannel, installPath, planDigest, payloadDigest, attestedBy, attestedAt, expiresAt.`,
282
+ `6) Re-run release-skill reconcile (promotes PARTIAL -> PUBLISHED) and then verify (-> VERIFIED). Both read the attestation from this same plan-digest-keyed authority directory, so a fresh run directory does not lose the proof.`,
283
+ ];
284
+ }
285
+
286
+ /**
287
+ * Read the authoritative CodeBuddy plugin manifest from a verified plugin root.
288
+ *
289
+ * Single candidate `.codebuddy-plugin/plugin.json` (no precedence chain, unlike
290
+ * kimi). Returns the parsed manifest and the root-relative manifest path.
291
+ * Throws when the manifest is absent, not valid JSON, or not an object.
292
+ *
293
+ * @param {string} pluginRootReal - realpath of the verified plugin root.
294
+ * @returns {Promise<{manifest:object, manifestRelative:string}>}
295
+ */
296
+ export async function readCodeBuddyManifest(pluginRootReal) {
297
+ const manifestRelative = CODEBUDDY_PLUGIN_MANIFEST_RELATIVE;
298
+ const manifestPath = resolve(pluginRootReal, manifestRelative);
299
+ let content;
300
+ try {
301
+ content = await readFile(manifestPath, 'utf8');
302
+ } catch {
303
+ throw new Error(`no codebuddy plugin manifest found (expected ${manifestRelative})`);
304
+ }
305
+ let manifest;
306
+ try {
307
+ manifest = JSON.parse(content);
308
+ } catch {
309
+ throw new Error(`codebuddy plugin manifest ${manifestRelative} is not valid JSON`);
310
+ }
311
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
312
+ throw new Error(`codebuddy plugin manifest ${manifestRelative} is not an object`);
313
+ }
314
+ return { manifest, manifestRelative };
315
+ }
316
+
317
+ /**
318
+ * Validate a structured codebuddy manual-install attestation against the frozen
319
+ * action and the verified frozen plan digest.
320
+ *
321
+ * Bindings (fail closed on any mismatch):
322
+ * - `consumer` must be "codebuddy".
323
+ * - `installChannel` must be "desktop" or "cli".
324
+ * - `marketplace` must equal the requirement-declared unified marketplace
325
+ * (`artifact-skill-set`).
326
+ * - `planDigest` binds to the REAL frozen plan digest (`boundPlanDigest`, from
327
+ * `context.plan.digest`) — NOT to `action.manifestDigest`.
328
+ * - `payloadDigest` binds separately to `action.manifestDigest`.
329
+ * - plugin identity, version, entry skill, repo, and frozen ref must match.
330
+ * - Time bounds: `attestedAt` not in the future, window (`expiresAt -
331
+ * attestedAt`) <= 24h, and not expired relative to `isoNow`.
332
+ * - installPath per channel (lexical): desktop must end with the well-known
333
+ * `/.workbuddy/plugins/marketplaces/<marketplace>/plugins/<plugin>` segment
334
+ * tail. The cli channel's containment within the isolated home is enforced in
335
+ * the adapter observe branch (it needs the context-derived isolated home and
336
+ * realpath resolution, mirroring kimi's observe-side managed-root check).
337
+ *
338
+ * @param {object} attestation - parsed attestation JSON.
339
+ * @param {object} action - the expanded codebuddy action (top-level fields).
340
+ * @param {string} isoNow - current ISO timestamp.
341
+ * @param {string} boundPlanDigest - verified frozen plan digest.
342
+ * @returns {{valid:boolean, error:string|null}}
343
+ */
344
+ export function validateCodeBuddyAttestation(attestation, action, isoNow, boundPlanDigest) {
345
+ if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)) {
346
+ return { valid: false, error: 'codebuddy attestation is not an object' };
347
+ }
348
+ const requiredStrings = ['plugin', 'version', 'entrySkill', 'repo', 'ref', 'installPath', 'payloadDigest', 'planDigest', 'attestedBy', 'attestedAt', 'expiresAt', 'marketplace', 'installChannel'];
349
+ for (const field of requiredStrings) {
350
+ if (typeof attestation[field] !== 'string' || attestation[field].length === 0) {
351
+ return { valid: false, error: `codebuddy attestation missing required field "${field}"` };
352
+ }
353
+ }
354
+ if (attestation.consumer !== 'codebuddy') {
355
+ return { valid: false, error: `codebuddy attestation consumer "${attestation.consumer}" must be "codebuddy"` };
356
+ }
357
+ if (!CODEBUDDY_INSTALL_CHANNELS.has(attestation.installChannel)) {
358
+ return { valid: false, error: `codebuddy attestation installChannel "${attestation.installChannel}" must be "desktop" or "cli"` };
359
+ }
360
+ if (attestation.marketplace !== CODEBUDDY_MARKETPLACE_NAME) {
361
+ return { valid: false, error: `codebuddy attestation marketplace "${attestation.marketplace}" must be "${CODEBUDDY_MARKETPLACE_NAME}"` };
362
+ }
363
+ if (!HEX_DIGEST_RE.test(attestation.planDigest)) {
364
+ return { valid: false, error: 'codebuddy attestation planDigest must be a 64-char lowercase hex digest' };
365
+ }
366
+ if (attestation.planDigest !== boundPlanDigest) {
367
+ return { valid: false, error: 'codebuddy attestation planDigest does not match the frozen plan digest' };
368
+ }
369
+ if (attestation.plugin !== action.plugin) {
370
+ return { valid: false, error: `codebuddy attestation plugin "${attestation.plugin}" does not match action plugin "${action.plugin}"` };
371
+ }
372
+ if (attestation.version !== action.version) {
373
+ return { valid: false, error: `codebuddy attestation version "${attestation.version}" does not match action version "${action.version}"` };
374
+ }
375
+ if (attestation.entrySkill !== action.entrySkill) {
376
+ return { valid: false, error: `codebuddy attestation entrySkill "${attestation.entrySkill}" does not match action entrySkill "${action.entrySkill}"` };
377
+ }
378
+ if (attestation.repo !== action.repo) {
379
+ return { valid: false, error: `codebuddy attestation repo "${attestation.repo}" does not match action repo "${action.repo}"` };
380
+ }
381
+ const expectedRef = action.ref ?? `v${action.version}`;
382
+ if (attestation.ref !== expectedRef) {
383
+ return { valid: false, error: `codebuddy attestation ref "${attestation.ref}" does not match frozen ref "${expectedRef}"` };
384
+ }
385
+ if (attestation.payloadDigest !== action.manifestDigest) {
386
+ return { valid: false, error: 'codebuddy attestation payloadDigest does not match the frozen payload digest' };
387
+ }
388
+ const attestedMs = Date.parse(attestation.attestedAt);
389
+ const expiresMs = Date.parse(attestation.expiresAt);
390
+ const nowMs = Date.parse(isoNow);
391
+ if (!Number.isFinite(attestedMs) || !Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) {
392
+ return { valid: false, error: 'codebuddy attestation attestedAt/expiresAt must be valid ISO timestamps' };
393
+ }
394
+ if (attestedMs > nowMs) {
395
+ return { valid: false, error: 'codebuddy attestation attestedAt is in the future' };
396
+ }
397
+ if (expiresMs <= attestedMs) {
398
+ return { valid: false, error: 'codebuddy attestation expiresAt must be after attestedAt' };
399
+ }
400
+ if (expiresMs - attestedMs > CODEBUDDY_MAX_ATTESTATION_VALIDITY_MS) {
401
+ return { valid: false, error: 'codebuddy attestation validity must not exceed 24 hours' };
402
+ }
403
+ if (nowMs > expiresMs) {
404
+ return { valid: false, error: 'codebuddy attestation has expired' };
405
+ }
406
+ // Desktop installPath must end with the well-known WorkBuddy marketplace
407
+ // layout (segment-level). The cli channel's isolated-home containment is
408
+ // enforced in the adapter observe branch (needs the context-derived isolated
409
+ // home + realpath), mirroring kimi's observe-side managed-root check.
410
+ if (attestation.installChannel === 'desktop'
411
+ && !matchesDesktopLayout(attestation.installPath, attestation.marketplace, attestation.plugin)) {
412
+ return { valid: false, error: `codebuddy attestation installPath does not match the WorkBuddy desktop marketplace layout (.workbuddy/plugins/marketplaces/${attestation.marketplace}/plugins/${attestation.plugin})` };
413
+ }
414
+ return { valid: true, error: null };
415
+ }
416
+
417
+ /**
418
+ * CodeBuddy protocol capability gap: the codebuddy CLI cannot pin a frozen ref,
419
+ * so there is NO trustworthy automated install checkpoint. execute NEVER execs a
420
+ * codebuddy command. Instead it emits an actionable manual-install requirement
421
+ * bound to the real frozen plan digest + identity (naming both install channels
422
+ * and the unified marketplace), and leaves success to observe/verify, which
423
+ * consume only a trusted human attestation plus read-only verification. Without
424
+ * that proof the checkpoint fails closed and can never reach VERIFIED.
425
+ *
426
+ * Isolation model: the cli channel's isolated home is a STABLE, plan-digest-keyed
427
+ * directory under the attestation authority (`<authorityDir>/codebuddy-home`).
428
+ * execute creates the isolated home (so the operator's CLI has a HOME to write
429
+ * into) but never the marketplace install subdir (the operator's CLI install
430
+ * creates that). The desktop channel uses the user's real `~/.workbuddy` and
431
+ * needs no isolated home. The requirement write is idempotent: an identical
432
+ * existing requirement is left untouched, a divergent one fails closed.
433
+ *
434
+ * Referenced from the registry as the codebuddy strategy.buildManualRequirement
435
+ * — the automatable=false manual-requirement path.
436
+ *
437
+ * @param {object} action - expanded codebuddy action (validated params already).
438
+ * @param {object} context - adapter context (root, runDir, plan).
439
+ * @returns {Promise<import('../adapters/contract.mjs').AdapterResult>}
440
+ */
441
+ export async function executeCodeBuddyManualRequirement(action, context) {
442
+ const actionType = ActionType.CODEBUDDY_MARKETPLACE_INSTALL;
443
+
444
+ // (A) Bind to the REAL frozen plan digest via strict normalized recompute.
445
+ let planDigest;
446
+ try {
447
+ planDigest = await resolveCodeBuddyBoundPlanDigest(context);
448
+ } catch (planErr) {
449
+ return createResult({
450
+ actionType,
451
+ status: ActionStatus.EXECUTE_FAILED,
452
+ error: `cannot bind codebuddy requirement to the frozen plan: ${planErr.message}`,
453
+ });
454
+ }
455
+
456
+ // Validate the frozen timeout. CodeBuddy execs no CLI, but the frozen-timeout
457
+ // fail-closed invariant still holds for every marketplace action.
458
+ try {
459
+ resolveTimeoutMs(action);
460
+ } catch (timeoutErr) {
461
+ return createResult({
462
+ actionType,
463
+ status: ActionStatus.EXECUTE_FAILED,
464
+ error: timeoutErr.message,
465
+ });
466
+ }
467
+
468
+ const ref = action.ref ?? `v${action.version}`;
469
+
470
+ // (B) Stable, plan-digest-keyed authority dir, shared across
471
+ // publish/reconcile/verify run dirs.
472
+ let attestationDir;
473
+ try {
474
+ attestationDir = codebuddyAuthorityDir(context, planDigest, action.plugin);
475
+ } catch (dirErr) {
476
+ return createResult({
477
+ actionType,
478
+ status: ActionStatus.EXECUTE_FAILED,
479
+ error: dirErr.message,
480
+ });
481
+ }
482
+ const cliHome = codebuddyCliHome(attestationDir);
483
+ const cliInstallRoot = codebuddyCliInstallRoot(cliHome, CODEBUDDY_MARKETPLACE_NAME, action.plugin);
484
+ const desktopInstallRoot = `~/${CODEBUDDY_DESKTOP_HOME_DIR}/plugins/marketplaces/${CODEBUDDY_MARKETPLACE_NAME}/plugins/${action.plugin}`;
485
+
486
+ const instructions = buildCodeBuddyManualInstructions({
487
+ plugin: action.plugin,
488
+ version: action.version,
489
+ ref,
490
+ cliHome,
491
+ attestationDir,
492
+ });
493
+
494
+ const requirement = {
495
+ kind: 'codebuddy-manual-install-requirement',
496
+ consumer: 'codebuddy',
497
+ plugin: action.plugin,
498
+ version: action.version,
499
+ entrySkill: action.entrySkill,
500
+ repo: action.repo,
501
+ ref,
502
+ marketplace: CODEBUDDY_MARKETPLACE_NAME,
503
+ marketplaceSource: CODEBUDDY_MARKETPLACE_SOURCE,
504
+ installChannels: ['desktop', 'cli'],
505
+ // (A) planDigest binds to the real frozen plan digest;
506
+ // expectedPayloadDigest binds separately to the snapshot payload digest.
507
+ planDigest,
508
+ expectedPayloadDigest: action.manifestDigest,
509
+ isolatedHome: cliHome,
510
+ codebuddyHome: cliHome,
511
+ cliInstallRoot,
512
+ desktopInstallRoot,
513
+ attestationDir,
514
+ attestationFile: CODEBUDDY_ATTESTATION_FILE,
515
+ attestationTemplate: {
516
+ consumer: 'codebuddy',
517
+ plugin: action.plugin,
518
+ version: action.version,
519
+ entrySkill: action.entrySkill,
520
+ repo: action.repo,
521
+ ref,
522
+ marketplace: CODEBUDDY_MARKETPLACE_NAME,
523
+ installChannel: '<"desktop" or "cli">',
524
+ installPath: '<actual installed plugin directory for the chosen channel>',
525
+ planDigest,
526
+ payloadDigest: action.manifestDigest,
527
+ attestedBy: '<person responsible for the manual install>',
528
+ attestedAt: '<ISO 8601 now; must not be in the future>',
529
+ expiresAt: '<ISO 8601; within 24h of attestedAt>',
530
+ },
531
+ instructions,
532
+ };
533
+
534
+ // Create ONLY the isolated cli home (which transitively creates the authority
535
+ // dir); never pre-create the marketplace install subdir — the operator's CLI
536
+ // install (cli channel) or the WorkBuddy desktop app (desktop channel) owns
537
+ // the actual install directory.
538
+ try {
539
+ await mkdir(cliHome, { recursive: true, mode: 0o700 });
540
+ } catch (mkdirErr) {
541
+ return createResult({
542
+ actionType,
543
+ status: ActionStatus.EXECUTE_FAILED,
544
+ error: `cannot create codebuddy isolated home directory: ${mkdirErr.message}`,
545
+ });
546
+ }
547
+
548
+ // Idempotent requirement write: an identical existing requirement is left
549
+ // untouched; a divergent existing requirement fails closed (never silently
550
+ // overwritten). `createdAt` is volatile and excluded from the comparison.
551
+ const requirementPath = resolve(attestationDir, CODEBUDDY_REQUIREMENT_FILE);
552
+ let existing = null;
553
+ let requirementMissing = false;
554
+ try {
555
+ const existingRaw = await readFile(requirementPath, 'utf8');
556
+ try {
557
+ existing = JSON.parse(existingRaw);
558
+ } catch (parseErr) {
559
+ return createResult({
560
+ actionType,
561
+ status: ActionStatus.EXECUTE_FAILED,
562
+ error: `existing codebuddy manual-install requirement is invalid JSON; refusing to overwrite: ${parseErr.message}`,
563
+ });
564
+ }
565
+ } catch (readErr) {
566
+ if (readErr?.code === 'ENOENT') {
567
+ requirementMissing = true;
568
+ } else {
569
+ return createResult({
570
+ actionType,
571
+ status: ActionStatus.EXECUTE_FAILED,
572
+ error: `existing codebuddy manual-install requirement cannot be read; refusing to overwrite: ${readErr.message}`,
573
+ });
574
+ }
575
+ }
576
+ if (!requirementMissing) {
577
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
578
+ return createResult({
579
+ actionType,
580
+ status: ActionStatus.EXECUTE_FAILED,
581
+ error: 'existing codebuddy manual-install requirement is not an object; refusing to overwrite',
582
+ });
583
+ }
584
+ const { createdAt: _existingCreatedAt, ...existingBody } = existing;
585
+ if (canonicalJson(existingBody) !== canonicalJson(requirement)) {
586
+ return createResult({
587
+ actionType,
588
+ status: ActionStatus.EXECUTE_FAILED,
589
+ error: 'existing codebuddy manual-install requirement conflicts with the current frozen action; refusing to overwrite',
590
+ });
591
+ }
592
+ } else {
593
+ await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
594
+ }
595
+
596
+ return createResult({
597
+ actionType,
598
+ status: ActionStatus.EXECUTED,
599
+ observation: {
600
+ installed: false,
601
+ manualInstallRequired: true,
602
+ consumer: 'codebuddy',
603
+ plugin: action.plugin,
604
+ version: action.version,
605
+ entrySkill: action.entrySkill,
606
+ repo: action.repo,
607
+ ref,
608
+ marketplace: CODEBUDDY_MARKETPLACE_NAME,
609
+ installChannels: ['desktop', 'cli'],
610
+ planDigest,
611
+ attestationDir,
612
+ codebuddyHome: cliHome,
613
+ cliInstallRoot,
614
+ desktopInstallRoot,
615
+ instructions,
616
+ },
617
+ });
618
+ }