@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,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
+ }
@@ -18,6 +18,15 @@ import { existsSync, statSync } from 'node:fs'
18
18
  import { join } from 'node:path'
19
19
  import { MANIFEST_FILENAME } from './manifest.mjs'
20
20
 
21
+ // Package.json-shape capability detection lives in util/ (shared with
22
+ // verify-proof/runtime-detect.mjs). Re-exported here so install-layer callers
23
+ // (e.g. install/mcp-provision.mjs) consume it from their OWN layer instead of
24
+ // reaching across into verify-proof/.
25
+ export {
26
+ detectFromPackageJson,
27
+ loadRuntimeCapabilities,
28
+ } from '../util/runtime-capabilities.mjs'
29
+
21
30
  /**
22
31
  * @typedef {Object} RuntimeDescriptor
23
32
  * @property {string} id Stable identifier (`claude`, `cursor`, …).
@@ -0,0 +1,139 @@
1
+ /**
2
+ * install/toml-merge.mjs — dependency-free minimal TOML upsert for the
3
+ * Codex MCP-server config table.
4
+ *
5
+ * Codex's MCP config lives at project-root `.codex/config.toml` as:
6
+ *
7
+ * [mcp_servers.<id>]
8
+ * command = "bash"
9
+ * args = ["./scripts/tauri-mcp.sh"]
10
+ *
11
+ * [mcp_servers.<id>.env]
12
+ * FOO = "bar"
13
+ *
14
+ * No TOML serializer ships in the framework and `@iarna/toml` is not a
15
+ * dependency, so we hand-roll the MINIMAL upsert covering ONLY this shape
16
+ * (string command, string[] args, optional nested env table of
17
+ * string=string). Presence is detected by scanning for the
18
+ * `[mcp_servers.<id>]` header line; if present, we SKIP unchanged (TOFU
19
+ * per ID-05) — never diff/merge/overwrite a hand-authored table. All other
20
+ * content is preserved verbatim; the new table block is appended at EOF.
21
+ *
22
+ * Pure module — string in, string out.
23
+ */
24
+
25
+ /**
26
+ * Escape a string for a TOML basic (double-quoted) string. Only the
27
+ * characters that can occur in a command/arg/env value: backslash and
28
+ * double-quote.
29
+ *
30
+ * @param {string} s
31
+ * @returns {string}
32
+ */
33
+ function tomlString(s) {
34
+ return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
35
+ }
36
+
37
+ /**
38
+ * Render a TOML array of double-quoted strings: `["a", "b"]`.
39
+ *
40
+ * @param {string[]} arr
41
+ * @returns {string}
42
+ */
43
+ function tomlStringArray(arr) {
44
+ return `[${arr.map((a) => tomlString(a)).join(', ')}]`
45
+ }
46
+
47
+ /**
48
+ * Render a TOML key. A bare key (TOML `[A-Za-z0-9_-]+`) is emitted verbatim;
49
+ * any other key is quoted as a TOML basic string so a non-bare env name
50
+ * (e.g. one containing `.`, a space, or `"`) cannot break the table or inject
51
+ * extra structure.
52
+ *
53
+ * @param {string} key
54
+ * @returns {string}
55
+ */
56
+ function tomlKey(key) {
57
+ return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key)
58
+ }
59
+
60
+ /**
61
+ * Whether `existingToml` already declares a `[mcp_servers.<id>]` table.
62
+ * Matches the header at the start of a line (ignoring leading whitespace),
63
+ * permitting an optional TOML trailing comment (` # …`) after the header —
64
+ * a hand-authored `[mcp_servers.tauri] # my server` must still count as
65
+ * present, otherwise the upsert would append a DUPLICATE table and produce
66
+ * an unparseable config (two same-named tables). The id is taken literally
67
+ * (dotted-key TOML ids do not occur for our server ids — electron/tauri are
68
+ * bare) and regex-escaped defensively. A `#`-prefixed comment LINE that
69
+ * merely mentions the header is NOT a match (the header must be the first
70
+ * non-whitespace token).
71
+ *
72
+ * @param {string} existingToml
73
+ * @param {string} serverId
74
+ * @returns {boolean}
75
+ */
76
+ export function hasMcpServerTable(existingToml, serverId) {
77
+ if (typeof existingToml !== 'string' || existingToml.length === 0) return false
78
+ const escapedId = serverId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
79
+ // `[mcp_servers.<id>]` at line start (after optional indent), optionally
80
+ // followed by whitespace and/or a `#` trailing comment, to EOL.
81
+ const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapedId}\\]\\s*(#.*)?$`)
82
+ const lines = existingToml.split(/\r?\n/)
83
+ for (const line of lines) {
84
+ if (headerRe.test(line)) return true
85
+ }
86
+ return false
87
+ }
88
+
89
+ /**
90
+ * Upsert a `[mcp_servers.<id>]` table into a Codex config.toml string.
91
+ *
92
+ * TOFU (ID-05): if a `[mcp_servers.<id>]` table already exists, returns the
93
+ * input unchanged with `action: 'skipped'`. Otherwise appends the new table
94
+ * (and a nested `[mcp_servers.<id>.env]` table when `env` is non-empty) at
95
+ * EOF, preserving all existing content verbatim, and returns the new
96
+ * content with `action: 'written'`.
97
+ *
98
+ * @param {string} existingToml — current file contents ('' when the file is absent)
99
+ * @param {string} serverId
100
+ * @param {{command: string, args: string[], env?: Record<string, string>}} spec
101
+ * @returns {{content: string, action: 'written'|'skipped'}}
102
+ */
103
+ export function upsertMcpServerToml(existingToml, serverId, spec) {
104
+ const existing = typeof existingToml === 'string' ? existingToml : ''
105
+ if (hasMcpServerTable(existing, serverId)) {
106
+ return { content: existing, action: 'skipped' }
107
+ }
108
+
109
+ const lines = []
110
+ lines.push(`[mcp_servers.${serverId}]`)
111
+ lines.push(`command = ${tomlString(spec.command)}`)
112
+ lines.push(`args = ${tomlStringArray(spec.args ?? [])}`)
113
+
114
+ const env = spec.env && typeof spec.env === 'object' ? spec.env : null
115
+ const envKeys = env ? Object.keys(env) : []
116
+ if (envKeys.length > 0) {
117
+ lines.push('')
118
+ lines.push(`[mcp_servers.${serverId}.env]`)
119
+ for (const key of envKeys) {
120
+ lines.push(`${tomlKey(key)} = ${tomlString(env[key])}`)
121
+ }
122
+ }
123
+
124
+ const block = lines.join('\n')
125
+
126
+ // Preserve existing content; separate the new block with exactly one blank
127
+ // line. An empty existing file yields just the block + trailing newline.
128
+ // Strip the existing file's trailing newline run first so a file that
129
+ // already ends in a blank line (`…\n\n`) does not produce a DOUBLE blank
130
+ // line before the appended block.
131
+ let content
132
+ if (existing.trim().length === 0) {
133
+ content = `${block}\n`
134
+ } else {
135
+ const head = existing.replace(/\n+$/, '')
136
+ content = `${head}\n\n${block}\n`
137
+ }
138
+ return { content, action: 'written' }
139
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * util/events-path.mjs — canonical path computation for the per-day event
3
+ * log at `.aperant/events/{YYYY-MM-DD}.jsonl` (ID-05).
4
+ *
5
+ * Lives in util/ so both the install layer (install/mcp-provision.mjs) and the
6
+ * verify-proof layer (verify-proof/audit.mjs) compute the same path WITHOUT
7
+ * install/ importing from verify-proof/ (the layer-boundary rule). Pure
8
+ * function — no fs, no exec.
9
+ */
10
+
11
+ import { join, resolve } from 'node:path'
12
+
13
+ /**
14
+ * Compute today's events filename relative to projectDir.
15
+ * @param {string} projectDir
16
+ * @param {Date} [now]
17
+ * @returns {string}
18
+ */
19
+ export function dailyEventsPath(projectDir, now = new Date()) {
20
+ const yyyy = now.getUTCFullYear()
21
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0')
22
+ const dd = String(now.getUTCDate()).padStart(2, '0')
23
+ return join(resolve(projectDir), '.aperant/events', `${yyyy}-${mm}-${dd}.jsonl`)
24
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * util/runtime-capabilities.mjs — pure project-shape capability detection.
3
+ *
4
+ * Extracted from packages/core/src/prompts/prompt-loader.ts:572-595 so the
5
+ * framework CLI is self-contained (no @aperant/core dep). Same heuristics:
6
+ * read package.json's dependencies + devDependencies, return a capability
7
+ * record the resolver can score against.
8
+ *
9
+ * Lives in util/ so BOTH the verify-proof layer (verify-proof/runtime-detect.mjs
10
+ * re-exports it) AND the install layer (install/runtime-detect.mjs re-exports
11
+ * it) can share one implementation without install/ importing from
12
+ * verify-proof/ (the layer-boundary rule).
13
+ *
14
+ * Pattern: pure data in → pure data out. No filesystem walking, no exec.
15
+ * Use loadRuntimeCapabilities(projectDir) for the cwd-rooted variant.
16
+ *
17
+ * v0.8.6 — `loadRuntimeCapabilities` now ALSO walks `apps/*` by default
18
+ * (configurable via the second arg `{monorepo_globs}`) and OR-merges
19
+ * each match's `package.json` capabilities with the root. The default
20
+ * `['apps/*']` matches the canonical Aperant monorepo layout; adopters
21
+ * whose monorepo lives elsewhere (e.g. `packages/apps/*`) pass a
22
+ * custom `monorepo_globs`. Setting `monorepo_globs: []` disables the
23
+ * walk entirely (root-only escape hatch).
24
+ */
25
+
26
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
27
+ import { join, resolve } from 'node:path'
28
+
29
+ /**
30
+ * @typedef {Object} RuntimeCapabilities
31
+ * @property {boolean} is_electron
32
+ * @property {boolean} is_tauri
33
+ * @property {boolean} is_nextjs
34
+ * @property {boolean} is_nuxt
35
+ * @property {boolean} is_web_frontend
36
+ * @property {boolean} is_expo
37
+ * @property {boolean} is_react_native
38
+ * @property {boolean} has_node
39
+ */
40
+
41
+ /** A capability record with every flag defaulted to false. */
42
+ function emptyCapabilities() {
43
+ return {
44
+ is_electron: false,
45
+ is_tauri: false,
46
+ is_nextjs: false,
47
+ is_nuxt: false,
48
+ is_web_frontend: false,
49
+ is_expo: false,
50
+ is_react_native: false,
51
+ has_node: false,
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Detect runtime capabilities from a parsed package.json object.
57
+ *
58
+ * @param {Record<string, unknown> | null} packageJson
59
+ * @returns {RuntimeCapabilities}
60
+ */
61
+ export function detectFromPackageJson(packageJson) {
62
+ const capabilities = emptyCapabilities()
63
+ if (packageJson === null || typeof packageJson !== 'object') return capabilities
64
+
65
+ const allDeps = {
66
+ ...(packageJson.dependencies ?? {}) /** @type {Record<string,string>} */,
67
+ ...(packageJson.devDependencies ?? {}) /** @type {Record<string,string>} */,
68
+ }
69
+ const deps = new Set(Object.keys(allDeps).map((k) => k.toLowerCase()))
70
+
71
+ if (deps.has('electron') || [...deps].some((d) => d.startsWith('@electron'))) {
72
+ capabilities.is_electron = true
73
+ }
74
+ if (deps.has('@tauri-apps/api') || deps.has('tauri')) {
75
+ capabilities.is_tauri = true
76
+ }
77
+ if (deps.has('expo')) capabilities.is_expo = true
78
+ if (deps.has('react-native')) capabilities.is_react_native = true
79
+
80
+ // Web frontends — react/vue/svelte/angular/solid → frontend. BUT: an
81
+ // Electron app commonly bundles React as its renderer; in that case the
82
+ // runtime is "electron", not "web_frontend". Electron + react-native +
83
+ // expo + tauri all win over the web flag.
84
+ const isNativeContainer =
85
+ capabilities.is_electron ||
86
+ capabilities.is_tauri ||
87
+ capabilities.is_react_native ||
88
+ capabilities.is_expo
89
+ const webFrameworks = ['react', 'vue', 'svelte', 'angular', 'solid']
90
+ if (!isNativeContainer && webFrameworks.some((f) => deps.has(f))) {
91
+ capabilities.is_web_frontend = true
92
+ }
93
+
94
+ if (deps.has('next')) {
95
+ capabilities.is_nextjs = true
96
+ capabilities.is_web_frontend = true
97
+ }
98
+ if (deps.has('nuxt')) {
99
+ capabilities.is_nuxt = true
100
+ capabilities.is_web_frontend = true
101
+ }
102
+ if (deps.has('vite') && !isNativeContainer) {
103
+ capabilities.is_web_frontend = true
104
+ }
105
+
106
+ // Node-ish? Anything with a package.json + an entry point counts.
107
+ if (typeof packageJson.main === 'string' || typeof packageJson.module === 'string') {
108
+ capabilities.has_node = true
109
+ }
110
+ if (!capabilities.has_node && packageJson.scripts && typeof packageJson.scripts === 'object') {
111
+ capabilities.has_node = true
112
+ }
113
+
114
+ return capabilities
115
+ }
116
+
117
+ /**
118
+ * Load and detect capabilities from a project directory's package.json.
119
+ * v0.8.6 — also walks `monorepo_globs` (default `['apps/*']`) and
120
+ * OR-merges each match's capabilities with the root.
121
+ *
122
+ * @param {string} projectDir
123
+ * @param {{monorepo_globs?: string[]}} [opts]
124
+ * @returns {RuntimeCapabilities}
125
+ */
126
+ export function loadRuntimeCapabilities(projectDir, opts) {
127
+ const root = resolve(projectDir)
128
+ const rootPkgPath = join(root, 'package.json')
129
+ let rootCaps = emptyCapabilities()
130
+ if (existsSync(rootPkgPath)) {
131
+ try {
132
+ rootCaps = detectFromPackageJson(JSON.parse(readFileSync(rootPkgPath, 'utf-8')))
133
+ } catch {
134
+ /* fall through with empty caps */
135
+ }
136
+ }
137
+
138
+ // Default to the canonical Aperant monorepo layout. Passing `[]`
139
+ // disables the walk entirely (root-only escape hatch per spec ID-05).
140
+ const globs = opts && Array.isArray(opts.monorepo_globs) ? opts.monorepo_globs : ['apps/*']
141
+ if (globs.length === 0) return rootCaps
142
+
143
+ let merged = rootCaps
144
+ for (const glob of globs) {
145
+ const matches = expandSimpleGlob(root, glob)
146
+ for (const dir of matches) {
147
+ const childPkgPath = join(dir, 'package.json')
148
+ if (!existsSync(childPkgPath)) continue
149
+ try {
150
+ const childCaps = detectFromPackageJson(JSON.parse(readFileSync(childPkgPath, 'utf-8')))
151
+ merged = orMergeCapabilities(merged, childCaps)
152
+ } catch {
153
+ /* skip malformed package.json */
154
+ }
155
+ }
156
+ }
157
+ return merged
158
+ }
159
+
160
+ /**
161
+ * Expand a simple `prefix/*` glob against the root. Supports ONLY the
162
+ * `<dir>/*` shape (matches each direct subdirectory of `<dir>`). Other
163
+ * glob syntax is intentionally rejected — v1 keeps the dep surface
164
+ * small. Fails closed on malformed patterns (returns []).
165
+ *
166
+ * @param {string} root
167
+ * @param {string} glob
168
+ * @returns {string[]}
169
+ */
170
+ function expandSimpleGlob(root, glob) {
171
+ if (typeof glob !== 'string' || !glob.endsWith('/*')) return []
172
+ const prefix = glob.slice(0, -2)
173
+ if (prefix.length === 0 || prefix.includes('*')) return []
174
+ const dir = join(root, prefix)
175
+ if (!existsSync(dir)) return []
176
+ let entries
177
+ try {
178
+ entries = readdirSync(dir)
179
+ } catch {
180
+ return []
181
+ }
182
+ const matches = []
183
+ for (const name of entries) {
184
+ const sub = join(dir, name)
185
+ try {
186
+ if (statSync(sub).isDirectory()) matches.push(sub)
187
+ } catch {
188
+ /* skip unreadable entries */
189
+ }
190
+ }
191
+ return matches
192
+ }
193
+
194
+ /**
195
+ * Boolean OR-merge of two capability records. Any flag true in either
196
+ * input is true in the output. The "Electron wins over web" rule from
197
+ * `detectFromPackageJson` is preserved when this is called repeatedly —
198
+ * each child's flags are honest about that child's own shape, and the
199
+ * OR-merge just unions them.
200
+ *
201
+ * @param {RuntimeCapabilities} a
202
+ * @param {RuntimeCapabilities} b
203
+ * @returns {RuntimeCapabilities}
204
+ */
205
+ function orMergeCapabilities(a, b) {
206
+ return {
207
+ is_electron: a.is_electron || b.is_electron,
208
+ is_tauri: a.is_tauri || b.is_tauri,
209
+ is_nextjs: a.is_nextjs || b.is_nextjs,
210
+ is_nuxt: a.is_nuxt || b.is_nuxt,
211
+ is_web_frontend: a.is_web_frontend || b.is_web_frontend,
212
+ is_expo: a.is_expo || b.is_expo,
213
+ is_react_native: a.is_react_native || b.is_react_native,
214
+ has_node: a.has_node || b.has_node,
215
+ }
216
+ }
@@ -12,21 +12,14 @@
12
12
  * lines but no line is ever overwritten.
13
13
  */
14
14
 
15
- import { appendFileSync, mkdirSync } from 'node:fs'
16
- import { dirname, join, resolve } from 'node:path'
15
+ import { appendJsonl } from '../util/atomic-write.mjs'
16
+ import { dailyEventsPath } from '../util/events-path.mjs'
17
17
 
18
- /**
19
- * Compute today's events filename relative to projectDir.
20
- * @param {string} projectDir
21
- * @param {Date} [now]
22
- * @returns {string}
23
- */
24
- export function dailyEventsPath(projectDir, now = new Date()) {
25
- const yyyy = now.getUTCFullYear()
26
- const mm = String(now.getUTCMonth() + 1).padStart(2, '0')
27
- const dd = String(now.getUTCDate()).padStart(2, '0')
28
- return join(resolve(projectDir), '.aperant/events', `${yyyy}-${mm}-${dd}.jsonl`)
29
- }
18
+ // dailyEventsPath now lives in util/events-path.mjs (so install/ can share it
19
+ // without crossing into verify-proof/). Re-exported here byte-for-byte so
20
+ // every existing `import { dailyEventsPath } from '../verify-proof/audit.mjs'`
21
+ // call site keeps working.
22
+ export { dailyEventsPath }
30
23
 
31
24
  /**
32
25
  * Append a driver-invocation audit line.
@@ -45,11 +38,12 @@ export function dailyEventsPath(projectDir, now = new Date()) {
45
38
  */
46
39
  export function appendDriverEvent(projectDir, entry) {
47
40
  const path = dailyEventsPath(projectDir)
48
- mkdirSync(dirname(path), { recursive: true })
49
- const line = JSON.stringify({
41
+ // Single canonical jsonl-append primitive for the event log (appendJsonl
42
+ // creates parent dirs, fsyncs, and locks on Windows). Keeps every writer to
43
+ // .aperant/events/{date}.jsonl on the same atomic path.
44
+ appendJsonl(path, {
50
45
  ts: new Date().toISOString(),
51
46
  kind: 'driver_invocation',
52
47
  ...entry,
53
48
  })
54
- appendFileSync(path, line + '\n', 'utf-8')
55
49
  }