@aperant/framework 0.9.0 → 0.10.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 (72) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/agents/apt-verifier.md +6 -3
  3. package/dist/cli/commands/init.d.mts.map +1 -1
  4. package/dist/cli/commands/init.mjs +17 -1
  5. package/dist/cli/commands/init.mjs.map +1 -1
  6. package/dist/cli/consistency/parse-review.mjs +5 -3
  7. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  8. package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
  9. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +10 -8
  10. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
  11. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  12. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  13. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  14. package/dist/cli/install/mcp-provision.d.mts +26 -0
  15. package/dist/cli/install/mcp-provision.d.mts.map +1 -0
  16. package/dist/cli/install/mcp-provision.mjs +281 -0
  17. package/dist/cli/install/mcp-provision.mjs.map +1 -0
  18. package/dist/cli/install/mcp-server-specs.d.mts +80 -0
  19. package/dist/cli/install/mcp-server-specs.d.mts.map +1 -0
  20. package/dist/cli/install/mcp-server-specs.mjs +102 -0
  21. package/dist/cli/install/mcp-server-specs.mjs.map +1 -0
  22. package/dist/cli/install/runtime-detect.d.mts +1 -0
  23. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  24. package/dist/cli/install/runtime-detect.mjs +5 -0
  25. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  26. package/dist/cli/install/toml-merge.d.mts +40 -0
  27. package/dist/cli/install/toml-merge.d.mts.map +1 -0
  28. package/dist/cli/install/toml-merge.mjs +134 -0
  29. package/dist/cli/install/toml-merge.mjs.map +1 -0
  30. package/dist/cli/util/events-path.d.mts +8 -0
  31. package/dist/cli/util/events-path.d.mts.map +1 -0
  32. package/dist/cli/util/events-path.mjs +23 -0
  33. package/dist/cli/util/events-path.mjs.map +1 -0
  34. package/dist/cli/util/runtime-capabilities.d.mts +30 -0
  35. package/dist/cli/util/runtime-capabilities.d.mts.map +1 -0
  36. package/dist/cli/util/runtime-capabilities.mjs +214 -0
  37. package/dist/cli/util/runtime-capabilities.mjs.map +1 -0
  38. package/dist/cli/verify-proof/audit.d.mts +2 -7
  39. package/dist/cli/verify-proof/audit.d.mts.map +1 -1
  40. package/dist/cli/verify-proof/audit.mjs +11 -17
  41. package/dist/cli/verify-proof/audit.mjs.map +1 -1
  42. package/dist/cli/verify-proof/runtime-detect.d.mts +2 -29
  43. package/dist/cli/verify-proof/runtime-detect.d.mts.map +1 -1
  44. package/dist/cli/verify-proof/runtime-detect.mjs +8 -203
  45. package/dist/cli/verify-proof/runtime-detect.mjs.map +1 -1
  46. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  47. package/dist/plugin/agents/apt-verifier.md +6 -3
  48. package/dist/plugin/skills/apt-pr-review/SKILL.md +54 -0
  49. package/dist/plugin/skills/apt-verify/SKILL.md +1 -1
  50. package/dist/plugin/skills/apt-verify-proof/SKILL.md +25 -1
  51. package/drivers/browser/skill/agent-browser/SKILL.md +51 -0
  52. package/drivers/tauri/README.md +107 -0
  53. package/drivers/tauri/driver.mjs +88 -0
  54. package/drivers/tauri/manifest.json +37 -0
  55. package/drivers/tauri/scripts/tauri-mcp.sh +41 -0
  56. package/package.json +1 -1
  57. package/skills/apt-pr-review/SKILL.md +54 -0
  58. package/skills/apt-verify/SKILL.md +1 -1
  59. package/skills/apt-verify-proof/SKILL.md +25 -1
  60. package/src/cli/commands/init.mjs +19 -0
  61. package/src/cli/consistency/parse-review.mjs +5 -3
  62. package/src/cli/consistency/rules/r5-verdict-consistency.mjs +14 -9
  63. package/src/cli/gate/gates/review-clean.mjs +4 -2
  64. package/src/cli/install/mcp-provision.mjs +293 -0
  65. package/src/cli/install/mcp-server-specs.mjs +101 -0
  66. package/src/cli/install/runtime-detect.mjs +9 -0
  67. package/src/cli/install/toml-merge.mjs +139 -0
  68. package/src/cli/util/events-path.mjs +24 -0
  69. package/src/cli/util/runtime-capabilities.mjs +216 -0
  70. package/src/cli/verify-proof/audit.mjs +11 -17
  71. package/src/cli/verify-proof/runtime-detect.mjs +11 -204
  72. package/templates/config.json +2 -1
@@ -0,0 +1,37 @@
1
+ {
2
+ "driverId": "tauri",
3
+ "version": "0.1.0",
4
+ "targets": ["tauri@>=1"],
5
+ "capabilities": [
6
+ "click",
7
+ "type",
8
+ "key",
9
+ "navigate",
10
+ "screenshot",
11
+ "assert_visible",
12
+ "assert_text",
13
+ "wait_for_idle",
14
+ "execute_js",
15
+ "dump_state"
16
+ ],
17
+ "evidence_capabilities": ["screenshot", "dom_dump", "capture_logs"],
18
+ "frameworkApiRange": "^1.0.0",
19
+ "priority": 85,
20
+ "stability": "ga",
21
+ "trust": {
22
+ "tier": "first_party_signed",
23
+ "publisher": {
24
+ "name": "Aperant",
25
+ "npm_scope": "@aperant",
26
+ "github_org": "Mikalsen-AI",
27
+ "verified": true
28
+ }
29
+ },
30
+ "security": {
31
+ "sandbox_profile": "workspace_write",
32
+ "network": "deny",
33
+ "requires_user_approval": false
34
+ },
35
+ "audit": { "log_invocations": true, "log_manifest_hash": true },
36
+ "transport": { "mcp_server_id": "tauri" }
37
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # Launches the tauri-plugin-mcp-server with the project's .env injected.
3
+ # Generated by `apt-tools init` (Aperant Framework) and referenced from the
4
+ # host CLI's MCP config as: { command: "bash", args: ["./scripts/tauri-mcp.sh"] }
5
+ #
6
+ # This launcher exists because the tauri-plugin-mcp-server npm bin ships
7
+ # WITHOUT a shebang, so `npx -y tauri-plugin-mcp-server` runs the .js as a
8
+ # shell script and fails. We invoke the bin via `node` with a stable path.
9
+ #
10
+ # Idempotent: this file is never overwritten once present (edit it freely).
11
+
12
+ set -euo pipefail
13
+
14
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
15
+
16
+ # Load .env (gitignored) WITHOUT executing it. We do NOT dot-source the file
17
+ # (that would run any `$(...)`, backticks, or function defs it contains as
18
+ # shell). Instead parse it line-by-line: skip blank/comment lines, and export
19
+ # only well-formed `KEY=VALUE` assignments whose key is a valid shell name.
20
+ if [[ -f "$ROOT/.env" ]]; then
21
+ while IFS= read -r line || [[ -n "$line" ]]; do
22
+ # Strip a leading `export ` prefix if present.
23
+ line="${line#export }"
24
+ # Skip blank lines and comments.
25
+ [[ -z "$line" || "$line" == \#* ]] && continue
26
+ # Only accept lines of the form NAME=VALUE (bash-style identifier name).
27
+ if [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
28
+ key="${line%%=*}"
29
+ value="${line#*=}"
30
+ export "$key=$value"
31
+ fi
32
+ done < "$ROOT/.env"
33
+ fi
34
+
35
+ export TAURI_MCP_CONNECTION_TYPE="${TAURI_MCP_CONNECTION_TYPE:-ipc}"
36
+ export TAURI_MCP_IPC_PATH="${TAURI_MCP_IPC_PATH:-$HOME/.{{PROJECT_SOCKET}}-mcp.sock}"
37
+
38
+ # Install the transport package as a devDependency first:
39
+ # pnpm add -D tauri-plugin-mcp-server
40
+ # and add the tauri-plugin-mcp Rust plugin to your app (see drivers/tauri/README.md).
41
+ exec node "$ROOT/node_modules/tauri-plugin-mcp-server/build/index.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aperant/framework",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "AI coding framework — composable skills for planning, executing, verifying, and reviewing code with any LLM provider. Works as Claude Code commands, Codex tasks, or programmatically.",
5
5
  "author": "Mikalsen AI <hello@mikalsen.ai>",
6
6
  "type": "module",
@@ -469,6 +469,55 @@ fi
469
469
 
470
470
  After this point, the per-round artifact root is `.aperant/pr-reviews/pr-${PR_NUMBER}/rounds/${ROUND}/` (the `${REVIEW_ID}` is still used for the git worktree branch and temporary files). Subsequent `git diff` invocations within the round use `${PINNED_HEAD_SHA}` as the head ref.
471
471
 
472
+ ### Step 4c: Mergeability Pre-Check (PR reviews only)
473
+
474
+ **Why this exists.** The review diff is `git diff {BASE}...{HEAD}` (three-dot — from the *merge-base*), so it shows the PR's own changes relative to where it branched. It is **blind to drift on `{BASE}` since then** and therefore says nothing about whether the PR still merges cleanly. A PR can pass a glowing review and be unmergeable. This step closes that gap: it is **informational, never blocking** (the review still runs in full), but a conflicting/stale state MUST be surfaced so the reader doesn't trust a review of a diff that no longer applies.
475
+
476
+ ```bash
477
+ # PR-mode only. Query GitHub's authoritative mergeability + measure drift.
478
+ if [ -n "$PR_NUMBER" ]; then
479
+ MERGE_JSON=$(gh pr view ${PR_NUMBER} --json mergeable,mergeStateStatus,baseRefName 2>/dev/null)
480
+ MERGEABLE=$(echo "$MERGE_JSON" | jq -r '.mergeable // "UNKNOWN"')
481
+ MERGE_STATE=$(echo "$MERGE_JSON" | jq -r '.mergeStateStatus // "UNKNOWN"')
482
+ BASE_REF=$(echo "$MERGE_JSON" | jq -r '.baseRefName // "main"')
483
+ git fetch origin "${BASE_REF}" 2>/dev/null
484
+ BEHIND=$(git rev-list --count ${PINNED_HEAD_SHA}..origin/${BASE_REF} 2>/dev/null || echo "?")
485
+ AHEAD=$(git rev-list --count origin/${BASE_REF}..${PINNED_HEAD_SHA} 2>/dev/null || echo "?")
486
+
487
+ # If GitHub reports a conflict (or hasn't computed yet), enumerate the conflicting
488
+ # files locally without touching any working tree (git >= 2.38):
489
+ CONFLICT_FILES=""
490
+ if [ "$MERGEABLE" = "CONFLICTING" ] || [ "$MERGE_STATE" = "DIRTY" ]; then
491
+ CONFLICT_FILES=$(git merge-tree --write-tree --name-only origin/${BASE_REF} ${PINNED_HEAD_SHA} 2>/dev/null \
492
+ | tail -n +2 | sed '/^$/d' | grep '/' )
493
+ fi
494
+ fi
495
+ ```
496
+
497
+ Record the result in `metadata.json` under a `mergeability` key:
498
+
499
+ ```json
500
+ {
501
+ "mergeability": {
502
+ "mergeable": "CONFLICTING | MERGEABLE | UNKNOWN",
503
+ "merge_state_status": "DIRTY | CLEAN | BLOCKED | BEHIND | ...",
504
+ "base_ref": "main",
505
+ "commits_behind_base": 101,
506
+ "commits_ahead_base": 91,
507
+ "conflicting_files": ["apps/.../App.tsx", "..."]
508
+ }
509
+ }
510
+ ```
511
+
512
+ **How to use the result:**
513
+
514
+ - `MERGEABLE` ∈ {`CONFLICTING`} OR `MERGE_STATE` = `DIRTY` → the PR has conflicts. Surface a **⚠️ Mergeability** block in the Phase 5 intermediate report AND the Phase 8 final report, listing `commits_behind_base` and the `conflicting_files`. In **external / comment-only** mode, include the same block in the Phase 6b comment. State plainly that the branch needs a rebase/merge from `{BASE}` and that **the reviewed diff may be stale** with respect to current `{BASE}`.
515
+ - `MERGE_STATE` = `BEHIND` (mergeable but behind) → note the drift (`commits_behind_base`) as a softer advisory; no conflict, but the diff predates recent `{BASE}` commits.
516
+ - `MERGEABLE` = `UNKNOWN` (GitHub hasn't finished computing, common right after a push) → record `UNKNOWN`; optionally re-poll once after a few seconds. Do not block.
517
+ - `MERGEABLE` = `MERGEABLE`, `MERGE_STATE` = `CLEAN` → record it and move on silently.
518
+
519
+ This step never changes the review's findings or assessment — a conflicting PR with zero code findings is still `approve` on its own merits. Mergeability is reported as a **separate** signal so the reader knows whether the approval can actually land.
520
+
472
521
  ### Step 5: Read All Changed Files
473
522
 
474
523
  For every file in the changed file list, read it in full using the Read tool **from the worktree**:
@@ -1058,6 +1107,10 @@ Review Synthesis (Before Auto-Fix)
1058
1107
  Auto-Checks: {PASS/FAIL}
1059
1108
  lint: {P/F} | typecheck: {P/F} | tests: {P/F}
1060
1109
 
1110
+ Mergeability (PR reviews — from Step 4c): {MERGEABLE/CONFLICTING/UNKNOWN}
1111
+ {if CONFLICTING/DIRTY: "⚠️ conflicts with {BASE} — {behind} commits behind — needs rebase — reviewed diff may be stale"}
1112
+ {if conflicting_files: list them}
1113
+
1061
1114
  Validated Findings:
1062
1115
  CRITICAL: {N}
1063
1116
  HIGH: {N}
@@ -1630,6 +1683,7 @@ Output the human-readable summary:
1630
1683
 
1631
1684
  Review ID: {REVIEW_ID}
1632
1685
  Assessment: {APPROVE / REQUEST CHANGES / COMMENT}
1686
+ Mergeability: {MERGEABLE / CONFLICTING (N behind {BASE}, needs rebase) / UNKNOWN} # PR reviews — from Step 4c / metadata.json.mergeability
1633
1687
 
1634
1688
  Scores:
1635
1689
  Security: {score}/10
@@ -60,7 +60,7 @@ Before spawning any verifier subagent or running scoring against acceptance crit
60
60
  </locked_decisions>
61
61
  ```
62
62
 
63
- Verifiers apply this digest when interpreting "completeness" and "correctness" — if a decision explicitly deferred a behavior, its absence is NOT a verification failure.
63
+ Verifiers apply this digest when interpreting "completeness" and "correctness" — if a decision explicitly deferred a behavior AND the deferred finding has severity below critical or major, its absence is NOT a verification failure. A deferred critical or major finding IS a Completeness gap — score accordingly and note it in the issues list.
64
64
  </decision_ledger_inject>
65
65
 
66
66
  <process>
@@ -20,6 +20,7 @@ gates: []
20
20
  config_keys:
21
21
  - verification.open_report
22
22
  - verification.report_formats
23
+ - verification.provision_mcp
23
24
  ---
24
25
 
25
26
  # Proof Verification
@@ -104,13 +105,36 @@ disable the walk entirely (root-only behaviour, backwards compat).
104
105
  The `verification.runtimes[]` override stays the final escape hatch
105
106
  and is now usually unnecessary because the walk runs by default.
106
107
 
107
- ### Bundled drivers (4)
108
+ ### Bundled drivers (5)
108
109
 
109
110
  - **browser** (priority=100, ga) — Next.js / vanilla web frontends.
110
111
  - **electron** (priority=90, ga) — Electron desktop apps.
112
+ - **tauri** (priority=85, ga) — Tauri desktop apps.
111
113
  - **cli** (priority=60, ga) — headless CI, output-shape assertions.
112
114
  - **api** (priority=50, ga) — HTTP / JSON-API regression tests.
113
115
 
116
+ ### Install-time MCP provisioning (verification.provision_mcp)
117
+
118
+ `apt-tools init` (and `/apt:update`, which routes through the same path)
119
+ auto-provisions the MCP transport a driver needs into each SELECTED host
120
+ CLI's config, so verify-proof works at the prompt with no manual MCP
121
+ setup. It detects the project runtime and, per host:
122
+
123
+ - **claude** → JSON-merges the server into the project-root `.mcp.json`
124
+ (`mcpServers.<id>`).
125
+ - **codex** → upserts a `[mcp_servers.<id>]` table into the project-root
126
+ `.codex/config.toml`.
127
+
128
+ Electron and Tauri projects get the matching MCP server; web frontends
129
+ get the browser driver's declared `agent-browser` skill copied into the
130
+ host skill dir (`.claude/skills/` / `.agents/skills/`) — its `agent-browser`
131
+ CLI is a documented one-time prereq, the same class as the
132
+ `electron-mcp-server` / `tauri-plugin-mcp-server` npm packages (see each
133
+ driver's README). All writes are **TOFU/non-clobbering**: a pre-existing
134
+ server id, config table, or skill file is never overwritten. Opt out with
135
+ `verification.provision_mcp: false` (default `true`) — then init/update
136
+ writes no host MCP config and no skill.
137
+
114
138
  ### Driver authors
115
139
 
116
140
  Third-party driver authors should run `apt-tools driver doctor .` before
@@ -32,6 +32,7 @@ import { deriveSidecar } from '../bootstrap/product-context-sidecar.mjs'
32
32
  import { assertConfigWriteAllowed } from '../config/guard.mjs'
33
33
  import {
34
34
  AUDIT_001_LOCAL_FIELDS,
35
+ loadMergedProjectConfig,
35
36
  migrateDeferredFieldsToLocalConfig,
36
37
  writeLocalConfigField,
37
38
  } from '../config/load.mjs'
@@ -42,6 +43,7 @@ import { detectPublicRemote } from '../git/remote.mjs'
42
43
  import { detectArchetype } from '../install/archetype.mjs'
43
44
  import { installKernel } from '../install/install-kernel.mjs'
44
45
  import { MANIFEST_FILENAME } from '../install/manifest.mjs'
46
+ import { provisionMcpConfig } from '../install/mcp-provision.mjs'
45
47
  import {
46
48
  discoverCanonicalFiles,
47
49
  runInstall,
@@ -1304,6 +1306,23 @@ export async function cmdInit(projectDir, extraArgs = []) {
1304
1306
  if (envelope?.__earlyExit) {
1305
1307
  return envelope.__earlyExit
1306
1308
  }
1309
+
1310
+ // Auto-provision host MCP-server config + the agent-browser skill for
1311
+ // the detected runtime (C38). Runs on BOTH the fresh install AND the
1312
+ // no-drift /apt:update upgrade path (both reach finishInit). finishInit
1313
+ // has already written .aperant/config.json from the template, so the
1314
+ // merged config carries verification.provision_mcp (default true) — the
1315
+ // opt-out gate. TOFU/non-clobbering; never throws.
1316
+ const mergedConfig = loadMergedProjectConfig(installTarget)
1317
+ const mcpProvision = provisionMcpConfig({
1318
+ targetDir: installTarget,
1319
+ selectedRuntimes: effectiveAnswers.runtimes,
1320
+ config: mergedConfig,
1321
+ frameworkRoot: canonicalRoot,
1322
+ version,
1323
+ })
1324
+ envelope.mcp_provision = mcpProvision
1325
+
1307
1326
  if (interactiveTty) {
1308
1327
  await renderOutro(effectiveAnswers, results, version, kernelResult)
1309
1328
  // TTY mode: envelope was persisted to disk + suppressed from stdout
@@ -97,9 +97,11 @@ function normalizeStatus(f) {
97
97
  if (STATUS_MAP[s]) return STATUS_MAP[s]
98
98
  // BUG-009: reviewer-skill writes `deferred-per-spec`, `deferred-followup`,
99
99
  // etc. for findings that are intentionally not being fixed in the
100
- // current scope. Collapse the variant prefix back to canonical
101
- // `deferred` so downstream gates (review-clean, ship) treat them
102
- // the same as a bare `status: "deferred"`.
100
+ // current scope. Collapse the variant prefix back to canonical `deferred`.
101
+ // Note: for blocking severities (critical/major), `deferred` no longer
102
+ // clears review-clean or R5 (G42 Phase 1). Only `status: "fixed"` clears
103
+ // those gates. Non-blocking severities (suggestion/note) are dropped by
104
+ // normalizeSeverity and never reach the blocking filter.
103
105
  if (s.startsWith('deferred-')) return 'deferred'
104
106
  }
105
107
  return 'open'
@@ -3,12 +3,13 @@
3
3
  * consistency/rules/r5-verdict-consistency.mjs — R5 (C15).
4
4
  *
5
5
  * Fails when:
6
- * - qa.verdict === 'approved' AND review has >=1 unresolved critical finding
6
+ * - qa.verdict === 'approved' or 'approved-with-notes' AND review has >=1
7
+ * unresolved critical or major finding
7
8
  * - qa.verdict === 'rejected' AND qa has no findings at all AND review has
8
9
  * zero unresolved findings (rejected-without-evidence)
9
10
  *
10
- * `approved-with-notes` is the escape hatch passes regardless of unresolved
11
- * criticals in review.json.
11
+ * `approved-with-notes` no longer escapes unresolved critical or major
12
+ * findings (G42 Phase 1). It still passes for lower-severity issues.
12
13
  *
13
14
  * Severity: error. Stages: verify, ship.
14
15
  */
@@ -26,23 +27,27 @@ const rule = {
26
27
  }
27
28
  const verdict = parsed.qa.verdict ?? 'unknown'
28
29
  const reviewFindings = parsed?.review?.findings ?? []
29
- const unresolvedCritical = reviewFindings.filter(
30
- (f) => f.severity === 'critical' && f.status !== 'fixed',
30
+ const unresolvedBlocking = reviewFindings.filter(
31
+ (f) => (f.severity === 'critical' || f.severity === 'major') && f.status !== 'fixed',
31
32
  )
32
- if (verdict === 'approved' && unresolvedCritical.length > 0) {
33
+ if (
34
+ (verdict === 'approved' || verdict === 'approved-with-notes') &&
35
+ unresolvedBlocking.length > 0
36
+ ) {
33
37
  return {
34
38
  status: 'fail',
35
39
  drift: [
36
40
  {
37
41
  verdict,
38
- blockers: unresolvedCritical.map((f) => ({
42
+ blockers: unresolvedBlocking.map((f) => ({
39
43
  id: f.id,
40
44
  title: f.title,
41
45
  })),
42
- reason: 'approved verdict with unresolved critical finding(s)',
46
+ reason:
47
+ 'approved/approved-with-notes verdict with unresolved critical or major finding(s) (G42 Phase 1)',
43
48
  },
44
49
  ],
45
- reason: `approved verdict contradicts ${unresolvedCritical.length} unresolved critical finding(s)`,
50
+ reason: `approved/approved-with-notes verdict contradicts ${unresolvedBlocking.length} unresolved critical/major finding(s)`,
46
51
  }
47
52
  }
48
53
  if (verdict === 'rejected') {
@@ -8,7 +8,9 @@
8
8
  * shapes the reviewer skill writes: `fixed: true`, `resolved: true`,
9
9
  * `status: "fixed"` (string), and `status: "deferred-*"` variants
10
10
  * (BUG-009). Fails if any post-normalization finding is severity
11
- * critical|major AND status is neither `fixed` nor `deferred`.
11
+ * critical|major AND status is not `fixed`. `deferred` no longer clears
12
+ * blocking findings (G42 Phase 1 — only `--force --reason` override may
13
+ * bypass blocking severity deferrals).
12
14
  */
13
15
 
14
16
  import { existsSync, readFileSync } from 'node:fs'
@@ -55,7 +57,7 @@ const gate = {
55
57
  // hole and picks up `deferred-*` variants for free.
56
58
  const { findings: normalized } = parseReview(doc)
57
59
  const unresolved = normalized.filter(
58
- (f) => POST_NORM_BLOCKING.has(f.severity) && f.status !== 'fixed' && f.status !== 'deferred',
60
+ (f) => POST_NORM_BLOCKING.has(f.severity) && f.status !== 'fixed',
59
61
  )
60
62
  if (unresolved.length > 0) {
61
63
  return {
@@ -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
+ }