gcf-aif-ext 0.5.3 → 0.6.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 +8 -0
- package/commands/gcf.js +62 -12
- package/extension.json +1 -1
- package/lib/board.js +112 -32
- package/lib/integrate.js +3 -3
- package/lib/live.js +6 -2
- package/lib/playtest-list.js +1 -1
- package/lib/transitions.js +25 -0
- package/lib/work-ops.js +32 -13
- package/package.json +1 -1
- package/templates/agents-block.md +2 -1
package/README.md
CHANGED
|
@@ -50,6 +50,14 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
|
|
|
50
50
|
|
|
51
51
|
On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
|
|
52
52
|
|
|
53
|
+
### 0.6.0
|
|
54
|
+
|
|
55
|
+
- `gcf board --json` carries `schema: 1`, the version of its format (Methodology §11 lists every field); a board refuses a version it does not know. New fields: per item `file`, `plan_file`, `log`, `depends`, `milestone`, `tune_count`, `since`, `model`, `effort` and `moves` (what the item takes now by Methodology §4.1, empty while a run drives it); at the top `history` and `graveyard`.
|
|
56
|
+
- `item new --model <m> --effort <e>`: the run of the item uses them (gcf-harness-aif-ext 0.7). One word each.
|
|
57
|
+
- `playtest session --json` and `live check --json` answer JSON: the flag went to the parent command, which printed text.
|
|
58
|
+
- `--commit` on `item new`, `inbox add` and `inbox drop` records the move alone (`gcf(item): <id>`, `gcf(inbox): …`) for a caller with no console, a board. Without it nothing is committed, as before. Inbox lines added earlier without a commit go into the same commit: the inbox is raw input.
|
|
59
|
+
- `inbox add --text=<text>`: a text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of ai-factory's commands, `--` included.
|
|
60
|
+
|
|
53
61
|
### 0.5.3
|
|
54
62
|
|
|
55
63
|
- `gcf init` restamps an unplayed item frozen by 0.4.0 (the hash of its body alone), so its `live:` is guarded too — only while that `live:` is still the one its close committed. Commit the restamped items it names.
|
package/commands/gcf.js
CHANGED
|
@@ -321,7 +321,8 @@ function registerPlaytest(gcf) {
|
|
|
321
321
|
const r = await (
|
|
322
322
|
await lib('playtest-list')
|
|
323
323
|
).playtestSession(process.cwd(), { build: Boolean(o.build) });
|
|
324
|
-
|
|
324
|
+
// Commander gives a flag both levels define to the parent: `playtest session --json`.
|
|
325
|
+
out(o.json || playtest.opts().json, r, sessionText);
|
|
325
326
|
if (r.build && !r.build.ok) process.exitCode = 1;
|
|
326
327
|
});
|
|
327
328
|
}
|
|
@@ -643,6 +644,9 @@ function registerItem(gcf) {
|
|
|
643
644
|
.option('--milestone <name>', 'the milestone of game.md it belongs to')
|
|
644
645
|
.option('--release <version>', 'a release: the version it ships')
|
|
645
646
|
.option('--channel <name>', 'a release: where it ships, e.g. steam')
|
|
647
|
+
.option('--model <name>', 'the model its run uses, e.g. opus')
|
|
648
|
+
.option('--effort <level>', 'the effort its run uses, e.g. high')
|
|
649
|
+
.option('--commit', 'commit it (gcf(item)): for a caller with no console, e.g. a board')
|
|
646
650
|
.action(async (id, o) => {
|
|
647
651
|
const { newItem } = await lib('work-ops');
|
|
648
652
|
const r = await newItem(process.cwd(), {
|
|
@@ -660,8 +664,11 @@ function registerItem(gcf) {
|
|
|
660
664
|
milestone: o.milestone,
|
|
661
665
|
version: o.release,
|
|
662
666
|
channel: o.channel,
|
|
667
|
+
model: o.model,
|
|
668
|
+
effort: o.effort,
|
|
669
|
+
commit: o.commit === true,
|
|
663
670
|
});
|
|
664
|
-
console.log(r.item);
|
|
671
|
+
console.log(`${r.item}${shortHash(r.hash)}`);
|
|
665
672
|
});
|
|
666
673
|
}
|
|
667
674
|
|
|
@@ -736,6 +743,36 @@ function registerDecide(gcf) {
|
|
|
736
743
|
});
|
|
737
744
|
}
|
|
738
745
|
|
|
746
|
+
/**
|
|
747
|
+
* An inbox edit; with `commit`, committed alone under the main lock — a board has no console to
|
|
748
|
+
* commit from. Returns the commit hash, or null.
|
|
749
|
+
* @param {boolean | undefined} commit
|
|
750
|
+
* @param {string} message
|
|
751
|
+
* @param {(root: string) => Promise<unknown>} edit
|
|
752
|
+
*/
|
|
753
|
+
async function inboxMove(commit, message, edit) {
|
|
754
|
+
const { gcfRoot } = await lib('gcf-paths');
|
|
755
|
+
const root = gcfRoot();
|
|
756
|
+
if (!commit) {
|
|
757
|
+
await edit(root);
|
|
758
|
+
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const { commitMove } = await lib('unit-ops');
|
|
763
|
+
const inbox = path.join(root, 'inbox.md');
|
|
764
|
+
|
|
765
|
+
// Raw input (merge=union): lines added before without a commit ride along with this one.
|
|
766
|
+
return commitMove(process.cwd(), message, {
|
|
767
|
+
files: [inbox],
|
|
768
|
+
inputs: [inbox],
|
|
769
|
+
mutate: async () => {
|
|
770
|
+
await edit(root);
|
|
771
|
+
},
|
|
772
|
+
commit: true,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
739
776
|
function registerInbox(gcf) {
|
|
740
777
|
const inbox = gcf
|
|
741
778
|
.command('inbox')
|
|
@@ -749,21 +786,34 @@ function registerInbox(gcf) {
|
|
|
749
786
|
);
|
|
750
787
|
});
|
|
751
788
|
inbox
|
|
752
|
-
.command('add
|
|
789
|
+
.command('add [text]')
|
|
753
790
|
.description('Append an entry')
|
|
754
791
|
.option('--source <source>', 'playtest | ci | agent | operator', 'operator')
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
792
|
+
// A text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of
|
|
793
|
+
// ai-factory's commands, `--` included: as `--text=<text>` it stays one value.
|
|
794
|
+
.option('--text <text>', 'the entry, when it starts with a hyphen')
|
|
795
|
+
.option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
|
|
796
|
+
.action(async (arg, o) => {
|
|
797
|
+
const text = o.text ?? arg;
|
|
798
|
+
if (!text || (o.text !== undefined && arg !== undefined))
|
|
799
|
+
throw new Error('give the entry once: as the argument, or as --text=<text>');
|
|
800
|
+
const { addInbox } = await lib('inbox');
|
|
801
|
+
let id = '';
|
|
802
|
+
const hash = await inboxMove(o.commit, 'gcf(inbox): add', async (root) => {
|
|
803
|
+
id = (await addInbox(root, text, o.source)).id;
|
|
804
|
+
});
|
|
805
|
+
console.log(`${id}${shortHash(hash)}`);
|
|
759
806
|
});
|
|
760
807
|
inbox
|
|
761
808
|
.command('drop <in-id>')
|
|
762
809
|
.description('Erase an entry: triaged as noise, a duplicate, or turned into an item')
|
|
763
|
-
.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
810
|
+
.option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
|
|
811
|
+
.action(async (id, o) => {
|
|
812
|
+
const { dropInbox } = await lib('inbox');
|
|
813
|
+
const hash = await inboxMove(o.commit, `gcf(inbox): drop ${id}`, (root) =>
|
|
814
|
+
dropInbox(root, id),
|
|
815
|
+
);
|
|
816
|
+
console.log(`erased ${id}${shortHash(hash)}`);
|
|
767
817
|
});
|
|
768
818
|
}
|
|
769
819
|
|
|
@@ -794,7 +844,7 @@ function registerLive(gcf) {
|
|
|
794
844
|
.option('--json', 'machine output')
|
|
795
845
|
.action(async (o) => {
|
|
796
846
|
const r = await (await lib('live')).checkLive(process.cwd());
|
|
797
|
-
out(o.json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
|
|
847
|
+
out(o.json || live.opts().json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
|
|
798
848
|
if (r.issues.length) process.exitCode = 1;
|
|
799
849
|
});
|
|
800
850
|
}
|
package/extension.json
CHANGED
package/lib/board.js
CHANGED
|
@@ -12,29 +12,80 @@ import { verifiedBranches, worktreeOf } from './integrate.js';
|
|
|
12
12
|
import { resolveInside } from './safe-path.js';
|
|
13
13
|
import { wipOf } from './audit-core.js';
|
|
14
14
|
import { runHolder } from './main-lock.js';
|
|
15
|
+
import { ITEM_TYPES } from './validate-item.js';
|
|
16
|
+
import { movesOf } from './transitions.js';
|
|
17
|
+
|
|
18
|
+
// The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
|
|
19
|
+
// when a field changes meaning or goes away; a new field does not bump it.
|
|
20
|
+
export const BOARD_SCHEMA = 1;
|
|
15
21
|
|
|
16
22
|
const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
|
-
* Plan progress `{tasks, done}` from its checkboxes
|
|
20
|
-
* outside the project or not a file reads as `{error}`, the
|
|
25
|
+
* Plan progress `{tasks, done}` from its checkboxes and the plan's project path, both null
|
|
26
|
+
* without a plan file (§3.3); a plan: outside the project or not a file reads as `{error}`, the
|
|
27
|
+
* rest of the board still shows.
|
|
21
28
|
*/
|
|
22
29
|
async function progress(projectDir, item) {
|
|
30
|
+
const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
23
31
|
try {
|
|
24
|
-
const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
25
32
|
const file = resolveInside(projectDir, plan);
|
|
26
|
-
if (!existsSync(file)) return null;
|
|
33
|
+
if (!existsSync(file)) return { plan: null, plan_file: null };
|
|
27
34
|
const text = await readFile(file, 'utf8');
|
|
28
35
|
|
|
29
36
|
return {
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
plan: {
|
|
38
|
+
tasks: (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length,
|
|
39
|
+
done: (text.match(/^\s*- \[[xX]\] /gm) ?? []).length,
|
|
40
|
+
},
|
|
41
|
+
plan_file: plan,
|
|
32
42
|
};
|
|
33
43
|
} catch (e) {
|
|
34
|
-
return { error: e.message };
|
|
44
|
+
return { plan: { error: e.message }, plan_file: null };
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
/** The lines `- <a> | <b> | …` of a gcf record file, split on the pipes. */
|
|
49
|
+
async function records(root, name) {
|
|
50
|
+
const file = path.join(root, name);
|
|
51
|
+
if (!existsSync(file)) return [];
|
|
52
|
+
|
|
53
|
+
return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
|
|
54
|
+
const m = l.match(/^- (\S+)\s*\|(.*)$/);
|
|
55
|
+
|
|
56
|
+
return m ? [[m[1], ...m[2].split('|').map((f) => f.trim())]] : [];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** history.md: `- <id> | <event> | <date>` (§9). */
|
|
61
|
+
const historyOf = async (root) =>
|
|
62
|
+
(await records(root, 'history.md')).map(([id, event, date]) => ({
|
|
63
|
+
id,
|
|
64
|
+
event: event || null,
|
|
65
|
+
date: date || null,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* graveyard.md: `- <id> | <type> | <reason> | tags: <tags>`; a 0.3 line has no type, and its
|
|
70
|
+
* second field is a type only when it names one (§9).
|
|
71
|
+
*/
|
|
72
|
+
const graveyardOf = async (root) =>
|
|
73
|
+
(await records(root, 'graveyard.md')).map(([id, ...fields]) => {
|
|
74
|
+
const tags = fields.at(-1)?.startsWith('tags:') ? fields.pop() : null;
|
|
75
|
+
const typed = fields.length > 1 && ITEM_TYPES.includes(fields[0]);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
type: typed ? fields[0] : null,
|
|
80
|
+
reason: (typed ? fields.slice(1) : fields).join(' | ') || null,
|
|
81
|
+
tags: (tags ?? '')
|
|
82
|
+
.slice('tags:'.length)
|
|
83
|
+
.split(',')
|
|
84
|
+
.map((t) => t.trim())
|
|
85
|
+
.filter(Boolean),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
38
89
|
/** Verified branches; none outside a repository. */
|
|
39
90
|
function verifiedOf(projectDir) {
|
|
40
91
|
try {
|
|
@@ -68,42 +119,69 @@ async function phaseOf(projectDir) {
|
|
|
68
119
|
}
|
|
69
120
|
}
|
|
70
121
|
|
|
122
|
+
/** The release block with every field: one a malformed block lacks is null (live check says so). */
|
|
123
|
+
const releaseOf = (r) =>
|
|
124
|
+
r && {
|
|
125
|
+
version: r.version,
|
|
126
|
+
...Object.fromEntries(['channel', 'date', 'phase'].map((k) => [k, r[k] ?? null])),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** The run log of an item, project-relative, or null before its first run. */
|
|
130
|
+
function logOf(projectDir, id) {
|
|
131
|
+
const log = `.gcf-session/runs/${id}.log`;
|
|
132
|
+
|
|
133
|
+
return existsSync(path.join(projectDir, log)) ? log : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fields of the item file a board shows as they are: null or [] when the file has none.
|
|
137
|
+
const SCALARS = ['title', 'parent', 'milestone', 'tune_count', 'since', 'model', 'effort'];
|
|
138
|
+
const LISTS = ['children', 'system', 'depends'];
|
|
139
|
+
|
|
140
|
+
/** One item of the board. */
|
|
141
|
+
async function itemOf(projectDir, c, verified) {
|
|
142
|
+
const id = String(c.data.id);
|
|
143
|
+
// A trailer counts only while the item waits on main for its integrate.
|
|
144
|
+
const ready = c.data.status === 'ready' && verified.has(id);
|
|
145
|
+
// `running` and `verify` are not stored (§4): a live harness run, or a worktree of a ready
|
|
146
|
+
// item, is a run; a trailer is a passed DoD.
|
|
147
|
+
const running =
|
|
148
|
+
runHolder(projectDir, id) !== null ||
|
|
149
|
+
(c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id)));
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
id,
|
|
153
|
+
type: c.data.type,
|
|
154
|
+
gate: c.data.gate,
|
|
155
|
+
status: c.data.status,
|
|
156
|
+
...Object.fromEntries(SCALARS.map((k) => [k, c.data[k] ?? null])),
|
|
157
|
+
...Object.fromEntries(LISTS.map((k) => [k, c.data[k] ?? []])),
|
|
158
|
+
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
159
|
+
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
160
|
+
running,
|
|
161
|
+
verified: ready,
|
|
162
|
+
// What a board may offer (§4.1): nothing while a run drives the item.
|
|
163
|
+
moves: running ? [] : movesOf(c.data),
|
|
164
|
+
file: `gcf/work/${c.name}`,
|
|
165
|
+
log: logOf(projectDir, id),
|
|
166
|
+
...(await progress(projectDir, c)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
71
170
|
export async function board(projectDir) {
|
|
72
171
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
73
172
|
const ledger = await buildLedger(projectDir);
|
|
74
173
|
const verified = verifiedOf(projectDir);
|
|
75
174
|
const items = [];
|
|
76
|
-
for (const c of ledger.items)
|
|
77
|
-
const id = String(c.data.id);
|
|
78
|
-
// A trailer counts only while the item waits on main for its integrate.
|
|
79
|
-
const ready = c.data.status === 'ready' && verified.has(id);
|
|
80
|
-
items.push({
|
|
81
|
-
id,
|
|
82
|
-
type: c.data.type,
|
|
83
|
-
gate: c.data.gate,
|
|
84
|
-
status: c.data.status,
|
|
85
|
-
title: c.data.title ?? null,
|
|
86
|
-
parent: c.data.parent ?? null,
|
|
87
|
-
children: c.data.children ?? [],
|
|
88
|
-
system: c.data.system ?? [],
|
|
89
|
-
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
90
|
-
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
91
|
-
// `running` and `verify` are not stored (§4): a live harness run, or a worktree of a ready
|
|
92
|
-
// item, is a run; a trailer is a passed DoD.
|
|
93
|
-
running:
|
|
94
|
-
runHolder(projectDir, id) !== null ||
|
|
95
|
-
(c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id))),
|
|
96
|
-
verified: ready,
|
|
97
|
-
plan: await progress(projectDir, c),
|
|
98
|
-
});
|
|
99
|
-
}
|
|
175
|
+
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
|
|
100
176
|
|
|
101
177
|
const config = await readConfig(root);
|
|
102
178
|
const live = await readLive(root);
|
|
103
179
|
|
|
104
180
|
return {
|
|
181
|
+
schema: BOARD_SCHEMA,
|
|
105
182
|
phase: await phaseOf(projectDir),
|
|
106
|
-
|
|
183
|
+
// A malformed block reads with fields missing (live check names it): null, not absent.
|
|
184
|
+
release: releaseOf(live.release),
|
|
107
185
|
columns: Object.fromEntries(
|
|
108
186
|
COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
|
|
109
187
|
),
|
|
@@ -111,5 +189,7 @@ export async function board(projectDir) {
|
|
|
111
189
|
wip: wipOf(ledger.items, config),
|
|
112
190
|
inbox: await listInbox(root),
|
|
113
191
|
questions: await questions(root),
|
|
192
|
+
history: await historyOf(root),
|
|
193
|
+
graveyard: await graveyardOf(root),
|
|
114
194
|
};
|
|
115
195
|
}
|
package/lib/integrate.js
CHANGED
|
@@ -238,7 +238,7 @@ async function integrateMerged(projectDir, id, item) {
|
|
|
238
238
|
if (!item || item.data.status !== 'ready' || movedSinceMerge(projectDir, id))
|
|
239
239
|
return { id, skipped: `${branchOf(id)} is in main already`, ...cleanUp(projectDir, id) };
|
|
240
240
|
|
|
241
|
-
return {
|
|
241
|
+
return { ...(await recordMove(projectDir, item)), id, ...cleanUp(projectDir, id) };
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
async function integrateOne(projectDir, id) {
|
|
@@ -250,7 +250,7 @@ async function integrateOne(projectDir, id) {
|
|
|
250
250
|
if (git(projectDir, ancestor, { check: false }).ok) return integrateMerged(projectDir, id, item);
|
|
251
251
|
if (!item || item.data.status !== 'ready')
|
|
252
252
|
return { id, skipped: `${id} is ${item ? item.data.status : 'not a work item'} on main` };
|
|
253
|
-
if (item.data.type === 'spike') return {
|
|
253
|
+
if (item.data.type === 'spike') return { ...(await integrateSpike(projectDir, item)), id };
|
|
254
254
|
const foreign = foreignGcfEdits(projectDir, id);
|
|
255
255
|
if (foreign.length)
|
|
256
256
|
return {
|
|
@@ -263,7 +263,7 @@ async function integrateOne(projectDir, id) {
|
|
|
263
263
|
if (failed) return { id, ...failed };
|
|
264
264
|
const moved = await recordMove(projectDir, (await buildLedger(projectDir)).item(id));
|
|
265
265
|
|
|
266
|
-
return {
|
|
266
|
+
return { ...moved, id, ...cleanUp(projectDir, id) };
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
/**
|
package/lib/live.js
CHANGED
|
@@ -66,7 +66,7 @@ export function parseLive(text) {
|
|
|
66
66
|
/** A record line of capabilities or known-issues; false when the line is not one. */
|
|
67
67
|
const recordLine = (line) => {
|
|
68
68
|
const start = line.match(/^- id:\s*(\S+)\s*$/);
|
|
69
|
-
if (start && section) {
|
|
69
|
+
if (start && section && section !== 'release') {
|
|
70
70
|
rec = { id: start[1], summary: '' };
|
|
71
71
|
out[section].push(rec);
|
|
72
72
|
|
|
@@ -106,7 +106,11 @@ const recordText = (r) =>
|
|
|
106
106
|
|
|
107
107
|
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly.
|
|
111
|
+
* @param {{phase?: string | null, release?: LiveRelease | null, capabilities: LiveRecord[],
|
|
112
|
+
* knownIssues: LiveRecord[]}} live
|
|
113
|
+
*/
|
|
110
114
|
export function serializeLive({ phase = null, release = null, capabilities, knownIssues }) {
|
|
111
115
|
const block = (key, list) =>
|
|
112
116
|
`${key}:\n${list.length ? [...list].sort(byId).map(recordText).join('\n') : NONE[key]}\n`;
|
package/lib/playtest-list.js
CHANGED
|
@@ -73,7 +73,7 @@ export async function playtestSession(
|
|
|
73
73
|
commit: git(projectDir, ['rev-parse', '--short', 'HEAD']),
|
|
74
74
|
// No commit (no repository, or none yet): the build is unknown, not clean.
|
|
75
75
|
dirty: dirty === null ? null : Boolean(dirty),
|
|
76
|
-
build: null,
|
|
76
|
+
build: /** @type {ReturnType<typeof runDod> | null} */ (null),
|
|
77
77
|
items,
|
|
78
78
|
};
|
|
79
79
|
if (build) session.build = runDod(projectDir, await readConfig(root), 'build', stdio);
|
package/lib/transitions.js
CHANGED
|
@@ -37,6 +37,31 @@ export function allowedFrom(move, status) {
|
|
|
37
37
|
/** Statuses `move` starts from, for error messages. */
|
|
38
38
|
export const fromOf = (move) => FROM[move] ?? [];
|
|
39
39
|
|
|
40
|
+
export const FEATURE_ONLY = ['close', 'continue', 'kill'];
|
|
41
|
+
const BUG_ONLY = ['wontfix', 'known'];
|
|
42
|
+
|
|
43
|
+
/** Whether a decision fits an item's type: a feature has its own, wontfix and known a bug's. */
|
|
44
|
+
export const decisionFits = (choice, type) =>
|
|
45
|
+
FEATURE_ONLY.includes(choice) === (type === 'feature') &&
|
|
46
|
+
(!BUG_ONLY.includes(choice) || type === 'bug');
|
|
47
|
+
|
|
48
|
+
/** Whether a move fits the item beyond its status: its gate for submit and close, its type. */
|
|
49
|
+
function fits(move, { type, gate }) {
|
|
50
|
+
const [cmd, arg] = move.split(':');
|
|
51
|
+
if (cmd === 'submit') return gate !== 'auto';
|
|
52
|
+
if (cmd === 'close') return gate === 'auto';
|
|
53
|
+
|
|
54
|
+
return cmd !== 'decide' || decisionFits(arg, type);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The moves an item may take now (§4.1), named as `allowedFrom` names them: `verdict:keep`,
|
|
59
|
+
* `decide:go`, `unblock`… A board offers these and nothing else; each command still checks the
|
|
60
|
+
* rest itself (dependencies, a live: delta, the tune limit).
|
|
61
|
+
*/
|
|
62
|
+
export const movesOf = (data) =>
|
|
63
|
+
Object.keys(FROM).filter((m) => allowedFrom(m, data.status) && fits(m, data));
|
|
64
|
+
|
|
40
65
|
/** A status an item FILE may carry. */
|
|
41
66
|
export function isStorable(status) {
|
|
42
67
|
return STORED.includes(status);
|
package/lib/work-ops.js
CHANGED
|
@@ -11,9 +11,8 @@ import path from 'node:path';
|
|
|
11
11
|
import { parse, serialize } from './frontmatter.js';
|
|
12
12
|
import { buildLedger } from './ledger.js';
|
|
13
13
|
import { readConfig } from './gcf-paths.js';
|
|
14
|
-
import { atomicWrite
|
|
15
|
-
import {
|
|
16
|
-
import { allowedFrom, fromOf } from './transitions.js';
|
|
14
|
+
import { atomicWrite } from './safe-path.js';
|
|
15
|
+
import { allowedFrom, decisionFits, FEATURE_ONLY, fromOf } from './transitions.js';
|
|
17
16
|
import { ITEM_TYPES, DEFAULT_GATE, GATES, SEVERITIES, UNIT_ITEM_TYPES } from './validate-item.js';
|
|
18
17
|
import { isValidId } from './id.js';
|
|
19
18
|
import { commitMove, addConstraints } from './unit-ops.js';
|
|
@@ -646,10 +645,9 @@ const DECIDE = {
|
|
|
646
645
|
},
|
|
647
646
|
kill: killFeature,
|
|
648
647
|
};
|
|
649
|
-
const FEATURE_ONLY = ['close', 'continue', 'kill'];
|
|
650
|
-
|
|
651
648
|
/** Refuse a decision that does not fit the item's type. */
|
|
652
649
|
function fitDecision(choice, item) {
|
|
650
|
+
if (decisionFits(choice, item.data.type)) return;
|
|
653
651
|
const feature = item.data.type === 'feature';
|
|
654
652
|
if (FEATURE_ONLY.includes(choice) !== feature)
|
|
655
653
|
throw new Error(
|
|
@@ -657,8 +655,7 @@ function fitDecision(choice, item) {
|
|
|
657
655
|
? `${item.data.id} is a feature: decide close | continue | kill`
|
|
658
656
|
: `decide ${choice} is for a feature; ${item.data.id} is a ${item.data.type}`,
|
|
659
657
|
);
|
|
660
|
-
|
|
661
|
-
throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
|
|
658
|
+
throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
|
|
662
659
|
}
|
|
663
660
|
|
|
664
661
|
/** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
|
|
@@ -727,6 +724,9 @@ export async function dropItem(projectDir, id, opts = {}) {
|
|
|
727
724
|
return { id, hash: await finish(m, 'drop', id, commit), removal };
|
|
728
725
|
}
|
|
729
726
|
|
|
727
|
+
// A leading hyphen would read as a flag of the agent's command line.
|
|
728
|
+
export const RUN_WORD = /^[A-Za-z0-9][\w.-]*$/;
|
|
729
|
+
|
|
730
730
|
/** The version and channel of a new item: well-formed, and only on a release. */
|
|
731
731
|
function checkRelease(spec) {
|
|
732
732
|
const { type, version, channel } = spec;
|
|
@@ -740,6 +740,20 @@ function checkRelease(spec) {
|
|
|
740
740
|
throw new Error('--release and --channel belong to a release item');
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
const RUN_KEYS = ['model', 'effort'];
|
|
744
|
+
|
|
745
|
+
/** The model and effort a new item gives: the run passes them to the agent's command line. */
|
|
746
|
+
function runFields(spec) {
|
|
747
|
+
for (const k of RUN_KEYS)
|
|
748
|
+
if (spec[k] !== undefined && !RUN_WORD.test(spec[k]))
|
|
749
|
+
throw new Error(
|
|
750
|
+
`--${k} is one word that starts with a letter or a digit ` +
|
|
751
|
+
'(then letters, digits, dots, hyphens, underscores), e.g. opus',
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
return Object.fromEntries(RUN_KEYS.filter((k) => spec[k]).map((k) => [k, spec[k]]));
|
|
755
|
+
}
|
|
756
|
+
|
|
743
757
|
/** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
|
|
744
758
|
function checkNew(ledger, spec) {
|
|
745
759
|
const { id, type, gate, severity } = spec;
|
|
@@ -790,6 +804,7 @@ function optionalFields(spec, gate, since) {
|
|
|
790
804
|
...(spec.milestone ? { milestone: spec.milestone } : {}),
|
|
791
805
|
...(spec.version ? { version: spec.version } : {}),
|
|
792
806
|
...(spec.channel ? { channel: spec.channel } : {}),
|
|
807
|
+
...runFields(spec),
|
|
793
808
|
...(live ? { live } : {}),
|
|
794
809
|
};
|
|
795
810
|
}
|
|
@@ -838,14 +853,18 @@ export async function newItem(projectDir, spec) {
|
|
|
838
853
|
writes.push([p.file, serialize({ ...p.data, children }, p.body)]);
|
|
839
854
|
}
|
|
840
855
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
856
|
+
// `commit` is for a caller with no console to commit from, a board: a run starts from HEAD.
|
|
857
|
+
// The inbox is raw input (merge=union): lines added without a commit ride along with it.
|
|
858
|
+
const inbox = spec.from ? [path.join(root, 'inbox.md')] : [];
|
|
859
|
+
const hash = await commitMove(projectDir, `gcf(item): ${spec.id}`, {
|
|
860
|
+
files: [...writes.map((w) => w[0]), ...inbox],
|
|
861
|
+
inputs: inbox,
|
|
862
|
+
mutate: async () => {
|
|
844
863
|
for (const [f, text] of writes) await atomicWrite(f, text);
|
|
845
864
|
if (spec.from) await dropInbox(root, spec.from);
|
|
846
|
-
await writeIndex(root);
|
|
847
865
|
},
|
|
848
|
-
|
|
866
|
+
commit: spec.commit ?? false,
|
|
867
|
+
});
|
|
849
868
|
|
|
850
|
-
return { item: `gcf/work/${spec.id}.md
|
|
869
|
+
return { item: `gcf/work/${spec.id}.md`, hash };
|
|
851
870
|
}
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ gcf_context:
|
|
|
6
6
|
|
|
7
7
|
gcf_work:
|
|
8
8
|
- every piece of work is one work item `gcf/work/<id>.md`: find the item before writing code for a feature, a bug or a tech task, or create it with the gcf-card skill
|
|
9
|
-
- a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later
|
|
9
|
+
- a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later; a text that starts with a hyphen goes as `inbox add --text="<text>"`
|
|
10
10
|
- before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
|
|
11
11
|
- drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext (a batch at once: `ai-factory gcf-harness run <ids...>`), otherwise with aif-plan and aif-implement
|
|
12
12
|
- after the code, write the item's `live:` delta and run `ai-factory gcf verify <id>`: fix what it reports and run it again until every check is green
|
|
@@ -18,6 +18,7 @@ gcf_work:
|
|
|
18
18
|
- the human plays from `ai-factory gcf playtest session`; each free note from that play goes to `ai-factory gcf inbox add --source playtest "<note>"`
|
|
19
19
|
- a release item carries `version:` and `channel:` (`item new <id> --type release --release <v> --channel <c>`): its end writes the `release:` block of `gcf/LIVE.md`
|
|
20
20
|
- `item new` and `take` refuse a type the current phase does not take: run `ai-factory gcf phase` and bring the refusal to the human
|
|
21
|
+
- an item the human wants run on a given model or effort gets `item new <id> --model <m> --effort <e>`: the harness run passes both to the agent
|
|
21
22
|
- a pillar changes only on the human's word: `ai-factory gcf pillar rename <from> <to>`, or `ai-factory gcf pillar retire <id> --reason "<why>"`
|
|
22
23
|
|
|
23
24
|
gcf_forbidden:
|