@theronap/agnoclast-mcp 0.9.96
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.
- package/README.md +47 -0
- package/bin/cortex-mcp.mjs +223 -0
- package/lib/capture.mjs +470 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/context_log.mjs +92 -0
- package/lib/diagnose.mjs +360 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/doctor.mjs +117 -0
- package/lib/edge_extract.mjs +156 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +130 -0
- package/lib/editors/claude.mjs +202 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/graphify_sync.mjs +134 -0
- package/lib/grep_cli.mjs +82 -0
- package/lib/hydrate.mjs +181 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/ingest_folder.mjs +170 -0
- package/lib/install.mjs +163 -0
- package/lib/login.mjs +148 -0
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/presence.mjs +226 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/red_link_triage.mjs +37 -0
- package/lib/redact.mjs +40 -0
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +153 -0
- package/lib/server.mjs +2986 -0
- package/lib/session_key.mjs +37 -0
- package/lib/setup.mjs +215 -0
- package/lib/skills.mjs +374 -0
- package/lib/statusline.mjs +67 -0
- package/lib/uninstall.mjs +237 -0
- package/lib/use_brain.mjs +82 -0
- package/lib/with_token.mjs +66 -0
- package/package.json +36 -0
- package/skills/author-docs/SKILL.md +74 -0
- package/skills/context/SKILL.md +25 -0
- package/skills/log/SKILL.md +114 -0
- package/skills/walkthrough/SKILL.md +189 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { openSync, closeSync, writeSync, unlinkSync, readFileSync, statSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { homedir } from 'os'
|
|
5
|
+
|
|
6
|
+
// Single-flight lock so at most ONE edge summarizer (`claude -p`) runs on this machine at a time.
|
|
7
|
+
// Without it, N concurrent Claude sessions all fire their Stop hook at once → N simultaneous headless
|
|
8
|
+
// `claude --print` calls that contend on the same subscription-OAuth refresh and can deadlock (the
|
|
9
|
+
// "prompts hang forever" incident, 2026-07-08). If the lock is already held by a live, recent holder,
|
|
10
|
+
// the caller skips extraction and ships the transcript tail instead (the server summarizes async) —
|
|
11
|
+
// no session is dropped, just summarized server-side that turn. A stale lock (older than the summary
|
|
12
|
+
// timeout + grace, or whose PID is dead) is reclaimed.
|
|
13
|
+
const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
|
|
14
|
+
|
|
15
|
+
function summaryTimeoutMs() {
|
|
16
|
+
const n = Number(process.env.CORTEX_SUMMARY_TIMEOUT_MS)
|
|
17
|
+
return Number.isFinite(n) && n > 0 ? n : 45_000
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Try to acquire the lock. Returns true if acquired (caller must call releaseSummaryLock in a finally),
|
|
21
|
+
// false if another live holder has it. O_CREAT|O_EXCL is the atomic "create only if absent" primitive.
|
|
22
|
+
function acquireSummaryLock(_retried = false) {
|
|
23
|
+
try {
|
|
24
|
+
const fd = openSync(LOCK_PATH, 'wx') // wx = O_CREAT|O_EXCL|O_WRONLY — fails if it exists
|
|
25
|
+
try { writeSync(fd, String(process.pid)) } catch { /* PID write is best-effort */ }
|
|
26
|
+
closeSync(fd)
|
|
27
|
+
return true
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e && e.code === 'EEXIST') {
|
|
30
|
+
// Someone holds it — reclaim only if it's stale (older than timeout + 15s grace, or PID dead).
|
|
31
|
+
if (_retried) return false // one reclaim attempt only; a live race means "ship the tail"
|
|
32
|
+
try {
|
|
33
|
+
const age = Date.now() - statSync(LOCK_PATH).mtimeMs
|
|
34
|
+
const holderDead = !pidAlive(readFileSync(LOCK_PATH, 'utf8').trim())
|
|
35
|
+
if (age > summaryTimeoutMs() + 15_000 || holderDead) {
|
|
36
|
+
try { unlinkSync(LOCK_PATH) } catch { /* raced with another reclaimer */ }
|
|
37
|
+
return acquireSummaryLock(true)
|
|
38
|
+
}
|
|
39
|
+
} catch { /* unreadable lock — treat as held; ship the tail this turn */ }
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
// Any other error (e.g. ~/.cortex missing): don't block extraction, just run without the lock.
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pidAlive(pid) {
|
|
48
|
+
const n = Number(pid)
|
|
49
|
+
if (!Number.isInteger(n) || n <= 0) return false
|
|
50
|
+
try { process.kill(n, 0); return true } catch (e) { return e && e.code === 'EPERM' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function releaseSummaryLock() {
|
|
54
|
+
try { unlinkSync(LOCK_PATH) } catch { /* already gone */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Edge session extractor (slice 2c) — ONE `claude --print` call on the user's SUBSCRIPTION that
|
|
58
|
+
// produces summary + people + non-person entities LOCALLY, so the cloud receives only the derived
|
|
59
|
+
// digest. The server-side summarizer (Anthropic API) silently failed in prod ~2026-06-16 and took
|
|
60
|
+
// both session people AND the entity catalogue down; extracting here keeps the engine on `claude -p`
|
|
61
|
+
// (the standing preference) and removes that dependency. Node-native (no bun) to match capture.mjs.
|
|
62
|
+
//
|
|
63
|
+
// edgeSafeEnv() strips EVERY ANTHROPIC_* var (but KEEPS CLAUDE_CODE_OAUTH_TOKEN — the headless
|
|
64
|
+
// runs on the file-based subscription login and a stray ANTHROPIC_BASE_URL (e.g. a keytunnel proxy)
|
|
65
|
+
// can't reroute "free, local" inference through a billed proxy or ship raw text off-machine. The
|
|
66
|
+
// spawn also sets CORTEX_SUMMARIZING=1 so the headless session's own Stop hook no-ops (runCapture
|
|
67
|
+
// guards on it) instead of recursing.
|
|
68
|
+
//
|
|
69
|
+
// CONTRACT: people[] / namedEntities[] shapes MUST stay compatible with the server validators
|
|
70
|
+
// web/lib/engine/extract_people.ts (parsePeople) + extract_entities.ts (parseEntities). The prompt
|
|
71
|
+
// fragments are copied from there; the SERVER validator is the enforced source of truth — the edge
|
|
72
|
+
// is never trusted. Returns null on any failure → caller falls back to shipping the transcript tail.
|
|
73
|
+
|
|
74
|
+
export function edgeSafeEnv(base = process.env, extra = {}) {
|
|
75
|
+
const env = {}
|
|
76
|
+
for (const [k, v] of Object.entries(base)) {
|
|
77
|
+
if (v === undefined) continue
|
|
78
|
+
// Strip billed/proxy routing only (ANTHROPIC_API_KEY metered, ANTHROPIC_BASE_URL keytunnel,
|
|
79
|
+
// ANTHROPIC_AUTH_TOKEN/CUSTOM_HEADERS proxy) so edge inference runs on the SUBSCRIPTION, never a
|
|
80
|
+
// metered/proxy path. KEEP CLAUDE_CODE_OAUTH_TOKEN — it IS the headless subscription credential
|
|
81
|
+
// (from `claude setup-token`); stripping it leaves `claude --print` with no usable auth → 401.
|
|
82
|
+
// (The earlier "subscription is file-based" assumption was wrong on macOS — headless needs this token.)
|
|
83
|
+
if (k.startsWith('ANTHROPIC_')) continue
|
|
84
|
+
env[k] = v
|
|
85
|
+
}
|
|
86
|
+
return { ...env, ...extra }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PEOPLE_FRAGMENT =
|
|
90
|
+
'"people" (array of important, NAMED individuals mentioned — each an object with ' +
|
|
91
|
+
'"name" (the person\'s real full name as written — NEVER an email address, handle, or group alias), ' +
|
|
92
|
+
'"email" (only if explicitly stated, else null), ' +
|
|
93
|
+
'"company" (their org/employer if stated, else null), ' +
|
|
94
|
+
'"title" (their role if stated, else null), ' +
|
|
95
|
+
'"relationship" (one short phrase: how they relate to this work), ' +
|
|
96
|
+
'"importance" ("high" for people central to the work, "low" for incidental). ' +
|
|
97
|
+
'Only real named humans; omit anonymous or incidental mentions. Empty array is fine.)'
|
|
98
|
+
|
|
99
|
+
const ENTITY_FRAGMENT =
|
|
100
|
+
'"namedEntities" (array of important NON-PERSON things this content is about — concrete, named ' +
|
|
101
|
+
'projects, processes, systems, products, documents, teams, tools, events, places, or topics. ' +
|
|
102
|
+
'Each an object with "name" (the specific name as written, NOT a generic word), ' +
|
|
103
|
+
'"kind" (one of: project|process|system|product|document|team|topic|tool|event|place), ' +
|
|
104
|
+
'"description" (one short phrase: what it is / how it relates to this work, else null), ' +
|
|
105
|
+
'"importance" ("high" if central to the work, "low" if incidental). ' +
|
|
106
|
+
'Do NOT include people or companies (those go in "people"). Omit vague/generic mentions. Empty array is fine.)'
|
|
107
|
+
|
|
108
|
+
export function extractSession(transcript) {
|
|
109
|
+
const text = (transcript ?? '').trim()
|
|
110
|
+
if (!text || process.env.CORTEX_SUMMARIZE_DISABLED) return null
|
|
111
|
+
const prompt =
|
|
112
|
+
'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
|
|
113
|
+
'(no prose, no markdown fences) with EXACTLY these three keys:\n' +
|
|
114
|
+
'"summary" (ONE concrete sentence under 20 words: what was worked on or decided. If the session ' +
|
|
115
|
+
'had no real work — greetings, no tasks — set summary to exactly "NOOP"),\n' +
|
|
116
|
+
PEOPLE_FRAGMENT + ',\n' +
|
|
117
|
+
ENTITY_FRAGMENT +
|
|
118
|
+
'\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
|
|
119
|
+
// Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
|
|
120
|
+
// server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
|
|
121
|
+
if (!acquireSummaryLock()) return null
|
|
122
|
+
try {
|
|
123
|
+
const r = spawnSync(
|
|
124
|
+
'claude',
|
|
125
|
+
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
126
|
+
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
|
|
127
|
+
)
|
|
128
|
+
if (r.status !== 0 || !r.stdout) return null
|
|
129
|
+
return parseEdgeJson(r.stdout.trim())
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
} finally {
|
|
133
|
+
releaseSummaryLock()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Tolerant JSON extraction: the model may wrap output in prose / ```json fences, so grab the first
|
|
138
|
+
// balanced-looking {...} span. Arrays pass through untouched — the SERVER validates them.
|
|
139
|
+
export function parseEdgeJson(out) {
|
|
140
|
+
if (!out) return null
|
|
141
|
+
const start = out.indexOf('{')
|
|
142
|
+
const end = out.lastIndexOf('}')
|
|
143
|
+
if (start < 0 || end <= start) return null
|
|
144
|
+
try {
|
|
145
|
+
const o = JSON.parse(out.slice(start, end + 1))
|
|
146
|
+
const summary = typeof o.summary === 'string' ? o.summary.trim() : ''
|
|
147
|
+
if (!summary) return null
|
|
148
|
+
return {
|
|
149
|
+
summary: summary.slice(0, 200),
|
|
150
|
+
people: Array.isArray(o.people) ? o.people : [],
|
|
151
|
+
namedEntities: Array.isArray(o.namedEntities) ? o.namedEntities : [],
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared file helpers for editor adapters. These MIRROR setup.mjs's private helpers exactly so an
|
|
2
|
+
// extracted wire() writes byte-identical output (same backup name, same JSON formatting).
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** Parse a JSON config, tolerating absent/empty files; THROWS on malformed (caller decides). */
|
|
7
|
+
export function readJson(path) {
|
|
8
|
+
if (!existsSync(path)) return {}
|
|
9
|
+
const raw = readFileSync(path, 'utf8').trim()
|
|
10
|
+
if (!raw) return {}
|
|
11
|
+
return JSON.parse(raw)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Copy path → path.cortex-bak before mutating. Returns the backup path, or null if nothing to back up. */
|
|
15
|
+
export function backupFile(path) {
|
|
16
|
+
if (!existsSync(path)) return null
|
|
17
|
+
const bak = `${path}.cortex-bak`
|
|
18
|
+
copyFileSync(path, bak)
|
|
19
|
+
return bak
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Ensure the parent directory of `path` exists. */
|
|
23
|
+
export function ensureDir(path) {
|
|
24
|
+
const dir = dirname(path)
|
|
25
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Write an object as pretty JSON — the exact formatting setup.mjs uses (2-space indent). */
|
|
29
|
+
export function writeJson(path, obj) {
|
|
30
|
+
writeFileSync(path, JSON.stringify(obj, null, 2))
|
|
31
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Editor adapter: Antigravity. P0-b task 4 — deterministic artifact generators + wire().
|
|
2
|
+
//
|
|
3
|
+
// Antigravity has no in-editor hook; sync is a launchd agent (com.cortex.antigravity-sync) that
|
|
4
|
+
// WatchPaths the trajectory store and runs a one-shot sync. wire() generates the two artifacts
|
|
5
|
+
// (the sync.sh + the launchd plist) deterministically. It does NOT run `launchctl load` here
|
|
6
|
+
// (an unverifiable system mutation) and it flags the PORTABILITY BLOCKER below.
|
|
7
|
+
//
|
|
8
|
+
// ⚠ BLOCKER (surfaced P0-b task 4): antigravity-sync.sh runs the sync from a LOCAL DEV CHECKOUT
|
|
9
|
+
// ($HOME/dev/cortex/packages/cortex-agent via `bun run src/index.ts antigravity-sync`). A coworker
|
|
10
|
+
// has no ~/dev/cortex, so Antigravity capture is bespoke to the author's machine and is NOT yet
|
|
11
|
+
// installable for a real seat. Making it portable requires a published `antigravity-sync` entrypoint
|
|
12
|
+
// (npx @theronap/cortex-mcp antigravity-sync, or a published cortex-agent) — a prerequisite this
|
|
13
|
+
// adapter cannot satisfy alone. Until then wire() writes the artifacts + returns a blocking warning.
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const AG_STATE = ['Library', 'Application Support', 'Antigravity']
|
|
19
|
+
const AG_WATCH = [...AG_STATE, 'User', 'globalStorage']
|
|
20
|
+
|
|
21
|
+
/** The launchd plist, home-substituted. Deterministic — golden-testable byte-for-byte. */
|
|
22
|
+
export function renderAntigravityPlist({ home = homedir() } = {}) {
|
|
23
|
+
const sh = join(home, '.cortex', 'antigravity-sync.sh')
|
|
24
|
+
const watch = join(home, ...AG_WATCH)
|
|
25
|
+
const logp = join(home, '.cortex', 'antigravity-sync.launchd.log')
|
|
26
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
|
+
<plist version="1.0">
|
|
29
|
+
<dict>
|
|
30
|
+
<key>Label</key>
|
|
31
|
+
<string>com.cortex.antigravity-sync</string>
|
|
32
|
+
<key>ProgramArguments</key>
|
|
33
|
+
<array>
|
|
34
|
+
<string>/bin/bash</string>
|
|
35
|
+
<string>${sh}</string>
|
|
36
|
+
</array>
|
|
37
|
+
<key>WatchPaths</key>
|
|
38
|
+
<array>
|
|
39
|
+
<string>${watch}</string>
|
|
40
|
+
</array>
|
|
41
|
+
<key>RunAtLoad</key>
|
|
42
|
+
<false/>
|
|
43
|
+
<key>ThrottleInterval</key>
|
|
44
|
+
<integer>30</integer>
|
|
45
|
+
<key>StandardOutPath</key>
|
|
46
|
+
<string>${logp}</string>
|
|
47
|
+
<key>StandardErrorPath</key>
|
|
48
|
+
<string>${logp}</string>
|
|
49
|
+
</dict>
|
|
50
|
+
</plist>
|
|
51
|
+
`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The sync.sh. Self-resolves paths via $HOME at runtime, so it is a static template.
|
|
55
|
+
* NOTE the AGENT_DIR line is the portability blocker documented above. */
|
|
56
|
+
export function renderAntigravitySyncSh() {
|
|
57
|
+
return `#!/bin/bash
|
|
58
|
+
# Agnoclast ⇄ Antigravity one-shot sync, triggered by launchd WatchPaths on the Antigravity
|
|
59
|
+
# trajectory store. There is no resident daemon — launchd wakes this on change and it exits.
|
|
60
|
+
# Token is resolved by 'cortex-mcp with-token' at runtime (never stored in plist/script, and
|
|
61
|
+
# never parsed here -- see ADR-0033 D9 on frozen generated scripts).
|
|
62
|
+
# ⚠ AGENT_DIR points at a local dev checkout — see antigravity.mjs BLOCKER note (not coworker-portable).
|
|
63
|
+
set -o pipefail
|
|
64
|
+
LOG="$HOME/.cortex/antigravity-sync.log"
|
|
65
|
+
NPX="/usr/local/bin/npx"
|
|
66
|
+
BUN="$HOME/.bun/bin/bun"
|
|
67
|
+
AGENT_DIR="$HOME/dev/cortex/packages/cortex-agent"
|
|
68
|
+
|
|
69
|
+
stamp() { date -u +%FT%TZ; }
|
|
70
|
+
|
|
71
|
+
if [ ! -d "$AGENT_DIR" ]; then echo "$(stamp) agent dir missing: $AGENT_DIR" >> "$LOG"; exit 0; fi
|
|
72
|
+
|
|
73
|
+
# The token is resolved by the package, not parsed here. This script is written once and never
|
|
74
|
+
# rewritten by an upgrade, so an inlined config read would silently stop working the moment the
|
|
75
|
+
# config shape changed -- and its own error handling would swallow that. with-token also keeps the
|
|
76
|
+
# secret out of this shell entirely: it goes from the resolver straight into the child environment.
|
|
77
|
+
# Exit 3 means "nothing wired", which is a skip rather than a failure.
|
|
78
|
+
cd "$AGENT_DIR" || exit 0
|
|
79
|
+
CORTEX_WATCH="$HOME/.cortex-agent" "$NPX" -y @theronap/cortex-mcp@stable with-token -- \
|
|
80
|
+
"$BUN" run src/index.ts antigravity-sync >> "$LOG" 2>&1
|
|
81
|
+
CODE=$?
|
|
82
|
+
if [ "$CODE" = "3" ]; then echo "$(stamp) no token wired, skipping" >> "$LOG"; exit 0; fi
|
|
83
|
+
echo "$(stamp) exit=$CODE" >> "$LOG"
|
|
84
|
+
exit 0
|
|
85
|
+
`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @type {import('./index.mjs').EditorAdapter} */
|
|
89
|
+
export default {
|
|
90
|
+
id: 'antigravity',
|
|
91
|
+
displayName: 'Antigravity',
|
|
92
|
+
|
|
93
|
+
detect({ home = homedir(), exists = existsSync } = {}) {
|
|
94
|
+
return exists(join(home, ...AG_STATE))
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
capabilities: {
|
|
98
|
+
promptTimeInjection: false, // no in-editor injection possible today
|
|
99
|
+
sessionStart: false,
|
|
100
|
+
captureHook: true, // post-sync, via antigravity-sync.sh
|
|
101
|
+
skillDir: null,
|
|
102
|
+
docTrigger: 'watch',
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// Writes the two artifacts deterministically. Does NOT launchctl-load (unverifiable side effect) and
|
|
106
|
+
// returns a BLOCKING warning about the dev-checkout dependency — honest about not-yet-installable.
|
|
107
|
+
async wire({ home = homedir(), log = () => {} } = {}) {
|
|
108
|
+
const wrote = [], skipped = [], warnings = []
|
|
109
|
+
const cortexDir = join(home, '.cortex')
|
|
110
|
+
const shPath = join(cortexDir, 'antigravity-sync.sh')
|
|
111
|
+
const plistPath = join(home, 'Library', 'LaunchAgents', 'com.cortex.antigravity-sync.plist')
|
|
112
|
+
|
|
113
|
+
try { // non-fatal: an IO error must not abort sibling editors
|
|
114
|
+
mkdirSync(cortexDir, { recursive: true })
|
|
115
|
+
writeFileSync(shPath, renderAntigravitySyncSh())
|
|
116
|
+
try { chmodSync(shPath, 0o755) } catch { /* non-fatal on non-posix */ }
|
|
117
|
+
wrote.push(shPath)
|
|
118
|
+
|
|
119
|
+
mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true })
|
|
120
|
+
writeFileSync(plistPath, renderAntigravityPlist({ home }))
|
|
121
|
+
wrote.push(plistPath)
|
|
122
|
+
log(` ✓ Antigravity artifacts → ${shPath}, ${plistPath}`)
|
|
123
|
+
} catch (e) {
|
|
124
|
+
warnings.push(`Antigravity artifact write skipped: ${e.message}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
warnings.push('Antigravity NOT fully wired: (1) run `launchctl load ' + plistPath + '` to activate the watcher; (2) BLOCKER — the sync runs from a local dev checkout (~/dev/cortex/packages/cortex-agent), so it will not work on a machine without that checkout until a published antigravity-sync entrypoint exists.')
|
|
128
|
+
return { wrote, skipped, warnings }
|
|
129
|
+
},
|
|
130
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Editor adapter: Claude Code. P0-b task 2 — wire() + the pure merge functions extracted from
|
|
2
|
+
// setup.mjs. setup.mjs imports THESE SAME functions (and re-exports them), so the config bytes are
|
|
3
|
+
// structurally identical, not merely "supposed to match".
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { readJson, backupFile, ensureDir, writeJson } from './_fsutil.mjs'
|
|
8
|
+
import { markCommand, isManagedSubcommand } from '../managed.mjs'
|
|
9
|
+
|
|
10
|
+
/** Merge the Agnoclast MCP server into a ~/.claude.json object. Pure + idempotent: sets only the
|
|
11
|
+
* `cortex` entry (type:'stdio'), preserves every other server. `spec` = the package@dist-tag string. */
|
|
12
|
+
export function mergeClaudeMcp(existing, spec, token) {
|
|
13
|
+
const cfg = existing && typeof existing === 'object' ? { ...existing } : {}
|
|
14
|
+
cfg.mcpServers = cfg.mcpServers && typeof cfg.mcpServers === 'object' ? { ...cfg.mcpServers } : {}
|
|
15
|
+
cfg.mcpServers.cortex = { type: 'stdio', command: 'npx', args: ['-y', spec], env: { CORTEX_TOKEN: token } }
|
|
16
|
+
return cfg
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The Agnoclast tools every seat may run WITHOUT a permission prompt: the read surface, the live
|
|
20
|
+
* authoring core, and trivially-reversible maintenance. The contract this enforces: authoring is
|
|
21
|
+
* EXPECTED agent behavior — a page update must never stall on a yes/no dialog the user won't read
|
|
22
|
+
* (the ask-permission failure mode is how pages go stale). Safe because every page edit is
|
|
23
|
+
* CAS-protected + snapshotted (page_revisions → page_history/rollback_page).
|
|
24
|
+
* Deliberately EXCLUDED — these keep prompting: send_imessage (external side effect),
|
|
25
|
+
* set_page_privacy / set_record_privacy / grant_page_access (visibility widening — the
|
|
26
|
+
* unowned-project accessible-default sharp edge, 2026-07-02), replace_variant (destroys a page body
|
|
27
|
+
* and deletes a variant row — the one write here that is not merely CAS-protected but genuinely
|
|
28
|
+
* lossy at the row level, so the prompt IS the guard D1 argued for), rollback_page, decide_page_merge /
|
|
29
|
+
* decide_file_request / request_file / get_file, create_brain / set_active_brain, alias_page,
|
|
30
|
+
* set_writing_style. */
|
|
31
|
+
export const ALLOWED_TOOL_NAMES = [
|
|
32
|
+
// read surface
|
|
33
|
+
'grep', 'read_page', 'my_context', 'project_status', 'session_context', 'search_org',
|
|
34
|
+
'list_records', 'page_history', 'page_diff', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
|
|
35
|
+
'code_graph_query', 'my_retier_notices', 'list_page_grants', 'file_requests', 'page_merge_requests',
|
|
36
|
+
// live authoring core
|
|
37
|
+
'authoring_context', 'author', 'log_session',
|
|
38
|
+
// routine, reversible maintenance
|
|
39
|
+
'set_page_validity', 'snooze_red_link', 'attribute_thread',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/** The same allowlist, rendered for a given tool namespace. ONE source of names, so the cortex and
|
|
43
|
+
* agnoclast lists cannot drift apart during the migration — C2 adds the new namespace's rules
|
|
44
|
+
* before flipping the config key, and it derives them from here rather than a second copy. */
|
|
45
|
+
export const allowedToolsFor = (namespace) => ALLOWED_TOOL_NAMES.map((t) => `mcp__${namespace}__${t}`)
|
|
46
|
+
|
|
47
|
+
export const CORTEX_ALLOWED_TOOLS = allowedToolsFor('cortex')
|
|
48
|
+
|
|
49
|
+
/** Merge Agnoclast's Claude Code hooks into a ~/.claude/settings.json object. Pure + idempotent: drops
|
|
50
|
+
* any prior cortex entry (old token/path/version) from each hook array before appending the current
|
|
51
|
+
* one — capture (Stop), status + skills-repair + snapshot-context (SessionStart), hydrate
|
|
52
|
+
* (UserPromptSubmit). Also UNWIRES the retired PreCompact reminder from seats that still carry it.
|
|
53
|
+
* Commands carry NO inline token (each subcommand
|
|
54
|
+
* self-resolves it). Mirrors setup.mjs
|
|
55
|
+
* step 2 exactly; foreign hooks are never touched. Also merges the CORTEX_ALLOWED_TOOLS permission
|
|
56
|
+
* allowlist — additive-only: a user's own allow entries (even extra mcp__cortex__* ones) are never
|
|
57
|
+
* removed, and `deny` is never touched (a user deny always beats our allow). */
|
|
58
|
+
export function mergeClaudeSettings(existing, spec) {
|
|
59
|
+
const s = existing && typeof existing === 'object' ? existing : {}
|
|
60
|
+
s.hooks = s.hooks ?? {}
|
|
61
|
+
|
|
62
|
+
// Stop — capture.
|
|
63
|
+
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
64
|
+
const captureCmd = markCommand(`npx -y ${spec} capture`)
|
|
65
|
+
for (const grp of s.hooks.Stop) {
|
|
66
|
+
if (Array.isArray(grp.hooks)) grp.hooks = grp.hooks.filter((h) => !isManagedSubcommand(h.command, 'capture'))
|
|
67
|
+
}
|
|
68
|
+
let grp = s.hooks.Stop.find((g) => (g.matcher ?? '') === '')
|
|
69
|
+
if (!grp) { grp = { matcher: '', hooks: [] }; s.hooks.Stop.push(grp) }
|
|
70
|
+
grp.hooks = grp.hooks ?? []
|
|
71
|
+
grp.hooks.push({ type: 'command', command: captureCmd })
|
|
72
|
+
|
|
73
|
+
// SessionStart — status, then skills self-heal, then snapshot-context (order matters: matches setup).
|
|
74
|
+
s.hooks.SessionStart = Array.isArray(s.hooks.SessionStart) ? s.hooks.SessionStart : []
|
|
75
|
+
const statusCmd = markCommand(`npx -y ${spec} status`)
|
|
76
|
+
for (const sg of s.hooks.SessionStart) {
|
|
77
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !isManagedSubcommand(h.command, 'status'))
|
|
78
|
+
}
|
|
79
|
+
let sgrp = s.hooks.SessionStart.find((g) => (g.matcher ?? '') === '')
|
|
80
|
+
if (!sgrp) { sgrp = { matcher: '', hooks: [] }; s.hooks.SessionStart.push(sgrp) }
|
|
81
|
+
sgrp.hooks = sgrp.hooks ?? []
|
|
82
|
+
sgrp.hooks.push({ type: 'command', command: statusCmd })
|
|
83
|
+
|
|
84
|
+
const skillsCmd = markCommand(`npx -y ${spec} skills --repair --quiet`)
|
|
85
|
+
for (const sg of s.hooks.SessionStart) {
|
|
86
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !isManagedSubcommand(h.command, 'skills'))
|
|
87
|
+
}
|
|
88
|
+
sgrp.hooks.push({ type: 'command', command: skillsCmd })
|
|
89
|
+
|
|
90
|
+
const snapshotCmd = markCommand(`npx -y ${spec} snapshot-context`)
|
|
91
|
+
for (const sg of s.hooks.SessionStart) {
|
|
92
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !isManagedSubcommand(h.command, 'snapshot-context'))
|
|
93
|
+
}
|
|
94
|
+
sgrp.hooks.push({ type: 'command', command: snapshotCmd })
|
|
95
|
+
|
|
96
|
+
// UserPromptSubmit — hydrate (① discovery). Makes the FIRST context load of a session
|
|
97
|
+
// non-discretionary: without it, hydration is a skill the agent may simply forget, and an agent
|
|
98
|
+
// that never reads the wiki never triggers a currency update — it re-derives design that is
|
|
99
|
+
// already authored and already shipped. This is also the surface that prints the presence receipt,
|
|
100
|
+
// so wiring it is what makes a read visible to the person in the chair.
|
|
101
|
+
//
|
|
102
|
+
// The filter is deliberately loose (`cortex-mcp.*hydrate`) so it also reclaims a hand-wired LOCAL
|
|
103
|
+
// pointer of the form `[ -f <worktree>/bin/cortex-mcp.mjs ] && node … hydrate || true`. Those were
|
|
104
|
+
// the recommended way to trial the hook before release, and one on this project's own machine went
|
|
105
|
+
// dead for five days when its worktree was deleted — the `-f` guard fails open, so the hook silently
|
|
106
|
+
// became a no-op with the config still looking wired. Re-running setup should heal that, not skip it.
|
|
107
|
+
s.hooks.UserPromptSubmit = Array.isArray(s.hooks.UserPromptSubmit) ? s.hooks.UserPromptSubmit : []
|
|
108
|
+
const hydrateCmd = markCommand(`npx -y ${spec} hydrate`)
|
|
109
|
+
for (const hg of s.hooks.UserPromptSubmit) {
|
|
110
|
+
if (Array.isArray(hg.hooks)) hg.hooks = hg.hooks.filter((h) => !isManagedSubcommand(h.command, 'hydrate'))
|
|
111
|
+
}
|
|
112
|
+
let hgrp = s.hooks.UserPromptSubmit.find((g) => (g.matcher ?? '') === '')
|
|
113
|
+
if (!hgrp) { hgrp = { matcher: '', hooks: [] }; s.hooks.UserPromptSubmit.push(hgrp) }
|
|
114
|
+
hgrp.hooks = hgrp.hooks ?? []
|
|
115
|
+
hgrp.hooks.push({ type: 'command', command: hydrateCmd })
|
|
116
|
+
|
|
117
|
+
// PreCompact — REMOVED, and this block is the migration that unwires existing seats.
|
|
118
|
+
//
|
|
119
|
+
// The "author now" reminder wrote to stdout on the assumption the harness surfaced it as context for
|
|
120
|
+
// the next turn. It does not: PreCompact accepts a blocking `decision` and nothing else — it has no
|
|
121
|
+
// additionalContext channel — so the reminder never reached a model. Measured 2026-08-10 across 7,232
|
|
122
|
+
// local transcripts: the reminder text appears 5 times, every one of them a tool_result from someone
|
|
123
|
+
// READING the file, assistant prose about it, or a compaction summary that absorbed such prose. Zero
|
|
124
|
+
// injections, against >=13 transcripts that demonstrably compacted. The control is what makes that
|
|
125
|
+
// conclusive rather than merely absent: SessionStart's hook output, on a channel that IS injected,
|
|
126
|
+
// appears in 912 transcripts of the same corpus.
|
|
127
|
+
//
|
|
128
|
+
// So: filter, never append. Every install/repair strips the stale entry from seats that already have
|
|
129
|
+
// it, which is why this runs unconditionally instead of shipping as a separate migration.
|
|
130
|
+
if (Array.isArray(s.hooks.PreCompact)) {
|
|
131
|
+
for (const pg of s.hooks.PreCompact) {
|
|
132
|
+
if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? precompact/.test(h.command ?? ''))
|
|
133
|
+
}
|
|
134
|
+
// Drop groups we just emptied, then the key itself if no foreign hook remains — a bare
|
|
135
|
+
// `PreCompact: []` reads as "cortex wires this event" to the next person to open settings.json.
|
|
136
|
+
s.hooks.PreCompact = s.hooks.PreCompact.filter((pg) => (pg.hooks ?? []).length > 0)
|
|
137
|
+
if (s.hooks.PreCompact.length === 0) delete s.hooks.PreCompact
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Permissions — pre-authorize the read + authoring core so a page update never stalls on a
|
|
141
|
+
// permission prompt. Append-missing only (no filter-and-rebuild like the hooks above): removals
|
|
142
|
+
// ship via uninstall, and rebuilding would delete allows the user added by hand.
|
|
143
|
+
s.permissions = s.permissions && typeof s.permissions === 'object' ? s.permissions : {}
|
|
144
|
+
s.permissions.allow = Array.isArray(s.permissions.allow) ? s.permissions.allow : []
|
|
145
|
+
for (const rule of CORTEX_ALLOWED_TOOLS) {
|
|
146
|
+
if (!s.permissions.allow.includes(rule)) s.permissions.allow.push(rule)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return s
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @type {import('./index.mjs').EditorAdapter} */
|
|
153
|
+
export default {
|
|
154
|
+
id: 'claude-code',
|
|
155
|
+
displayName: 'Claude Code',
|
|
156
|
+
|
|
157
|
+
detect({ home = homedir(), exists = existsSync } = {}) {
|
|
158
|
+
return exists(join(home, '.claude')) || exists(join(home, '.claude.json'))
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
capabilities: {
|
|
162
|
+
promptTimeInjection: true,
|
|
163
|
+
sessionStart: true,
|
|
164
|
+
captureHook: true,
|
|
165
|
+
skillDir: '~/.claude/skills',
|
|
166
|
+
docTrigger: 'stop-hook',
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
// Two independent, non-fatal blocks (mirrors codex.mjs): a malformed file OR an IO error is
|
|
170
|
+
// skipped-with-warning, never a throw that aborts the other block or a sibling editor. readJson
|
|
171
|
+
// throws on malformed → caught here → that file is left untouched (never overwritten). The WRITTEN
|
|
172
|
+
// bytes come from the shared pure functions above, so they are identical to setup's output.
|
|
173
|
+
async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
|
|
174
|
+
const wrote = [], skipped = [], warnings = []
|
|
175
|
+
if (!token) { warnings.push('no token — skipped Claude Code'); return { wrote, skipped, warnings } }
|
|
176
|
+
|
|
177
|
+
const claudeJson = join(home, '.claude.json')
|
|
178
|
+
try {
|
|
179
|
+
const cfg = readJson(claudeJson) // {} if absent/empty; THROWS (→ skip) if malformed
|
|
180
|
+
const bak = backupFile(claudeJson)
|
|
181
|
+
ensureDir(claudeJson)
|
|
182
|
+
writeJson(claudeJson, mergeClaudeMcp(cfg, spec, token))
|
|
183
|
+
wrote.push(claudeJson)
|
|
184
|
+
log(` ✓ MCP server → ${claudeJson}${bak ? ' (backup saved)' : ''}`)
|
|
185
|
+
} catch (e) {
|
|
186
|
+
warnings.push(`${claudeJson} wiring skipped: ${e.message}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const settingsJson = join(home, '.claude', 'settings.json')
|
|
190
|
+
try {
|
|
191
|
+
const s = readJson(settingsJson)
|
|
192
|
+
const bak = backupFile(settingsJson)
|
|
193
|
+
ensureDir(settingsJson)
|
|
194
|
+
writeJson(settingsJson, mergeClaudeSettings(s, spec))
|
|
195
|
+
wrote.push(settingsJson)
|
|
196
|
+
log(` ✓ Capture + hydrate hooks + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
197
|
+
} catch (e) {
|
|
198
|
+
warnings.push(`${settingsJson} wiring skipped: ${e.message}`)
|
|
199
|
+
}
|
|
200
|
+
return { wrote, skipped, warnings }
|
|
201
|
+
},
|
|
202
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Editor adapter: OpenAI Codex. P0-b task 3 — wire() + the pure merge functions, moved here verbatim
|
|
2
|
+
// from setup.mjs (which now imports + re-exports them). No caller imported them from setup except
|
|
3
|
+
// setup itself, so this move is transparent.
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { readJson, backupFile, ensureDir, writeJson } from './_fsutil.mjs'
|
|
8
|
+
|
|
9
|
+
/** Merge the Agnoclast MCP server into a Codex config.toml. Pure + idempotent: strips any existing
|
|
10
|
+
* [mcp_servers.cortex] / [mcp_servers.cortex.env] tables (so re-runs update in place rather than
|
|
11
|
+
* duplicating — a duplicate TOML table would break Codex's parser), preserves every other table,
|
|
12
|
+
* and appends a fresh block. Only touches the cortex tables; never rewrites the user's config. */
|
|
13
|
+
export function mergeCodexToml(text, spec, token) {
|
|
14
|
+
const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]'])
|
|
15
|
+
const kept = []
|
|
16
|
+
let skipping = false
|
|
17
|
+
for (const line of (text || '').split('\n')) {
|
|
18
|
+
const t = line.trim()
|
|
19
|
+
if (t.startsWith('[') && t.endsWith(']')) skipping = targets.has(t)
|
|
20
|
+
if (!skipping) kept.push(line)
|
|
21
|
+
}
|
|
22
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop() // drop trailing blanks
|
|
23
|
+
const block = [
|
|
24
|
+
'',
|
|
25
|
+
'[mcp_servers.cortex]',
|
|
26
|
+
'command = "npx"',
|
|
27
|
+
`args = ["-y", "${spec}"]`,
|
|
28
|
+
'startup_timeout_sec = 60', // first npx fetch can be slow; don't time out the server on cold start
|
|
29
|
+
'',
|
|
30
|
+
'[mcp_servers.cortex.env]',
|
|
31
|
+
`CORTEX_TOKEN = "${token}"`,
|
|
32
|
+
'',
|
|
33
|
+
]
|
|
34
|
+
return [...kept, ...block].join('\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Merge the capture Stop hook into a Codex hooks.json object. Pure + idempotent: drops any prior
|
|
38
|
+
* cortex capture entry (old token / old path / old pinned version) before appending the current one,
|
|
39
|
+
* so re-running never duplicates and never leaves a stale version pinned behind a live one.
|
|
40
|
+
* Codex's hook runtime has NO SessionStart — only capture (Stop) is wired here. */
|
|
41
|
+
export function mergeCodexHooks(existing, captureCmd) {
|
|
42
|
+
const h = existing && typeof existing === 'object' ? existing : {}
|
|
43
|
+
h.hooks = h.hooks && typeof h.hooks === 'object' ? h.hooks : {}
|
|
44
|
+
h.hooks.Stop = Array.isArray(h.hooks.Stop) ? h.hooks.Stop : []
|
|
45
|
+
for (const grp of h.hooks.Stop) {
|
|
46
|
+
if (Array.isArray(grp.hooks)) {
|
|
47
|
+
grp.hooks = grp.hooks.filter((c) => !/cortex-mcp.*capture|capture-session-cloud/.test(c.command ?? ''))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let grp = h.hooks.Stop.find((g) => (g.matcher ?? '') === '')
|
|
51
|
+
if (!grp) { grp = { matcher: '', hooks: [] }; h.hooks.Stop.push(grp) }
|
|
52
|
+
grp.hooks = grp.hooks ?? []
|
|
53
|
+
grp.hooks.push({ type: 'command', command: captureCmd })
|
|
54
|
+
return h
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @type {import('./index.mjs').EditorAdapter} */
|
|
58
|
+
export default {
|
|
59
|
+
id: 'codex',
|
|
60
|
+
displayName: 'OpenAI Codex',
|
|
61
|
+
|
|
62
|
+
detect({ home = homedir(), exists = existsSync } = {}) {
|
|
63
|
+
return exists(join(home, '.codex'))
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// Codex's hook runtime recognizes PreToolUse/PostToolUse/PreCompact/UserPromptSubmit/Stop — notably
|
|
67
|
+
// NO SessionStart. So status/skills-repair/snapshot-context stay Claude-only; the manifest must
|
|
68
|
+
// encode sessionStart:false so P1/P2 don't assume it.
|
|
69
|
+
capabilities: {
|
|
70
|
+
promptTimeInjection: true,
|
|
71
|
+
sessionStart: false,
|
|
72
|
+
captureHook: true,
|
|
73
|
+
skillDir: null,
|
|
74
|
+
docTrigger: 'stop-hook',
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Two independent, non-fatal blocks (config.toml + hooks.json) — mirrors setup.mjs steps 1b/1c.
|
|
78
|
+
// Written bytes come from the shared pure functions above → identical to setup's output.
|
|
79
|
+
async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
|
|
80
|
+
const wrote = [], skipped = [], warnings = []
|
|
81
|
+
if (!token) { warnings.push('no token — skipped Codex'); return { wrote, skipped, warnings } }
|
|
82
|
+
const codexDir = join(home, '.codex')
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const codexToml = join(codexDir, 'config.toml')
|
|
86
|
+
const existing = existsSync(codexToml) ? readFileSync(codexToml, 'utf8') : ''
|
|
87
|
+
const bak = backupFile(codexToml)
|
|
88
|
+
ensureDir(codexToml)
|
|
89
|
+
writeFileSync(codexToml, mergeCodexToml(existing, spec, token)) // raw TOML text, not JSON
|
|
90
|
+
wrote.push(codexToml)
|
|
91
|
+
log(` ✓ MCP server → ${codexToml}${bak ? ' (backup saved)' : ''}`)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
warnings.push(`Codex MCP wiring skipped: ${e.message}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const codexHooks = join(codexDir, 'hooks.json')
|
|
98
|
+
let existingHooks
|
|
99
|
+
try { existingHooks = readJson(codexHooks) } catch { existingHooks = {} } // malformed → start fresh, don't block
|
|
100
|
+
const bak = backupFile(codexHooks)
|
|
101
|
+
const captureCmd = `npx -y ${spec} capture`
|
|
102
|
+
ensureDir(codexHooks)
|
|
103
|
+
writeJson(codexHooks, mergeCodexHooks(existingHooks, captureCmd))
|
|
104
|
+
wrote.push(codexHooks)
|
|
105
|
+
log(` ✓ Capture hook → ${codexHooks}${bak ? ' (backup saved)' : ''}`)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
warnings.push(`Codex capture hook skipped: ${e.message}`)
|
|
108
|
+
}
|
|
109
|
+
return { wrote, skipped, warnings }
|
|
110
|
+
},
|
|
111
|
+
}
|