@open-tomato/rafa 0.7.1
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 +202 -0
- package/NOTICE +59 -0
- package/README.md +362 -0
- package/dist/PROMPT.md +28 -0
- package/dist/SKILL.md +547 -0
- package/dist/cli.js +26675 -0
- package/dist/effort/store/index.js +17 -0
- package/dist/index-adgde7x3.js +1101 -0
- package/dist/index-akz06erg.js +866 -0
- package/dist/index-zjxrphjq.js +1755 -0
- package/dist/index.js +9027 -0
- package/dist/plan/index.js +47 -0
- package/dist/plan-prompt.md +65 -0
- package/dist/plans/resolve-ci-install.md +24 -0
- package/dist/plans/resolve-ci-lint.md +24 -0
- package/dist/plans/resolve-conflict-lockfile.md +28 -0
- package/dist/plans/resolve-conflict-manifest.md +28 -0
- package/dist/ports/index.js +1 -0
- package/dist/templates/spec.md +37 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1755 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
STORE_BACKENDS,
|
|
4
|
+
activeOutput,
|
|
5
|
+
activeOutputMode,
|
|
6
|
+
createTextOutput,
|
|
7
|
+
describeValue,
|
|
8
|
+
isMapping,
|
|
9
|
+
messageOf,
|
|
10
|
+
readRafaBlocks
|
|
11
|
+
} from "./index-akz06erg.js";
|
|
12
|
+
|
|
13
|
+
// src/effort/store.ts
|
|
14
|
+
import {
|
|
15
|
+
appendFileSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync
|
|
19
|
+
} from "fs";
|
|
20
|
+
import { dirname, join } from "path";
|
|
21
|
+
var EFFORT_STORE_DIR = join(".rafa", "effort");
|
|
22
|
+
var STORE_FILE_NAMES = {
|
|
23
|
+
sessions: "sessions.ndjson",
|
|
24
|
+
commits: "commits.ndjson"
|
|
25
|
+
};
|
|
26
|
+
function asObject(value) {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
28
|
+
}
|
|
29
|
+
function normaliseKey(key) {
|
|
30
|
+
return typeof key === "string" && key.length > 0 ? key : null;
|
|
31
|
+
}
|
|
32
|
+
function effortStorePath(repoRoot, kind) {
|
|
33
|
+
return join(repoRoot, EFFORT_STORE_DIR, STORE_FILE_NAMES[kind]);
|
|
34
|
+
}
|
|
35
|
+
function readStoreText(path) {
|
|
36
|
+
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
37
|
+
}
|
|
38
|
+
function parseStoreText(text) {
|
|
39
|
+
const rows = [];
|
|
40
|
+
if (text === null || text.length === 0) {
|
|
41
|
+
return { rows, lineCount: 0, unparsedLineCount: 0 };
|
|
42
|
+
}
|
|
43
|
+
const lines = text.split(/\r?\n/);
|
|
44
|
+
if (lines[lines.length - 1] === "")
|
|
45
|
+
lines.pop();
|
|
46
|
+
let unparsedLineCount = 0;
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const record = parseRow(line);
|
|
49
|
+
if (record === null) {
|
|
50
|
+
unparsedLineCount += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
rows.push(record);
|
|
54
|
+
}
|
|
55
|
+
return { rows, lineCount: lines.length, unparsedLineCount };
|
|
56
|
+
}
|
|
57
|
+
function parseRow(line) {
|
|
58
|
+
if (line.trim().length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
return asObject(JSON.parse(line));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function readStoreRows(path) {
|
|
67
|
+
return parseStoreText(readStoreText(path));
|
|
68
|
+
}
|
|
69
|
+
function keysOfRows(rows, keyOf) {
|
|
70
|
+
const keys = new Set;
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
const key = normaliseKey(keyOf(row));
|
|
73
|
+
if (key !== null)
|
|
74
|
+
keys.add(key);
|
|
75
|
+
}
|
|
76
|
+
return keys;
|
|
77
|
+
}
|
|
78
|
+
function collectedKeys(path, keyOf) {
|
|
79
|
+
return keysOfRows(readStoreRows(path).rows, keyOf);
|
|
80
|
+
}
|
|
81
|
+
function appendRows(path, rows, keyOf) {
|
|
82
|
+
const keys = rows.map((row) => normaliseKey(keyOf(row)));
|
|
83
|
+
const keylessAt = keys.indexOf(null);
|
|
84
|
+
if (keylessAt !== -1) {
|
|
85
|
+
const at = `row ${keylessAt} of ${rows.length}`;
|
|
86
|
+
throw new Error(`effort store: ${at} carries no key; batch refused`);
|
|
87
|
+
}
|
|
88
|
+
const text = readStoreText(path);
|
|
89
|
+
const held = keysOfRows(parseStoreText(text).rows, keyOf);
|
|
90
|
+
const lines = [];
|
|
91
|
+
let skipped = 0;
|
|
92
|
+
for (const [index, row] of rows.entries()) {
|
|
93
|
+
const key = keys[index] ?? null;
|
|
94
|
+
if (key === null || held.has(key)) {
|
|
95
|
+
skipped += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
held.add(key);
|
|
99
|
+
lines.push(JSON.stringify(row));
|
|
100
|
+
}
|
|
101
|
+
if (lines.length === 0) {
|
|
102
|
+
return { path, appended: 0, skipped, separatedPartialLine: false };
|
|
103
|
+
}
|
|
104
|
+
const separatedPartialLine = text !== null && text.length > 0 && !text.endsWith(`
|
|
105
|
+
`);
|
|
106
|
+
const lead = separatedPartialLine ? `
|
|
107
|
+
` : "";
|
|
108
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
109
|
+
appendFileSync(path, `${lead}${lines.join(`
|
|
110
|
+
`)}
|
|
111
|
+
`, "utf8");
|
|
112
|
+
return {
|
|
113
|
+
path,
|
|
114
|
+
appended: lines.length,
|
|
115
|
+
skipped,
|
|
116
|
+
separatedPartialLine
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/effort/store/types.ts
|
|
121
|
+
function keyOrNull(value) {
|
|
122
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
123
|
+
}
|
|
124
|
+
var EFFORT_KEY_PROJECTIONS = {
|
|
125
|
+
sessions: (row) => keyOrNull(row.sessionId),
|
|
126
|
+
commits: (row) => keyOrNull(row.sha)
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// src/effort/store/ndjson.ts
|
|
130
|
+
function readKind(repoRoot, kind) {
|
|
131
|
+
return readStoreRows(effortStorePath(repoRoot, kind));
|
|
132
|
+
}
|
|
133
|
+
function keysOfKind(repoRoot, kind) {
|
|
134
|
+
const path = effortStorePath(repoRoot, kind);
|
|
135
|
+
return collectedKeys(path, EFFORT_KEY_PROJECTIONS[kind]);
|
|
136
|
+
}
|
|
137
|
+
function appendKind(repoRoot, kind, rows) {
|
|
138
|
+
const path = effortStorePath(repoRoot, kind);
|
|
139
|
+
return appendRows(path, rows, EFFORT_KEY_PROJECTIONS[kind]);
|
|
140
|
+
}
|
|
141
|
+
function openNdjsonStore(repoRoot) {
|
|
142
|
+
return {
|
|
143
|
+
append: (kind, rows) => appendKind(repoRoot, kind, rows),
|
|
144
|
+
keys: (kind) => keysOfKind(repoRoot, kind),
|
|
145
|
+
read: (kind) => readKind(repoRoot, kind).rows,
|
|
146
|
+
path: (kind) => effortStorePath(repoRoot, kind),
|
|
147
|
+
readRows: (kind) => readKind(repoRoot, kind)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/effort/store/sqlite.ts
|
|
152
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
153
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
154
|
+
import { Database } from "bun:sqlite";
|
|
155
|
+
var STORE_FILE_NAME = "effort.sqlite";
|
|
156
|
+
function sqliteStorePath(repoRoot) {
|
|
157
|
+
return join2(repoRoot, EFFORT_STORE_DIR, STORE_FILE_NAME);
|
|
158
|
+
}
|
|
159
|
+
var SQLITE_MIGRATIONS = [
|
|
160
|
+
`
|
|
161
|
+
CREATE TABLE sessions (
|
|
162
|
+
seq INTEGER PRIMARY KEY,
|
|
163
|
+
session_id TEXT NOT NULL UNIQUE CHECK (session_id <> ''),
|
|
164
|
+
row_json TEXT NOT NULL
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
CREATE TABLE commits (
|
|
168
|
+
seq INTEGER PRIMARY KEY,
|
|
169
|
+
sha TEXT NOT NULL UNIQUE CHECK (sha <> ''),
|
|
170
|
+
row_json TEXT NOT NULL
|
|
171
|
+
);
|
|
172
|
+
`,
|
|
173
|
+
`
|
|
174
|
+
CREATE TABLE findings (
|
|
175
|
+
seq INTEGER PRIMARY KEY,
|
|
176
|
+
id TEXT NOT NULL UNIQUE CHECK (id <> ''),
|
|
177
|
+
session_id TEXT NOT NULL CHECK (session_id <> ''),
|
|
178
|
+
plan_stub TEXT,
|
|
179
|
+
task_line TEXT NOT NULL,
|
|
180
|
+
kind TEXT
|
|
181
|
+
CHECK (kind IN ('gotcha', 'pattern', 'location', 'skill-suggestion')),
|
|
182
|
+
trigger TEXT CHECK (trigger <> ''),
|
|
183
|
+
what TEXT CHECK (what <> ''),
|
|
184
|
+
cause TEXT,
|
|
185
|
+
resolution TEXT,
|
|
186
|
+
artifact TEXT CHECK (artifact <> ''),
|
|
187
|
+
signal TEXT CHECK (signal IN ('loud', 'silent')),
|
|
188
|
+
outcome TEXT NOT NULL,
|
|
189
|
+
tracker_ref TEXT,
|
|
190
|
+
collected_at TEXT NOT NULL,
|
|
191
|
+
CHECK (artifact IS NOT NULL OR (trigger IS NOT NULL AND what IS NOT NULL))
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
CREATE UNIQUE INDEX findings_by_artifact
|
|
195
|
+
ON findings (session_id, artifact)
|
|
196
|
+
WHERE artifact IS NOT NULL;
|
|
197
|
+
|
|
198
|
+
CREATE UNIQUE INDEX findings_by_trigger_what
|
|
199
|
+
ON findings (session_id, trigger, what)
|
|
200
|
+
WHERE artifact IS NULL;
|
|
201
|
+
`,
|
|
202
|
+
`
|
|
203
|
+
CREATE TABLE blockers (
|
|
204
|
+
seq INTEGER PRIMARY KEY,
|
|
205
|
+
id TEXT NOT NULL UNIQUE CHECK (id <> ''),
|
|
206
|
+
session_id TEXT NOT NULL CHECK (session_id <> ''),
|
|
207
|
+
plan_stub TEXT,
|
|
208
|
+
task_line TEXT NOT NULL,
|
|
209
|
+
what TEXT NOT NULL CHECK (what <> ''),
|
|
210
|
+
artifact TEXT CHECK (artifact <> ''),
|
|
211
|
+
outcome TEXT NOT NULL,
|
|
212
|
+
collected_at TEXT NOT NULL
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
CREATE UNIQUE INDEX blockers_by_entry
|
|
216
|
+
ON blockers (session_id, what, ifnull(artifact, ''));
|
|
217
|
+
|
|
218
|
+
CREATE TABLE out_of_scope_bugs (
|
|
219
|
+
seq INTEGER PRIMARY KEY,
|
|
220
|
+
id TEXT NOT NULL UNIQUE CHECK (id <> ''),
|
|
221
|
+
session_id TEXT NOT NULL CHECK (session_id <> ''),
|
|
222
|
+
plan_stub TEXT,
|
|
223
|
+
task_line TEXT NOT NULL,
|
|
224
|
+
what TEXT NOT NULL CHECK (what <> ''),
|
|
225
|
+
artifact TEXT CHECK (artifact <> ''),
|
|
226
|
+
security INTEGER CHECK (security IN (0, 1)),
|
|
227
|
+
outcome TEXT NOT NULL,
|
|
228
|
+
collected_at TEXT NOT NULL
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
CREATE UNIQUE INDEX out_of_scope_bugs_by_entry
|
|
232
|
+
ON out_of_scope_bugs (session_id, what, ifnull(artifact, ''), ifnull(security, -1));
|
|
233
|
+
`,
|
|
234
|
+
`
|
|
235
|
+
CREATE TABLE report_absences (
|
|
236
|
+
seq INTEGER PRIMARY KEY,
|
|
237
|
+
id TEXT NOT NULL UNIQUE CHECK (id <> ''),
|
|
238
|
+
session_id TEXT NOT NULL UNIQUE CHECK (session_id <> ''),
|
|
239
|
+
plan_stub TEXT,
|
|
240
|
+
task_line TEXT NOT NULL,
|
|
241
|
+
reason TEXT NOT NULL CHECK (reason <> ''),
|
|
242
|
+
detail TEXT NOT NULL CHECK (detail <> ''),
|
|
243
|
+
block_body TEXT,
|
|
244
|
+
outcome TEXT NOT NULL,
|
|
245
|
+
collected_at TEXT NOT NULL
|
|
246
|
+
);
|
|
247
|
+
`,
|
|
248
|
+
`
|
|
249
|
+
CREATE TABLE task_reports (
|
|
250
|
+
seq INTEGER PRIMARY KEY,
|
|
251
|
+
id TEXT NOT NULL UNIQUE CHECK (id <> ''),
|
|
252
|
+
session_id TEXT NOT NULL UNIQUE CHECK (session_id <> ''),
|
|
253
|
+
plan_stub TEXT,
|
|
254
|
+
task_line TEXT NOT NULL,
|
|
255
|
+
status TEXT CHECK (status IN ('done', 'blocked')),
|
|
256
|
+
outcome TEXT NOT NULL,
|
|
257
|
+
collected_at TEXT NOT NULL
|
|
258
|
+
);
|
|
259
|
+
`,
|
|
260
|
+
`
|
|
261
|
+
CREATE TABLE preflight (
|
|
262
|
+
seq INTEGER PRIMARY KEY,
|
|
263
|
+
run_id TEXT NOT NULL CHECK (run_id <> ''),
|
|
264
|
+
position INTEGER NOT NULL CHECK (typeof(position) = 'integer' AND position >= 0),
|
|
265
|
+
tier TEXT NOT NULL CHECK (tier IN ('required', 'optional')),
|
|
266
|
+
kind TEXT NOT NULL CHECK (kind <> ''),
|
|
267
|
+
item TEXT NOT NULL CHECK (item <> ''),
|
|
268
|
+
probe TEXT CHECK (probe <> ''),
|
|
269
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('pass', 'fail', 'timeout')),
|
|
270
|
+
duration_ms INTEGER NOT NULL CHECK (typeof(duration_ms) = 'integer' AND duration_ms >= 0),
|
|
271
|
+
failure TEXT CHECK (failure <> ''),
|
|
272
|
+
collected_at TEXT NOT NULL,
|
|
273
|
+
UNIQUE (run_id, position),
|
|
274
|
+
CHECK ((outcome = 'pass') = (failure IS NULL))
|
|
275
|
+
);
|
|
276
|
+
`,
|
|
277
|
+
`
|
|
278
|
+
CREATE TABLE dispatches (
|
|
279
|
+
seq INTEGER PRIMARY KEY,
|
|
280
|
+
session_id TEXT NOT NULL UNIQUE CHECK (session_id <> ''),
|
|
281
|
+
plan_stub TEXT,
|
|
282
|
+
task_line TEXT NOT NULL,
|
|
283
|
+
declaration TEXT CHECK (declaration <> ''),
|
|
284
|
+
agent TEXT CHECK (agent <> ''),
|
|
285
|
+
model TEXT CHECK (model <> ''),
|
|
286
|
+
effort TEXT CHECK (effort <> ''),
|
|
287
|
+
budget_usd REAL CHECK (budget_usd IS NULL OR (typeof(budget_usd) IN ('integer', 'real') AND budget_usd > 0)),
|
|
288
|
+
tools TEXT CHECK (tools <> ''),
|
|
289
|
+
flags TEXT NOT NULL,
|
|
290
|
+
collected_at TEXT NOT NULL
|
|
291
|
+
);
|
|
292
|
+
`,
|
|
293
|
+
`
|
|
294
|
+
CREATE TABLE changes (
|
|
295
|
+
seq INTEGER PRIMARY KEY,
|
|
296
|
+
id TEXT NOT NULL UNIQUE CHECK (id <> ''),
|
|
297
|
+
session_id TEXT NOT NULL CHECK (session_id <> ''),
|
|
298
|
+
plan_stub TEXT,
|
|
299
|
+
task_line TEXT NOT NULL,
|
|
300
|
+
level TEXT NOT NULL
|
|
301
|
+
CHECK (level IN ('patch', 'minor', 'major', 'none')),
|
|
302
|
+
area TEXT CHECK (area <> ''),
|
|
303
|
+
summary TEXT NOT NULL CHECK (summary <> ''),
|
|
304
|
+
collected_at TEXT NOT NULL
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
CREATE UNIQUE INDEX changes_by_entry
|
|
308
|
+
ON changes (session_id, level, ifnull(area, ''), summary);
|
|
309
|
+
`,
|
|
310
|
+
`
|
|
311
|
+
ALTER TABLE out_of_scope_bugs
|
|
312
|
+
ADD COLUMN scope TEXT CHECK (scope IN ('machine', 'rafa'));
|
|
313
|
+
`
|
|
314
|
+
];
|
|
315
|
+
var SQLITE_SCHEMA_VERSION = SQLITE_MIGRATIONS.length;
|
|
316
|
+
var KIND_TABLES = {
|
|
317
|
+
sessions: { name: "sessions", keyColumn: "session_id" },
|
|
318
|
+
commits: { name: "commits", keyColumn: "sha" }
|
|
319
|
+
};
|
|
320
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
321
|
+
function hasKey(entry) {
|
|
322
|
+
return entry.key !== null;
|
|
323
|
+
}
|
|
324
|
+
function refused(index, length, reason) {
|
|
325
|
+
return new Error(`effort store: row ${index} of ${length} ${reason}; batch refused`);
|
|
326
|
+
}
|
|
327
|
+
function batchEntries(kind, rows) {
|
|
328
|
+
const keyOf = EFFORT_KEY_PROJECTIONS[kind];
|
|
329
|
+
const projected = rows.map((row) => ({ key: keyOf(row), row }));
|
|
330
|
+
const keyed = projected.filter(hasKey);
|
|
331
|
+
if (keyed.length < projected.length) {
|
|
332
|
+
const keylessAt = projected.findIndex((entry) => !hasKey(entry));
|
|
333
|
+
throw refused(keylessAt, rows.length, "carries no key");
|
|
334
|
+
}
|
|
335
|
+
const unholdableAt = keyed.findIndex(({ key }) => LONE_SURROGATE.test(key));
|
|
336
|
+
if (unholdableAt !== -1) {
|
|
337
|
+
const reason = "carries a key with a lone UTF-16 surrogate," + " which SQLite text cannot hold";
|
|
338
|
+
throw refused(unholdableAt, rows.length, reason);
|
|
339
|
+
}
|
|
340
|
+
return keyed.map(({ key, row }) => ({ key, body: JSON.stringify(row) }));
|
|
341
|
+
}
|
|
342
|
+
function userVersion(db) {
|
|
343
|
+
const row = db.query("PRAGMA user_version").get();
|
|
344
|
+
return row?.user_version ?? 0;
|
|
345
|
+
}
|
|
346
|
+
function checkedVersion(db, path, latest) {
|
|
347
|
+
const version = userVersion(db);
|
|
348
|
+
if (version > latest) {
|
|
349
|
+
throw new Error(`effort store: ${path} is at schema version ${version}, past the` + ` ${latest} this rafa knows; refusing to read or write it`);
|
|
350
|
+
}
|
|
351
|
+
return version;
|
|
352
|
+
}
|
|
353
|
+
function migrateSchema(db, path, migrations = SQLITE_MIGRATIONS) {
|
|
354
|
+
const latest = migrations.length;
|
|
355
|
+
if (checkedVersion(db, path, latest) === latest)
|
|
356
|
+
return;
|
|
357
|
+
const applyPending = db.transaction(() => {
|
|
358
|
+
const from = checkedVersion(db, path, latest);
|
|
359
|
+
for (const sql of migrations.slice(from))
|
|
360
|
+
db.run(sql);
|
|
361
|
+
db.run(`PRAGMA user_version = ${latest}`);
|
|
362
|
+
});
|
|
363
|
+
applyPending.immediate();
|
|
364
|
+
}
|
|
365
|
+
function withSqliteStore(path, create, use) {
|
|
366
|
+
if (create)
|
|
367
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
368
|
+
const db = new Database(path, { readwrite: true, create });
|
|
369
|
+
try {
|
|
370
|
+
migrateSchema(db, path);
|
|
371
|
+
return use(db);
|
|
372
|
+
} finally {
|
|
373
|
+
db.close();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function insertBatch(db, table, entries) {
|
|
377
|
+
const insert = db.query(`INSERT INTO ${table.name} (${table.keyColumn}, row_json) VALUES (?, ?)` + ` ON CONFLICT (${table.keyColumn}) DO NOTHING`);
|
|
378
|
+
const insertAll = db.transaction(() => entries.reduce((appended, { key, body }) => appended + insert.run(key, body).changes, 0));
|
|
379
|
+
return insertAll.immediate();
|
|
380
|
+
}
|
|
381
|
+
function writeSqliteStore(path, rowCount, empty, insert) {
|
|
382
|
+
const exists = existsSync2(path);
|
|
383
|
+
if (rowCount > 0)
|
|
384
|
+
return withSqliteStore(path, !exists, insert);
|
|
385
|
+
if (exists)
|
|
386
|
+
withSqliteStore(path, false, () => {
|
|
387
|
+
return;
|
|
388
|
+
});
|
|
389
|
+
return empty;
|
|
390
|
+
}
|
|
391
|
+
function appendKind2(path, kind, rows) {
|
|
392
|
+
const entries = batchEntries(kind, rows);
|
|
393
|
+
const table = KIND_TABLES[kind];
|
|
394
|
+
const appended = writeSqliteStore(path, entries.length, 0, (db) => insertBatch(db, table, entries));
|
|
395
|
+
return { path, appended, skipped: entries.length - appended };
|
|
396
|
+
}
|
|
397
|
+
function keysOfKind2(path, kind) {
|
|
398
|
+
if (!existsSync2(path))
|
|
399
|
+
return new Set;
|
|
400
|
+
const { name, keyColumn } = KIND_TABLES[kind];
|
|
401
|
+
const keys = withSqliteStore(path, false, (db) => db.query(`SELECT ${keyColumn} AS key FROM ${name} ORDER BY seq`).all());
|
|
402
|
+
return new Set(keys.map(({ key }) => key));
|
|
403
|
+
}
|
|
404
|
+
function parsedObject(body) {
|
|
405
|
+
try {
|
|
406
|
+
const value = JSON.parse(body);
|
|
407
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function rowOf(body, where) {
|
|
413
|
+
const value = parsedObject(body);
|
|
414
|
+
if (value === null) {
|
|
415
|
+
throw new Error(`effort store: ${where} does not hold a JSON object;` + " no append writes one");
|
|
416
|
+
}
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
function readKind2(path, kind) {
|
|
420
|
+
if (!existsSync2(path))
|
|
421
|
+
return [];
|
|
422
|
+
const { name } = KIND_TABLES[kind];
|
|
423
|
+
const stored = withSqliteStore(path, false, (db) => db.query(`SELECT seq, row_json AS body FROM ${name} ORDER BY seq`).all());
|
|
424
|
+
return stored.map(({ seq, body }) => rowOf(body, `${path}: ${name} row ${seq}`));
|
|
425
|
+
}
|
|
426
|
+
function openSqliteStore(repoRoot) {
|
|
427
|
+
const path = sqliteStorePath(repoRoot);
|
|
428
|
+
return {
|
|
429
|
+
append: (kind, rows) => appendKind2(path, kind, rows),
|
|
430
|
+
keys: (kind) => keysOfKind2(path, kind),
|
|
431
|
+
read: (kind) => readKind2(path, kind),
|
|
432
|
+
path: () => path
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/adapters/learning/local.ts
|
|
437
|
+
import { appendFile, mkdir, readFile } from "fs/promises";
|
|
438
|
+
import { join as join3 } from "path";
|
|
439
|
+
var PREFIX = "local learning";
|
|
440
|
+
var INSTINCTS_FILE = "instincts.ndjson";
|
|
441
|
+
var FLAGS_FILE = "flags.ndjson";
|
|
442
|
+
var MIN_CONFIDENCE = 0.3;
|
|
443
|
+
var MAX_CONFIDENCE = 0.9;
|
|
444
|
+
var SIGNALS = ["loud", "silent"];
|
|
445
|
+
var STATUSES = ["active", "flagged"];
|
|
446
|
+
function isString(value) {
|
|
447
|
+
return typeof value === "string";
|
|
448
|
+
}
|
|
449
|
+
function isNonEmptyString(value) {
|
|
450
|
+
return typeof value === "string" && value !== "";
|
|
451
|
+
}
|
|
452
|
+
function oneOf(members) {
|
|
453
|
+
return (value) => typeof value === "string" && members.includes(value);
|
|
454
|
+
}
|
|
455
|
+
var INSTINCT_CHECKS = {
|
|
456
|
+
id: [isNonEmptyString, "a non-empty string"],
|
|
457
|
+
trigger: [isString, "a string"],
|
|
458
|
+
action: [isString, "a string"],
|
|
459
|
+
action_hash: [isString, "a string"],
|
|
460
|
+
confidence: [
|
|
461
|
+
(value) => typeof value === "number" && value >= MIN_CONFIDENCE && value <= MAX_CONFIDENCE,
|
|
462
|
+
`a number from ${MIN_CONFIDENCE} to ${MAX_CONFIDENCE}`
|
|
463
|
+
],
|
|
464
|
+
usage_count: [
|
|
465
|
+
(value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0,
|
|
466
|
+
"a whole number from 0"
|
|
467
|
+
],
|
|
468
|
+
artifact: [(value) => value === undefined || typeof value === "string", "a string when present"],
|
|
469
|
+
signal: [oneOf(SIGNALS), `one of: ${SIGNALS.join(", ")}`],
|
|
470
|
+
status: [oneOf(STATUSES), `one of: ${STATUSES.join(", ")}`],
|
|
471
|
+
created_at: [isString, "a string"],
|
|
472
|
+
updated_at: [isString, "a string"]
|
|
473
|
+
};
|
|
474
|
+
var PAYLOAD_CHECKS = {
|
|
475
|
+
source_id: [isNonEmptyString, "a non-empty string"],
|
|
476
|
+
instincts: [Array.isArray, "a list"]
|
|
477
|
+
};
|
|
478
|
+
var HELD_INSTINCT_CHECKS = {
|
|
479
|
+
source_id: [isNonEmptyString, "a non-empty string"],
|
|
480
|
+
instinct: [isMapping, "a mapping"]
|
|
481
|
+
};
|
|
482
|
+
var FLAG_CHECKS = {
|
|
483
|
+
id: [isNonEmptyString, "a non-empty string"],
|
|
484
|
+
reason: [isString, "a string"],
|
|
485
|
+
flagged_at: [isString, "a string"]
|
|
486
|
+
};
|
|
487
|
+
function fieldsProblem(value, checks, at) {
|
|
488
|
+
if (!isMapping(value)) {
|
|
489
|
+
return `${at} is ${describeValue(value)}, expected a mapping`;
|
|
490
|
+
}
|
|
491
|
+
for (const [key, [accepts, expected]] of Object.entries(checks)) {
|
|
492
|
+
if (!accepts(value[key])) {
|
|
493
|
+
return `${key} is ${describeValue(value[key])} in ${at}, expected ${expected}`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
function payloadProblem(payload) {
|
|
499
|
+
const problem = fieldsProblem(payload, PAYLOAD_CHECKS, "the payload");
|
|
500
|
+
if (problem !== null)
|
|
501
|
+
return problem;
|
|
502
|
+
const { instincts } = payload;
|
|
503
|
+
return instincts.map((record, index) => fieldsProblem(record, INSTINCT_CHECKS, `instincts[${index}]`)).find((found) => found !== null) ?? null;
|
|
504
|
+
}
|
|
505
|
+
function heldInstinctProblem(value) {
|
|
506
|
+
return fieldsProblem(value, HELD_INSTINCT_CHECKS, "the line") ?? fieldsProblem(value.instinct, INSTINCT_CHECKS, "the instinct");
|
|
507
|
+
}
|
|
508
|
+
function flagProblem(value) {
|
|
509
|
+
return fieldsProblem(value, FLAG_CHECKS, "the flag");
|
|
510
|
+
}
|
|
511
|
+
function copyInstinct(record) {
|
|
512
|
+
const { artifact } = record;
|
|
513
|
+
return {
|
|
514
|
+
id: record.id,
|
|
515
|
+
trigger: record.trigger,
|
|
516
|
+
action: record.action,
|
|
517
|
+
action_hash: record.action_hash,
|
|
518
|
+
confidence: record.confidence,
|
|
519
|
+
usage_count: record.usage_count,
|
|
520
|
+
...artifact === undefined ? {} : { artifact },
|
|
521
|
+
signal: record.signal,
|
|
522
|
+
status: record.status,
|
|
523
|
+
created_at: record.created_at,
|
|
524
|
+
updated_at: record.updated_at
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function hasCode(error, code) {
|
|
528
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
529
|
+
}
|
|
530
|
+
async function readIfPresent(path) {
|
|
531
|
+
try {
|
|
532
|
+
return await readFile(path, "utf8");
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (hasCode(error, "ENOENT"))
|
|
535
|
+
return "";
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async function appendLines(path, lines) {
|
|
540
|
+
const held = await readIfPresent(path);
|
|
541
|
+
const lead = held === "" || held.endsWith(`
|
|
542
|
+
`) ? "" : `
|
|
543
|
+
`;
|
|
544
|
+
await appendFile(path, `${lead}${lines.join(`
|
|
545
|
+
`)}
|
|
546
|
+
`, "utf8");
|
|
547
|
+
}
|
|
548
|
+
function numberedLines(contents) {
|
|
549
|
+
return contents.split(`
|
|
550
|
+
`).flatMap((line, index) => line === "" ? [] : [[index + 1, line]]);
|
|
551
|
+
}
|
|
552
|
+
function readLine(line, problemOf) {
|
|
553
|
+
let value;
|
|
554
|
+
try {
|
|
555
|
+
value = JSON.parse(line);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return { ok: false, problem: `it is not JSON (${messageOf(error)})` };
|
|
558
|
+
}
|
|
559
|
+
const problem = problemOf(value);
|
|
560
|
+
return problem === null ? { ok: true, value } : { ok: false, problem };
|
|
561
|
+
}
|
|
562
|
+
function warnThroughActiveOutput(message) {
|
|
563
|
+
activeOutput().warn(message);
|
|
564
|
+
}
|
|
565
|
+
function localInstinctsDir(repoRoot) {
|
|
566
|
+
return join3(repoRoot, ".rafa", "instincts");
|
|
567
|
+
}
|
|
568
|
+
function createLocalLearning(options) {
|
|
569
|
+
const { instinctsDir } = options;
|
|
570
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
571
|
+
const warn = options.warn ?? warnThroughActiveOutput;
|
|
572
|
+
const instinctsPath = join3(instinctsDir, INSTINCTS_FILE);
|
|
573
|
+
const flagsPath = join3(instinctsDir, FLAGS_FILE);
|
|
574
|
+
async function heldInstincts() {
|
|
575
|
+
const lines = numberedLines(await readIfPresent(instinctsPath));
|
|
576
|
+
return lines.flatMap(([number, line]) => {
|
|
577
|
+
const reading = readLine(line, heldInstinctProblem);
|
|
578
|
+
if (reading.ok)
|
|
579
|
+
return [copyInstinct(reading.value.instinct)];
|
|
580
|
+
warn(`${PREFIX}: line ${number} of ${instinctsPath} is not a held instinct, so it was skipped: ${reading.problem}`);
|
|
581
|
+
return [];
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
async function flaggedIds() {
|
|
585
|
+
const lines = numberedLines(await readIfPresent(flagsPath));
|
|
586
|
+
return new Set(lines.map(([number, line]) => {
|
|
587
|
+
const reading = readLine(line, flagProblem);
|
|
588
|
+
if (reading.ok)
|
|
589
|
+
return reading.value.id;
|
|
590
|
+
throw new Error(`${PREFIX}: line ${number} of ${flagsPath} is not a flag,` + ` and a bundle skipping it could hold an instinct it flags: ${reading.problem}`);
|
|
591
|
+
}));
|
|
592
|
+
}
|
|
593
|
+
const learning = {
|
|
594
|
+
push: async (payload) => {
|
|
595
|
+
const problem = payloadProblem(payload);
|
|
596
|
+
if (problem !== null) {
|
|
597
|
+
throw new TypeError(`${PREFIX}: refused to store a push: ${problem}`);
|
|
598
|
+
}
|
|
599
|
+
const decisions = payload.instincts.map((incoming) => ({
|
|
600
|
+
incoming,
|
|
601
|
+
rule: "new-trigger",
|
|
602
|
+
produced: [copyInstinct(incoming)]
|
|
603
|
+
}));
|
|
604
|
+
const lines = decisions.flatMap(({ produced }) => produced.map((instinct) => JSON.stringify({
|
|
605
|
+
source_id: payload.source_id,
|
|
606
|
+
instinct
|
|
607
|
+
})));
|
|
608
|
+
if (lines.length > 0) {
|
|
609
|
+
await mkdir(instinctsDir, { recursive: true });
|
|
610
|
+
await appendLines(instinctsPath, lines);
|
|
611
|
+
}
|
|
612
|
+
return { decisions, discarded: [] };
|
|
613
|
+
},
|
|
614
|
+
pullBlessed: async () => {
|
|
615
|
+
const flagged = await flaggedIds();
|
|
616
|
+
const instincts = (await heldInstincts()).filter((instinct) => instinct.status === "active" && !flagged.has(instinct.id));
|
|
617
|
+
return { version: now(), instincts };
|
|
618
|
+
},
|
|
619
|
+
flag: async (id, reason) => {
|
|
620
|
+
const flag = { id, reason, flagged_at: now() };
|
|
621
|
+
const problem = flagProblem(flag);
|
|
622
|
+
if (problem !== null) {
|
|
623
|
+
throw new TypeError(`${PREFIX}: refused to write an invalid flag: ${problem}`);
|
|
624
|
+
}
|
|
625
|
+
const held = await heldInstincts();
|
|
626
|
+
if (!held.some((instinct) => instinct.id === id)) {
|
|
627
|
+
throw new Error(`${PREFIX}: no instinct ${describeValue(id)} is held under ${instinctsDir}`);
|
|
628
|
+
}
|
|
629
|
+
await appendLines(flagsPath, [JSON.stringify(flag)]);
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
return Object.freeze(learning);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// src/adapters/output/json.ts
|
|
636
|
+
var SECOND_RESULT_REFUSAL = "json output: refused a second terminal result event; a command emits exactly one";
|
|
637
|
+
function createJsonOutput({
|
|
638
|
+
stream,
|
|
639
|
+
now = () => new Date
|
|
640
|
+
}) {
|
|
641
|
+
let resultWritten = false;
|
|
642
|
+
const writeEvent = (event) => {
|
|
643
|
+
const isResult = event.type === "result";
|
|
644
|
+
if (isResult && resultWritten)
|
|
645
|
+
throw new Error(SECOND_RESULT_REFUSAL);
|
|
646
|
+
stream.write(`${JSON.stringify(event)}
|
|
647
|
+
`);
|
|
648
|
+
if (isResult)
|
|
649
|
+
resultWritten = true;
|
|
650
|
+
};
|
|
651
|
+
const logAt = (level, message) => {
|
|
652
|
+
writeEvent({ type: "log", level, message, ts: now().toISOString() });
|
|
653
|
+
};
|
|
654
|
+
const debug = (message) => {
|
|
655
|
+
logAt("debug", message);
|
|
656
|
+
};
|
|
657
|
+
const info = (message) => {
|
|
658
|
+
logAt("info", message);
|
|
659
|
+
};
|
|
660
|
+
const warn = (message) => {
|
|
661
|
+
logAt("warn", message);
|
|
662
|
+
};
|
|
663
|
+
const error = (message) => {
|
|
664
|
+
logAt("error", message);
|
|
665
|
+
};
|
|
666
|
+
const emit = (event) => {
|
|
667
|
+
writeEvent(event);
|
|
668
|
+
};
|
|
669
|
+
const result = (payload) => {
|
|
670
|
+
writeEvent({ type: "result", ok: true, data: payload, ts: now().toISOString() });
|
|
671
|
+
};
|
|
672
|
+
return Object.freeze({ info, warn, error, debug, emit, result });
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/adapters/planner/claude.ts
|
|
676
|
+
import { existsSync as existsSync3 } from "fs";
|
|
677
|
+
import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
|
|
678
|
+
import { posix, resolve } from "path";
|
|
679
|
+
|
|
680
|
+
// src/board/spec-review.ts
|
|
681
|
+
var SPEC_REVIEW_KIND = "spec-review";
|
|
682
|
+
var SPEC_REVIEW_VERDICTS = ["ready", "not-ready"];
|
|
683
|
+
var REVIEW_HEADING = "the review";
|
|
684
|
+
var MISSING_REVIEW_GAP = {
|
|
685
|
+
heading: REVIEW_HEADING,
|
|
686
|
+
what: "the review block was not returned"
|
|
687
|
+
};
|
|
688
|
+
var UNNAMED_GAP = {
|
|
689
|
+
heading: REVIEW_HEADING,
|
|
690
|
+
what: "the review block judged the spec not ready without naming a gap"
|
|
691
|
+
};
|
|
692
|
+
var VERDICT_BREAK = /[\s_]+/gu;
|
|
693
|
+
function isMapping2(value) {
|
|
694
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
695
|
+
}
|
|
696
|
+
function describeValue2(value) {
|
|
697
|
+
if (typeof value === "string")
|
|
698
|
+
return JSON.stringify(value);
|
|
699
|
+
if (value === null)
|
|
700
|
+
return "nothing";
|
|
701
|
+
if (Array.isArray(value))
|
|
702
|
+
return "a list";
|
|
703
|
+
if (typeof value === "object")
|
|
704
|
+
return "a mapping";
|
|
705
|
+
return `the ${typeof value} ${String(value)}`;
|
|
706
|
+
}
|
|
707
|
+
function messageOf2(error) {
|
|
708
|
+
return error instanceof Error ? error.message : String(error);
|
|
709
|
+
}
|
|
710
|
+
function counted(count, noun) {
|
|
711
|
+
return count === 1 ? `1 ${noun}` : `${String(count)} ${noun}s`;
|
|
712
|
+
}
|
|
713
|
+
function valueAt(mapping, key) {
|
|
714
|
+
return Object.hasOwn(mapping, key) ? mapping[key] ?? null : null;
|
|
715
|
+
}
|
|
716
|
+
function stringAt(mapping, key) {
|
|
717
|
+
const value = valueAt(mapping, key);
|
|
718
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
719
|
+
return null;
|
|
720
|
+
return value;
|
|
721
|
+
}
|
|
722
|
+
function verdictOf(value) {
|
|
723
|
+
if (typeof value !== "string")
|
|
724
|
+
return null;
|
|
725
|
+
const spelled = value.trim().toLowerCase();
|
|
726
|
+
const word = spelled.replace(VERDICT_BREAK, "-");
|
|
727
|
+
return SPEC_REVIEW_VERDICTS.includes(word) ? word : null;
|
|
728
|
+
}
|
|
729
|
+
function issueAt(field, text) {
|
|
730
|
+
return { field, text };
|
|
731
|
+
}
|
|
732
|
+
function readGap(item, field, issues) {
|
|
733
|
+
if (!isMapping2(item)) {
|
|
734
|
+
const text = `${field} is ${describeValue2(item)}, not a mapping of heading and what; dropped`;
|
|
735
|
+
issues.push(issueAt(field, text));
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
const what = stringAt(item, "what");
|
|
739
|
+
if (what === null) {
|
|
740
|
+
const text = `${field}.what is ${describeValue2(valueAt(item, "what"))}, ` + "not a sentence; the gap is dropped";
|
|
741
|
+
issues.push(issueAt(`${field}.what`, text));
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
const heading = stringAt(item, "heading");
|
|
745
|
+
if (heading === null) {
|
|
746
|
+
const text = `${field}.heading is ${describeValue2(valueAt(item, "heading"))}, ` + `not a heading; the gap is filed under ${REVIEW_HEADING}`;
|
|
747
|
+
issues.push(issueAt(`${field}.heading`, text));
|
|
748
|
+
}
|
|
749
|
+
return { heading: heading ?? REVIEW_HEADING, what };
|
|
750
|
+
}
|
|
751
|
+
function readGaps(document, issues) {
|
|
752
|
+
const value = valueAt(document, "gaps");
|
|
753
|
+
if (value === null)
|
|
754
|
+
return [];
|
|
755
|
+
if (!Array.isArray(value)) {
|
|
756
|
+
const text = `gaps is ${describeValue2(value)}, not a list; no gap is read from it`;
|
|
757
|
+
issues.push(issueAt("gaps", text));
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
const items = value;
|
|
761
|
+
const gaps = [];
|
|
762
|
+
for (const [index, item] of items.entries()) {
|
|
763
|
+
const gap = readGap(item, `gaps[${String(index)}]`, issues);
|
|
764
|
+
if (gap !== null)
|
|
765
|
+
gaps.push(gap);
|
|
766
|
+
}
|
|
767
|
+
return gaps;
|
|
768
|
+
}
|
|
769
|
+
function unreadable(answer, block, text) {
|
|
770
|
+
return { answer, ready: false, gaps: [MISSING_REVIEW_GAP], block, text, issues: [] };
|
|
771
|
+
}
|
|
772
|
+
function judged(verdict, gaps, block, issues) {
|
|
773
|
+
const line = String(block.span.first);
|
|
774
|
+
if (verdict === "ready") {
|
|
775
|
+
return {
|
|
776
|
+
answer: "ready",
|
|
777
|
+
ready: true,
|
|
778
|
+
gaps,
|
|
779
|
+
block,
|
|
780
|
+
text: `the rafa:spec-review block at line ${line} judged the spec ready`,
|
|
781
|
+
issues
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
const named = gaps.length === 0 ? [UNNAMED_GAP] : gaps;
|
|
785
|
+
return {
|
|
786
|
+
answer: "not-ready",
|
|
787
|
+
ready: false,
|
|
788
|
+
gaps: named,
|
|
789
|
+
block,
|
|
790
|
+
text: `the rafa:spec-review block at line ${line} judged the spec not ready, ` + `naming ${counted(named.length, "gap")}`,
|
|
791
|
+
issues
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function parseSpecReview(output) {
|
|
795
|
+
const reviews = readRafaBlocks(output).filter((found) => found.kind === SPEC_REVIEW_KIND);
|
|
796
|
+
const block = reviews.at(-1);
|
|
797
|
+
if (block === undefined) {
|
|
798
|
+
return unreadable("absent", null, "the session output holds no rafa:spec-review block");
|
|
799
|
+
}
|
|
800
|
+
const line = String(block.span.first);
|
|
801
|
+
if (!block.closed) {
|
|
802
|
+
return unreadable("malformed", block, `the rafa:spec-review block at line ${line} is never closed, so its body may have been ` + "cut short and is not read");
|
|
803
|
+
}
|
|
804
|
+
let document;
|
|
805
|
+
try {
|
|
806
|
+
document = Bun.YAML.parse(block.body);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
const text = `the rafa:spec-review block at line ${line} is not valid YAML ` + `(${messageOf2(error)})`;
|
|
809
|
+
return unreadable("malformed", block, text);
|
|
810
|
+
}
|
|
811
|
+
if (!isMapping2(document)) {
|
|
812
|
+
const text = `the rafa:spec-review block at line ${line} holds ${describeValue2(document)}, ` + "not a mapping of verdict and gaps";
|
|
813
|
+
return unreadable("malformed", block, text);
|
|
814
|
+
}
|
|
815
|
+
const verdict = verdictOf(valueAt(document, "verdict"));
|
|
816
|
+
if (verdict === null) {
|
|
817
|
+
const text = `the rafa:spec-review block at line ${line} has a verdict of ` + `${describeValue2(valueAt(document, "verdict"))}, ` + `not one of ${SPEC_REVIEW_VERDICTS.join(", ")}`;
|
|
818
|
+
return unreadable("malformed", block, text);
|
|
819
|
+
}
|
|
820
|
+
const issues = [];
|
|
821
|
+
return judged(verdict, readGaps(document, issues), block, issues);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/utils/claude.ts
|
|
825
|
+
async function getClaudeUsagePercent() {
|
|
826
|
+
const envPct = process.env["CLAUDE_USAGE_PERCENT"];
|
|
827
|
+
if (envPct !== undefined) {
|
|
828
|
+
const pct = Number(envPct);
|
|
829
|
+
return Number.isFinite(pct) ? Math.max(0, Math.min(100, pct)) : null;
|
|
830
|
+
}
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
async function checkUsage(context) {
|
|
834
|
+
const pct = await getClaudeUsagePercent();
|
|
835
|
+
if (pct === null)
|
|
836
|
+
return false;
|
|
837
|
+
if (context === "task" && pct >= 90) {
|
|
838
|
+
activeOutput().warn(`
|
|
839
|
+
Claude usage at ${pct.toFixed(0)}% (>=90%). Pausing after current task to avoid hitting the limit.`);
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
if (pct >= 80) {
|
|
843
|
+
activeOutput().warn(`
|
|
844
|
+
Claude usage at ${pct.toFixed(0)}% (>=80%). Monitor closely \u2014 tasks may be interrupted.`);
|
|
845
|
+
} else if (context === "issue" && pct >= 70) {
|
|
846
|
+
activeOutput().warn(`
|
|
847
|
+
Claude usage at ${pct.toFixed(0)}% (>=70%). Consider whether to start the next issue.`);
|
|
848
|
+
} else {
|
|
849
|
+
activeOutput().info(`
|
|
850
|
+
Claude usage: ${pct.toFixed(0)}%`);
|
|
851
|
+
}
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
var CLAUDE_BIN = "claude";
|
|
855
|
+
var CLAUDE_BASE_ARGS = [
|
|
856
|
+
"-p",
|
|
857
|
+
"--dangerously-skip-permissions"
|
|
858
|
+
];
|
|
859
|
+
var SETTING_SOURCES_FLAG = "--setting-sources";
|
|
860
|
+
function claudeArgs(settingSources, flags = []) {
|
|
861
|
+
return [...CLAUDE_BASE_ARGS, SETTING_SOURCES_FLAG, settingSources.join(","), ...flags];
|
|
862
|
+
}
|
|
863
|
+
var liveSessions = new Set;
|
|
864
|
+
function interruptClaudeSessions() {
|
|
865
|
+
for (const session of liveSessions)
|
|
866
|
+
session.kill("SIGINT");
|
|
867
|
+
return liveSessions.size;
|
|
868
|
+
}
|
|
869
|
+
function claudeSessionEnv() {
|
|
870
|
+
return {
|
|
871
|
+
...process.env,
|
|
872
|
+
CLAUDE_CODE_ENTRYPOINT: "cli"
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async function spawnClaude(args, prompt) {
|
|
876
|
+
if (activeOutputMode() === "json") {
|
|
877
|
+
const { exitCode } = await spawnClaudeCaptured(args, prompt);
|
|
878
|
+
return exitCode;
|
|
879
|
+
}
|
|
880
|
+
const proc = Bun.spawn([CLAUDE_BIN, ...args], {
|
|
881
|
+
stdin: new TextEncoder().encode(prompt),
|
|
882
|
+
stdout: "inherit",
|
|
883
|
+
stderr: "inherit",
|
|
884
|
+
env: claudeSessionEnv()
|
|
885
|
+
});
|
|
886
|
+
liveSessions.add(proc);
|
|
887
|
+
try {
|
|
888
|
+
return await proc.exited ?? 1;
|
|
889
|
+
} finally {
|
|
890
|
+
liveSessions.delete(proc);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
function runClaude(prompt, settingSources, flags = [], spawn = spawnClaude) {
|
|
894
|
+
return spawn(claudeArgs(settingSources, flags), prompt);
|
|
895
|
+
}
|
|
896
|
+
function writeWholeLines(text, write) {
|
|
897
|
+
const lines = text.split(`
|
|
898
|
+
`);
|
|
899
|
+
const rest = lines.pop() ?? "";
|
|
900
|
+
for (const line of lines)
|
|
901
|
+
write(line);
|
|
902
|
+
return rest;
|
|
903
|
+
}
|
|
904
|
+
async function teeToOperator(stream) {
|
|
905
|
+
const decoder = new TextDecoder;
|
|
906
|
+
const jsonOutput = activeOutputMode() === "json" ? activeOutput() : null;
|
|
907
|
+
const writeLine = (line) => {
|
|
908
|
+
jsonOutput?.info(line);
|
|
909
|
+
};
|
|
910
|
+
let text = "";
|
|
911
|
+
let pending = "";
|
|
912
|
+
for await (const chunk of stream) {
|
|
913
|
+
if (jsonOutput === null)
|
|
914
|
+
process.stdout.write(chunk);
|
|
915
|
+
const decoded = decoder.decode(chunk, { stream: true });
|
|
916
|
+
text += decoded;
|
|
917
|
+
if (jsonOutput !== null)
|
|
918
|
+
pending = writeWholeLines(pending + decoded, writeLine);
|
|
919
|
+
}
|
|
920
|
+
const tail = decoder.decode();
|
|
921
|
+
if (jsonOutput !== null) {
|
|
922
|
+
const rest = writeWholeLines(pending + tail, writeLine);
|
|
923
|
+
if (rest !== "")
|
|
924
|
+
writeLine(rest);
|
|
925
|
+
}
|
|
926
|
+
return text + tail;
|
|
927
|
+
}
|
|
928
|
+
async function spawnClaudeCaptured(args, prompt) {
|
|
929
|
+
const proc = Bun.spawn([CLAUDE_BIN, ...args], {
|
|
930
|
+
stdin: new TextEncoder().encode(prompt),
|
|
931
|
+
stdout: "pipe",
|
|
932
|
+
stderr: "inherit",
|
|
933
|
+
env: claudeSessionEnv()
|
|
934
|
+
});
|
|
935
|
+
liveSessions.add(proc);
|
|
936
|
+
let stdout;
|
|
937
|
+
try {
|
|
938
|
+
stdout = await teeToOperator(proc.stdout);
|
|
939
|
+
} finally {
|
|
940
|
+
await proc.exited;
|
|
941
|
+
liveSessions.delete(proc);
|
|
942
|
+
}
|
|
943
|
+
return { exitCode: await proc.exited, stdout };
|
|
944
|
+
}
|
|
945
|
+
function runClaudeCaptured(prompt, settingSources, flags = [], spawn = spawnClaudeCaptured) {
|
|
946
|
+
return spawn(claudeArgs(settingSources, flags), prompt);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/adapters/planner/claude.ts
|
|
950
|
+
var FAILURE_EXIT_CODE = 1;
|
|
951
|
+
function planFilePath(planDir, fileName) {
|
|
952
|
+
return posix.join(planDir, fileName);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
class ClaudePlannerError extends Error {
|
|
956
|
+
exitCode;
|
|
957
|
+
review;
|
|
958
|
+
constructor(message, exitCode, review = null) {
|
|
959
|
+
super(message);
|
|
960
|
+
this.name = "ClaudePlannerError";
|
|
961
|
+
this.exitCode = exitCode;
|
|
962
|
+
this.review = review;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function createClaudePlanner(options) {
|
|
966
|
+
const { repoRoot, planDir, settingSources, buildPrompt, spawn = spawnClaudeCaptured } = options;
|
|
967
|
+
const create = async ({ specPath, stub }) => {
|
|
968
|
+
const planPath = planFilePath(planDir, `PLAN-${stub}.md`);
|
|
969
|
+
const prerequisitesPath = planFilePath(planDir, `PREREQUISITES-${stub}.md`);
|
|
970
|
+
const isWritten = (path) => existsSync3(resolve(repoRoot, path));
|
|
971
|
+
if (isWritten(planPath)) {
|
|
972
|
+
throw new ClaudePlannerError(`${planPath} already exists`, FAILURE_EXIT_CODE);
|
|
973
|
+
}
|
|
974
|
+
const specContent = await readFile2(resolve(repoRoot, specPath), "utf8");
|
|
975
|
+
await mkdir2(resolve(repoRoot, planDir), { recursive: true });
|
|
976
|
+
const session = await runClaudeCaptured(buildPrompt(specContent, stub), settingSources, [], spawn);
|
|
977
|
+
const review = parseSpecReview(session.stdout);
|
|
978
|
+
const { exitCode } = session;
|
|
979
|
+
if (exitCode !== 0) {
|
|
980
|
+
throw new ClaudePlannerError(`Plan generation failed (exit ${exitCode}).`, exitCode, review);
|
|
981
|
+
}
|
|
982
|
+
if (!isWritten(planPath)) {
|
|
983
|
+
throw new ClaudePlannerError(`The session finished but ${planPath} was not created \u2014 inspect the output above.`, FAILURE_EXIT_CODE, review);
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
planPath,
|
|
987
|
+
prerequisitesPath: isWritten(prerequisitesPath) ? prerequisitesPath : null,
|
|
988
|
+
review
|
|
989
|
+
};
|
|
990
|
+
};
|
|
991
|
+
return Object.freeze({ create });
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// src/adapters/tracker/issue-values.ts
|
|
995
|
+
var TYPE_MEMBERS = {
|
|
996
|
+
code: true,
|
|
997
|
+
bug: true,
|
|
998
|
+
spike: true,
|
|
999
|
+
adr: true,
|
|
1000
|
+
chore: true,
|
|
1001
|
+
"package-api": true
|
|
1002
|
+
};
|
|
1003
|
+
var PRIORITY_MEMBERS = {
|
|
1004
|
+
urgent: true,
|
|
1005
|
+
high: true,
|
|
1006
|
+
medium: true,
|
|
1007
|
+
low: true
|
|
1008
|
+
};
|
|
1009
|
+
var STATE_MEMBERS = {
|
|
1010
|
+
backlog: true,
|
|
1011
|
+
todo: true,
|
|
1012
|
+
"in-progress": true,
|
|
1013
|
+
"in-review": true,
|
|
1014
|
+
done: true,
|
|
1015
|
+
released: true,
|
|
1016
|
+
cancelled: true
|
|
1017
|
+
};
|
|
1018
|
+
var ISSUE_TYPES = Object.freeze(Object.keys(TYPE_MEMBERS));
|
|
1019
|
+
var ISSUE_PRIORITIES = Object.freeze(Object.keys(PRIORITY_MEMBERS));
|
|
1020
|
+
var ISSUE_STATES = Object.freeze(Object.keys(STATE_MEMBERS));
|
|
1021
|
+
|
|
1022
|
+
// src/adapters/tracker/github.ts
|
|
1023
|
+
var PREFIX2 = "github tracker";
|
|
1024
|
+
var GITHUB_LABELS = Object.freeze({
|
|
1025
|
+
modulePrefix: "module:",
|
|
1026
|
+
typePrefix: "type:",
|
|
1027
|
+
priorityPrefix: "priority:",
|
|
1028
|
+
needsTriage: "needs-triage",
|
|
1029
|
+
blockedByPrefix: "blocked-by:OPT-"
|
|
1030
|
+
});
|
|
1031
|
+
var VIEW_FIELDS = "number,title,body,state,stateReason,labels,url";
|
|
1032
|
+
var LIST_FIELDS = "number,url,labels";
|
|
1033
|
+
var DEFAULT_FIND_LIMIT = 30;
|
|
1034
|
+
var NOT_PLANNED = "NOT_PLANNED";
|
|
1035
|
+
var ISSUE_NUMBER = /^[1-9]\d*$/;
|
|
1036
|
+
var REPO_PART = /^[\w.-]+$/;
|
|
1037
|
+
var CREATED_ISSUE_URL = /^https:\/\/[^\s/]+\/[\w.-]+\/[\w.-]+\/issues\/([1-9]\d*)$/;
|
|
1038
|
+
var REST_STATES = {
|
|
1039
|
+
backlog: "open",
|
|
1040
|
+
todo: "open",
|
|
1041
|
+
"in-progress": "open",
|
|
1042
|
+
"in-review": "open",
|
|
1043
|
+
done: "closed",
|
|
1044
|
+
released: "closed",
|
|
1045
|
+
cancelled: "closed"
|
|
1046
|
+
};
|
|
1047
|
+
function createGhRunner(options) {
|
|
1048
|
+
const { cwd, command = "gh" } = options;
|
|
1049
|
+
return async (args) => {
|
|
1050
|
+
try {
|
|
1051
|
+
const proc = Bun.spawn([command, ...args], {
|
|
1052
|
+
cwd,
|
|
1053
|
+
stdin: "ignore",
|
|
1054
|
+
stdout: "pipe",
|
|
1055
|
+
stderr: "pipe"
|
|
1056
|
+
});
|
|
1057
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1058
|
+
new Response(proc.stdout).text(),
|
|
1059
|
+
new Response(proc.stderr).text(),
|
|
1060
|
+
proc.exited
|
|
1061
|
+
]);
|
|
1062
|
+
return { ok: exitCode === 0, stdout, stderr };
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
return { ok: false, stdout: "", stderr: `could not run ${command} in ${cwd}: ${messageOf(error)}` };
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
function isOneOf(members, value) {
|
|
1069
|
+
return typeof value === "string" && members.includes(value);
|
|
1070
|
+
}
|
|
1071
|
+
function isIssueNumber(value) {
|
|
1072
|
+
return typeof value === "string" && ISSUE_NUMBER.test(value) && Number.isSafeInteger(Number(value));
|
|
1073
|
+
}
|
|
1074
|
+
function isRepoName(value) {
|
|
1075
|
+
if (typeof value !== "string")
|
|
1076
|
+
return false;
|
|
1077
|
+
const parts = value.split("/");
|
|
1078
|
+
return parts.length === 2 && parts.every((part) => REPO_PART.test(part) && part !== "." && part !== "..");
|
|
1079
|
+
}
|
|
1080
|
+
function isLabelPart(value) {
|
|
1081
|
+
return typeof value === "string" && !value.includes(",");
|
|
1082
|
+
}
|
|
1083
|
+
function optionalFlag(name, value) {
|
|
1084
|
+
return value === undefined ? [] : [name, value];
|
|
1085
|
+
}
|
|
1086
|
+
function detailOf(result) {
|
|
1087
|
+
return result.stderr.trim() || result.stdout.trim() || "it exited non-zero and wrote nothing";
|
|
1088
|
+
}
|
|
1089
|
+
var DRAFT_CHECKS = [
|
|
1090
|
+
["opt", (value) => typeof value === "number" && Number.isFinite(value), "a number"],
|
|
1091
|
+
["title", (value) => typeof value === "string", "a string"],
|
|
1092
|
+
["body", (value) => typeof value === "string", "a string"],
|
|
1093
|
+
["type", (value) => isOneOf(ISSUE_TYPES, value), `one of: ${ISSUE_TYPES.join(", ")}`],
|
|
1094
|
+
["module", isLabelPart, "a string holding no comma"],
|
|
1095
|
+
[
|
|
1096
|
+
"priority",
|
|
1097
|
+
(value) => value === null || isOneOf(ISSUE_PRIORITIES, value),
|
|
1098
|
+
`null or one of: ${ISSUE_PRIORITIES.join(", ")}`
|
|
1099
|
+
],
|
|
1100
|
+
["project", (value) => value === null || typeof value === "string", "a string or null"],
|
|
1101
|
+
[
|
|
1102
|
+
"blockedBy",
|
|
1103
|
+
(value) => Array.isArray(value) && value.every((item) => Number.isSafeInteger(item) && item > 0),
|
|
1104
|
+
"a list of positive whole numbers"
|
|
1105
|
+
]
|
|
1106
|
+
];
|
|
1107
|
+
function draftProblem(draft) {
|
|
1108
|
+
if (!isMapping(draft))
|
|
1109
|
+
return `the draft is ${describeValue(draft)}, expected a mapping`;
|
|
1110
|
+
const failed = DRAFT_CHECKS.find(([key, accepts]) => !accepts(draft[key]));
|
|
1111
|
+
return failed === undefined ? null : `${failed[0]} is ${describeValue(draft[failed[0]])}, expected ${failed[2]}`;
|
|
1112
|
+
}
|
|
1113
|
+
function labelsFor(draft) {
|
|
1114
|
+
const priority = draft.priority === null ? GITHUB_LABELS.needsTriage : `${GITHUB_LABELS.priorityPrefix}${draft.priority}`;
|
|
1115
|
+
return [
|
|
1116
|
+
`${GITHUB_LABELS.modulePrefix}${draft.module}`,
|
|
1117
|
+
`${GITHUB_LABELS.typePrefix}${draft.type}`,
|
|
1118
|
+
...draft.blockedBy.map((opt) => `${GITHUB_LABELS.blockedByPrefix}${opt}`),
|
|
1119
|
+
priority
|
|
1120
|
+
];
|
|
1121
|
+
}
|
|
1122
|
+
function issueNumberOfRef(ref) {
|
|
1123
|
+
if (ref.kind !== "github") {
|
|
1124
|
+
throw new Error(`${PREFIX2}: refused a ref of kind ${describeValue(ref.kind)}; this tracker reads github refs only`);
|
|
1125
|
+
}
|
|
1126
|
+
if (!isIssueNumber(ref.externalId)) {
|
|
1127
|
+
throw new Error(`${PREFIX2}: externalId ${describeValue(ref.externalId)} is not a GitHub issue number,` + " expected a positive whole number with no leading zero");
|
|
1128
|
+
}
|
|
1129
|
+
if (ref.repo !== undefined && !isRepoName(ref.repo)) {
|
|
1130
|
+
throw new Error(`${PREFIX2}: repo ${describeValue(ref.repo)} is not a repository, expected owner/name`);
|
|
1131
|
+
}
|
|
1132
|
+
return ref.externalId;
|
|
1133
|
+
}
|
|
1134
|
+
function parseJson(stdout, command) {
|
|
1135
|
+
try {
|
|
1136
|
+
return JSON.parse(stdout);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
throw new Error(`${PREFIX2}: ${command} wrote output that is not JSON: ${messageOf(error)}`, {
|
|
1139
|
+
cause: error
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
function labelNames(value) {
|
|
1144
|
+
if (!Array.isArray(value))
|
|
1145
|
+
return null;
|
|
1146
|
+
const names = value.map((label) => isMapping(label) && typeof label["name"] === "string" ? label["name"] : null);
|
|
1147
|
+
return names.every((name) => name !== null) ? names : null;
|
|
1148
|
+
}
|
|
1149
|
+
function viewProblem(payload, number) {
|
|
1150
|
+
if (!isMapping(payload))
|
|
1151
|
+
return `${describeValue(payload)}, expected a mapping`;
|
|
1152
|
+
const { title, body, state, stateReason, labels, url } = payload;
|
|
1153
|
+
if (typeof url !== "string" || !url.endsWith(`/issues/${number}`)) {
|
|
1154
|
+
return `url ${describeValue(url)}, expected an issue URL ending /issues/${number},` + ` where a pull request URL ends /pull/${number}`;
|
|
1155
|
+
}
|
|
1156
|
+
if (typeof title !== "string")
|
|
1157
|
+
return `title ${describeValue(title)}, expected a string`;
|
|
1158
|
+
if (typeof body !== "string")
|
|
1159
|
+
return `body ${describeValue(body)}, expected a string`;
|
|
1160
|
+
if (state !== "OPEN" && state !== "CLOSED")
|
|
1161
|
+
return `state ${describeValue(state)}, expected "OPEN" or "CLOSED"`;
|
|
1162
|
+
if (stateReason !== null && typeof stateReason !== "string") {
|
|
1163
|
+
return `stateReason ${describeValue(stateReason)}, expected a string or null`;
|
|
1164
|
+
}
|
|
1165
|
+
return labelNames(labels) === null ? "labels that are not a list of named labels" : null;
|
|
1166
|
+
}
|
|
1167
|
+
function viewedIssue(stdout, number) {
|
|
1168
|
+
const command = `gh issue view ${number}`;
|
|
1169
|
+
const payload = parseJson(stdout, command);
|
|
1170
|
+
const problem = viewProblem(payload, number);
|
|
1171
|
+
if (problem !== null)
|
|
1172
|
+
throw new Error(`${PREFIX2}: ${command} answered ${problem}`);
|
|
1173
|
+
const checked = payload;
|
|
1174
|
+
return { ...checked, labels: labelNames(checked.labels) ?? [] };
|
|
1175
|
+
}
|
|
1176
|
+
function listedIssues(stdout) {
|
|
1177
|
+
const command = "gh issue list";
|
|
1178
|
+
const payload = parseJson(stdout, command);
|
|
1179
|
+
if (!Array.isArray(payload)) {
|
|
1180
|
+
throw new Error(`${PREFIX2}: ${command} answered ${describeValue(payload)}, expected a list`);
|
|
1181
|
+
}
|
|
1182
|
+
return payload.map((row, index) => {
|
|
1183
|
+
const refuse = (problem) => new Error(`${PREFIX2}: ${command} answered row ${index} ${problem}`);
|
|
1184
|
+
if (!isMapping(row))
|
|
1185
|
+
throw refuse(`${describeValue(row)}, expected a mapping`);
|
|
1186
|
+
const { number, url } = row;
|
|
1187
|
+
const labels = labelNames(row["labels"]);
|
|
1188
|
+
if (typeof number !== "number" || !isIssueNumber(String(number))) {
|
|
1189
|
+
throw refuse(`with number ${describeValue(number)}, expected an issue number`);
|
|
1190
|
+
}
|
|
1191
|
+
if (typeof url !== "string")
|
|
1192
|
+
throw refuse(`with url ${describeValue(url)}, expected a string`);
|
|
1193
|
+
if (labels === null)
|
|
1194
|
+
throw refuse("with labels that are not a list of named labels");
|
|
1195
|
+
return { number, url, labels };
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
function labelValue(labels, prefix) {
|
|
1199
|
+
return labels.find((label) => label.startsWith(prefix))?.slice(prefix.length);
|
|
1200
|
+
}
|
|
1201
|
+
function stateOf(viewed) {
|
|
1202
|
+
if (viewed.state === "OPEN")
|
|
1203
|
+
return "todo";
|
|
1204
|
+
return viewed.stateReason === NOT_PLANNED ? "cancelled" : "done";
|
|
1205
|
+
}
|
|
1206
|
+
function issueOf(ref, viewed) {
|
|
1207
|
+
const type = labelValue(viewed.labels, GITHUB_LABELS.typePrefix);
|
|
1208
|
+
const priority = labelValue(viewed.labels, GITHUB_LABELS.priorityPrefix);
|
|
1209
|
+
return {
|
|
1210
|
+
opt: ref.opt,
|
|
1211
|
+
title: viewed.title,
|
|
1212
|
+
body: viewed.body,
|
|
1213
|
+
type: isOneOf(ISSUE_TYPES, type) ? type : "code",
|
|
1214
|
+
module: labelValue(viewed.labels, GITHUB_LABELS.modulePrefix) ?? "unassigned",
|
|
1215
|
+
priority: isOneOf(ISSUE_PRIORITIES, priority) ? priority : null,
|
|
1216
|
+
project: null,
|
|
1217
|
+
blockedBy: viewed.labels.map((label) => label.startsWith(GITHUB_LABELS.blockedByPrefix) ? label.slice(GITHUB_LABELS.blockedByPrefix.length) : "").filter(isIssueNumber).map(Number),
|
|
1218
|
+
ref: { ...ref, url: viewed.url },
|
|
1219
|
+
state: stateOf(viewed)
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function readBackState(state) {
|
|
1223
|
+
if (REST_STATES[state] === "open")
|
|
1224
|
+
return "todo";
|
|
1225
|
+
return state === "cancelled" ? "cancelled" : "done";
|
|
1226
|
+
}
|
|
1227
|
+
function stateQueryRefusal(state) {
|
|
1228
|
+
const bucket = REST_STATES[state];
|
|
1229
|
+
const siblings = ISSUE_STATES.filter((other) => other !== state && REST_STATES[other] === bucket);
|
|
1230
|
+
return `${PREFIX2}: find cannot narrow by state ${describeValue(state)}: GitHub holds an issue open or` + ` closed, and ${state} shares ${bucket} with ${siblings.join(", ")}, so a result would include` + " those too. Search without a state, then read each issue with get.";
|
|
1231
|
+
}
|
|
1232
|
+
function createGithubTracker(options) {
|
|
1233
|
+
const { gh } = options;
|
|
1234
|
+
let knownLabels = new Set;
|
|
1235
|
+
async function run(args, command) {
|
|
1236
|
+
const result = await gh(args);
|
|
1237
|
+
if (!result.ok)
|
|
1238
|
+
throw new Error(`${PREFIX2}: ${command} failed: ${detailOf(result)}`);
|
|
1239
|
+
return result.stdout;
|
|
1240
|
+
}
|
|
1241
|
+
async function ensureLabels(names) {
|
|
1242
|
+
const missing = [...new Set(names)].filter((name) => !knownLabels.has(name));
|
|
1243
|
+
for (const name of missing) {
|
|
1244
|
+
await run(["label", "create", name, "--force"], `gh label create ${describeValue(name)}`);
|
|
1245
|
+
}
|
|
1246
|
+
knownLabels = new Set([...knownLabels, ...missing]);
|
|
1247
|
+
}
|
|
1248
|
+
const tracker = {
|
|
1249
|
+
kind: "github",
|
|
1250
|
+
capabilities: () => ({
|
|
1251
|
+
projects: false,
|
|
1252
|
+
customFields: false,
|
|
1253
|
+
issueTypes: false
|
|
1254
|
+
}),
|
|
1255
|
+
preflight: async () => {
|
|
1256
|
+
try {
|
|
1257
|
+
const auth = await gh(["auth", "status"]);
|
|
1258
|
+
if (!auth.ok)
|
|
1259
|
+
return { ok: false, reason: `gh auth status: ${detailOf(auth)}` };
|
|
1260
|
+
const repo = await gh(["repo", "view", "--json", "nameWithOwner"]);
|
|
1261
|
+
if (!repo.ok)
|
|
1262
|
+
return { ok: false, reason: `gh repo view: ${detailOf(repo)}` };
|
|
1263
|
+
return { ok: true };
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
return { ok: false, reason: `gh could not be run: ${messageOf(error)}` };
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
create: async (draft) => {
|
|
1269
|
+
const problem = draftProblem(draft);
|
|
1270
|
+
if (problem !== null)
|
|
1271
|
+
throw new TypeError(`${PREFIX2}: refused to file an invalid draft: ${problem}`);
|
|
1272
|
+
const labels = labelsFor(draft);
|
|
1273
|
+
await ensureLabels(labels);
|
|
1274
|
+
const stdout = await run([
|
|
1275
|
+
"issue",
|
|
1276
|
+
"create",
|
|
1277
|
+
"--title",
|
|
1278
|
+
draft.title,
|
|
1279
|
+
"--body",
|
|
1280
|
+
draft.body,
|
|
1281
|
+
...labels.flatMap((label) => ["--label", label])
|
|
1282
|
+
], "gh issue create");
|
|
1283
|
+
const url = stdout.trim().split(`
|
|
1284
|
+
`).at(-1) ?? "";
|
|
1285
|
+
const number = CREATED_ISSUE_URL.exec(url)?.[1];
|
|
1286
|
+
if (number === undefined) {
|
|
1287
|
+
throw new Error(`${PREFIX2}: gh issue create exited 0 and printed no issue URL, so the issue may exist` + ` unrecorded; it wrote ${describeValue(stdout)}`);
|
|
1288
|
+
}
|
|
1289
|
+
return { opt: draft.opt, kind: "github", externalId: number, url, module: draft.module };
|
|
1290
|
+
},
|
|
1291
|
+
get: async (ref) => {
|
|
1292
|
+
const number = issueNumberOfRef(ref);
|
|
1293
|
+
const stdout = await run(["issue", "view", number, ...optionalFlag("--repo", ref.repo), "--json", VIEW_FIELDS], `gh issue view ${number}`);
|
|
1294
|
+
return issueOf(ref, viewedIssue(stdout, number));
|
|
1295
|
+
},
|
|
1296
|
+
find: async (query) => {
|
|
1297
|
+
if (query.state !== undefined)
|
|
1298
|
+
throw new Error(stateQueryRefusal(query.state));
|
|
1299
|
+
if (query.module !== undefined && !isLabelPart(query.module)) {
|
|
1300
|
+
throw new TypeError(`${PREFIX2}: find refused module ${describeValue(query.module)}, expected a string holding no comma`);
|
|
1301
|
+
}
|
|
1302
|
+
const moduleLabel = query.module === undefined ? undefined : `${GITHUB_LABELS.modulePrefix}${query.module}`;
|
|
1303
|
+
const typeLabel = query.type === undefined ? undefined : `${GITHUB_LABELS.typePrefix}${query.type}`;
|
|
1304
|
+
const stdout = await run([
|
|
1305
|
+
"issue",
|
|
1306
|
+
"list",
|
|
1307
|
+
"--state",
|
|
1308
|
+
"all",
|
|
1309
|
+
"--json",
|
|
1310
|
+
LIST_FIELDS,
|
|
1311
|
+
"--limit",
|
|
1312
|
+
String(query.limit ?? DEFAULT_FIND_LIMIT),
|
|
1313
|
+
...optionalFlag("--label", moduleLabel),
|
|
1314
|
+
...optionalFlag("--label", typeLabel),
|
|
1315
|
+
...optionalFlag("--search", query.text)
|
|
1316
|
+
], "gh issue list");
|
|
1317
|
+
return listedIssues(stdout).map((row) => {
|
|
1318
|
+
const module = labelValue(row.labels, GITHUB_LABELS.modulePrefix);
|
|
1319
|
+
const ref = { opt: 0, kind: "github", externalId: String(row.number), url: row.url };
|
|
1320
|
+
return module === undefined ? ref : { ...ref, module };
|
|
1321
|
+
});
|
|
1322
|
+
},
|
|
1323
|
+
comment: async (ref, body) => {
|
|
1324
|
+
const number = issueNumberOfRef(ref);
|
|
1325
|
+
if (typeof body !== "string") {
|
|
1326
|
+
throw new TypeError(`${PREFIX2}: refused a comment body ${describeValue(body)}, expected a string`);
|
|
1327
|
+
}
|
|
1328
|
+
await run(["issue", "comment", number, ...optionalFlag("--repo", ref.repo), "--body", body], `gh issue comment ${number}`);
|
|
1329
|
+
},
|
|
1330
|
+
transition: async (ref, state) => {
|
|
1331
|
+
const number = issueNumberOfRef(ref);
|
|
1332
|
+
if (!isOneOf(ISSUE_STATES, state)) {
|
|
1333
|
+
throw new TypeError(`${PREFIX2}: refused state ${describeValue(state)}, expected one of: ${ISSUE_STATES.join(", ")}`);
|
|
1334
|
+
}
|
|
1335
|
+
if (REST_STATES[state] === "closed") {
|
|
1336
|
+
const path = `repos/${ref.repo ?? "{owner}/{repo}"}/issues/${number}`;
|
|
1337
|
+
const reason = state === "cancelled" ? "not_planned" : "completed";
|
|
1338
|
+
await run(["api", path, "-X", "PATCH", "-f", "state=closed", "-f", `state_reason=${reason}`], `gh api PATCH ${path}`);
|
|
1339
|
+
} else {
|
|
1340
|
+
await run(["issue", "reopen", number, ...optionalFlag("--repo", ref.repo)], `gh issue reopen ${number}`);
|
|
1341
|
+
}
|
|
1342
|
+
const readBack = readBackState(state);
|
|
1343
|
+
if (readBack === state)
|
|
1344
|
+
return {};
|
|
1345
|
+
return {
|
|
1346
|
+
warning: `issue #${number} is now ${REST_STATES[state]}, but GitHub Issues without a board` + ` holds no ${state} state, so get reads it back as ${readBack}`
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
return Object.freeze(tracker);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// src/adapters/tracker/local.ts
|
|
1354
|
+
import { mkdir as mkdir3, readdir, readFile as readFile3, writeFile } from "fs/promises";
|
|
1355
|
+
import { join as join4 } from "path";
|
|
1356
|
+
var PREFIX3 = "local tracker";
|
|
1357
|
+
var FRONTMATTER = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
|
1358
|
+
var ISSUE_NUMBER2 = /^[1-9]\d*$/;
|
|
1359
|
+
var ISSUE_EXTENSION = ".md";
|
|
1360
|
+
function isString2(value) {
|
|
1361
|
+
return typeof value === "string";
|
|
1362
|
+
}
|
|
1363
|
+
function isFiniteNumber(value) {
|
|
1364
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1365
|
+
}
|
|
1366
|
+
function oneOf2(members) {
|
|
1367
|
+
return (value) => typeof value === "string" && members.includes(value);
|
|
1368
|
+
}
|
|
1369
|
+
function orNull(accepts) {
|
|
1370
|
+
return (value) => value === null || accepts(value);
|
|
1371
|
+
}
|
|
1372
|
+
function listOf(accepts) {
|
|
1373
|
+
return (value) => Array.isArray(value) && value.every(accepts);
|
|
1374
|
+
}
|
|
1375
|
+
var FIELD_CHECKS = {
|
|
1376
|
+
opt: [isFiniteNumber, "a number"],
|
|
1377
|
+
type: [oneOf2(ISSUE_TYPES), `one of: ${ISSUE_TYPES.join(", ")}`],
|
|
1378
|
+
module: [isString2, "a string"],
|
|
1379
|
+
priority: [orNull(oneOf2(ISSUE_PRIORITIES)), `null or one of: ${ISSUE_PRIORITIES.join(", ")}`],
|
|
1380
|
+
project: [orNull(isString2), "a string or null"],
|
|
1381
|
+
title: [isString2, "a string"],
|
|
1382
|
+
blockedBy: [listOf(isFiniteNumber), "a list of numbers"],
|
|
1383
|
+
state: [oneOf2(ISSUE_STATES), `one of: ${ISSUE_STATES.join(", ")}`],
|
|
1384
|
+
fallbackReason: [orNull(isString2), "a string or null"],
|
|
1385
|
+
capturedAt: [isString2, "a string"],
|
|
1386
|
+
comments: [listOf(isString2), "a list of strings"]
|
|
1387
|
+
};
|
|
1388
|
+
function frontmatterProblem(value) {
|
|
1389
|
+
if (!isMapping(value)) {
|
|
1390
|
+
return `the frontmatter is ${describeValue(value)}, expected a mapping`;
|
|
1391
|
+
}
|
|
1392
|
+
for (const [key, [accepts, expected]] of Object.entries(FIELD_CHECKS)) {
|
|
1393
|
+
if (!accepts(value[key])) {
|
|
1394
|
+
return `${key} is ${describeValue(value[key])}, expected ${expected}`;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
function hasCode2(error, code) {
|
|
1400
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1401
|
+
}
|
|
1402
|
+
function parseIssueNumber(text) {
|
|
1403
|
+
if (!ISSUE_NUMBER2.test(text))
|
|
1404
|
+
return null;
|
|
1405
|
+
const number = Number(text);
|
|
1406
|
+
return Number.isSafeInteger(number) ? number : null;
|
|
1407
|
+
}
|
|
1408
|
+
function issueNumberOfFile(name) {
|
|
1409
|
+
return name.endsWith(ISSUE_EXTENSION) ? parseIssueNumber(name.slice(0, -ISSUE_EXTENSION.length)) : null;
|
|
1410
|
+
}
|
|
1411
|
+
function issuePath(issuesDir, number) {
|
|
1412
|
+
return join4(issuesDir, `${number}${ISSUE_EXTENSION}`);
|
|
1413
|
+
}
|
|
1414
|
+
async function issueNumbers(issuesDir) {
|
|
1415
|
+
let names;
|
|
1416
|
+
try {
|
|
1417
|
+
names = await readdir(issuesDir);
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
if (hasCode2(error, "ENOENT"))
|
|
1420
|
+
return [];
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
1423
|
+
return names.map(issueNumberOfFile).filter((number) => number !== null).sort((a, b) => a - b);
|
|
1424
|
+
}
|
|
1425
|
+
function localIssuesDir(repoRoot) {
|
|
1426
|
+
return join4(repoRoot, ".rafa", "issues");
|
|
1427
|
+
}
|
|
1428
|
+
function renderLocalIssue(record) {
|
|
1429
|
+
const { draft } = record;
|
|
1430
|
+
const frontmatter = {
|
|
1431
|
+
opt: draft.opt,
|
|
1432
|
+
type: draft.type,
|
|
1433
|
+
module: draft.module,
|
|
1434
|
+
priority: draft.priority,
|
|
1435
|
+
project: draft.project,
|
|
1436
|
+
title: draft.title,
|
|
1437
|
+
blockedBy: draft.blockedBy,
|
|
1438
|
+
state: record.state,
|
|
1439
|
+
fallbackReason: record.fallbackReason,
|
|
1440
|
+
capturedAt: record.capturedAt,
|
|
1441
|
+
comments: record.comments
|
|
1442
|
+
};
|
|
1443
|
+
const problem = frontmatterProblem(frontmatter) ?? (typeof draft.body === "string" ? null : `body is ${describeValue(draft.body)}, expected a string`);
|
|
1444
|
+
if (problem !== null) {
|
|
1445
|
+
throw new TypeError(`${PREFIX3}: refused to write an invalid issue: ${problem}`);
|
|
1446
|
+
}
|
|
1447
|
+
return `---
|
|
1448
|
+
${Bun.YAML.stringify(frontmatter, null, 2)}
|
|
1449
|
+
---
|
|
1450
|
+
${draft.body}`;
|
|
1451
|
+
}
|
|
1452
|
+
function parseLocalIssue(contents, sourcePath) {
|
|
1453
|
+
const at = sourcePath === undefined ? "" : ` at ${sourcePath}`;
|
|
1454
|
+
const match = FRONTMATTER.exec(contents);
|
|
1455
|
+
if (match === null) {
|
|
1456
|
+
throw new Error(`${PREFIX3}: the issue${at} has no YAML frontmatter block`);
|
|
1457
|
+
}
|
|
1458
|
+
const [, block = "", body = ""] = match;
|
|
1459
|
+
let meta;
|
|
1460
|
+
try {
|
|
1461
|
+
meta = Bun.YAML.parse(block);
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
throw new Error(`${PREFIX3}: invalid issue${at}: ${messageOf(error)}`, { cause: error });
|
|
1464
|
+
}
|
|
1465
|
+
const problem = frontmatterProblem(meta);
|
|
1466
|
+
if (problem !== null) {
|
|
1467
|
+
throw new Error(`${PREFIX3}: invalid issue${at}: ${problem}`);
|
|
1468
|
+
}
|
|
1469
|
+
const fields = meta;
|
|
1470
|
+
return {
|
|
1471
|
+
draft: {
|
|
1472
|
+
opt: fields.opt,
|
|
1473
|
+
title: fields.title,
|
|
1474
|
+
body,
|
|
1475
|
+
type: fields.type,
|
|
1476
|
+
module: fields.module,
|
|
1477
|
+
priority: fields.priority,
|
|
1478
|
+
project: fields.project,
|
|
1479
|
+
blockedBy: fields.blockedBy
|
|
1480
|
+
},
|
|
1481
|
+
fallbackReason: fields.fallbackReason,
|
|
1482
|
+
capturedAt: fields.capturedAt,
|
|
1483
|
+
state: fields.state,
|
|
1484
|
+
comments: fields.comments
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
function warnThroughActiveOutput2(message) {
|
|
1488
|
+
activeOutput().warn(message);
|
|
1489
|
+
}
|
|
1490
|
+
async function listLocalIssues(issuesDir, warn = warnThroughActiveOutput2) {
|
|
1491
|
+
const numbers = await issueNumbers(issuesDir);
|
|
1492
|
+
const settled = await Promise.allSettled(numbers.map(async (number) => {
|
|
1493
|
+
const path = issuePath(issuesDir, number);
|
|
1494
|
+
let contents;
|
|
1495
|
+
try {
|
|
1496
|
+
contents = await readFile3(path, "utf8");
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
throw new Error(`${PREFIX3}: could not read ${path}: ${messageOf(error)}`, { cause: error });
|
|
1499
|
+
}
|
|
1500
|
+
return { number, path, record: parseLocalIssue(contents, path) };
|
|
1501
|
+
}));
|
|
1502
|
+
return settled.flatMap((result) => {
|
|
1503
|
+
if (result.status === "fulfilled")
|
|
1504
|
+
return [result.value];
|
|
1505
|
+
warn(`${messageOf(result.reason)}; find skipped it`);
|
|
1506
|
+
return [];
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
function matchesQuery(record, query) {
|
|
1510
|
+
const { draft } = record;
|
|
1511
|
+
if (query.module !== undefined && draft.module !== query.module)
|
|
1512
|
+
return false;
|
|
1513
|
+
if (query.type !== undefined && draft.type !== query.type)
|
|
1514
|
+
return false;
|
|
1515
|
+
if (query.state !== undefined && record.state !== query.state)
|
|
1516
|
+
return false;
|
|
1517
|
+
if (query.text === undefined)
|
|
1518
|
+
return true;
|
|
1519
|
+
return `${draft.title}
|
|
1520
|
+
${draft.body}`.toLowerCase().includes(query.text.toLowerCase());
|
|
1521
|
+
}
|
|
1522
|
+
function refFor(number, opt) {
|
|
1523
|
+
return { opt, kind: "local", externalId: String(number), url: null };
|
|
1524
|
+
}
|
|
1525
|
+
function issueNumberOfRef2(ref) {
|
|
1526
|
+
if (ref.kind !== "local") {
|
|
1527
|
+
throw new Error(`${PREFIX3}: refused a ref of kind ${describeValue(ref.kind)}; this tracker reads local refs only`);
|
|
1528
|
+
}
|
|
1529
|
+
const number = typeof ref.externalId === "string" ? parseIssueNumber(ref.externalId) : null;
|
|
1530
|
+
if (number === null) {
|
|
1531
|
+
throw new Error(`${PREFIX3}: externalId ${describeValue(ref.externalId)} is not a local issue number,` + " expected a positive whole number with no leading zero");
|
|
1532
|
+
}
|
|
1533
|
+
return number;
|
|
1534
|
+
}
|
|
1535
|
+
function createLocalTracker(options) {
|
|
1536
|
+
const { issuesDir, fallbackReason } = options;
|
|
1537
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
1538
|
+
const warn = options.warn ?? warnThroughActiveOutput2;
|
|
1539
|
+
async function read(ref) {
|
|
1540
|
+
const number = issueNumberOfRef2(ref);
|
|
1541
|
+
const path = issuePath(issuesDir, number);
|
|
1542
|
+
let contents;
|
|
1543
|
+
try {
|
|
1544
|
+
contents = await readFile3(path, "utf8");
|
|
1545
|
+
} catch (cause) {
|
|
1546
|
+
throw new Error(`${PREFIX3}: no issue ${number} under ${issuesDir}`, { cause });
|
|
1547
|
+
}
|
|
1548
|
+
return { number, record: parseLocalIssue(contents, path) };
|
|
1549
|
+
}
|
|
1550
|
+
async function rewrite(number, record) {
|
|
1551
|
+
const contents = renderLocalIssue(record);
|
|
1552
|
+
await writeFile(issuePath(issuesDir, number), contents, "utf8");
|
|
1553
|
+
}
|
|
1554
|
+
const tracker = {
|
|
1555
|
+
kind: "local",
|
|
1556
|
+
capabilities: () => ({
|
|
1557
|
+
projects: false,
|
|
1558
|
+
customFields: false,
|
|
1559
|
+
issueTypes: false
|
|
1560
|
+
}),
|
|
1561
|
+
preflight: async () => ({ ok: true }),
|
|
1562
|
+
create: async (draft) => {
|
|
1563
|
+
const contents = renderLocalIssue({
|
|
1564
|
+
draft,
|
|
1565
|
+
fallbackReason,
|
|
1566
|
+
capturedAt: now(),
|
|
1567
|
+
state: "todo",
|
|
1568
|
+
comments: []
|
|
1569
|
+
});
|
|
1570
|
+
await mkdir3(issuesDir, { recursive: true });
|
|
1571
|
+
const held = await issueNumbers(issuesDir);
|
|
1572
|
+
for (let number = (held.at(-1) ?? 0) + 1;Number.isSafeInteger(number); number += 1) {
|
|
1573
|
+
try {
|
|
1574
|
+
await writeFile(issuePath(issuesDir, number), contents, { encoding: "utf8", flag: "wx" });
|
|
1575
|
+
return refFor(number, draft.opt);
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
if (!hasCode2(error, "EEXIST"))
|
|
1578
|
+
throw error;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
throw new Error(`${PREFIX3}: no issue number is left under ${issuesDir}`);
|
|
1582
|
+
},
|
|
1583
|
+
get: async (ref) => {
|
|
1584
|
+
const { record } = await read(ref);
|
|
1585
|
+
return { ...record.draft, ref, state: record.state };
|
|
1586
|
+
},
|
|
1587
|
+
find: async (query) => {
|
|
1588
|
+
const matched = (await listLocalIssues(issuesDir, warn)).filter(({ record }) => matchesQuery(record, query));
|
|
1589
|
+
return matched.slice(0, query.limit ?? matched.length).map(({ number, record }) => refFor(number, record.draft.opt));
|
|
1590
|
+
},
|
|
1591
|
+
comment: async (ref, body) => {
|
|
1592
|
+
const { number, record } = await read(ref);
|
|
1593
|
+
await rewrite(number, { ...record, comments: [...record.comments, `${now()}: ${body}`] });
|
|
1594
|
+
},
|
|
1595
|
+
transition: async (ref, state) => {
|
|
1596
|
+
const { number, record } = await read(ref);
|
|
1597
|
+
await rewrite(number, { ...record, state });
|
|
1598
|
+
return {};
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
return Object.freeze(tracker);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// src/adapters/registry.ts
|
|
1605
|
+
var REFUSAL = "adapter registry";
|
|
1606
|
+
var PORT_VERSIONS = Object.freeze({
|
|
1607
|
+
tracker: 1,
|
|
1608
|
+
store: 1,
|
|
1609
|
+
learning: 1,
|
|
1610
|
+
output: 1,
|
|
1611
|
+
planner: 1
|
|
1612
|
+
});
|
|
1613
|
+
var SERVED_VERSIONS = new Map(Object.entries(PORT_VERSIONS));
|
|
1614
|
+
var PORT_TYPE_LIST = [...SERVED_VERSIONS.keys()].join(", ");
|
|
1615
|
+
function checkPortType(port) {
|
|
1616
|
+
if (!SERVED_VERSIONS.has(port)) {
|
|
1617
|
+
throw new TypeError(`${REFUSAL}: port is ${describeValue(port)}, expected one of: ${PORT_TYPE_LIST}`);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
function accept(adapter, held) {
|
|
1621
|
+
if (typeof adapter !== "object" || adapter === null) {
|
|
1622
|
+
throw new TypeError(`${REFUSAL}: an adapter is ${describeValue(adapter)}, expected a mapping`);
|
|
1623
|
+
}
|
|
1624
|
+
const { port, kind, portVersion, create } = adapter;
|
|
1625
|
+
checkPortType(port);
|
|
1626
|
+
if (typeof kind !== "string" || kind === "") {
|
|
1627
|
+
throw new TypeError(`${REFUSAL}: a ${port} adapter has kind ${describeValue(kind)}, expected a non-empty name`);
|
|
1628
|
+
}
|
|
1629
|
+
const key = `${port}/${kind}`;
|
|
1630
|
+
if (typeof create !== "function") {
|
|
1631
|
+
throw new TypeError(`${REFUSAL}: ${key} has create ${describeValue(create)}, expected a function`);
|
|
1632
|
+
}
|
|
1633
|
+
const served = SERVED_VERSIONS.get(port);
|
|
1634
|
+
if (portVersion !== served) {
|
|
1635
|
+
throw new Error(`${REFUSAL}: ${key} implements ${port} port version ${describeValue(portVersion)},` + ` and core serves ${port} port version ${served}`);
|
|
1636
|
+
}
|
|
1637
|
+
if (held.some((other) => other.port === port && other.kind === kind)) {
|
|
1638
|
+
throw new Error(`${REFUSAL}: ${key} is already registered`);
|
|
1639
|
+
}
|
|
1640
|
+
return Object.freeze({ port, kind, portVersion, create });
|
|
1641
|
+
}
|
|
1642
|
+
function indexByPort(adapters) {
|
|
1643
|
+
const only = (port) => new Map(adapters.filter((adapter) => adapter.port === port).map((adapter) => [adapter.kind, adapter]));
|
|
1644
|
+
return {
|
|
1645
|
+
tracker: only("tracker"),
|
|
1646
|
+
store: only("store"),
|
|
1647
|
+
learning: only("learning"),
|
|
1648
|
+
output: only("output"),
|
|
1649
|
+
planner: only("planner")
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
function createAdapterRegistry(adapters = []) {
|
|
1653
|
+
const held = adapters.reduce((accepted, adapter) => [...accepted, accept(adapter, accepted)], []);
|
|
1654
|
+
const byPort = indexByPort(held);
|
|
1655
|
+
const registry = {
|
|
1656
|
+
register: (adapter) => createAdapterRegistry([...held, adapter]),
|
|
1657
|
+
find: (port, kind) => SERVED_VERSIONS.has(port) ? byPort[port].get(kind) : undefined,
|
|
1658
|
+
resolve: (port, kind) => {
|
|
1659
|
+
checkPortType(port);
|
|
1660
|
+
const adapter = byPort[port].get(kind);
|
|
1661
|
+
if (adapter !== undefined)
|
|
1662
|
+
return adapter;
|
|
1663
|
+
const registered = [...byPort[port].keys()];
|
|
1664
|
+
const listed = registered.length === 0 ? "none" : registered.join(", ");
|
|
1665
|
+
throw new Error(`${REFUSAL}: no ${port} adapter is registered as ${describeValue(kind)};` + ` registered: ${listed}`);
|
|
1666
|
+
},
|
|
1667
|
+
kinds: (port) => {
|
|
1668
|
+
checkPortType(port);
|
|
1669
|
+
return [...byPort[port].keys()];
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
return Object.freeze(registry);
|
|
1673
|
+
}
|
|
1674
|
+
var STORE_OPENERS = {
|
|
1675
|
+
sqlite: openSqliteStore,
|
|
1676
|
+
ndjson: openNdjsonStore
|
|
1677
|
+
};
|
|
1678
|
+
var CORE_ADAPTERS = [
|
|
1679
|
+
...STORE_BACKENDS.map((backend) => ({
|
|
1680
|
+
port: "store",
|
|
1681
|
+
kind: backend,
|
|
1682
|
+
portVersion: PORT_VERSIONS.store,
|
|
1683
|
+
create: ({ repoRoot }) => STORE_OPENERS[backend](repoRoot)
|
|
1684
|
+
})),
|
|
1685
|
+
{
|
|
1686
|
+
port: "output",
|
|
1687
|
+
kind: "text",
|
|
1688
|
+
portVersion: PORT_VERSIONS.output,
|
|
1689
|
+
create: ({ stream = process.stdout, verbosity = 0 }) => createTextOutput({ verbosity, stream })
|
|
1690
|
+
},
|
|
1691
|
+
{
|
|
1692
|
+
port: "output",
|
|
1693
|
+
kind: "json",
|
|
1694
|
+
portVersion: PORT_VERSIONS.output,
|
|
1695
|
+
create: ({ stream = process.stdout }) => createJsonOutput({ stream })
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
port: "tracker",
|
|
1699
|
+
kind: "local",
|
|
1700
|
+
portVersion: PORT_VERSIONS.tracker,
|
|
1701
|
+
create: ({ repoRoot, fallbackReason = null }) => createLocalTracker({
|
|
1702
|
+
issuesDir: localIssuesDir(repoRoot),
|
|
1703
|
+
fallbackReason
|
|
1704
|
+
})
|
|
1705
|
+
},
|
|
1706
|
+
{
|
|
1707
|
+
port: "tracker",
|
|
1708
|
+
kind: "github",
|
|
1709
|
+
portVersion: PORT_VERSIONS.tracker,
|
|
1710
|
+
create: ({ repoRoot, gh }) => createGithubTracker({ gh: gh ?? createGhRunner({ cwd: repoRoot }) })
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
port: "learning",
|
|
1714
|
+
kind: "local",
|
|
1715
|
+
portVersion: PORT_VERSIONS.learning,
|
|
1716
|
+
create: ({ repoRoot }) => createLocalLearning({ instinctsDir: localInstinctsDir(repoRoot) })
|
|
1717
|
+
},
|
|
1718
|
+
{
|
|
1719
|
+
port: "planner",
|
|
1720
|
+
kind: "claude",
|
|
1721
|
+
portVersion: PORT_VERSIONS.planner,
|
|
1722
|
+
create: ({ repoRoot, settingSources, planPrompt, planDir, claude }) => {
|
|
1723
|
+
if (!Array.isArray(settingSources)) {
|
|
1724
|
+
throw new TypeError(`${REFUSAL}: planner/claude has settingSources ${describeValue(settingSources)} in its context,` + " expected a list of setting sources");
|
|
1725
|
+
}
|
|
1726
|
+
if (typeof planPrompt !== "function") {
|
|
1727
|
+
throw new TypeError(`${REFUSAL}: planner/claude has planPrompt ${describeValue(planPrompt)} in its context,` + " expected a function");
|
|
1728
|
+
}
|
|
1729
|
+
if (typeof planDir !== "string") {
|
|
1730
|
+
throw new TypeError(`${REFUSAL}: planner/claude has planDir ${describeValue(planDir)} in its context,` + " expected a directory path");
|
|
1731
|
+
}
|
|
1732
|
+
return createClaudePlanner({ repoRoot, planDir, settingSources, buildPrompt: planPrompt, spawn: claude });
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
];
|
|
1736
|
+
var CORE_ADAPTER_REGISTRY = createAdapterRegistry(CORE_ADAPTERS);
|
|
1737
|
+
|
|
1738
|
+
// src/effort/store/index.ts
|
|
1739
|
+
function describeValue3(value) {
|
|
1740
|
+
if (typeof value === "string")
|
|
1741
|
+
return JSON.stringify(value);
|
|
1742
|
+
if (typeof value === "object" && value !== null)
|
|
1743
|
+
return "an object";
|
|
1744
|
+
return String(value);
|
|
1745
|
+
}
|
|
1746
|
+
function selectEffortStore(repoRoot, config) {
|
|
1747
|
+
const adapter = CORE_ADAPTER_REGISTRY.find("store", config.store);
|
|
1748
|
+
if (adapter === undefined) {
|
|
1749
|
+
const expected = CORE_ADAPTER_REGISTRY.kinds("store").join(", ");
|
|
1750
|
+
throw new TypeError(`effort store: store is ${describeValue3(config.store)},` + ` expected one of: ${expected}`);
|
|
1751
|
+
}
|
|
1752
|
+
return adapter.create({ repoRoot });
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
export { EFFORT_KEY_PROJECTIONS, openNdjsonStore, sqliteStorePath, LONE_SURROGATE, withSqliteStore, writeSqliteStore, openSqliteStore, getClaudeUsagePercent, checkUsage, interruptClaudeSessions, runClaude, runClaudeCaptured, planFilePath, ClaudePlannerError, createGhRunner, createLocalTracker, PORT_VERSIONS, createAdapterRegistry, CORE_ADAPTER_REGISTRY, selectEffortStore };
|