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 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.9 (`gcf board --json` with `schema: 2`: the columns and the item moves come from it as data)
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. The board keeps only each move's form; the core still checks the rest itself, and the board shows its answer.
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`. The item's `model` and `effort` go to its run.
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`.
@@ -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 gcf-harness metrics`). Writes: a project
4
- // on the board, a move, a run. Every write is a command of the framework.
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 r = await cli(await projectOf(m[1]), ['gcf-harness', 'metrics', '--json']);
198
- send(res, 200, jsonOf(r, 'gcf-harness metrics'));
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 = (await boardOf(p)).run_mode;
238
- send(res, 202, startRun(o.entry, p, runIds(body), mode, body.fresh === true));
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 r = await cli(await projectOf(m[1]), ['gcf-harness', 'next', count, '--json']);
248
- send(res, 200, jsonOf(r, 'gcf-harness next'));
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, ['gcf-harness', 'stop', id]);
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
  },
@@ -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
- /** An item's move by its domain form: the words with the item's id, then each field's flag. */
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.map((w) => (w === '{id}' ? id(b) : w));
126
- const flags = spec.fields.flatMap((f) => {
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 ['gcf', ...domainMoveArgs(moves[cmd], b)];
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. */
@@ -1,4 +1,4 @@
1
- // Runs the board started: `ai-factory gcf-harness run <ids> --json`, one process per batch, held
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, 'gcf-harness', 'run', ...ids, '--json', ...(q.fresh ? ['--fresh'] : [])];
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
  }
@@ -1,3 +1,3 @@
1
1
  // The read model of `ai-factory gcf board --json` (Methodology §11): the board refuses a schema
2
2
  // it does not know. Types only, plus the version this board reads.
3
- export const BOARD_SCHEMA = 2;
3
+ export const BOARD_SCHEMA = 3;