@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,81 @@
1
+ /**
2
+ * commands/recipe-diff.mjs — `apt-tools recipe-diff <projectDir> [--format json|md]`.
3
+ *
4
+ * Loads bundled recipes, runs the pattern-matcher against the adopter's
5
+ * runtime + package.json to pick a recipe, then calls generateRecipeDiff
6
+ * to emit the Markdown body.
7
+ *
8
+ * Exit codes:
9
+ * 0 — success (envelope.status:'ok')
10
+ * 1 — no-recipe-match (envelope.status:'error', code:'E_NO_RECIPE')
11
+ *
12
+ * Output:
13
+ * default / --format json → JSON envelope to stdout
14
+ * --format md → raw Markdown body to stdout
15
+ */
16
+
17
+ import { parseFlags } from '../util/args.mjs'
18
+ import { err, exitWith, ok, rawText } from '../util/result.mjs'
19
+ import { loadRuntimeCapabilities } from '../util/runtime-capabilities.mjs'
20
+ import { matchPatternForProject } from '../verify-proof/sandbox/pattern-matcher.mjs'
21
+ import { generateRecipeDiff } from '../verify-proof/sandbox/recipe-diff.mjs'
22
+ import { discoverBundledRecipes } from '../verify-proof/sandbox/sandbox-config.mjs'
23
+
24
+ /**
25
+ * @param {string} projectDir
26
+ * @param {string[]} [extra]
27
+ */
28
+ export function cmdRecipeDiff(projectDir, extra = []) {
29
+ if (!projectDir) return err('Usage: apt-tools recipe-diff <project-dir> [--format json|md]')
30
+
31
+ const flags = parseFlags(extra)
32
+ const formatFlag = flags.get('format') ?? 'json'
33
+ if (formatFlag !== 'json' && formatFlag !== 'md') {
34
+ return err(`recipe-diff: --format must be 'json' or 'md', got '${formatFlag}'`)
35
+ }
36
+
37
+ const recipes = discoverBundledRecipes()
38
+ const runtime = loadRuntimeCapabilities(projectDir)
39
+
40
+ // recipeId can be passed explicitly via --recipe; otherwise we match.
41
+ const explicitRecipeId = flags.get('recipe') ?? null
42
+ let recipeId
43
+ let matchReason
44
+ if (explicitRecipeId) {
45
+ recipeId = explicitRecipeId
46
+ matchReason = 'explicit'
47
+ } else {
48
+ const match = matchPatternForProject(projectDir, runtime, recipes)
49
+ recipeId = match.recipeId
50
+ matchReason = match.reason
51
+ }
52
+
53
+ if (!recipeId) {
54
+ const envelope = {
55
+ status: 'error',
56
+ command: 'recipe-diff',
57
+ code: 'E_NO_RECIPE',
58
+ reason: matchReason,
59
+ runtime,
60
+ docs: 'docs/verify-proof-sandbox-patterns.md',
61
+ }
62
+ return exitWith(envelope, 1)
63
+ }
64
+
65
+ const diff = generateRecipeDiff({ projectDir, recipeId, recipes })
66
+
67
+ if (formatFlag === 'md') {
68
+ const exitCode = diff.envelope.status === 'ok' ? 0 : 1
69
+ return rawText(diff.body, exitCode)
70
+ }
71
+
72
+ return ok({
73
+ status: 'ok',
74
+ command: 'recipe-diff',
75
+ project_dir: projectDir,
76
+ recipe_id: recipeId,
77
+ match_reason: matchReason,
78
+ runtime,
79
+ ...diff,
80
+ })
81
+ }
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
11
11
  import { basename, join, resolve } from 'node:path'
12
12
  import { selfStageCommittableWrites } from '../artifacts/self-stage.mjs'
13
13
  import { getDefaultOwner, getGitIdentity } from '../git/identity.mjs'
14
+ import { dispatchRoadmapWrite } from '../roadmap/backend-adapter.mjs'
14
15
  import { runConvexMirror } from '../roadmap/convex-mirror.mjs'
15
16
  import {
16
17
  loadRoadmapForScope as ioLoadRoadmapForScope,
@@ -197,6 +198,34 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
197
198
  return defaultScope
198
199
  }
199
200
 
201
+ // Task 26-05-26 subtask 2.4 — `.aperant/config.json :: roadmap.backend`
202
+ // dispatch. Called at the top of each write-side subcommand. When the
203
+ // configured backend is `cloud-mcp` AND the dispatch succeeds (or fails
204
+ // with a structured error), we surface that envelope and short-circuit
205
+ // the existing markdown file-write path. When the backend is `markdown`
206
+ // (the Fast Path Guarantee default), the dispatcher returns
207
+ // `delegated:false` and the subcommand proceeds with the existing
208
+ // markdown writes verbatim — byte-identical to pre-2.4 behaviour.
209
+ async function maybeDelegate(subcommand, args) {
210
+ const fetchImpl =
211
+ cmdRoadmap._injectedFetch !== undefined ? cmdRoadmap._injectedFetch : undefined
212
+ const decision = await dispatchRoadmapWrite(targetDir, subcommand, args, { fetchImpl })
213
+ if (decision.delegated === false) return null
214
+ if (decision.status === 'error') {
215
+ return err(decision.error, decision.code === 401 ? 1 : 1, {
216
+ command: `roadmap-${subcommand}`,
217
+ backend: 'cloud-mcp',
218
+ })
219
+ }
220
+ return ok({
221
+ status: 'ok',
222
+ command: `roadmap-${subcommand}`,
223
+ backend: 'cloud-mcp',
224
+ delegated: true,
225
+ result: decision.result,
226
+ })
227
+ }
228
+
200
229
  // C28 D-07 — cascade-null helper. The scanning of affected tasks is
201
230
  // inlined at each destructive roadmap path so the scan + mutate can run
202
231
  // under a single `withFileLock(state.json)` and close the TOCTOU window.
@@ -357,6 +386,9 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
357
386
  const scope = (flags.get('scope') || 'project').trim() || 'project'
358
387
  if (!id || !title) throw new RoadmapUsageError('Required: --id <id> --title <title>')
359
388
 
389
+ const delegated = await maybeDelegate('add-milestone', { id, title, goal, scope })
390
+ if (delegated) return delegated
391
+
360
392
  // Auto-init the scope if its roadmap file doesn't exist yet.
361
393
  const { jsonPath } = scopePaths(scope)
362
394
  if (!existsSync(jsonPath)) {
@@ -402,6 +434,15 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
402
434
  if (!milestoneId || !id || !title)
403
435
  throw new RoadmapUsageError('Required: --milestone <id> --id <id> --title <title>')
404
436
 
437
+ const delegated = await maybeDelegate('add-phase', {
438
+ milestoneId,
439
+ id,
440
+ title,
441
+ description: flags.get('description') || '',
442
+ status: flags.get('status') || 'planned',
443
+ })
444
+ if (delegated) return delegated
445
+
405
446
  const scope = resolveScope({ milestoneId })
406
447
  const roadmap = loadRoadmap(scope)
407
448
  const milestone = roadmap.milestones.find((m) => m.id === milestoneId)
@@ -445,6 +486,13 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
445
486
  if (!milestoneId || !phaseId || !status)
446
487
  throw new RoadmapUsageError('Required: --milestone <id> --phase <id> --status <status>')
447
488
 
489
+ const delegated = await maybeDelegate('set-phase-status', {
490
+ milestoneId,
491
+ phaseId,
492
+ status,
493
+ })
494
+ if (delegated) return delegated
495
+
448
496
  // C28 D-07 — `deleted` is the destructive path that triggers orphan
449
497
  // block + cascade-null. All other statuses are non-destructive.
450
498
  const validStatuses = ['planned', 'in-progress', 'completed', 'blocked', 'skipped', 'deleted']
@@ -602,6 +650,9 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
602
650
  const phaseId = flags.get('phase')
603
651
  if (!milestoneId || !phaseId)
604
652
  throw new RoadmapUsageError('Required: --milestone <id> --phase <id>')
653
+
654
+ const delegated = await maybeDelegate('delete-phase', { milestoneId, phaseId })
655
+ if (delegated) return delegated
605
656
  const force = extraArgs.includes('--force')
606
657
  const reason = flags.get('reason') || null
607
658
  if (force && !reason) {
@@ -698,6 +749,9 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
698
749
  case 'delete-milestone': {
699
750
  const milestoneId = flags.get('id') || flags.get('milestone')
700
751
  if (!milestoneId) throw new RoadmapUsageError('Required: --id <milestone-id>')
752
+
753
+ const delegated = await maybeDelegate('delete-milestone', { milestoneId })
754
+ if (delegated) return delegated
701
755
  const force = extraArgs.includes('--force')
702
756
  const reason = flags.get('reason') || null
703
757
  if (force && !reason) {
@@ -0,0 +1,231 @@
1
+ /**
2
+ * commands/verify-sandbox.mjs — `apt-tools verify-sandbox <projectDir>`.
3
+ *
4
+ * Drives runSelfTest() then atomically writes
5
+ * .aperant/state/verify-proof-sandbox-verified.json with the v2-shaped
6
+ * verdict envelope. On win32, short-circuits to a platform_unsupported
7
+ * verdict WITHOUT spawning child processes (ID-06 v1 supported floor =
8
+ * darwin + linux).
9
+ *
10
+ * The state file is written for BOTH verified-true and verified-false
11
+ * outcomes (ID-02 — no silent state mutation). The sandbox gate at
12
+ * apt-verify-proof/SKILL.md Step 2c (subtask 5.1) reads this file.
13
+ */
14
+
15
+ import { join, resolve } from 'node:path'
16
+ import { atomicWriteJson } from '../util/atomic-write.mjs'
17
+ import { withFileLock } from '../util/fs-lock.mjs'
18
+ import { err, exitWith, ok } from '../util/result.mjs'
19
+ import { loadRuntimeCapabilities } from '../util/runtime-capabilities.mjs'
20
+ import { matchPatternForProject } from '../verify-proof/sandbox/pattern-matcher.mjs'
21
+ import {
22
+ discoverBundledRecipes,
23
+ readSandboxConfig,
24
+ } from '../verify-proof/sandbox/sandbox-config.mjs'
25
+ import { runSelfTest } from '../verify-proof/sandbox/self-test.mjs'
26
+
27
+ /**
28
+ * @param {string} projectDir
29
+ * @param {string[]} [extra]
30
+ */
31
+ export async function cmdVerifySandbox(projectDir, _extra = []) {
32
+ if (!projectDir) return err('Usage: apt-tools verify-sandbox <project-dir>')
33
+
34
+ const target = resolve(projectDir)
35
+ const platform = process.platform
36
+ const statePath = join(target, '.aperant', 'state', 'verify-proof-sandbox-verified.json')
37
+ const at = new Date().toISOString()
38
+
39
+ const host = {
40
+ platform,
41
+ arch: process.arch,
42
+ nodeVersion: process.version,
43
+ }
44
+
45
+ // Resolve recipeId from config or runtime detection.
46
+ const sandboxCfg = readSandboxConfig(target)
47
+ const recipes = discoverBundledRecipes()
48
+ let recipeId = sandboxCfg?.recipeId ?? null
49
+ let recipeVersion = sandboxCfg?.recipeVersion ?? null
50
+ if (!recipeId) {
51
+ const runtime = loadRuntimeCapabilities(target)
52
+ const match = matchPatternForProject(target, runtime, recipes)
53
+ recipeId = match.recipeId
54
+ }
55
+ // Always populate recipeVersion from bundled recipes when absent — covers
56
+ // the case where config pins recipeId but omits recipeVersion (LOG-003).
57
+ if (recipeId && !recipeVersion) {
58
+ recipeVersion = recipes.find((r) => r.recipeId === recipeId)?.recipeVersion ?? null
59
+ }
60
+
61
+ // win32 short-circuit (AC12, ID-06 v1 floor).
62
+ if (platform === 'win32') {
63
+ const envelope = {
64
+ verified: false,
65
+ verdict: 'needs_human',
66
+ reason: 'platform_unsupported',
67
+ recipeId,
68
+ recipeVersion,
69
+ verifiedAt: at,
70
+ host,
71
+ }
72
+ const writeErr = writeStateFile(statePath, envelope)
73
+ if (writeErr) {
74
+ return exitWith(
75
+ {
76
+ status: 'error',
77
+ command: 'verify-sandbox',
78
+ code: 'E_STATE_WRITE_FAILED',
79
+ verdict: 'needs_human',
80
+ reason: 'state_write_failed',
81
+ cause: writeErr.message,
82
+ project_dir: target,
83
+ state_path: statePath,
84
+ },
85
+ 1,
86
+ )
87
+ }
88
+ return ok({
89
+ status: 'ok',
90
+ command: 'verify-sandbox',
91
+ project_dir: target,
92
+ state_path: statePath,
93
+ ...envelope,
94
+ })
95
+ }
96
+
97
+ if (!recipeId) {
98
+ const envelope = {
99
+ verified: false,
100
+ verdict: 'needs_human',
101
+ reason: 'no_recipe_available',
102
+ recipeId: null,
103
+ recipeVersion: null,
104
+ verifiedAt: at,
105
+ host,
106
+ }
107
+ const writeErr = writeStateFile(statePath, envelope)
108
+ if (writeErr) {
109
+ return exitWith(
110
+ {
111
+ status: 'error',
112
+ command: 'verify-sandbox',
113
+ code: 'E_STATE_WRITE_FAILED',
114
+ verdict: 'needs_human',
115
+ reason: 'state_write_failed',
116
+ cause: writeErr.message,
117
+ project_dir: target,
118
+ state_path: statePath,
119
+ },
120
+ 1,
121
+ )
122
+ }
123
+ return exitWith(
124
+ {
125
+ status: 'error',
126
+ command: 'verify-sandbox',
127
+ code: 'E_NO_RECIPE',
128
+ project_dir: target,
129
+ state_path: statePath,
130
+ ...envelope,
131
+ },
132
+ 1,
133
+ )
134
+ }
135
+
136
+ let result
137
+ try {
138
+ result = await runSelfTest({ projectDir: target, recipeId, recipes })
139
+ } catch (e) {
140
+ const envelope = {
141
+ verified: false,
142
+ verdict: 'needs_human',
143
+ reason: 'self_test_failed_app_init_timeout',
144
+ recipeId,
145
+ recipeVersion,
146
+ verifiedAt: at,
147
+ host,
148
+ error: e?.message ?? String(e),
149
+ }
150
+ const writeErr = writeStateFile(statePath, envelope)
151
+ if (writeErr) {
152
+ return exitWith(
153
+ {
154
+ status: 'error',
155
+ command: 'verify-sandbox',
156
+ code: 'E_STATE_WRITE_FAILED',
157
+ verdict: 'needs_human',
158
+ reason: 'state_write_failed',
159
+ cause: writeErr.message,
160
+ project_dir: target,
161
+ state_path: statePath,
162
+ },
163
+ 1,
164
+ )
165
+ }
166
+ return exitWith(
167
+ {
168
+ status: 'error',
169
+ command: 'verify-sandbox',
170
+ project_dir: target,
171
+ state_path: statePath,
172
+ ...envelope,
173
+ },
174
+ 1,
175
+ )
176
+ }
177
+
178
+ const envelope = {
179
+ verified: result.verified,
180
+ verdict: result.verdict,
181
+ reason: result.reason,
182
+ recipeId,
183
+ recipeVersion,
184
+ verifiedAt: at,
185
+ host,
186
+ durationMs: result.durationMs,
187
+ }
188
+ const writeErr = writeStateFile(statePath, envelope)
189
+ if (writeErr) {
190
+ return exitWith(
191
+ {
192
+ status: 'error',
193
+ command: 'verify-sandbox',
194
+ code: 'E_STATE_WRITE_FAILED',
195
+ verdict: 'needs_human',
196
+ reason: 'state_write_failed',
197
+ cause: writeErr.message,
198
+ project_dir: target,
199
+ state_path: statePath,
200
+ },
201
+ 1,
202
+ )
203
+ }
204
+
205
+ const exitCode = result.verified ? 0 : 1
206
+ return exitWith(
207
+ {
208
+ status: result.verified ? 'ok' : 'error',
209
+ command: 'verify-sandbox',
210
+ project_dir: target,
211
+ state_path: statePath,
212
+ ...envelope,
213
+ },
214
+ exitCode,
215
+ )
216
+ }
217
+
218
+ /**
219
+ * Write the verdict envelope to the state file under a file lock.
220
+ * Returns null on success, or an Error if the lock or write fails.
221
+ */
222
+ function writeStateFile(statePath, envelope) {
223
+ try {
224
+ withFileLock(statePath, () => {
225
+ atomicWriteJson(statePath, envelope)
226
+ })
227
+ return null
228
+ } catch (e) {
229
+ return e instanceof Error ? e : new Error(String(e))
230
+ }
231
+ }
@@ -7,13 +7,17 @@
7
7
  * - flat findings: findings: [{severity}]
8
8
  * - legacy `issues: [{severity}]`
9
9
  *
10
- * Verdict closed set: {approved, approved-with-notes, rejected, needs-changes}.
11
- * Anything else -> 'unknown'. Accepts legacy `status` as a fallback field name.
10
+ * v0.9.0 (verify-proof-v2-outcome-walker_27-05-26) verdict closed set is
11
+ * `{approved, rejected, needs_human, needs-changes}`. `approved_with_notes`
12
+ * is DELETED (no shim) — reading a legacy `approved_with_notes` /
13
+ * `approved-with-notes` value throws a clear migration error pointing at
14
+ * the framework CHANGELOG entry. Anything else maps to `'unknown'`.
15
+ * Accepts legacy `status` as a fallback field name.
12
16
  *
13
17
  * Output: {verdict, findings: [{severity, title, status, file}]}
14
18
  */
15
19
 
16
- const KNOWN_VERDICTS = new Set(['approved', 'approved-with-notes', 'rejected', 'needs-changes'])
20
+ const KNOWN_VERDICTS = new Set(['approved', 'rejected', 'needs-human', 'needs-changes'])
17
21
 
18
22
  const SEVERITY_MAP = {
19
23
  critical: 'critical',
@@ -56,7 +60,19 @@ function normalizeVerdict(v) {
56
60
  const k = v.toLowerCase().trim()
57
61
  // normalize common spellings
58
62
  const canonical = k.replace(/_/g, '-')
59
- return KNOWN_VERDICTS.has(canonical) ? canonical : 'unknown'
63
+ // v0.9.0 migration: approved_with_notes / approved-with-notes is
64
+ // DELETED with no shim. Crash loudly so the consumer task is
65
+ // re-run, not silently re-mapped to `approved`.
66
+ if (canonical === 'approved-with-notes') {
67
+ throw new Error(
68
+ "qa_signoff.json carries the legacy verdict 'approved_with_notes' (or 'approved-with-notes'). " +
69
+ "This verdict is removed in framework v0.9.0+ (see packages/framework/CHANGELOG.md :: 'verify-proof v2 — outcome walker + two-gate verdict'). " +
70
+ 'Re-run verify-proof on this task to mint a fresh qa_signoff.json with the new {approved, rejected, needs_human} enum.',
71
+ )
72
+ }
73
+ // Accept both snake_case and kebab-case for needs_human.
74
+ const needsHuman = canonical === 'needs-human' ? 'needs-human' : canonical
75
+ return KNOWN_VERDICTS.has(needsHuman) ? needsHuman : 'unknown'
60
76
  }
61
77
 
62
78
  function collectRaw(qa) {
@@ -3,13 +3,15 @@
3
3
  * consistency/rules/r5-verdict-consistency.mjs — R5 (C15).
4
4
  *
5
5
  * Fails when:
6
- * - qa.verdict === 'approved' or 'approved-with-notes' AND review has >=1
7
- * unresolved critical or major finding
8
- * - qa.verdict === 'rejected' AND qa has no findings at all AND review has
9
- * zero unresolved findings (rejected-without-evidence)
6
+ * - qa.verdict === 'approved' AND review has >=1 unresolved critical or
7
+ * major finding.
8
+ * - qa.verdict === 'rejected' AND qa has no findings at all AND review
9
+ * has zero unresolved findings (rejected-without-evidence).
10
10
  *
11
- * `approved-with-notes` no longer escapes unresolved critical or major
12
- * findings (G42 Phase 1). It still passes for lower-severity issues.
11
+ * v0.9.0 (verify-proof-v2-outcome-walker_27-05-26) the legacy
12
+ * `approved-with-notes` branch is REMOVED. The verdict enum is the closed
13
+ * set `{approved, rejected, needs_human}`. Reading a legacy verdict
14
+ * crashes in parseQaSignoff before this rule executes.
13
15
  *
14
16
  * Severity: error. Stages: verify, ship.
15
17
  */
@@ -30,10 +32,7 @@ const rule = {
30
32
  const unresolvedBlocking = reviewFindings.filter(
31
33
  (f) => (f.severity === 'critical' || f.severity === 'major') && f.status !== 'fixed',
32
34
  )
33
- if (
34
- (verdict === 'approved' || verdict === 'approved-with-notes') &&
35
- unresolvedBlocking.length > 0
36
- ) {
35
+ if (verdict === 'approved' && unresolvedBlocking.length > 0) {
37
36
  return {
38
37
  status: 'fail',
39
38
  drift: [
@@ -43,11 +42,10 @@ const rule = {
43
42
  id: f.id,
44
43
  title: f.title,
45
44
  })),
46
- reason:
47
- 'approved/approved-with-notes verdict with unresolved critical or major finding(s) (G42 Phase 1)',
45
+ reason: 'approved verdict with unresolved critical or major finding(s) (G42 Phase 1)',
48
46
  },
49
47
  ],
50
- reason: `approved/approved-with-notes verdict contradicts ${unresolvedBlocking.length} unresolved critical/major finding(s)`,
48
+ reason: `approved verdict contradicts ${unresolvedBlocking.length} unresolved critical/major finding(s)`,
51
49
  }
52
50
  }
53
51
  if (verdict === 'rejected') {
@@ -33,20 +33,21 @@
33
33
  * authoring contract.
34
34
  */
35
35
 
36
- const VALID_SURFACES = new Set(['electron', 'web', 'cli', 'api', 'electron+web'])
36
+ const VALID_SURFACES = new Set(['electron', 'web', 'cli', 'api', 'mobile', 'electron+web'])
37
37
 
38
38
  const HEADING_RE = /^(#{1,6})\s+(.*)$/
39
39
  const USER_OUTCOMES_HEADING_RE = /^user\s+outcomes\s*$/i
40
40
  const EPIC_RE = /^\*\*Epic:\*\*\s+(.+)$/
41
41
  const EMPTY_WITH_NOTE_RE = /^_No user-observable changes — pure refactor\._\s*$/
42
42
  const OUTCOME_RE =
43
- /^-\s+\*\*ON\*\*\s+\[(electron|web|cli|api|electron\+web)\]:\s+(.+?)(\s+\*\(requires\s+ON\s+(\d+)\)\*)?\s*$/
43
+ /^-\s+\*\*ON\*\*\s+\[(electron|web|cli|api|mobile|electron\+web)\]:\s+(.+?)(\s+\*\(requires\s+ON\s+(\d+)\)\*)?\s*$/
44
44
 
45
45
  /**
46
46
  * @typedef {Object} UserOutcome
47
47
  * @property {string} id — synthesized positional ID (O1, O2, ...)
48
- * @property {('electron'|'web'|'cli'|'api'|'electron+web')} surface
48
+ * @property {('electron'|'web'|'cli'|'api'|'mobile'|'electron+web')} surface
49
49
  * @property {string} text — outcome prose (sans the surface tag and requires suffix)
50
+ * @property {string[]} inferred — v0.9.0 — indented child bullets under the parent outcome (raw prose, no further parsing). Defaults to [] when no children are present.
50
51
  * @property {number} [requires] — the digit captured from `*(requires ON N)*`, when present and valid
51
52
  */
52
53
 
@@ -165,24 +166,58 @@ export function parseUserOutcomes(specContent) {
165
166
  return result
166
167
  }
167
168
 
168
- // Parse outcome rows in document order. Skip non-matching lines (e.g.
169
- // stray prose between outcomes) they don't count as outcomes.
169
+ // Parse outcome rows in document order. v0.9.0 walk the raw body lines
170
+ // (not the trimmed `outcomeLines`) so we preserve indentation of child
171
+ // bullets. A `**ON**` parent row is followed by zero or more indented
172
+ // child bullets (lines starting with at least 2 spaces + `- `); those
173
+ // children attach to the parent as `inferred: string[]` (raw prose, no
174
+ // further parsing). The children block ends at the next `**ON**` row,
175
+ // a non-indented line, or a section terminator (blank line followed by
176
+ // another non-bullet non-blank line is fine; the next `**ON**` always
177
+ // resets ownership).
170
178
  /** @type {UserOutcome[]} */
171
179
  const outcomes = []
172
180
  const rawRequires = []
173
- for (const line of outcomeLines) {
174
- const m = line.match(OUTCOME_RE)
175
- if (!m) continue
176
- const surface = /** @type {UserOutcome['surface']} */ (m[1])
177
- if (!VALID_SURFACES.has(surface)) continue
178
- const text = m[2].trim()
179
- const id = 'O' + (outcomes.length + 1)
180
- const outcome = /** @type {UserOutcome} */ ({ id, surface, text })
181
- if (m[4]) {
182
- const n = Number.parseInt(m[4], 10)
183
- rawRequires.push({ outcomeIndex: outcomes.length, requires: n })
181
+ const CHILD_RE = /^(?: {2}|\t)+- (.+)$/
182
+ let currentOutcomeIndex = -1
183
+ for (let i = 0; i < bodyLines.length; i++) {
184
+ const rawLine = bodyLines[i]
185
+ const trimmed = rawLine.trim()
186
+ if (trimmed.length === 0) {
187
+ // Blank lines don't close the parent — children may resume after
188
+ // a blank line (loose-list markdown). The next non-blank line
189
+ // decides ownership.
190
+ continue
191
+ }
192
+ const onMatch = trimmed.match(OUTCOME_RE)
193
+ if (onMatch) {
194
+ const surface = /** @type {UserOutcome['surface']} */ (onMatch[1])
195
+ if (!VALID_SURFACES.has(surface)) {
196
+ currentOutcomeIndex = -1
197
+ continue
198
+ }
199
+ const text = onMatch[2].trim()
200
+ const id = 'O' + (outcomes.length + 1)
201
+ const outcome = /** @type {UserOutcome} */ ({ id, surface, text, inferred: [] })
202
+ if (onMatch[4]) {
203
+ const n = Number.parseInt(onMatch[4], 10)
204
+ rawRequires.push({ outcomeIndex: outcomes.length, requires: n })
205
+ }
206
+ outcomes.push(outcome)
207
+ currentOutcomeIndex = outcomes.length - 1
208
+ continue
209
+ }
210
+ // Non-`**ON**` non-blank line. If it's an indented child bullet AND
211
+ // we have a current outcome, attach it. Otherwise (e.g. Epic line,
212
+ // sentinel, stray prose), drop ownership.
213
+ const childMatch = rawLine.match(CHILD_RE)
214
+ if (childMatch && currentOutcomeIndex >= 0) {
215
+ outcomes[currentOutcomeIndex].inferred.push(childMatch[1].trim())
216
+ continue
184
217
  }
185
- outcomes.push(outcome)
218
+ // Non-indented, non-`**ON**` line — closes the current outcome's
219
+ // child block. (The next `**ON**` will start a new block.)
220
+ currentOutcomeIndex = -1
186
221
  }
187
222
 
188
223
  if (outcomes.length === 0) {
@@ -36,6 +36,7 @@
36
36
 
37
37
  import { cmdActive } from './commands/active.mjs'
38
38
  import { cmdAdr } from './commands/adr.mjs'
39
+ // cmdApplyRecipe — loaded lazily in the dispatch handler (Fast Path PRF-001)
39
40
  import { cmdAudit } from './commands/audit.mjs'
40
41
  import { cmdAuthorSkill } from './commands/author-skill.mjs'
41
42
  import { cmdBootstrap } from './commands/bootstrap.mjs'
@@ -70,12 +71,14 @@ import { cmdInitState } from './commands/init-state.mjs'
70
71
  import { cmdInstallFromSource } from './commands/install-from-source.mjs'
71
72
  import { cmdLock } from './commands/lock.mjs'
72
73
  import { cmdMigratePrReviews } from './commands/migrate-pr-reviews.mjs'
74
+ import { cmdMobilePrepare } from './commands/mobile-prepare.mjs'
73
75
  import { cmdPersonas } from './commands/personas.mjs'
74
76
  import { cmdPlanApproval } from './commands/plan-approval.mjs'
75
77
  import { cmdPrReview } from './commands/pr-review.mjs'
76
78
  import { cmdPrecompactHook } from './commands/precompact-hook.mjs'
77
79
  import { cmdProductContext } from './commands/product-context.mjs'
78
80
  import { cmdProfile } from './commands/profile.mjs'
81
+ // cmdRecipeDiff — loaded lazily in the dispatch handler (Fast Path PRF-001)
79
82
  import { cmdReleaseNotes } from './commands/release-notes.mjs'
80
83
  import { cmdRiskCheck } from './commands/risk-check.mjs'
81
84
  import { cmdRoadmap } from './commands/roadmap.mjs'
@@ -98,6 +101,7 @@ import { cmdValidateEvidence } from './commands/validate-evidence.mjs'
98
101
  import { cmdVariantGallery } from './commands/variant-gallery.mjs'
99
102
  import { cmdVariantPick } from './commands/variant-pick.mjs'
100
103
  import { cmdVariantPool } from './commands/variant-pool.mjs'
104
+ // cmdVerifySandbox — loaded lazily in the dispatch handler (Fast Path PRF-001)
101
105
  import { cmdVitestDoctor } from './commands/vitest-doctor.mjs'
102
106
  import { cmdWave } from './commands/wave.mjs'
103
107
  import { cmdWfrun } from './commands/wfrun.mjs'
@@ -219,6 +223,18 @@ export const DISPATCH = {
219
223
  'state-artifacts': (args) => wrap(cmdStateArtifacts)(args[1], args.slice(2)),
220
224
  'detect-project': (args) => wrap(cmdDetectProject)(args[1]),
221
225
  'detect-runtime': (args) => wrap(cmdDetectRuntime)(args[1], args.slice(2)),
226
+ 'recipe-diff': async (args) => {
227
+ const { cmdRecipeDiff } = await import('./commands/recipe-diff.mjs')
228
+ return wrap(cmdRecipeDiff)(args[1], args.slice(2))
229
+ },
230
+ 'apply-recipe': async (args) => {
231
+ const { cmdApplyRecipe } = await import('./commands/apply-recipe.mjs')
232
+ return wrap(cmdApplyRecipe)(args[1], args.slice(2))
233
+ },
234
+ 'verify-sandbox': async (args) => {
235
+ const { cmdVerifySandbox } = await import('./commands/verify-sandbox.mjs')
236
+ return wrap(cmdVerifySandbox)(args[1], args.slice(2))
237
+ },
222
238
  driver: (args) => {
223
239
  // `apt-tools driver doctor <project-dir>` — only subcommand for now.
224
240
  if (args[1] === 'doctor') return wrap(cmdDriverDoctor)(args[2], args.slice(3))
@@ -230,6 +246,17 @@ export const DISPATCH = {
230
246
  exitCode: 1,
231
247
  }))()
232
248
  },
249
+ mobile: (args) => {
250
+ // `apt-tools mobile prepare <project-dir> --platform <ios|android> [--mode <expo|native>] [--device <id>]`
251
+ if (args[1] === 'prepare') return wrap(cmdMobilePrepare)(args[2], args.slice(3))
252
+ return wrap(() => ({
253
+ envelope: {
254
+ status: 'error',
255
+ error: `Usage: apt-tools mobile prepare <project-dir> --platform <ios|android>. Unknown subcommand: ${args[1] ?? '(none)'}`,
256
+ },
257
+ exitCode: 1,
258
+ }))()
259
+ },
233
260
  'features-audit': (args) => wrap(cmdFeaturesAudit)(args[1], args.slice(2)),
234
261
  'init-state': (args) => wrap(cmdInitState)(args[1], args.slice(2)),
235
262
  'risk-check': (args) => wrap(cmdRiskCheck)(args[1], args.slice(2)),