@aperant/framework 0.10.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 (199) hide show
  1. package/CHANGELOG.md +236 -1
  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/catch-up.d.mts +9 -0
  10. package/dist/cli/commands/catch-up.d.mts.map +1 -0
  11. package/dist/cli/commands/catch-up.mjs +64 -0
  12. package/dist/cli/commands/catch-up.mjs.map +1 -0
  13. package/dist/cli/commands/mobile-prepare.d.mts +53 -0
  14. package/dist/cli/commands/mobile-prepare.d.mts.map +1 -0
  15. package/dist/cli/commands/mobile-prepare.mjs +139 -0
  16. package/dist/cli/commands/mobile-prepare.mjs.map +1 -0
  17. package/dist/cli/commands/pr-review.d.mts +16 -4
  18. package/dist/cli/commands/pr-review.d.mts.map +1 -1
  19. package/dist/cli/commands/pr-review.mjs +30 -11
  20. package/dist/cli/commands/pr-review.mjs.map +1 -1
  21. package/dist/cli/commands/recipe-diff.d.mts +6 -0
  22. package/dist/cli/commands/recipe-diff.d.mts.map +1 -0
  23. package/dist/cli/commands/recipe-diff.mjs +75 -0
  24. package/dist/cli/commands/recipe-diff.mjs.map +1 -0
  25. package/dist/cli/commands/roadmap.d.mts.map +1 -1
  26. package/dist/cli/commands/roadmap.mjs +53 -0
  27. package/dist/cli/commands/roadmap.mjs.map +1 -1
  28. package/dist/cli/commands/route.d.mts.map +1 -1
  29. package/dist/cli/commands/route.mjs +92 -2
  30. package/dist/cli/commands/route.mjs.map +1 -1
  31. package/dist/cli/commands/verify-sandbox.d.mts +6 -0
  32. package/dist/cli/commands/verify-sandbox.d.mts.map +1 -0
  33. package/dist/cli/commands/verify-sandbox.mjs +200 -0
  34. package/dist/cli/commands/verify-sandbox.mjs.map +1 -0
  35. package/dist/cli/config/share-policy.d.mts.map +1 -1
  36. package/dist/cli/config/share-policy.mjs +1 -0
  37. package/dist/cli/config/share-policy.mjs.map +1 -1
  38. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  39. package/dist/cli/config/upgrade-gitignore.mjs +1 -0
  40. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  41. package/dist/cli/consistency/parse-qa.d.mts.map +1 -1
  42. package/dist/cli/consistency/parse-qa.mjs +18 -4
  43. package/dist/cli/consistency/parse-qa.mjs.map +1 -1
  44. package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
  45. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +11 -10
  46. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
  47. package/dist/cli/coordination/catch-up.d.mts +26 -0
  48. package/dist/cli/coordination/catch-up.d.mts.map +1 -0
  49. package/dist/cli/coordination/catch-up.mjs +239 -0
  50. package/dist/cli/coordination/catch-up.mjs.map +1 -0
  51. package/dist/cli/coordination/last-seen.d.mts +45 -0
  52. package/dist/cli/coordination/last-seen.d.mts.map +1 -0
  53. package/dist/cli/coordination/last-seen.mjs +128 -0
  54. package/dist/cli/coordination/last-seen.mjs.map +1 -0
  55. package/dist/cli/coordination/store.d.mts +15 -0
  56. package/dist/cli/coordination/store.d.mts.map +1 -1
  57. package/dist/cli/coordination/store.mjs +16 -0
  58. package/dist/cli/coordination/store.mjs.map +1 -1
  59. package/dist/cli/coverage-check/user-outcomes.d.mts +7 -2
  60. package/dist/cli/coverage-check/user-outcomes.d.mts.map +1 -1
  61. package/dist/cli/coverage-check/user-outcomes.mjs +50 -17
  62. package/dist/cli/coverage-check/user-outcomes.mjs.map +1 -1
  63. package/dist/cli/dispatch.d.mts.map +1 -1
  64. package/dist/cli/dispatch.mjs +30 -0
  65. package/dist/cli/dispatch.mjs.map +1 -1
  66. package/dist/cli/gate/gates/verify-approved.d.mts +0 -48
  67. package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
  68. package/dist/cli/gate/gates/verify-approved.mjs +23 -80
  69. package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
  70. package/dist/cli/git/default-branch.d.mts +51 -0
  71. package/dist/cli/git/default-branch.d.mts.map +1 -0
  72. package/dist/cli/git/default-branch.mjs +234 -0
  73. package/dist/cli/git/default-branch.mjs.map +1 -0
  74. package/dist/cli/git/identity.d.mts +3 -5
  75. package/dist/cli/git/identity.d.mts.map +1 -1
  76. package/dist/cli/git/identity.mjs +10 -4
  77. package/dist/cli/git/identity.mjs.map +1 -1
  78. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  79. package/dist/cli/install/legacy-paths.mjs +2 -0
  80. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  81. package/dist/cli/install/mcp-server-specs.d.mts +6 -5
  82. package/dist/cli/install/mcp-server-specs.d.mts.map +1 -1
  83. package/dist/cli/install/mcp-server-specs.mjs +22 -4
  84. package/dist/cli/install/mcp-server-specs.mjs.map +1 -1
  85. package/dist/cli/roadmap/backend-adapter.d.mts +43 -0
  86. package/dist/cli/roadmap/backend-adapter.d.mts.map +1 -0
  87. package/dist/cli/roadmap/backend-adapter.mjs +222 -0
  88. package/dist/cli/roadmap/backend-adapter.mjs.map +1 -0
  89. package/dist/cli/util/runtime-capabilities.d.mts +7 -0
  90. package/dist/cli/util/runtime-capabilities.d.mts.map +1 -1
  91. package/dist/cli/util/runtime-capabilities.mjs +65 -0
  92. package/dist/cli/util/runtime-capabilities.mjs.map +1 -1
  93. package/dist/cli/verify-proof/manifest-validator.mjs +13 -0
  94. package/dist/cli/verify-proof/manifest-validator.mjs.map +1 -1
  95. package/dist/cli/verify-proof/resolver.d.mts +10 -7
  96. package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
  97. package/dist/cli/verify-proof/resolver.mjs +28 -7
  98. package/dist/cli/verify-proof/resolver.mjs.map +1 -1
  99. package/dist/cli/verify-proof/sandbox/apply.d.mts +64 -0
  100. package/dist/cli/verify-proof/sandbox/apply.d.mts.map +1 -0
  101. package/dist/cli/verify-proof/sandbox/apply.mjs +379 -0
  102. package/dist/cli/verify-proof/sandbox/apply.mjs.map +1 -0
  103. package/dist/cli/verify-proof/sandbox/gate-predicate.d.mts +63 -0
  104. package/dist/cli/verify-proof/sandbox/gate-predicate.d.mts.map +1 -0
  105. package/dist/cli/verify-proof/sandbox/gate-predicate.mjs +115 -0
  106. package/dist/cli/verify-proof/sandbox/gate-predicate.mjs.map +1 -0
  107. package/dist/cli/verify-proof/sandbox/pattern-matcher.d.mts +57 -0
  108. package/dist/cli/verify-proof/sandbox/pattern-matcher.d.mts.map +1 -0
  109. package/dist/cli/verify-proof/sandbox/pattern-matcher.mjs +124 -0
  110. package/dist/cli/verify-proof/sandbox/pattern-matcher.mjs.map +1 -0
  111. package/dist/cli/verify-proof/sandbox/recipe-diff.d.mts +28 -0
  112. package/dist/cli/verify-proof/sandbox/recipe-diff.d.mts.map +1 -0
  113. package/dist/cli/verify-proof/sandbox/recipe-diff.mjs +190 -0
  114. package/dist/cli/verify-proof/sandbox/recipe-diff.mjs.map +1 -0
  115. package/dist/cli/verify-proof/sandbox/sandbox-config.d.mts +25 -0
  116. package/dist/cli/verify-proof/sandbox/sandbox-config.d.mts.map +1 -0
  117. package/dist/cli/verify-proof/sandbox/sandbox-config.mjs +85 -0
  118. package/dist/cli/verify-proof/sandbox/sandbox-config.mjs.map +1 -0
  119. package/dist/cli/verify-proof/sandbox/self-test.d.mts +39 -0
  120. package/dist/cli/verify-proof/sandbox/self-test.d.mts.map +1 -0
  121. package/dist/cli/verify-proof/sandbox/self-test.mjs +248 -0
  122. package/dist/cli/verify-proof/sandbox/self-test.mjs.map +1 -0
  123. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  124. package/dist/plugin/agents/apt-planner.md +15 -0
  125. package/dist/plugin/agents/apt-verifier.md +15 -10
  126. package/dist/plugin/skills/apt/SKILL.md +29 -0
  127. package/dist/plugin/skills/apt-catch-up/SKILL.md +79 -0
  128. package/dist/plugin/skills/apt-plan/SKILL.md +23 -0
  129. package/dist/plugin/skills/apt-pr-review/SKILL.md +5 -4
  130. package/dist/plugin/skills/apt-run/SKILL.md +1 -1
  131. package/dist/plugin/skills/apt-setup/SKILL.md +132 -0
  132. package/dist/plugin/skills/apt-verify/SKILL.md +20 -15
  133. package/dist/plugin/skills/apt-verify-proof/SKILL.md +146 -11
  134. package/dist/proof-report.d.ts.map +1 -1
  135. package/dist/proof-report.js +4 -1
  136. package/dist/proof-report.js.map +1 -1
  137. package/dist/types/config.d.ts +42 -4
  138. package/dist/types/config.d.ts.map +1 -1
  139. package/dist/types/qa-scoring.d.ts +45 -9
  140. package/dist/types/qa-scoring.d.ts.map +1 -1
  141. package/dist/types/qa-scoring.js +34 -13
  142. package/dist/types/qa-scoring.js.map +1 -1
  143. package/dist/types/state.d.ts +7 -2
  144. package/dist/types/state.d.ts.map +1 -1
  145. package/dist/types/task-record.d.ts +10 -2
  146. package/dist/types/task-record.d.ts.map +1 -1
  147. package/drivers/mobile/README.md +40 -0
  148. package/drivers/mobile/driver.mjs +106 -0
  149. package/drivers/mobile/manifest.json +49 -0
  150. package/package.json +1 -1
  151. package/prompts/inbox-clarification.md +11 -0
  152. package/prompts/inbox-triage.md +20 -0
  153. package/prompts/qa_orchestrator_agentic.md +18 -17
  154. package/prompts/qa_reviewer.md +17 -15
  155. package/skills/apt/SKILL.md +29 -0
  156. package/skills/apt-catch-up/SKILL.md +79 -0
  157. package/skills/apt-plan/SKILL.md +23 -0
  158. package/skills/apt-pr-review/SKILL.md +5 -4
  159. package/skills/apt-run/SKILL.md +1 -1
  160. package/skills/apt-setup/SKILL.md +132 -0
  161. package/skills/apt-verifier.md +7 -4
  162. package/skills/apt-verify/SKILL.md +20 -15
  163. package/skills/apt-verify-proof/SKILL.md +146 -11
  164. package/src/cli/commands/apply-recipe.mjs +105 -0
  165. package/src/cli/commands/catch-up.mjs +67 -0
  166. package/src/cli/commands/mobile-prepare.mjs +151 -0
  167. package/src/cli/commands/pr-review.mjs +32 -11
  168. package/src/cli/commands/recipe-diff.mjs +81 -0
  169. package/src/cli/commands/roadmap.mjs +54 -0
  170. package/src/cli/commands/route.mjs +92 -1
  171. package/src/cli/commands/verify-sandbox.mjs +231 -0
  172. package/src/cli/config/share-policy.mjs +1 -0
  173. package/src/cli/config/upgrade-gitignore.mjs +1 -0
  174. package/src/cli/consistency/parse-qa.mjs +20 -4
  175. package/src/cli/consistency/rules/r5-verdict-consistency.mjs +11 -13
  176. package/src/cli/coordination/catch-up.mjs +231 -0
  177. package/src/cli/coordination/last-seen.mjs +131 -0
  178. package/src/cli/coordination/store.mjs +18 -0
  179. package/src/cli/coverage-check/user-outcomes.mjs +52 -17
  180. package/src/cli/dispatch.mjs +29 -0
  181. package/src/cli/gate/gates/verify-approved.mjs +22 -81
  182. package/src/cli/git/default-branch.mjs +250 -0
  183. package/src/cli/git/identity.mjs +9 -3
  184. package/src/cli/install/legacy-paths.mjs +2 -0
  185. package/src/cli/install/mcp-server-specs.mjs +24 -4
  186. package/src/cli/roadmap/backend-adapter.mjs +231 -0
  187. package/src/cli/util/runtime-capabilities.mjs +67 -0
  188. package/src/cli/verify-proof/manifest-validator.mjs +15 -0
  189. package/src/cli/verify-proof/resolver.mjs +27 -7
  190. package/src/cli/verify-proof/sandbox/apply.mjs +401 -0
  191. package/src/cli/verify-proof/sandbox/gate-predicate.mjs +126 -0
  192. package/src/cli/verify-proof/sandbox/pattern-matcher.mjs +127 -0
  193. package/src/cli/verify-proof/sandbox/recipe-diff.mjs +208 -0
  194. package/src/cli/verify-proof/sandbox/sandbox-config.mjs +82 -0
  195. package/src/cli/verify-proof/sandbox/sandbox-schema.json +33 -0
  196. package/src/cli/verify-proof/sandbox/self-test.mjs +265 -0
  197. package/templates/config.json +21 -1
  198. package/templates/proof-verification.md +27 -8
  199. package/workflows/verify-proof.md +376 -302
@@ -0,0 +1,105 @@
1
+ /**
2
+ * commands/apply-recipe.mjs — `apt-tools apply-recipe <projectDir> [--confirm] [--accept-all] [--force]`.
3
+ *
4
+ * Wraps applyRecipe() at the CLI surface.
5
+ *
6
+ * --confirm switches from dry-run (the default) to live writes.
7
+ * --accept-all bypasses per-change confirmation (pipeline/scripted use).
8
+ * --force overrides the TOFU user_modified guard — overwrites files
9
+ * whose SHA diverges from the recipe's expected before value.
10
+ * Files containing the recipe's marker are still skipped.
11
+ *
12
+ * The two flags compose: `--confirm` alone enters live mode but defers
13
+ * per-change accept/skip to the caller — without `--accept-all` and
14
+ * without a stdin-driven prompt, every change is SKIPPED (fail-safe per
15
+ * ID-02). Adopters using the interactive apt-setup SKILL.md mini-flow
16
+ * walk each change one at a time before invoking the CLI with
17
+ * `--accept-all` once they've reviewed the diff. Adopters in CI /
18
+ * fan-out / scripted contexts pass both flags up front.
19
+ *
20
+ * Exit codes:
21
+ * 0 — applyRecipe completed (dry-run or confirmed)
22
+ * 1 — usage error / no-recipe-match / applyRecipe threw
23
+ */
24
+
25
+ import { parseFlags } from '../util/args.mjs'
26
+ import { err, exitWith, ok } from '../util/result.mjs'
27
+ import { loadRuntimeCapabilities } from '../util/runtime-capabilities.mjs'
28
+ import { applyRecipe } from '../verify-proof/sandbox/apply.mjs'
29
+ import { matchPatternForProject } from '../verify-proof/sandbox/pattern-matcher.mjs'
30
+ import { discoverBundledRecipes } from '../verify-proof/sandbox/sandbox-config.mjs'
31
+
32
+ /**
33
+ * @param {string} projectDir
34
+ * @param {string[]} [extra]
35
+ */
36
+ export async function cmdApplyRecipe(projectDir, extra = []) {
37
+ if (!projectDir) {
38
+ return err('Usage: apt-tools apply-recipe <project-dir> [--confirm] [--accept-all]')
39
+ }
40
+
41
+ const flags = parseFlags(extra)
42
+ const confirm = flags.has('confirm')
43
+ const acceptAll = flags.has('accept-all')
44
+ const force = flags.has('force')
45
+
46
+ const recipes = discoverBundledRecipes()
47
+ const runtime = loadRuntimeCapabilities(projectDir)
48
+
49
+ const explicitRecipeId = flags.get('recipe') ?? null
50
+ let recipeId
51
+ let matchReason
52
+ if (explicitRecipeId) {
53
+ recipeId = explicitRecipeId
54
+ matchReason = 'explicit'
55
+ } else {
56
+ const match = matchPatternForProject(projectDir, runtime, recipes)
57
+ recipeId = match.recipeId
58
+ matchReason = match.reason
59
+ }
60
+
61
+ if (!recipeId) {
62
+ return exitWith(
63
+ {
64
+ status: 'error',
65
+ command: 'apply-recipe',
66
+ code: 'E_NO_RECIPE',
67
+ reason: matchReason,
68
+ runtime,
69
+ docs: 'docs/verify-proof-sandbox-patterns.md',
70
+ },
71
+ 1,
72
+ )
73
+ }
74
+
75
+ try {
76
+ const result = await applyRecipe({
77
+ projectDir,
78
+ recipeId,
79
+ recipes,
80
+ confirm,
81
+ // AC8 + ID-02 — only auto-accept when the caller passed
82
+ // --accept-all explicitly. Without the flag the underlying
83
+ // applyRecipe defaults to skip (every change recorded as
84
+ // `user_declined`); callers driving per-change UX wrap the
85
+ // applier directly with their own confirmer.
86
+ perChangeConfirm: acceptAll ? async () => 'accept' : undefined,
87
+ force,
88
+ })
89
+ return ok({
90
+ status: 'ok',
91
+ command: 'apply-recipe',
92
+ project_dir: projectDir,
93
+ recipe_id: recipeId,
94
+ recipe_version: result.recipe_version,
95
+ confirm,
96
+ accept_all: acceptAll,
97
+ aborted: result.aborted,
98
+ writes: result.writes,
99
+ skips: result.skips,
100
+ audit_path: result.audit_path,
101
+ })
102
+ } catch (e) {
103
+ return err(`apply-recipe: ${e?.message ?? String(e)}`)
104
+ }
105
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * commands/catch-up.mjs — read-only re-view of the combined catch-up panel
3
+ * (LD-13). Backs the `/apt:catch-up` skill.
4
+ *
5
+ * Recompute-only contract: this command computes the SAME panel the
6
+ * interactive route call would show (Welcome-Back + gated Team-Change Digest)
7
+ * against the CURRENT Last-Seen Marker, but it NEVER advances the marker. A
8
+ * user who dismissed the auto-on-apt display can re-summon it without
9
+ * disturbing their baseline. Distinct from /apt:resume (full context restore).
10
+ */
11
+
12
+ import { resolve } from 'node:path'
13
+ import { loadProjectConfig } from '../config/load.mjs'
14
+ import { resolveSharePolicy } from '../config/share-policy.mjs'
15
+ import { computeCatchUpPanel } from '../coordination/catch-up.mjs'
16
+ import { createCoordinationStore } from '../coordination/store.mjs'
17
+ import { resolveLocalDefaultBranch } from '../git/default-branch.mjs'
18
+ import { err, ok } from '../util/result.mjs'
19
+
20
+ /**
21
+ * @param {string} projectDir
22
+ * @returns {{ envelope: object, exitCode: number }}
23
+ */
24
+ export function cmdCatchUp(projectDir) {
25
+ if (!projectDir) return err('Usage: apt-tools catch-up <project-dir>')
26
+ const targetDir = resolve(projectDir)
27
+
28
+ // Setup mirrors computeCatchUpForRoute in commands/route.mjs (advances marker).
29
+ const config = loadProjectConfig(targetDir)
30
+ if (!config) {
31
+ return ok({
32
+ status: 'ok',
33
+ command: 'catch-up',
34
+ catch_up: null,
35
+ reason: 'No .aperant/config.json — nothing to catch up on',
36
+ })
37
+ }
38
+
39
+ const store = createCoordinationStore(targetDir)
40
+ const marker = store.readLastSeen()
41
+ const td = config?.collaboration?.team_digest
42
+ const vis = resolveSharePolicy(config).visibility
43
+ const needsDigest = td?.enabled !== false && (vis === 'team' || vis === 'oss')
44
+ const defaultBranch = needsDigest ? resolveLocalDefaultBranch(targetDir) : null
45
+
46
+ let catch_up = null
47
+ try {
48
+ catch_up = computeCatchUpPanel(targetDir, {
49
+ config,
50
+ now: Date.now(),
51
+ marker,
52
+ defaultBranch,
53
+ })
54
+ } catch (e) {
55
+ console.warn('[apt] catch-up:', e?.message ?? e)
56
+ catch_up = null
57
+ }
58
+
59
+ // NOTE: recompute-only — we deliberately do NOT call store.advanceLastSeen.
60
+ // The marker baseline is owned by the interactive route path (route.mjs).
61
+ return ok({
62
+ status: 'ok',
63
+ command: 'catch-up',
64
+ catch_up,
65
+ reason: catch_up ? 'Catch-up panel recomputed' : 'Nothing new since your last visit',
66
+ })
67
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * commands/mobile-prepare.mjs — `apt-tools mobile prepare`.
3
+ *
4
+ * Owns the mobile app lifecycle for the verify-proof mobile surface
5
+ * (decisions D-03/D-04): build → install → launch → Metro → launch-readiness
6
+ * gate. This is the home for multi-minute orchestration that CANNOT live in a
7
+ * driver PreflightCheck (those are ≤5s, shell-free, single LocalCommand).
8
+ *
9
+ * The command is PLAN-producing: it resolves the ordered launch sequence and
10
+ * the readiness-gate contract and returns them in the envelope. The
11
+ * verify-proof workflow runner (which holds the host tool surface) executes
12
+ * the sequence, gates on the readiness signals, THEN hands UI verbs to the
13
+ * mobile driver. This mirrors the driver pattern (driver.mjs are adapters;
14
+ * the runner dispatches).
15
+ *
16
+ * Pure, unit-testable seams: `planLaunchSequence` + `isAppReady`.
17
+ */
18
+
19
+ import { loadRuntimeCapabilities } from '../util/runtime-capabilities.mjs'
20
+
21
+ /**
22
+ * @typedef {{ binary: string, args: string[] }} LocalCommand
23
+ */
24
+
25
+ /**
26
+ * Resolve the ordered launch sequence for a (platform, mode, device).
27
+ *
28
+ * - mode 'expo' — `npx expo run:ios|run:android` builds, installs, launches,
29
+ * and starts Metro in one step (the common Expo/RN path).
30
+ * - mode 'native' — device-prep only (`xcrun simctl boot` / `adb
31
+ * wait-for-device`); the native build+install+launch is delegated (gradle /
32
+ * the XcodeBuildMCP escalation per D-02), out of scope for v1's planner.
33
+ *
34
+ * @param {{ platform?: string, mode?: string, device?: string }} [opts]
35
+ * @returns {LocalCommand[]}
36
+ */
37
+ export function planLaunchSequence(opts = {}) {
38
+ const { platform, mode = 'expo', device } = opts
39
+ if (platform !== 'ios' && platform !== 'android') {
40
+ throw new Error(`planLaunchSequence: platform must be 'ios' or 'android', got "${platform}"`)
41
+ }
42
+ if (mode !== 'expo' && mode !== 'native') {
43
+ throw new Error(`planLaunchSequence: mode must be 'expo' or 'native', got "${mode}"`)
44
+ }
45
+
46
+ if (mode === 'expo') {
47
+ const expoArgs = ['expo', platform === 'ios' ? 'run:ios' : 'run:android']
48
+ if (device) expoArgs.push('--device', device)
49
+ return [{ binary: 'npx', args: expoArgs }]
50
+ }
51
+
52
+ // native — device prep only.
53
+ if (platform === 'ios') {
54
+ return [{ binary: 'xcrun', args: ['simctl', 'boot', device ?? 'booted'] }]
55
+ }
56
+ return [{ binary: 'adb', args: device ? ['-s', device, 'wait-for-device'] : ['wait-for-device'] }]
57
+ }
58
+
59
+ /**
60
+ * Launch-readiness predicate (D-04). A successful screenshot alone is NOT
61
+ * enough — for RN/Expo it can capture a native splash, an Expo loading
62
+ * overlay, a redbox, or the dev-client launcher. ALL signals must hold before
63
+ * UI driving starts. For a pure-native app (no JS bundle) the caller passes
64
+ * `bundleConnected: true` and `launcherState: false`.
65
+ *
66
+ * @param {{ foregrounded?: boolean, bundleConnected?: boolean, redbox?: boolean, launcherState?: boolean }} [signals]
67
+ * @returns {boolean}
68
+ */
69
+ export function isAppReady(signals) {
70
+ const s = signals && typeof signals === 'object' ? signals : {}
71
+ return (
72
+ s.foregrounded === true &&
73
+ s.bundleConnected === true &&
74
+ s.redbox !== true &&
75
+ s.launcherState !== true
76
+ )
77
+ }
78
+
79
+ /** The readiness-gate contract surfaced to the workflow runner. */
80
+ const READINESS_GATE = {
81
+ description:
82
+ 'App-ready gate (D-04). All signals must hold before UI verbs are dispatched to the mobile driver.',
83
+ signals: ['foregrounded', 'bundleConnected', 'no-redbox', 'no-launcher-state'],
84
+ }
85
+
86
+ /** @param {string} message */
87
+ function errorEnvelope(message) {
88
+ return { envelope: { status: 'error', command: 'mobile-prepare', error: message }, exitCode: 1 }
89
+ }
90
+
91
+ /**
92
+ * Parse `--platform <ios|android> [--mode <expo|native>] [--device <id>]`.
93
+ *
94
+ * @param {string[]} args
95
+ * @returns {{ platform?: string, mode?: string, device?: string }}
96
+ */
97
+ function parseFlags(args) {
98
+ /** @type {{ platform?: string, mode?: string, device?: string }} */
99
+ const opts = {}
100
+ for (let i = 0; i < args.length; i++) {
101
+ if (args[i] === '--platform' && args[i + 1]) opts.platform = args[++i]
102
+ else if (args[i] === '--mode' && args[i + 1]) opts.mode = args[++i]
103
+ else if (args[i] === '--device' && args[i + 1]) opts.device = args[++i]
104
+ }
105
+ return opts
106
+ }
107
+
108
+ /**
109
+ * `apt-tools mobile prepare <project-dir> --platform <ios|android>
110
+ * [--mode <expo|native>] [--device <id>]`
111
+ *
112
+ * @param {string} projectDir
113
+ * @param {string[]} args
114
+ * @returns {{ envelope: object, exitCode: number }}
115
+ */
116
+ export function cmdMobilePrepare(projectDir, args) {
117
+ const opts = parseFlags(args ?? [])
118
+ if (opts.platform !== 'ios' && opts.platform !== 'android') {
119
+ return errorEnvelope('--platform <ios|android> is required')
120
+ }
121
+ if (opts.mode && opts.mode !== 'expo' && opts.mode !== 'native') {
122
+ return errorEnvelope(`--mode must be 'expo' or 'native', got "${opts.mode}"`)
123
+ }
124
+
125
+ // Infer mode from the project shape when not explicitly given (ties the
126
+ // detector to the launch story — an Expo/RN project launches via `expo run`).
127
+ let mode = opts.mode
128
+ if (!mode) {
129
+ const caps = loadRuntimeCapabilities(projectDir ?? '.')
130
+ mode = caps.is_expo || caps.is_react_native ? 'expo' : 'native'
131
+ }
132
+
133
+ const launch_sequence = planLaunchSequence({ platform: opts.platform, mode, device: opts.device })
134
+
135
+ return {
136
+ envelope: {
137
+ status: 'ok',
138
+ command: 'mobile-prepare',
139
+ platform: opts.platform,
140
+ mode,
141
+ device: opts.device ?? null,
142
+ launch_sequence,
143
+ readiness_gate: READINESS_GATE,
144
+ note:
145
+ 'Plan only — the verify-proof workflow runner executes the launch sequence, gates on ' +
146
+ 'the readiness signals, then hands UI verbs to the mobile driver. System prereqs ' +
147
+ '(idb/adb/Xcode/SDK) are validated by `apt-tools driver doctor` (D-09).',
148
+ },
149
+ exitCode: 0,
150
+ }
151
+ }
@@ -727,17 +727,30 @@ function parseGhPrView(raw) {
727
727
  * the viewer's personal handle (e.g. `AndyMik90`). They never match, which
728
728
  * previously caused every same-repo, owned PR to be mis-classified as
729
729
  * `external` and silently disabled auto-fix. The third arg `baseRepoOwner`
730
- * is the source of truth for "is this a fork PR?". `viewerLogin` is kept
731
- * only for backward-compatible signal reporting.
730
+ * is the source of truth for "is this a fork PR?".
731
+ *
732
+ * IMPORTANT (G44): "owned" means "a PR I authored," NOT "a branch in a repo I
733
+ * can push to." The owned test is strictly `gh.author.login === viewerLogin`
734
+ * — author IDENTITY, not `authorAssociation`. Previously a same-repo PR with
735
+ * `authorAssociation ∈ {OWNER, MEMBER, COLLABORATOR}` was classified `owned`,
736
+ * which meant every teammate's same-repo PR triggered the auto-fix-and-push
737
+ * pipeline on their branch. Now `authorAssociation` is retained only in the
738
+ * signal for human-readable tracing; it no longer gates the decision. The
739
+ * "is this mine" comparand is `gh.author?.login`, never `headRepoOwner`
740
+ * (comparing headRepoOwner to viewerLogin was the H1 bug — it never matches
741
+ * on an org-owned repo). Fork detection and author identity are independent:
742
+ * fork detection answers "did this come from a fork?"; author identity
743
+ * answers "did I write it?".
732
744
  *
733
745
  * @param {Record<string, any> | null | undefined} gh parsed `gh pr view` JSON (may have `authorAssociation` grafted in by the REST-fetch path)
734
- * @param {string | null | undefined} viewerLogin personal login from `gh api user` (fallback comparand for fork detection)
735
- * @param {string | null | undefined} [baseRepoOwner] the org/user that owns the repo the PR targets (preferred comparand)
746
+ * @param {string | null | undefined} viewerLogin personal login from `gh api user` (compared to `gh.author.login` for the owned test)
747
+ * @param {string | null | undefined} [baseRepoOwner] the org/user that owns the repo the PR targets (fork-detection comparand)
736
748
  * @returns {{ authorship_mode: 'owned' | 'external' | 'unknown', authorship_signal: string }}
737
749
  */
738
750
  export function decideAuthorshipFromGh(gh, viewerLogin, baseRepoOwner = null) {
739
751
  if (!gh) return { authorship_mode: 'unknown', authorship_signal: 'gh_payload_missing' }
740
752
  const association = gh.authorAssociation ?? gh.author?.association ?? null
753
+ const author = gh.author?.login ?? null
741
754
  const headRepoOwner =
742
755
  gh.headRepository?.owner?.login ??
743
756
  gh.headRepositoryOwner?.login ??
@@ -745,28 +758,36 @@ export function decideAuthorshipFromGh(gh, viewerLogin, baseRepoOwner = null) {
745
758
  null
746
759
  // Prefer baseRepoOwner when supplied; fall back to viewerLogin only for
747
760
  // older callers that haven't been updated yet (e.g. legacy tests). The
748
- // fallback preserves prior behavior for the personal-repo case.
761
+ // fallback preserves prior fork-detection behavior for the personal-repo
762
+ // case.
749
763
  const compareOwner = baseRepoOwner ?? viewerLogin
750
764
  const isFork = headRepoOwner && compareOwner && headRepoOwner !== compareOwner
751
765
 
752
- const ownerLike = ['OWNER', 'MEMBER', 'COLLABORATOR']
753
766
  const externalLike = ['NONE', 'CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER']
754
767
 
755
- if (!isFork && association && ownerLike.includes(association)) {
768
+ // G44: owned ⟺ (not a fork) AND the PR was authored by the running viewer.
769
+ if (!isFork && author && viewerLogin && author === viewerLogin) {
756
770
  return {
757
771
  authorship_mode: 'owned',
758
- authorship_signal: `authorAssociation=${association}, same-repo branch (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
772
+ authorship_signal: `authored-by-viewer (author=${author}, viewer=${viewerLogin}), same-repo branch (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'}), authorAssociation=${association ?? 'null'}`,
759
773
  }
760
774
  }
761
- if (isFork || (association && externalLike.includes(association))) {
775
+ // Forks, external-like associations, AND non-fork PRs authored by someone
776
+ // other than the viewer (a teammate's same-repo PR — the G44 case) all
777
+ // resolve to external (comment-only).
778
+ if (
779
+ isFork ||
780
+ (association && externalLike.includes(association)) ||
781
+ (!isFork && author && viewerLogin && author !== viewerLogin)
782
+ ) {
762
783
  return {
763
784
  authorship_mode: 'external',
764
- authorship_signal: `authorAssociation=${association}, fork=${Boolean(isFork)} (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
785
+ authorship_signal: `authorAssociation=${association ?? 'null'}, fork=${Boolean(isFork)}, author=${author ?? '?'}, viewer=${viewerLogin ?? '?'} (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
765
786
  }
766
787
  }
767
788
  return {
768
789
  authorship_mode: 'unknown',
769
- authorship_signal: `authorAssociation=${association ?? 'null'}, fork=${Boolean(isFork)}`,
790
+ authorship_signal: `authorAssociation=${association ?? 'null'}, fork=${Boolean(isFork)}, author=${author ?? 'null'}`,
770
791
  }
771
792
  }
772
793
 
@@ -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) {