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.
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +17 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/up.test.js +2 -2
- package/dist/index.js +1 -1
- package/dist/lib/codex-hooks.d.ts +26 -0
- package/dist/lib/codex-hooks.d.ts.map +1 -0
- package/dist/lib/codex-hooks.js +65 -0
- package/dist/lib/codex-hooks.js.map +1 -0
- package/dist/lib/codex-hooks.test.d.ts +2 -0
- package/dist/lib/codex-hooks.test.d.ts.map +1 -0
- package/dist/lib/codex-hooks.test.js +64 -0
- package/dist/lib/codex-hooks.test.js.map +1 -0
- package/dist/lib/editor.codex.test.js +20 -0
- package/dist/lib/editor.codex.test.js.map +1 -1
- package/dist/lib/editor.d.ts +2 -0
- package/dist/lib/editor.d.ts.map +1 -1
- package/dist/lib/editor.js +28 -0
- package/dist/lib/editor.js.map +1 -1
- package/dist/lib/mcp-bundle.test.js +2 -2
- package/dist/lib/release-guard.d.ts +22 -0
- package/dist/lib/release-guard.d.ts.map +1 -0
- package/dist/lib/release-guard.js +46 -0
- package/dist/lib/release-guard.js.map +1 -0
- package/dist/lib/release-guard.test.d.ts +2 -0
- package/dist/lib/release-guard.test.d.ts.map +1 -0
- package/dist/lib/release-guard.test.js +60 -0
- package/dist/lib/release-guard.test.js.map +1 -0
- package/package.json +10 -11
- package/scripts/vendor-codex-hooks.mjs +31 -0
- package/scripts/verify-publish-tarball.mjs +9 -0
- package/scripts/verify-release-artifacts.mjs +77 -0
- package/vendor/codex-hooks/lib.mjs +276 -0
- package/vendor/codex-hooks/session-start.mjs +29 -0
- package/vendor/codex-hooks/stop.mjs +78 -0
- package/vendor/codex-hooks/user-prompt-submit.mjs +95 -0
- package/vendor/sensing-mcp/dist/index.js +26 -1
- package/vendor/sensing-mcp/package.json +1 -1
|
@@ -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.
|
|
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()
|