@aperant/framework 0.9.0 → 0.11.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 (138) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/agents/apt-verifier.md +6 -3
  3. package/dist/cli/commands/catch-up.d.mts +9 -0
  4. package/dist/cli/commands/catch-up.d.mts.map +1 -0
  5. package/dist/cli/commands/catch-up.mjs +64 -0
  6. package/dist/cli/commands/catch-up.mjs.map +1 -0
  7. package/dist/cli/commands/init.d.mts.map +1 -1
  8. package/dist/cli/commands/init.mjs +17 -1
  9. package/dist/cli/commands/init.mjs.map +1 -1
  10. package/dist/cli/commands/pr-review.d.mts +16 -4
  11. package/dist/cli/commands/pr-review.d.mts.map +1 -1
  12. package/dist/cli/commands/pr-review.mjs +30 -11
  13. package/dist/cli/commands/pr-review.mjs.map +1 -1
  14. package/dist/cli/commands/route.d.mts.map +1 -1
  15. package/dist/cli/commands/route.mjs +92 -2
  16. package/dist/cli/commands/route.mjs.map +1 -1
  17. package/dist/cli/commands/validate-evidence.d.mts +1 -1
  18. package/dist/cli/config/share-policy.d.mts.map +1 -1
  19. package/dist/cli/config/share-policy.mjs +1 -0
  20. package/dist/cli/config/share-policy.mjs.map +1 -1
  21. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  22. package/dist/cli/config/upgrade-gitignore.mjs +1 -0
  23. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  24. package/dist/cli/consistency/parse-review.mjs +5 -3
  25. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  26. package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
  27. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +10 -8
  28. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
  29. package/dist/cli/coordination/catch-up.d.mts +26 -0
  30. package/dist/cli/coordination/catch-up.d.mts.map +1 -0
  31. package/dist/cli/coordination/catch-up.mjs +239 -0
  32. package/dist/cli/coordination/catch-up.mjs.map +1 -0
  33. package/dist/cli/coordination/last-seen.d.mts +45 -0
  34. package/dist/cli/coordination/last-seen.d.mts.map +1 -0
  35. package/dist/cli/coordination/last-seen.mjs +128 -0
  36. package/dist/cli/coordination/last-seen.mjs.map +1 -0
  37. package/dist/cli/coordination/store.d.mts +15 -0
  38. package/dist/cli/coordination/store.d.mts.map +1 -1
  39. package/dist/cli/coordination/store.mjs +16 -0
  40. package/dist/cli/coordination/store.mjs.map +1 -1
  41. package/dist/cli/design/frontmatter-schema.d.mts +8 -8
  42. package/dist/cli/dispatch.d.mts.map +1 -1
  43. package/dist/cli/dispatch.mjs +2 -0
  44. package/dist/cli/dispatch.mjs.map +1 -1
  45. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  46. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  47. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  48. package/dist/cli/git/default-branch.d.mts +51 -0
  49. package/dist/cli/git/default-branch.d.mts.map +1 -0
  50. package/dist/cli/git/default-branch.mjs +234 -0
  51. package/dist/cli/git/default-branch.mjs.map +1 -0
  52. package/dist/cli/git/identity.d.mts +3 -5
  53. package/dist/cli/git/identity.d.mts.map +1 -1
  54. package/dist/cli/git/identity.mjs +10 -4
  55. package/dist/cli/git/identity.mjs.map +1 -1
  56. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  57. package/dist/cli/install/legacy-paths.mjs +2 -0
  58. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  59. package/dist/cli/install/mcp-provision.d.mts +26 -0
  60. package/dist/cli/install/mcp-provision.d.mts.map +1 -0
  61. package/dist/cli/install/mcp-provision.mjs +281 -0
  62. package/dist/cli/install/mcp-provision.mjs.map +1 -0
  63. package/dist/cli/install/mcp-server-specs.d.mts +80 -0
  64. package/dist/cli/install/mcp-server-specs.d.mts.map +1 -0
  65. package/dist/cli/install/mcp-server-specs.mjs +102 -0
  66. package/dist/cli/install/mcp-server-specs.mjs.map +1 -0
  67. package/dist/cli/install/runtime-detect.d.mts +1 -0
  68. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  69. package/dist/cli/install/runtime-detect.mjs +5 -0
  70. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  71. package/dist/cli/install/toml-merge.d.mts +40 -0
  72. package/dist/cli/install/toml-merge.d.mts.map +1 -0
  73. package/dist/cli/install/toml-merge.mjs +134 -0
  74. package/dist/cli/install/toml-merge.mjs.map +1 -0
  75. package/dist/cli/personas/sidecar.d.mts +1 -1
  76. package/dist/cli/roadmap/lifecycle.d.mts +1 -1
  77. package/dist/cli/roadmap/rollup.d.mts +2 -2
  78. package/dist/cli/util/events-path.d.mts +8 -0
  79. package/dist/cli/util/events-path.d.mts.map +1 -0
  80. package/dist/cli/util/events-path.mjs +23 -0
  81. package/dist/cli/util/events-path.mjs.map +1 -0
  82. package/dist/cli/util/runtime-capabilities.d.mts +30 -0
  83. package/dist/cli/util/runtime-capabilities.d.mts.map +1 -0
  84. package/dist/cli/util/runtime-capabilities.mjs +214 -0
  85. package/dist/cli/util/runtime-capabilities.mjs.map +1 -0
  86. package/dist/cli/verify-proof/audit.d.mts +2 -7
  87. package/dist/cli/verify-proof/audit.d.mts.map +1 -1
  88. package/dist/cli/verify-proof/audit.mjs +11 -17
  89. package/dist/cli/verify-proof/audit.mjs.map +1 -1
  90. package/dist/cli/verify-proof/runtime-detect.d.mts +2 -29
  91. package/dist/cli/verify-proof/runtime-detect.d.mts.map +1 -1
  92. package/dist/cli/verify-proof/runtime-detect.mjs +8 -203
  93. package/dist/cli/verify-proof/runtime-detect.mjs.map +1 -1
  94. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  95. package/dist/plugin/agents/apt-verifier.md +6 -3
  96. package/dist/plugin/skills/apt/SKILL.md +29 -0
  97. package/dist/plugin/skills/apt-catch-up/SKILL.md +79 -0
  98. package/dist/plugin/skills/apt-pr-review/SKILL.md +59 -4
  99. package/dist/plugin/skills/apt-setup/SKILL.md +76 -0
  100. package/dist/plugin/skills/apt-verify/SKILL.md +1 -1
  101. package/dist/plugin/skills/apt-verify-proof/SKILL.md +25 -1
  102. package/drivers/browser/skill/agent-browser/SKILL.md +51 -0
  103. package/drivers/tauri/README.md +107 -0
  104. package/drivers/tauri/driver.mjs +88 -0
  105. package/drivers/tauri/manifest.json +37 -0
  106. package/drivers/tauri/scripts/tauri-mcp.sh +41 -0
  107. package/package.json +138 -138
  108. package/skills/apt/SKILL.md +29 -0
  109. package/skills/apt-catch-up/SKILL.md +79 -0
  110. package/skills/apt-pr-review/SKILL.md +59 -4
  111. package/skills/apt-setup/SKILL.md +76 -0
  112. package/skills/apt-verify/SKILL.md +1 -1
  113. package/skills/apt-verify-proof/SKILL.md +25 -1
  114. package/src/cli/commands/catch-up.mjs +67 -0
  115. package/src/cli/commands/init.mjs +19 -0
  116. package/src/cli/commands/pr-review.mjs +32 -11
  117. package/src/cli/commands/route.mjs +92 -1
  118. package/src/cli/config/share-policy.mjs +1 -0
  119. package/src/cli/config/upgrade-gitignore.mjs +1 -0
  120. package/src/cli/consistency/parse-review.mjs +5 -3
  121. package/src/cli/consistency/rules/r5-verdict-consistency.mjs +14 -9
  122. package/src/cli/coordination/catch-up.mjs +231 -0
  123. package/src/cli/coordination/last-seen.mjs +131 -0
  124. package/src/cli/coordination/store.mjs +18 -0
  125. package/src/cli/dispatch.mjs +2 -0
  126. package/src/cli/gate/gates/review-clean.mjs +4 -2
  127. package/src/cli/git/default-branch.mjs +250 -0
  128. package/src/cli/git/identity.mjs +9 -3
  129. package/src/cli/install/legacy-paths.mjs +2 -0
  130. package/src/cli/install/mcp-provision.mjs +293 -0
  131. package/src/cli/install/mcp-server-specs.mjs +101 -0
  132. package/src/cli/install/runtime-detect.mjs +9 -0
  133. package/src/cli/install/toml-merge.mjs +139 -0
  134. package/src/cli/util/events-path.mjs +24 -0
  135. package/src/cli/util/runtime-capabilities.mjs +216 -0
  136. package/src/cli/verify-proof/audit.mjs +11 -17
  137. package/src/cli/verify-proof/runtime-detect.mjs +11 -204
  138. package/templates/config.json +13 -1
@@ -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',
@@ -0,0 +1,293 @@
1
+ /**
2
+ * install/mcp-provision.mjs — TOFU install-time MCP-server + skill provisioner.
3
+ *
4
+ * Called from cmdInit (the only call site — the QUICK fast-path never invokes
5
+ * init, so the Fast Path Guarantee is structurally untouched). Detects the
6
+ * project's runtime, then for each selected host CLI that supports the action:
7
+ *
8
+ * - kind 'mcp-server' (electron/tauri) → write the server into the host's
9
+ * MCP config (claude → .mcp.json JSON merge; codex → .codex/config.toml
10
+ * TOML upsert).
11
+ * - kind 'skill' (agent-browser) → copy the vendored SKILL.md into the
12
+ * host's skill dir (claude → .claude/skills/; codex → .agents/skills/).
13
+ *
14
+ * Every write is TOFU/idempotent (ID-05): if the target server id / table /
15
+ * skill file already exists, SKIP (never diff/merge/overwrite) — this is the
16
+ * must-not-clobber invariant for a hand-authored electron
17
+ * SCREENSHOT_ENCRYPTION_KEY (US-3) and a user-edited agent-browser skill
18
+ * (US-6). Each action appends one audit line to .aperant/events/{date}.jsonl.
19
+ *
20
+ * Gated behind config.verification.provision_mcp (default true) so users can
21
+ * opt out. Never throws — wraps the body in try/catch → {status:'error'}.
22
+ */
23
+
24
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
25
+ import { basename, dirname, join, resolve } from 'node:path'
26
+ import { appendJsonl, atomicWriteJson, atomicWriteText } from '../util/atomic-write.mjs'
27
+ import { dailyEventsPath } from '../util/events-path.mjs'
28
+ import { capabilityToProvisionIds, PROVISION_SPECS } from './mcp-server-specs.mjs'
29
+ import { loadRuntimeCapabilities } from './runtime-detect.mjs'
30
+ import { upsertMcpServerToml } from './toml-merge.mjs'
31
+
32
+ /**
33
+ * Per-host writer registry (ID-07, ID-08). v1 = claude + codex. The MCP
34
+ * config path AND skill dir are EXPLICIT per host — neither is derived from
35
+ * the runtime's installRoot (Codex's MCP path .codex/config.toml diverges
36
+ * from its skill dir .agents/skills/). Adding gemini/opencode later = one
37
+ * entry each.
38
+ *
39
+ * @type {Record<string, {mcpFormat: 'json'|'toml', mcpConfigPath: string, skillDir: string}>}
40
+ */
41
+ const HOST_PROVISION_WRITERS = {
42
+ claude: { mcpFormat: 'json', mcpConfigPath: '.mcp.json', skillDir: '.claude/skills' },
43
+ codex: { mcpFormat: 'toml', mcpConfigPath: '.codex/config.toml', skillDir: '.agents/skills' },
44
+ }
45
+
46
+ /**
47
+ * Substitute the per-project socket basename into the tauri launcher
48
+ * template, deriving uniqueness from the project dir basename.
49
+ *
50
+ * @param {string} template
51
+ * @param {string} targetDir
52
+ * @returns {string}
53
+ */
54
+ function renderTauriLauncher(template, targetDir) {
55
+ const slug =
56
+ basename(resolve(targetDir))
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]+/g, '-')
59
+ .replace(/^-+|-+$/g, '') || 'tauri'
60
+ return template.replaceAll('{{PROJECT_SOCKET}}', slug)
61
+ }
62
+
63
+ /**
64
+ * Write the electron/tauri MCP server into a Claude `.mcp.json`. JSON merge:
65
+ * read-or-{}, ensure mcpServers, SKIP if the server id already exists, else
66
+ * insert {type:'stdio', command, args, env?} preserving all sibling keys.
67
+ *
68
+ * TOFU non-clobber (ID-05): if the existing file fails to parse OR parses to
69
+ * a non-plain-object root (array / null / primitive), do NOT overwrite it —
70
+ * return `skipped_unsafe` with a reason so the caller can audit it and leave
71
+ * the file byte-identical. (`typeof [] === 'object'` is why arrays must be
72
+ * excluded explicitly: a `.mcp.json` containing `[]` would otherwise pass the
73
+ * object guard, drop the user's content on stringify, and report `written`.)
74
+ *
75
+ * @param {string} configPath — absolute path to .mcp.json
76
+ * @param {string} serverId
77
+ * @param {import('./mcp-server-specs.mjs').McpServerSpec} spec
78
+ * @returns {{action: 'written'|'skipped'|'skipped_unsafe', reason?: string}}
79
+ */
80
+ function writeClaudeMcpServer(configPath, serverId, spec) {
81
+ let config = {}
82
+ if (existsSync(configPath)) {
83
+ try {
84
+ config = JSON.parse(readFileSync(configPath, 'utf-8'))
85
+ } catch {
86
+ // Malformed JSON: overwriting would erase every hand-authored MCP
87
+ // server. Leave the file untouched and surface it.
88
+ return { action: 'skipped_unsafe', reason: 'malformed_json' }
89
+ }
90
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
91
+ // Non-plain-object root (array / null / primitive): same non-clobber
92
+ // rule — never overwrite content we cannot safely merge into.
93
+ return { action: 'skipped_unsafe', reason: 'non_object_root' }
94
+ }
95
+ }
96
+ if (config.mcpServers === null || typeof config.mcpServers !== 'object') {
97
+ config.mcpServers = {}
98
+ }
99
+ // TOFU: never clobber a hand-authored server id.
100
+ if (Object.hasOwn(config.mcpServers, serverId)) return { action: 'skipped' }
101
+
102
+ const entry = { type: 'stdio', command: spec.command, args: spec.args ?? [] }
103
+ if (spec.env && Object.keys(spec.env).length > 0) entry.env = spec.env
104
+ config.mcpServers[serverId] = entry
105
+ atomicWriteJson(configPath, config)
106
+ return { action: 'written' }
107
+ }
108
+
109
+ /**
110
+ * Write the electron/tauri MCP server into a Codex `.codex/config.toml`.
111
+ *
112
+ * @param {string} configPath — absolute path to .codex/config.toml
113
+ * @param {string} serverId
114
+ * @param {import('./mcp-server-specs.mjs').McpServerSpec} spec
115
+ * @returns {{action: 'written'|'skipped'}}
116
+ */
117
+ function writeCodexMcpServer(configPath, serverId, spec) {
118
+ const existing = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
119
+ const { content, action } = upsertMcpServerToml(existing, serverId, {
120
+ command: spec.command,
121
+ args: spec.args ?? [],
122
+ ...(spec.env && Object.keys(spec.env).length > 0 ? { env: spec.env } : {}),
123
+ })
124
+ if (action === 'written') atomicWriteText(configPath, content)
125
+ return { action }
126
+ }
127
+
128
+ /**
129
+ * Copy the vendored skill into a host skill dir. TOFU: never overwrite an
130
+ * existing agent-browser/SKILL.md (a user may have edited it).
131
+ *
132
+ * @param {string} skillDestPath — absolute <skillDir>/<skillId>/SKILL.md
133
+ * @param {string} vendoredSkillPath — absolute source path
134
+ * @returns {'written'|'skipped'}
135
+ */
136
+ function copySkill(skillDestPath, vendoredSkillPath) {
137
+ if (existsSync(skillDestPath)) return 'skipped'
138
+ mkdirSync(dirname(skillDestPath), { recursive: true })
139
+ // copyFileSync is fine here — atomicWriteText would re-read; the dest is
140
+ // guaranteed absent (TOFU check above) so there is no clobber risk.
141
+ copyFileSync(vendoredSkillPath, skillDestPath)
142
+ return 'written'
143
+ }
144
+
145
+ /**
146
+ * Provision MCP servers + skills for the selected host CLIs.
147
+ *
148
+ * @param {Object} opts
149
+ * @param {string} opts.targetDir — project root being installed into
150
+ * @param {string[]} opts.selectedRuntimes — the runtimes the user selected
151
+ * @param {object} opts.config — the effective merged project config
152
+ * @param {string} opts.frameworkRoot — absolute path to packages/framework (for vendored skill resolution)
153
+ * @returns {{status: 'ok'|'disabled'|'error', writes?: Array<{host:string, spec_id:string, kind:string, action:string}>, reason?: string}}
154
+ */
155
+ export function provisionMcpConfig(opts) {
156
+ const targetDir = resolve(opts.targetDir)
157
+ const config = opts.config ?? {}
158
+ const selected = Array.isArray(opts.selectedRuntimes) ? opts.selectedRuntimes : []
159
+ const frameworkRoot = opts.frameworkRoot
160
+
161
+ try {
162
+ // (1) Opt-out gate (default true).
163
+ if (config?.verification?.provision_mcp === false) {
164
+ appendEvent(targetDir, 'mcp-provision.disabled', {
165
+ reason: 'verification.provision_mcp=false',
166
+ })
167
+ return { status: 'disabled' }
168
+ }
169
+
170
+ // (2) Detect runtime → derive spec ids.
171
+ const runtimeCaps = loadRuntimeCapabilities(targetDir)
172
+ const specIds = capabilityToProvisionIds(runtimeCaps)
173
+
174
+ /** @type {Array<{host:string, spec_id:string, kind:string, action:string}>} */
175
+ const writes = []
176
+
177
+ // Iterate every (selected host, spec) pair.
178
+ for (const host of selected) {
179
+ const writer = HOST_PROVISION_WRITERS[host]
180
+ if (!writer) {
181
+ // Selected host with no writer (gemini/opencode/... in v1) — record
182
+ // once as unsupported_host, no write.
183
+ writes.push({ host, spec_id: '*', kind: '*', action: 'unsupported_host' })
184
+ appendEvent(targetDir, 'mcp-provision.unsupported_host', { host })
185
+ continue
186
+ }
187
+
188
+ for (const specId of specIds) {
189
+ const spec = PROVISION_SPECS[specId]
190
+ if (!spec) continue
191
+
192
+ if (spec.kind === 'mcp-server') {
193
+ const configPath = join(targetDir, writer.mcpConfigPath)
194
+ const { action, reason } =
195
+ writer.mcpFormat === 'json'
196
+ ? writeClaudeMcpServer(configPath, specId, spec)
197
+ : writeCodexMcpServer(configPath, specId, spec)
198
+ writes.push({ host, spec_id: specId, kind: 'mcp-server', action })
199
+ appendEvent(targetDir, `mcp-provision.${action}`, {
200
+ host,
201
+ spec_id: specId,
202
+ config_path: writer.mcpConfigPath,
203
+ ...(reason ? { reason } : {}),
204
+ })
205
+ // (5) tauri needs its launcher script — only provision it once the
206
+ // MCP config entry is actually written, so a TOFU-skipped or
207
+ // skipped_unsafe config never leaves an orphan launcher + a
208
+ // misleading audit line.
209
+ if (spec.requiresLauncherScript && frameworkRoot && action === 'written') {
210
+ provisionTauriLauncher(targetDir, frameworkRoot, writes)
211
+ }
212
+ } else if (spec.kind === 'skill') {
213
+ if (!frameworkRoot) continue
214
+ const vendoredSkillPath = resolve(frameworkRoot, spec.vendoredSkillRelPath)
215
+ const skillDestPath = join(targetDir, writer.skillDir, spec.skillId, 'SKILL.md')
216
+ const action = copySkill(skillDestPath, vendoredSkillPath)
217
+ writes.push({ host, spec_id: specId, kind: 'skill', action })
218
+ appendEvent(
219
+ targetDir,
220
+ `skill-provision.${action}`,
221
+ {
222
+ host,
223
+ spec_id: specId,
224
+ skill_dir: writer.skillDir,
225
+ },
226
+ 'skill_provision',
227
+ )
228
+ }
229
+ }
230
+ }
231
+
232
+ return { status: 'ok', writes }
233
+ } catch (e) {
234
+ return { status: 'error', reason: e instanceof Error ? e.message : String(e) }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Copy the tauri launcher template into the project's scripts/ dir (TOFU).
240
+ * Records its own audit line; appends to `writes` with a synthetic host.
241
+ *
242
+ * @param {string} targetDir
243
+ * @param {string} frameworkRoot
244
+ * @param {Array<{host:string, spec_id:string, kind:string, action:string}>} writes
245
+ */
246
+ function provisionTauriLauncher(targetDir, frameworkRoot, writes) {
247
+ const destPath = join(targetDir, 'scripts', 'tauri-mcp.sh')
248
+ // Already recorded a launcher write/skip for this run? Only provision once.
249
+ if (writes.some((w) => w.kind === 'launcher')) return
250
+ if (existsSync(destPath)) {
251
+ writes.push({ host: '-', spec_id: 'tauri', kind: 'launcher', action: 'skipped' })
252
+ appendEvent(targetDir, 'mcp-provision.skipped', {
253
+ host: '-',
254
+ spec_id: 'tauri',
255
+ launcher: 'scripts/tauri-mcp.sh',
256
+ })
257
+ return
258
+ }
259
+ const templatePath = resolve(frameworkRoot, 'drivers/tauri/scripts/tauri-mcp.sh')
260
+ const template = readFileSync(templatePath, 'utf-8')
261
+ atomicWriteText(destPath, renderTauriLauncher(template, targetDir))
262
+ writes.push({ host: '-', spec_id: 'tauri', kind: 'launcher', action: 'written' })
263
+ appendEvent(targetDir, 'mcp-provision.written', {
264
+ host: '-',
265
+ spec_id: 'tauri',
266
+ launcher: 'scripts/tauri-mcp.sh',
267
+ })
268
+ }
269
+
270
+ /**
271
+ * Append one audit line to .aperant/events/{date}.jsonl, mirroring the
272
+ * daily-events shape. `kind` discriminates the provisioning channel:
273
+ * `mcp_provision` for MCP-server + launcher events, `skill_provision` for
274
+ * skill-copy events.
275
+ *
276
+ * @param {string} targetDir
277
+ * @param {string} op — e.g. mcp-provision.written, skill-provision.skipped
278
+ * @param {Record<string, unknown>} [extra]
279
+ * @param {string} [kind] — audit channel (default `mcp_provision`)
280
+ */
281
+ function appendEvent(targetDir, op, extra = {}, kind = 'mcp_provision') {
282
+ try {
283
+ const path = dailyEventsPath(targetDir)
284
+ appendJsonl(path, {
285
+ ts: new Date().toISOString(),
286
+ kind,
287
+ op,
288
+ ...extra,
289
+ })
290
+ } catch {
291
+ // Audit is best-effort — never fail provisioning on a log write error.
292
+ }
293
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * install/mcp-server-specs.mjs — single-sourced install-time provision specs.
3
+ *
4
+ * One registry, two kinds (ID-03):
5
+ * - kind 'mcp-server' (electron, tauri): the {command, args, env?} the
6
+ * installer writes into each host CLI's MCP config. Keyed by the SAME
7
+ * `transport.mcp_server_id` string the driver manifests carry.
8
+ * - kind 'skill' (agent-browser): the vendored SKILL.md the installer
9
+ * copies into each host CLI's skill dir. Keyed by the SAME
10
+ * `transport.skill_id` the browser driver manifest carries.
11
+ *
12
+ * We do NOT derive commands from the driver manifests — manifests are
13
+ * transport-agnostic by design (ID-01 keeps the verb→tool mapping in the
14
+ * driver; this is a separate install-only transport/skill concern). We do
15
+ * NOT add a per-driver mcp-server.json (over-engineering for 3 specs —
16
+ * CLAUDE.md Simplicity First).
17
+ *
18
+ * Pure module — no fs, no exec.
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} McpServerSpec
23
+ * @property {'mcp-server'} kind
24
+ * @property {string} command
25
+ * @property {string[]} args
26
+ * @property {Record<string, string>} [env]
27
+ * @property {boolean} [requiresLauncherScript] — tauri: copy scripts/tauri-mcp.sh first
28
+ *
29
+ * @typedef {Object} SkillSpec
30
+ * @property {'skill'} kind
31
+ * @property {string} skillId
32
+ * @property {string} vendoredSkillRelPath — relative to the framework root
33
+ *
34
+ * @typedef {McpServerSpec | SkillSpec} ProvisionSpec
35
+ */
36
+
37
+ /**
38
+ * The install-time provision specs, keyed by spec id.
39
+ *
40
+ * `electron` / `tauri` ids match each driver manifest's
41
+ * `transport.mcp_server_id`; `agent-browser` matches the browser driver
42
+ * manifest's `transport.skill_id`.
43
+ *
44
+ * @type {Record<string, ProvisionSpec>}
45
+ */
46
+ export const PROVISION_SPECS = {
47
+ // kind 'mcp-server'
48
+ electron: {
49
+ kind: 'mcp-server',
50
+ command: 'npx',
51
+ args: ['-y', 'electron-mcp-server'],
52
+ // No SCREENSHOT_ENCRYPTION_KEY — left for the user to add per ID-09.
53
+ env: {},
54
+ },
55
+ tauri: {
56
+ kind: 'mcp-server',
57
+ // The tauri-plugin-mcp-server npm bin ships without a shebang, so it
58
+ // cannot be invoked via `npx -y`. We launch it through a generated
59
+ // scripts/tauri-mcp.sh (ID-04) — the proven racing-ai shape.
60
+ command: 'bash',
61
+ args: ['./scripts/tauri-mcp.sh'],
62
+ requiresLauncherScript: true,
63
+ },
64
+ // kind 'skill'
65
+ 'agent-browser': {
66
+ kind: 'skill',
67
+ skillId: 'agent-browser',
68
+ // Vendored UNDER the browser driver (NOT skills/agent-browser/) so the
69
+ // install pipeline's discoverCanonicalFiles does not auto-install it
70
+ // into every project unconditionally — that would break the
71
+ // verification.provision_mcp opt-out (AC7) and the web-frontend runtime
72
+ // gate. The provisioner (mcp-provision.mjs) is the sole installer.
73
+ vendoredSkillRelPath: 'drivers/browser/skill/agent-browser/SKILL.md',
74
+ },
75
+ }
76
+
77
+ /**
78
+ * Map a project's runtime capabilities to the provision spec ids it
79
+ * triggers. A multi-surface monorepo (e.g. electron + nextjs) returns
80
+ * multiple ids.
81
+ *
82
+ * is_electron → ['electron']
83
+ * is_tauri → ['tauri']
84
+ * is_web_frontend|is_nextjs|is_nuxt → ['agent-browser']
85
+ *
86
+ * Accepts a partial capability record — only the flags it reads need be
87
+ * present (the rest default to absent/false).
88
+ *
89
+ * @param {Partial<import('../util/runtime-capabilities.mjs').RuntimeCapabilities>} runtimeCaps
90
+ * @returns {string[]}
91
+ */
92
+ export function capabilityToProvisionIds(runtimeCaps) {
93
+ const ids = []
94
+ if (!runtimeCaps || typeof runtimeCaps !== 'object') return ids
95
+ if (runtimeCaps.is_electron) ids.push('electron')
96
+ if (runtimeCaps.is_tauri) ids.push('tauri')
97
+ if (runtimeCaps.is_web_frontend || runtimeCaps.is_nextjs || runtimeCaps.is_nuxt) {
98
+ ids.push('agent-browser')
99
+ }
100
+ return ids
101
+ }