@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
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Requires {task_dir}/qa_signoff.json to exist, parse, and carry
6
6
  * verdict === "approved" (BUG-013: normalized via parseQaSignoff — case
7
- * and underscore/hyphen variants collapse to canonical). BUG-012: also
8
- * accepts `approved-with-notes` when config.qa.approved_with_notes_threshold
9
- * is set AND every dimension score meets the threshold (best-effort when
10
- * dimensions are missing or in an unknown shape).
7
+ * and underscore/hyphen variants collapse to canonical).
8
+ *
9
+ * v0.9.0 (verify-proof-v2-outcome-walker_27-05-26) the legacy
10
+ * `approved-with-notes` acceptance branch is REMOVED. `verify-approved`
11
+ * only accepts a verdict of exactly `approved`. Tasks whose verify-proof
12
+ * run returned `needs_human` MUST re-run verify-proof with the missing
13
+ * transport installed; tasks whose run returned `rejected` MUST fix the
14
+ * code. There is no middle band.
11
15
  *
12
16
  * Accepts both `verdict` (newer) and `status` (older) field names — that
13
17
  * normalization is delegated to parseQaSignoff.
@@ -18,39 +22,9 @@ import { join } from 'node:path'
18
22
  import { parseQaSignoff } from '../../consistency/parse-qa.mjs'
19
23
  import { registerGate } from '../registry.mjs'
20
24
 
21
- /**
22
- * Extract dimension scores from the qa_signoff.json shape. We've seen two
23
- * shapes in the wild:
24
- * - Array: `dimensions: [{name: 'security', score: 0.9}, ...]`
25
- * - Object: `dimensions: {security: 0.9, performance: 0.85}` OR
26
- * `dimensions: {security: {score: 0.9}, ...}`
27
- * Returns an array of numeric scores, or null when no recognizable shape
28
- * is present (best-effort accept per brief).
29
- */
30
- function extractDimensionScores(doc) {
31
- const dims = doc.dimensions ?? doc.qa?.dimensions ?? null
32
- if (!dims) return null
33
- if (Array.isArray(dims)) {
34
- const scores = dims
35
- .map((d) => (typeof d?.score === 'number' ? d.score : null))
36
- .filter((s) => s !== null)
37
- return scores.length > 0 ? scores : null
38
- }
39
- if (typeof dims === 'object') {
40
- const scores = []
41
- for (const v of Object.values(dims)) {
42
- if (typeof v === 'number') scores.push(v)
43
- else if (typeof v?.score === 'number') scores.push(v.score)
44
- }
45
- return scores.length > 0 ? scores : null
46
- }
47
- return null
48
- }
49
-
50
25
  const gate = {
51
26
  id: 'verify-approved',
52
- description:
53
- 'qa_signoff.json exists with verdict === "approved" (or "approved-with-notes" when configured)',
27
+ description: 'qa_signoff.json exists with verdict === "approved" (v0.9.0+ two-gate verdict)',
54
28
  severity: 'blocking',
55
29
  skills: ['ship'],
56
30
  applies_to_profile: (ctx) => ctx.profile === 'full-pipeline',
@@ -77,65 +51,32 @@ const gate = {
77
51
 
78
52
  // BUG-013: route through parseQaSignoff so the verdict is
79
53
  // case-insensitive AND tolerates `_↔-` (so 'APPROVED', 'Approved',
80
- // ' approved ', 'APPROVED_WITH_NOTES' all behave canonically). The
81
- // pre-fix gate compared the raw string against `'approved'` which
82
- // false-rejected every legitimate variant.
83
- const { verdict } = parseQaSignoff(doc)
84
-
85
- if (verdict === 'approved') {
54
+ // ' approved ', 'APPROVED_WITH_NOTES' all behave canonically).
55
+ // v0.9.0: parseQaSignoff THROWS on legacy approved_with_notes
56
+ // surface that as a gate fail with the migration message verbatim.
57
+ let parsed
58
+ try {
59
+ parsed = parseQaSignoff(doc)
60
+ } catch (err) {
86
61
  return {
87
- status: 'pass',
88
- reason: 'qa_signoff.json verdict normalized to "approved"',
62
+ status: 'fail',
63
+ reason: `qa_signoff.json carries a legacy verdict that requires migration: ${err.message}`,
89
64
  inputs,
90
65
  }
91
66
  }
67
+ const { verdict } = parsed
92
68
 
93
- // BUG-012: accept `approved-with-notes` when the project has opted
94
- // in via config.qa.approved_with_notes_threshold. The threshold is
95
- // the minimum per-dimension score required. Best-effort: when
96
- // dimensions are missing / in an unknown shape we still accept and
97
- // log, rather than hard-failing, so projects that haven't started
98
- // scoring dimensions yet aren't blocked.
99
- if (verdict === 'approved-with-notes') {
100
- const threshold = ctx.config?.qa?.approved_with_notes_threshold
101
- if (typeof threshold !== 'number') {
102
- return {
103
- status: 'fail',
104
- reason:
105
- 'qa_signoff.json verdict is "approved-with-notes" but config.qa.approved_with_notes_threshold is not set',
106
- evidence: { verdict, threshold: threshold ?? null },
107
- inputs,
108
- }
109
- }
110
- const scores = extractDimensionScores(doc)
111
- if (scores === null) {
112
- return {
113
- status: 'pass',
114
- reason: `qa_signoff.json verdict is "approved-with-notes" and threshold ${threshold} is enabled; dimensions missing or unknown-shape — accepting best-effort`,
115
- evidence: { verdict, threshold, dimensions_present: false },
116
- inputs,
117
- }
118
- }
119
- const failing = scores.filter((s) => s < threshold)
120
- if (failing.length > 0) {
121
- return {
122
- status: 'fail',
123
- reason: `qa_signoff.json verdict is "approved-with-notes" but ${failing.length}/${scores.length} dimension score(s) below threshold ${threshold}`,
124
- evidence: { verdict, threshold, scores, failing },
125
- inputs,
126
- }
127
- }
69
+ if (verdict === 'approved') {
128
70
  return {
129
71
  status: 'pass',
130
- reason: `qa_signoff.json verdict is "approved-with-notes" and all ${scores.length} dimension score(s) meet threshold ${threshold}`,
131
- evidence: { verdict, threshold, scores },
72
+ reason: 'qa_signoff.json verdict normalized to "approved"',
132
73
  inputs,
133
74
  }
134
75
  }
135
76
 
136
77
  return {
137
78
  status: 'fail',
138
- reason: `qa_signoff.json verdict is "${verdict}" (expected "approved" or "approved-with-notes" with threshold)`,
79
+ reason: `qa_signoff.json verdict is "${verdict}" (expected "approved"; v0.9.0 enum is {approved, rejected, needs_human})`,
139
80
  evidence: { verdict },
140
81
  inputs,
141
82
  }
@@ -61,6 +61,17 @@ export const PROVISION_SPECS = {
61
61
  args: ['./scripts/tauri-mcp.sh'],
62
62
  requiresLauncherScript: true,
63
63
  },
64
+ 'mobile-mcp': {
65
+ kind: 'mcp-server',
66
+ // Unified iOS + Android UI-driving server (Apache-2.0). Pure npm —
67
+ // the iOS/Android SYSTEM prereqs (idb-companion / adb / Xcode /
68
+ // Android SDK) are validated by the mobile driver's preflight, NOT
69
+ // installed here (decision D-09). Matches the driver manifest's
70
+ // transport.mcp_server_id = "mobile-mcp".
71
+ command: 'npx',
72
+ args: ['-y', '@mobilenext/mobile-mcp@latest'],
73
+ env: {},
74
+ },
64
75
  // kind 'skill'
65
76
  'agent-browser': {
66
77
  kind: 'skill',
@@ -79,14 +90,15 @@ export const PROVISION_SPECS = {
79
90
  * triggers. A multi-surface monorepo (e.g. electron + nextjs) returns
80
91
  * multiple ids.
81
92
  *
82
- * is_electron → ['electron']
83
- * is_tauri → ['tauri']
84
- * is_web_frontend|is_nextjs|is_nuxt → ['agent-browser']
93
+ * is_electron → ['electron']
94
+ * is_tauri → ['tauri']
95
+ * is_web_frontend|is_nextjs|is_nuxt → ['agent-browser']
96
+ * is_ios_native|is_android_native|is_expo|is_react_native → ['mobile-mcp']
85
97
  *
86
98
  * Accepts a partial capability record — only the flags it reads need be
87
99
  * present (the rest default to absent/false).
88
100
  *
89
- * @param {Partial<import('../util/runtime-capabilities.mjs').RuntimeCapabilities>} runtimeCaps
101
+ * @param {Partial<import('../util/runtime-capabilities.mjs').RuntimeCapabilities> | null | undefined} runtimeCaps
90
102
  * @returns {string[]}
91
103
  */
92
104
  export function capabilityToProvisionIds(runtimeCaps) {
@@ -97,5 +109,13 @@ export function capabilityToProvisionIds(runtimeCaps) {
97
109
  if (runtimeCaps.is_web_frontend || runtimeCaps.is_nextjs || runtimeCaps.is_nuxt) {
98
110
  ids.push('agent-browser')
99
111
  }
112
+ if (
113
+ runtimeCaps.is_ios_native ||
114
+ runtimeCaps.is_android_native ||
115
+ runtimeCaps.is_expo ||
116
+ runtimeCaps.is_react_native
117
+ ) {
118
+ ids.push('mobile-mcp')
119
+ }
100
120
  return ids
101
121
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Task 26-05-26 subtask 2.4 — roadmap.backend dispatch adapter.
3
+ *
4
+ * Reads `.aperant/config.json :: roadmap.backend` and either delegates
5
+ * the subcommand to the Aperant Cloud MCP server (`'cloud-mcp'`) or
6
+ * returns a `{ delegated: false }` envelope so the calling subcommand
7
+ * proceeds with the existing markdown-canonical file-write path.
8
+ *
9
+ * Fast Path Guarantee: a missing `roadmap` block, a missing
10
+ * `roadmap.backend` key, or `backend: 'markdown'` ALL return
11
+ * `{ backend: 'markdown', delegated: false }` byte-identically — OSS
12
+ * framework users with zero cloud config see no behaviour change.
13
+ *
14
+ * Token resolution mirrors convex-mirror.mjs (token-source order ID-07):
15
+ * 1. process.env.MCP_ROADMAP_TOKEN
16
+ * 2. .aperant/team.json :: roadmapMcpToken
17
+ *
18
+ * Tool registration on the server side lands in subtask 2.3
19
+ * (apps/web/src/app/api/mcp/_lib/tools.ts). Until 2.3 ships, the
20
+ * adapter will issue the MCP call against whatever endpoint the user
21
+ * configured; a 404 / "unknown tool" response surfaces as a delegated
22
+ * error envelope rather than silently falling back to markdown.
23
+ */
24
+
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import { join } from 'node:path'
27
+
28
+ /**
29
+ * Load `.aperant/config.json` defensively. Returns `{}` for any read
30
+ * or parse failure so callers never throw — the Fast Path branch must
31
+ * hit even when the config file is missing or malformed.
32
+ *
33
+ * @param {string} projectDir
34
+ * @returns {Record<string, unknown>}
35
+ */
36
+ function loadConfig(projectDir) {
37
+ const p = join(projectDir, '.aperant', 'config.json')
38
+ if (!existsSync(p)) return {}
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'))
41
+ return parsed && typeof parsed === 'object' ? parsed : {}
42
+ } catch {
43
+ return {}
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve the cloud-mcp bearer token via the documented order. Returns
49
+ * `null` when neither source has a non-empty string.
50
+ *
51
+ * @param {string} projectDir
52
+ * @returns {string | null}
53
+ */
54
+ function resolveToken(projectDir) {
55
+ const env = process.env.MCP_ROADMAP_TOKEN
56
+ if (typeof env === 'string' && env.length > 0) return env
57
+ const teamPath = join(projectDir, '.aperant', 'team.json')
58
+ if (!existsSync(teamPath)) return null
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(teamPath, 'utf-8'))
61
+ if (typeof parsed.roadmapMcpToken === 'string' && parsed.roadmapMcpToken.length > 0) {
62
+ return parsed.roadmapMcpToken
63
+ }
64
+ } catch {
65
+ // Bad JSON — fall through to null
66
+ }
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Map a CLI subcommand to the MCP tool name registered in subtask 2.3.
72
+ * Read-side subcommands are intentionally absent — Phase 2 keeps reads
73
+ * markdown-only (cloud-mcp render is a Phase 3 follow-up per the plan).
74
+ *
75
+ * @param {string} subcommand
76
+ * @returns {string | null}
77
+ */
78
+ function toolNameFor(subcommand) {
79
+ switch (subcommand) {
80
+ case 'add-milestone':
81
+ return 'roadmap.hierarchy.milestones.create'
82
+ case 'add-phase':
83
+ return 'roadmap.hierarchy.phases.create'
84
+ case 'set-phase-status':
85
+ return 'roadmap.hierarchy.phases.update'
86
+ case 'delete-phase':
87
+ return 'roadmap.hierarchy.phases.remove'
88
+ case 'delete-milestone':
89
+ return 'roadmap.hierarchy.milestones.remove'
90
+ default:
91
+ return null
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Dispatch a roadmap write subcommand through the configured backend.
97
+ *
98
+ * Return shapes:
99
+ * - markdown (default) → `{ backend: 'markdown', delegated: false }`
100
+ * The caller proceeds with the existing markdown file-write path.
101
+ * - cloud-mcp success → `{ backend: 'cloud-mcp', delegated: true,
102
+ * result: <MCP response JSON> }`
103
+ * - cloud-mcp error → `{ backend: 'cloud-mcp', delegated: true,
104
+ * error: '<msg>', code?: <http>, status: 'error' }`
105
+ *
106
+ * The `error` branch is still `delegated: true` because the caller MUST
107
+ * NOT silently fall through to the markdown path when the user has
108
+ * opted into cloud-mcp — that would lose writes the user expected to
109
+ * land in Convex.
110
+ *
111
+ * @param {string} projectDir
112
+ * @param {string} subcommand
113
+ * @param {Record<string, unknown>} args
114
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
115
+ * @returns {Promise<
116
+ * | { backend: 'markdown', delegated: false }
117
+ * | { backend: 'cloud-mcp', delegated: true, result: unknown }
118
+ * | { backend: 'cloud-mcp', delegated: true, status: 'error', error: string, code?: number }
119
+ * >}
120
+ */
121
+ export async function dispatchRoadmapWrite(projectDir, subcommand, args, opts = {}) {
122
+ const config = loadConfig(projectDir)
123
+ const roadmapBlock = config.roadmap && typeof config.roadmap === 'object' ? config.roadmap : null
124
+ const backend =
125
+ roadmapBlock && typeof roadmapBlock.backend === 'string' ? roadmapBlock.backend : 'markdown'
126
+
127
+ if (backend !== 'cloud-mcp') {
128
+ return { backend: 'markdown', delegated: false }
129
+ }
130
+
131
+ const cloudMcp =
132
+ roadmapBlock && typeof roadmapBlock.cloudMcp === 'object' && roadmapBlock.cloudMcp !== null
133
+ ? roadmapBlock.cloudMcp
134
+ : {}
135
+ const teamSlug = typeof cloudMcp.teamSlug === 'string' ? cloudMcp.teamSlug : null
136
+ if (!teamSlug || teamSlug.length === 0) {
137
+ return {
138
+ backend: 'cloud-mcp',
139
+ delegated: true,
140
+ status: 'error',
141
+ error: 'roadmap.cloudMcp.teamSlug required when roadmap.backend=cloud-mcp',
142
+ }
143
+ }
144
+
145
+ const tool = toolNameFor(subcommand)
146
+ if (!tool) {
147
+ return {
148
+ backend: 'cloud-mcp',
149
+ delegated: true,
150
+ status: 'error',
151
+ error: `cloud-mcp backend does not handle subcommand "${subcommand}" (read-side stays markdown in Phase 2)`,
152
+ }
153
+ }
154
+
155
+ const token = resolveToken(projectDir)
156
+ if (!token) {
157
+ return {
158
+ backend: 'cloud-mcp',
159
+ delegated: true,
160
+ status: 'error',
161
+ error: 'No roadmap MCP token (set MCP_ROADMAP_TOKEN or .aperant/team.json::roadmapMcpToken)',
162
+ }
163
+ }
164
+
165
+ const baseUrl = (
166
+ typeof cloudMcp.mcpServerUrl === 'string' && cloudMcp.mcpServerUrl.length > 0
167
+ ? cloudMcp.mcpServerUrl
168
+ : process.env.APERANT_BASE_URL || 'https://app.aperant.com'
169
+ ).replace(/\/$/, '')
170
+ const url = `${baseUrl}/api/mcp/${encodeURIComponent(teamSlug)}`
171
+
172
+ // Streamable HTTP MCP body — tools/call envelope per the existing
173
+ // server-factory contract. The wire format mirrors what the
174
+ // MCP SDK clients send; the Aperant route handler dispatches on
175
+ // `params.name` to the matching tool registered in subtask 2.3.
176
+ const body = {
177
+ jsonrpc: '2.0',
178
+ id: 1,
179
+ method: 'tools/call',
180
+ params: {
181
+ name: tool,
182
+ arguments: args,
183
+ },
184
+ }
185
+
186
+ const f = opts.fetchImpl ?? fetch
187
+ let res
188
+ try {
189
+ res = await f(url, {
190
+ method: 'POST',
191
+ headers: {
192
+ Authorization: `Bearer ${token}`,
193
+ 'Content-Type': 'application/json',
194
+ Accept: 'application/json, text/event-stream',
195
+ },
196
+ body: JSON.stringify(body),
197
+ })
198
+ } catch (e) {
199
+ const msg = e instanceof Error ? e.message : String(e)
200
+ return {
201
+ backend: 'cloud-mcp',
202
+ delegated: true,
203
+ status: 'error',
204
+ error: `network: ${msg}`,
205
+ }
206
+ }
207
+
208
+ if (res.status >= 400) {
209
+ let detail = ''
210
+ try {
211
+ detail = await res.text()
212
+ } catch {
213
+ // ignore body-read failures
214
+ }
215
+ return {
216
+ backend: 'cloud-mcp',
217
+ delegated: true,
218
+ status: 'error',
219
+ code: res.status,
220
+ error: detail || `http ${res.status}`,
221
+ }
222
+ }
223
+
224
+ let parsed
225
+ try {
226
+ parsed = await res.json()
227
+ } catch {
228
+ parsed = null
229
+ }
230
+ return { backend: 'cloud-mcp', delegated: true, result: parsed }
231
+ }
@@ -21,6 +21,14 @@
21
21
  * whose monorepo lives elsewhere (e.g. `packages/apps/*`) pass a
22
22
  * custom `monorepo_globs`. Setting `monorepo_globs: []` disables the
23
23
  * walk entirely (root-only escape hatch).
24
+ *
25
+ * Mobile verify-proof surface — `loadRuntimeCapabilities` ALSO detects
26
+ * pure-native iOS/Android projects (Swift `*.xcodeproj` / Kotlin
27
+ * `build.gradle`) that carry no JS deps, via a shallow fs check on the
28
+ * root + each monorepo dir (`detectNativeProject`). The pure
29
+ * `detectFromPackageJson` is intentionally NOT given fs access — native
30
+ * detection lives ONLY in the fs-walking `loadRuntimeCapabilities` so the
31
+ * pure-function contract holds (decision D-06).
24
32
  */
25
33
 
26
34
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
@@ -35,6 +43,10 @@ import { join, resolve } from 'node:path'
35
43
  * @property {boolean} is_web_frontend
36
44
  * @property {boolean} is_expo
37
45
  * @property {boolean} is_react_native
46
+ * @property {boolean} [is_ios_native] — optional: always populated by the
47
+ * detector, but optional in the type so partial capability records built by
48
+ * other consumers (and pre-existing test fixtures) stay valid.
49
+ * @property {boolean} [is_android_native]
38
50
  * @property {boolean} has_node
39
51
  */
40
52
 
@@ -48,6 +60,8 @@ function emptyCapabilities() {
48
60
  is_web_frontend: false,
49
61
  is_expo: false,
50
62
  is_react_native: false,
63
+ is_ios_native: false,
64
+ is_android_native: false,
51
65
  has_node: false,
52
66
  }
53
67
  }
@@ -134,6 +148,8 @@ export function loadRuntimeCapabilities(projectDir, opts) {
134
148
  /* fall through with empty caps */
135
149
  }
136
150
  }
151
+ // Fold in pure-native detection on the root (covers a JS-less iOS/Android app).
152
+ rootCaps = orMergeCapabilities(rootCaps, detectNativeProject(root))
137
153
 
138
154
  // Default to the canonical Aperant monorepo layout. Passing `[]`
139
155
  // disables the walk entirely (root-only escape hatch per spec ID-05).
@@ -144,6 +160,9 @@ export function loadRuntimeCapabilities(projectDir, opts) {
144
160
  for (const glob of globs) {
145
161
  const matches = expandSimpleGlob(root, glob)
146
162
  for (const dir of matches) {
163
+ // Native detection runs regardless of package.json — a pure-native
164
+ // iOS/Android app in apps/* may carry no package.json at all.
165
+ merged = orMergeCapabilities(merged, detectNativeProject(dir))
147
166
  const childPkgPath = join(dir, 'package.json')
148
167
  if (!existsSync(childPkgPath)) continue
149
168
  try {
@@ -211,6 +230,54 @@ function orMergeCapabilities(a, b) {
211
230
  is_web_frontend: a.is_web_frontend || b.is_web_frontend,
212
231
  is_expo: a.is_expo || b.is_expo,
213
232
  is_react_native: a.is_react_native || b.is_react_native,
233
+ is_ios_native: a.is_ios_native || b.is_ios_native,
234
+ is_android_native: a.is_android_native || b.is_android_native,
214
235
  has_node: a.has_node || b.has_node,
215
236
  }
216
237
  }
238
+
239
+ /**
240
+ * Shallow fs detection of a pure-native mobile project — a Swift/ObjC iOS
241
+ * app (`*.xcodeproj` / `*.xcworkspace`) or a Kotlin/Java Android app
242
+ * (`build.gradle[.kts]` / `settings.gradle[.kts]`). Checks `dir` itself and
243
+ * the conventional `ios/` + `android/` subdirs (the React Native / Expo
244
+ * prebuild layout). Returns a full capability record so it OR-merges with
245
+ * the package.json-derived caps.
246
+ *
247
+ * This is the ONLY fs-touching detection — `detectFromPackageJson` stays
248
+ * pure (D-06). Fails closed (treats unreadable dirs as absent).
249
+ *
250
+ * @param {string} dir
251
+ * @returns {RuntimeCapabilities}
252
+ */
253
+ function detectNativeProject(dir) {
254
+ const caps = emptyCapabilities()
255
+ if (dirHasXcodeProject(dir) || dirHasXcodeProject(join(dir, 'ios'))) {
256
+ caps.is_ios_native = true
257
+ }
258
+ if (dirHasGradle(dir) || dirHasGradle(join(dir, 'android'))) {
259
+ caps.is_android_native = true
260
+ }
261
+ return caps
262
+ }
263
+
264
+ /** True when `dir` directly contains a `*.xcodeproj` or `*.xcworkspace`. */
265
+ function dirHasXcodeProject(dir) {
266
+ if (!existsSync(dir)) return false
267
+ try {
268
+ return readdirSync(dir).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
269
+ } catch {
270
+ return false
271
+ }
272
+ }
273
+
274
+ /** True when `dir` directly contains a Gradle build/settings file. */
275
+ function dirHasGradle(dir) {
276
+ if (!existsSync(dir)) return false
277
+ return (
278
+ existsSync(join(dir, 'build.gradle')) ||
279
+ existsSync(join(dir, 'build.gradle.kts')) ||
280
+ existsSync(join(dir, 'settings.gradle')) ||
281
+ existsSync(join(dir, 'settings.gradle.kts'))
282
+ )
283
+ }
@@ -172,6 +172,21 @@ function validateNode(value, schema, path, errors) {
172
172
  }
173
173
  }
174
174
  }
175
+ // additionalProperties: false — reject any key not declared in `properties`.
176
+ if (
177
+ schema.additionalProperties === false &&
178
+ schema.properties &&
179
+ typeof schema.properties === 'object'
180
+ ) {
181
+ for (const key of Object.keys(obj)) {
182
+ if (!(key in schema.properties)) {
183
+ errors.push({
184
+ path: `${path}/${key}`,
185
+ message: `additional property "${key}" is not allowed`,
186
+ })
187
+ }
188
+ }
189
+ }
175
190
  }
176
191
 
177
192
  // array validation
@@ -124,6 +124,8 @@ function matchesRuntime(manifest, runtime) {
124
124
  if (stripped === 'nuxt' && runtime.is_nuxt) return true
125
125
  if (stripped === 'expo' && runtime.is_expo) return true
126
126
  if (stripped === 'react-native' && runtime.is_react_native) return true
127
+ if (stripped === 'ios' && runtime.is_ios_native) return true
128
+ if (stripped === 'android' && runtime.is_android_native) return true
127
129
  if (stripped === 'node' && runtime.has_node) return true
128
130
  if (stripped === 'cli' && runtime.has_node) return true
129
131
  if (stripped === 'api' && runtime.has_node) return true
@@ -246,12 +248,15 @@ export function resolveDriver(opts) {
246
248
  * Map a User Outcomes surface tag to the runtime-capability flag a driver
247
249
  * must carry (via `matchesRuntime`) to be eligible for that surface.
248
250
  *
249
- * Closed surface set: `electron | web | cli | api | electron+web`.
251
+ * Closed surface set: `electron | web | cli | api | mobile | electron+web`.
250
252
  * `electron+web` is NOT a single flag — it's a cross-surface tag the
251
- * workflow runner expands into two single-surface dispatches.
253
+ * workflow runner expands into two single-surface dispatches. `mobile`
254
+ * maps to the synthetic `is_mobile` marker, which `maskRuntimeForSurface`
255
+ * expands to the four real mobile flags (the unified mobile-mcp driver
256
+ * targets ios/android/expo/react-native — decision D-08).
252
257
  *
253
- * @param {('electron'|'web'|'cli'|'api')} surface
254
- * @returns {('is_electron'|'is_web_frontend'|'has_node')}
258
+ * @param {('electron'|'web'|'cli'|'api'|'mobile')} surface
259
+ * @returns {('is_electron'|'is_web_frontend'|'has_node'|'is_mobile')}
255
260
  */
256
261
  export function surfaceToRequiredFlag(surface) {
257
262
  switch (surface) {
@@ -262,6 +267,8 @@ export function surfaceToRequiredFlag(surface) {
262
267
  case 'cli':
263
268
  case 'api':
264
269
  return 'has_node'
270
+ case 'mobile':
271
+ return 'is_mobile'
265
272
  default:
266
273
  throw new Error(`surfaceToRequiredFlag: unknown surface "${surface}"`)
267
274
  }
@@ -292,7 +299,7 @@ export function surfaceToRequiredFlag(surface) {
292
299
  * @param {Object} opts
293
300
  * @param {DriverManifest[]} opts.drivers
294
301
  * @param {RuntimeCapabilities} opts.runtime
295
- * @param {Array<{id: string, surface: 'electron'|'web'|'cli'|'api'|'electron+web'}>} opts.outcomes
302
+ * @param {Array<{id: string, surface: 'electron'|'web'|'cli'|'api'|'mobile'|'electron+web'}>} opts.outcomes
296
303
  * @param {string[]} [opts.override]
297
304
  * @param {string[]} [opts.required_stability]
298
305
  * @param {string[]} [opts.required_capabilities] — defaults to ['screenshot']
@@ -314,7 +321,9 @@ export function resolveDriversForOutcomes(opts) {
314
321
  outcome.surface === 'electron+web' ? ['electron', 'web'] : [outcome.surface]
315
322
 
316
323
  for (const surface of surfacesToDispatch) {
317
- const flag = surfaceToRequiredFlag(/** @type {'electron'|'web'|'cli'|'api'} */ (surface))
324
+ const flag = surfaceToRequiredFlag(
325
+ /** @type {'electron'|'web'|'cli'|'api'|'mobile'} */ (surface),
326
+ )
318
327
  // Synthesize a per-surface runtime view: only the surface's
319
328
  // required flag is on; every other flag is masked off so the
320
329
  // scoring prefers per-surface drivers (otherwise, on a
@@ -347,7 +356,7 @@ export function resolveDriversForOutcomes(opts) {
347
356
  * single surface during per-outcome dispatch.
348
357
  *
349
358
  * @param {RuntimeCapabilities} runtime
350
- * @param {string} flag — one of: is_electron | is_web_frontend | has_node
359
+ * @param {string} flag — one of: is_electron | is_web_frontend | has_node | is_mobile
351
360
  * @returns {RuntimeCapabilities}
352
361
  */
353
362
  function maskRuntimeForSurface(runtime, flag) {
@@ -360,6 +369,8 @@ function maskRuntimeForSurface(runtime, flag) {
360
369
  is_web_frontend: false,
361
370
  is_expo: false,
362
371
  is_react_native: false,
372
+ is_ios_native: false,
373
+ is_android_native: false,
363
374
  has_node: false,
364
375
  }
365
376
  // Only flip the surface's flag on if the project actually carries it
@@ -373,5 +384,14 @@ function maskRuntimeForSurface(runtime, flag) {
373
384
  masked.is_nuxt = runtime.is_nuxt
374
385
  }
375
386
  if (flag === 'has_node') masked.has_node = runtime.has_node
387
+ // `is_mobile` is a synthetic surface marker — expand it to the four real
388
+ // mobile flags so the unified mobile-mcp driver (targets ios/android/
389
+ // expo/react-native) matches whichever the project actually carries.
390
+ if (flag === 'is_mobile') {
391
+ masked.is_ios_native = runtime.is_ios_native
392
+ masked.is_android_native = runtime.is_android_native
393
+ masked.is_expo = runtime.is_expo
394
+ masked.is_react_native = runtime.is_react_native
395
+ }
376
396
  return masked
377
397
  }