gcf-aif-ext 0.7.15 → 0.9.0

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/commands/gcf.js CHANGED
@@ -731,6 +731,27 @@ function registerItem(gcf) {
731
731
  });
732
732
  }
733
733
 
734
+ function registerDepends(gcf) {
735
+ gcf
736
+ .command('depends <id>')
737
+ .description(
738
+ 'Change the items it waits for: refuses an unknown or killed id and a cycle (commit)',
739
+ )
740
+ .option('--add <ids>', 'ids to add, comma-separated')
741
+ .option('--remove <ids>', 'ids to remove, comma-separated')
742
+ .option('--no-commit', 'skip the commit (tests only)')
743
+ .action(async (id, o) => {
744
+ const r = await (
745
+ await lib('work-ops')
746
+ ).setDepends(process.cwd(), id, {
747
+ add: splitList(o.add),
748
+ remove: splitList(o.remove),
749
+ commit: o.commit,
750
+ });
751
+ console.log(`${id} depends on ${r.depends.join(', ') || 'nothing'}${shortHash(r.hash)}`);
752
+ });
753
+ }
754
+
734
755
  function registerItemMoves(gcf) {
735
756
  const ops = () => lib('work-ops');
736
757
  gcf
@@ -741,6 +762,7 @@ function registerItemMoves(gcf) {
741
762
  const r = await (await ops()).prioritizeItems(process.cwd(), ids, { commit: o.commit });
742
763
  console.log(`ranked ${r.ids.join(', ')}${shortHash(r.hash)}`);
743
764
  });
765
+ registerDepends(gcf);
744
766
  const question = gcf.command('question').description('Open questions (gcf/open-questions.md)');
745
767
  question
746
768
  .command('answer <qid>')
@@ -861,6 +883,26 @@ async function inboxMove(commit, message, edit) {
861
883
  });
862
884
  }
863
885
 
886
+ function registerInboxTag(inbox) {
887
+ inbox
888
+ .command('tag <in-id>')
889
+ .description('Add tags to an entry and remove others')
890
+ .option('--add <tags>', 'tags to add, comma-separated')
891
+ .option('--remove <tags>', 'tags to remove, comma-separated')
892
+ .option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
893
+ .action(async (id, o) => {
894
+ const { tagInbox } = await lib('inbox');
895
+ let tags = [];
896
+ const hash = await inboxMove(o.commit, `gcf(inbox): tag ${id}`, async (root) => {
897
+ ({ tags } = await tagInbox(root, id, {
898
+ add: splitList(o.add),
899
+ remove: splitList(o.remove),
900
+ }));
901
+ });
902
+ console.log(`${id} tags: ${tags.join(', ') || 'none'}${shortHash(hash)}`);
903
+ });
904
+ }
905
+
864
906
  function registerInbox(gcf) {
865
907
  const inbox = gcf
866
908
  .command('inbox')
@@ -874,8 +916,9 @@ function registerInbox(gcf) {
874
916
  ? l
875
917
  .map((e) => {
876
918
  const source = e.kind ? `lesson:${e.kind}:${e.item}` : e.source;
919
+ const tags = e.tags.length ? ` (tags: ${e.tags.join(', ')})` : '';
877
920
 
878
- return `${e.id} [${source}] ${e.text}`;
921
+ return `${e.id} [${source}] ${e.text}${tags}`;
879
922
  })
880
923
  .join('\n')
881
924
  : 'inbox empty',
@@ -888,6 +931,7 @@ function registerInbox(gcf) {
888
931
  // A text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of
889
932
  // ai-factory's commands, `--` included: as `--text=<text>` it stays one value.
890
933
  .option('--text <text>', 'the entry, when it starts with a hyphen')
934
+ .option('--tags <tags>', 'its tags, comma-separated: idea, later, ui:hud')
891
935
  .option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
892
936
  .action(async (arg, o) => {
893
937
  const text = o.text ?? arg;
@@ -896,10 +940,11 @@ function registerInbox(gcf) {
896
940
  const { addInbox } = await lib('inbox');
897
941
  let id = '';
898
942
  const hash = await inboxMove(o.commit, 'gcf(inbox): add', async (root) => {
899
- id = (await addInbox(root, text, o.source)).id;
943
+ id = (await addInbox(root, text, o.source, { tags: splitList(o.tags) })).id;
900
944
  });
901
945
  console.log(`${id}${shortHash(hash)}`);
902
946
  });
947
+ registerInboxTag(inbox);
903
948
  inbox
904
949
  .command('drop <in-id>')
905
950
  .description('Erase an entry: triaged as noise, a duplicate, or turned into an item')
@@ -1061,12 +1106,18 @@ function registerBoard(gcf) {
1061
1106
  const tag = (i) =>
1062
1107
  `${i.id}${i.plan?.tasks !== undefined ? ` ${i.plan.done}/${i.plan.tasks}` : ''}` +
1063
1108
  `${i.running ? ' (running)' : ''}${i.verified ? ' (verified)' : ''}`;
1064
- const byId = new Map(r.items.map((i) => [i.id, i]));
1065
1109
  out(o.json, r, (x) =>
1066
1110
  [
1067
1111
  `phase: ${x.phase.phase ?? x.phase.error}`,
1068
- ...Object.entries(x.columns).map(
1069
- ([s, ids]) => `${s}: ${ids.map((id) => tag(byId.get(id))).join(', ') || '-'}`,
1112
+ // Items come in run order, so each column lists its own in that order.
1113
+ ...x.domain.columns.map(
1114
+ ({ status }) =>
1115
+ `${status}: ${
1116
+ x.items
1117
+ .filter((i) => i.status === status)
1118
+ .map(tag)
1119
+ .join(', ') || '-'
1120
+ }`,
1070
1121
  ),
1071
1122
  `wip: ${x.wip.count}/${x.wip.limit}`,
1072
1123
  `inbox: ${x.inbox.length}, open questions: ${x.questions.length}`,
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.7.15",
4
+ "version": "0.9.0",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -0,0 +1,22 @@
1
+ // The language AI Factory sets in `.ai-factory/config.yaml` for what an agent writes to the
2
+ // human: `language.ui`. GCF adds no setting of its own (Methodology §5.2.3); agents read the
3
+ // file themselves, the core reads it only for the prompts it writes.
4
+ import { readFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import YAML from 'yaml';
7
+
8
+ /**
9
+ * The project's `language.ui`, `en` by default. A missing or unreadable file gives `en`: the
10
+ * language is a preference, and a broken one must not stop the work.
11
+ */
12
+ export async function uiLanguage(projectDir) {
13
+ try {
14
+ const file = path.join(path.resolve(projectDir), '.ai-factory', 'config.yaml');
15
+ const ui = YAML.parse(await readFile(file, 'utf8'))?.language?.ui;
16
+
17
+ // A language name or code, one word: anything else would break the prompt line it goes in.
18
+ return typeof ui === 'string' && /^[\w-]{2,32}$/.test(ui.trim()) ? ui.trim() : 'en';
19
+ } catch {
20
+ return 'en';
21
+ }
22
+ }
package/lib/board.js CHANGED
@@ -14,7 +14,7 @@ import { resolveInside } from './safe-path.js';
14
14
  import { wipOf } from './audit-core.js';
15
15
  import { runHolder } from './main-lock.js';
16
16
  import { ITEM_TYPES } from './validate-item.js';
17
- import { movesOf } from './transitions.js';
17
+ import { domainOf, movesOf } from './transitions.js';
18
18
  import { openDepends } from './work-ops.js';
19
19
  import { listQuestions } from './questions.js';
20
20
  import { sessionActive } from './session-mark.js';
@@ -23,9 +23,7 @@ import { settingsOf } from './config-set.js';
23
23
 
24
24
  // The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
25
25
  // when a field changes meaning or goes away; a new field does not bump it.
26
- export const BOARD_SCHEMA = 1;
27
-
28
- const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
26
+ export const BOARD_SCHEMA = 2;
29
27
 
30
28
  /**
31
29
  * Plan progress `{tasks, done}` from its checkboxes and the plan's project path, both null
@@ -134,6 +132,26 @@ async function phaseOf(projectDir) {
134
132
  }
135
133
  }
136
134
 
135
+ /**
136
+ * The AI Factory extensions the project has, name → version, from `.ai-factory.json`: the board
137
+ * shows which framework packages a project runs. `{}` when the file is missing or unreadable.
138
+ */
139
+ async function extensionsOf(projectDir) {
140
+ try {
141
+ const cfg = JSON.parse(
142
+ await readFile(path.join(path.resolve(projectDir), '.ai-factory.json'), 'utf8'),
143
+ );
144
+
145
+ return Object.fromEntries(
146
+ (cfg.extensions ?? [])
147
+ .filter((e) => typeof e?.name === 'string')
148
+ .map((e) => [e.name, typeof e.version === 'string' ? e.version : null]),
149
+ );
150
+ } catch {
151
+ return {};
152
+ }
153
+ }
154
+
137
155
  /** The release block with every field: one a malformed block lacks is null (live check says so). */
138
156
  const releaseOf = (r) =>
139
157
  r && {
@@ -274,6 +292,9 @@ export async function board(projectDir) {
274
292
  const inbox = await listInbox(root);
275
293
  // Where agents struggle: the lessons in the inbox each item's sessions left.
276
294
  for (const i of items) i.lessons = inbox.filter((e) => e.item === i.id).length;
295
+ // The reverse of depends: the items on the board that wait for this one.
296
+ for (const i of items)
297
+ i.needed_by = items.filter((o) => o.depends.includes(i.id)).map((o) => o.id);
277
298
 
278
299
  const config = await readConfig(root);
279
300
  const live = await readLive(root);
@@ -291,11 +312,11 @@ export async function board(projectDir) {
291
312
  compact_pct: config.auto_compact_pct,
292
313
  },
293
314
  phase: await phaseOf(projectDir),
315
+ extensions: await extensionsOf(projectDir),
294
316
  // A malformed block reads with fields missing (live check names it): null, not absent.
295
317
  release: releaseOf(live.release),
296
- columns: Object.fromEntries(
297
- COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
298
- ),
318
+ // The columns and the moves as data (Board-PRD decision 16): the board hard-codes neither.
319
+ domain: domainOf(),
299
320
  items,
300
321
  wip: wipOf(ledger.items, config),
301
322
  inbox,
@@ -557,10 +557,10 @@ function ledgerIssues(ledger) {
557
557
  }
558
558
 
559
559
  /**
560
- * M2 over `depends:` of units and items together: a cycle never gets played — every id in it
561
- * waits for the next one. Each cycle is reported once, from the id DFS meets it at.
560
+ * `depends:` of every unit and item: id → the ids it waits for. A feature also waits for its
561
+ * children: it closes only after them, so a child that depends on it never gets played.
562
562
  */
563
- export function dependsCycles(ledger) {
563
+ export function dependsGraph(ledger) {
564
564
  /** @type {Map<string, string[]>} */
565
565
  const graph = new Map();
566
566
  for (const u of ledger.design.flatMap((d) => d.units)) {
@@ -568,7 +568,43 @@ export function dependsCycles(ledger) {
568
568
  graph.set(u.id, Array.isArray(list) ? list : []);
569
569
  }
570
570
 
571
- for (const c of ledger.items) graph.set(String(c.data.id), c.data.depends ?? []);
571
+ for (const c of ledger.items) {
572
+ const children = c.data.type === 'feature' ? (c.data.children ?? []) : [];
573
+ graph.set(String(c.data.id), [...(c.data.depends ?? []), ...children]);
574
+ }
575
+
576
+ return graph;
577
+ }
578
+
579
+ /** The ids from `from` to `to` over `graph`, both included, or null when there is no way. */
580
+ export function pathTo(graph, from, to, seen = new Set()) {
581
+ if (from === to) return [to];
582
+ if (seen.has(from)) return null;
583
+ seen.add(from);
584
+ for (const next of graph.get(from) ?? []) {
585
+ const rest = pathTo(graph, next, to, seen);
586
+ if (rest) return [from, ...rest];
587
+ }
588
+
589
+ return null;
590
+ }
591
+
592
+ /** Why a depends `cycle` (its ids, the first again at the end) never gets played. */
593
+ export function cycleMessage(ledger, cycle) {
594
+ const feature = cycle.some((id) => ledger.item(id)?.data.type === 'feature');
595
+
596
+ return (
597
+ `depends cycle ${cycle.join(' -> ')}: none of them can be played first` +
598
+ (feature ? ' (a feature waits for its children)' : '')
599
+ );
600
+ }
601
+
602
+ /**
603
+ * M2 over `depends:` of units and items together: a cycle never gets played — every id in it
604
+ * waits for the next one. Each cycle is reported once, from the id DFS meets it at.
605
+ */
606
+ export function dependsCycles(ledger) {
607
+ const graph = dependsGraph(ledger);
572
608
  /** @type {Issue[]} */
573
609
  const out = [];
574
610
  const state = new Map(); // id → 'open' while on the DFS path, 'done' after
@@ -583,7 +619,7 @@ export function dependsCycles(ledger) {
583
619
  file,
584
620
  line: 1,
585
621
  id,
586
- message: `depends cycle ${cycle.join(' -> ')}: none of them can be played first`,
622
+ message: cycleMessage(ledger, cycle),
587
623
  });
588
624
 
589
625
  return;
package/lib/inbox.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // gcf/inbox.md (Methodology §5.1): raw input waiting for triage — playtest notes, CI failures,
2
- // the agent's ideas and requests. One line per entry, `- <in-id> | <source> | <text>`; a triaged
3
- // line is erased, not marked: the file holds what is still waiting, not what happened to it.
2
+ // the agent's ideas and requests, the operator's ideas and notes. One line per entry,
3
+ // `- <in-id> | <source> | <text>[ | tags: a, b]`; a triaged line is erased, not marked: the file
4
+ // holds what is still waiting, not what happened to it. Tags sort it; an older core reads the
5
+ // tags field as part of the text, which loses nothing.
4
6
  import { readFile } from 'node:fs/promises';
5
7
  import { existsSync } from 'node:fs';
6
8
  import { createHash } from 'node:crypto';
@@ -8,48 +10,136 @@ import path from 'node:path';
8
10
  import { atomicWrite } from './safe-path.js';
9
11
 
10
12
  export const INBOX_HEAD =
11
- '# Inbox — `- <in-id> | <source> | <text>`, raw input waiting for triage; a triaged line ' +
12
- 'is erased\n';
13
+ '# Inbox — `- <in-id> | <source> | <text>[ | tags: a, b]`, raw input waiting for triage; a ' +
14
+ 'triaged line is erased\n';
13
15
  export const SOURCES = ['playtest', 'ci', 'agent', 'operator'];
14
16
  const LINE = /^- (in-[0-9a-f]+) \| (\S+) \| (.+)$/;
17
+ // The text may hold ` | ` itself: the tags are only the field at the very end.
18
+ const TAGS = / \| tags: ([^|]*)$/;
19
+ const TAG = /^[a-z0-9][a-z0-9:-]{0,39}$/;
15
20
  // An agent's lesson (lessons.js): `lesson:<kind>:<item-id>`.
16
21
  const LESSON = /^lesson:([a-z]+):([a-z0-9][a-z0-9-]*)$/;
17
22
 
18
23
  const inboxFile = (root) => path.join(root, 'inbox.md');
19
24
 
25
+ /**
26
+ * The text and the tags of a line's last field. A field that is not a clean tag list (a bad
27
+ * tag, a tag twice, no text before it) stays part of the text: nothing written is lost.
28
+ */
29
+ function textAndTags(rest) {
30
+ const m = rest.match(TAGS);
31
+ const tags = m ? m[1].split(',').map((t) => t.trim()) : [];
32
+ const text = m ? rest.slice(0, m.index).trim() : '';
33
+ const clean = tags.every((t) => TAG.test(t)) && new Set(tags).size === tags.length;
34
+ if (!m || !clean || !text) return { text: rest.trim(), tags: [] };
35
+
36
+ return { text, tags };
37
+ }
38
+
39
+ const lineOf = (e) =>
40
+ `- ${e.id} | ${e.source} | ${e.text}${e.tags.length ? ` | tags: ${e.tags.join(', ')}` : ''}`;
41
+
42
+ /** The entry of one line as written, or null. */
43
+ function rawOf(line) {
44
+ const m = line.match(LINE);
45
+
46
+ return m ? { id: m[1], source: m[2], ...textAndTags(m[3]) } : null;
47
+ }
48
+
49
+ /**
50
+ * One entry per id. A union merge of two branches that tagged an entry differently leaves two
51
+ * lines with its id: they read as one, its first line's text with the tags of both.
52
+ */
53
+ function mergeById(raws) {
54
+ /** @type {Map<string, {id: string, source: string, text: string, tags: string[]}>} */
55
+ const byId = new Map();
56
+ for (const r of raws) {
57
+ const seen = byId.get(r.id);
58
+ if (seen) seen.tags = [...new Set([...seen.tags, ...r.tags])];
59
+ else byId.set(r.id, { ...r });
60
+ }
61
+
62
+ return [...byId.values()];
63
+ }
64
+
20
65
  /**
21
66
  * Every entry; a lesson reads as source `lesson` with its `kind` and the `item` it came from.
22
- * @typedef {{id: string, source: string, text: string, kind?: string, item?: string}} Entry
67
+ * @typedef {{id: string, source: string, text: string, tags: string[], kind?: string,
68
+ * item?: string}} Entry
23
69
  * @returns {Promise<Entry[]>}
24
70
  */
25
71
  export async function listInbox(root) {
26
72
  const file = inboxFile(root);
27
73
  if (!existsSync(file)) return [];
74
+ const raws = (await readFile(file, 'utf8')).split(/\r?\n/).map(rawOf).filter(Boolean);
28
75
 
29
- return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
30
- const m = l.match(LINE);
31
- if (!m) return [];
32
- const lesson = m[2].match(LESSON);
33
- const entry = { id: m[1], source: m[2], text: m[3].trim() };
76
+ return mergeById(/** @type {any[]} */ (raws)).map((entry) => {
77
+ const lesson = entry.source.match(LESSON);
34
78
 
35
- return [lesson ? { ...entry, source: 'lesson', kind: lesson[1], item: lesson[2] } : entry];
79
+ return lesson ? { ...entry, source: 'lesson', kind: lesson[1], item: lesson[2] } : entry;
36
80
  });
37
81
  }
38
82
 
39
- /** Append an entry; its id hashes the text and the moment, so two branches never collide. */
40
- export async function addInbox(root, text, source = 'operator', now = new Date().toISOString()) {
83
+ /** Refuse a tag that is not a lowercase word (`idea`, `later`, `ui:hud`). */
84
+ function checkTags(tags) {
85
+ const bad = tags.find((t) => !TAG.test(t));
86
+ if (bad !== undefined)
87
+ throw new Error(`tag "${bad}" must be a lowercase word: letters, digits, - and :`);
88
+ if (new Set(tags).size !== tags.length) throw new Error('a tag is named twice');
89
+ }
90
+
91
+ /**
92
+ * Append an entry with its `tags`; its id hashes the text and the moment (`now`), so two
93
+ * branches never collide.
94
+ */
95
+ export async function addInbox(
96
+ root,
97
+ text,
98
+ source = 'operator',
99
+ { now = new Date().toISOString(), tags = [] } = {},
100
+ ) {
41
101
  const clean = String(text).replace(/\s+/g, ' ').trim();
42
102
  if (!clean) throw new Error('an inbox entry needs text');
43
103
  if (!SOURCES.includes(source) && !LESSON.test(source))
44
104
  throw new Error(`source must be ${SOURCES.join(' | ')}`);
105
+ if (textAndTags(clean).tags.length)
106
+ throw new Error('the text ends like a tags field: give tags as tags');
107
+ checkTags(tags);
45
108
  const id = `in-${createHash('sha1').update(`${now}\n${clean}`).digest('hex').slice(0, 8)}`;
46
109
  const file = inboxFile(root);
47
110
  const old = existsSync(file) ? await readFile(file, 'utf8') : INBOX_HEAD;
48
111
  const eol = old.includes('\r\n') ? '\r\n' : '\n';
49
112
  const head = old.endsWith('\n') ? old : old + eol;
50
- await atomicWrite(file, `${head}- ${id} | ${source} | ${clean}${eol}`);
113
+ const entry = { id, source, text: clean, tags };
114
+ await atomicWrite(file, `${head}${lineOf(entry)}${eol}`);
115
+
116
+ return entry;
117
+ }
118
+
119
+ /**
120
+ * Add tags to entry `id` and remove others; refuses one it has, or lacks, already. Lines a
121
+ * union merge doubled become one again, at the first one's place.
122
+ */
123
+ export async function tagInbox(root, id, { add = [], remove = [] }) {
124
+ if (!add.length && !remove.length) throw new Error('name tags to --add or --remove');
125
+ checkTags([...add, ...remove]);
126
+ const file = inboxFile(root);
127
+ const lines = (existsSync(file) ? await readFile(file, 'utf8') : '').split(/(?<=\n)/);
128
+ const bodyOf = (l) => l.replace(/\r?\n$/, '');
129
+ const mine = lines.flatMap((l, i) => (rawOf(bodyOf(l))?.id === id ? [i] : []));
130
+ if (!mine.length) throw new Error(`${id} is not in gcf/inbox.md`);
131
+ const [entry] = mergeById(mine.map((i) => /** @type {any} */ (rawOf(bodyOf(lines[i])))));
132
+ const had = add.filter((x) => entry.tags.includes(x));
133
+ if (had.length) throw new Error(`${id} is tagged ${had.join(', ')} already`);
134
+ const missing = remove.filter((x) => !entry.tags.includes(x));
135
+ if (missing.length) throw new Error(`${id} has no tag ${missing.join(', ')}`);
136
+ entry.tags = [...entry.tags.filter((x) => !remove.includes(x)), ...add];
137
+ // The line keeps its own ending: a CRLF file stays CRLF.
138
+ const first = lines[mine[0]];
139
+ lines[mine[0]] = `${lineOf(entry)}${first.slice(bodyOf(first).length)}`;
140
+ await atomicWrite(file, lines.filter((_, i) => !mine.slice(1).includes(i)).join(''));
51
141
 
52
- return { id, source, text: clean };
142
+ return entry;
53
143
  }
54
144
 
55
145
  /** Erase entry `id`; refuses an id the inbox does not hold. */
package/lib/lessons.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // the session ends: the harness asks them in one more turn, `/gcf-run` asks them at its close.
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
+ import { uiLanguage } from './aif-config.js';
8
9
  import { readRawConfig } from './gcf-paths.js';
9
10
  import { addInbox } from './inbox.js';
10
11
  import { isValidId } from './id.js';
@@ -66,6 +67,7 @@ function questionsOf(config) {
66
67
  export async function lessonsPrompt(projectDir, id, { reply = 'cli' } = {}) {
67
68
  if (!isValidId(id)) throw new Error(`${id} is not an item id`);
68
69
  const config = lessonsConfig(await readRawConfig(path.join(path.resolve(projectDir), 'gcf')));
70
+ const ui = await uiLanguage(projectDir);
69
71
  const asked = questionsOf(config)
70
72
  .map((q, i) => `${i + 1}. ${q.kind}: ${q.text}`)
71
73
  .join('\n');
@@ -83,6 +85,10 @@ export async function lessonsPrompt(projectDir, id, { reply = 'cli' } = {}) {
83
85
  'what got in your way, so it can be removed.',
84
86
  'Answer from this session only, one finding per entry, in a sentence or two; skip a ' +
85
87
  'question with nothing to say, and never invent one.',
88
+ // The operator reads the answers: they go in the project's language.ui (Methodology §5.2.3).
89
+ ...(ui === 'en'
90
+ ? []
91
+ : [`Write each finding in the language "${ui}"; keep the kinds as they are.`]),
86
92
  asked,
87
93
  // The harness's turn comes after the item's commit and its verified trailer: an edit then
88
94
  // would leave the checkout dirty or move the trailer off the tip.
@@ -104,7 +110,10 @@ function lessonOf(raw, i) {
104
110
  if (!text) throw new Error(`lesson ${i + 1} has no text`);
105
111
  const where = raw?.where ? String(raw.where).replace(/\s+/g, ' ').trim() : '';
106
112
 
107
- return { kind, text: where ? `${text} (${where})` : text };
113
+ // An agent's free text must not read as an inbox tags field, nor fail the whole batch on it.
114
+ const full = (where ? `${text} (${where})` : text).replace(/ \| tags:/g, ' / tags:');
115
+
116
+ return { kind, text: full };
108
117
  }
109
118
 
110
119
  /**
@@ -1,13 +1,14 @@
1
1
  // Methodology §4. A work item FILE carries one of the stored statuses; `running` and `verify` are
2
2
  // read from the run (worktree + session), never written. A terminal move deletes the file and
3
3
  // writes a history or graveyard line in the same commit, so no terminal status is ever stored.
4
+ // The order is the board's column order.
4
5
  export const STORED = [
5
6
  'ready',
7
+ 'blocked',
6
8
  'awaiting-verdict',
7
9
  'awaiting-decision',
8
- 'blocked',
9
- 'parked',
10
10
  'unplayed',
11
+ 'parked',
11
12
  ];
12
13
 
13
14
  // The status each command takes an item from (§4.1). `drop` leaves from any stored status.
@@ -66,3 +67,133 @@ export const movesOf = (data) =>
66
67
  export function isStorable(status) {
67
68
  return STORED.includes(status);
68
69
  }
70
+
71
+ // The board's words for each stored status (Board-PRD decision 16): a column's label and tooltip.
72
+ const COLUMN_TEXT = {
73
+ ready: ['ready', 'Waits for a run; the order here is the run order'],
74
+ blocked: ['blocked', 'Stopped: waits for an answer, an asset or a fix of a failed check'],
75
+ 'awaiting-verdict': ['awaiting verdict', 'Built; waits for a playtest and a keep, tune or kill'],
76
+ 'awaiting-decision': ['awaiting decision', 'Waits for the operator to decide: go, no, close…'],
77
+ unplayed: ['unplayed', 'Closed without a playtest; waits for one'],
78
+ parked: ['parked', 'A bug kept as a known issue; unblock takes it back to ready'],
79
+ };
80
+
81
+ // The form of each move (§4.1) for a board: the `ai-factory gcf` words with `{id}` for the item,
82
+ // and the fields the operator fills. A field goes as `--<flag or key>=<value>`; a list repeats
83
+ // it; `remove` is `--remove` / `--no-remove`, or the command's own rule when left empty.
84
+ const REASON = { key: 'reason', label: 'Reason', kind: 'longtext', required: true };
85
+ const OPTIONAL_REASON = { ...REASON, required: false };
86
+ const REMOVE = { key: 'remove', label: 'Removal item for its code', kind: 'remove' };
87
+ const CONSTRAINT = { key: 'constraint', label: 'Constraint lines', kind: 'list' };
88
+ // close and submit need a green gcf verify of the code as it is now; the operator may say why
89
+ // they go without one, and the reason goes into the move's commit.
90
+ const UNVERIFIED = {
91
+ key: 'unverified',
92
+ label: 'No green gcf verify? Why it goes anyway',
93
+ kind: 'text',
94
+ };
95
+
96
+ const verdict = (v, help, fields, danger) => ({
97
+ label: v,
98
+ help,
99
+ args: ['verdict', '{id}', v],
100
+ fields,
101
+ ...(danger && { danger }),
102
+ });
103
+ const decide = (choice, help, fields = [], danger) => ({
104
+ label: choice,
105
+ help,
106
+ args: ['decide', '{id}', choice],
107
+ fields,
108
+ ...(danger && { danger }),
109
+ });
110
+
111
+ const SPECS = {
112
+ submit: {
113
+ label: 'submit',
114
+ help: 'Built: on to awaiting verdict or awaiting decision, by its gate',
115
+ args: ['submit', '{id}'],
116
+ fields: [UNVERIFIED],
117
+ rare: true,
118
+ },
119
+ close: {
120
+ label: 'close',
121
+ help: 'Done, gate auto: to history; a mechanic or a resource waits unplayed',
122
+ args: ['close', '{id}'],
123
+ fields: [CONSTRAINT, UNVERIFIED],
124
+ rare: true,
125
+ },
126
+ 'verdict:keep': verdict('keep', 'Played and kept: it goes to history', [
127
+ { key: 'note', label: 'Note', kind: 'text' },
128
+ CONSTRAINT,
129
+ ]),
130
+ 'verdict:tune': verdict('tune', 'Played; needs a change: back to ready with the note', [
131
+ { key: 'note', label: 'What to change', kind: 'longtext', required: true },
132
+ ]),
133
+ 'verdict:kill': verdict(
134
+ 'kill',
135
+ 'Played and rejected: it goes to the graveyard',
136
+ [{ key: 'note', label: 'Why', kind: 'longtext' }, REMOVE],
137
+ true,
138
+ ),
139
+ 'decide:go': decide('go', 'Approved: it goes to history as decided'),
140
+ 'decide:no': decide(
141
+ 'no',
142
+ 'Not doing it: it goes to the graveyard',
143
+ [OPTIONAL_REASON, REMOVE],
144
+ true,
145
+ ),
146
+ 'decide:wontfix': decide(
147
+ 'wontfix',
148
+ 'A bug left as it is: it goes to the graveyard',
149
+ [OPTIONAL_REASON, REMOVE],
150
+ true,
151
+ ),
152
+ 'decide:known': decide('known', 'A bug kept as a known issue: it goes to parked'),
153
+ 'decide:close': decide('close', 'The feature is done: it goes to history'),
154
+ 'decide:continue': decide('continue', 'The feature goes on: back to ready for more children'),
155
+ 'decide:kill': decide(
156
+ 'kill',
157
+ 'The feature is dropped: it goes to the graveyard with its live children',
158
+ [OPTIONAL_REASON],
159
+ true,
160
+ ),
161
+ block: {
162
+ label: 'block',
163
+ help: 'Stop it until the operator answers',
164
+ args: ['block', '{id}'],
165
+ fields: [REASON],
166
+ },
167
+ unblock: {
168
+ label: 'unblock',
169
+ help: 'The answer is given: back to ready (a feature with a dead child: to a decision)',
170
+ args: ['unblock', '{id}'],
171
+ fields: [{ key: 'note', label: 'Answer or "file: <path>"', kind: 'longtext' }],
172
+ drag: 'ready',
173
+ },
174
+ drop: {
175
+ label: 'drop',
176
+ help: 'Off the board into the graveyard, from any status',
177
+ args: ['drop', '{id}'],
178
+ fields: [REASON, { key: 'tags', label: 'Graveyard tags', kind: 'text' }, REMOVE],
179
+ danger: true,
180
+ rare: true,
181
+ drag: 'graveyard',
182
+ },
183
+ };
184
+
185
+ /**
186
+ * The GCF domain of the board read (Board-PRD decision 16): its columns in order, the status a
187
+ * run starts from, and the form of every move `movesOf` may name. A board draws these and
188
+ * builds each command from `args` and `fields`; it hard-codes none of them.
189
+ */
190
+ export const domainOf = () => ({
191
+ kind: 'gcf',
192
+ runs_from: 'ready',
193
+ columns: STORED.map((status) => ({
194
+ status,
195
+ label: COLUMN_TEXT[status][0],
196
+ help: COLUMN_TEXT[status][1],
197
+ })),
198
+ moves: Object.fromEntries(Object.keys(FROM).map((m) => [m, SPECS[m]])),
199
+ });