dual-brain 0.2.6 → 0.2.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/CLAUDE.md +29 -143
- package/bin/dual-brain.mjs +216 -79
- package/package.json +10 -2
- package/src/dispatch.mjs +87 -2
- package/src/head.mjs +353 -0
- package/src/health.mjs +156 -0
- package/src/integrity.mjs +245 -0
- package/src/prompt-audit.mjs +231 -0
- package/src/templates.mjs +223 -0
- package/src/tui.mjs +79 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* integrity.mjs — State integrity primitives for dual-brain
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - atomicWriteJson / readJsonSafe — safe JSON file I/O with schema versioning
|
|
6
|
+
* - acquireLock / releaseLock / withLock — advisory file locks
|
|
7
|
+
* - lockedUpdate — locked atomic read-modify-write
|
|
8
|
+
* - atomicAppend — append-only ledger with lock
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// 1. Atomic JSON writes
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Write JSON to filePath atomically via a temp file + rename.
|
|
20
|
+
* Adds _schemaVersion and _writtenAt to plain objects.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} filePath - Destination file path
|
|
23
|
+
* @param {*} data - Value to serialize
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {number} [opts.schemaVersion=1] - Schema version stamped into data
|
|
26
|
+
* @param {boolean}[opts.backup=false] - Keep a .bak copy of the previous file
|
|
27
|
+
*/
|
|
28
|
+
export function atomicWriteJson(filePath, data, opts = {}) {
|
|
29
|
+
const { schemaVersion = 1, backup = false } = opts;
|
|
30
|
+
|
|
31
|
+
// Stamp schema version onto plain objects
|
|
32
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
33
|
+
data._schemaVersion = schemaVersion;
|
|
34
|
+
data._writtenAt = new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dir = dirname(filePath);
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
41
|
+
const json = JSON.stringify(data, null, 2) + '\n';
|
|
42
|
+
|
|
43
|
+
// Write to temp file
|
|
44
|
+
writeFileSync(tmpPath, json);
|
|
45
|
+
|
|
46
|
+
// Validate the temp file is parseable before committing
|
|
47
|
+
try {
|
|
48
|
+
JSON.parse(readFileSync(tmpPath, 'utf8'));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
unlinkSync(tmpPath);
|
|
51
|
+
throw new Error(`atomicWrite: validation failed for ${filePath}: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Optionally back up the existing file
|
|
55
|
+
if (backup && existsSync(filePath)) {
|
|
56
|
+
const backupPath = filePath + '.bak';
|
|
57
|
+
try { renameSync(filePath, backupPath); } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Atomic rename — either fully succeeds or the original is untouched
|
|
61
|
+
renameSync(tmpPath, filePath);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read and parse a JSON file safely, with optional schema migration.
|
|
66
|
+
* Falls back to a .bak copy on parse failure.
|
|
67
|
+
* Returns null when the file is absent or unrecoverable.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} filePath
|
|
70
|
+
* @param {object} opts
|
|
71
|
+
* @param {number} [opts.expectedVersion] - Schema version to verify
|
|
72
|
+
* @param {Function}[opts.migrate] - (data, fromVersion, toVersion) => data
|
|
73
|
+
* @returns {*|null}
|
|
74
|
+
*/
|
|
75
|
+
export function readJsonSafe(filePath, opts = {}) {
|
|
76
|
+
const { expectedVersion, migrate } = opts;
|
|
77
|
+
|
|
78
|
+
if (!existsSync(filePath)) return null;
|
|
79
|
+
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
// Primary file corrupt — try backup
|
|
85
|
+
const bakPath = filePath + '.bak';
|
|
86
|
+
if (existsSync(bakPath)) {
|
|
87
|
+
try {
|
|
88
|
+
data = JSON.parse(readFileSync(bakPath, 'utf8'));
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
} else {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Schema version check with optional migration
|
|
96
|
+
if (expectedVersion !== undefined && data?._schemaVersion !== expectedVersion) {
|
|
97
|
+
if (migrate && typeof migrate === 'function') {
|
|
98
|
+
data = migrate(data, data?._schemaVersion, expectedVersion);
|
|
99
|
+
}
|
|
100
|
+
// Tolerant read: return data even without a migrator
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// 2. Advisory file locks
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const LOCK_TIMEOUT_MS = 10_000; // stale lock threshold
|
|
111
|
+
const LOCK_RETRY_MS = 50; // busy-wait interval
|
|
112
|
+
const LOCK_MAX_RETRIES = 100; // max retries (~5 s)
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Acquire an advisory lock for filePath by creating filePath.lock.
|
|
116
|
+
* Stale locks (> LOCK_TIMEOUT_MS old) are cleared automatically.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} filePath
|
|
119
|
+
* @returns {{ acquired: boolean, lockPath: string, reason?: string }}
|
|
120
|
+
*/
|
|
121
|
+
export function acquireLock(filePath) {
|
|
122
|
+
const lockPath = filePath + '.lock';
|
|
123
|
+
|
|
124
|
+
// Clear stale or corrupt lock
|
|
125
|
+
if (existsSync(lockPath)) {
|
|
126
|
+
try {
|
|
127
|
+
const lockData = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
128
|
+
const age = Date.now() - (lockData.createdAt || 0);
|
|
129
|
+
if (age > LOCK_TIMEOUT_MS) {
|
|
130
|
+
unlinkSync(lockPath);
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
try { unlinkSync(lockPath); } catch {}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Spin-try to create the lock exclusively
|
|
138
|
+
let retries = 0;
|
|
139
|
+
while (retries < LOCK_MAX_RETRIES) {
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(lockPath, JSON.stringify({
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
createdAt: Date.now(),
|
|
144
|
+
holder: process.argv[1] || 'unknown',
|
|
145
|
+
}), { flag: 'wx' }); // 'wx' = exclusive create, EEXIST if present
|
|
146
|
+
return { acquired: true, lockPath };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err.code === 'EEXIST') {
|
|
149
|
+
retries++;
|
|
150
|
+
// Synchronous busy-wait — intentional; only triggered under contention
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
while (Date.now() - start < LOCK_RETRY_MS) {}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
throw err; // Unexpected error — propagate
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { acquired: false, lockPath, reason: 'timeout' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Release a previously acquired lock.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ lockPath?: string }} lockResult - Return value of acquireLock
|
|
166
|
+
*/
|
|
167
|
+
export function releaseLock(lockResult) {
|
|
168
|
+
if (lockResult?.lockPath) {
|
|
169
|
+
try { unlinkSync(lockResult.lockPath); } catch {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run fn while holding an advisory lock on filePath.
|
|
175
|
+
* Throws if the lock cannot be acquired within the retry window.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} filePath
|
|
178
|
+
* @param {Function} fn
|
|
179
|
+
* @returns {*} Return value of fn
|
|
180
|
+
*/
|
|
181
|
+
export function withLock(filePath, fn) {
|
|
182
|
+
const lock = acquireLock(filePath);
|
|
183
|
+
if (!lock.acquired) {
|
|
184
|
+
throw new Error(`Could not acquire lock for ${filePath}: ${lock.reason}`);
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
return fn();
|
|
188
|
+
} finally {
|
|
189
|
+
releaseLock(lock);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Locked atomic read-modify-write.
|
|
195
|
+
* Reads the current JSON, passes it to updateFn, then writes the result.
|
|
196
|
+
* If updateFn returns undefined the file is left unchanged.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} filePath
|
|
199
|
+
* @param {Function} updateFn - (currentData: *|null) => updatedData | undefined
|
|
200
|
+
* @param {object} opts - Forwarded to readJsonSafe and atomicWriteJson
|
|
201
|
+
* @returns {*} Return value of updateFn
|
|
202
|
+
*/
|
|
203
|
+
export function lockedUpdate(filePath, updateFn, opts = {}) {
|
|
204
|
+
return withLock(filePath, () => {
|
|
205
|
+
const current = readJsonSafe(filePath, opts);
|
|
206
|
+
const updated = updateFn(current);
|
|
207
|
+
if (updated !== undefined) {
|
|
208
|
+
atomicWriteJson(filePath, updated, opts);
|
|
209
|
+
}
|
|
210
|
+
return updated;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// 3. Append-only ledger with lock
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Append a NDJSON record to filePath under an advisory lock.
|
|
220
|
+
* On lock failure the write is attempted without a lock (best-effort).
|
|
221
|
+
*
|
|
222
|
+
* @param {string} filePath
|
|
223
|
+
* @param {*} record - Value to serialize as one JSON line
|
|
224
|
+
*/
|
|
225
|
+
export async function atomicAppend(filePath, record) {
|
|
226
|
+
const { appendFileSync } = await import('node:fs');
|
|
227
|
+
const line = JSON.stringify(record) + '\n';
|
|
228
|
+
|
|
229
|
+
const lock = acquireLock(filePath);
|
|
230
|
+
if (!lock.acquired) {
|
|
231
|
+
// Non-fatal: best-effort append without lock
|
|
232
|
+
try {
|
|
233
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
234
|
+
appendFileSync(filePath, line);
|
|
235
|
+
} catch {}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
241
|
+
appendFileSync(filePath, line);
|
|
242
|
+
} finally {
|
|
243
|
+
releaseLock(lock);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const AUDIT_DIR = join(process.cwd(), '.dualbrain', 'prompt-audit');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Score a prompt for quality before sending to a provider.
|
|
8
|
+
* Returns score 0-100 and specific feedback.
|
|
9
|
+
*/
|
|
10
|
+
export function scorePrompt(prompt, opts = {}) {
|
|
11
|
+
const { type = 'think', maxTokenBudget = 2000 } = opts;
|
|
12
|
+
|
|
13
|
+
const issues = [];
|
|
14
|
+
const strengths = [];
|
|
15
|
+
let score = 100;
|
|
16
|
+
|
|
17
|
+
// Length efficiency
|
|
18
|
+
const words = prompt.split(/\s+/).length;
|
|
19
|
+
const chars = prompt.length;
|
|
20
|
+
|
|
21
|
+
if (words < 20) {
|
|
22
|
+
issues.push({ rule: 'too-short', msg: 'Prompt under 20 words — likely missing context', penalty: 15 });
|
|
23
|
+
score -= 15;
|
|
24
|
+
}
|
|
25
|
+
if (words > 500) {
|
|
26
|
+
issues.push({ rule: 'too-long', msg: `Prompt is ${words} words — consider trimming`, penalty: 10 });
|
|
27
|
+
score -= 10;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Structure checks
|
|
31
|
+
if (type === 'think') {
|
|
32
|
+
if (!prompt.includes('?')) {
|
|
33
|
+
issues.push({ rule: 'no-question', msg: 'Think prompt has no question mark — unclear what decision is needed', penalty: 10 });
|
|
34
|
+
score -= 10;
|
|
35
|
+
}
|
|
36
|
+
if (!/\b(should|how|what|which|why|when|where|recommend|decide|choose|compare|tradeoff)\b/i.test(prompt)) {
|
|
37
|
+
issues.push({ rule: 'no-decision-language', msg: 'No decision-making language found', penalty: 5 });
|
|
38
|
+
score -= 5;
|
|
39
|
+
}
|
|
40
|
+
if (/\b(at least \d+ ideas|generate.*list|brainstorm)\b/i.test(prompt)) {
|
|
41
|
+
strengths.push('Requests specific output quantity');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Context quality
|
|
46
|
+
if (/\b(this project|the codebase|our system)\b/i.test(prompt) && !/\b(module|file|function|export|import)\b/i.test(prompt)) {
|
|
47
|
+
issues.push({ rule: 'vague-context', msg: 'References "the project" without naming specific modules/files', penalty: 10 });
|
|
48
|
+
score -= 10;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (/\b(src\/\w+|\.mjs|\.js|\.ts)\b/.test(prompt)) {
|
|
52
|
+
strengths.push('Names specific files/modules');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Constraint quality
|
|
56
|
+
if (/\b(at least|minimum|maximum|no more than|ranked|ordered|prioritized)\b/i.test(prompt)) {
|
|
57
|
+
strengths.push('Includes output constraints');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Anti-patterns
|
|
61
|
+
if (/\b(please|could you|would you mind)\b/i.test(prompt)) {
|
|
62
|
+
issues.push({ rule: 'politeness-waste', msg: 'Politeness tokens wasted on AI — be direct', penalty: 2 });
|
|
63
|
+
score -= 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (/\b(I think|I believe|maybe|perhaps|possibly)\b/i.test(prompt) && type === 'think') {
|
|
67
|
+
issues.push({ rule: 'hedging', msg: 'Hedging language in think prompt — state positions directly', penalty: 5 });
|
|
68
|
+
score -= 5;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Token efficiency estimate
|
|
72
|
+
const estimatedTokens = Math.ceil(chars / 4);
|
|
73
|
+
const efficiency = Math.min(100, Math.round((words / estimatedTokens) * 100));
|
|
74
|
+
|
|
75
|
+
// Duplication check
|
|
76
|
+
const sentences = prompt.split(/[.!?]+/).filter(s => s.trim().length > 10);
|
|
77
|
+
const unique = new Set(sentences.map(s => s.trim().toLowerCase()));
|
|
78
|
+
if (sentences.length > 3 && unique.size < sentences.length * 0.7) {
|
|
79
|
+
issues.push({ rule: 'repetitive', msg: `${sentences.length - unique.size} near-duplicate sentences`, penalty: 10 });
|
|
80
|
+
score -= 10;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
score = Math.max(0, Math.min(100, score));
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
score,
|
|
87
|
+
grade: score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F',
|
|
88
|
+
issues,
|
|
89
|
+
strengths,
|
|
90
|
+
stats: {
|
|
91
|
+
words,
|
|
92
|
+
chars,
|
|
93
|
+
estimatedTokens,
|
|
94
|
+
efficiency,
|
|
95
|
+
sentences: sentences.length,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Log a prompt exchange for auditing.
|
|
102
|
+
*/
|
|
103
|
+
export function logPromptExchange(exchange) {
|
|
104
|
+
const {
|
|
105
|
+
type = 'think',
|
|
106
|
+
round = 1,
|
|
107
|
+
prompt,
|
|
108
|
+
response,
|
|
109
|
+
provider = 'gpt',
|
|
110
|
+
model,
|
|
111
|
+
durationMs,
|
|
112
|
+
promptScore,
|
|
113
|
+
} = exchange;
|
|
114
|
+
|
|
115
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
116
|
+
|
|
117
|
+
const entry = {
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
type,
|
|
120
|
+
round,
|
|
121
|
+
provider,
|
|
122
|
+
model,
|
|
123
|
+
durationMs,
|
|
124
|
+
promptScore: promptScore?.score,
|
|
125
|
+
promptGrade: promptScore?.grade,
|
|
126
|
+
promptWords: promptScore?.stats?.words,
|
|
127
|
+
promptTokens: promptScore?.stats?.estimatedTokens,
|
|
128
|
+
responseWords: response ? response.split(/\s+/).length : 0,
|
|
129
|
+
responseTokens: response ? Math.ceil(response.length / 4) : 0,
|
|
130
|
+
issues: promptScore?.issues?.map(i => i.rule) || [],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const logFile = join(AUDIT_DIR, 'exchanges.jsonl');
|
|
134
|
+
appendFileSync(logFile, JSON.stringify(entry) + '\n');
|
|
135
|
+
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get prompt quality statistics over time.
|
|
141
|
+
*/
|
|
142
|
+
export function getPromptStats(opts = {}) {
|
|
143
|
+
const { days = 7 } = opts;
|
|
144
|
+
const logFile = join(AUDIT_DIR, 'exchanges.jsonl');
|
|
145
|
+
|
|
146
|
+
if (!existsSync(logFile)) return { available: false };
|
|
147
|
+
|
|
148
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
149
|
+
const lines = readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
150
|
+
|
|
151
|
+
const entries = [];
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
try {
|
|
154
|
+
const entry = JSON.parse(line);
|
|
155
|
+
if (entry.timestamp >= cutoff) entries.push(entry);
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (entries.length === 0) return { available: true, count: 0 };
|
|
160
|
+
|
|
161
|
+
const avgScore = entries.reduce((s, e) => s + (e.promptScore || 0), 0) / entries.length;
|
|
162
|
+
const avgPromptTokens = entries.reduce((s, e) => s + (e.promptTokens || 0), 0) / entries.length;
|
|
163
|
+
const avgResponseTokens = entries.reduce((s, e) => s + (e.responseTokens || 0), 0) / entries.length;
|
|
164
|
+
const avgDuration = entries.reduce((s, e) => s + (e.durationMs || 0), 0) / entries.length;
|
|
165
|
+
|
|
166
|
+
const grades = {};
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
grades[e.promptGrade] = (grades[e.promptGrade] || 0) + 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const commonIssues = {};
|
|
172
|
+
for (const e of entries) {
|
|
173
|
+
for (const issue of (e.issues || [])) {
|
|
174
|
+
commonIssues[issue] = (commonIssues[issue] || 0) + 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const topIssues = Object.entries(commonIssues)
|
|
179
|
+
.sort((a, b) => b[1] - a[1])
|
|
180
|
+
.slice(0, 5)
|
|
181
|
+
.map(([rule, count]) => ({ rule, count, pct: Math.round(count / entries.length * 100) }));
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
available: true,
|
|
185
|
+
count: entries.length,
|
|
186
|
+
avgScore: Math.round(avgScore),
|
|
187
|
+
avgPromptTokens: Math.round(avgPromptTokens),
|
|
188
|
+
avgResponseTokens: Math.round(avgResponseTokens),
|
|
189
|
+
avgDurationMs: Math.round(avgDuration),
|
|
190
|
+
totalPromptTokens: entries.reduce((s, e) => s + (e.promptTokens || 0), 0),
|
|
191
|
+
totalResponseTokens: entries.reduce((s, e) => s + (e.responseTokens || 0), 0),
|
|
192
|
+
grades,
|
|
193
|
+
topIssues,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Suggest improvements for a prompt.
|
|
199
|
+
*/
|
|
200
|
+
export function suggestImprovements(prompt, type = 'think') {
|
|
201
|
+
const score = scorePrompt(prompt, { type });
|
|
202
|
+
const suggestions = [];
|
|
203
|
+
|
|
204
|
+
for (const issue of score.issues) {
|
|
205
|
+
switch (issue.rule) {
|
|
206
|
+
case 'too-short':
|
|
207
|
+
suggestions.push('Add context: what modules are involved, what decision is needed, what constraints exist');
|
|
208
|
+
break;
|
|
209
|
+
case 'too-long':
|
|
210
|
+
suggestions.push('Trim: remove background the AI already knows from CLAUDE.md. Focus on what\'s unique to this question');
|
|
211
|
+
break;
|
|
212
|
+
case 'no-question':
|
|
213
|
+
suggestions.push('End with a clear question or decision point');
|
|
214
|
+
break;
|
|
215
|
+
case 'vague-context':
|
|
216
|
+
suggestions.push('Name specific files, functions, or modules instead of "the project"');
|
|
217
|
+
break;
|
|
218
|
+
case 'politeness-waste':
|
|
219
|
+
suggestions.push('Remove "please", "could you" — direct prompts produce better output');
|
|
220
|
+
break;
|
|
221
|
+
case 'hedging':
|
|
222
|
+
suggestions.push('State positions directly — "X is better because Y" not "I think maybe X"');
|
|
223
|
+
break;
|
|
224
|
+
case 'repetitive':
|
|
225
|
+
suggestions.push('Remove duplicate sentences — each sentence should add new information');
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { score, suggestions };
|
|
231
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Task contract — every dispatch must have one.
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* id: string,
|
|
8
|
+
* objective: string,
|
|
9
|
+
* scope: string[],
|
|
10
|
+
* nonGoals?: string[],
|
|
11
|
+
* risk: 'low'|'medium'|'high'|'critical',
|
|
12
|
+
* acceptanceCriteria: string[],
|
|
13
|
+
* allowedOperations?: string[],
|
|
14
|
+
* context?: string,
|
|
15
|
+
* files?: string[],
|
|
16
|
+
* timeoutMs?: number,
|
|
17
|
+
* }} TaskContract
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate a task contract has all required fields.
|
|
22
|
+
* Returns { valid, missing }
|
|
23
|
+
*/
|
|
24
|
+
export function validateContract(contract) {
|
|
25
|
+
const required = ['objective', 'scope', 'risk', 'acceptanceCriteria'];
|
|
26
|
+
const missing = required.filter(f => !contract?.[f] || (Array.isArray(contract[f]) && contract[f].length === 0));
|
|
27
|
+
return {
|
|
28
|
+
valid: missing.length === 0,
|
|
29
|
+
missing,
|
|
30
|
+
contract: missing.length === 0 ? { ...contract, id: contract.id || Date.now().toString(36) } : null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Template definitions ─────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const TEMPLATES = {
|
|
37
|
+
search: {
|
|
38
|
+
id: 'search',
|
|
39
|
+
version: '1.0',
|
|
40
|
+
tier: 'search',
|
|
41
|
+
description: 'Read-only lookups, grep, explore. Returns files found, line refs, confidence.',
|
|
42
|
+
requiredFields: ['objective', 'scope'],
|
|
43
|
+
render(contract, context = {}) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`Find: ${contract.objective}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
if (contract.scope.length) lines.push(`Scope: ${contract.scope.join(', ')}`);
|
|
48
|
+
if (contract.files?.length) lines.push(`Start with: ${contract.files.join(', ')}`);
|
|
49
|
+
if (contract.context) lines.push(`Context: ${contract.context}`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push('Return: file paths, line numbers, relevant code snippets, and confidence level.');
|
|
52
|
+
if (contract.nonGoals?.length) lines.push(`Do NOT: ${contract.nonGoals.join('; ')}`);
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
execute: {
|
|
58
|
+
id: 'execute',
|
|
59
|
+
version: '1.0',
|
|
60
|
+
tier: 'execute',
|
|
61
|
+
description: 'Edits, tests, git ops. Returns files changed, tests run, edge cases.',
|
|
62
|
+
requiredFields: ['objective', 'scope', 'acceptanceCriteria'],
|
|
63
|
+
render(contract, context = {}) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
lines.push(contract.objective);
|
|
66
|
+
lines.push('');
|
|
67
|
+
if (contract.scope.length) lines.push(`Files in scope: ${contract.scope.join(', ')}`);
|
|
68
|
+
if (contract.files?.length) lines.push(`Read first: ${contract.files.join(', ')}`);
|
|
69
|
+
if (contract.context) lines.push(`Context: ${contract.context}`);
|
|
70
|
+
lines.push('');
|
|
71
|
+
lines.push('Acceptance criteria:');
|
|
72
|
+
for (const c of contract.acceptanceCriteria) {
|
|
73
|
+
lines.push(`- ${c}`);
|
|
74
|
+
}
|
|
75
|
+
if (contract.nonGoals?.length) {
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push('Non-goals (do NOT do these):');
|
|
78
|
+
for (const ng of contract.nonGoals) lines.push(`- ${ng}`);
|
|
79
|
+
}
|
|
80
|
+
if (contract.allowedOperations?.length) {
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(`Allowed operations: ${contract.allowedOperations.join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push('Return: files changed, tests run, edge cases found.');
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
think: {
|
|
91
|
+
id: 'think',
|
|
92
|
+
version: '1.0',
|
|
93
|
+
tier: 'think',
|
|
94
|
+
description: 'Architecture decisions, design review, planning.',
|
|
95
|
+
requiredFields: ['objective'],
|
|
96
|
+
render(contract, context = {}) {
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push(contract.objective);
|
|
99
|
+
lines.push('');
|
|
100
|
+
if (contract.scope?.length) lines.push(`Relevant modules: ${contract.scope.join(', ')}`);
|
|
101
|
+
if (contract.context) lines.push(`Background: ${contract.context}`);
|
|
102
|
+
if (contract.files?.length) lines.push(`Key files: ${contract.files.join(', ')}`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push('Provide: recommendation, rationale, alternatives considered, risks, and confidence level.');
|
|
105
|
+
if (contract.acceptanceCriteria?.length) {
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push('Decision criteria:');
|
|
108
|
+
for (const c of contract.acceptanceCriteria) lines.push(`- ${c}`);
|
|
109
|
+
}
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
review: {
|
|
115
|
+
id: 'review',
|
|
116
|
+
version: '1.0',
|
|
117
|
+
tier: 'review',
|
|
118
|
+
description: 'Code review with severity, line refs, test gaps, security concerns.',
|
|
119
|
+
requiredFields: ['objective', 'scope'],
|
|
120
|
+
render(contract, context = {}) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`Review: ${contract.objective}`);
|
|
123
|
+
lines.push('');
|
|
124
|
+
if (contract.scope.length) lines.push(`Files to review: ${contract.scope.join(', ')}`);
|
|
125
|
+
if (contract.context) lines.push(`Context: ${contract.context}`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('Check for:');
|
|
128
|
+
lines.push('- Correctness and edge cases');
|
|
129
|
+
lines.push('- Security vulnerabilities (OWASP top 10)');
|
|
130
|
+
lines.push('- Test coverage gaps');
|
|
131
|
+
lines.push('- Architectural drift');
|
|
132
|
+
lines.push('- Performance concerns');
|
|
133
|
+
if (contract.acceptanceCriteria?.length) {
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push('Specific concerns:');
|
|
136
|
+
for (const c of contract.acceptanceCriteria) lines.push(`- ${c}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push('Return: findings with severity (critical/high/medium/low), file:line refs, and suggested fixes.');
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ── Template API ─────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get a template by tier name.
|
|
149
|
+
*/
|
|
150
|
+
export function getTemplate(tier) {
|
|
151
|
+
return TEMPLATES[tier] || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* List all available templates.
|
|
156
|
+
*/
|
|
157
|
+
export function listTemplates() {
|
|
158
|
+
return Object.values(TEMPLATES).map(t => ({
|
|
159
|
+
id: t.id,
|
|
160
|
+
version: t.version,
|
|
161
|
+
tier: t.tier,
|
|
162
|
+
description: t.description,
|
|
163
|
+
requiredFields: t.requiredFields,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render a prompt from a template and task contract.
|
|
169
|
+
* Validates contract first. Returns { prompt, template, contract, valid, errors }
|
|
170
|
+
*/
|
|
171
|
+
export function renderPrompt(tier, contract, context = {}) {
|
|
172
|
+
const template = TEMPLATES[tier];
|
|
173
|
+
if (!template) {
|
|
174
|
+
return { prompt: null, valid: false, errors: [`Unknown template tier: ${tier}`] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Validate required fields
|
|
178
|
+
const missing = template.requiredFields.filter(f => !contract?.[f] || (Array.isArray(contract[f]) && contract[f].length === 0));
|
|
179
|
+
if (missing.length > 0) {
|
|
180
|
+
return {
|
|
181
|
+
prompt: null,
|
|
182
|
+
valid: false,
|
|
183
|
+
errors: missing.map(f => `Missing required field: ${f}`),
|
|
184
|
+
template: { id: template.id, version: template.version },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const prompt = template.render(contract, context);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
prompt,
|
|
192
|
+
valid: true,
|
|
193
|
+
errors: [],
|
|
194
|
+
template: { id: template.id, version: template.version },
|
|
195
|
+
contract: { ...contract, id: contract.id || Date.now().toString(36) },
|
|
196
|
+
stats: {
|
|
197
|
+
words: prompt.split(/\s+/).length,
|
|
198
|
+
chars: prompt.length,
|
|
199
|
+
estimatedTokens: Math.ceil(prompt.length / 4),
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Quick render: build a contract from minimal inputs and render.
|
|
206
|
+
* For when HEAD knows the tier and objective but hasn't built a full contract.
|
|
207
|
+
*/
|
|
208
|
+
export function quickRender(tier, objective, opts = {}) {
|
|
209
|
+
const { scope = [], files = [], risk = 'medium', criteria = [], nonGoals = [], context = '' } = opts;
|
|
210
|
+
|
|
211
|
+
const contract = {
|
|
212
|
+
objective,
|
|
213
|
+
scope,
|
|
214
|
+
files,
|
|
215
|
+
risk,
|
|
216
|
+
acceptanceCriteria: criteria.length ? criteria : [`${objective} is complete and working`],
|
|
217
|
+
nonGoals,
|
|
218
|
+
context,
|
|
219
|
+
allowedOperations: tier === 'search' ? ['read'] : tier === 'execute' ? ['read', 'write', 'test'] : ['read', 'analyze'],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return renderPrompt(tier, contract);
|
|
223
|
+
}
|