@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,231 @@
1
+ /**
2
+ * @internal
3
+ * coordination/catch-up.mjs — composes the combined catch-up panel: the
4
+ * Welcome-Back Summary (your own half-finished work, local-only) and the
5
+ * gated Team-Change Digest (what teammates merged while you were away).
6
+ *
7
+ * Composition order is fixed: welcome_back first, then team_digest (LD-10).
8
+ * The renderer (apt/SKILL.md) enforces display order; this module only sets
9
+ * the field order on the returned object.
10
+ *
11
+ * Both halves diff against one Last-Seen Marker:
12
+ * - Welcome-Back fires on a staleness threshold (now - last_run_at), reads
13
+ * only local state.active_tasks, and is ALWAYS-ON regardless of visibility
14
+ * (LD-08/09).
15
+ * - Team-Change Digest fires only when share.visibility is team/oss AND the
16
+ * local default-branch sha has moved since last_main_sha (LD-04/06/07).
17
+ *
18
+ * Returns null when neither half fires. This module is `@internal` — reached
19
+ * via the route.mjs lazy-compute path and the apt-tools catch-up command, not
20
+ * from bin/apt-tools.mjs directly.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import { resolveSharePolicy } from '../config/share-policy.mjs'
26
+ import { listMergedPullRequests } from '../git/default-branch.mjs'
27
+ import { getGitIdentity } from '../git/identity.mjs'
28
+ import { resolveSharedRoot } from './agent-identity.mjs'
29
+
30
+ const HOUR_MS = 60 * 60 * 1000
31
+
32
+ /**
33
+ * Best-available per-task activity timestamp (ms). Prefers an explicit
34
+ * updated_at, then the most recent history entry, then created_at.
35
+ *
36
+ * @param {object} task
37
+ * @returns {number} epoch ms, or 0 when no timestamp is parseable.
38
+ */
39
+ function taskActivityMs(task) {
40
+ const candidates = []
41
+ if (task?.updated_at) candidates.push(Date.parse(task.updated_at))
42
+ if (Array.isArray(task?.history)) {
43
+ for (const h of task.history) {
44
+ if (h?.at) candidates.push(Date.parse(h.at))
45
+ }
46
+ }
47
+ if (task?.created_at) candidates.push(Date.parse(task.created_at))
48
+ const valid = candidates.filter((n) => Number.isFinite(n))
49
+ return valid.length ? Math.max(...valid) : 0
50
+ }
51
+
52
+ /**
53
+ * A coarse "x ago" relative-time string. Kept dependency-free and stable for
54
+ * tests — granularity is minutes/hours/days.
55
+ *
56
+ * @param {number} deltaMs
57
+ * @returns {string}
58
+ */
59
+ function relativeTime(deltaMs) {
60
+ if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now'
61
+ const mins = Math.floor(deltaMs / 60000)
62
+ if (mins < 1) return 'just now'
63
+ if (mins < 60) return `${mins}m ago`
64
+ const hours = Math.floor(mins / 60)
65
+ if (hours < 24) return `${hours}h ago`
66
+ const days = Math.floor(hours / 24)
67
+ return `${days}d ago`
68
+ }
69
+
70
+ /**
71
+ * Read the first actionable next-step line from a task's CONTINUE note, if one
72
+ * exists at `.aperant/tasks/{taskId}/CONTINUE.md`. Parses the first non-empty
73
+ * item under the `## Next Steps` heading (the pause-note format).
74
+ *
75
+ * @param {string} sharedRoot
76
+ * @param {string} taskId
77
+ * @returns {string|null}
78
+ */
79
+ function readNextStepLine(sharedRoot, taskId) {
80
+ if (!taskId) return null
81
+ const notePath = join(sharedRoot, '.aperant', 'tasks', taskId, 'CONTINUE.md')
82
+ if (!existsSync(notePath)) return null
83
+ let text
84
+ try {
85
+ text = readFileSync(notePath, 'utf-8')
86
+ } catch {
87
+ return null
88
+ }
89
+ const lines = text.split('\n')
90
+ const headingIdx = lines.findIndex((l) => /^##\s+Next Steps\s*$/i.test(l.trim()))
91
+ if (headingIdx === -1) return null
92
+ for (let i = headingIdx + 1; i < lines.length; i++) {
93
+ const raw = lines[i].trim()
94
+ if (raw.startsWith('##')) break // next section
95
+ if (raw.length === 0) continue
96
+ // Strip a leading list marker: "1. ", "- ", "* ".
97
+ const cleaned = raw.replace(/^(\d+\.|[-*])\s+/, '').trim()
98
+ if (cleaned.length > 0) return cleaned
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * Build the Welcome-Back half from local state (LD-08/09). Returns null when
105
+ * the threshold has not elapsed or there is no active task.
106
+ *
107
+ * @param {object} params
108
+ * @param {object} params.config
109
+ * @param {number} params.now epoch ms
110
+ * @param {object|null} params.marker
111
+ * @param {object|null} params.state raw state.json (for active_tasks)
112
+ * @param {string} params.sharedRoot
113
+ * @returns {object|null}
114
+ */
115
+ function buildWelcomeBack({ config, now, marker, state, sharedRoot }) {
116
+ const wb = config?.collaboration?.welcome_back
117
+ if (wb?.enabled === false) return null
118
+
119
+ const lastRunAt = marker?.last_run_at ? Date.parse(marker.last_run_at) : NaN
120
+ const thresholdHours = Number.isFinite(wb?.threshold_hours) ? wb.threshold_hours : 16
121
+ // Only fire when we have a baseline AND the gap strictly exceeds the threshold.
122
+ if (!Number.isFinite(lastRunAt)) return null
123
+ if (now - lastRunAt <= thresholdHours * HOUR_MS) return null
124
+
125
+ const activeTasks =
126
+ state?.active_tasks && typeof state.active_tasks === 'object' ? state.active_tasks : {}
127
+ const ids = Object.keys(activeTasks)
128
+ if (ids.length === 0) return null
129
+
130
+ // Pick the single most-recently-active task.
131
+ let bestId = null
132
+ let bestMs = -1
133
+ for (const id of ids) {
134
+ const ms = taskActivityMs(activeTasks[id])
135
+ if (ms > bestMs) {
136
+ bestMs = ms
137
+ bestId = id
138
+ }
139
+ }
140
+ if (!bestId) return null
141
+
142
+ const task = activeTasks[bestId]
143
+ const currentSubtask = task?.progress?.current_subtask ?? task?.current_subtask ?? null
144
+
145
+ return {
146
+ task_id: bestId,
147
+ description: task?.description ?? bestId,
148
+ lifecycle_phase: task?.lifecycle_phase ?? null,
149
+ current_subtask: currentSubtask,
150
+ progress: task?.progress ?? null,
151
+ relative_time: bestMs > 0 ? relativeTime(now - bestMs) : null,
152
+ next_step: readNextStepLine(sharedRoot, bestId),
153
+ others_count: Math.max(0, ids.length - 1),
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Build the Team-Change Digest half (LD-04/06/07). Returns null when the
159
+ * visibility gate is closed (solo), the digest is disabled, or the
160
+ * default-branch sha has not moved since the marker.
161
+ *
162
+ * @param {object} params
163
+ * @param {string} params.projectRoot
164
+ * @param {object} params.config
165
+ * @param {object|null} params.marker
166
+ * @param {{ branch: string, sha: string }|null} params.defaultBranch
167
+ * @returns {object|null}
168
+ */
169
+ function buildTeamDigest({ projectRoot, config, marker, defaultBranch }) {
170
+ const td = config?.collaboration?.team_digest
171
+ if (td?.enabled === false) return null
172
+
173
+ const visibility = resolveSharePolicy(config).visibility
174
+ if (visibility !== 'team' && visibility !== 'oss') return null
175
+
176
+ if (!defaultBranch?.sha) return null
177
+ // Only fire when main has actually moved since the recorded baseline.
178
+ if (marker?.last_main_sha && marker.last_main_sha === defaultBranch.sha) return null
179
+
180
+ const maxEntries = Number.isInteger(td?.max_entries) && td.max_entries > 0 ? td.max_entries : 7
181
+ const identity = (() => {
182
+ try {
183
+ return getGitIdentity(projectRoot)
184
+ } catch {
185
+ return null
186
+ }
187
+ })()
188
+
189
+ const { entries, more_count } = listMergedPullRequests(projectRoot, {
190
+ sinceSha: marker?.last_main_sha || null,
191
+ defaultBranch: defaultBranch.branch,
192
+ currentUserEmail: identity?.email || '',
193
+ maxEntries,
194
+ })
195
+
196
+ if (entries.length === 0 && more_count === 0) return null
197
+ return { entries, more_count }
198
+ }
199
+
200
+ /**
201
+ * Compose the combined catch-up panel. Returns null when neither half fires.
202
+ *
203
+ * @param {string} projectRoot
204
+ * @param {object} params
205
+ * @param {object} params.config
206
+ * @param {number} params.now epoch ms
207
+ * @param {object|null} params.marker Last-Seen Marker
208
+ * @param {{ branch: string, sha: string }|null} params.defaultBranch
209
+ * @returns {{ welcome_back: object|null, team_digest: object|null }|null}
210
+ *
211
+ * @internal
212
+ */
213
+ export function computeCatchUpPanel(projectRoot, { config, now, marker, defaultBranch } = {}) {
214
+ const sharedRoot = resolveSharedRoot(projectRoot)
215
+ const state = (() => {
216
+ const statePath = join(sharedRoot, '.aperant', 'state.json')
217
+ if (!existsSync(statePath)) return null
218
+ try {
219
+ return JSON.parse(readFileSync(statePath, 'utf-8'))
220
+ } catch {
221
+ return null
222
+ }
223
+ })()
224
+
225
+ const welcome_back = buildWelcomeBack({ config, now, marker, state, sharedRoot })
226
+ const team_digest = buildTeamDigest({ projectRoot, config, marker, defaultBranch })
227
+
228
+ if (!welcome_back && !team_digest) return null
229
+ // Fixed field order: welcome_back first (LD-10).
230
+ return { welcome_back, team_digest }
231
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @internal
3
+ * last-seen.mjs — per-machine, per-git-identity Last-Seen Marker (LD-01).
4
+ *
5
+ * The collaboration catch-up surfaces (Welcome-Back Summary + Team-Change
6
+ * Digest) both diff against one baseline record per human/machine:
7
+ *
8
+ * .aperant/last-seen/{email-slug}.json → { last_run_at, last_main_sha, last_active_task }
9
+ *
10
+ * The slug is derived from the git `user.email` (stable per human/machine),
11
+ * lowercased with every non-[a-z0-9] run collapsed to '-'. The directory is
12
+ * gitignored in the same per-machine ephemeral class as `team-status/` and
13
+ * `events/` (see upgrade-gitignore.mjs / share-policy.mjs).
14
+ *
15
+ * Modeled on team-status.mjs: resolveSharedRoot-based path so the marker lives
16
+ * in the main repo even when called from a worktree; atomicWriteJson +
17
+ * mkdirSync(recursive) for durable writes.
18
+ *
19
+ * This module is `@internal` — consumed only through the CoordinationStore
20
+ * seam (store.readLastSeen / store.advanceLastSeen). Do NOT import it from
21
+ * bin/apt-tools.mjs.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+ import { getGitIdentity } from '../git/identity.mjs'
27
+ import { atomicWriteJson } from '../util/io.mjs'
28
+ import { resolveSharedRoot } from './agent-identity.mjs'
29
+
30
+ function lastSeenDir(projectRoot) {
31
+ return join(resolveSharedRoot(projectRoot), '.aperant', 'last-seen')
32
+ }
33
+
34
+ /**
35
+ * Slug a git email into a filesystem-safe key: lowercase, every run of
36
+ * non-[a-z0-9] characters collapsed to a single '-'.
37
+ *
38
+ * @param {string} email
39
+ * @returns {string}
40
+ */
41
+ export function slugFromEmail(email) {
42
+ return String(email)
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '')
46
+ }
47
+
48
+ function markerPath(projectRoot) {
49
+ const identity = (() => {
50
+ try {
51
+ return getGitIdentity(projectRoot)
52
+ } catch {
53
+ return null
54
+ }
55
+ })()
56
+ if (!identity?.email) return null
57
+ const slug = slugFromEmail(identity.email)
58
+ if (!slug) return null
59
+ return join(lastSeenDir(projectRoot), `${slug}.json`)
60
+ }
61
+
62
+ /**
63
+ * Read the current user's Last-Seen Marker. Returns null when the git email is
64
+ * absent (no identity → no key) or the file does not yet exist / is malformed.
65
+ *
66
+ * @param {string} projectRoot
67
+ * @returns {{ last_run_at: string|null, last_main_sha: string|null, last_active_task: string|null }|null}
68
+ *
69
+ * @internal
70
+ */
71
+ export function readLastSeen(projectRoot) {
72
+ const path = markerPath(projectRoot)
73
+ if (!path || !existsSync(path)) return null
74
+ try {
75
+ const record = JSON.parse(readFileSync(path, 'utf-8'))
76
+ if (!record || typeof record !== 'object') return null
77
+ return {
78
+ last_run_at: record.last_run_at ?? null,
79
+ last_main_sha: record.last_main_sha ?? null,
80
+ last_active_task: record.last_active_task ?? null,
81
+ }
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Advance the marker, merging partial fields onto any existing record.
89
+ *
90
+ * Marker-advance semantics (LD-02):
91
+ * - last_run_at is always settable (it measures engagement).
92
+ * - last_main_sha is optional — the caller passes it only AFTER the digest
93
+ * was computed (even when empty). When undefined, the existing sha is kept.
94
+ * - last_active_task is optional — the welcome-back task id, kept otherwise.
95
+ *
96
+ * No-op (returns null) when the git email is absent — there is no key to write.
97
+ *
98
+ * @param {string} projectRoot
99
+ * @param {{ last_run_at?: string, last_main_sha?: string|null, last_active_task?: string|null }} fields
100
+ * @returns {object|null} the written record, or null when there is no identity key.
101
+ *
102
+ * @internal
103
+ */
104
+ export function advanceLastSeen(projectRoot, fields = {}) {
105
+ const path = markerPath(projectRoot)
106
+ if (!path) return null
107
+
108
+ let existing = {}
109
+ if (existsSync(path)) {
110
+ try {
111
+ existing = JSON.parse(readFileSync(path, 'utf-8')) || {}
112
+ } catch {
113
+ existing = {}
114
+ }
115
+ }
116
+
117
+ const record = {
118
+ last_run_at:
119
+ fields.last_run_at !== undefined ? fields.last_run_at : (existing.last_run_at ?? null),
120
+ last_main_sha:
121
+ fields.last_main_sha !== undefined ? fields.last_main_sha : (existing.last_main_sha ?? null),
122
+ last_active_task:
123
+ fields.last_active_task !== undefined
124
+ ? fields.last_active_task
125
+ : (existing.last_active_task ?? null),
126
+ }
127
+
128
+ mkdirSync(lastSeenDir(projectRoot), { recursive: true })
129
+ atomicWriteJson(path, record)
130
+ return record
131
+ }
@@ -33,6 +33,7 @@ import { atomicWriteJson } from '../util/io.mjs'
33
33
  import { getAgentIdentity, resolveSharedRoot } from './agent-identity.mjs'
34
34
  import { appendLifecycleEvent } from './event-log.mjs'
35
35
  import { validateConductorEvent } from './event-schema.mjs'
36
+ import { advanceLastSeen as lastSeenAdvance, readLastSeen as lastSeenRead } from './last-seen.mjs'
36
37
  import {
37
38
  listActive as teamStatusListActive,
38
39
  removeSelf as teamStatusRemoveSelf,
@@ -152,6 +153,23 @@ export function createCoordinationStore(projectRoot) {
152
153
  }
153
154
  },
154
155
 
156
+ /**
157
+ * @internal — LD-01: read the per-machine Last-Seen Marker. Returns
158
+ * null when the git email is absent or the marker has not been written.
159
+ */
160
+ readLastSeen() {
161
+ return lastSeenRead(projectRoot)
162
+ },
163
+
164
+ /**
165
+ * @internal — LD-02: advance the Last-Seen Marker. `last_run_at` is
166
+ * always settable; `last_main_sha` / `last_active_task` are optional
167
+ * partial fields merged onto the existing record.
168
+ */
169
+ advanceLastSeen(fields = {}) {
170
+ return lastSeenAdvance(projectRoot, fields)
171
+ },
172
+
155
173
  /** @internal */
156
174
  getIdentity() {
157
175
  return getAgentIdentity(projectRoot)
@@ -33,20 +33,21 @@
33
33
  * authoring contract.
34
34
  */
35
35
 
36
- const VALID_SURFACES = new Set(['electron', 'web', 'cli', 'api', 'electron+web'])
36
+ const VALID_SURFACES = new Set(['electron', 'web', 'cli', 'api', 'mobile', 'electron+web'])
37
37
 
38
38
  const HEADING_RE = /^(#{1,6})\s+(.*)$/
39
39
  const USER_OUTCOMES_HEADING_RE = /^user\s+outcomes\s*$/i
40
40
  const EPIC_RE = /^\*\*Epic:\*\*\s+(.+)$/
41
41
  const EMPTY_WITH_NOTE_RE = /^_No user-observable changes — pure refactor\._\s*$/
42
42
  const OUTCOME_RE =
43
- /^-\s+\*\*ON\*\*\s+\[(electron|web|cli|api|electron\+web)\]:\s+(.+?)(\s+\*\(requires\s+ON\s+(\d+)\)\*)?\s*$/
43
+ /^-\s+\*\*ON\*\*\s+\[(electron|web|cli|api|mobile|electron\+web)\]:\s+(.+?)(\s+\*\(requires\s+ON\s+(\d+)\)\*)?\s*$/
44
44
 
45
45
  /**
46
46
  * @typedef {Object} UserOutcome
47
47
  * @property {string} id — synthesized positional ID (O1, O2, ...)
48
- * @property {('electron'|'web'|'cli'|'api'|'electron+web')} surface
48
+ * @property {('electron'|'web'|'cli'|'api'|'mobile'|'electron+web')} surface
49
49
  * @property {string} text — outcome prose (sans the surface tag and requires suffix)
50
+ * @property {string[]} inferred — v0.9.0 — indented child bullets under the parent outcome (raw prose, no further parsing). Defaults to [] when no children are present.
50
51
  * @property {number} [requires] — the digit captured from `*(requires ON N)*`, when present and valid
51
52
  */
52
53
 
@@ -165,24 +166,58 @@ export function parseUserOutcomes(specContent) {
165
166
  return result
166
167
  }
167
168
 
168
- // Parse outcome rows in document order. Skip non-matching lines (e.g.
169
- // stray prose between outcomes) they don't count as outcomes.
169
+ // Parse outcome rows in document order. v0.9.0 walk the raw body lines
170
+ // (not the trimmed `outcomeLines`) so we preserve indentation of child
171
+ // bullets. A `**ON**` parent row is followed by zero or more indented
172
+ // child bullets (lines starting with at least 2 spaces + `- `); those
173
+ // children attach to the parent as `inferred: string[]` (raw prose, no
174
+ // further parsing). The children block ends at the next `**ON**` row,
175
+ // a non-indented line, or a section terminator (blank line followed by
176
+ // another non-bullet non-blank line is fine; the next `**ON**` always
177
+ // resets ownership).
170
178
  /** @type {UserOutcome[]} */
171
179
  const outcomes = []
172
180
  const rawRequires = []
173
- for (const line of outcomeLines) {
174
- const m = line.match(OUTCOME_RE)
175
- if (!m) continue
176
- const surface = /** @type {UserOutcome['surface']} */ (m[1])
177
- if (!VALID_SURFACES.has(surface)) continue
178
- const text = m[2].trim()
179
- const id = 'O' + (outcomes.length + 1)
180
- const outcome = /** @type {UserOutcome} */ ({ id, surface, text })
181
- if (m[4]) {
182
- const n = Number.parseInt(m[4], 10)
183
- rawRequires.push({ outcomeIndex: outcomes.length, requires: n })
181
+ const CHILD_RE = /^(?: {2}|\t)+- (.+)$/
182
+ let currentOutcomeIndex = -1
183
+ for (let i = 0; i < bodyLines.length; i++) {
184
+ const rawLine = bodyLines[i]
185
+ const trimmed = rawLine.trim()
186
+ if (trimmed.length === 0) {
187
+ // Blank lines don't close the parent — children may resume after
188
+ // a blank line (loose-list markdown). The next non-blank line
189
+ // decides ownership.
190
+ continue
191
+ }
192
+ const onMatch = trimmed.match(OUTCOME_RE)
193
+ if (onMatch) {
194
+ const surface = /** @type {UserOutcome['surface']} */ (onMatch[1])
195
+ if (!VALID_SURFACES.has(surface)) {
196
+ currentOutcomeIndex = -1
197
+ continue
198
+ }
199
+ const text = onMatch[2].trim()
200
+ const id = 'O' + (outcomes.length + 1)
201
+ const outcome = /** @type {UserOutcome} */ ({ id, surface, text, inferred: [] })
202
+ if (onMatch[4]) {
203
+ const n = Number.parseInt(onMatch[4], 10)
204
+ rawRequires.push({ outcomeIndex: outcomes.length, requires: n })
205
+ }
206
+ outcomes.push(outcome)
207
+ currentOutcomeIndex = outcomes.length - 1
208
+ continue
209
+ }
210
+ // Non-`**ON**` non-blank line. If it's an indented child bullet AND
211
+ // we have a current outcome, attach it. Otherwise (e.g. Epic line,
212
+ // sentinel, stray prose), drop ownership.
213
+ const childMatch = rawLine.match(CHILD_RE)
214
+ if (childMatch && currentOutcomeIndex >= 0) {
215
+ outcomes[currentOutcomeIndex].inferred.push(childMatch[1].trim())
216
+ continue
184
217
  }
185
- outcomes.push(outcome)
218
+ // Non-indented, non-`**ON**` line — closes the current outcome's
219
+ // child block. (The next `**ON**` will start a new block.)
220
+ currentOutcomeIndex = -1
186
221
  }
187
222
 
188
223
  if (outcomes.length === 0) {
@@ -36,9 +36,11 @@
36
36
 
37
37
  import { cmdActive } from './commands/active.mjs'
38
38
  import { cmdAdr } from './commands/adr.mjs'
39
+ // cmdApplyRecipe — loaded lazily in the dispatch handler (Fast Path PRF-001)
39
40
  import { cmdAudit } from './commands/audit.mjs'
40
41
  import { cmdAuthorSkill } from './commands/author-skill.mjs'
41
42
  import { cmdBootstrap } from './commands/bootstrap.mjs'
43
+ import { cmdCatchUp } from './commands/catch-up.mjs'
42
44
  import { cmdCheck } from './commands/check.mjs'
43
45
  import { cmdCheckVersion } from './commands/check-version.mjs'
44
46
  import { cmdCiWatch, cmdCiWatchSweep } from './commands/ci-watch.mjs'
@@ -69,12 +71,14 @@ import { cmdInitState } from './commands/init-state.mjs'
69
71
  import { cmdInstallFromSource } from './commands/install-from-source.mjs'
70
72
  import { cmdLock } from './commands/lock.mjs'
71
73
  import { cmdMigratePrReviews } from './commands/migrate-pr-reviews.mjs'
74
+ import { cmdMobilePrepare } from './commands/mobile-prepare.mjs'
72
75
  import { cmdPersonas } from './commands/personas.mjs'
73
76
  import { cmdPlanApproval } from './commands/plan-approval.mjs'
74
77
  import { cmdPrReview } from './commands/pr-review.mjs'
75
78
  import { cmdPrecompactHook } from './commands/precompact-hook.mjs'
76
79
  import { cmdProductContext } from './commands/product-context.mjs'
77
80
  import { cmdProfile } from './commands/profile.mjs'
81
+ // cmdRecipeDiff — loaded lazily in the dispatch handler (Fast Path PRF-001)
78
82
  import { cmdReleaseNotes } from './commands/release-notes.mjs'
79
83
  import { cmdRiskCheck } from './commands/risk-check.mjs'
80
84
  import { cmdRoadmap } from './commands/roadmap.mjs'
@@ -97,6 +101,7 @@ import { cmdValidateEvidence } from './commands/validate-evidence.mjs'
97
101
  import { cmdVariantGallery } from './commands/variant-gallery.mjs'
98
102
  import { cmdVariantPick } from './commands/variant-pick.mjs'
99
103
  import { cmdVariantPool } from './commands/variant-pool.mjs'
104
+ // cmdVerifySandbox — loaded lazily in the dispatch handler (Fast Path PRF-001)
100
105
  import { cmdVitestDoctor } from './commands/vitest-doctor.mjs'
101
106
  import { cmdWave } from './commands/wave.mjs'
102
107
  import { cmdWfrun } from './commands/wfrun.mjs'
@@ -212,11 +217,24 @@ export const DISPATCH = {
212
217
 
213
218
  // ---- (positional dir, slice(2)) shape ----
214
219
  state: (args) => wrap(cmdState)(args[1]),
220
+ 'catch-up': (args) => wrap(cmdCatchUp)(args[1]),
215
221
  config: (args) => wrap(cmdConfig)(args[1], args.slice(2)),
216
222
  'state-update': (args) => wrap(cmdStateUpdate)(args[1], args.slice(2)),
217
223
  'state-artifacts': (args) => wrap(cmdStateArtifacts)(args[1], args.slice(2)),
218
224
  'detect-project': (args) => wrap(cmdDetectProject)(args[1]),
219
225
  'detect-runtime': (args) => wrap(cmdDetectRuntime)(args[1], args.slice(2)),
226
+ 'recipe-diff': async (args) => {
227
+ const { cmdRecipeDiff } = await import('./commands/recipe-diff.mjs')
228
+ return wrap(cmdRecipeDiff)(args[1], args.slice(2))
229
+ },
230
+ 'apply-recipe': async (args) => {
231
+ const { cmdApplyRecipe } = await import('./commands/apply-recipe.mjs')
232
+ return wrap(cmdApplyRecipe)(args[1], args.slice(2))
233
+ },
234
+ 'verify-sandbox': async (args) => {
235
+ const { cmdVerifySandbox } = await import('./commands/verify-sandbox.mjs')
236
+ return wrap(cmdVerifySandbox)(args[1], args.slice(2))
237
+ },
220
238
  driver: (args) => {
221
239
  // `apt-tools driver doctor <project-dir>` — only subcommand for now.
222
240
  if (args[1] === 'doctor') return wrap(cmdDriverDoctor)(args[2], args.slice(3))
@@ -228,6 +246,17 @@ export const DISPATCH = {
228
246
  exitCode: 1,
229
247
  }))()
230
248
  },
249
+ mobile: (args) => {
250
+ // `apt-tools mobile prepare <project-dir> --platform <ios|android> [--mode <expo|native>] [--device <id>]`
251
+ if (args[1] === 'prepare') return wrap(cmdMobilePrepare)(args[2], args.slice(3))
252
+ return wrap(() => ({
253
+ envelope: {
254
+ status: 'error',
255
+ error: `Usage: apt-tools mobile prepare <project-dir> --platform <ios|android>. Unknown subcommand: ${args[1] ?? '(none)'}`,
256
+ },
257
+ exitCode: 1,
258
+ }))()
259
+ },
231
260
  'features-audit': (args) => wrap(cmdFeaturesAudit)(args[1], args.slice(2)),
232
261
  'init-state': (args) => wrap(cmdInitState)(args[1], args.slice(2)),
233
262
  'risk-check': (args) => wrap(cmdRiskCheck)(args[1], args.slice(2)),