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/README.md +234 -230
- package/commands/gcf.js +56 -5
- package/extension.json +1 -1
- package/lib/aif-config.js +22 -0
- package/lib/board.js +28 -7
- package/lib/design-check.js +41 -5
- package/lib/inbox.js +105 -15
- package/lib/lessons.js +10 -1
- package/lib/transitions.js +133 -2
- package/lib/unit-ops.js +16 -1
- package/lib/work-ops.js +66 -0
- package/package.json +1 -1
- package/skills/gcf-card/SKILL.md +3 -2
- package/skills/gcf-triage/SKILL.md +3 -2
- package/templates/agents-block.md +45 -42
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
|
-
|
|
1069
|
-
|
|
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.
|
|
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 =
|
|
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:
|
|
297
|
-
|
|
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,
|
package/lib/design-check.js
CHANGED
|
@@ -557,10 +557,10 @@ function ledgerIssues(ledger) {
|
|
|
557
557
|
}
|
|
558
558
|
|
|
559
559
|
/**
|
|
560
|
-
*
|
|
561
|
-
*
|
|
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
|
|
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)
|
|
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:
|
|
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,
|
|
3
|
-
//
|
|
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
|
|
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,
|
|
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 (
|
|
30
|
-
const
|
|
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
|
|
79
|
+
return lesson ? { ...entry, source: 'lesson', kind: lesson[1], item: lesson[2] } : entry;
|
|
36
80
|
});
|
|
37
81
|
}
|
|
38
82
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/lib/transitions.js
CHANGED
|
@@ -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
|
+
});
|