robrain 2.3.7 → 2.3.8

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 (38) hide show
  1. package/dist/commands/install.d.ts.map +1 -1
  2. package/dist/commands/install.js +17 -2
  3. package/dist/commands/install.js.map +1 -1
  4. package/dist/commands/up.test.js +2 -2
  5. package/dist/index.js +1 -1
  6. package/dist/lib/codex-hooks.d.ts +26 -0
  7. package/dist/lib/codex-hooks.d.ts.map +1 -0
  8. package/dist/lib/codex-hooks.js +65 -0
  9. package/dist/lib/codex-hooks.js.map +1 -0
  10. package/dist/lib/codex-hooks.test.d.ts +2 -0
  11. package/dist/lib/codex-hooks.test.d.ts.map +1 -0
  12. package/dist/lib/codex-hooks.test.js +64 -0
  13. package/dist/lib/codex-hooks.test.js.map +1 -0
  14. package/dist/lib/editor.codex.test.js +20 -0
  15. package/dist/lib/editor.codex.test.js.map +1 -1
  16. package/dist/lib/editor.d.ts +2 -0
  17. package/dist/lib/editor.d.ts.map +1 -1
  18. package/dist/lib/editor.js +28 -0
  19. package/dist/lib/editor.js.map +1 -1
  20. package/dist/lib/mcp-bundle.test.js +2 -2
  21. package/dist/lib/release-guard.d.ts +22 -0
  22. package/dist/lib/release-guard.d.ts.map +1 -0
  23. package/dist/lib/release-guard.js +46 -0
  24. package/dist/lib/release-guard.js.map +1 -0
  25. package/dist/lib/release-guard.test.d.ts +2 -0
  26. package/dist/lib/release-guard.test.d.ts.map +1 -0
  27. package/dist/lib/release-guard.test.js +60 -0
  28. package/dist/lib/release-guard.test.js.map +1 -0
  29. package/package.json +10 -11
  30. package/scripts/vendor-codex-hooks.mjs +31 -0
  31. package/scripts/verify-publish-tarball.mjs +9 -0
  32. package/scripts/verify-release-artifacts.mjs +77 -0
  33. package/vendor/codex-hooks/lib.mjs +276 -0
  34. package/vendor/codex-hooks/session-start.mjs +29 -0
  35. package/vendor/codex-hooks/stop.mjs +78 -0
  36. package/vendor/codex-hooks/user-prompt-submit.mjs +95 -0
  37. package/vendor/sensing-mcp/dist/index.js +26 -1
  38. package/vendor/sensing-mcp/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=release-guard.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"release-guard.test.d.ts","sourceRoot":"","sources":["../../src/lib/release-guard.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,60 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { evaluateReleaseGuard, manifestStatusToPublished, originHasReleaseTag } from './release-guard.js';
4
+ describe('originHasReleaseTag', () => {
5
+ const lsRemote = [
6
+ 'aaa111\trefs/tags/v2.3.4',
7
+ 'bbb222\trefs/tags/v2.3.5',
8
+ 'ccc333\trefs/tags/v2.3.5^{}',
9
+ '',
10
+ ].join('\n');
11
+ it('finds a lightweight tag ref', () => {
12
+ assert.equal(originHasReleaseTag(lsRemote, '2.3.4'), true);
13
+ });
14
+ it('finds an annotated tag via its peeled ref', () => {
15
+ assert.equal(originHasReleaseTag('ccc333\trefs/tags/v2.3.5^{}\n', '2.3.5'), true);
16
+ });
17
+ it('does not match prefixes of longer versions', () => {
18
+ assert.equal(originHasReleaseTag('ddd444\trefs/tags/v2.3.45\n', '2.3.4'), false);
19
+ });
20
+ it('returns false when the tag is absent or output is empty', () => {
21
+ assert.equal(originHasReleaseTag(lsRemote, '2.3.7'), false);
22
+ assert.equal(originHasReleaseTag('', '2.3.7'), false);
23
+ });
24
+ });
25
+ describe('manifestStatusToPublished', () => {
26
+ it('reads 200 as published and 404 as missing', () => {
27
+ assert.equal(manifestStatusToPublished(200), true);
28
+ assert.equal(manifestStatusToPublished(404), false);
29
+ });
30
+ it('throws on auth errors and outages instead of treating them as a missing image', () => {
31
+ assert.throws(() => manifestStatusToPublished(401));
32
+ assert.throws(() => manifestStatusToPublished(429));
33
+ assert.throws(() => manifestStatusToPublished(502));
34
+ });
35
+ });
36
+ describe('evaluateReleaseGuard', () => {
37
+ const base = {
38
+ version: '2.3.7',
39
+ imageRepo: 'ghcr.io/adelinamart/robrain-perception',
40
+ };
41
+ it('passes when the tag is on origin and the image is on GHCR', () => {
42
+ const result = evaluateReleaseGuard({ ...base, originHasTag: true, imagePublished: true });
43
+ assert.equal(result.ok, true);
44
+ assert.deepEqual(result.problems, []);
45
+ });
46
+ it('fails with both problems when tag and image are missing (the 2.3.6/2.3.7 incident)', () => {
47
+ const result = evaluateReleaseGuard({ ...base, originHasTag: false, imagePublished: false });
48
+ assert.equal(result.ok, false);
49
+ assert.equal(result.problems.length, 2);
50
+ assert.match(result.problems[0], /git push origin v2\.3\.7/);
51
+ assert.match(result.problems[1], /robrain-perception:2\.3\.7 is not published/);
52
+ });
53
+ it('fails on image-only gap when the tag is pushed but the workflow has not finished', () => {
54
+ const result = evaluateReleaseGuard({ ...base, originHasTag: true, imagePublished: false });
55
+ assert.equal(result.ok, false);
56
+ assert.equal(result.problems.length, 1);
57
+ assert.match(result.problems[0], /publish-perception-image\.yml/);
58
+ });
59
+ });
60
+ //# sourceMappingURL=release-guard.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"release-guard.test.js","sourceRoot":"","sources":["../../src/lib/release-guard.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robrain",
3
- "version": "2.3.7",
3
+ "version": "2.3.8",
4
4
  "type": "module",
5
5
  "description": "RoBrain CLI for installation, setup, and memory workflows",
6
6
  "author": "Rory Plans, Inc.",
@@ -41,15 +41,6 @@
41
41
  "publishConfig": {
42
42
  "access": "public"
43
43
  },
44
- "scripts": {
45
- "build": "tsc",
46
- "prepublishOnly": "pnpm build && node scripts/vendor-sensing-mcp.mjs && node scripts/vendor-hermes-plugin.mjs",
47
- "dev": "tsc --watch",
48
- "typecheck": "tsc --noEmit",
49
- "test": "pnpm build && node --test dist/lib/editor.codex.test.js dist/lib/mcp-bundle.test.js dist/lib/claude-plugin.test.js dist/lib/hermes-plugin.test.js dist/commands/outcomes.test.js dist/commands/export-interchange.test.js dist/commands/up.test.js dist/commands/mcp.test.js",
50
- "pack:verify": "node scripts/verify-publish-tarball.mjs",
51
- "postinstall": "node scripts/chmod-bin.cjs"
52
- },
53
44
  "dependencies": {
54
45
  "dotenv": "^16.4.5",
55
46
  "chalk": "^5.3.0",
@@ -61,5 +52,13 @@
61
52
  "typescript": "^5.4.0",
62
53
  "@types/node": "^20.0.0",
63
54
  "@types/prompts": "^2.4.9"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc",
58
+ "dev": "tsc --watch",
59
+ "typecheck": "tsc --noEmit",
60
+ "test": "pnpm build && node --test dist/lib/editor.codex.test.js dist/lib/release-guard.test.js dist/lib/mcp-bundle.test.js dist/lib/claude-plugin.test.js dist/lib/hermes-plugin.test.js dist/lib/codex-hooks.test.js dist/commands/outcomes.test.js dist/commands/export-interchange.test.js dist/commands/up.test.js dist/commands/mcp.test.js",
61
+ "pack:verify": "node scripts/verify-publish-tarball.mjs",
62
+ "postinstall": "node scripts/chmod-bin.cjs"
64
63
  }
65
- }
64
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // Assemble the Codex hook scripts into vendor/codex-hooks for the published
3
+ // tarball: the shared scripts come from the Claude plugin (single source),
4
+ // stop.mjs from the Codex plugin. Same pattern as vendor-sensing-mcp.
5
+ import { cpSync, existsSync, mkdirSync, rmSync } from 'fs'
6
+ import { dirname, join } from 'path'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ const cliRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const claudeHooks = join(cliRoot, '..', '..', 'plugins', 'claude-code', 'hooks')
11
+ const codexHooks = join(cliRoot, '..', '..', 'plugins', 'codex', 'hooks')
12
+ const dest = join(cliRoot, 'vendor', 'codex-hooks')
13
+
14
+ const wanted = [
15
+ [claudeHooks, 'lib.mjs'],
16
+ [claudeHooks, 'session-start.mjs'],
17
+ [claudeHooks, 'user-prompt-submit.mjs'],
18
+ [codexHooks, 'stop.mjs'],
19
+ ]
20
+
21
+ for (const [dir, f] of wanted) {
22
+ if (!existsSync(join(dir, f))) {
23
+ console.error('vendor-codex-hooks: missing', join(dir, f))
24
+ process.exit(1)
25
+ }
26
+ }
27
+
28
+ rmSync(dest, { recursive: true, force: true })
29
+ mkdirSync(dest, { recursive: true })
30
+ for (const [dir, f] of wanted) cpSync(join(dir, f), join(dest, f))
31
+ console.log(`Vendored codex hooks → packages/cli/vendor/codex-hooks`)
@@ -20,6 +20,7 @@ mkdirSync(work, { recursive: true })
20
20
  try {
21
21
  execFileSync('node', ['scripts/vendor-sensing-mcp.mjs'], { cwd: cliRoot, stdio: 'inherit' })
22
22
  execFileSync('node', ['scripts/vendor-hermes-plugin.mjs'], { cwd: cliRoot, stdio: 'inherit' })
23
+ execFileSync('node', ['scripts/vendor-codex-hooks.mjs'], { cwd: cliRoot, stdio: 'inherit' })
23
24
 
24
25
  execFileSync('pnpm', ['pack', '--pack-destination', work], { cwd: cliRoot, stdio: 'inherit' })
25
26
 
@@ -70,6 +71,14 @@ try {
70
71
  fail(`installHermesPlugin() copy incomplete at ${dest}`)
71
72
  }
72
73
 
74
+ // Codex hooks: vendored scripts present and installable from the tarball.
75
+ const codexHooksUrl = pathToFileURL(join(extracted, 'dist', 'lib', 'codex-hooks.js')).href
76
+ const { installCodexHooks } = await import(codexHooksUrl)
77
+ const codexHooksDest = installCodexHooks(join(work, 'codex-hooks'))
78
+ if (!codexHooksDest || !existsSync(join(codexHooksDest, 'stop.mjs'))) {
79
+ fail('installCodexHooks() could not materialize from the tarball vendor dir')
80
+ }
81
+
73
82
  // Smoke-test the server entry robrain mcp execs (no CLI deps needed in the tarball).
74
83
  const mcpEntry = join(extracted, 'vendor', 'sensing-mcp', 'dist', 'index.js')
75
84
  await new Promise((resolve, reject) => {
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // Publish gate: refuse to publish a CLI version whose GHCR Perception image
3
+ // does not exist — `robrain up` defaults its Docker tag to the CLI version, so
4
+ // npm-without-image breaks every no-clone install. Runs in prepublishOnly
5
+ // after the build; evaluation logic lives in dist/lib/release-guard.js.
6
+ // Emergency bypass: ROBRAIN_SKIP_RELEASE_GUARD=1 pnpm publish
7
+ import { execFileSync } from 'child_process'
8
+ import { readFileSync } from 'fs'
9
+ import { dirname, join } from 'path'
10
+ import { fileURLToPath, pathToFileURL } from 'url'
11
+
12
+ const cliRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
13
+
14
+ if (process.env.ROBRAIN_SKIP_RELEASE_GUARD === '1') {
15
+ console.warn('release-guard SKIPPED via ROBRAIN_SKIP_RELEASE_GUARD=1 — the GHCR image for this version may not exist.')
16
+ process.exit(0)
17
+ }
18
+
19
+ const { evaluateReleaseGuard, manifestStatusToPublished, originHasReleaseTag } =
20
+ await import(pathToFileURL(join(cliRoot, 'dist', 'lib', 'release-guard.js')).href)
21
+ const { DEFAULT_IMAGE_REPO } = await import(pathToFileURL(join(cliRoot, 'dist', 'commands', 'up.js')).href)
22
+
23
+ const version = JSON.parse(readFileSync(join(cliRoot, 'package.json'), 'utf8')).version
24
+
25
+ function fail(problems) {
26
+ console.error(`\nrelease-guard FAILED for robrain@${version}:\n`)
27
+ for (const p of problems) console.error(` ✗ ${p}\n`)
28
+ console.error(' (Emergency bypass: ROBRAIN_SKIP_RELEASE_GUARD=1)\n')
29
+ process.exit(1)
30
+ }
31
+
32
+ let lsRemote
33
+ try {
34
+ lsRemote = execFileSync('git', ['ls-remote', '--tags', 'origin', `refs/tags/v${version}*`], {
35
+ cwd: cliRoot,
36
+ encoding: 'utf8',
37
+ })
38
+ } catch (err) {
39
+ fail([`could not query origin tags (git ls-remote failed): ${err.message}`])
40
+ }
41
+
42
+ // Anonymous pull-scope token, then HEAD the version's manifest — per-tag, so
43
+ // immune to tags/list pagination as releases accumulate.
44
+ const ghcrRepo = DEFAULT_IMAGE_REPO.replace(/^ghcr\.io\//, '')
45
+ let imagePublished
46
+ try {
47
+ const tokenRes = await fetch(`https://ghcr.io/token?scope=repository:${ghcrRepo}:pull`)
48
+ const { token } = await tokenRes.json()
49
+ const manifestRes = await fetch(`https://ghcr.io/v2/${ghcrRepo}/manifests/${version}`, {
50
+ method: 'HEAD',
51
+ headers: {
52
+ Authorization: `Bearer ${token}`,
53
+ // The multi-arch index plus single-manifest fallbacks; without these
54
+ // some registries 404 even for tags that exist.
55
+ Accept: [
56
+ 'application/vnd.oci.image.index.v1+json',
57
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
58
+ 'application/vnd.oci.image.manifest.v1+json',
59
+ 'application/vnd.docker.distribution.manifest.v2+json',
60
+ ].join(', '),
61
+ },
62
+ })
63
+ imagePublished = manifestStatusToPublished(manifestRes.status)
64
+ } catch (err) {
65
+ fail([`could not check ${DEFAULT_IMAGE_REPO}:${version} on GHCR: ${err.message}`])
66
+ }
67
+
68
+ const result = evaluateReleaseGuard({
69
+ version,
70
+ imageRepo: DEFAULT_IMAGE_REPO,
71
+ originHasTag: originHasReleaseTag(lsRemote, version),
72
+ imagePublished,
73
+ })
74
+
75
+ if (!result.ok) fail(result.problems)
76
+
77
+ console.log(`release-guard ok — v${version} tag on origin, ${DEFAULT_IMAGE_REPO}:${version} published`)
@@ -0,0 +1,276 @@
1
+ // plugins/claude-code/hooks/lib.mjs
2
+ // ─────────────────────────────────────────────────────────────
3
+ // Shared plumbing for RoBrain Claude Code plugin hooks.
4
+ //
5
+ // Design constraints:
6
+ // - Zero dependencies, Node >= 18 (built-in fetch). Hooks must start fast.
7
+ // - Fail-open ALWAYS: a dead Perception, missing config, or timeout must
8
+ // never block or break the user's Claude Code session. Hooks print nothing
9
+ // and exit 0 on any failure.
10
+ // - Thin client: raw turns go to Perception POST /signals with
11
+ // needs_classification=true — classification/extraction/dedup happen
12
+ // server-side, so this plugin (and future Codex/Cursor ports) stays dumb.
13
+ // ─────────────────────────────────────────────────────────────
14
+
15
+ import { createHash } from 'node:crypto'
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ /** Read all of stdin (the hook input JSON). */
21
+ export async function readStdin() {
22
+ const chunks = []
23
+ for await (const chunk of process.stdin) chunks.push(chunk)
24
+ return Buffer.concat(chunks).toString('utf8')
25
+ }
26
+
27
+ export function parseHookInput(raw) {
28
+ try {
29
+ return JSON.parse(raw)
30
+ } catch {
31
+ return {}
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Perception connection: env wins, then ~/.robrain/config.json (written by
37
+ * `robrain up` / `robrain install`), then localhost default.
38
+ */
39
+ export function loadPerception() {
40
+ let url = process.env.PERCEPTION_URL?.trim() || ''
41
+ let key = process.env.PERCEPTION_API_KEY?.trim() || ''
42
+ if (!url || !key) {
43
+ try {
44
+ const cfg = JSON.parse(readFileSync(join(homedir(), '.robrain', 'config.json'), 'utf8'))
45
+ if (!url && typeof cfg.perceptionUrl === 'string') url = cfg.perceptionUrl.trim()
46
+ if (!key && typeof cfg.perceptionKey === 'string') key = cfg.perceptionKey.trim()
47
+ } catch {
48
+ // no config yet — fall through to defaults
49
+ }
50
+ }
51
+ return { url: url || 'http://localhost:3001', key }
52
+ }
53
+
54
+ /**
55
+ * Same derivation as the CLI (packages/cli/src/lib/project.ts): prefer the
56
+ * project_id init-project wrote into CLAUDE.md / AGENTS.md / the Cursor rule,
57
+ * else a stable hash of the working directory.
58
+ */
59
+ export function resolveProjectId(cwd) {
60
+ for (const rel of ['CLAUDE.md', 'AGENTS.md', join('.cursor', 'rules', 'robrain.mdc')]) {
61
+ const p = join(cwd, rel)
62
+ try {
63
+ if (!existsSync(p)) continue
64
+ const match = readFileSync(p, 'utf8').match(/project_id="([^"]+)"/)
65
+ if (match?.[1]) return match[1].trim()
66
+ } catch {
67
+ // unreadable file — try the next candidate
68
+ }
69
+ }
70
+ return createHash('sha256').update(cwd).digest('hex').slice(0, 12)
71
+ }
72
+
73
+ /** fetch with a hard timeout; returns null instead of throwing. */
74
+ export async function perceptionFetch(path, { url, key }, init = {}, timeoutMs = 2500) {
75
+ try {
76
+ const res = await fetch(`${url}${path}`, {
77
+ ...init,
78
+ headers: {
79
+ 'Content-Type': 'application/json',
80
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
81
+ ...(init.headers ?? {}),
82
+ },
83
+ signal: AbortSignal.timeout(timeoutMs),
84
+ })
85
+ if (!res.ok) return null
86
+ return await res.json()
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Extract the trailing turn from a Claude Code transcript (JSONL): the last
94
+ * user text message and every assistant text block after it, plus file paths
95
+ * touched by Edit/Write/NotebookEdit tool calls in that span.
96
+ */
97
+ export function extractLastTurn(transcriptPath) {
98
+ let lines
99
+ try {
100
+ lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean)
101
+ } catch {
102
+ return null
103
+ }
104
+
105
+ let lastUserIdx = -1
106
+ const entries = []
107
+ for (const line of lines) {
108
+ let e
109
+ try {
110
+ e = JSON.parse(line)
111
+ } catch {
112
+ continue
113
+ }
114
+ entries.push(e)
115
+ }
116
+
117
+ for (let i = entries.length - 1; i >= 0; i--) {
118
+ const e = entries[i]
119
+ if (e.type === 'user' && !e.isMeta && typeof textOf(e.message?.content) === 'string' && textOf(e.message.content).trim()) {
120
+ // Skip tool_result-only user entries (tool outputs come back as user role)
121
+ if (hasOnlyToolResults(e.message?.content)) continue
122
+ lastUserIdx = i
123
+ break
124
+ }
125
+ }
126
+ if (lastUserIdx === -1) return null
127
+
128
+ const userMessage = textOf(entries[lastUserIdx].message?.content) ?? ''
129
+ const replyParts = []
130
+ const filesTouched = new Set()
131
+
132
+ for (let i = lastUserIdx + 1; i < entries.length; i++) {
133
+ const e = entries[i]
134
+ if (e.type !== 'assistant') continue
135
+ const content = e.message?.content
136
+ if (!Array.isArray(content)) continue
137
+ for (const block of content) {
138
+ if (block.type === 'text' && block.text?.trim()) replyParts.push(block.text)
139
+ if (block.type === 'tool_use') {
140
+ const fp = block.input?.file_path ?? block.input?.notebook_path
141
+ if (typeof fp === 'string' && fp) filesTouched.add(fp)
142
+ }
143
+ }
144
+ }
145
+
146
+ if (!userMessage.trim() || replyParts.length === 0) return null
147
+
148
+ // Sequence: number of user-authored messages up to and including this one —
149
+ // stable across re-reads of the same transcript.
150
+ let sequence = 0
151
+ for (let i = 0; i <= lastUserIdx; i++) {
152
+ const e = entries[i]
153
+ if (e.type === 'user' && !e.isMeta && !hasOnlyToolResults(e.message?.content) && textOf(e.message?.content)?.trim()) sequence++
154
+ }
155
+
156
+ return {
157
+ userMessage,
158
+ claudeReply: replyParts.join('\n\n'),
159
+ filesTouched: [...filesTouched].slice(0, 50),
160
+ sequence: Math.max(1, sequence),
161
+ }
162
+ }
163
+
164
+ function textOf(content) {
165
+ if (typeof content === 'string') return content
166
+ if (Array.isArray(content)) {
167
+ return content.filter(b => b.type === 'text').map(b => b.text ?? '').join('\n')
168
+ }
169
+ return null
170
+ }
171
+
172
+ function hasOnlyToolResults(content) {
173
+ return Array.isArray(content) && content.length > 0 && content.every(b => b.type === 'tool_result')
174
+ }
175
+
176
+ // ── Session-scoped warning state ──────────────────────────────
177
+ // The same veto re-injected on every on-topic prompt is context spend for
178
+ // zero new information — Control must never cost more context than it saves.
179
+
180
+ const STATE_DIR = join(homedir(), '.robrain', 'state')
181
+
182
+ function warnedFile(sessionId) {
183
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
184
+ return join(STATE_DIR, `cc-warned-${safe}.json`)
185
+ }
186
+
187
+ /** Decision ids already warned about in this session. */
188
+ export function loadWarnedIds(sessionId) {
189
+ if (!sessionId) return new Set()
190
+ try {
191
+ const ids = JSON.parse(readFileSync(warnedFile(sessionId), 'utf8'))
192
+ return new Set(Array.isArray(ids) ? ids : [])
193
+ } catch {
194
+ return new Set()
195
+ }
196
+ }
197
+
198
+ export function recordWarnedIds(sessionId, ids) {
199
+ if (!sessionId || ids.length === 0) return
200
+ try {
201
+ mkdirSync(STATE_DIR, { recursive: true })
202
+ const merged = [...new Set([...loadWarnedIds(sessionId), ...ids])].slice(-200)
203
+ writeFileSync(warnedFile(sessionId), JSON.stringify(merged))
204
+ cleanupStateDir()
205
+ } catch {
206
+ // fail-open — a broken state file must never block the session
207
+ }
208
+ }
209
+
210
+ /** Best-effort hygiene: drop state files older than 7 days. */
211
+ function cleanupStateDir() {
212
+ try {
213
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000
214
+ for (const f of readdirSync(STATE_DIR)) {
215
+ const p = join(STATE_DIR, f)
216
+ try {
217
+ if (statSync(p).mtimeMs < cutoff) unlinkSync(p)
218
+ } catch {
219
+ // race or permissions — skip
220
+ }
221
+ }
222
+ } catch {
223
+ // dir unreadable — skip
224
+ }
225
+ }
226
+
227
+ // ── Per-session prompt stash ──────────────────────────────────
228
+ // Written by the UserPromptSubmit hook, read by stop hooks that cannot rely
229
+ // on a parseable transcript (Codex ships last_assistant_message on Stop stdin
230
+ // but its transcript format is not a contract we own). Pairing the stashed
231
+ // prompt with that message gives deterministic capture with zero transcript
232
+ // assumptions.
233
+
234
+ function stashFile(sessionId) {
235
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
236
+ return join(STATE_DIR, `stash-${safe}.json`)
237
+ }
238
+
239
+ export function stashPrompt(sessionId, prompt) {
240
+ if (!sessionId || !prompt) return
241
+ try {
242
+ mkdirSync(STATE_DIR, { recursive: true })
243
+ const prev = loadPromptStash(sessionId)
244
+ writeFileSync(stashFile(sessionId), JSON.stringify({
245
+ prompt: prompt.slice(0, 60_000),
246
+ sequence: (prev?.sequence ?? 0) + 1,
247
+ }))
248
+ } catch {
249
+ // fail-open
250
+ }
251
+ }
252
+
253
+ export function loadPromptStash(sessionId) {
254
+ if (!sessionId) return null
255
+ try {
256
+ const s = JSON.parse(readFileSync(stashFile(sessionId), 'utf8'))
257
+ return typeof s?.prompt === 'string' ? s : null
258
+ } catch {
259
+ return null
260
+ }
261
+ }
262
+
263
+ /** Emit hook JSON output (context injection) and exit 0. */
264
+ export function emitContext(hookEventName, additionalContext) {
265
+ if (additionalContext) {
266
+ process.stdout.write(JSON.stringify({
267
+ hookSpecificOutput: { hookEventName, additionalContext },
268
+ }))
269
+ }
270
+ process.exit(0)
271
+ }
272
+
273
+ /** Silent success — hooks must never fail the session. */
274
+ export function exitSilently() {
275
+ process.exit(0)
276
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook — inject the always-on project summary (top decisions with
3
+ // their rejected alternatives) at the start of every Claude Code session.
4
+ // Deterministic replacement for the model remembering to call sensing_start_session.
5
+
6
+ import {
7
+ readStdin, parseHookInput, loadPerception, resolveProjectId,
8
+ perceptionFetch, emitContext, exitSilently,
9
+ } from './lib.mjs'
10
+
11
+ const input = parseHookInput(await readStdin())
12
+ const cwd = input.cwd ?? process.cwd()
13
+ const perception = loadPerception()
14
+ const projectId = resolveProjectId(cwd)
15
+
16
+ const summary = await perceptionFetch(`/projects/${projectId}/summary`, perception, {}, 3000)
17
+ const text = summary?.summary ?? summary?.always_on_summary
18
+ if (!text || typeof text !== 'string' || !text.trim()) exitSilently()
19
+
20
+ emitContext(
21
+ 'SessionStart',
22
+ [
23
+ '## RoBrain project memory (always-on summary)',
24
+ 'Prior decisions for this project, including rejected alternatives. Respect the rejections —',
25
+ 'if a task invites a previously rejected approach, surface the rejection instead of re-proposing it.',
26
+ '',
27
+ text.trim(),
28
+ ].join('\n'),
29
+ )
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // Codex Stop hook — deterministic turn capture for Codex CLI / IDE.
3
+ //
4
+ // Codex's Stop stdin carries `last_assistant_message` directly, and the
5
+ // UserPromptSubmit hook stashed the user prompt — so capture needs ZERO
6
+ // assumptions about Codex's transcript format (which is not a contract we
7
+ // own). The transcript, when it happens to parse as Claude-style JSONL, only
8
+ // enriches the signal with files touched.
9
+ //
10
+ // Ships the turn to Perception POST /signals with needs_classification=true;
11
+ // extraction, the confidence gate, and near-duplicate dedup run server-side —
12
+ // including dedup against turns captured via the Sensing MCP protocol, so
13
+ // running both double-captures nothing.
14
+
15
+ import {
16
+ readStdin, parseHookInput, loadPerception, resolveProjectId,
17
+ perceptionFetch, extractLastTurn, loadPromptStash, exitSilently,
18
+ } from './lib.mjs'
19
+
20
+ const input = parseHookInput(await readStdin())
21
+
22
+ // stop_hook_active guards re-entry when a stop hook itself continues the turn.
23
+ if (input.stop_hook_active) exitSilently()
24
+
25
+ const sessionId = typeof input.session_id === 'string' ? input.session_id : ''
26
+ const cwd = input.cwd ?? process.cwd()
27
+ if (!sessionId) exitSilently()
28
+
29
+ // Reply: stdin first; transcript parse as fallback.
30
+ let reply = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : ''
31
+
32
+ // Prompt: the UserPromptSubmit stash is authoritative; transcript is fallback.
33
+ const stash = loadPromptStash(sessionId)
34
+ let userMessage = stash?.prompt ?? ''
35
+ let sequence = stash?.sequence ?? 1
36
+ let filesTouched = []
37
+
38
+ if ((!userMessage || !reply) && input.transcript_path) {
39
+ const turn = extractLastTurn(input.transcript_path)
40
+ if (turn) {
41
+ if (!userMessage) { userMessage = turn.userMessage; sequence = turn.sequence }
42
+ if (!reply) reply = turn.claudeReply
43
+ filesTouched = turn.filesTouched
44
+ }
45
+ } else if (input.transcript_path) {
46
+ // Both present — transcript only enriches with files touched, best-effort.
47
+ const turn = extractLastTurn(input.transcript_path)
48
+ if (turn) filesTouched = turn.filesTouched
49
+ }
50
+
51
+ if (!userMessage.trim() || !reply.trim()) exitSilently()
52
+
53
+ const perception = loadPerception()
54
+ const projectId = resolveProjectId(cwd)
55
+
56
+ await perceptionFetch('/signals', perception, {
57
+ method: 'POST',
58
+ headers: { 'X-Project-Id': projectId },
59
+ body: JSON.stringify({
60
+ signal: {
61
+ turn: {
62
+ session_id: `codex-${sessionId}`.slice(0, 200),
63
+ sequence,
64
+ user_message: userMessage.slice(0, 60_000),
65
+ claude_reply: reply.slice(0, 60_000),
66
+ files_touched: filesTouched,
67
+ timestamp: new Date().toISOString(),
68
+ },
69
+ decision_type: 'unclassified',
70
+ confidence: 0,
71
+ files_affected: filesTouched,
72
+ scope: 'team',
73
+ needs_classification: true,
74
+ },
75
+ }),
76
+ }, 8000)
77
+
78
+ exitSilently()