@aperant/framework 0.11.0 → 0.12.0

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 (147) hide show
  1. package/CHANGELOG.md +213 -30
  2. package/agents/apt-planner.md +15 -0
  3. package/agents/apt-verifier.md +15 -10
  4. package/bin/apt-proof-video.mjs +256 -76
  5. package/dist/cli/commands/apply-recipe.d.mts +6 -0
  6. package/dist/cli/commands/apply-recipe.d.mts.map +1 -0
  7. package/dist/cli/commands/apply-recipe.mjs +98 -0
  8. package/dist/cli/commands/apply-recipe.mjs.map +1 -0
  9. package/dist/cli/commands/mobile-prepare.d.mts +53 -0
  10. package/dist/cli/commands/mobile-prepare.d.mts.map +1 -0
  11. package/dist/cli/commands/mobile-prepare.mjs +139 -0
  12. package/dist/cli/commands/mobile-prepare.mjs.map +1 -0
  13. package/dist/cli/commands/recipe-diff.d.mts +6 -0
  14. package/dist/cli/commands/recipe-diff.d.mts.map +1 -0
  15. package/dist/cli/commands/recipe-diff.mjs +75 -0
  16. package/dist/cli/commands/recipe-diff.mjs.map +1 -0
  17. package/dist/cli/commands/roadmap.d.mts.map +1 -1
  18. package/dist/cli/commands/roadmap.mjs +53 -0
  19. package/dist/cli/commands/roadmap.mjs.map +1 -1
  20. package/dist/cli/commands/validate-evidence.d.mts +1 -1
  21. package/dist/cli/commands/verify-sandbox.d.mts +6 -0
  22. package/dist/cli/commands/verify-sandbox.d.mts.map +1 -0
  23. package/dist/cli/commands/verify-sandbox.mjs +200 -0
  24. package/dist/cli/commands/verify-sandbox.mjs.map +1 -0
  25. package/dist/cli/consistency/parse-qa.d.mts.map +1 -1
  26. package/dist/cli/consistency/parse-qa.mjs +18 -4
  27. package/dist/cli/consistency/parse-qa.mjs.map +1 -1
  28. package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
  29. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +11 -10
  30. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
  31. package/dist/cli/coverage-check/user-outcomes.d.mts +7 -2
  32. package/dist/cli/coverage-check/user-outcomes.d.mts.map +1 -1
  33. package/dist/cli/coverage-check/user-outcomes.mjs +50 -17
  34. package/dist/cli/coverage-check/user-outcomes.mjs.map +1 -1
  35. package/dist/cli/design/frontmatter-schema.d.mts +8 -8
  36. package/dist/cli/dispatch.d.mts.map +1 -1
  37. package/dist/cli/dispatch.mjs +28 -0
  38. package/dist/cli/dispatch.mjs.map +1 -1
  39. package/dist/cli/gate/gates/verify-approved.d.mts +0 -48
  40. package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
  41. package/dist/cli/gate/gates/verify-approved.mjs +23 -80
  42. package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
  43. package/dist/cli/install/mcp-server-specs.d.mts +6 -5
  44. package/dist/cli/install/mcp-server-specs.d.mts.map +1 -1
  45. package/dist/cli/install/mcp-server-specs.mjs +22 -4
  46. package/dist/cli/install/mcp-server-specs.mjs.map +1 -1
  47. package/dist/cli/personas/sidecar.d.mts +1 -1
  48. package/dist/cli/roadmap/backend-adapter.d.mts +43 -0
  49. package/dist/cli/roadmap/backend-adapter.d.mts.map +1 -0
  50. package/dist/cli/roadmap/backend-adapter.mjs +222 -0
  51. package/dist/cli/roadmap/backend-adapter.mjs.map +1 -0
  52. package/dist/cli/roadmap/lifecycle.d.mts +1 -1
  53. package/dist/cli/roadmap/rollup.d.mts +2 -2
  54. package/dist/cli/util/runtime-capabilities.d.mts +7 -0
  55. package/dist/cli/util/runtime-capabilities.d.mts.map +1 -1
  56. package/dist/cli/util/runtime-capabilities.mjs +65 -0
  57. package/dist/cli/util/runtime-capabilities.mjs.map +1 -1
  58. package/dist/cli/verify-proof/manifest-validator.mjs +13 -0
  59. package/dist/cli/verify-proof/manifest-validator.mjs.map +1 -1
  60. package/dist/cli/verify-proof/resolver.d.mts +10 -7
  61. package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
  62. package/dist/cli/verify-proof/resolver.mjs +28 -7
  63. package/dist/cli/verify-proof/resolver.mjs.map +1 -1
  64. package/dist/cli/verify-proof/sandbox/apply.d.mts +64 -0
  65. package/dist/cli/verify-proof/sandbox/apply.d.mts.map +1 -0
  66. package/dist/cli/verify-proof/sandbox/apply.mjs +379 -0
  67. package/dist/cli/verify-proof/sandbox/apply.mjs.map +1 -0
  68. package/dist/cli/verify-proof/sandbox/gate-predicate.d.mts +63 -0
  69. package/dist/cli/verify-proof/sandbox/gate-predicate.d.mts.map +1 -0
  70. package/dist/cli/verify-proof/sandbox/gate-predicate.mjs +115 -0
  71. package/dist/cli/verify-proof/sandbox/gate-predicate.mjs.map +1 -0
  72. package/dist/cli/verify-proof/sandbox/pattern-matcher.d.mts +57 -0
  73. package/dist/cli/verify-proof/sandbox/pattern-matcher.d.mts.map +1 -0
  74. package/dist/cli/verify-proof/sandbox/pattern-matcher.mjs +124 -0
  75. package/dist/cli/verify-proof/sandbox/pattern-matcher.mjs.map +1 -0
  76. package/dist/cli/verify-proof/sandbox/recipe-diff.d.mts +28 -0
  77. package/dist/cli/verify-proof/sandbox/recipe-diff.d.mts.map +1 -0
  78. package/dist/cli/verify-proof/sandbox/recipe-diff.mjs +190 -0
  79. package/dist/cli/verify-proof/sandbox/recipe-diff.mjs.map +1 -0
  80. package/dist/cli/verify-proof/sandbox/sandbox-config.d.mts +25 -0
  81. package/dist/cli/verify-proof/sandbox/sandbox-config.d.mts.map +1 -0
  82. package/dist/cli/verify-proof/sandbox/sandbox-config.mjs +85 -0
  83. package/dist/cli/verify-proof/sandbox/sandbox-config.mjs.map +1 -0
  84. package/dist/cli/verify-proof/sandbox/self-test.d.mts +39 -0
  85. package/dist/cli/verify-proof/sandbox/self-test.d.mts.map +1 -0
  86. package/dist/cli/verify-proof/sandbox/self-test.mjs +248 -0
  87. package/dist/cli/verify-proof/sandbox/self-test.mjs.map +1 -0
  88. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  89. package/dist/plugin/agents/apt-planner.md +15 -0
  90. package/dist/plugin/agents/apt-verifier.md +15 -10
  91. package/dist/plugin/skills/apt-plan/SKILL.md +23 -0
  92. package/dist/plugin/skills/apt-run/SKILL.md +1 -1
  93. package/dist/plugin/skills/apt-setup/SKILL.md +56 -0
  94. package/dist/plugin/skills/apt-verify/SKILL.md +20 -15
  95. package/dist/plugin/skills/apt-verify-proof/SKILL.md +146 -11
  96. package/dist/proof-report.d.ts.map +1 -1
  97. package/dist/proof-report.js +4 -1
  98. package/dist/proof-report.js.map +1 -1
  99. package/dist/types/config.d.ts +42 -4
  100. package/dist/types/config.d.ts.map +1 -1
  101. package/dist/types/qa-scoring.d.ts +45 -9
  102. package/dist/types/qa-scoring.d.ts.map +1 -1
  103. package/dist/types/qa-scoring.js +34 -13
  104. package/dist/types/qa-scoring.js.map +1 -1
  105. package/dist/types/state.d.ts +7 -2
  106. package/dist/types/state.d.ts.map +1 -1
  107. package/dist/types/task-record.d.ts +10 -2
  108. package/dist/types/task-record.d.ts.map +1 -1
  109. package/drivers/mobile/README.md +40 -0
  110. package/drivers/mobile/driver.mjs +106 -0
  111. package/drivers/mobile/manifest.json +49 -0
  112. package/package.json +138 -138
  113. package/prompts/inbox-clarification.md +11 -0
  114. package/prompts/inbox-triage.md +20 -0
  115. package/prompts/qa_orchestrator_agentic.md +18 -17
  116. package/prompts/qa_reviewer.md +17 -15
  117. package/skills/apt-plan/SKILL.md +23 -0
  118. package/skills/apt-run/SKILL.md +1 -1
  119. package/skills/apt-setup/SKILL.md +56 -0
  120. package/skills/apt-verifier.md +7 -4
  121. package/skills/apt-verify/SKILL.md +20 -15
  122. package/skills/apt-verify-proof/SKILL.md +146 -11
  123. package/src/cli/commands/apply-recipe.mjs +105 -0
  124. package/src/cli/commands/mobile-prepare.mjs +151 -0
  125. package/src/cli/commands/recipe-diff.mjs +81 -0
  126. package/src/cli/commands/roadmap.mjs +54 -0
  127. package/src/cli/commands/verify-sandbox.mjs +231 -0
  128. package/src/cli/consistency/parse-qa.mjs +20 -4
  129. package/src/cli/consistency/rules/r5-verdict-consistency.mjs +11 -13
  130. package/src/cli/coverage-check/user-outcomes.mjs +52 -17
  131. package/src/cli/dispatch.mjs +27 -0
  132. package/src/cli/gate/gates/verify-approved.mjs +22 -81
  133. package/src/cli/install/mcp-server-specs.mjs +24 -4
  134. package/src/cli/roadmap/backend-adapter.mjs +231 -0
  135. package/src/cli/util/runtime-capabilities.mjs +67 -0
  136. package/src/cli/verify-proof/manifest-validator.mjs +15 -0
  137. package/src/cli/verify-proof/resolver.mjs +27 -7
  138. package/src/cli/verify-proof/sandbox/apply.mjs +401 -0
  139. package/src/cli/verify-proof/sandbox/gate-predicate.mjs +126 -0
  140. package/src/cli/verify-proof/sandbox/pattern-matcher.mjs +127 -0
  141. package/src/cli/verify-proof/sandbox/recipe-diff.mjs +208 -0
  142. package/src/cli/verify-proof/sandbox/sandbox-config.mjs +82 -0
  143. package/src/cli/verify-proof/sandbox/sandbox-schema.json +33 -0
  144. package/src/cli/verify-proof/sandbox/self-test.mjs +265 -0
  145. package/templates/config.json +10 -1
  146. package/templates/proof-verification.md +27 -8
  147. package/workflows/verify-proof.md +376 -302
@@ -0,0 +1,401 @@
1
+ /**
2
+ * apply.mjs — recipe applier with dry-run default + per-change
3
+ * confirmation + TOFU non-clobber + audit-row writing.
4
+ *
5
+ * Three layered concerns covered here (subtasks 3.1, 3.2, 3.3):
6
+ *
7
+ * 3.1 — `confirm:false` (the default) is a pure dry-run. Returns
8
+ * the planned writes list; writes ZERO bytes under the
9
+ * adopter's source tree (ID-02).
10
+ *
11
+ * 3.2 — TOFU non-clobber. Per-change skip semantics modeled on
12
+ * install/mcp-provision.mjs's already_applied / user_modified
13
+ * / malformed_json / non_object_root states:
14
+ * - already_applied: target file contains the recipe's marker
15
+ * substring → no write.
16
+ * - user_modified: target file exists, doesn't contain the
17
+ * marker, AND its content sha256 doesn't match the
18
+ * recipe's expected-before hash (when provided) → no write.
19
+ * - malformed_json / non_object_root: applicable to
20
+ * json-merge changes only.
21
+ *
22
+ * 3.3 — Audit-row writing under withLock to .aperant/recipes-applied/
23
+ * {recipe-id}.json. Captures original_sha256 + applied_sha256
24
+ * + recipe_version + applied_at per change. file-create rows
25
+ * record original_sha256:null.
26
+ *
27
+ * Public contract:
28
+ * applyRecipe({projectDir, recipeId, recipes, confirm, perChangeConfirm})
29
+ * → {writes, skips, audit_path}
30
+ *
31
+ * No process.exit; no thrown errors except for usage-shape violations.
32
+ * The CLI command wraps this and translates to the {envelope, exitCode}
33
+ * shape.
34
+ */
35
+
36
+ import { createHash } from 'node:crypto'
37
+ import { chmodSync, existsSync, readFileSync } from 'node:fs'
38
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
39
+ import { fileURLToPath } from 'node:url'
40
+ import { atomicWriteJson, atomicWriteText } from '../../util/atomic-write.mjs'
41
+ import { withFileLock } from '../../util/fs-lock.mjs'
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url))
44
+
45
+ /**
46
+ * Defense-in-depth: assert a recipe-declared path resolves under the
47
+ * expected root. Rejects absolute paths and `../` escapes regardless of
48
+ * what the recipe schema validator accepted.
49
+ *
50
+ * Throws — caller surfaces the message in audit + CLI envelope. We throw
51
+ * rather than skip because a malicious-path-in-recipe is a CONTRACT
52
+ * violation, not a TOFU skip case.
53
+ *
54
+ * @param {string} rootAbs — absolute root the path must stay under.
55
+ * @param {string} relPath — recipe-declared relative path.
56
+ * @param {string} label — 'change.path' | 'change.template' for the error message.
57
+ * @returns {string} the validated absolute path under rootAbs.
58
+ */
59
+ function assertPathUnderRoot(rootAbs, relPath, label) {
60
+ if (typeof relPath !== 'string' || relPath.length === 0) {
61
+ throw new Error(`applyRecipe: ${label} must be a non-empty string`)
62
+ }
63
+ if (isAbsolute(relPath)) {
64
+ throw new Error(`applyRecipe: ${label} must be relative, got absolute: ${relPath}`)
65
+ }
66
+ const candidate = resolve(rootAbs, relPath)
67
+ const rel = relative(rootAbs, candidate)
68
+ if (rel.startsWith('..') || isAbsolute(rel)) {
69
+ throw new Error(`applyRecipe: ${label} escapes root: ${relPath}`)
70
+ }
71
+ return candidate
72
+ }
73
+
74
+ /**
75
+ * @typedef {Object} ApplyChangeRecord
76
+ * @property {string} path — adopter-relative target
77
+ * @property {'written'|'skipped'|'skipped_unsafe'} action
78
+ * @property {string} [skip_reason] — already_applied | user_modified | malformed_json | non_object_root | dry_run
79
+ * @property {string|null} original_sha256
80
+ * @property {string|null} applied_sha256
81
+ * @property {string} recipe_version
82
+ * @property {string} applied_at
83
+ * @property {string} kind
84
+ */
85
+
86
+ /**
87
+ * @param {{
88
+ * projectDir: string,
89
+ * recipeId: string,
90
+ * recipes: object[],
91
+ * confirm?: boolean,
92
+ * perChangeConfirm?: (change: object, preview: {before: string, after: string}) => Promise<'accept'|'skip'|'abort'>,
93
+ * force?: boolean
94
+ * }} args
95
+ * @returns {Promise<{
96
+ * writes: ApplyChangeRecord[],
97
+ * skips: ApplyChangeRecord[],
98
+ * audit_path: string|null,
99
+ * aborted: boolean,
100
+ * recipe_id: string,
101
+ * recipe_version: string
102
+ * }>}
103
+ */
104
+ export async function applyRecipe(args) {
105
+ const { projectDir, recipeId, recipes, confirm = false, perChangeConfirm, force = false } = args
106
+ if (!projectDir) throw new Error('applyRecipe: projectDir required')
107
+ if (!recipeId) throw new Error('applyRecipe: recipeId required')
108
+ if (!Array.isArray(recipes)) throw new Error('applyRecipe: recipes[] required')
109
+
110
+ const recipe = recipes.find((r) => r && r.recipeId === recipeId)
111
+ if (!recipe) throw new Error(`applyRecipe: recipe not found: ${recipeId}`)
112
+
113
+ const adopterRoot = resolve(projectDir)
114
+ const writes = []
115
+ const skips = []
116
+ let aborted = false
117
+ const at = new Date().toISOString()
118
+
119
+ for (const change of recipe.changes ?? []) {
120
+ // Defense-in-depth: reject path-traversal in recipe-declared paths
121
+ // regardless of schema validation. A malicious community recipe
122
+ // could otherwise write outside the adopter root via `../../`.
123
+ const targetAbs = assertPathUnderRoot(adopterRoot, change.path, 'change.path')
124
+ const exists = existsSync(targetAbs)
125
+ const before = exists ? safeReadFile(targetAbs) : ''
126
+ const beforeSha = exists ? sha256(before) : null
127
+ const afterContent = renderAfterContent(change, recipe)
128
+
129
+ // TOFU non-clobber checks — per-change skip decisions.
130
+ const tofu = decideTofu({ change, before, exists, force })
131
+ if (tofu.skip) {
132
+ skips.push({
133
+ path: change.path,
134
+ kind: change.kind,
135
+ action: tofu.unsafe ? 'skipped_unsafe' : 'skipped',
136
+ skip_reason: tofu.reason,
137
+ original_sha256: beforeSha,
138
+ applied_sha256: null,
139
+ recipe_version: recipe.recipeVersion,
140
+ applied_at: at,
141
+ })
142
+ continue
143
+ }
144
+
145
+ const preview = { before, after: afterContent }
146
+
147
+ if (!confirm) {
148
+ // Dry-run: no write, no per-change prompt. The plan is the output.
149
+ writes.push({
150
+ path: change.path,
151
+ kind: change.kind,
152
+ action: 'written',
153
+ skip_reason: 'dry_run',
154
+ original_sha256: beforeSha,
155
+ applied_sha256: null,
156
+ recipe_version: recipe.recipeVersion,
157
+ applied_at: at,
158
+ })
159
+ continue
160
+ }
161
+
162
+ // With --confirm: ask the user per change. Default WHEN NO
163
+ // confirmer is provided is `skip` (fail-safe per ID-02 — the
164
+ // framework never auto-applies without an explicit accept signal
165
+ // from the caller surface).
166
+ const verdict = perChangeConfirm ? await perChangeConfirm(change, preview) : 'skip'
167
+ if (verdict === 'abort') {
168
+ aborted = true
169
+ break
170
+ }
171
+ if (verdict === 'skip') {
172
+ skips.push({
173
+ path: change.path,
174
+ kind: change.kind,
175
+ action: 'skipped',
176
+ skip_reason: 'user_declined',
177
+ original_sha256: beforeSha,
178
+ applied_sha256: null,
179
+ recipe_version: recipe.recipeVersion,
180
+ applied_at: at,
181
+ })
182
+ continue
183
+ }
184
+
185
+ // Verdict 'accept' — perform the write.
186
+ const writeResult = performWrite(targetAbs, change, afterContent, before)
187
+ writes.push({
188
+ path: change.path,
189
+ kind: change.kind,
190
+ action: 'written',
191
+ skip_reason: null,
192
+ original_sha256: beforeSha,
193
+ applied_sha256: writeResult.applied_sha256,
194
+ recipe_version: recipe.recipeVersion,
195
+ applied_at: at,
196
+ })
197
+ }
198
+
199
+ // Audit row write — only when we actually wrote (or attempted to).
200
+ // For dry-runs we still return the plan in `writes` but do NOT touch
201
+ // the audit file. ID-02: dry-run never mutates state.
202
+ let auditPath = null
203
+ if (confirm) {
204
+ auditPath = join(adopterRoot, '.aperant', 'recipes-applied', `${recipe.recipeId}.json`)
205
+ writeAuditRow({
206
+ auditPath,
207
+ recipeId: recipe.recipeId,
208
+ recipeVersion: recipe.recipeVersion,
209
+ at,
210
+ writes,
211
+ skips,
212
+ aborted,
213
+ })
214
+ }
215
+
216
+ return {
217
+ writes,
218
+ skips,
219
+ audit_path: auditPath,
220
+ aborted,
221
+ recipe_id: recipe.recipeId,
222
+ recipe_version: recipe.recipeVersion,
223
+ }
224
+ }
225
+
226
+ /**
227
+ * TOFU decision per change. Returns {skip:true, reason, unsafe?} or
228
+ * {skip:false}.
229
+ */
230
+ function decideTofu({ change, before, exists, force }) {
231
+ if (!exists) {
232
+ // New target — always safe to write.
233
+ return { skip: false }
234
+ }
235
+
236
+ // Marker check first — if the recipe's marker substring is already
237
+ // in the file, treat as idempotent application (regardless of kind).
238
+ if (
239
+ typeof change.marker === 'string' &&
240
+ change.marker.length > 0 &&
241
+ before.includes(change.marker)
242
+ ) {
243
+ return { skip: true, reason: 'already_applied' }
244
+ }
245
+
246
+ // json-merge specific safety: only touch plain-object roots.
247
+ if (change.kind === 'json-merge') {
248
+ let parsed
249
+ try {
250
+ parsed = before === '' ? {} : JSON.parse(before)
251
+ } catch {
252
+ return { skip: true, reason: 'malformed_json', unsafe: true }
253
+ }
254
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
255
+ return { skip: true, reason: 'non_object_root', unsafe: true }
256
+ }
257
+ }
258
+
259
+ // `before` SHA divergence: target file exists but the recipe has an
260
+ // expected before-content/sha and it doesn't match. Without --force,
261
+ // skip as user_modified. A 64-char hex string is treated as a SHA;
262
+ // anything else is treated as a substring anchor.
263
+ if (!force && typeof change.before === 'string' && change.before.length > 0) {
264
+ if (/^[a-f0-9]{64}$/.test(change.before)) {
265
+ if (sha256(before) !== change.before) {
266
+ return { skip: true, reason: 'user_modified' }
267
+ }
268
+ } else if (!before.includes(change.before)) {
269
+ return { skip: true, reason: 'user_modified' }
270
+ }
271
+ }
272
+
273
+ return { skip: false }
274
+ }
275
+
276
+ /**
277
+ * Materialize the post-condition content for a change.
278
+ */
279
+ function renderAfterContent(change, recipe) {
280
+ if (change.kind === 'json-merge') {
281
+ return JSON.stringify(change.after ?? {}, null, 2)
282
+ }
283
+ if (change.template) {
284
+ const tpl = locateRecipeTemplate(recipe, change.template)
285
+ if (tpl && existsSync(tpl)) return safeReadFile(tpl)
286
+ }
287
+ if (typeof change.after === 'string') return change.after
288
+ return ''
289
+ }
290
+
291
+ function performWrite(targetAbs, change, afterContent, before) {
292
+ if (change.kind === 'json-merge') {
293
+ // Deep-merge change.after into existing JSON (already verified to
294
+ // be plain-object root in decideTofu).
295
+ const parsed = before === '' ? {} : JSON.parse(before)
296
+ const merged = deepMerge(parsed, change.after ?? {})
297
+ atomicWriteJson(targetAbs, merged)
298
+ const out = `${JSON.stringify(merged, null, 2)}\n`
299
+ return { applied_sha256: sha256(out) }
300
+ }
301
+ if (change.kind === 'text-insert') {
302
+ // v1: append the patch block after the existing file content. Adopters
303
+ // confirm per-change; the diff has been previewed; explicit append is
304
+ // the safest non-destructive semantic for a v1.
305
+ const out = before + (before.endsWith('\n') ? '' : '\n') + afterContent
306
+ atomicWriteText(targetAbs, out)
307
+ return { applied_sha256: sha256(out) }
308
+ }
309
+ if (change.kind === 'file-create' || change.kind === 'helper-script') {
310
+ atomicWriteText(targetAbs, afterContent)
311
+ if (typeof change.mode === 'number') {
312
+ try {
313
+ chmodSync(targetAbs, change.mode)
314
+ } catch {
315
+ // chmod failure is non-fatal — the recipe's verify-sandbox step
316
+ // will surface a non-executable script as a self-test failure.
317
+ }
318
+ }
319
+ return { applied_sha256: sha256(afterContent) }
320
+ }
321
+ // Unknown kind — fail loud (ID-01). A recipe shipping an unrecognized
322
+ // change.kind is a contract violation, not a TOFU skip case.
323
+ throw Object.assign(
324
+ new Error(`applyRecipe: unknown change.kind '${change.kind}' on path '${change.path}'`),
325
+ {
326
+ code: 'E_UNKNOWN_CHANGE_KIND',
327
+ kind: change.kind,
328
+ path: change.path,
329
+ },
330
+ )
331
+ }
332
+
333
+ function deepMerge(a, b) {
334
+ if (b === null || typeof b !== 'object' || Array.isArray(b)) return b
335
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) return b
336
+ const out = { ...a }
337
+ for (const k of Object.keys(b)) {
338
+ out[k] = k in a ? deepMerge(a[k], b[k]) : b[k]
339
+ }
340
+ return out
341
+ }
342
+
343
+ /**
344
+ * Write/update the audit row file under withFileLock. The lock helper is
345
+ * synchronous (it uses proper-lockfile's sync API + an Atomics.wait
346
+ * retry loop), so this function is sync — the caller already declared
347
+ * `await` for forward-compatibility with a future async lock.
348
+ */
349
+ const AUDIT_ENTRIES_CAP = 50
350
+
351
+ function writeAuditRow({ auditPath, recipeId, recipeVersion, at, writes, skips, aborted }) {
352
+ withFileLock(auditPath, () => {
353
+ const existing = existsSync(auditPath) ? JSON.parse(safeReadFile(auditPath) || '{}') : {}
354
+ const entries = Array.isArray(existing.entries) ? existing.entries : []
355
+ entries.push({
356
+ recipe_id: recipeId,
357
+ recipe_version: recipeVersion,
358
+ applied_at: at,
359
+ aborted,
360
+ writes,
361
+ skips,
362
+ })
363
+ // Cap to the last N entries to prevent unbounded disk growth (PRF-003).
364
+ const capped = entries.length > AUDIT_ENTRIES_CAP ? entries.slice(-AUDIT_ENTRIES_CAP) : entries
365
+ const doc = {
366
+ recipe_id: recipeId,
367
+ latest_version: recipeVersion,
368
+ last_applied_at: at,
369
+ entries: capped,
370
+ }
371
+ atomicWriteJson(auditPath, doc)
372
+ })
373
+ }
374
+
375
+ function safeReadFile(path) {
376
+ try {
377
+ return readFileSync(path, 'utf-8')
378
+ } catch {
379
+ return ''
380
+ }
381
+ }
382
+
383
+ function sha256(content) {
384
+ return createHash('sha256').update(content, 'utf-8').digest('hex')
385
+ }
386
+
387
+ function locateRecipeTemplate(recipe, templateName) {
388
+ // Defense-in-depth: a malicious recipe could otherwise specify
389
+ // `templateName: "../../etc/passwd"` and read arbitrary host files
390
+ // into the adopter source tree. Resolve under templates/ and assert.
391
+ // Also guard the recipeId itself — discoverBundledRecipes() skips schema
392
+ // validation, so the ^[a-z][a-z0-9_-]*$ pattern may not have been enforced.
393
+ if (typeof recipe.recipeId !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(recipe.recipeId)) {
394
+ throw new Error(
395
+ `applyRecipe: invalid recipeId '${recipe.recipeId}' — must match ^[a-z][a-z0-9_-]*$`,
396
+ )
397
+ }
398
+ const frameworkRoot = resolve(__dirname, '..', '..', '..', '..')
399
+ const templatesRoot = join(frameworkRoot, 'recipes', recipe.recipeId, 'templates')
400
+ return assertPathUnderRoot(templatesRoot, templateName, 'change.template')
401
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * gate-predicate.mjs — extracted decision function for the sandbox
3
+ * precondition gate at apt-verify-proof/SKILL.md Step 2c.
4
+ *
5
+ * Refactored out of the SKILL.md prose so the e2e test (subtask 7.3)
6
+ * can call it directly without re-implementing the dispatch table.
7
+ *
8
+ * Inputs:
9
+ * - config: {verification?: {sandbox?: {enabled:bool, ...}}}
10
+ * - stateFile: the v2-shaped envelope read from
11
+ * .aperant/state/verify-proof-sandbox-verified.json
12
+ * OR null when the file is missing.
13
+ * - parallelRequested: bool — true when /apt:verify-proof was invoked
14
+ * with --parallel.
15
+ *
16
+ * Returns:
17
+ * - {action: 'proceed'} — verified + approved, walker may run
18
+ * - {action: 'skip-gate'} — --parallel NOT requested OR
19
+ * verification.sandbox absent;
20
+ * Fast Path Guarantee (ID-07).
21
+ * - {action: 'exit', envelope} — verified:false OR state missing;
22
+ * envelope is the v2 fail-loud shape.
23
+ *
24
+ * The `envelope` shape on exit is:
25
+ * {status:'error', code:'E_SANDBOX_UNVERIFIED', verdict:'needs_human',
26
+ * reason:'sandbox_unverified', remediation:'/apt:verify-proof setup'}
27
+ *
28
+ * No I/O, no process.exit — pure function for testability.
29
+ */
30
+
31
+ const FAIL_ENVELOPE = Object.freeze({
32
+ status: 'error',
33
+ code: 'E_SANDBOX_UNVERIFIED',
34
+ verdict: 'needs_human',
35
+ reason: 'sandbox_unverified',
36
+ remediation: '/apt:verify-proof setup',
37
+ })
38
+
39
+ const STALE_VERDICT_ENVELOPE = Object.freeze({
40
+ status: 'error',
41
+ code: 'E_SANDBOX_VERDICT_STALE',
42
+ verdict: 'needs_human',
43
+ reason: 'sandbox_recipe_version_drift',
44
+ remediation: 'apt-tools verify-sandbox <project-dir>',
45
+ })
46
+
47
+ /**
48
+ * @typedef {Object} GateInput
49
+ * @property {object|null|undefined} config
50
+ * @property {object|null|undefined} stateFile
51
+ * @property {boolean} parallelRequested
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} GateEnvelope
56
+ * @property {'error'} status
57
+ * @property {string} code
58
+ * @property {string} verdict
59
+ * @property {string} reason
60
+ * @property {string} remediation
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} GateOutput
65
+ * @property {'proceed'|'skip-gate'|'exit'} action
66
+ * @property {GateEnvelope} [envelope]
67
+ */
68
+
69
+ /**
70
+ * @param {GateInput} input
71
+ * @returns {GateOutput}
72
+ */
73
+ export function gatePredicate({ config, stateFile, parallelRequested }) {
74
+ // (1) --parallel NOT requested → gate is a no-op (Fast Path Guarantee ID-07).
75
+ if (!parallelRequested) return { action: 'skip-gate' }
76
+
77
+ // (2) verification.sandbox is ABSENT → also skip-gate (the adopter
78
+ // has not opted in; respect the absent-config branch as no-op).
79
+ const sandboxCfg = config?.verification?.sandbox
80
+ if (!sandboxCfg || sandboxCfg.enabled !== true) {
81
+ // Note: even though --parallel was requested, the absent-config
82
+ // branch returns skip-gate; the SKILL.md Step 2c prose handles
83
+ // the absent-config case via the first-run hook (Step 1) which
84
+ // is a UX surface, not a gate failure.
85
+ return { action: 'skip-gate' }
86
+ }
87
+
88
+ // (3) state file present + verified:true + verdict:'approved' → check version drift.
89
+ if (stateFile && stateFile.verified === true && stateFile.verdict === 'approved') {
90
+ // (3a) Version drift check: when both sides have recipeVersion, verify the
91
+ // state file's version is in the active recipe's compatibleWithApplied[].
92
+ // If either side has null/undefined recipeVersion, fall through (compat
93
+ // with pre-LOG-003 state files that predate recipeVersion population).
94
+ const stateVersion = stateFile.recipeVersion ?? null
95
+ const activeVersion = sandboxCfg.recipeVersion ?? null
96
+ const compatibleWith = Array.isArray(sandboxCfg.compatibleWithApplied)
97
+ ? sandboxCfg.compatibleWithApplied
98
+ : null
99
+
100
+ if (stateVersion && activeVersion && compatibleWith !== null) {
101
+ // Both sides versioned — only stale if the state version is NOT
102
+ // listed in the active recipe's compatibleWithApplied AND the
103
+ // versions differ.
104
+ if (stateVersion !== activeVersion && !compatibleWith.includes(stateVersion)) {
105
+ return { action: 'exit', envelope: { ...STALE_VERDICT_ENVELOPE } }
106
+ }
107
+ }
108
+
109
+ return { action: 'proceed' }
110
+ }
111
+
112
+ // (4) Everything else → fail loud with the v2 envelope.
113
+ return { action: 'exit', envelope: { ...FAIL_ENVELOPE } }
114
+ }
115
+
116
+ /**
117
+ * The frozen fail envelope — exported so callers can detect-and-match
118
+ * without re-stringifying.
119
+ */
120
+ export const SANDBOX_UNVERIFIED_ENVELOPE = FAIL_ENVELOPE
121
+
122
+ /**
123
+ * The frozen stale-verdict envelope — emitted when the state file's
124
+ * recipeVersion is no longer in the active recipe's compatibleWithApplied[].
125
+ */
126
+ export const SANDBOX_VERDICT_STALE_ENVELOPE = STALE_VERDICT_ENVELOPE
@@ -0,0 +1,127 @@
1
+ /**
2
+ * pattern-matcher.mjs — sandbox-recipe pattern matcher.
3
+ *
4
+ * Walks the registered recipes' `match.capabilities[]` + `match.deps[]`
5
+ * predicates against the loaded runtime capabilities + package.json deps,
6
+ * and returns the FIRST matching recipe (or null with a human reason).
7
+ *
8
+ * Two-arg shape:
9
+ * - `match.capabilities[]` — every entry must be `true` on the
10
+ * RuntimeCapabilities record (AND semantics — all required).
11
+ * - `match.deps[]` — at least one entry must appear in the merged
12
+ * dependencies + devDependencies of the project's root package.json
13
+ * (OR semantics — any-of).
14
+ *
15
+ * This split lets a recipe say "I need is_electron AND one of
16
+ * [@libsql/client, better-sqlite3]" without enumerating every combination.
17
+ *
18
+ * See: spec ID-04 (v1 ships ONE recipe — electron-libsql), AC5.
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { join, resolve } from 'node:path'
23
+
24
+ /**
25
+ * @typedef {import('../../util/runtime-capabilities.mjs').RuntimeCapabilities} RuntimeCapabilities
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} SandboxRecipe
30
+ * @property {string} recipeId
31
+ * @property {string} recipeVersion
32
+ * @property {string} name
33
+ * @property {string} description
34
+ * @property {{capabilities: string[], deps: string[]}} match
35
+ * @property {{logPattern: string, timeoutMs: number}} readiness
36
+ * @property {Array<object>} changes
37
+ * @property {string[]} compatibleWithApplied
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} MatchResult
42
+ * @property {string|null} recipeId
43
+ * @property {string} reason
44
+ */
45
+
46
+ /**
47
+ * @param {RuntimeCapabilities} runtimeCaps
48
+ * @param {Record<string, unknown> | null} packageJson
49
+ * @param {SandboxRecipe[]} recipes
50
+ * @returns {MatchResult}
51
+ */
52
+ export function matchPattern(runtimeCaps, packageJson, recipes) {
53
+ if (!Array.isArray(recipes) || recipes.length === 0) {
54
+ return { recipeId: null, reason: 'no recipes registered' }
55
+ }
56
+
57
+ const deps = mergedDeps(packageJson)
58
+
59
+ for (const recipe of recipes) {
60
+ const reqCaps = recipe.match?.capabilities ?? []
61
+ const anyDeps = recipe.match?.deps ?? []
62
+
63
+ const capsOk = reqCaps.every((cap) => runtimeCaps && runtimeCaps[cap] === true)
64
+ if (!capsOk) continue
65
+
66
+ // deps[] is any-of — recipe matches if at least one declared dep is in
67
+ // the project. An empty deps[] means "no dep constraint" (cap-only match).
68
+ const depsOk = anyDeps.length === 0 || anyDeps.some((d) => deps.has(d.toLowerCase()))
69
+ if (!depsOk) continue
70
+
71
+ return { recipeId: recipe.recipeId, reason: 'matched' }
72
+ }
73
+
74
+ return {
75
+ recipeId: null,
76
+ reason: `no recipe available for runtime: ${describeRuntime(runtimeCaps)}`,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Convenience overload — load the project's package.json from disk.
82
+ *
83
+ * @param {string} projectDir
84
+ * @param {RuntimeCapabilities} runtimeCaps
85
+ * @param {SandboxRecipe[]} recipes
86
+ * @returns {MatchResult}
87
+ */
88
+ export function matchPatternForProject(projectDir, runtimeCaps, recipes) {
89
+ const pkgPath = join(resolve(projectDir), 'package.json')
90
+ let pkg = null
91
+ if (existsSync(pkgPath)) {
92
+ try {
93
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
94
+ } catch {
95
+ /* fall through with pkg=null */
96
+ }
97
+ }
98
+ return matchPattern(runtimeCaps, pkg, recipes)
99
+ }
100
+
101
+ /**
102
+ * Build a Set of lowercased package names from dependencies + devDependencies.
103
+ *
104
+ * @param {Record<string, unknown> | null} packageJson
105
+ */
106
+ function mergedDeps(packageJson) {
107
+ const out = new Set()
108
+ if (packageJson === null || typeof packageJson !== 'object') return out
109
+ const d = /** @type {Record<string, unknown>} */ (packageJson.dependencies)
110
+ const dd = /** @type {Record<string, unknown>} */ (packageJson.devDependencies)
111
+ if (d && typeof d === 'object') for (const k of Object.keys(d)) out.add(k.toLowerCase())
112
+ if (dd && typeof dd === 'object') for (const k of Object.keys(dd)) out.add(k.toLowerCase())
113
+ return out
114
+ }
115
+
116
+ /**
117
+ * One-line description of the detected runtime shape — used in the
118
+ * no-match reason so an adopter sees what the matcher saw.
119
+ *
120
+ * @param {RuntimeCapabilities} runtimeCaps
121
+ */
122
+ function describeRuntime(runtimeCaps) {
123
+ if (!runtimeCaps || typeof runtimeCaps !== 'object') return 'unknown'
124
+ const active = Object.keys(runtimeCaps).filter((k) => runtimeCaps[k] === true)
125
+ if (active.length === 0) return 'no capabilities detected'
126
+ return active.join(', ')
127
+ }