@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
@@ -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
  }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * git/default-branch.mjs — local default-branch resolution + merged-PR parsing
3
+ * for the Team-Change Digest (LD-03/04/05).
4
+ *
5
+ * Pure git helpers (no .aperant IO). Every git call uses the no-throw
6
+ * execFileSync pattern from git/remote.mjs (stdio ['ignore','pipe','ignore'])
7
+ * so a non-git dir / unresolvable ref returns null instead of throwing.
8
+ *
9
+ * The resolver reads the LOCAL default-branch ref only — it NEVER calls
10
+ * `git fetch`. The digest surfaces work the user has actually pulled, not
11
+ * unfetched server state (ADR Consequences ¶1).
12
+ *
13
+ * @internal
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process'
17
+
18
+ /**
19
+ * Run a git command, returning trimmed stdout or null on any error.
20
+ * @param {string} targetDir
21
+ * @param {string[]} args
22
+ * @returns {string|null}
23
+ */
24
+ function git(targetDir, args) {
25
+ try {
26
+ return execFileSync('git', args, {
27
+ cwd: targetDir,
28
+ encoding: 'utf-8',
29
+ stdio: ['ignore', 'pipe', 'ignore'],
30
+ }).trim()
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Resolve the local default-branch ref, with its current sha. Resolution order:
38
+ * 1. `git symbolic-ref refs/remotes/origin/HEAD` (strip the
39
+ * 'refs/remotes/origin/' prefix);
40
+ * 2. `git config init.defaultBranch`;
41
+ * 3. existence check of `main` then `master` via `git rev-parse --verify`.
42
+ *
43
+ * NEVER fetches. Returns null outside a git repo or when nothing resolves.
44
+ *
45
+ * @param {string} targetDir
46
+ * @returns {{ branch: string, sha: string }|null}
47
+ */
48
+ export function resolveLocalDefaultBranch(targetDir) {
49
+ // Fast bail when not a git repo.
50
+ if (git(targetDir, ['rev-parse', '--git-dir']) === null) return null
51
+
52
+ let branch = null
53
+
54
+ // (1) origin/HEAD symbolic-ref — the authoritative "what main points at".
55
+ const originHead = git(targetDir, ['symbolic-ref', 'refs/remotes/origin/HEAD'])
56
+ if (originHead?.startsWith('refs/remotes/origin/')) {
57
+ branch = originHead.slice('refs/remotes/origin/'.length)
58
+ }
59
+
60
+ // (2) init.defaultBranch config.
61
+ if (!branch) {
62
+ const configured = git(targetDir, ['config', 'init.defaultBranch'])
63
+ if (configured) branch = configured
64
+ }
65
+
66
+ // (3) existence check main → master.
67
+ if (!branch) {
68
+ for (const candidate of ['main', 'master']) {
69
+ if (git(targetDir, ['rev-parse', '--verify', '--quiet', candidate]) !== null) {
70
+ branch = candidate
71
+ break
72
+ }
73
+ }
74
+ }
75
+
76
+ if (!branch) return null
77
+
78
+ const sha = git(targetDir, ['rev-parse', branch])
79
+ if (!sha) return null
80
+
81
+ return { branch, sha }
82
+ }
83
+
84
+ /**
85
+ * Parse a PR number + title from a merge-commit subject/body.
86
+ *
87
+ * GitHub merge-commit format:
88
+ * subject: "Merge pull request #N from owner/branch"
89
+ * body: "<PR title>" (first non-empty body line)
90
+ * Squash-merge format:
91
+ * subject: "<PR title> (#N)"
92
+ *
93
+ * @param {string} subject
94
+ * @param {string} body
95
+ * @returns {{ number: number, title: string }|null}
96
+ */
97
+ function parsePullRequest(subject, body) {
98
+ const mergeMatch = subject.match(/^Merge pull request #(\d+) from /)
99
+ if (mergeMatch) {
100
+ const number = Number(mergeMatch[1])
101
+ const firstBodyLine = (body || '')
102
+ .split('\n')
103
+ .map((l) => l.trim())
104
+ .find((l) => l.length > 0)
105
+ return { number, title: firstBodyLine || subject }
106
+ }
107
+
108
+ // GitHub squash-merge convention: greedy `(.*)` captures the longest prefix
109
+ // so titles with parentheses (e.g. "feat(core): ...") are handled correctly.
110
+ const squashMatch = subject.match(/^(.*)\s\(#(\d+)\)\s*$/)
111
+ if (squashMatch) {
112
+ return { number: Number(squashMatch[2]), title: squashMatch[1].trim() }
113
+ }
114
+
115
+ return null
116
+ }
117
+
118
+ /**
119
+ * Resolve both the introduced-commit author emails (LD-05) and a display name
120
+ * from a single `git log` call per merge. Replaces the former separate
121
+ * `introducedAuthorEmails` + `pickAuthorName` pair (PRF-002).
122
+ *
123
+ * @param {string} targetDir
124
+ * @param {string[]} parents parent shas of the merge commit
125
+ * @param {string} authorEmail the merge commit's own author email
126
+ * @param {string} currentUserEmail the user's git email (for name picking)
127
+ * @param {string} fallbackName merge/commit author name when no better pick
128
+ * @returns {{ emails: string[], authorName: string }}
129
+ */
130
+ function resolveIntroducedAuthors(targetDir, parents, authorEmail, currentUserEmail, fallbackName) {
131
+ if (parents.length >= 2) {
132
+ const raw = git(targetDir, ['log', `${parents[0]}..${parents[1]}`, '--format=%ae%x1f%an'])
133
+ if (raw) {
134
+ const emails = new Set()
135
+ let pickedName = null
136
+ for (const line of raw.split('\n')) {
137
+ const [email, name] = line.split('\x1f')
138
+ if (!email) continue
139
+ const normEmail = email.trim().toLowerCase()
140
+ if (normEmail.length > 0) emails.add(normEmail)
141
+ if (!pickedName && normEmail !== currentUserEmail) {
142
+ pickedName = (name || fallbackName).trim()
143
+ }
144
+ }
145
+ if (emails.size > 0) {
146
+ return { emails: [...emails], authorName: pickedName || fallbackName.trim() }
147
+ }
148
+ }
149
+ }
150
+ const own = authorEmail.trim().toLowerCase()
151
+ return {
152
+ emails: own.length > 0 ? [own] : [],
153
+ authorName: fallbackName.trim(),
154
+ }
155
+ }
156
+
157
+ /**
158
+ * List merged PRs on the local default branch since `sinceSha`, attributed to
159
+ * whoever actually wrote the work (LD-04/05).
160
+ *
161
+ * Walks `git log --first-parent <sinceSha>..<defaultBranch>`. Each commit that
162
+ * parses as a PR (merge-commit or squash form) becomes one entry, attributed
163
+ * via its introduced-commit author emails. An entry is excluded only when ALL
164
+ * its introduced emails equal `currentUserEmail` (the user's own work) — so a
165
+ * PR the user merged but a teammate authored still appears.
166
+ *
167
+ * The scan is bounded (R3): at most `maxEntries * SCAN_MULTIPLIER` commits are
168
+ * inspected, so a stale marker can't enumerate an unbounded history.
169
+ *
170
+ * @param {string} targetDir
171
+ * @param {object} opts
172
+ * @param {string} opts.sinceSha baseline sha (exclusive lower bound)
173
+ * @param {string} [opts.defaultBranch] branch to walk to (default: resolved)
174
+ * @param {string} opts.currentUserEmail the user's git email (own-work filter)
175
+ * @param {number} [opts.maxEntries=7] cap on returned entries
176
+ * @returns {{ entries: Array<{ number: number, title: string, author: string }>, more_count: number }}
177
+ */
178
+ export function listMergedPullRequests(targetDir, opts = {}) {
179
+ const empty = { entries: [], more_count: 0, more_capped: false }
180
+ const sinceSha = opts.sinceSha
181
+ const maxEntries = Number.isInteger(opts.maxEntries) && opts.maxEntries > 0 ? opts.maxEntries : 7
182
+ const currentUserEmail = (opts.currentUserEmail || '').trim().toLowerCase()
183
+
184
+ let defaultBranch = opts.defaultBranch
185
+ if (!defaultBranch) {
186
+ const resolved = resolveLocalDefaultBranch(targetDir)
187
+ if (!resolved) return empty
188
+ defaultBranch = resolved.branch
189
+ }
190
+ if (!sinceSha) return empty
191
+ if (!/^[0-9a-f]{7,40}$/i.test(sinceSha)) return empty
192
+
193
+ const SCAN_MULTIPLIER = 4
194
+ const scanCap = maxEntries * SCAN_MULTIPLIER
195
+
196
+ // One commit per record, fields separated by \x1f, records by \x1e.
197
+ // %H sha · %P parents · %an author name · %ae author email · %s subject · %b body
198
+ const FIELD = '\x1f'
199
+ const RECORD = '\x1e'
200
+ const format = `%H${FIELD}%P${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${RECORD}`
201
+ const raw = git(targetDir, [
202
+ 'log',
203
+ '--first-parent',
204
+ `--max-count=${scanCap}`,
205
+ `--format=${format}`,
206
+ `${sinceSha}..${defaultBranch}`,
207
+ ])
208
+ if (!raw) return empty
209
+
210
+ const records = raw
211
+ .split(RECORD)
212
+ .map((r) => r.replace(/^\n+/, ''))
213
+ .filter((r) => r.trim().length > 0)
214
+
215
+ const matched = []
216
+ for (const rec of records) {
217
+ const [sha, parentsRaw, authorName, authorEmail, subject, body] = rec.split(FIELD)
218
+ if (!sha) continue
219
+ const pr = parsePullRequest(subject || '', body || '')
220
+ if (!pr) continue
221
+
222
+ const parents = (parentsRaw || '').trim().split(/\s+/).filter(Boolean)
223
+ const { emails, authorName: resolvedAuthor } = resolveIntroducedAuthors(
224
+ targetDir,
225
+ parents,
226
+ authorEmail || '',
227
+ currentUserEmail,
228
+ authorName || 'unknown',
229
+ )
230
+
231
+ // Exclude only when EVERY introduced commit is the current user's own.
232
+ const allOwn =
233
+ currentUserEmail.length > 0 &&
234
+ emails.length > 0 &&
235
+ emails.every((e) => e === currentUserEmail)
236
+ if (allOwn) continue
237
+
238
+ matched.push({ number: pr.number, title: pr.title, author: resolvedAuthor })
239
+ }
240
+
241
+ const more_capped = records.length >= scanCap
242
+ if (matched.length <= maxEntries) {
243
+ return { entries: matched, more_count: 0, more_capped }
244
+ }
245
+ return {
246
+ entries: matched.slice(0, maxEntries),
247
+ more_count: matched.length - maxEntries,
248
+ more_capped,
249
+ }
250
+ }
@@ -4,10 +4,15 @@
4
4
 
5
5
  import { execSync } from 'node:child_process'
6
6
 
7
+ const _identityCache = new Map()
8
+
7
9
  /**
8
10
  * Auto-detect the current user's git identity. Returns null if git config is unset.
11
+ * Memoized per targetDir for the process lifetime (git identity is stable within a CLI invocation).
9
12
  */
10
13
  export function getGitIdentity(targetDir) {
14
+ if (_identityCache.has(targetDir)) return _identityCache.get(targetDir)
15
+ let result = null
11
16
  try {
12
17
  const name = execSync('git config user.name 2>/dev/null', {
13
18
  cwd: targetDir,
@@ -17,11 +22,12 @@ export function getGitIdentity(targetDir) {
17
22
  cwd: targetDir,
18
23
  encoding: 'utf-8',
19
24
  }).trim()
20
- if (!name || !email) return null
21
- return { name, email }
25
+ if (name && email) result = { name, email }
22
26
  } catch {
23
- return null
27
+ /* no git config — result stays null */
24
28
  }
29
+ _identityCache.set(targetDir, result)
30
+ return result
25
31
  }
26
32
 
27
33
  /**
@@ -88,6 +88,7 @@ export const LEGACY_INSTALL_PATHS = Object.freeze({
88
88
  'agents/apt.md',
89
89
  'agents/apt-author-skill.md',
90
90
  'agents/apt-bootstrap.md',
91
+ 'agents/apt-catch-up.md',
91
92
  'agents/apt-caveman.md',
92
93
  'agents/apt-classify.md',
93
94
  'agents/apt-close-task.md',
@@ -131,6 +132,7 @@ export const LEGACY_INSTALL_PATHS = Object.freeze({
131
132
  'commands/apt.md',
132
133
  'commands/apt-author-skill.md',
133
134
  'commands/apt-bootstrap.md',
135
+ 'commands/apt-catch-up.md',
134
136
  'commands/apt-caveman.md',
135
137
  'commands/apt-classify.md',
136
138
  'commands/apt-close-task.md',
@@ -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
  }