gcf-board 0.3.0 → 0.4.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/README.md +7 -3
- package/dist/server/app.js +31 -9
- package/dist/server/moves.js +36 -63
- package/dist/server/runs.js +9 -8
- package/dist/shared/board.js +1 -1
- package/dist/web/assets/index-CCeftHBL.js +34 -0
- package/dist/web/assets/index-DCkdSzDJ.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cj06vUmK.js +0 -33
- package/dist/web/assets/index-CjG8ddKQ.css +0 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A local web board for game projects on [GCF](../gcf-aif-ext): the items by statu
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- Node.js ≥ 22.18
|
|
8
|
-
- [`ai-factory`](https://www.npmjs.com/package/ai-factory) installed globally, and in each project [`gcf-aif-ext`](../gcf-aif-ext) ≥ 0.
|
|
8
|
+
- [`ai-factory`](https://www.npmjs.com/package/ai-factory) installed globally, and in each project [`gcf-aif-ext`](../gcf-aif-ext) ≥ 0.10 (`gcf board --json` with `schema: 3`: the columns, the moves, the item's fields, the blocks and the Help terms come from it as data)
|
|
9
9
|
- for runs: [`gcf-harness-aif-ext`](../gcf-harness-aif-ext) ≥ 0.8 and Claude Code (`claude -p`)
|
|
10
10
|
- gcf-board 0.2 needs `gcf-aif-ext` ≥ 0.7 (priority, questions, session marks, `run_mode`)
|
|
11
11
|
|
|
@@ -30,8 +30,10 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
|
|
|
30
30
|
|
|
31
31
|
## What it does
|
|
32
32
|
|
|
33
|
+
The board knows no domain by name: the project's board read gives its columns, moves with their forms, item fields, blocks and Help terms (`domain`, Board-PRD decision 16). For a GCF project:
|
|
34
|
+
|
|
33
35
|
- **Board**: columns ready, running, blocked, awaiting-verdict, awaiting-decision, unplayed, parked. A card shows the type, the gate, the plan progress (`2/5`), the severity, `verified`, the model. A running card shows when its run log was last written (`active 12 s ago`), and `quiet 6 min` when the log has been silent for 5 minutes or more. A run's end report gives each item's cost when the agent reported it.
|
|
34
|
-
- **An item**: its file, its plan and its run log as it grows, and the moves the core offers for it (`items[].moves`, Methodology §4.1): verdict, decide, block, unblock, drop, close, submit.
|
|
36
|
+
- **An item**: its file, its plan and its run log as it grows, its fields, and the moves the core offers for it (`items[].moves`, Methodology §4.1): run, verdict, decide, block, unblock, drop, close, submit, depends. Each move's form comes from the core; the core still checks the rest itself, and the board shows its answer.
|
|
35
37
|
- **Drag**:
|
|
36
38
|
- a ready card into **running** starts its run;
|
|
37
39
|
- a blocked or parked card into **ready** unblocks it;
|
|
@@ -47,7 +49,9 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
|
|
|
47
49
|
- **Run next N independent**: the framework's gate picks the ready items that can run together now, by priority, and the board runs them after asking.
|
|
48
50
|
- **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
|
|
49
51
|
- **Questions**: each D1 shows its question and proposed answer; **Answer** writes yours into its record (`gcf question answer`), and the next agent on its units puts it in place.
|
|
50
|
-
- **New item**: `gcf item new … --commit
|
|
52
|
+
- **New item**: `gcf item new … --commit`, its form from the core: a bug asks its severity, a release its version and channel. The item's `model` and `effort` go to its run.
|
|
53
|
+
- **Help** (**?**): the domain's terms, columns, moves and field values with what each means, and the words of runs, under one filter.
|
|
54
|
+
- **Settings**: the keys of `gcf/config.yml` and the agents' language (`language.ui`, `language.artifacts` of `.ai-factory/config.yaml`), each a `gcf config set` commit.
|
|
51
55
|
- **Inbox**: add, erase, or turn an entry into an item. Open questions are listed there too.
|
|
52
56
|
- **Lessons** (core 0.7.10, harness 0.9.2): what got in an agent's way, asked at the end of each session, lands in the inbox with its kind (`tool`, `rules`, `framework`, `template`, `other`) and its item. The inbox filters them; a card shows `N lessons`, and the drawer lists the item's lessons with the inbox's buttons. A run's report line ends with `, N lessons`, and with `— code written outside aif-implement` when a session skipped that skill.
|
|
53
57
|
- **Playtest**: `gcf playtest session` with the checklist grouped by subsystem; keep, tune or kill each item from the list. **Build** runs `dod.build`. A note goes to the inbox as `--source playtest`.
|
package/dist/server/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The board's HTTP server: the page, and an API that is a thin layer over `ai-factory gcf`.
|
|
2
2
|
// Reads: projects, the board, a shown file, a run log, the playtest session, the run metrics
|
|
3
|
-
// (`ai-factory
|
|
4
|
-
//
|
|
3
|
+
// (`ai-factory <harness> metrics`, the domain's harness). Writes: a project on the board, a
|
|
4
|
+
// move, a run. Every write is a command of the framework.
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
6
|
import { createServer } from 'node:http';
|
|
7
7
|
import { existsSync, statSync } from 'node:fs';
|
|
@@ -104,6 +104,23 @@ export function createApp(o) {
|
|
|
104
104
|
const cli = (p, args, timeout) => runCli(o.entry, p.path, args, timeout);
|
|
105
105
|
// The last domain each project's board read gave: an item move is built from its form.
|
|
106
106
|
const domains = new Map();
|
|
107
|
+
/**
|
|
108
|
+
* The harness of a project's domain, or a 400: its agent's runs need one. A fresh read: the
|
|
109
|
+
* harness may have been installed or removed since the last one; a read that fails (a schema
|
|
110
|
+
* mismatch mid-run) falls back on the last domain, so a Stop still stops.
|
|
111
|
+
*/
|
|
112
|
+
const harnessOf = async (p) => {
|
|
113
|
+
const domain = await boardOf(p).then((b) => b.domain, (e) => {
|
|
114
|
+
const last = domains.get(p.name);
|
|
115
|
+
if (!last)
|
|
116
|
+
throw e;
|
|
117
|
+
return last;
|
|
118
|
+
});
|
|
119
|
+
const h = domain.harness;
|
|
120
|
+
if (!h)
|
|
121
|
+
throw new HttpError(400, 'this project has no harness: its items run from a console');
|
|
122
|
+
return h;
|
|
123
|
+
};
|
|
107
124
|
/** `gcf board --json` of a project, refused when its schema is not the one this board reads. */
|
|
108
125
|
const boardOf = async (p) => {
|
|
109
126
|
const board = jsonOf(await cli(p, ['gcf', 'board', '--json']), 'gcf board');
|
|
@@ -194,8 +211,9 @@ export function createApp(o) {
|
|
|
194
211
|
'GET',
|
|
195
212
|
/^\/api\/p\/([^/]+)\/metrics$/,
|
|
196
213
|
async (_req, res, m) => {
|
|
197
|
-
const
|
|
198
|
-
|
|
214
|
+
const p = await projectOf(m[1]);
|
|
215
|
+
const harness = await harnessOf(p);
|
|
216
|
+
send(res, 200, jsonOf(await cli(p, [harness, 'metrics', '--json']), `${harness} metrics`));
|
|
199
217
|
},
|
|
200
218
|
],
|
|
201
219
|
[
|
|
@@ -234,8 +252,11 @@ export function createApp(o) {
|
|
|
234
252
|
async (req, res, m) => {
|
|
235
253
|
const p = await projectOf(m[1]);
|
|
236
254
|
const body = await bodyOf(req);
|
|
237
|
-
const mode =
|
|
238
|
-
|
|
255
|
+
const { run_mode: mode, domain } = await boardOf(p);
|
|
256
|
+
if (!domain.harness)
|
|
257
|
+
throw new HttpError(400, 'this project has no harness: its items run from a console');
|
|
258
|
+
const ids = runIds(body);
|
|
259
|
+
send(res, 202, startRun(o.entry, p, ids, mode, body.fresh === true, domain.harness));
|
|
239
260
|
},
|
|
240
261
|
],
|
|
241
262
|
[
|
|
@@ -244,8 +265,9 @@ export function createApp(o) {
|
|
|
244
265
|
/^\/api\/p\/([^/]+)\/next$/,
|
|
245
266
|
async (_req, res, m, url) => {
|
|
246
267
|
const count = String(Math.max(1, Math.min(20, Number(url.searchParams.get('count')) || 1)));
|
|
247
|
-
const
|
|
248
|
-
|
|
268
|
+
const p = await projectOf(m[1]);
|
|
269
|
+
const harness = await harnessOf(p);
|
|
270
|
+
send(res, 200, jsonOf(await cli(p, [harness, 'next', count, '--json']), `${harness} next`));
|
|
249
271
|
},
|
|
250
272
|
],
|
|
251
273
|
[
|
|
@@ -254,7 +276,7 @@ export function createApp(o) {
|
|
|
254
276
|
async (req, res, m) => {
|
|
255
277
|
const p = await projectOf(m[1]);
|
|
256
278
|
const [id] = runIds({ ids: [(await bodyOf(req)).id] });
|
|
257
|
-
const r = await cli(p, [
|
|
279
|
+
const r = await cli(p, [await harnessOf(p), 'stop', id]);
|
|
258
280
|
broadcast({ type: 'board', project: p.name });
|
|
259
281
|
send(res, 200, { ok: r.ok, out: r.out.trim(), err: briefError(r.err) });
|
|
260
282
|
},
|
package/dist/server/moves.js
CHANGED
|
@@ -43,61 +43,13 @@ const list = (b, key, re, what, flag = key) => {
|
|
|
43
43
|
throw new HttpError(400, `${key} must be a list of ${what}`);
|
|
44
44
|
return v.length ? [`--${flag}=${v.join(',')}`] : [];
|
|
45
45
|
};
|
|
46
|
-
const idList = (b, key) => list(b, key, SLUG, 'ids');
|
|
46
|
+
const idList = (b, key, flag = key) => list(b, key, SLUG, 'ids', flag);
|
|
47
47
|
const tagList = (b, key, flag = key) => list(b, key, TAG, 'tags', flag);
|
|
48
48
|
/** `--remove` / `--no-remove` when the operator chose; the command's default otherwise. */
|
|
49
49
|
const remove = (b, key = 'remove', flag = key) => b[key] === true ? [`--${flag}`] : b[key] === false ? [`--no-${flag}`] : [];
|
|
50
|
-
const NEW_ITEM_OPTIONS = [
|
|
51
|
-
'gate',
|
|
52
|
-
'title',
|
|
53
|
-
'parent',
|
|
54
|
-
'system',
|
|
55
|
-
'depends',
|
|
56
|
-
'severity',
|
|
57
|
-
'blocked',
|
|
58
|
-
'from',
|
|
59
|
-
'reopens',
|
|
60
|
-
'milestone',
|
|
61
|
-
'release',
|
|
62
|
-
'channel',
|
|
63
|
-
'model',
|
|
64
|
-
'effort',
|
|
65
|
-
];
|
|
66
50
|
const MOVES = {
|
|
67
51
|
// A session's mark taken off: the item no longer shows running (Board-PRD §3.2, kind c).
|
|
68
52
|
release: (b) => ['release', id(b)],
|
|
69
|
-
'item-new': (b) => [
|
|
70
|
-
'item',
|
|
71
|
-
'new',
|
|
72
|
-
id(b),
|
|
73
|
-
`--type=${required(b, 'type')}`,
|
|
74
|
-
...NEW_ITEM_OPTIONS.flatMap((k) => opt(b, k)),
|
|
75
|
-
'--commit',
|
|
76
|
-
],
|
|
77
|
-
// `--` does not hold through ai-factory's nested commands: the text goes as one `--text=`.
|
|
78
|
-
'inbox-add': (b) => [
|
|
79
|
-
'inbox',
|
|
80
|
-
'add',
|
|
81
|
-
...opt(b, 'source'),
|
|
82
|
-
...tagList(b, 'tags'),
|
|
83
|
-
'--commit',
|
|
84
|
-
`--text=${required(b, 'text')}`,
|
|
85
|
-
],
|
|
86
|
-
'inbox-drop': (b) => ['inbox', 'drop', id(b), '--commit'],
|
|
87
|
-
'inbox-tag': (b) => [
|
|
88
|
-
'inbox',
|
|
89
|
-
'tag',
|
|
90
|
-
id(b),
|
|
91
|
-
...tagList(b, 'add'),
|
|
92
|
-
...tagList(b, 'remove'),
|
|
93
|
-
'--commit',
|
|
94
|
-
],
|
|
95
|
-
'phase-next': (b) => ['phase', 'next', ...opt(b, 'reason')],
|
|
96
|
-
// A column's new order after a drag: ranks 1, 2, 3… in one commit.
|
|
97
|
-
prioritize: (b) => ['prioritize', ...runIds(b)],
|
|
98
|
-
// The ids an item waits for, added and removed; the core refuses an unknown id and a cycle.
|
|
99
|
-
depends: (b) => ['depends', id(b), ...idList(b, 'add'), ...idList(b, 'remove')],
|
|
100
|
-
'question-answer': (b) => ['question', 'answer', id(b), `--text=${required(b, 'text')}`],
|
|
101
53
|
// One setting of gcf/config.yml; the core checks the key and the value. An empty value takes
|
|
102
54
|
// a number back to its default.
|
|
103
55
|
'config-set': (b) => {
|
|
@@ -120,31 +72,52 @@ const MOVES = {
|
|
|
120
72
|
return ['permissions', ...args];
|
|
121
73
|
},
|
|
122
74
|
};
|
|
123
|
-
/**
|
|
75
|
+
/** Whether a field goes: one with `when` only while the field it names holds one of its values. */
|
|
76
|
+
const applies = (f, b) => !f.when || f.when.is.includes(String(b[f.when.key] ?? ''));
|
|
77
|
+
/** A choice: one of its options' values, or nothing. */
|
|
78
|
+
function choice(f, b, flag) {
|
|
79
|
+
const v = f.required ? required(b, f.key) : text(b, f.key);
|
|
80
|
+
if (v === undefined)
|
|
81
|
+
return [];
|
|
82
|
+
if (!(f.options ?? []).some((o) => o.value === v))
|
|
83
|
+
throw new HttpError(400, `${f.key} must be one of ${(f.options ?? []).map((o) => o.value).join(', ')}`);
|
|
84
|
+
return [`--${flag}=${v}`];
|
|
85
|
+
}
|
|
86
|
+
/** One field's arguments; a `slug` is the target itself (`{id}`), no flag. */
|
|
87
|
+
function fieldArgs(f, b) {
|
|
88
|
+
const flag = f.flag ?? f.key;
|
|
89
|
+
if (f.kind === 'slug')
|
|
90
|
+
return [];
|
|
91
|
+
if (f.kind === 'list')
|
|
92
|
+
return many(b, f.key, flag);
|
|
93
|
+
if (f.kind === 'remove')
|
|
94
|
+
return remove(b, f.key, flag);
|
|
95
|
+
if (f.kind === 'ids')
|
|
96
|
+
return idList(b, f.key, flag);
|
|
97
|
+
if (f.kind === 'tags')
|
|
98
|
+
return tagList(b, f.key, flag);
|
|
99
|
+
if (f.kind === 'choice')
|
|
100
|
+
return choice(f, b, flag);
|
|
101
|
+
return f.required ? [`--${flag}=${required(b, f.key)}`] : opt(b, f.key, flag);
|
|
102
|
+
}
|
|
103
|
+
/** A move by its domain form: its words with the target, then each field that goes. */
|
|
124
104
|
function domainMoveArgs(spec, b) {
|
|
125
|
-
const words = spec.args.
|
|
126
|
-
|
|
127
|
-
const flag = f.flag ?? f.key;
|
|
128
|
-
if (f.kind === 'list')
|
|
129
|
-
return many(b, f.key, flag);
|
|
130
|
-
if (f.kind === 'remove')
|
|
131
|
-
return remove(b, f.key, flag);
|
|
132
|
-
return f.required ? [`--${flag}=${required(b, f.key)}`] : opt(b, f.key, flag);
|
|
133
|
-
});
|
|
134
|
-
return [...words, ...flags];
|
|
105
|
+
const words = spec.args.flatMap((w) => w === '{id}' ? [id(b)] : w === '{ids}' ? runIds(b) : [w]);
|
|
106
|
+
return [...words, ...spec.fields.filter((f) => applies(f, b)).flatMap((f) => fieldArgs(f, b))];
|
|
135
107
|
}
|
|
136
108
|
/** Whether a move is the center's own: its arguments need no domain. */
|
|
137
109
|
export const isCenterMove = (cmd) => typeof cmd === 'string' && Object.hasOwn(MOVES, cmd);
|
|
138
110
|
/**
|
|
139
111
|
* The `ai-factory` arguments of a move, or a 400 naming what is wrong: a center move, or one of
|
|
140
|
-
* `moves`, the domain's forms from the project's board read.
|
|
112
|
+
* `moves`, the domain's forms from the project's board read. An agent's move is a run, not a
|
|
113
|
+
* command: it goes through the run route.
|
|
141
114
|
*/
|
|
142
115
|
export function moveArgs(b, moves = {}) {
|
|
143
116
|
const cmd = typeof b.cmd === 'string' ? b.cmd : '';
|
|
144
117
|
if (isCenterMove(cmd))
|
|
145
118
|
return ['gcf', ...MOVES[cmd](b)];
|
|
146
|
-
if (Object.hasOwn(moves, cmd))
|
|
147
|
-
return
|
|
119
|
+
if (Object.hasOwn(moves, cmd) && moves[cmd].kind !== 'agent')
|
|
120
|
+
return domainMoveArgs(moves[cmd], b);
|
|
148
121
|
throw new HttpError(400, `unknown move: ${String(b.cmd)}`);
|
|
149
122
|
}
|
|
150
123
|
/** The ids of a run: one slug each, at least one. */
|
package/dist/server/runs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Runs the board started: `ai-factory
|
|
1
|
+
// Runs the board started: `ai-factory <harness> run <ids> --json` (GCF: gcf-harness), one process per batch, held
|
|
2
2
|
// by the server until every agent session ends. A run started from a console shows on the
|
|
3
3
|
// board too (its lock or session mark makes the item `running`); only its report is not here.
|
|
4
4
|
// In `run_mode: main` the checkout takes one run at a time: a run asked for meanwhile waits in
|
|
@@ -119,7 +119,7 @@ function schedule(run, at) {
|
|
|
119
119
|
liftLimitPause(project);
|
|
120
120
|
try {
|
|
121
121
|
if (s) {
|
|
122
|
-
const r = startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode);
|
|
122
|
+
const r = startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode, false, s.run.harness);
|
|
123
123
|
const how = 'queued' in r ? 'wait in the queue' : 'run again';
|
|
124
124
|
trace(project, 'resume', `the usage limit reset: ${s.run.ids.join(', ')} ${how}`);
|
|
125
125
|
}
|
|
@@ -232,10 +232,10 @@ function isItem(i) {
|
|
|
232
232
|
return typeof id === 'string' && (resets == null || typeof resets === 'string');
|
|
233
233
|
}
|
|
234
234
|
function spawnRun(q, mode) {
|
|
235
|
-
const { entry, p, ids } = q;
|
|
235
|
+
const { entry, p, ids, harness } = q;
|
|
236
236
|
const run = { project: p.name, ids, started: new Date().toISOString(), mode };
|
|
237
237
|
active.add(run);
|
|
238
|
-
const args = [entry,
|
|
238
|
+
const args = [entry, harness, 'run', ...ids, '--json', ...(q.fresh ? ['--fresh'] : [])];
|
|
239
239
|
const child = spawn(process.execPath, args, {
|
|
240
240
|
cwd: p.path,
|
|
241
241
|
shell: false,
|
|
@@ -293,9 +293,10 @@ function spawnRun(q, mode) {
|
|
|
293
293
|
/**
|
|
294
294
|
* Start a batch in the project's run mode, or queue it behind the main run going on. Its end
|
|
295
295
|
* is a run report with the harness report, or what went wrong. `fresh` starts new agent
|
|
296
|
-
* sessions where the harness would resume the interrupted ones.
|
|
296
|
+
* sessions where the harness would resume the interrupted ones. `harness` is the domain's
|
|
297
|
+
* (`domain.harness`); the tests take GCF's.
|
|
297
298
|
*/
|
|
298
|
-
export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
|
|
299
|
+
export function startRun(entry, p, ids, mode = 'worktree', fresh = false, harness = 'gcf-harness') {
|
|
299
300
|
// A second click on the same card: the harness would refuse it on its lock, noisily.
|
|
300
301
|
const { active: going, queued } = runsOf(p.name);
|
|
301
302
|
const busy = ids.filter((id) => queued.includes(id) || going.some((r) => r.ids.includes(id)));
|
|
@@ -314,9 +315,9 @@ export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
|
|
|
314
315
|
}
|
|
315
316
|
// A paused queue holds a new main run too: it would repeat the failure, and jump the queue.
|
|
316
317
|
if (mode === 'main' && (paused.has(p.name) || going.some((r) => r.mode === 'main'))) {
|
|
317
|
-
queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode, fresh }]);
|
|
318
|
+
queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode, fresh, harness }]);
|
|
318
319
|
broadcast({ type: 'board', project: p.name });
|
|
319
320
|
return { queued: ids };
|
|
320
321
|
}
|
|
321
|
-
return { started: spawnRun({ entry, p, ids, mode, fresh }, mode) };
|
|
322
|
+
return { started: spawnRun({ entry, p, ids, mode, fresh, harness }, mode) };
|
|
322
323
|
}
|
package/dist/shared/board.js
CHANGED