dual-brain 0.2.17 → 0.2.19
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/CLAUDE.md +8 -6
- package/bin/dual-brain.mjs +8 -5
- package/hooks/head-guard.mjs +6 -10
- package/install.mjs +2 -2
- package/package.json +1 -1
- package/scripts/verify-publish.mjs +40 -3
- package/src/cognitive-loop.mjs +6 -1
- package/src/head.mjs +107 -0
- package/src/session-lock.mjs +6 -0
package/CLAUDE.md
CHANGED
|
@@ -7,12 +7,14 @@ This project uses dual-provider orchestration. Config: `.claude/orchestrator.jso
|
|
|
7
7
|
HEAD is the orchestration brain. Workers implement. This is enforced by architecture, not just policy.
|
|
8
8
|
|
|
9
9
|
1. **HEAD plans, workers implement.** HEAD dispatches typed task contracts via agents. HEAD never edits files, runs implementation commands, or writes code directly.
|
|
10
|
-
2. **
|
|
11
|
-
3. **
|
|
12
|
-
4. **
|
|
13
|
-
5. **
|
|
14
|
-
6. **
|
|
15
|
-
7. **
|
|
10
|
+
2. **Think before acting — always.** HEAD applies the same cognitive rigor to its own responses as it does to dispatches. Before proposing actions: assess depth, consider scope, check if the request needs thinking or just execution. Never list things to build without first determining if they should be built. This applies to conversations, not just agent calls.
|
|
11
|
+
3. **Discuss before dispatching.** Every action task starts with intent classification. Ambiguous requests get clarified. Architecture decisions get discussed.
|
|
12
|
+
4. **Typed contracts are mandatory.** Every dispatch includes: objective, scope, acceptance criteria, risk level, allowed operations. Use `src/templates.mjs` to generate prompts.
|
|
13
|
+
5. **Dangerous work requires approval.** Auth, credentials, secrets, billing, migrations, destructive git — explicit user confirmation before dispatch.
|
|
14
|
+
6. **Complete the cycle.** HEAD finishes what it starts: implement → test → commit → push → publish. Don't stop halfway and ask the user to do admin. If the system can do it, HEAD does it.
|
|
15
|
+
7. **Runtime state is source of truth.** HEAD's state machine (`src/head.mjs`) tracks phase, intent, confidence, and drift. Not CLAUDE.md text.
|
|
16
|
+
8. **Hooks enforce boundaries.** head-guard blocks HEAD from implementing. enforce-tier ensures correct routing. Telemetry hooks observe but never block.
|
|
17
|
+
9. **Subscription-only auth.** Users authenticate via `claude login` / `codex login`. No API keys.
|
|
16
18
|
|
|
17
19
|
## Quick Reference
|
|
18
20
|
|
package/bin/dual-brain.mjs
CHANGED
|
@@ -1151,7 +1151,10 @@ async function installGlobal() {
|
|
|
1151
1151
|
|
|
1152
1152
|
// Resolve absolute path to hooks directory via import.meta.url
|
|
1153
1153
|
const pkgRoot = join(__dirname, '..');
|
|
1154
|
-
|
|
1154
|
+
// Hooks live at hooks/ in the published package, .claude/hooks/ in dev
|
|
1155
|
+
const hooksDir = existsSync(join(pkgRoot, 'hooks', 'head-guard.mjs'))
|
|
1156
|
+
? join(pkgRoot, 'hooks')
|
|
1157
|
+
: join(pkgRoot, '.claude', 'hooks');
|
|
1155
1158
|
|
|
1156
1159
|
// Warn if running from npx (ephemeral path)
|
|
1157
1160
|
if (pkgRoot.includes('.npm/_npx') || pkgRoot.includes('npx-')) {
|
|
@@ -1178,9 +1181,9 @@ async function installGlobal() {
|
|
|
1178
1181
|
})();
|
|
1179
1182
|
|
|
1180
1183
|
if (hasProjectLocalHooks) {
|
|
1181
|
-
console.log(' hooks
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
+
console.log(' project-local hooks detected (will take precedence in this workspace)');
|
|
1185
|
+
}
|
|
1186
|
+
{
|
|
1184
1187
|
// Load existing settings (merge, never clobber)
|
|
1185
1188
|
let existing = {};
|
|
1186
1189
|
if (existsSync(globalSettingsPath)) {
|
|
@@ -4479,7 +4482,7 @@ async function askDefaultShell(cwd, rl, fx) {
|
|
|
4479
4482
|
` ${DIM}modifies${RST} ${YLW}.replit onBoot${RST}`,
|
|
4480
4483
|
` ${DIM}undo${RST} Settings → System → Startup`,
|
|
4481
4484
|
'',
|
|
4482
|
-
` ${CYAN}[
|
|
4485
|
+
` ${CYAN}[Enter]${RST} Start on boot ${DIM}[n] Run manually${RST}`,
|
|
4483
4486
|
];
|
|
4484
4487
|
process.stdout.write('\n' + panel('dual-brain setup', setupContent) + '\n');
|
|
4485
4488
|
|
package/hooks/head-guard.mjs
CHANGED
|
@@ -31,16 +31,12 @@ try {
|
|
|
31
31
|
const raw = readFileSync('/dev/stdin', 'utf8');
|
|
32
32
|
input = JSON.parse(raw);
|
|
33
33
|
} catch {
|
|
34
|
-
// Can't parse input — fail
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
process.stdout.write(JSON.stringify(output));
|
|
43
|
-
process.exit(2);
|
|
34
|
+
// Can't parse input — fail open. This hook's purpose is to block HEAD from
|
|
35
|
+
// implementing directly. If we can't parse stdin (e.g. subagent context where
|
|
36
|
+
// Claude Code doesn't pipe parseable JSON), blocking would incorrectly deny
|
|
37
|
+
// work agents. Allowing is safer: worst case HEAD slips through once, but
|
|
38
|
+
// work agents aren't blocked.
|
|
39
|
+
process.exit(0);
|
|
44
40
|
}
|
|
45
41
|
|
|
46
42
|
const toolName = input.tool_name || '';
|
package/install.mjs
CHANGED
|
@@ -913,9 +913,9 @@ function install(workspace, env, mode) {
|
|
|
913
913
|
'gpt-work-dispatcher.mjs', 'profiles.mjs',
|
|
914
914
|
'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
|
|
915
915
|
'risk-classifier.mjs', 'failure-detector.mjs',
|
|
916
|
-
'
|
|
916
|
+
'plan-generator.mjs', 'vibe-memory.mjs',
|
|
917
917
|
'wave-orchestrator.mjs',
|
|
918
|
-
'
|
|
918
|
+
'model-registry.mjs',
|
|
919
919
|
'auto-update-wrapper.mjs',
|
|
920
920
|
'head-guard.mjs',
|
|
921
921
|
];
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
|
|
3
5
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
4
6
|
const url = `https://registry.npmjs.org/dual-brain/${version}`;
|
|
5
7
|
const maxWait = 30000;
|
|
@@ -10,17 +12,52 @@ async function check() {
|
|
|
10
12
|
const res = await fetch(url);
|
|
11
13
|
if (res.ok) {
|
|
12
14
|
console.log(`✓ dual-brain@${version} verified on registry`);
|
|
13
|
-
return;
|
|
15
|
+
return true;
|
|
14
16
|
}
|
|
15
17
|
} catch {}
|
|
16
18
|
|
|
17
19
|
if (Date.now() - start > maxWait) {
|
|
18
20
|
console.log(`⚠ dual-brain@${version} published but CDN propagation may take a moment`);
|
|
19
|
-
return;
|
|
21
|
+
return true;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
await new Promise(r => setTimeout(r, 3000));
|
|
23
25
|
return check();
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
async function commitAndPush() {
|
|
29
|
+
try {
|
|
30
|
+
// Check if there are changes to commit
|
|
31
|
+
const status = execSync('git status --porcelain', { encoding: 'utf8' }).trim();
|
|
32
|
+
if (!status) return;
|
|
33
|
+
|
|
34
|
+
// Stage all tracked + new src/hooks/bin files (not .dualbrain/ runtime state)
|
|
35
|
+
execSync('git add src/ hooks/ bin/ scripts/ package.json CLAUDE.md .claude/ .replit .gitignore tests/ 2>/dev/null || true', { encoding: 'utf8' });
|
|
36
|
+
|
|
37
|
+
// Check if anything was staged
|
|
38
|
+
const staged = execSync('git diff --cached --stat', { encoding: 'utf8' }).trim();
|
|
39
|
+
if (!staged) return;
|
|
40
|
+
|
|
41
|
+
execSync(`git commit -m "${version}: publish"`, { encoding: 'utf8' });
|
|
42
|
+
console.log(`✓ committed ${version}`);
|
|
43
|
+
|
|
44
|
+
// Push with explicit credential helper (Replit persistence)
|
|
45
|
+
const ghDir = '/home/runner/workspace/.replit-tools/.gh-persistent';
|
|
46
|
+
const credHelper = `!GH_CONFIG_DIR=${ghDir} gh auth git-credential`;
|
|
47
|
+
execSync(`git -c 'credential.https://github.com.helper=${credHelper}' push`, {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 15000,
|
|
50
|
+
env: { ...process.env, GH_CONFIG_DIR: ghDir },
|
|
51
|
+
});
|
|
52
|
+
console.log(`✓ pushed to origin`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Non-fatal — publish succeeded even if commit/push fails
|
|
55
|
+
const msg = e.message || '';
|
|
56
|
+
if (msg.includes('Authentication')) {
|
|
57
|
+
console.log(`⚠ push failed (git auth not configured) — commit is local`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const verified = await check();
|
|
63
|
+
if (verified) await commitAndPush();
|
package/src/cognitive-loop.mjs
CHANGED
|
@@ -114,12 +114,14 @@ export function enter(userMessage, context = {}) {
|
|
|
114
114
|
// Phase 1: Full cognitive pipeline
|
|
115
115
|
const turn = processTurn(headState, userMessage, context);
|
|
116
116
|
|
|
117
|
-
// Save situation for history
|
|
117
|
+
// Save situation for history (includes mode for turn-over-turn tracking)
|
|
118
|
+
const mode = turn.situation?.mode || { primary: 'work', confidence: 0.5 };
|
|
118
119
|
loopState.situationHistory.push({
|
|
119
120
|
ts: Date.now(),
|
|
120
121
|
depth: turn.depth,
|
|
121
122
|
action: turn.action.type,
|
|
122
123
|
confidence: turn.result.confidence.score,
|
|
124
|
+
mode: mode.primary,
|
|
123
125
|
});
|
|
124
126
|
|
|
125
127
|
// Surface update notice as a noticing
|
|
@@ -145,6 +147,7 @@ export function enter(userMessage, context = {}) {
|
|
|
145
147
|
surfaceNoticings: turn.result.surfaceNoticings,
|
|
146
148
|
plan: null,
|
|
147
149
|
nextDispatch: null,
|
|
150
|
+
mode,
|
|
148
151
|
};
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -194,6 +197,7 @@ export function enter(userMessage, context = {}) {
|
|
|
194
197
|
plan,
|
|
195
198
|
nextDispatch: prepared,
|
|
196
199
|
suggestion: prepared.blockers[0],
|
|
200
|
+
mode,
|
|
197
201
|
};
|
|
198
202
|
}
|
|
199
203
|
|
|
@@ -206,6 +210,7 @@ export function enter(userMessage, context = {}) {
|
|
|
206
210
|
plan,
|
|
207
211
|
nextDispatch: prepared,
|
|
208
212
|
estimatedCost: plan.estimatedCost,
|
|
213
|
+
mode,
|
|
209
214
|
};
|
|
210
215
|
}
|
|
211
216
|
|
package/src/head.mjs
CHANGED
|
@@ -100,6 +100,9 @@ export function perceive(message, context = {}) {
|
|
|
100
100
|
// Relationship signals — should HEAD ask, act, or advise?
|
|
101
101
|
const relationship = _assessRelationship(message, context, taskShape);
|
|
102
102
|
|
|
103
|
+
// Mode sensing — what energy is the user bringing?
|
|
104
|
+
const mode = detectMode(message, context);
|
|
105
|
+
|
|
103
106
|
return {
|
|
104
107
|
raw: message,
|
|
105
108
|
explicitAsk: message.trim(),
|
|
@@ -111,6 +114,7 @@ export function perceive(message, context = {}) {
|
|
|
111
114
|
taskShape,
|
|
112
115
|
material,
|
|
113
116
|
relationship,
|
|
117
|
+
mode,
|
|
114
118
|
|
|
115
119
|
// Depth signals for adaptive processing
|
|
116
120
|
ambiguity: taskShape.ambiguity,
|
|
@@ -125,6 +129,109 @@ export function perceive(message, context = {}) {
|
|
|
125
129
|
};
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
// ── Mode Sensing ────────────────────────────────────────────────────────────
|
|
133
|
+
// Detects user energy/intent mode. Re-evaluated every turn. Never announced.
|
|
134
|
+
// Shapes HEAD's response style, not its decisions.
|
|
135
|
+
|
|
136
|
+
const MODE_EXECUTE_WORDS = /^(go|do it|ship it|fix it|run it|push|merge|deploy|yes|ok do it|lets go|make it|just do it|ship|publish)$/i;
|
|
137
|
+
const MODE_IDEATE_WORDS = /\b(what if|imagine|wouldn't it be|picture this|feels like|i feel like|sort of like|wild idea|crazy thought|could we maybe|vibe)\b/i;
|
|
138
|
+
const MODE_EXPLORE_WORDS = /\b(how does|what is|where is|why does|explain|walk me through|show me|tell me about|i don't understand|new to)\b/i;
|
|
139
|
+
const MODE_DISCUSS_WORDS = /\b(what do you think|should we|tradeoffs?|pros and cons|is it better|alternatively|option|or should|concerns?|worry|weigh)\b/i;
|
|
140
|
+
const MODE_WORK_SIGNALS = /(`[^`]+`|\.mjs|\.ts|\.js|\.py|src\/|hooks\/|bin\/|\bfunction\b|\bclass\b|\bimport\b)/;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Detect user's conversational mode from message signals.
|
|
144
|
+
* Returns a probability distribution with the dominant mode.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} message
|
|
147
|
+
* @param {object} context
|
|
148
|
+
* @returns {{primary: string, confidence: number, scores: object, signals: string[]}}
|
|
149
|
+
*/
|
|
150
|
+
export function detectMode(message, context = {}) {
|
|
151
|
+
const scores = { execute: 0, ideate: 0, work: 0, explore: 0, discuss: 0 };
|
|
152
|
+
const signals = [];
|
|
153
|
+
const words = message.trim().split(/\s+/);
|
|
154
|
+
const len = words.length;
|
|
155
|
+
|
|
156
|
+
// ── Length signal (most predictive single feature) ──
|
|
157
|
+
if (len <= 4) { scores.execute += 3; signals.push('very-short'); }
|
|
158
|
+
else if (len <= 10) { scores.execute += 1; scores.work += 1; }
|
|
159
|
+
else if (len >= 80) { scores.ideate += 2; signals.push('long-message'); }
|
|
160
|
+
else if (len >= 40) { scores.ideate += 1; scores.discuss += 1; }
|
|
161
|
+
|
|
162
|
+
// ── Lexical signals ──
|
|
163
|
+
if (MODE_EXECUTE_WORDS.test(message.trim())) { scores.execute += 4; signals.push('execute-word'); }
|
|
164
|
+
if (MODE_IDEATE_WORDS.test(message)) { scores.ideate += 3; signals.push('ideate-word'); }
|
|
165
|
+
if (MODE_EXPLORE_WORDS.test(message)) { scores.explore += 3; signals.push('explore-word'); }
|
|
166
|
+
if (MODE_DISCUSS_WORDS.test(message)) { scores.discuss += 4; signals.push('discuss-word'); }
|
|
167
|
+
|
|
168
|
+
// ── Specificity signal (file paths, code references) ──
|
|
169
|
+
const specificityMatches = message.match(MODE_WORK_SIGNALS);
|
|
170
|
+
if (specificityMatches) {
|
|
171
|
+
scores.work += 2;
|
|
172
|
+
signals.push('has-specifics');
|
|
173
|
+
// Specifics + imperative = work, not ideate
|
|
174
|
+
if (/\b(add|change|update|refactor|fix|remove|rename|move)\b/i.test(message)) {
|
|
175
|
+
scores.work += 2;
|
|
176
|
+
signals.push('specific-action');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Punctuation signal (only meaningful in longer messages) ──
|
|
181
|
+
const questionMarks = (message.match(/\?/g) || []).length;
|
|
182
|
+
if (questionMarks >= 2) { scores.discuss += 2; scores.explore += 1; signals.push('multi-question'); }
|
|
183
|
+
else if (questionMarks === 1 && len > 5) { scores.explore += 1; scores.discuss += 1; }
|
|
184
|
+
|
|
185
|
+
if (/\.{3}|—|–/.test(message)) { scores.ideate += 1; signals.push('ellipsis-dash'); }
|
|
186
|
+
if (/\b(maybe|might|could|perhaps|wonder)\b/i.test(message)) { scores.ideate += 1; scores.discuss += 1; signals.push('hedging'); }
|
|
187
|
+
|
|
188
|
+
// ── Contextual signals ──
|
|
189
|
+
// If prior turn was a plan/proposal and this is short, likely execute
|
|
190
|
+
if (context._priorWasProposal && len <= 15) { scores.execute += 2; signals.push('post-proposal-short'); }
|
|
191
|
+
|
|
192
|
+
// First message in session tends to be higher-level
|
|
193
|
+
if (context._isFirstTurn) { scores.explore += 1; scores.discuss += 1; }
|
|
194
|
+
|
|
195
|
+
// ── Anti-signals (prevent false positives) ──
|
|
196
|
+
// "go on" = continue explaining, not execute. But bare "continue?" = proceed
|
|
197
|
+
if (/^go on\b|^keep going/i.test(message.trim())) {
|
|
198
|
+
scores.execute -= 3;
|
|
199
|
+
scores.discuss += 2;
|
|
200
|
+
}
|
|
201
|
+
// "actually wait" / "hold on" = pumping the brakes, shifting to discuss
|
|
202
|
+
if (/^(actually|wait|hold on|hang on)/i.test(message.trim()) && questionMarks > 0) {
|
|
203
|
+
scores.execute -= 2;
|
|
204
|
+
scores.discuss += 3;
|
|
205
|
+
signals.push('pumping-brakes');
|
|
206
|
+
}
|
|
207
|
+
// "what if X breaks" = work concern, not ideation
|
|
208
|
+
if (/what if.*(break|fail|crash|error)/i.test(message)) {
|
|
209
|
+
scores.ideate -= 2;
|
|
210
|
+
scores.work += 2;
|
|
211
|
+
}
|
|
212
|
+
// "could we refactor" with file = work, not ideate
|
|
213
|
+
if (/could we.*(refactor|change|update)/i.test(message) && specificityMatches) {
|
|
214
|
+
scores.ideate -= 2;
|
|
215
|
+
scores.work += 2;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Resolve: pick dominant mode, bias toward action when uncertain ──
|
|
219
|
+
// Floor all scores at 0
|
|
220
|
+
for (const k of Object.keys(scores)) { if (scores[k] < 0) scores[k] = 0; }
|
|
221
|
+
|
|
222
|
+
const total = Object.values(scores).reduce((a, b) => a + b, 0) || 1;
|
|
223
|
+
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
|
224
|
+
const [primary, primaryScore] = sorted[0];
|
|
225
|
+
const confidence = primaryScore / total;
|
|
226
|
+
|
|
227
|
+
// If confidence is low (< 0.35), bias toward action — BUT only if discuss/ideate aren't strong
|
|
228
|
+
if (confidence < 0.35 && scores.execute > 0 && scores.discuss <= 1 && scores.ideate <= 1) {
|
|
229
|
+
return { primary: 'execute', confidence: 0.4, scores, signals: [...signals, 'low-confidence-action-bias'] };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { primary, confidence, scores, signals };
|
|
233
|
+
}
|
|
234
|
+
|
|
128
235
|
function _inferTaskShape(message, context) {
|
|
129
236
|
const lower = message.toLowerCase();
|
|
130
237
|
|
package/src/session-lock.mjs
CHANGED
|
@@ -46,6 +46,12 @@ export function acquire({ force = false } = {}) {
|
|
|
46
46
|
return { acquired: true, sessionId: _sessionId, existingSession: null, mode: 'primary' };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Same process (re-entry within same session) — always grant
|
|
50
|
+
if (existing.pid === process.pid) {
|
|
51
|
+
_sessionId = existing.sessionId;
|
|
52
|
+
return { acquired: true, sessionId: _sessionId, existingSession: null, mode: 'primary' };
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
const age = Date.now() - existing.heartbeat;
|
|
50
56
|
|
|
51
57
|
if (age > STALE_THRESHOLD_MS || force) {
|