framein 0.0.4
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/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/adr.js +17 -0
- package/dist/anomaly.js +39 -0
- package/dist/bin.js +27 -0
- package/dist/blast.js +51 -0
- package/dist/brief.js +21 -0
- package/dist/capsule.js +64 -0
- package/dist/cli.js +2090 -0
- package/dist/db.js +7 -0
- package/dist/debt.js +42 -0
- package/dist/delegate.js +64 -0
- package/dist/detect.js +118 -0
- package/dist/disagree.js +85 -0
- package/dist/evidence.js +72 -0
- package/dist/fileWriter.js +35 -0
- package/dist/ingest.js +38 -0
- package/dist/managedBlock.js +101 -0
- package/dist/mcpRegister.js +85 -0
- package/dist/mcpServer.js +138 -0
- package/dist/palette.js +63 -0
- package/dist/projector.js +65 -0
- package/dist/quota.js +30 -0
- package/dist/recipe.js +55 -0
- package/dist/rescue.js +38 -0
- package/dist/roles.js +61 -0
- package/dist/select.js +50 -0
- package/dist/shell.js +127 -0
- package/dist/stats.js +74 -0
- package/dist/store.js +319 -0
- package/dist/task.js +94 -0
- package/dist/trust.js +39 -0
- package/dist/types.js +3 -0
- package/dist/ui/banner.js +32 -0
- package/dist/ui/capabilities.js +35 -0
- package/dist/ui/theme.js +49 -0
- package/dist/wrappers.js +63 -0
- package/package.json +49 -0
package/dist/stats.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Repo-local Routing (F-LOOP-7, ADR-0008): route by THIS repo's actual results, not generic model
|
|
2
|
+
// reputation, and — crucially — EXPLAIN the choice rather than auto-deciding silently (trust comes
|
|
3
|
+
// from "why", not magic). Pure: derive per-agent stats from the ledger, then score + explain via
|
|
4
|
+
// roles.scoreAgent. Accumulating richer signals (first-try pass rate, human reverts) comes later.
|
|
5
|
+
import { AGENTS } from './types.js';
|
|
6
|
+
import { scoreAgent, DEFAULT_ROLE_PRIORITY } from './roles.js';
|
|
7
|
+
import { PLAIN } from './ui/theme.js';
|
|
8
|
+
const isAgentName = (s) => AGENTS.includes(s);
|
|
9
|
+
/** The trailing token of a ledger target is the agent: "reviewer:codex" -> codex, "codex" -> codex. */
|
|
10
|
+
function agentOf(target) {
|
|
11
|
+
const tail = target.split(':').pop() ?? '';
|
|
12
|
+
return isAgentName(tail) ? tail : undefined;
|
|
13
|
+
}
|
|
14
|
+
export function computeRepoStats(ledger) {
|
|
15
|
+
const stats = {};
|
|
16
|
+
const ensure = (a) => (stats[a] ??= { delegations: 0, failures: 0, quotaHits: 0 });
|
|
17
|
+
for (const e of ledger) {
|
|
18
|
+
const a = agentOf(e.target);
|
|
19
|
+
if (!a)
|
|
20
|
+
continue;
|
|
21
|
+
if (e.kind === 'delegated')
|
|
22
|
+
ensure(a).delegations++;
|
|
23
|
+
else if (e.kind === 'delegate-fail') {
|
|
24
|
+
const s = ensure(a);
|
|
25
|
+
s.delegations++;
|
|
26
|
+
s.failures++;
|
|
27
|
+
}
|
|
28
|
+
else if (e.kind === 'quota')
|
|
29
|
+
ensure(a).quotaHits++;
|
|
30
|
+
}
|
|
31
|
+
return stats;
|
|
32
|
+
}
|
|
33
|
+
function routeReasons(st) {
|
|
34
|
+
if (!st || st.delegations === 0)
|
|
35
|
+
return ['no local track record yet (using role defaults)'];
|
|
36
|
+
const success = st.delegations - st.failures;
|
|
37
|
+
const reasons = [`+ ${Math.round((success / st.delegations) * 100)}% delegation success in this repo (${success}/${st.delegations})`];
|
|
38
|
+
if (st.failures)
|
|
39
|
+
reasons.push(`- ${st.failures} failure${st.failures > 1 ? 's' : ''}`);
|
|
40
|
+
reasons.push(st.quotaHits ? `- ${st.quotaHits} quota hit${st.quotaHits > 1 ? 's' : ''}` : '+ no quota issues');
|
|
41
|
+
return reasons;
|
|
42
|
+
}
|
|
43
|
+
export function explainRoute(role, ctx, stats) {
|
|
44
|
+
const priority = (ctx.rolePriority ?? DEFAULT_ROLE_PRIORITY)[role] ?? [...AGENTS];
|
|
45
|
+
const scored = priority
|
|
46
|
+
.map((agent) => ({ agent, score: scoreAgent(agent, { ...ctx, role, repoStats: stats }) }))
|
|
47
|
+
.filter((s) => Number.isFinite(s.score))
|
|
48
|
+
.sort((a, b) => b.score - a.score);
|
|
49
|
+
const best = scored[0];
|
|
50
|
+
const second = scored[1];
|
|
51
|
+
let alternative;
|
|
52
|
+
if (best && second) {
|
|
53
|
+
const total = best.score + second.score;
|
|
54
|
+
alternative = { agent: second.agent, confidence: total > 0 ? Number((second.score / total).toFixed(2)) : 0 };
|
|
55
|
+
}
|
|
56
|
+
return { role, agent: best?.agent ?? null, reasons: best ? routeReasons(stats[best.agent]) : ['no eligible agent'], alternative };
|
|
57
|
+
}
|
|
58
|
+
export function renderRouteExplain(e, ui = PLAIN) {
|
|
59
|
+
const lines = [e.agent ? `Selected ${ui.tone(e.agent, 'brand')} as ${e.role}.` : `No eligible agent for ${e.role}.`, ui.tone('Why:', 'muted')];
|
|
60
|
+
for (const r of e.reasons)
|
|
61
|
+
lines.push(` ${r}`);
|
|
62
|
+
if (e.alternative)
|
|
63
|
+
lines.push(ui.tone(`Alternative: ${e.alternative.agent}, confidence ${e.alternative.confidence}`, 'muted'));
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
export function renderStats(stats, ui = PLAIN) {
|
|
67
|
+
const entries = Object.entries(stats);
|
|
68
|
+
if (entries.length === 0)
|
|
69
|
+
return 'No repo-local stats yet. Delegations (`framein ask --run`) accumulate them.';
|
|
70
|
+
const lines = [ui.tone('Repo-local agent stats (from the ledger):', 'muted')];
|
|
71
|
+
for (const [a, st] of entries)
|
|
72
|
+
lines.push(` ${ui.tone(a, 'brand')}: ${st.delegations} delegations, ${st.failures} failed, ${st.quotaHits} quota`);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// The single source of truth: a SQLite-backed store for config, roles,
|
|
2
|
+
// append-only ADRs, scoped memory, and the write lock.
|
|
3
|
+
import { openDb } from './db.js';
|
|
4
|
+
const SCHEMA = `
|
|
5
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
6
|
+
key TEXT PRIMARY KEY,
|
|
7
|
+
value TEXT NOT NULL
|
|
8
|
+
);
|
|
9
|
+
CREATE TABLE IF NOT EXISTS roles (
|
|
10
|
+
role TEXT PRIMARY KEY,
|
|
11
|
+
agent TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS adr (
|
|
14
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
title TEXT NOT NULL,
|
|
17
|
+
status TEXT NOT NULL,
|
|
18
|
+
context TEXT NOT NULL DEFAULT '',
|
|
19
|
+
decision TEXT NOT NULL,
|
|
20
|
+
consequences TEXT NOT NULL DEFAULT '',
|
|
21
|
+
author_agent TEXT,
|
|
22
|
+
supersedes INTEGER
|
|
23
|
+
);
|
|
24
|
+
CREATE TABLE IF NOT EXISTS memory (
|
|
25
|
+
scope TEXT NOT NULL,
|
|
26
|
+
key TEXT NOT NULL,
|
|
27
|
+
value TEXT NOT NULL,
|
|
28
|
+
updated_at TEXT NOT NULL,
|
|
29
|
+
PRIMARY KEY (scope, key)
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS write_lock (
|
|
32
|
+
scope TEXT PRIMARY KEY,
|
|
33
|
+
holder TEXT,
|
|
34
|
+
acquired_at TEXT,
|
|
35
|
+
expires_at TEXT
|
|
36
|
+
);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
ts TEXT NOT NULL,
|
|
40
|
+
kind TEXT NOT NULL,
|
|
41
|
+
target TEXT NOT NULL DEFAULT '',
|
|
42
|
+
detail TEXT NOT NULL DEFAULT ''
|
|
43
|
+
);
|
|
44
|
+
`;
|
|
45
|
+
// Guard object-key injection (e.g. '__proto__' arriving via the MCP write_memory tool):
|
|
46
|
+
// these keys are rejected at write time so assembled config/memory maps stay clean.
|
|
47
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
48
|
+
function safeKey(k) {
|
|
49
|
+
if (UNSAFE_KEYS.has(k))
|
|
50
|
+
throw new Error(`unsafe key: ${k}`);
|
|
51
|
+
return k;
|
|
52
|
+
}
|
|
53
|
+
function sleepMs(ms) {
|
|
54
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
55
|
+
}
|
|
56
|
+
function isBusy(e) {
|
|
57
|
+
const err = e;
|
|
58
|
+
return err.code === 'ERR_SQLITE_ERROR'
|
|
59
|
+
&& /locked|busy/i.test(`${String(err.message ?? '')} ${String(err.errstr ?? '')}`);
|
|
60
|
+
}
|
|
61
|
+
function execWithBusyRetry(db, sql) {
|
|
62
|
+
const deadline = Date.now() + 5000;
|
|
63
|
+
for (;;) {
|
|
64
|
+
try {
|
|
65
|
+
db.exec(sql);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
if (!isBusy(e) || Date.now() >= deadline)
|
|
70
|
+
throw e;
|
|
71
|
+
sleepMs(50);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function rowToAdr(row) {
|
|
76
|
+
return {
|
|
77
|
+
id: Number(row.id),
|
|
78
|
+
createdAt: String(row.created_at),
|
|
79
|
+
title: String(row.title),
|
|
80
|
+
status: row.status,
|
|
81
|
+
context: row.context ?? '',
|
|
82
|
+
decision: String(row.decision),
|
|
83
|
+
consequences: row.consequences ?? '',
|
|
84
|
+
authorAgent: row.author_agent ?? null,
|
|
85
|
+
supersedes: row.supersedes == null ? null : Number(row.supersedes),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export class Store {
|
|
89
|
+
db;
|
|
90
|
+
constructor(db) { this.db = db; }
|
|
91
|
+
static open(path = ':memory:') {
|
|
92
|
+
const db = openDb(path);
|
|
93
|
+
// WAL + a busy timeout let multiple processes share one .frame/store.db without
|
|
94
|
+
// "database is locked" errors; lock acquisition itself is a single atomic statement.
|
|
95
|
+
execWithBusyRetry(db, 'PRAGMA busy_timeout=5000;');
|
|
96
|
+
execWithBusyRetry(db, 'PRAGMA journal_mode=WAL;');
|
|
97
|
+
execWithBusyRetry(db, SCHEMA);
|
|
98
|
+
return new Store(db);
|
|
99
|
+
}
|
|
100
|
+
close() { this.db.close(); }
|
|
101
|
+
// --- config (project rules etc.) ---
|
|
102
|
+
setConfig(key, value) {
|
|
103
|
+
this.db.prepare('INSERT INTO config(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(safeKey(key), JSON.stringify(value));
|
|
104
|
+
}
|
|
105
|
+
getConfig(key) {
|
|
106
|
+
const row = this.db.prepare('SELECT value FROM config WHERE key=?').get(key);
|
|
107
|
+
return row ? JSON.parse(row.value) : undefined;
|
|
108
|
+
}
|
|
109
|
+
getAllConfig() {
|
|
110
|
+
const rows = this.db.prepare('SELECT key,value FROM config').all();
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const r of rows)
|
|
113
|
+
out[r.key] = JSON.parse(r.value);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
// --- roles ---
|
|
117
|
+
setRole(role, agent) {
|
|
118
|
+
this.db.prepare('INSERT INTO roles(role,agent) VALUES(?,?) ON CONFLICT(role) DO UPDATE SET agent=excluded.agent').run(role, agent);
|
|
119
|
+
}
|
|
120
|
+
getRole(role) {
|
|
121
|
+
const row = this.db.prepare('SELECT agent FROM roles WHERE role=?').get(role);
|
|
122
|
+
return row?.agent;
|
|
123
|
+
}
|
|
124
|
+
getRoles() {
|
|
125
|
+
const rows = this.db.prepare('SELECT role,agent FROM roles').all();
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const r of rows)
|
|
128
|
+
out[r.role] = r.agent;
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
// --- ADR (APPEND-ONLY by design: no update/delete methods exist) ---
|
|
132
|
+
appendAdr(input) {
|
|
133
|
+
const createdAt = new Date().toISOString();
|
|
134
|
+
const status = input.status ?? 'accepted';
|
|
135
|
+
const supersedes = input.supersedes ?? null;
|
|
136
|
+
if (supersedes != null && !this.getAdr(supersedes)) {
|
|
137
|
+
throw new Error(`ADR-${supersedes} not found; cannot supersede a non-existent decision`);
|
|
138
|
+
}
|
|
139
|
+
const res = this.db.prepare('INSERT INTO adr(created_at,title,status,context,decision,consequences,author_agent,supersedes) VALUES(?,?,?,?,?,?,?,?)').run(createdAt, input.title, status, input.context ?? '', input.decision, input.consequences ?? '', input.authorAgent ?? null, supersedes);
|
|
140
|
+
return {
|
|
141
|
+
id: Number(res.lastInsertRowid), createdAt, title: input.title, status,
|
|
142
|
+
context: input.context ?? '', decision: input.decision,
|
|
143
|
+
consequences: input.consequences ?? '', authorAgent: input.authorAgent ?? null,
|
|
144
|
+
supersedes,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Append-only correction: records a NEW ADR that replaces `oldId`. The old row is
|
|
149
|
+
* never mutated or deleted — supersession is a forward reference (see isSuperseded).
|
|
150
|
+
*/
|
|
151
|
+
supersedeAdr(oldId, input) {
|
|
152
|
+
if (!this.getAdr(oldId))
|
|
153
|
+
throw new Error(`ADR-${oldId} not found; cannot supersede`);
|
|
154
|
+
if (this.isSuperseded(oldId))
|
|
155
|
+
throw new Error(`ADR-${oldId} is already superseded; supersede the current decision instead`);
|
|
156
|
+
return this.appendAdr({ ...input, supersedes: oldId });
|
|
157
|
+
}
|
|
158
|
+
getAdr(id) {
|
|
159
|
+
const row = this.db.prepare('SELECT * FROM adr WHERE id=?').get(id);
|
|
160
|
+
return row ? rowToAdr(row) : undefined;
|
|
161
|
+
}
|
|
162
|
+
/** True if some LATER ADR supersedes this one. Append-only: derived, not stored. */
|
|
163
|
+
isSuperseded(id) {
|
|
164
|
+
return this.db.prepare('SELECT 1 FROM adr WHERE supersedes=? AND id>? LIMIT 1').get(id, id) !== undefined;
|
|
165
|
+
}
|
|
166
|
+
listAdrs() {
|
|
167
|
+
const rows = this.db.prepare('SELECT * FROM adr ORDER BY id').all();
|
|
168
|
+
return rows.map(rowToAdr);
|
|
169
|
+
}
|
|
170
|
+
// --- memory (mutable working state, scoped) ---
|
|
171
|
+
setMemory(scope, key, value) {
|
|
172
|
+
this.db.prepare('INSERT INTO memory(scope,key,value,updated_at) VALUES(?,?,?,?) ON CONFLICT(scope,key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at').run(safeKey(scope), safeKey(key), JSON.stringify(value), new Date().toISOString());
|
|
173
|
+
}
|
|
174
|
+
getMemory(scope, key) {
|
|
175
|
+
const row = this.db.prepare('SELECT value FROM memory WHERE scope=? AND key=?').get(scope, key);
|
|
176
|
+
return row ? JSON.parse(row.value) : undefined;
|
|
177
|
+
}
|
|
178
|
+
listMemory(scope) {
|
|
179
|
+
const rows = this.db.prepare('SELECT key,value FROM memory WHERE scope=?').all(scope);
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const r of rows)
|
|
182
|
+
out[r.key] = JSON.parse(r.value);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
deleteMemory(scope, key) {
|
|
186
|
+
this.db.prepare('DELETE FROM memory WHERE scope=? AND key=?').run(scope, key);
|
|
187
|
+
}
|
|
188
|
+
// --- write lock (governance: one writer at a time, per scope, with TTL) ---
|
|
189
|
+
// Acquisition is a SINGLE atomic conditional upsert — no read-then-write race across
|
|
190
|
+
// processes. A lock is takeable when it is free, held by the same holder (reentrant),
|
|
191
|
+
// or expired (its holder crashed without releasing).
|
|
192
|
+
//
|
|
193
|
+
// CAVEAT: reentrancy keys on the holder STRING. For cross-process mutual exclusion the
|
|
194
|
+
// caller must pass a holder that uniquely identifies the owner (e.g. include the pid) —
|
|
195
|
+
// two processes sharing a holder name would both be allowed in by design.
|
|
196
|
+
static DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
197
|
+
getLockHolder(scope = 'global') {
|
|
198
|
+
const row = this.db.prepare('SELECT holder, expires_at FROM write_lock WHERE scope=?').get(scope);
|
|
199
|
+
if (!row || row.holder == null)
|
|
200
|
+
return null;
|
|
201
|
+
if (row.expires_at != null && row.expires_at <= new Date().toISOString())
|
|
202
|
+
return null; // expired => free
|
|
203
|
+
return row.holder;
|
|
204
|
+
}
|
|
205
|
+
acquireLock(holder, opts = {}) {
|
|
206
|
+
const scope = opts.scope ?? 'global';
|
|
207
|
+
const ttlMs = opts.ttlMs ?? Store.DEFAULT_TTL_MS;
|
|
208
|
+
const now = new Date();
|
|
209
|
+
const acquiredAt = now.toISOString();
|
|
210
|
+
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
211
|
+
const res = this.db.prepare(`INSERT INTO write_lock(scope,holder,acquired_at,expires_at) VALUES(?,?,?,?)
|
|
212
|
+
ON CONFLICT(scope) DO UPDATE SET holder=excluded.holder, acquired_at=excluded.acquired_at, expires_at=excluded.expires_at
|
|
213
|
+
WHERE write_lock.holder IS NULL OR write_lock.holder=excluded.holder OR write_lock.expires_at <= excluded.acquired_at`).run(scope, holder, acquiredAt, expiresAt);
|
|
214
|
+
return res.changes > 0;
|
|
215
|
+
}
|
|
216
|
+
releaseLock(holder, opts = {}) {
|
|
217
|
+
const scope = opts.scope ?? 'global';
|
|
218
|
+
const res = this.db.prepare('UPDATE write_lock SET holder=NULL, acquired_at=NULL, expires_at=NULL WHERE scope=? AND holder=?').run(scope, holder);
|
|
219
|
+
return res.changes > 0;
|
|
220
|
+
}
|
|
221
|
+
/** Force-release a (possibly stale) lock regardless of holder. Backs `frame unlock`. */
|
|
222
|
+
forceUnlock(scope = 'global') {
|
|
223
|
+
this.db.prepare('UPDATE write_lock SET holder=NULL, acquired_at=NULL, expires_at=NULL WHERE scope=?').run(scope);
|
|
224
|
+
}
|
|
225
|
+
withWriteLock(holder, fn, opts = {}) {
|
|
226
|
+
if (opts.ttlMs !== undefined && opts.ttlMs <= 0) {
|
|
227
|
+
throw new Error('withWriteLock requires a positive ttlMs (a non-positive TTL expires immediately)');
|
|
228
|
+
}
|
|
229
|
+
if (!this.acquireLock(holder, opts)) {
|
|
230
|
+
throw new Error(`write lock held by '${this.getLockHolder(opts.scope)}', '${holder}' cannot acquire`);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return fn();
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
this.releaseLock(holder, opts);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// --- task ledger (runtime work-events; feeds the anomaly/audit detector) ---
|
|
240
|
+
appendLedger(kind, target = '', detail = '') {
|
|
241
|
+
this.db.prepare('INSERT INTO ledger(ts,kind,target,detail) VALUES(?,?,?,?)').run(new Date().toISOString(), kind, target, detail);
|
|
242
|
+
}
|
|
243
|
+
/** Most recent `limit` ledger entries, returned in chronological order. */
|
|
244
|
+
listLedger(limit = 500) {
|
|
245
|
+
const rows = this.db.prepare('SELECT id,ts,kind,target,detail FROM ledger ORDER BY id DESC LIMIT ?').all(limit);
|
|
246
|
+
return rows.reverse().map((r) => ({
|
|
247
|
+
id: Number(r.id), ts: String(r.ts), kind: String(r.kind), target: String(r.target ?? ''), detail: String(r.detail ?? ''),
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
// --- task contract (F-LOOP-1: mutable task state, stored under memory scope 'task') ---
|
|
251
|
+
getTaskContract() {
|
|
252
|
+
return this.getMemory('task', 'contract');
|
|
253
|
+
}
|
|
254
|
+
setTaskContract(c) {
|
|
255
|
+
this.setMemory('task', 'contract', c);
|
|
256
|
+
}
|
|
257
|
+
clearTaskContract() {
|
|
258
|
+
this.deleteMemory('task', 'contract');
|
|
259
|
+
}
|
|
260
|
+
// --- projection snapshot ---
|
|
261
|
+
getState() {
|
|
262
|
+
return { config: this.getAllConfig(), roles: this.getRoles(), adrs: this.listAdrs(), taskContract: this.getTaskContract() };
|
|
263
|
+
}
|
|
264
|
+
// --- git-friendly text serialization (F-SYNC-6) ---
|
|
265
|
+
static SCHEMA_VERSION = 1;
|
|
266
|
+
allMemory() {
|
|
267
|
+
const rows = this.db.prepare('SELECT scope,key,value FROM memory').all();
|
|
268
|
+
const out = {};
|
|
269
|
+
for (const r of rows) {
|
|
270
|
+
(out[r.scope] ??= {})[r.key] = JSON.parse(r.value);
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/** Full snapshot for the canonical text form. The write lock is intentionally excluded. */
|
|
275
|
+
exportSnapshot() {
|
|
276
|
+
return {
|
|
277
|
+
schemaVersion: Store.SCHEMA_VERSION,
|
|
278
|
+
config: this.getAllConfig(),
|
|
279
|
+
roles: this.getRoles(),
|
|
280
|
+
adrs: this.listAdrs(),
|
|
281
|
+
memory: this.allMemory(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Rebuild the store from a snapshot (replaces config/roles/adr/memory). ADR ids are
|
|
285
|
+
* preserved so `supersedes` references stay valid. Transactional. */
|
|
286
|
+
importSnapshot(snap) {
|
|
287
|
+
if (snap.schemaVersion !== Store.SCHEMA_VERSION) {
|
|
288
|
+
throw new Error(`unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${Store.SCHEMA_VERSION})`);
|
|
289
|
+
}
|
|
290
|
+
this.db.exec('BEGIN');
|
|
291
|
+
try {
|
|
292
|
+
this.db.exec('DELETE FROM config; DELETE FROM roles; DELETE FROM adr; DELETE FROM memory;');
|
|
293
|
+
for (const [k, v] of Object.entries(snap.config))
|
|
294
|
+
this.setConfig(k, v);
|
|
295
|
+
for (const [r, a] of Object.entries(snap.roles))
|
|
296
|
+
this.setRole(r, a);
|
|
297
|
+
const ins = this.db.prepare('INSERT INTO adr(id,created_at,title,status,context,decision,consequences,author_agent,supersedes) VALUES(?,?,?,?,?,?,?,?,?)');
|
|
298
|
+
let maxAdrId = 0;
|
|
299
|
+
for (const a of snap.adrs) {
|
|
300
|
+
ins.run(a.id, a.createdAt, a.title, a.status, a.context, a.decision, a.consequences, a.authorAgent, a.supersedes);
|
|
301
|
+
if (a.id > maxAdrId)
|
|
302
|
+
maxAdrId = a.id;
|
|
303
|
+
}
|
|
304
|
+
// Reset the AUTOINCREMENT counter so the next appendAdr() id follows the imported max,
|
|
305
|
+
// not the pre-import max (DELETE FROM adr does not touch sqlite_sequence).
|
|
306
|
+
this.db.exec("DELETE FROM sqlite_sequence WHERE name='adr'");
|
|
307
|
+
this.db.prepare("INSERT INTO sqlite_sequence(name,seq) VALUES('adr', ?)").run(maxAdrId);
|
|
308
|
+
for (const [scope, kv] of Object.entries(snap.memory)) {
|
|
309
|
+
for (const [k, v] of Object.entries(kv))
|
|
310
|
+
this.setMemory(scope, k, v);
|
|
311
|
+
}
|
|
312
|
+
this.db.exec('COMMIT');
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
this.db.exec('ROLLBACK');
|
|
316
|
+
throw e;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
package/dist/task.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Task Contract (F-LOOP-1, ADR-0008): fix "what to treat as done" so every agent judges against
|
|
2
|
+
// the same bar. Pure logic only — the store wiring lives in store.ts and the lead drafting a
|
|
3
|
+
// contract from the user's prompt is the deferred live path. The contract is stored as structured
|
|
4
|
+
// task state (memory scope 'task') and projected into the managed block as standing intent.
|
|
5
|
+
import { PLAIN } from './ui/theme.js';
|
|
6
|
+
export function emptyContract(goal = '') {
|
|
7
|
+
return { goal, mustPreserve: [], acceptance: [], protected: [], nonGoals: [] };
|
|
8
|
+
}
|
|
9
|
+
/** Set the goal, or append to a list field. Returns a new contract (does not mutate input). */
|
|
10
|
+
export function amendContract(c, field, value) {
|
|
11
|
+
const next = {
|
|
12
|
+
goal: c.goal,
|
|
13
|
+
mustPreserve: [...c.mustPreserve],
|
|
14
|
+
acceptance: [...c.acceptance],
|
|
15
|
+
protected: [...c.protected],
|
|
16
|
+
nonGoals: [...c.nonGoals],
|
|
17
|
+
};
|
|
18
|
+
switch (field) {
|
|
19
|
+
case 'goal':
|
|
20
|
+
next.goal = value;
|
|
21
|
+
break;
|
|
22
|
+
case 'preserve':
|
|
23
|
+
next.mustPreserve.push(value);
|
|
24
|
+
break;
|
|
25
|
+
case 'acceptance':
|
|
26
|
+
next.acceptance.push(value);
|
|
27
|
+
break;
|
|
28
|
+
case 'protected':
|
|
29
|
+
next.protected.push(value);
|
|
30
|
+
break;
|
|
31
|
+
case 'nongoal':
|
|
32
|
+
next.nonGoals.push(value);
|
|
33
|
+
break;
|
|
34
|
+
default: {
|
|
35
|
+
const _x = field;
|
|
36
|
+
throw new Error(`unknown contract field: ${String(_x)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
export const GUIDED_CONTRACT_STEPS = [
|
|
42
|
+
{ field: 'goal', question: 'Goal — what should be true when this is done?' },
|
|
43
|
+
{ field: 'acceptance', question: 'Acceptance — how will you verify it? (enter to skip)' },
|
|
44
|
+
{ field: 'nongoal', question: 'Non-goal — what is explicitly out of scope? (enter to skip)' },
|
|
45
|
+
{ field: 'preserve', question: 'Must preserve — what must keep working? (enter to skip)' },
|
|
46
|
+
];
|
|
47
|
+
/** Build a contract from guided answers: goal is set (trimmed), blank optional answers are skipped. Pure. */
|
|
48
|
+
export function buildGuidedContract(answers) {
|
|
49
|
+
let c = emptyContract((answers.goal ?? '').trim());
|
|
50
|
+
for (const { field } of GUIDED_CONTRACT_STEPS) {
|
|
51
|
+
if (field === 'goal')
|
|
52
|
+
continue;
|
|
53
|
+
const v = (answers[field] ?? '').trim();
|
|
54
|
+
if (v)
|
|
55
|
+
c = amendContract(c, field, v);
|
|
56
|
+
}
|
|
57
|
+
return c;
|
|
58
|
+
}
|
|
59
|
+
/** Soft warnings (the "ambiguous items" the lead would confirm once). Empty = well-formed. */
|
|
60
|
+
export function contractIssues(c) {
|
|
61
|
+
const issues = [];
|
|
62
|
+
if (!c.goal.trim())
|
|
63
|
+
issues.push('no goal set');
|
|
64
|
+
if (c.acceptance.length === 0)
|
|
65
|
+
issues.push('no acceptance criteria — how will "done" be judged?');
|
|
66
|
+
return issues;
|
|
67
|
+
}
|
|
68
|
+
const inline = (xs) => xs.join('; ');
|
|
69
|
+
/** Compact digest for the managed block (always-loaded standing intent). */
|
|
70
|
+
export function renderContractDigest(c) {
|
|
71
|
+
if (!c.goal.trim() && c.acceptance.length === 0)
|
|
72
|
+
return '_No active task contract._';
|
|
73
|
+
const lines = [`**Goal:** ${c.goal || '(unset)'}`];
|
|
74
|
+
if (c.acceptance.length)
|
|
75
|
+
lines.push(`- Acceptance: ${inline(c.acceptance)}`);
|
|
76
|
+
if (c.mustPreserve.length)
|
|
77
|
+
lines.push(`- Must preserve: ${inline(c.mustPreserve)}`);
|
|
78
|
+
if (c.protected.length)
|
|
79
|
+
lines.push(`- Protected: ${inline(c.protected)}`);
|
|
80
|
+
if (c.nonGoals.length)
|
|
81
|
+
lines.push(`- Non-goals: ${inline(c.nonGoals)}`);
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
/** Full multi-line view for `frame task show`. */
|
|
85
|
+
export function renderContractFull(c, ui = PLAIN) {
|
|
86
|
+
const section = (label, xs) => xs.length ? ` ${ui.tone(label, 'muted')}:\n${xs.map((x) => ` - ${x}`).join('\n')}` : ` ${ui.tone(label, 'muted')}: (none)`;
|
|
87
|
+
return [
|
|
88
|
+
`Task goal: ${ui.bold(c.goal || '(unset)')}`,
|
|
89
|
+
section('must preserve', c.mustPreserve),
|
|
90
|
+
section('acceptance', c.acceptance),
|
|
91
|
+
section('protected', c.protected),
|
|
92
|
+
section('non-goals', c.nonGoals),
|
|
93
|
+
].join('\n');
|
|
94
|
+
}
|
package/dist/trust.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Unified permission mode (F-TRUST, ADR-0007 B-3). framein's most dangerous surface, so it leans
|
|
2
|
+
// on SAFETY over convenience: per-agent opt-in, time-boxed, with honest limits. This module is
|
|
3
|
+
// pure — it only PLANS what trust would enable (the bypass flags each CLI understands). framein
|
|
4
|
+
// does NOT auto-apply it; actually launching an agent with these flags is the live path. A
|
|
5
|
+
// worktree is NOT a sandbox (network/credentials/installs are not blocked) — surfaced as a warning.
|
|
6
|
+
// The "stop asking me" flag per CLI. Codex's --full-auto stays sandboxed; the fuller
|
|
7
|
+
// --dangerously-bypass-approvals-and-sandbox also drops the sandbox (mentioned in warnings).
|
|
8
|
+
const BYPASS_FLAGS = {
|
|
9
|
+
claude: ['--dangerously-skip-permissions'],
|
|
10
|
+
codex: ['--full-auto'],
|
|
11
|
+
gemini: ['--yolo'],
|
|
12
|
+
};
|
|
13
|
+
export const DEFAULT_TRUST_TTL_SEC = 1800; // 30m
|
|
14
|
+
/** Parse "90s" | "30m" | "1h" | "45" (bare = seconds). Returns seconds, or null if unparseable. */
|
|
15
|
+
export function parseDuration(s) {
|
|
16
|
+
const m = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs)?$/i.exec(s.trim());
|
|
17
|
+
if (!m)
|
|
18
|
+
return null;
|
|
19
|
+
const n = Number(m[1]);
|
|
20
|
+
const unit = (m[2] ?? 's').toLowerCase();
|
|
21
|
+
if (unit.startsWith('h'))
|
|
22
|
+
return n * 3600;
|
|
23
|
+
if (unit.startsWith('m'))
|
|
24
|
+
return n * 60;
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
27
|
+
export function trustPlan(agent, opts = {}) {
|
|
28
|
+
const ttlSec = opts.ttlSec && opts.ttlSec > 0 ? opts.ttlSec : DEFAULT_TRUST_TTL_SEC;
|
|
29
|
+
return {
|
|
30
|
+
agent,
|
|
31
|
+
flags: BYPASS_FLAGS[agent],
|
|
32
|
+
ttlSec,
|
|
33
|
+
warnings: [
|
|
34
|
+
`${agent} will run WITHOUT per-action permission prompts (${BYPASS_FLAGS[agent].join(' ')}).`,
|
|
35
|
+
'A worktree is NOT a sandbox: network, credentials, and `npm install` are NOT blocked.',
|
|
36
|
+
'Use per-agent, time-boxed, and only for a task you trust; pair with freeze/careful guardrails.',
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Active Frame banner + status block. PURE.
|
|
2
|
+
// Width math is done on PLAIN text; color is applied by wrapping whole already-sized lines, so ANSI
|
|
3
|
+
// escapes never corrupt alignment. Unicode box-drawing falls back to ASCII per capabilities (Windows).
|
|
4
|
+
import { statusTone } from './theme.js';
|
|
5
|
+
const MAXW = 72;
|
|
6
|
+
function fit(s, w, unicode) {
|
|
7
|
+
if (s.length <= w)
|
|
8
|
+
return s.padEnd(w);
|
|
9
|
+
return s.slice(0, Math.max(0, w - 1)) + (unicode ? '…' : '~');
|
|
10
|
+
}
|
|
11
|
+
/** The open "Active Frame": brand-colored border with the title in the top rule. */
|
|
12
|
+
export function renderFrame(title, body, ctx) {
|
|
13
|
+
const longest = Math.max(title.length + 4, ...body.map((b) => b.length + 2), 32);
|
|
14
|
+
const inner = Math.min(longest, MAXW - 2, Math.max(18, ctx.columns - 2));
|
|
15
|
+
const ch = ctx.unicode
|
|
16
|
+
? { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' }
|
|
17
|
+
: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' };
|
|
18
|
+
const topRule = `${ch.h} ${title} `.padEnd(inner, ch.h).slice(0, inner);
|
|
19
|
+
const top = ctx.ui.tone(`${ch.tl}${topRule}${ch.tr}`, 'brand');
|
|
20
|
+
const bottom = ctx.ui.tone(`${ch.bl}${ch.h.repeat(inner)}${ch.br}`, 'brand');
|
|
21
|
+
const mid = body.map((b) => `${ch.v} ${fit(b, inner - 2, ctx.unicode)} ${ch.v}`);
|
|
22
|
+
return [top, ...mid, bottom].join('\n');
|
|
23
|
+
}
|
|
24
|
+
/** Indented, aligned key/value rows (§7.2 / §8.1 status block). Keys muted, values default. */
|
|
25
|
+
export function renderKeyVals(rows, ui) {
|
|
26
|
+
const kw = rows.reduce((m, [k]) => Math.max(m, k.length), 0);
|
|
27
|
+
return rows.map(([k, v]) => ` ${ui.tone(k.padEnd(kw), 'muted')} ${v}`).join('\n');
|
|
28
|
+
}
|
|
29
|
+
/** A result header: bold label + semantically-colored status word (§8.6/§8.7). */
|
|
30
|
+
export function statusLine(label, status, ui) {
|
|
31
|
+
return `${ui.bold(label)} ${ui.tone(status, statusTone(status))}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Terminal capability detection. PURE: resolveCapabilities takes
|
|
2
|
+
// an explicit input snapshot so it is fully unit-testable; cli.ts feeds the real process/env values.
|
|
3
|
+
// Zero-dep — every signal is a Node built-in (process.stdout.isTTY/.columns/.getColorDepth(), env).
|
|
4
|
+
//
|
|
5
|
+
// Two gates that matter for non-breaking output:
|
|
6
|
+
// - color → ONLY when interactive (tty) + not disabled + depth≥4. Pipes/CI/tests get no ANSI, so
|
|
7
|
+
// existing plain-string assertions keep passing.
|
|
8
|
+
// - unicode→ symbols are UTF-8 and pipe fine, so default true; the ASCII fallback only kicks in on
|
|
9
|
+
// --plain or a *live* legacy Windows console (where box-drawing width actually breaks).
|
|
10
|
+
function quantizeDepth(bits) {
|
|
11
|
+
if (bits >= 24)
|
|
12
|
+
return 24;
|
|
13
|
+
if (bits >= 8)
|
|
14
|
+
return 8;
|
|
15
|
+
if (bits >= 4)
|
|
16
|
+
return 4;
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
export function resolveCapabilities(input = {}) {
|
|
20
|
+
const env = input.env ?? {};
|
|
21
|
+
const flags = input.flags ?? [];
|
|
22
|
+
const plain = flags.includes('--plain');
|
|
23
|
+
const tty = Boolean(input.isTTY);
|
|
24
|
+
const colorDepth = quantizeDepth(input.colorDepth ?? (tty ? 4 : 1));
|
|
25
|
+
// NO_COLOR: any defined value disables (informal standard). FORCE_COLOR (≠"0") forces on.
|
|
26
|
+
const noColor = plain || flags.includes('--no-color') || env.NO_COLOR !== undefined;
|
|
27
|
+
const forceColor = env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0';
|
|
28
|
+
const color = !noColor && (forceColor || (tty && colorDepth >= 4));
|
|
29
|
+
const utf8 = /utf-?8/i.test(`${env.LANG ?? ''} ${env.LC_ALL ?? ''} ${env.LC_CTYPE ?? ''}`);
|
|
30
|
+
// Box-drawing only misrenders in a *live* legacy Windows console; in a pipe the bytes are fine.
|
|
31
|
+
const winLegacyTty = tty && input.platform === 'win32' && !env.WT_SESSION && !env.TERM_PROGRAM && !utf8;
|
|
32
|
+
const unicode = !plain && !winLegacyTty;
|
|
33
|
+
const columns = input.columns && input.columns > 0 ? input.columns : 80;
|
|
34
|
+
return { tty, color, colorDepth, unicode, columns };
|
|
35
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Semantic symbols + color. PURE + zero-dep: color is emitted as
|
|
2
|
+
// raw ANSI escapes (no chalk/library), gated by a Painter built from UiCapabilities. The default PLAIN
|
|
3
|
+
// painter colors nothing and uses UTF-8 symbols → byte-identical to framein's pre-existing renderer
|
|
4
|
+
// output, so existing tests pass and color only appears in a real terminal.
|
|
5
|
+
// §5.2/§5.3 truecolor + §5.5 ANSI-16 fallback. agent/provider are never color-coded (§5.4).
|
|
6
|
+
const TRUECOLOR = {
|
|
7
|
+
brand: [200, 255, 61],
|
|
8
|
+
success: [82, 210, 115],
|
|
9
|
+
warning: [242, 184, 75],
|
|
10
|
+
danger: [255, 101, 119],
|
|
11
|
+
info: [84, 199, 236],
|
|
12
|
+
muted: [160, 160, 165],
|
|
13
|
+
};
|
|
14
|
+
const ANSI16 = { brand: 96, success: 92, warning: 93, danger: 91, info: 94, muted: 90 };
|
|
15
|
+
export function symbolSet(unicode) {
|
|
16
|
+
return unicode
|
|
17
|
+
? { pass: '✓', warn: '!', fail: '×', running: '●', waiting: '○', next: '→', note: '·' }
|
|
18
|
+
: { pass: '[ok]', warn: '[!]', fail: '[x]', running: '[*]', waiting: '[ ]', next: '->', note: '-' };
|
|
19
|
+
}
|
|
20
|
+
function makePainter(opts) {
|
|
21
|
+
const sym = symbolSet(opts.unicode);
|
|
22
|
+
if (!opts.color)
|
|
23
|
+
return { sym, color: false, tone: (t) => t, bold: (t) => t };
|
|
24
|
+
const code = (t) => (opts.depth >= 24 ? `38;2;${TRUECOLOR[t].join(';')}` : String(ANSI16[t]));
|
|
25
|
+
return {
|
|
26
|
+
sym,
|
|
27
|
+
color: true,
|
|
28
|
+
tone: (text, t) => `\x1b[${code(t)}m${text}\x1b[0m`,
|
|
29
|
+
bold: (text) => `\x1b[1m${text}\x1b[0m`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function painter(caps) {
|
|
33
|
+
return makePainter({ color: caps.color, depth: caps.colorDepth, unicode: caps.unicode });
|
|
34
|
+
}
|
|
35
|
+
/** No color, UTF-8 symbols — the renderer default so non-cli callers (and tests) get stable plain text. */
|
|
36
|
+
export const PLAIN = makePainter({ color: false, depth: 0, unicode: true });
|
|
37
|
+
/** Map a framein status word to its semantic tone (§8.4). */
|
|
38
|
+
export function statusTone(status) {
|
|
39
|
+
const s = status.toUpperCase();
|
|
40
|
+
if (s === 'READY')
|
|
41
|
+
return 'success';
|
|
42
|
+
if (s.includes('NOT READY') || s === 'BLOCKED' || s === 'FAILED')
|
|
43
|
+
return 'danger';
|
|
44
|
+
if (s === 'WARNING' || s.includes('WARNING'))
|
|
45
|
+
return 'warning';
|
|
46
|
+
if (s === 'RUNNING' || s === 'WAITING' || s === 'PAUSED')
|
|
47
|
+
return 'info';
|
|
48
|
+
return 'muted';
|
|
49
|
+
}
|