@aperant/framework 0.11.0 → 0.12.1

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 (149) hide show
  1. package/CHANGELOG.md +226 -29
  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/SKILL.md +2 -2
  92. package/dist/plugin/skills/apt-plan/SKILL.md +23 -0
  93. package/dist/plugin/skills/apt-run/SKILL.md +1 -1
  94. package/dist/plugin/skills/apt-setup/SKILL.md +56 -0
  95. package/dist/plugin/skills/apt-verify/SKILL.md +20 -15
  96. package/dist/plugin/skills/apt-verify-proof/SKILL.md +146 -11
  97. package/dist/proof-report.d.ts.map +1 -1
  98. package/dist/proof-report.js +4 -1
  99. package/dist/proof-report.js.map +1 -1
  100. package/dist/types/config.d.ts +42 -4
  101. package/dist/types/config.d.ts.map +1 -1
  102. package/dist/types/qa-scoring.d.ts +45 -9
  103. package/dist/types/qa-scoring.d.ts.map +1 -1
  104. package/dist/types/qa-scoring.js +34 -13
  105. package/dist/types/qa-scoring.js.map +1 -1
  106. package/dist/types/state.d.ts +7 -2
  107. package/dist/types/state.d.ts.map +1 -1
  108. package/dist/types/task-record.d.ts +10 -2
  109. package/dist/types/task-record.d.ts.map +1 -1
  110. package/drivers/mobile/README.md +40 -0
  111. package/drivers/mobile/driver.mjs +106 -0
  112. package/drivers/mobile/manifest.json +49 -0
  113. package/package.json +138 -138
  114. package/prompts/inbox-clarification.md +17 -0
  115. package/prompts/inbox-triage.md +21 -0
  116. package/prompts/qa_orchestrator_agentic.md +18 -17
  117. package/prompts/qa_reviewer.md +17 -15
  118. package/skills/apt/SKILL.md +2 -2
  119. package/skills/apt-plan/SKILL.md +23 -0
  120. package/skills/apt-run/SKILL.md +1 -1
  121. package/skills/apt-setup/SKILL.md +56 -0
  122. package/skills/apt-verifier.md +7 -4
  123. package/skills/apt-verify/SKILL.md +20 -15
  124. package/skills/apt-verify-proof/SKILL.md +146 -11
  125. package/src/cli/commands/apply-recipe.mjs +105 -0
  126. package/src/cli/commands/mobile-prepare.mjs +151 -0
  127. package/src/cli/commands/recipe-diff.mjs +81 -0
  128. package/src/cli/commands/roadmap.mjs +54 -0
  129. package/src/cli/commands/verify-sandbox.mjs +231 -0
  130. package/src/cli/consistency/parse-qa.mjs +20 -4
  131. package/src/cli/consistency/rules/r5-verdict-consistency.mjs +11 -13
  132. package/src/cli/coverage-check/user-outcomes.mjs +52 -17
  133. package/src/cli/dispatch.mjs +27 -0
  134. package/src/cli/gate/gates/verify-approved.mjs +22 -81
  135. package/src/cli/install/mcp-server-specs.mjs +24 -4
  136. package/src/cli/roadmap/backend-adapter.mjs +231 -0
  137. package/src/cli/util/runtime-capabilities.mjs +67 -0
  138. package/src/cli/verify-proof/manifest-validator.mjs +15 -0
  139. package/src/cli/verify-proof/resolver.mjs +27 -7
  140. package/src/cli/verify-proof/sandbox/apply.mjs +401 -0
  141. package/src/cli/verify-proof/sandbox/gate-predicate.mjs +126 -0
  142. package/src/cli/verify-proof/sandbox/pattern-matcher.mjs +127 -0
  143. package/src/cli/verify-proof/sandbox/recipe-diff.mjs +208 -0
  144. package/src/cli/verify-proof/sandbox/sandbox-config.mjs +82 -0
  145. package/src/cli/verify-proof/sandbox/sandbox-schema.json +33 -0
  146. package/src/cli/verify-proof/sandbox/self-test.mjs +265 -0
  147. package/templates/config.json +10 -1
  148. package/templates/proof-verification.md +27 -8
  149. package/workflows/verify-proof.md +376 -302
@@ -0,0 +1,208 @@
1
+ /**
2
+ * recipe-diff.mjs — read-only Markdown diff generator for a recipe.
3
+ *
4
+ * Loads the recipe, walks `changes[]`, and emits a Markdown report
5
+ * enumerating every proposed write WITH `--- before` / `+++ after`
6
+ * blocks. The generator is pure read — it never writes to the adopter's
7
+ * tree (ID-02). The companion `apply-recipe` command is the only
8
+ * writer.
9
+ *
10
+ * Public contract:
11
+ * generateRecipeDiff({projectDir, recipeId, recipes}) → {
12
+ * format: 'markdown',
13
+ * body: string,
14
+ * changes: [{path, kind, summary}],
15
+ * envelope: {status, recipe_version, compatible}
16
+ * }
17
+ *
18
+ * The Markdown is user-facing and load-bearing — drift = golden-snapshot
19
+ * test failure by design (subtask 2.1 / TD-03).
20
+ */
21
+
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url))
27
+
28
+ /**
29
+ * Defense-in-depth path-under-root check. Returns the validated absolute
30
+ * path or null when the candidate escapes the root. The diff generator
31
+ * surfaces a "template not found" envelope rather than throwing (read-
32
+ * only generator stays non-fatal).
33
+ *
34
+ * @param {string} rootAbs
35
+ * @param {string} relPath
36
+ * @returns {string|null}
37
+ */
38
+ function safeJoinUnderRoot(rootAbs, relPath) {
39
+ if (typeof relPath !== 'string' || relPath.length === 0) return null
40
+ if (isAbsolute(relPath)) return null
41
+ const candidate = resolve(rootAbs, relPath)
42
+ const rel = relative(rootAbs, candidate)
43
+ if (rel.startsWith('..') || isAbsolute(rel)) return null
44
+ return candidate
45
+ }
46
+
47
+ /**
48
+ * @param {{projectDir: string, recipeId: string, recipes: object[]}} args
49
+ * @returns {{
50
+ * format: 'markdown',
51
+ * body: string,
52
+ * changes: Array<{path: string, kind: string, summary: string}>,
53
+ * envelope: {status: 'ok'|'error', recipe_version: string|null, compatible: boolean}
54
+ * }}
55
+ */
56
+ export function generateRecipeDiff({ projectDir, recipeId, recipes }) {
57
+ const recipe = (recipes ?? []).find((r) => r && r.recipeId === recipeId)
58
+ if (!recipe) {
59
+ return {
60
+ format: 'markdown',
61
+ body: `# No recipe available\n\nrecipeId \`${recipeId}\` is not in the registered recipes list.\n`,
62
+ changes: [],
63
+ envelope: { status: 'error', recipe_version: null, compatible: false },
64
+ }
65
+ }
66
+
67
+ const adopterRoot = resolve(projectDir)
68
+ const changeRows = []
69
+ const sections = []
70
+
71
+ for (const change of recipe.changes ?? []) {
72
+ // Defense-in-depth: a malicious recipe could otherwise read
73
+ // arbitrary host files via `change.path: '../../etc/passwd'`. The
74
+ // diff generator is read-only but the read CONTENT lands in the
75
+ // emitted Markdown — refuse to enumerate paths outside root.
76
+ const targetAbs = safeJoinUnderRoot(adopterRoot, change.path)
77
+ if (targetAbs === null) {
78
+ sections.push(
79
+ `## ${change.path}\n\n_(rejected: change.path escapes project root — recipe defect)_\n`,
80
+ )
81
+ changeRows.push({
82
+ path: change.path,
83
+ kind: change.kind,
84
+ summary: 'rejected (path escapes root)',
85
+ })
86
+ continue
87
+ }
88
+ const exists = existsSync(targetAbs)
89
+ const before = exists ? safeReadFile(targetAbs) : ''
90
+ const after = renderAfter(change, recipe, adopterRoot)
91
+
92
+ const summary = renderChangeSummary(change, exists)
93
+ changeRows.push({ path: change.path, kind: change.kind, summary })
94
+
95
+ sections.push(renderChangeSection(change, before, after, exists))
96
+ }
97
+
98
+ const body = renderBody(recipe, sections)
99
+ return {
100
+ format: 'markdown',
101
+ body,
102
+ changes: changeRows,
103
+ envelope: {
104
+ status: 'ok',
105
+ recipe_version: recipe.recipeVersion ?? null,
106
+ compatible: true,
107
+ },
108
+ }
109
+ }
110
+
111
+ function renderBody(recipe, sections) {
112
+ const lines = []
113
+ lines.push(`# Recipe diff: ${recipe.name ?? recipe.recipeId}`)
114
+ lines.push('')
115
+ lines.push(
116
+ `**Recipe:** \`${recipe.recipeId}\` v${recipe.recipeVersion} ` +
117
+ `(compatibleWithApplied: ${JSON.stringify(recipe.compatibleWithApplied ?? [])})`,
118
+ )
119
+ lines.push('')
120
+ lines.push(recipe.description ?? '')
121
+ lines.push('')
122
+ lines.push(`This recipe proposes **${(recipe.changes ?? []).length}** changes.`)
123
+ lines.push(
124
+ 'No file is written until you run `apt-tools apply-recipe . --confirm` ' +
125
+ 'and accept each change individually.',
126
+ )
127
+ lines.push('')
128
+ for (const section of sections) {
129
+ lines.push(section)
130
+ lines.push('')
131
+ }
132
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n')
133
+ }
134
+
135
+ function renderChangeSummary(change, exists) {
136
+ const verb =
137
+ change.kind === 'file-create' ? 'create' : change.kind === 'json-merge' ? 'merge into' : 'edit'
138
+ const target = exists ? change.path : `${change.path} (new file)`
139
+ return `${verb} ${target}`
140
+ }
141
+
142
+ function renderChangeSection(change, before, after, exists) {
143
+ const lines = []
144
+ lines.push(`## ${change.path}`)
145
+ lines.push('')
146
+ lines.push(
147
+ `- kind: \`${change.kind}\``,
148
+ `- marker: \`${change.marker ?? '(none)'}\``,
149
+ `- target ${exists ? 'exists' : 'does not exist'} on disk`,
150
+ )
151
+ lines.push('')
152
+ lines.push('```diff')
153
+ const beforeBlock = before === '' ? '(empty / file does not exist)' : before
154
+ for (const line of beforeBlock.split('\n')) lines.push(`--- ${line}`)
155
+ lines.push('')
156
+ for (const line of after.split('\n')) lines.push(`+++ ${line}`)
157
+ lines.push('```')
158
+ return lines.join('\n')
159
+ }
160
+
161
+ /**
162
+ * Render the "after" content per change kind. For json-merge we stringify
163
+ * the patch payload; for text-insert / file-create we load the template
164
+ * from the recipe directory (when present).
165
+ */
166
+ function renderAfter(change, recipe, adopterRoot) {
167
+ if (change.kind === 'json-merge') {
168
+ return JSON.stringify(change.after ?? {}, null, 2)
169
+ }
170
+ if (change.template) {
171
+ const tplPath = locateRecipeTemplate(recipe, change.template, adopterRoot)
172
+ if (tplPath && existsSync(tplPath)) {
173
+ return safeReadFile(tplPath)
174
+ }
175
+ return `(template not found on disk: ${change.template})`
176
+ }
177
+ if (typeof change.after === 'string') return change.after
178
+ return ''
179
+ }
180
+
181
+ /**
182
+ * Resolve a recipe template path relative to the framework's recipes/
183
+ * directory. The framework ships recipes at packages/framework/recipes/
184
+ * <id>/templates/. We compute the directory from this module's location
185
+ * so the resolver works regardless of cwd.
186
+ */
187
+ function locateRecipeTemplate(recipe, templateName, _adopterRoot) {
188
+ // __dirname → packages/framework/src/cli/verify-proof/sandbox/
189
+ // → up four levels → packages/framework/
190
+ // Defense-in-depth: reject `../` escapes in templateName AND validate
191
+ // recipeId so a crafted id (e.g. '../../evil') cannot shift the anchor
192
+ // outside the framework root. discoverBundledRecipes() skips schema
193
+ // validation, so the ^[a-z][a-z0-9_-]*$ pattern may not have been enforced.
194
+ if (typeof recipe.recipeId !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(recipe.recipeId)) {
195
+ return null
196
+ }
197
+ const frameworkRoot = resolve(__dirname, '..', '..', '..', '..')
198
+ const templatesRoot = join(frameworkRoot, 'recipes', recipe.recipeId, 'templates')
199
+ return safeJoinUnderRoot(templatesRoot, templateName)
200
+ }
201
+
202
+ function safeReadFile(path) {
203
+ try {
204
+ return readFileSync(path, 'utf-8')
205
+ } catch {
206
+ return ''
207
+ }
208
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * sandbox-config.mjs — central loader for sandbox recipes + the
3
+ * adopter's verification.sandbox config block.
4
+ *
5
+ * Two responsibilities:
6
+ *
7
+ * 1. discoverBundledRecipes() — walk packages/framework/recipes/<id>/
8
+ * and return every parseable recipe.json. Mirrors the bundled-
9
+ * drivers discovery pattern in commands/detect-runtime.mjs.
10
+ *
11
+ * 2. readSandboxConfig(projectDir) — read .aperant/config.json's
12
+ * verification.sandbox block (or null if absent). Pure read; no
13
+ * merge with config.local.json — sandbox state is shared, not
14
+ * per-device.
15
+ *
16
+ * Stays in src/cli/verify-proof/sandbox/ so consumers under that tree
17
+ * share one loader. The CLI commands (recipe-diff, apply-recipe,
18
+ * verify-sandbox) all import from here.
19
+ */
20
+
21
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
22
+ import { dirname, join, resolve } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url))
26
+
27
+ /**
28
+ * Resolve the framework's recipes directory at runtime. Works whether
29
+ * the module is loaded from source (packages/framework/src/...) or from
30
+ * the installed dist (.aperant/deps/node_modules/@aperant/framework/...).
31
+ *
32
+ * @returns {string}
33
+ */
34
+ export function resolveRecipesDir() {
35
+ // __dirname → packages/framework/src/cli/verify-proof/sandbox/
36
+ // → up four levels → packages/framework/
37
+ return resolve(__dirname, '..', '..', '..', '..', 'recipes')
38
+ }
39
+
40
+ /**
41
+ * Walk the recipes/ directory and return every recipe.json that parses.
42
+ * Each entry is the raw recipe object (NOT validated — callers can
43
+ * call validateManifest if they need schema guarantees).
44
+ *
45
+ * @returns {Array<object>}
46
+ */
47
+ export function discoverBundledRecipes() {
48
+ const dir = resolveRecipesDir()
49
+ if (!existsSync(dir)) return []
50
+ const out = []
51
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
52
+ if (!entry.isDirectory()) continue
53
+ const recipePath = join(dir, entry.name, 'recipe.json')
54
+ if (!existsSync(recipePath)) continue
55
+ try {
56
+ out.push(JSON.parse(readFileSync(recipePath, 'utf-8')))
57
+ } catch {
58
+ /* skip malformed recipes */
59
+ }
60
+ }
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * Read .aperant/config.json's verification.sandbox block, or null when
66
+ * absent / unparseable. Pure read — no defaults injected.
67
+ *
68
+ * @param {string} projectDir
69
+ * @returns {object|null}
70
+ */
71
+ export function readSandboxConfig(projectDir) {
72
+ const path = join(resolve(projectDir), '.aperant', 'config.json')
73
+ if (!existsSync(path)) return null
74
+ try {
75
+ const config = JSON.parse(readFileSync(path, 'utf-8'))
76
+ const v = config?.verification
77
+ if (v === null || typeof v !== 'object') return null
78
+ return v.sandbox ?? null
79
+ } catch {
80
+ return null
81
+ }
82
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://aperant.dev/schemas/verification-sandbox-v1.json",
4
+ "title": "Aperant verification.sandbox config block v1",
5
+ "description": "Structural contract for the optional verification.sandbox config block at .aperant/config.json. When this block is ABSENT every existing code path is byte-identical to today (Fast Path Guarantee, ID-07). When present, it records which recipe the adopter opted into and the audit metadata of the apply-recipe run that wrote it.",
6
+ "type": "object",
7
+ "required": ["enabled", "recipeId", "recipeVersion"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "enabled": {
11
+ "type": "boolean",
12
+ "description": "Master switch. False suppresses the parallel gate even when a recipe is applied — used to wind back without removing the recipe artifacts."
13
+ },
14
+ "recipeId": {
15
+ "type": "string",
16
+ "pattern": "^[a-z][a-z0-9_-]*$",
17
+ "description": "Identifier of the recipe applied to this project (e.g. 'electron-libsql')."
18
+ },
19
+ "recipeVersion": {
20
+ "type": "string",
21
+ "description": "Semver of the recipe at apply time (e.g. '0.1.0'). Used by `apt-tools recipe-diff` to detect already-applied / incremental / incompatible cases via compatibleWithApplied[]."
22
+ },
23
+ "appliedAt": {
24
+ "type": "string",
25
+ "description": "ISO-8601 timestamp recorded at apply time. Optional — only written by apply-recipe."
26
+ },
27
+ "appliedHash": {
28
+ "type": "string",
29
+ "pattern": "^[a-f0-9]{64}$",
30
+ "description": "SHA-256 of the canonical recipe manifest contents at apply time. Optional — used to detect drift between the applied recipe and the on-disk recipe library."
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * self-test.mjs — two-instance concurrent self-test for parallel
3
+ * /apt:verify-proof.
4
+ *
5
+ * Spawns TWO instances of the recipe's launch command concurrently
6
+ * (Promise.all with B jittered 50-150 ms) against per-instance sandbox
7
+ * dirs. Asserts BOTH instances reach the recipe's `readiness.logPattern`
8
+ * within `readiness.timeoutMs` AND neither emits `SQLITE_BUSY` or
9
+ * `EADDRINUSE`/`bind` errors in stderr.
10
+ *
11
+ * The verdict envelope shape mirrors verify-proof v2's
12
+ * `verification.json :: meta.verdict`:
13
+ * {verified: bool, verdict: 'approved'|'needs_human',
14
+ * reason: null|'self_test_failed_sqlite_busy'|
15
+ * 'self_test_failed_port_conflict'|
16
+ * 'self_test_failed_app_init_timeout',
17
+ * durationMs: number}
18
+ *
19
+ * Cross-platform via `node:child_process.spawn` (NO shell). win32 short-
20
+ * circuits at the verify-sandbox command layer (subtask 4.2) — this
21
+ * module assumes a POSIX-ish host.
22
+ *
23
+ * No state-file writing here (4.2's job). Pure functional contract so
24
+ * the integration tests can drive it via `vi.spyOn(child_process,
25
+ * 'spawn')`.
26
+ */
27
+
28
+ import child_process from 'node:child_process'
29
+ import { mkdirSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join, resolve } from 'node:path'
32
+
33
+ /**
34
+ * @typedef {Object} SelfTestVerdict
35
+ * @property {boolean} verified
36
+ * @property {'approved'|'needs_human'} verdict
37
+ * @property {null|'self_test_failed_sqlite_busy'|'self_test_failed_port_conflict'|'self_test_failed_app_init_timeout'} reason
38
+ * @property {number} durationMs
39
+ * @property {{A: ChildOutcome, B: ChildOutcome}} children
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} ChildOutcome
44
+ * @property {boolean} ready
45
+ * @property {string|null} failureReason
46
+ * @property {string} stderrTail
47
+ */
48
+
49
+ /**
50
+ * @param {{projectDir: string, recipeId: string, recipes: object[]}} args
51
+ * @returns {Promise<SelfTestVerdict>}
52
+ */
53
+ export async function runSelfTest({ projectDir, recipeId, recipes }) {
54
+ const recipe = (recipes ?? []).find((r) => r && r.recipeId === recipeId)
55
+ if (!recipe) {
56
+ throw new Error(`runSelfTest: recipe not found: ${recipeId}`)
57
+ }
58
+ const launch = recipe.launch
59
+ if (!launch?.binary) {
60
+ throw new Error(`runSelfTest: recipe ${recipeId} missing launch.{binary, args}`)
61
+ }
62
+ const readiness = recipe.readiness
63
+ if (!readiness || typeof readiness.logPattern !== 'string') {
64
+ throw new Error(`runSelfTest: recipe ${recipeId} missing readiness.logPattern`)
65
+ }
66
+ const timeoutMs = readiness.timeoutMs ?? 30000
67
+ const pid = process.pid
68
+ const sandboxRoot = join(tmpdir(), `aperant-sandbox-${pid}-${Date.now()}`)
69
+ const dirs = {
70
+ A: join(sandboxRoot, 'A'),
71
+ B: join(sandboxRoot, 'B'),
72
+ }
73
+ mkdirSync(dirs.A, { recursive: true })
74
+ mkdirSync(dirs.B, { recursive: true })
75
+
76
+ const startedAt = Date.now()
77
+ const jitter = 50 + Math.random() * 100
78
+
79
+ // Concurrency by construction: A starts immediately, B's start is
80
+ // awaited via a microtask + jitter delay. Promise.all races them.
81
+ const [outcomeA, outcomeB] = await Promise.all([
82
+ spawnInstance({ index: 'A', launch, readiness, timeoutMs, sandboxDir: dirs.A, projectDir }),
83
+ delay(jitter).then(() =>
84
+ spawnInstance({ index: 'B', launch, readiness, timeoutMs, sandboxDir: dirs.B, projectDir }),
85
+ ),
86
+ ])
87
+
88
+ const durationMs = Date.now() - startedAt
89
+ const verdict = decideVerdict(outcomeA, outcomeB)
90
+ return {
91
+ ...verdict,
92
+ durationMs,
93
+ children: { A: outcomeA, B: outcomeB },
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Race a readiness signal against the timeout, scanning stderr for the
99
+ * three known-bad patterns. Resolves to a ChildOutcome regardless of
100
+ * which arm wins. Always kills the child before resolving.
101
+ */
102
+ function spawnInstance({ index: _index, launch, readiness, timeoutMs, sandboxDir, projectDir }) {
103
+ return new Promise((resolveOutcome) => {
104
+ const env = renderLaunchEnv(launch.envTemplate ?? {}, sandboxDir)
105
+ const child = child_process.spawn(launch.binary, launch.args ?? [], {
106
+ cwd: resolve(projectDir),
107
+ env: { ...process.env, ...env, SANDBOX_DIR: sandboxDir },
108
+ stdio: ['ignore', 'pipe', 'pipe'],
109
+ })
110
+
111
+ let resolved = false
112
+ let stderrBuf = ''
113
+ const stdoutBuf = []
114
+ const MAX_STDERR_BYTES = 4096
115
+
116
+ const finish = (outcome) => {
117
+ if (resolved) return
118
+ resolved = true
119
+ clearTimeout(timer)
120
+ try {
121
+ child.kill('SIGTERM')
122
+ } catch {
123
+ /* already gone */
124
+ }
125
+ // SIGKILL grace
126
+ setTimeout(() => {
127
+ try {
128
+ child.kill('SIGKILL')
129
+ } catch {
130
+ /* gone */
131
+ }
132
+ }, 5000).unref?.()
133
+ resolveOutcome(outcome)
134
+ }
135
+
136
+ // onStderrText: appends to stderrBuf and checks failure-reason patterns.
137
+ // Must NOT be called with stdout data — informational app logs that mention
138
+ // "SQLITE_BUSY" or "EADDRINUSE" must not trigger failure verdicts (LOG-001).
139
+ const onStderrText = (text) => {
140
+ stderrBuf = (stderrBuf + text).slice(-MAX_STDERR_BYTES)
141
+ if (/SQLITE_BUSY/i.test(text)) {
142
+ finish({
143
+ ready: false,
144
+ failureReason: 'self_test_failed_sqlite_busy',
145
+ stderrTail: stderrBuf.slice(-500),
146
+ })
147
+ return
148
+ }
149
+ if (/EADDRINUSE|bind\s+failed|address already in use|EACCES|EPERM/i.test(text)) {
150
+ finish({
151
+ ready: false,
152
+ failureReason: 'self_test_failed_port_conflict',
153
+ stderrTail: stderrBuf.slice(-500),
154
+ })
155
+ return
156
+ }
157
+ }
158
+
159
+ // onStdoutText: checks readiness signal only. Never touches stderrBuf and
160
+ // never triggers failure-reason heuristics — stdout is not an error stream.
161
+ const onStdoutText = (text) => {
162
+ if (text.includes(readiness.logPattern)) {
163
+ finish({ ready: true, failureReason: null, stderrTail: stderrBuf.slice(-500) })
164
+ }
165
+ }
166
+
167
+ child.stdout?.on?.('data', (chunk) => {
168
+ const text = String(chunk)
169
+ stdoutBuf.push(text)
170
+ onStdoutText(text)
171
+ })
172
+ child.stderr?.on?.('data', (chunk) => {
173
+ const text = String(chunk)
174
+ onStderrText(text)
175
+ // Readiness may also appear on stderr (some apps log to stderr only).
176
+ if (text.includes(readiness.logPattern)) {
177
+ finish({ ready: true, failureReason: null, stderrTail: stderrBuf.slice(-500) })
178
+ }
179
+ })
180
+ child.on?.('error', (err) => {
181
+ finish({
182
+ ready: false,
183
+ failureReason: 'self_test_failed_app_init_timeout',
184
+ stderrTail: String(err.message ?? err),
185
+ })
186
+ })
187
+ child.on?.('exit', () => {
188
+ if (!resolved) {
189
+ // Process exited before signalling readiness. Treat as init timeout
190
+ // unless the stderr already revealed a known bad pattern (handled
191
+ // above before exit).
192
+ finish({
193
+ ready: false,
194
+ failureReason: 'self_test_failed_app_init_timeout',
195
+ stderrTail: stderrBuf.slice(-500),
196
+ })
197
+ }
198
+ })
199
+
200
+ const timer = setTimeout(() => {
201
+ finish({
202
+ ready: false,
203
+ failureReason: 'self_test_failed_app_init_timeout',
204
+ stderrTail: stderrBuf.slice(-500),
205
+ })
206
+ }, timeoutMs)
207
+ // Don't keep the event loop alive on the timer.
208
+ timer.unref?.()
209
+ })
210
+ }
211
+
212
+ /**
213
+ * Combine two outcomes into the v2-shaped verdict envelope.
214
+ * Failure precedence: sqlite_busy > port_conflict > app_init_timeout.
215
+ *
216
+ * Every spawnInstance() resolution sets failureReason explicitly on the
217
+ * !ready path (the four finish() call sites cover SQLITE_BUSY, port
218
+ * conflict, app_init_timeout, and the child-error path which also maps
219
+ * to app_init_timeout). The priority loop therefore always finds a
220
+ * match; the unreachable fallthrough that used to live here was
221
+ * deleted per CLAUDE.md guideline 2 (no handling for impossible
222
+ * scenarios).
223
+ */
224
+ function decideVerdict(a, b) {
225
+ if (a.ready && b.ready) {
226
+ return { verified: true, verdict: /** @type {const} */ ('approved'), reason: null }
227
+ }
228
+ const reasons = [a.failureReason, b.failureReason].filter(Boolean)
229
+ const priority = [
230
+ 'self_test_failed_sqlite_busy',
231
+ 'self_test_failed_port_conflict',
232
+ 'self_test_failed_app_init_timeout',
233
+ ]
234
+ for (const cand of priority) {
235
+ if (reasons.includes(cand)) {
236
+ return {
237
+ verified: false,
238
+ verdict: /** @type {const} */ ('needs_human'),
239
+ reason: /** @type {any} */ (cand),
240
+ }
241
+ }
242
+ }
243
+ throw new Error(
244
+ `self-test decideVerdict: unreachable — both outcomes failed without a known reason: ${JSON.stringify({ a, b })}`,
245
+ )
246
+ }
247
+
248
+ /**
249
+ * Substitute ${SANDBOX_DIR} placeholders in the recipe's envTemplate.
250
+ */
251
+ function renderLaunchEnv(envTemplate, sandboxDir) {
252
+ const out = {}
253
+ for (const [k, v] of Object.entries(envTemplate)) {
254
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: config template marker, not a typo
255
+ out[k] = typeof v === 'string' ? v.replaceAll('${SANDBOX_DIR}', sandboxDir) : v
256
+ }
257
+ return out
258
+ }
259
+
260
+ function delay(ms) {
261
+ return new Promise((res) => {
262
+ const t = setTimeout(res, ms)
263
+ t.unref?.()
264
+ })
265
+ }
@@ -18,7 +18,6 @@
18
18
  },
19
19
  "qa": {
20
20
  "approved_threshold": 7,
21
- "approved_with_notes_threshold": 5,
22
21
  "require_proof_for_ui": true,
23
22
  "max_fix_iterations": 3,
24
23
  "grace": {
@@ -116,6 +115,16 @@
116
115
  "diagram": {
117
116
  "mode": "file"
118
117
  },
118
+ "roadmap": {
119
+ "$comment": "Roadmap backend toggle (task 26-05-26 D-09 + D-13). 'markdown' (default) keeps the existing OSS file-write path under .aperant/roadmap/{scope}/. 'cloud-mcp' routes write-side subcommands (add-milestone, add-phase, set-phase-status, delete-phase, delete-milestone) through the Aperant Cloud MCP server. Cloud-mcp requires roadmap.cloudMcp.teamSlug + a bearer token (env MCP_ROADMAP_TOKEN OR .aperant/team.json::roadmapMcpToken). Fast Path Guarantee: a missing 'roadmap' block is byte-identical to backend='markdown'.",
120
+ "backend": "markdown",
121
+ "cloudMcp": {
122
+ "teamSlug": null,
123
+ "mcpServerUrl": null,
124
+ "tokenSource": null,
125
+ "scopeMap": {}
126
+ }
127
+ },
119
128
  "docs_policy": {
120
129
  "$comment": "C33 docs-coherence policy. Override via /apt:setup Batch 7. Keys are globs/paths; values are 'auto-update' | 'flag-drift' | 'skip'. Missing block → defaults from packages/framework/templates/docs-policy-defaults.json apply."
121
130
  },
@@ -17,7 +17,7 @@ JSON schema reference for `.aperant/proof/{app}/{date}/{run}/verification.json`
17
17
  "started": "{ISO timestamp}",
18
18
  "updated": "{ISO timestamp}",
19
19
  "spec": "{spec.md path or task description}",
20
- "verdict": "pending | approved | approved_with_notes | rejected",
20
+ "verdict": "pending | approved | rejected | needs_human",
21
21
  "tools": ["electron", "puppeteer", "cdp_video"],
22
22
  "selected_driver": {
23
23
  "driverId": "electron",
@@ -47,6 +47,11 @@ JSON schema reference for `.aperant/proof/{app}/{date}/{run}/verification.json`
47
47
  }
48
48
  },
49
49
 
50
+ "gates": {
51
+ "automation_gate": "pass | fail",
52
+ "proof_gate": "pass | fail"
53
+ },
54
+
50
55
  "coverage": {
51
56
  "registry_file": ".aperant/features/{area}.json",
52
57
  "registered_count": 0,
@@ -76,7 +81,7 @@ JSON schema reference for `.aperant/proof/{app}/{date}/{run}/verification.json`
76
81
  "actual": "Grid correctly tiled to 2+1",
77
82
  "evidence": "electron/screenshots/03-grid.png",
78
83
  "analysis": "Screenshot shows three terminal panes. Top row has 2 equal-width panes, bottom row has 1 centered pane. Grid lines are clean, no overlap or gap artifacts. Each pane shows an active shell prompt with correct path.",
79
- "result": "pass | fail | blocked | skip",
84
+ "result": "pass | fail | blocked | skip | needs_human_with_transport",
80
85
  "severity": null
81
86
  },
82
87
  {
@@ -189,7 +194,19 @@ JSON schema reference for `.aperant/proof/{app}/{date}/{run}/verification.json`
189
194
  | `fallback_attempts` | array | IMMUTABLE | Drivers the resolver REJECTED before selecting. Each entry has `driver_id` + `reason` (missing capabilities, stability filter, hash drift, policy denial). Audit trail for coverage-loss decisions. |
190
195
 
191
196
  ### automated_checks
192
- Each check has `status` (pass/fail) and `output` (captured terminal output). These are populated in step 3 before any manual testing begins.
197
+ Each check has `status` (closed set `{pass, fail}`) and `output` (captured terminal output). These are populated in Step 3 before any outcome driving begins.
198
+
199
+ **Hard STOP gate (v0.9.0).** `status` MUST be one of `{pass, fail}`. The legacy `"enforced_at_commit"` dodge string is REJECTED — the workflow runner aborts at Step 3 with `automation_gate: fail` if any check carries a non-conforming status. Each check must actually execute in the run (no deferring to commit-time enforcement).
200
+
201
+ ### gates (v0.9.0)
202
+
203
+ Top-level `{automation_gate, proof_gate}` derived from `automated_checks` (gate 1) and per-outcome evidence (gate 2). Closed set `{pass, fail}` for each. The two-gate envelope drives `meta.verdict`:
204
+
205
+ | automation_gate | proof_gate | verdict |
206
+ |---|---|---|
207
+ | `pass` | `pass` | `approved` |
208
+ | `pass` | `fail` | `needs_human` (UI-surface transport gap; install driver and re-run) |
209
+ | `fail` | * | `rejected` (any of lint/typecheck/tests red) |
193
210
 
194
211
  ### coverage
195
212
  | Field | Type | Description |
@@ -219,7 +236,7 @@ Each test entry tracks one sub-feature verification. Fields:
219
236
  | `actual` | string | OVERWRITE | What actually happened (filled after testing) |
220
237
  | `evidence` | string | OVERWRITE | Screenshot filename or video timestamp. v0.8.6 — paths are PER-SURFACE: `{surface}/screenshots/{outcome-id}.png`. |
221
238
  | `analysis` | string | OVERWRITE | Adversarial analysis of evidence — describe what you SEE, not what you expect |
222
- | `result` | string | OVERWRITE | `pass`, `fail`, `blocked`, or `skip` |
239
+ | `result` | string | OVERWRITE | `pass`, `fail`, `blocked`, `skip`, or `needs_human_with_transport` (v0.9.0 — UI-surface outcome lacks driver transport; host must install MCP server / driver and re-run). `blocked` keeps its legacy meaning: driver advertised the capability but couldn't perform it in this run. |
223
240
  | `severity` | string/null | OVERWRITE | `blocker`, `major`, `minor`, `cosmetic`, or `null` if pass |
224
241
 
225
242
  ### skipped (MANDATORY)
@@ -242,10 +259,12 @@ Four QA dimensions, each with `score` (1-10) and `rationale` (string). Scores ar
242
259
  - Any major in the area → max score 5
243
260
  - A dimension cannot score above 5 if any blocker exists
244
261
 
245
- **Verdict derivation:**
246
- - ALL dimensions >= 7 → `approved`
247
- - ALL dimensions >= 5 → `approved_with_notes`
248
- - ANY dimension < 5 → `rejected`
262
+ **Verdict derivation (v0.9.0 two-gate envelope — see workflow Step 7):**
263
+ - `automation_gate: pass` AND `proof_gate: pass` → `approved`
264
+ - `automation_gate: pass` AND `proof_gate: fail` → `needs_human`
265
+ - `automation_gate: fail` OR critical issues unresolved → `rejected`
266
+
267
+ Dimensions are informational rationale only — they do NOT drive the verdict in v0.9.0+.
249
268
 
250
269
  ### gaps
251
270
  Issues discovered during testing. Feeds into the fix planning pipeline.