gcf-aif-ext 0.7.8 → 0.7.10
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 +14 -0
- package/commands/gcf.js +68 -3
- package/extension.json +1 -1
- package/lib/agent-permissions.js +11 -17
- package/lib/board.js +7 -2
- package/lib/config-set.js +27 -16
- package/lib/gcf-paths.js +4 -1
- package/lib/inbox.js +13 -3
- package/lib/lessons.js +131 -0
- package/lib/main-lock.js +11 -0
- package/lib/phase.js +6 -1
- package/lib/pillars.js +11 -2
- package/lib/playtest-list.js +16 -2
- package/lib/question-fields.js +1 -0
- package/lib/questions.js +6 -1
- package/lib/unit-ops.js +14 -4
- package/lib/verify.js +118 -8
- package/lib/work-ops.js +3 -11
- package/package.json +1 -1
- package/skills/gcf-triage/SKILL.md +8 -7
- package/templates/config.yml +12 -1
package/README.md
CHANGED
|
@@ -50,6 +50,20 @@ 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.7.10
|
|
54
|
+
|
|
55
|
+
- Lessons: what got in an agent's way, asked at the end of its session (Methodology §5.2.2). `gcf lessons prompt <id>` prints the questions: tools and their workarounds, project rules that are missing or conflict, trouble with GCF itself, and, with `lessons.template` in `gcf/config.yml`, what the project's template should learn; `lessons.questions` adds the operator's own, `lessons.off: true` turns them off. `gcf lessons add <id>` records each answer as an inbox entry with the source `lesson:<kind>:<item>`; `gcf board --json` gives such an entry `source: lesson`, `kind` and `item`, and each item its `lessons` count. The harness 0.9.2 asks them in a turn of its own after each session, so they stay out of the agent's working context; `/gcf-run` without the harness asks them at its end. `/gcf-triage` proposes a fix for each.
|
|
56
|
+
|
|
57
|
+
- `gcf verify` kills a `dod.test` or `dod.build` command that runs longer than `dod_timeout_min` minutes (60 by default, 0 for no limit), with every process it started, and fails the DoD: `dod.test timed out after 60 min (dod_timeout_min), killed: <command>`. Before, a hung engine held the check, and a run with it, for hours.
|
|
58
|
+
- `gcf prioritize` takes an item made with `gcf item new` and never committed, and commits it with its rank. Before, it refused the item as an uncommitted file. An item with uncommitted edits is still refused.
|
|
59
|
+
- `gcf playtest session` lists the items awaiting a decision in a section of their own: before, they were missing from the sheet.
|
|
60
|
+
- `plan_passes` defaults to 3 (was 5). A plan converges when an improve pass finds nothing blocking (Methodology §5.2.1); a project that sets `plan_passes` keeps its value.
|
|
61
|
+
|
|
62
|
+
### 0.7.9
|
|
63
|
+
|
|
64
|
+
- `gcf done`, `gcf drop` (a design unit), `gcf pillar rename|retire`, `gcf phase next` and `gcf question answer` hold the main lock from their first read to their commit, as the work item moves do. Before, two at once could both pass their checks and write over each other: an answer lost, a unit in the history twice.
|
|
65
|
+
- `gcf claim` says the mark lives 2 hours without a gcf command, as it does (it said 30 minutes).
|
|
66
|
+
|
|
53
67
|
### 0.7.8
|
|
54
68
|
|
|
55
69
|
- An open question's field written as a YAML block (`assumed: |` or `>`, and the lines under it) reads as its text: before, `gcf board --json` gave the block sign `|`. Every reader of `gcf/open-questions.md` (the board, `design check`, the audit, `context`, the index) reads the fields the same way:
|
package/commands/gcf.js
CHANGED
|
@@ -33,7 +33,7 @@ function registerSession(gcf) {
|
|
|
33
33
|
.command('claim <id>')
|
|
34
34
|
.description(
|
|
35
35
|
'An agent session starts working the item on main: the board shows it running until a ' +
|
|
36
|
-
'move ends it, release, or
|
|
36
|
+
'move ends it, release, or 2 hours without a gcf command on it',
|
|
37
37
|
)
|
|
38
38
|
.action(async (id) => {
|
|
39
39
|
const { buildLedger } = await lib('ledger');
|
|
@@ -308,7 +308,16 @@ function sessionHead(s) {
|
|
|
308
308
|
/** The session sheet: the build, then per item what to check and how to get there. */
|
|
309
309
|
function sessionText(s) {
|
|
310
310
|
const lines = sessionHead(s);
|
|
311
|
-
|
|
311
|
+
const decide = s.decisions.length
|
|
312
|
+
? [
|
|
313
|
+
'',
|
|
314
|
+
'## decide: no verdict, a go or a no; play it first where the build shows it',
|
|
315
|
+
...s.decisions.map(
|
|
316
|
+
(d) => `[ ] ${d.id} [${d.system.join(',')}] ${d.title ?? d.intent ?? ''}`,
|
|
317
|
+
),
|
|
318
|
+
]
|
|
319
|
+
: [];
|
|
320
|
+
if (!s.items.length) return [...lines, 'nothing waits for a playtest', ...decide].join('\n');
|
|
312
321
|
let group = null;
|
|
313
322
|
// Items come sorted by their first subsystem: one header each, so play goes system by system.
|
|
314
323
|
for (const c of s.items) {
|
|
@@ -326,8 +335,10 @@ function sessionText(s) {
|
|
|
326
335
|
|
|
327
336
|
return [
|
|
328
337
|
...lines,
|
|
338
|
+
...decide,
|
|
329
339
|
'',
|
|
330
340
|
'after play: a verdict per item — /gcf-verdict <id> keep|tune|kill;',
|
|
341
|
+
...(s.decisions.length ? ['a decision — /gcf-verdict <id> go|no;'] : []),
|
|
331
342
|
'free notes — ai-factory gcf inbox add --source playtest "<note>"',
|
|
332
343
|
].join('\n');
|
|
333
344
|
}
|
|
@@ -846,7 +857,15 @@ function registerInbox(gcf) {
|
|
|
846
857
|
const { gcfRoot } = await lib('gcf-paths');
|
|
847
858
|
const list = await (await lib('inbox')).listInbox(gcfRoot());
|
|
848
859
|
out(o.json, list, (l) =>
|
|
849
|
-
l.length
|
|
860
|
+
l.length
|
|
861
|
+
? l
|
|
862
|
+
.map((e) => {
|
|
863
|
+
const source = e.kind ? `lesson:${e.kind}:${e.item}` : e.source;
|
|
864
|
+
|
|
865
|
+
return `${e.id} [${source}] ${e.text}`;
|
|
866
|
+
})
|
|
867
|
+
.join('\n')
|
|
868
|
+
: 'inbox empty',
|
|
850
869
|
);
|
|
851
870
|
});
|
|
852
871
|
inbox
|
|
@@ -881,6 +900,46 @@ function registerInbox(gcf) {
|
|
|
881
900
|
});
|
|
882
901
|
}
|
|
883
902
|
|
|
903
|
+
function registerLessons(gcf) {
|
|
904
|
+
const lessons = gcf
|
|
905
|
+
.command('lessons')
|
|
906
|
+
.description("What got in an agent's way, asked at the end of its session (into the inbox)");
|
|
907
|
+
lessons
|
|
908
|
+
.command('prompt <id>')
|
|
909
|
+
.description('Print the end-of-session questions for the agent that worked item <id>')
|
|
910
|
+
.option('--json', 'machine output: {off, prompt}, the prompt asking for a JSON reply')
|
|
911
|
+
.action(async (id, o) => {
|
|
912
|
+
const r = await (
|
|
913
|
+
await lib('lessons')
|
|
914
|
+
).lessonsPrompt(process.cwd(), id, { reply: o.json ? 'json' : 'cli' });
|
|
915
|
+
out(o.json, r, (p) => (p.off ? 'lessons are off (gcf/config.yml lessons.off)' : p.prompt));
|
|
916
|
+
});
|
|
917
|
+
lessons
|
|
918
|
+
.command('add <id>')
|
|
919
|
+
.description('Record lessons of item <id> as inbox entries')
|
|
920
|
+
.option('--kind <kind>', 'tool | rules | framework | template | other')
|
|
921
|
+
.option('--text <text>', 'one finding')
|
|
922
|
+
.option('--where <place>', 'file:line it is about')
|
|
923
|
+
.option('--from <file>', 'a JSON array [{kind, text, where}] instead of --kind/--text')
|
|
924
|
+
.option('--commit', 'commit it (gcf(lessons)): for a caller with no console, e.g. the harness')
|
|
925
|
+
.action(async (id, o) => {
|
|
926
|
+
const { addLessons, lessonsFile, KINDS } = await lib('lessons');
|
|
927
|
+
if (Boolean(o.from) === Boolean(o.text))
|
|
928
|
+
throw new Error('give the lessons once: --from <file>, or --kind and --text');
|
|
929
|
+
if (o.text && !KINDS.includes(o.kind)) throw new Error(`--kind must be ${KINDS.join(' | ')}`);
|
|
930
|
+
const list = o.from
|
|
931
|
+
? await lessonsFile(o.from)
|
|
932
|
+
: [{ kind: o.kind, text: o.text, where: o.where }];
|
|
933
|
+
if (!Array.isArray(list)) throw new Error(`${o.from}: lessons must be a JSON array`);
|
|
934
|
+
if (!list.length) return console.log(`recorded 0 lessons for ${id}`);
|
|
935
|
+
let n = 0;
|
|
936
|
+
const hash = await inboxMove(o.commit, `gcf(lessons): ${id}`, async (root) => {
|
|
937
|
+
n = (await addLessons(root, id, list)).length;
|
|
938
|
+
});
|
|
939
|
+
console.log(`recorded ${n} lessons for ${id}${shortHash(hash)}`);
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
884
943
|
function registerLive(gcf) {
|
|
885
944
|
const live = gcf
|
|
886
945
|
.command('live')
|
|
@@ -977,6 +1036,10 @@ function registerBoard(gcf) {
|
|
|
977
1036
|
].join('\n'),
|
|
978
1037
|
);
|
|
979
1038
|
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** The human's settings a board edits through the core: config keys and agent deny rules. */
|
|
1042
|
+
function registerSettings(gcf) {
|
|
980
1043
|
const config = gcf
|
|
981
1044
|
.command('config')
|
|
982
1045
|
.description("The human's settings of gcf/config.yml: list them, or set one (commit)")
|
|
@@ -1184,11 +1247,13 @@ export function register(program) {
|
|
|
1184
1247
|
registerSession(gcf);
|
|
1185
1248
|
registerDecide(gcf);
|
|
1186
1249
|
registerInbox(gcf);
|
|
1250
|
+
registerLessons(gcf);
|
|
1187
1251
|
registerLive(gcf);
|
|
1188
1252
|
registerPlan(gcf);
|
|
1189
1253
|
registerVerify(gcf);
|
|
1190
1254
|
registerIntegrate(gcf);
|
|
1191
1255
|
registerBoard(gcf);
|
|
1256
|
+
registerSettings(gcf);
|
|
1192
1257
|
registerPhase(gcf);
|
|
1193
1258
|
registerHooksInstall(gcf);
|
|
1194
1259
|
registerLint(gcf);
|
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.
|
|
4
|
+
"version": "0.7.10",
|
|
5
5
|
"description": "Game Context Framework for AI Factory",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills/gcf-init",
|
package/lib/agent-permissions.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import YAML from 'yaml';
|
|
11
10
|
import { commitMove } from './unit-ops.js';
|
|
12
11
|
import { atomicWrite } from './safe-path.js';
|
|
13
12
|
import { gcfRoot } from './gcf-paths.js';
|
|
13
|
+
import { configDoc, ensureMap } from './config-set.js';
|
|
14
|
+
|
|
15
|
+
const GROUPS_SHAPE = 'gcf/config.yml: agent.groups must map a group id to true or false';
|
|
14
16
|
|
|
15
17
|
// A shell command pattern turns into a rule for both shell tools: on Windows the agent runs
|
|
16
18
|
// commands through PowerShell, which Bash rules do not cover.
|
|
@@ -75,7 +77,8 @@ export const DENY_GROUPS = [
|
|
|
75
77
|
{
|
|
76
78
|
id: 'human-moves',
|
|
77
79
|
label:
|
|
78
|
-
"the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, settings,
|
|
80
|
+
"the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, settings, " +
|
|
81
|
+
'permissions, setup',
|
|
79
82
|
on: true,
|
|
80
83
|
rules: shell(
|
|
81
84
|
'ai-factory gcf verdict*',
|
|
@@ -159,12 +162,12 @@ function rules(agent, key) {
|
|
|
159
162
|
*/
|
|
160
163
|
function choices(agent) {
|
|
161
164
|
const g = agent.groups ?? {};
|
|
162
|
-
if (typeof g !== 'object' || Array.isArray(g))
|
|
163
|
-
throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
|
|
165
|
+
if (typeof g !== 'object' || Array.isArray(g)) throw new Error(GROUPS_SHAPE);
|
|
164
166
|
for (const [id, on] of Object.entries(g))
|
|
165
167
|
if (!known(id) || typeof on !== 'boolean')
|
|
166
168
|
throw new Error(
|
|
167
|
-
`gcf/config.yml: agent.groups.${id} must be true or false,
|
|
169
|
+
`gcf/config.yml: agent.groups.${id} must be true or false, ` +
|
|
170
|
+
`for one of ${GROUP_IDS.join(' | ')}`,
|
|
168
171
|
);
|
|
169
172
|
|
|
170
173
|
return g;
|
|
@@ -214,18 +217,9 @@ export async function setPermissions(projectDir, change, { commit = true } = {})
|
|
|
214
217
|
const hash = await commitMove(projectDir, 'gcf(permissions): agent deny rules', {
|
|
215
218
|
files: [file],
|
|
216
219
|
mutate: async () => {
|
|
217
|
-
const doc =
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// A blank `agent:` (or one holding only comments) is no map yet.
|
|
221
|
-
const agent = doc.get('agent', true);
|
|
222
|
-
if (agent != null && !YAML.isMap(agent) && !(YAML.isScalar(agent) && agent.value == null))
|
|
223
|
-
throw new Error('gcf/config.yml: agent must be a mapping');
|
|
224
|
-
if (!YAML.isMap(agent)) doc.set('agent', doc.createNode({}));
|
|
225
|
-
const groups = doc.getIn(['agent', 'groups'], true);
|
|
226
|
-
if (groups != null && !YAML.isMap(groups) && !(YAML.isScalar(groups) && groups.value == null))
|
|
227
|
-
throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
|
|
228
|
-
if (!YAML.isMap(groups)) doc.setIn(['agent', 'groups'], doc.createNode({}));
|
|
220
|
+
const doc = configDoc(existsSync(file) ? await readFile(file, 'utf8') : '');
|
|
221
|
+
ensureMap(doc, ['agent'], 'gcf/config.yml: agent must be a mapping');
|
|
222
|
+
ensureMap(doc, ['agent', 'groups'], GROUPS_SHAPE);
|
|
229
223
|
const current = agentOf({ agent: doc.toJS().agent });
|
|
230
224
|
checkChange(change, current);
|
|
231
225
|
for (const id of change.on ?? []) doc.setIn(['agent', 'groups', id], true);
|
package/lib/board.js
CHANGED
|
@@ -213,6 +213,8 @@ async function itemOf(projectDir, c, verified) {
|
|
|
213
213
|
moves: running ? [] : movesOf(c.data),
|
|
214
214
|
file: `gcf/work/${c.name}`,
|
|
215
215
|
log: logOf(projectDir, id),
|
|
216
|
+
// Filled by board() from the inbox.
|
|
217
|
+
lessons: 0,
|
|
216
218
|
...(await progress(projectDir, c)),
|
|
217
219
|
};
|
|
218
220
|
}
|
|
@@ -224,6 +226,9 @@ export async function board(projectDir) {
|
|
|
224
226
|
const items = [];
|
|
225
227
|
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
|
|
226
228
|
inRunOrder(items);
|
|
229
|
+
const inbox = await listInbox(root);
|
|
230
|
+
// Where agents struggle: the lessons in the inbox each item's sessions left.
|
|
231
|
+
for (const i of items) i.lessons = inbox.filter((e) => e.item === i.id).length;
|
|
227
232
|
|
|
228
233
|
const config = await readConfig(root);
|
|
229
234
|
const live = await readLive(root);
|
|
@@ -231,7 +236,7 @@ export async function board(projectDir) {
|
|
|
231
236
|
return {
|
|
232
237
|
schema: BOARD_SCHEMA,
|
|
233
238
|
run_mode: config.run_mode,
|
|
234
|
-
// What the harness hands its agent runs (Harness-PRD §7):
|
|
239
|
+
// What the harness hands its agent runs (Harness-PRD §7): deny rules and the context limits.
|
|
235
240
|
agent: agentOf(config),
|
|
236
241
|
// The human's settings of config.yml, each with its value and default (`gcf config set`).
|
|
237
242
|
settings: settingsOf(await readRawConfig(root)),
|
|
@@ -248,7 +253,7 @@ export async function board(projectDir) {
|
|
|
248
253
|
),
|
|
249
254
|
items,
|
|
250
255
|
wip: wipOf(ledger.items, config),
|
|
251
|
-
inbox
|
|
256
|
+
inbox,
|
|
252
257
|
questions: await listQuestions(root),
|
|
253
258
|
history: await historyOf(root),
|
|
254
259
|
graveyard: await graveyardOf(root),
|
package/lib/config-set.js
CHANGED
|
@@ -16,13 +16,31 @@ import { configOf, DEFAULT_CONFIG, gcfRoot, RUN_MODES } from './gcf-paths.js';
|
|
|
16
16
|
// Thrown inside the move to leave without a write or a commit.
|
|
17
17
|
const NO_CHANGE = new Error('no change');
|
|
18
18
|
|
|
19
|
+
/** gcf/config.yml as a YAML document with a map on top: an edit keeps its comments. */
|
|
20
|
+
export function configDoc(text) {
|
|
21
|
+
const doc = YAML.parseDocument(text);
|
|
22
|
+
if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
|
|
23
|
+
if (!YAML.isMap(doc.contents)) doc.contents = /** @type {any} */ (doc.createNode({}));
|
|
24
|
+
|
|
25
|
+
return doc;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Make `keys` a map. A blank key (`dod:` with nothing or only comments under it) is none yet. */
|
|
29
|
+
export function ensureMap(doc, keys, error) {
|
|
30
|
+
const node = doc.getIn(keys, true);
|
|
31
|
+
if (YAML.isMap(node)) return;
|
|
32
|
+
if (node != null && !(YAML.isScalar(node) && node.value == null)) throw new Error(error);
|
|
33
|
+
doc.setIn(keys, doc.createNode({}));
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
/** @type {{ key: string, kind: Kind, label: string }[]} */
|
|
20
37
|
export const SETTINGS = [
|
|
21
38
|
{
|
|
22
39
|
key: 'run_mode',
|
|
23
40
|
kind: 'mode',
|
|
24
41
|
label:
|
|
25
|
-
'how a run drives an item: worktree (several at once) or main (one at a time in the
|
|
42
|
+
'how a run drives an item: worktree (several at once) or main (one at a time in the ' +
|
|
43
|
+
'project folder)',
|
|
26
44
|
},
|
|
27
45
|
{ key: 'dod.test', kind: 'command', label: "the project's test command for the DoD, or none" },
|
|
28
46
|
{ key: 'dod.build', kind: 'command', label: "the project's build command for the DoD, or none" },
|
|
@@ -32,6 +50,11 @@ export const SETTINGS = [
|
|
|
32
50
|
label: 'improve passes a plan may take before the item blocks',
|
|
33
51
|
},
|
|
34
52
|
{ key: 'dod_attempts', kind: 'number', label: 'DoD attempts before the item blocks' },
|
|
53
|
+
{
|
|
54
|
+
key: 'dod_timeout_min',
|
|
55
|
+
kind: 'number',
|
|
56
|
+
label: 'minutes a DoD command may take before it is killed (0: no limit)',
|
|
57
|
+
},
|
|
35
58
|
{
|
|
36
59
|
key: 'wip_built_limit',
|
|
37
60
|
kind: 'number',
|
|
@@ -132,21 +155,9 @@ export async function setConfig(projectDir, key, text, { commit = true } = {}) {
|
|
|
132
155
|
files: [file],
|
|
133
156
|
mutate: async () => {
|
|
134
157
|
const text = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
135
|
-
const doc =
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
// A blank parent (`dod:` with nothing under it) is no map yet.
|
|
139
|
-
if (keys.length > 1) {
|
|
140
|
-
const parent = doc.get(keys[0], true);
|
|
141
|
-
if (
|
|
142
|
-
parent != null &&
|
|
143
|
-
!YAML.isMap(parent) &&
|
|
144
|
-
!(YAML.isScalar(parent) && parent.value == null)
|
|
145
|
-
)
|
|
146
|
-
throw new Error(`gcf/config.yml: ${keys[0]} must map its keys`);
|
|
147
|
-
if (!YAML.isMap(parent)) doc.set(keys[0], doc.createNode({}));
|
|
148
|
-
}
|
|
149
|
-
|
|
158
|
+
const doc = configDoc(text);
|
|
159
|
+
if (keys.length > 1)
|
|
160
|
+
ensureMap(doc, [keys[0]], `gcf/config.yml: ${keys[0]} must map its keys`);
|
|
150
161
|
if (value === null) doc.deleteIn(keys);
|
|
151
162
|
else doc.setIn(keys, value);
|
|
152
163
|
raw = doc.toJS();
|
package/lib/gcf-paths.js
CHANGED
|
@@ -14,8 +14,10 @@ export const DEFAULT_CONFIG = {
|
|
|
14
14
|
pillars_budget: 1500,
|
|
15
15
|
envelope_default_spread: 0.3,
|
|
16
16
|
unplayed_warn_after: 5,
|
|
17
|
-
plan_passes:
|
|
17
|
+
plan_passes: 3,
|
|
18
18
|
dod_attempts: 2,
|
|
19
|
+
// Minutes a dod.test or dod.build command may take before it is killed (0: no limit).
|
|
20
|
+
dod_timeout_min: 60,
|
|
19
21
|
// A run hands its item to a fresh session at this share of the context window (0: never);
|
|
20
22
|
// the window comes from the model unless context_window sets it (Harness-PRD §7).
|
|
21
23
|
context_handoff_pct: 60,
|
|
@@ -68,6 +70,7 @@ export function configOf(parsed) {
|
|
|
68
70
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
69
71
|
throw new Error('gcf/config.yml must be a key: value mapping');
|
|
70
72
|
// A blank `key:` parses as null and means "not set", like an absent key.
|
|
73
|
+
/** @type {Record<string, any>} */
|
|
71
74
|
const config = {
|
|
72
75
|
...DEFAULT_CONFIG,
|
|
73
76
|
...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)),
|
package/lib/inbox.js
CHANGED
|
@@ -12,18 +12,27 @@ export const INBOX_HEAD =
|
|
|
12
12
|
'is erased\n';
|
|
13
13
|
export const SOURCES = ['playtest', 'ci', 'agent', 'operator'];
|
|
14
14
|
const LINE = /^- (in-[0-9a-f]+) \| (\S+) \| (.+)$/;
|
|
15
|
+
// An agent's lesson (lessons.js): `lesson:<kind>:<item-id>`.
|
|
16
|
+
const LESSON = /^lesson:([a-z]+):([a-z0-9][a-z0-9-]*)$/;
|
|
15
17
|
|
|
16
18
|
const inboxFile = (root) => path.join(root, 'inbox.md');
|
|
17
19
|
|
|
18
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* 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
|
|
23
|
+
* @returns {Promise<Entry[]>}
|
|
24
|
+
*/
|
|
19
25
|
export async function listInbox(root) {
|
|
20
26
|
const file = inboxFile(root);
|
|
21
27
|
if (!existsSync(file)) return [];
|
|
22
28
|
|
|
23
29
|
return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
|
|
24
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() };
|
|
25
34
|
|
|
26
|
-
return
|
|
35
|
+
return [lesson ? { ...entry, source: 'lesson', kind: lesson[1], item: lesson[2] } : entry];
|
|
27
36
|
});
|
|
28
37
|
}
|
|
29
38
|
|
|
@@ -31,7 +40,8 @@ export async function listInbox(root) {
|
|
|
31
40
|
export async function addInbox(root, text, source = 'operator', now = new Date().toISOString()) {
|
|
32
41
|
const clean = String(text).replace(/\s+/g, ' ').trim();
|
|
33
42
|
if (!clean) throw new Error('an inbox entry needs text');
|
|
34
|
-
if (!SOURCES.includes(source)
|
|
43
|
+
if (!SOURCES.includes(source) && !LESSON.test(source))
|
|
44
|
+
throw new Error(`source must be ${SOURCES.join(' | ')}`);
|
|
35
45
|
const id = `in-${createHash('sha1').update(`${now}\n${clean}`).digest('hex').slice(0, 8)}`;
|
|
36
46
|
const file = inboxFile(root);
|
|
37
47
|
const old = existsSync(file) ? await readFile(file, 'utf8') : INBOX_HEAD;
|
package/lib/lessons.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Lessons (Methodology §5.2.2): at the end of a session on a work item, its agent says what got in
|
|
2
|
+
// its way — a tool, a rule, the framework, the project's template. Each answer is an inbox entry
|
|
3
|
+
// with the source `lesson:<kind>:<item>`, so the operator triages it like any raw input and the
|
|
4
|
+
// board shows where agents struggle. The questions stay out of the agent's working context until
|
|
5
|
+
// the session ends: the harness asks them in one more turn, `/gcf-run` asks them at its close.
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readRawConfig } from './gcf-paths.js';
|
|
9
|
+
import { addInbox } from './inbox.js';
|
|
10
|
+
import { isValidId } from './id.js';
|
|
11
|
+
|
|
12
|
+
export const KINDS = ['tool', 'rules', 'framework', 'template', 'other'];
|
|
13
|
+
|
|
14
|
+
const QUESTIONS = {
|
|
15
|
+
tool:
|
|
16
|
+
'a tool, the editor or the environment behaved wrongly or cost time: what happened, and ' +
|
|
17
|
+
'what was the workaround?',
|
|
18
|
+
rules:
|
|
19
|
+
'a project rule was missing, unclear or contradicted another: name the file and line of ' +
|
|
20
|
+
'each, and what the rule should say',
|
|
21
|
+
framework:
|
|
22
|
+
'GCF got in the way: ai-factory gcf, the gcf-run steps, the harness or its hooks - what ' +
|
|
23
|
+
'cost time, and what would have helped?',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The project's lessons settings from the `lessons:` section of gcf/config.yml: `off`, the
|
|
28
|
+
* project `template` the lessons may feed, and `questions` the operator adds. Throws on a
|
|
29
|
+
* section of the wrong shape.
|
|
30
|
+
*/
|
|
31
|
+
export function lessonsConfig(raw) {
|
|
32
|
+
const l = raw?.lessons ?? {};
|
|
33
|
+
if (typeof l !== 'object' || Array.isArray(l))
|
|
34
|
+
throw new Error('gcf/config.yml: lessons must map off:, template: and questions:');
|
|
35
|
+
const questions = l.questions ?? [];
|
|
36
|
+
if (!Array.isArray(questions) || questions.some((q) => typeof q !== 'string' || !q.trim()))
|
|
37
|
+
throw new Error('gcf/config.yml: lessons.questions must be a list of questions');
|
|
38
|
+
if (l.template != null && typeof l.template !== 'string')
|
|
39
|
+
throw new Error('gcf/config.yml: lessons.template must be a path');
|
|
40
|
+
|
|
41
|
+
return { off: l.off === true, template: l.template?.trim() || null, questions };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The questions the agent answers, each with the kind its answer records as. */
|
|
45
|
+
function questionsOf(config) {
|
|
46
|
+
return [
|
|
47
|
+
...Object.entries(QUESTIONS).map(([kind, text]) => ({ kind, text })),
|
|
48
|
+
...(config.template
|
|
49
|
+
? [
|
|
50
|
+
{
|
|
51
|
+
kind: 'template',
|
|
52
|
+
text:
|
|
53
|
+
`what would the next project made from the template ${config.template} need ` +
|
|
54
|
+
'that this one had to build, fix or work around?',
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
: []),
|
|
58
|
+
...config.questions.map((text) => ({ kind: 'other', text })),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The end-of-session prompt for item `id`. `reply: 'json'` asks for a JSON array (the harness
|
|
64
|
+
* records it); `reply: 'cli'` asks the agent to record each answer with `gcf lessons add`.
|
|
65
|
+
*/
|
|
66
|
+
export async function lessonsPrompt(projectDir, id, { reply = 'cli' } = {}) {
|
|
67
|
+
if (!isValidId(id)) throw new Error(`${id} is not an item id`);
|
|
68
|
+
const config = lessonsConfig(await readRawConfig(path.join(path.resolve(projectDir), 'gcf')));
|
|
69
|
+
const asked = questionsOf(config)
|
|
70
|
+
.map((q, i) => `${i + 1}. ${q.kind}: ${q.text}`)
|
|
71
|
+
.join('\n');
|
|
72
|
+
const how =
|
|
73
|
+
reply === 'json'
|
|
74
|
+
? 'Reply with ONLY a JSON array, no other text: ' +
|
|
75
|
+
'[{"kind": "<the kind of its question>", "text": "<one finding>", ' +
|
|
76
|
+
'"where": "<file:line or null>"}]; ' +
|
|
77
|
+
'reply [] when nothing got in the way.'
|
|
78
|
+
: `Record each finding with: ai-factory gcf lessons add ${id} --kind <the kind of its ` +
|
|
79
|
+
'question> --text "<one finding>" [--where <file:line>]. Record nothing when nothing ' +
|
|
80
|
+
'got in the way.';
|
|
81
|
+
const prompt = [
|
|
82
|
+
`The work on ${id} in this session is over. Before the session ends, tell the operator ` +
|
|
83
|
+
'what got in your way, so it can be removed.',
|
|
84
|
+
'Answer from this session only, one finding per entry, in a sentence or two; skip a ' +
|
|
85
|
+
'question with nothing to say, and never invent one.',
|
|
86
|
+
asked,
|
|
87
|
+
// The harness's turn comes after the item's commit and its verified trailer: an edit then
|
|
88
|
+
// would leave the checkout dirty or move the trailer off the tip.
|
|
89
|
+
reply === 'json'
|
|
90
|
+
? 'Do not edit, write or commit any file in this turn: only answer.'
|
|
91
|
+
: 'If the project keeps its own lessons file (its rules name it), update it now as they say.',
|
|
92
|
+
how,
|
|
93
|
+
].join('\n\n');
|
|
94
|
+
|
|
95
|
+
return { off: config.off, prompt };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A lesson as the agent gave it, checked: `{kind, text, where}`. */
|
|
99
|
+
function lessonOf(raw, i) {
|
|
100
|
+
const kind = KINDS.includes(raw?.kind) ? raw.kind : 'other';
|
|
101
|
+
const text = String(raw?.text ?? '')
|
|
102
|
+
.replace(/\s+/g, ' ')
|
|
103
|
+
.trim();
|
|
104
|
+
if (!text) throw new Error(`lesson ${i + 1} has no text`);
|
|
105
|
+
const where = raw?.where ? String(raw.where).replace(/\s+/g, ' ').trim() : '';
|
|
106
|
+
|
|
107
|
+
return { kind, text: where ? `${text} (${where})` : text };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Record `lessons` of item `id` as inbox entries; `{kind, text, where?}` each, a kind outside
|
|
112
|
+
* KINDS reads as other. Returns the entries written.
|
|
113
|
+
*/
|
|
114
|
+
export async function addLessons(root, id, lessons) {
|
|
115
|
+
if (!isValidId(id)) throw new Error(`${id} is not an item id`);
|
|
116
|
+
if (!Array.isArray(lessons)) throw new Error('lessons must be a JSON array');
|
|
117
|
+
const checked = lessons.map(lessonOf);
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const l of checked) out.push(await addInbox(root, l.text, `lesson:${l.kind}:${id}`));
|
|
120
|
+
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The lessons of a JSON file: an array, as the harness writes it. */
|
|
125
|
+
export async function lessonsFile(file) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
128
|
+
} catch (e) {
|
|
129
|
+
throw new Error(`cannot read lessons from ${file}: ${e.message}`, { cause: e });
|
|
130
|
+
}
|
|
131
|
+
}
|
package/lib/main-lock.js
CHANGED
|
@@ -140,3 +140,14 @@ export async function withMainLock(projectDir, fn, { waitMs = 60000 } = {}) {
|
|
|
140
140
|
if (text.includes(token)) await unlink(file).catch(() => {});
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A committed move holds the main lock from its first read to its commit (Methodology §7): one
|
|
146
|
+
* that read the ledger before taking it would write over a move committed meanwhile.
|
|
147
|
+
* @template T
|
|
148
|
+
* @param {string} projectDir
|
|
149
|
+
* @param {boolean} commit
|
|
150
|
+
* @param {() => Promise<T>} move
|
|
151
|
+
*/
|
|
152
|
+
export const locked = (projectDir, commit, move) =>
|
|
153
|
+
commit ? withMainLock(projectDir, move) : move();
|
package/lib/phase.js
CHANGED
|
@@ -8,6 +8,7 @@ import { readLiveStrict, writeLive, liveFile } from './live.js';
|
|
|
8
8
|
import { buildLedger } from './ledger.js';
|
|
9
9
|
import { ITEM_TYPES } from './validate-item.js';
|
|
10
10
|
import { commitMove } from './unit-ops.js';
|
|
11
|
+
import { locked } from './main-lock.js';
|
|
11
12
|
|
|
12
13
|
const ALL = ITEM_TYPES;
|
|
13
14
|
const SOFT = ['mechanic', 'resource'];
|
|
@@ -206,8 +207,12 @@ export async function phaseStatus(projectDir) {
|
|
|
206
207
|
* The operator moves the phase on, commit `gcf(phase): <from> → <to>`. An unmet criterion needs
|
|
207
208
|
* `reason`, which the commit carries; the move itself is the operator's word on one that only
|
|
208
209
|
* the operator can check.
|
|
210
|
+
* @type {typeof nextPhaseUnlocked}
|
|
209
211
|
*/
|
|
210
|
-
export
|
|
212
|
+
export const nextPhase = (projectDir, opts = {}) =>
|
|
213
|
+
locked(projectDir, opts.commit ?? true, () => nextPhaseUnlocked(projectDir, opts));
|
|
214
|
+
|
|
215
|
+
async function nextPhaseUnlocked(projectDir, { reason = '', commit = true } = {}) {
|
|
211
216
|
const status = await phaseStatus(projectDir);
|
|
212
217
|
if (!status.next) throw new Error(`${status.phase} is the last phase`);
|
|
213
218
|
const unmet = status.criteria.filter((c) => c.met === false);
|
package/lib/pillars.js
CHANGED
|
@@ -10,6 +10,7 @@ import { recordsIn } from './design-check.js';
|
|
|
10
10
|
import { buildLedger } from './ledger.js';
|
|
11
11
|
import { atomicWrite } from './safe-path.js';
|
|
12
12
|
import { commitMove } from './unit-ops.js';
|
|
13
|
+
import { locked } from './main-lock.js';
|
|
13
14
|
|
|
14
15
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
16
|
|
|
@@ -73,7 +74,7 @@ async function revise(projectDir, id, { edit, editPillars, tail, commit }) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** `pillar rename <from> <to>`: the id changes in PILLARS.md and every unit and item citing it. */
|
|
76
|
-
|
|
77
|
+
async function renamePillarUnlocked(projectDir, from, to, { commit = true } = {}) {
|
|
77
78
|
if (!SLUG_RE.test(to)) throw new Error(`${to}: a pillar id is a lowercase hyphen slug`);
|
|
78
79
|
const text = await readFile(path.join(projectDir, 'gcf', 'PILLARS.md'), 'utf8').catch(() => '');
|
|
79
80
|
if (recordsIn(text, 'pillars').some((p) => p.id === to))
|
|
@@ -92,7 +93,7 @@ export async function renamePillar(projectDir, from, to, { commit = true } = {})
|
|
|
92
93
|
* unit left citing no pillar reads `pillars: TBD` again — it is not ready until the human gives
|
|
93
94
|
* it one, or decides it is out of scope. The reason is kept in the commit.
|
|
94
95
|
*/
|
|
95
|
-
|
|
96
|
+
async function retirePillarUnlocked(projectDir, id, { reason, commit = true }) {
|
|
96
97
|
const why = String(reason ?? '')
|
|
97
98
|
.replace(/\s+/g, ' ')
|
|
98
99
|
.trim();
|
|
@@ -111,3 +112,11 @@ export async function retirePillar(projectDir, id, { reason, commit = true }) {
|
|
|
111
112
|
commit,
|
|
112
113
|
});
|
|
113
114
|
}
|
|
115
|
+
|
|
116
|
+
// PILLARS.md and the citing files are read for the edit: the lock holds from that read on.
|
|
117
|
+
/** @type {typeof renamePillarUnlocked} */
|
|
118
|
+
export const renamePillar = (projectDir, from, to, opts = {}) =>
|
|
119
|
+
locked(projectDir, opts.commit ?? true, () => renamePillarUnlocked(projectDir, from, to, opts));
|
|
120
|
+
/** @type {typeof retirePillarUnlocked} */
|
|
121
|
+
export const retirePillar = (projectDir, id, opts) =>
|
|
122
|
+
locked(projectDir, opts.commit ?? true, () => retirePillarUnlocked(projectDir, id, opts));
|
package/lib/playtest-list.js
CHANGED
|
@@ -41,6 +41,19 @@ export async function playtestList(root) {
|
|
|
41
41
|
}));
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Items awaiting a decision: no verdict, but the human may need the build to make it. */
|
|
45
|
+
async function decisionList(root) {
|
|
46
|
+
return (await listItems(root))
|
|
47
|
+
.filter((c) => c.data.status === 'awaiting-decision')
|
|
48
|
+
.map((c) => ({
|
|
49
|
+
id: c.data.id,
|
|
50
|
+
title: c.data.title ?? null,
|
|
51
|
+
system: c.data.system ?? [],
|
|
52
|
+
intent: firstBullet(c.body, 'intent') || null,
|
|
53
|
+
}))
|
|
54
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
const git = (projectDir, args) => {
|
|
45
58
|
const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
|
|
46
59
|
|
|
@@ -73,10 +86,11 @@ export async function playtestSession(
|
|
|
73
86
|
commit: git(projectDir, ['rev-parse', '--short', 'HEAD']),
|
|
74
87
|
// No commit (no repository, or none yet): the build is unknown, not clean.
|
|
75
88
|
dirty: dirty === null ? null : Boolean(dirty),
|
|
76
|
-
build: /** @type {ReturnType<typeof runDod
|
|
89
|
+
build: /** @type {Awaited<ReturnType<typeof runDod>> | null} */ (null),
|
|
77
90
|
items,
|
|
91
|
+
decisions: await decisionList(root),
|
|
78
92
|
};
|
|
79
|
-
if (build) session.build = runDod(projectDir, await readConfig(root), 'build', stdio);
|
|
93
|
+
if (build) session.build = await runDod(projectDir, await readConfig(root), 'build', stdio);
|
|
80
94
|
|
|
81
95
|
return session;
|
|
82
96
|
}
|
package/lib/question-fields.js
CHANGED
package/lib/questions.js
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { atomicWrite } from './safe-path.js';
|
|
9
9
|
import { commitMove } from './unit-ops.js';
|
|
10
|
+
import { locked } from './main-lock.js';
|
|
10
11
|
import { blockEnd, fieldOf, indentOf } from './question-fields.js';
|
|
11
12
|
|
|
12
13
|
// A D2 carries its assumption and the trigger that brings it back; `lens`, `sections` and `sys`
|
|
@@ -93,8 +94,12 @@ function withAnswer(record, answer) {
|
|
|
93
94
|
/**
|
|
94
95
|
* The human answers question `qid` (`gcf question answer`): the answer goes into its record, one
|
|
95
96
|
* line, and commits as `gcf(answer): <qid>`. Refuses an id the file does not hold.
|
|
97
|
+
* @type {typeof answerUnlocked}
|
|
96
98
|
*/
|
|
97
|
-
export
|
|
99
|
+
export const answerQuestion = (projectDir, qid, answer, opts = {}) =>
|
|
100
|
+
locked(projectDir, opts.commit ?? true, () => answerUnlocked(projectDir, qid, answer, opts));
|
|
101
|
+
|
|
102
|
+
async function answerUnlocked(projectDir, qid, answer, { commit = true } = {}) {
|
|
98
103
|
const text = String(answer ?? '')
|
|
99
104
|
.replace(/\s+/g, ' ')
|
|
100
105
|
.trim();
|
package/lib/unit-ops.js
CHANGED
|
@@ -16,7 +16,7 @@ import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
|
16
16
|
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
17
|
import { DEFAULT_GATE } from './validate-item.js';
|
|
18
18
|
import { refuseByPhase } from './phase.js';
|
|
19
|
-
import { withMainLock } from './main-lock.js';
|
|
19
|
+
import { locked, withMainLock } from './main-lock.js';
|
|
20
20
|
|
|
21
21
|
/** A design file's text without the `## <id>` section. */
|
|
22
22
|
export function removeUnit(text, id) {
|
|
@@ -265,8 +265,12 @@ async function record(projectDir, message, { files, mutate, commit, inputs = []
|
|
|
265
265
|
/**
|
|
266
266
|
* A hard unit is built and its tests are green: it leaves design/ without a work item. A line
|
|
267
267
|
* every future reader of the subsystem must remember goes to the systems doc's `constraints:`.
|
|
268
|
+
* @type {typeof doneUnitUnlocked}
|
|
268
269
|
*/
|
|
269
|
-
export
|
|
270
|
+
export const doneUnit = (projectDir, id, opts = {}) =>
|
|
271
|
+
locked(projectDir, opts.commit ?? true, () => doneUnitUnlocked(projectDir, id, opts));
|
|
272
|
+
|
|
273
|
+
async function doneUnitUnlocked(projectDir, id, { constraints = [], commit = true } = {}) {
|
|
270
274
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
271
275
|
const unit = await designUnit(projectDir, id);
|
|
272
276
|
if (CARD_TYPES.includes(unit.header.type))
|
|
@@ -300,8 +304,14 @@ export async function addConstraints(doc, id, constraints) {
|
|
|
300
304
|
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
|
|
301
305
|
}
|
|
302
306
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
307
|
+
/**
|
|
308
|
+
* A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9).
|
|
309
|
+
* @type {typeof dropUnitUnlocked}
|
|
310
|
+
*/
|
|
311
|
+
export const dropUnit = (projectDir, id, opts) =>
|
|
312
|
+
locked(projectDir, opts.commit ?? true, () => dropUnitUnlocked(projectDir, id, opts));
|
|
313
|
+
|
|
314
|
+
async function dropUnitUnlocked(projectDir, id, { reason, tags = [], commit = true }) {
|
|
305
315
|
if (!String(reason ?? '').trim())
|
|
306
316
|
throw new Error('a drop needs a reason: the graveyard keeps it');
|
|
307
317
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
package/lib/verify.js
CHANGED
|
@@ -1,15 +1,111 @@
|
|
|
1
1
|
// `gcf verify <id>` (Methodology §6): the auto-DoD checks code can run — the GCF checks of the
|
|
2
2
|
// pre-commit gate, the item's live: delta against LIVE.md, then the project's `dod.build` and
|
|
3
3
|
// `dod.test`. The second-agent review is the run's job; the attempt count lives in the run.
|
|
4
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { readConfig } from './gcf-paths.js';
|
|
7
8
|
import { readLive, applyDelta } from './live.js';
|
|
8
9
|
import { gateProblems } from './gate.js';
|
|
9
10
|
import { unplayedCloseProblem } from './work-ops.js';
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Every process's parent, `[pid, ppid]` (POSIX): from /proc on Linux, which a slim image has
|
|
14
|
+
* with no procps; from `ps` elsewhere.
|
|
15
|
+
*/
|
|
16
|
+
function parents() {
|
|
17
|
+
if (existsSync('/proc/self/stat'))
|
|
18
|
+
return readdirSync('/proc')
|
|
19
|
+
.filter((d) => /^\d+$/.test(d))
|
|
20
|
+
.flatMap((d) => {
|
|
21
|
+
try {
|
|
22
|
+
// `pid (comm) state ppid …`: comm may hold spaces and parentheses.
|
|
23
|
+
const stat = readFileSync(`/proc/${d}/stat`, 'utf8');
|
|
24
|
+
|
|
25
|
+
return [[Number(d), Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1])]];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const res = spawnSync('ps', ['-A', '-o', 'pid=,ppid='], { encoding: 'utf8' });
|
|
31
|
+
|
|
32
|
+
return (res.stdout ?? '')
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((l) => l.trim().split(/\s+/).map(Number))
|
|
35
|
+
.filter((p) => p.length === 2 && p.every(Number.isFinite));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stop `p` where it is, or kill it; a process gone already is no error. */
|
|
39
|
+
const signal = (p, sig) => {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(p, sig);
|
|
42
|
+
} catch {
|
|
43
|
+
// Gone already.
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* End a command with everything it started: an engine the shell spawned outlives the shell. The
|
|
49
|
+
* command stays in its caller's process group, so a harness that kills its session's group
|
|
50
|
+
* kills the command too. On POSIX each process found is stopped first, so none forks a child the
|
|
51
|
+
* walk would miss; then all are killed.
|
|
52
|
+
* shortcut: a grandchild whose parent exited before the walk is init's and is not found; a
|
|
53
|
+
* process group of its own per command would catch it, at the cost of the harness's group kill.
|
|
54
|
+
*/
|
|
55
|
+
function killTree(pid) {
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
58
|
+
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const doomed = new Set([pid]);
|
|
63
|
+
// finally: a walk that throws (an unreadable /proc) must not leave what it stopped frozen.
|
|
64
|
+
try {
|
|
65
|
+
while (stopChildren(doomed));
|
|
66
|
+
} finally {
|
|
67
|
+
for (const p of doomed) signal(p, 'SIGKILL');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stop every process of `doomed`, then add their children; true when one was added. */
|
|
72
|
+
function stopChildren(doomed) {
|
|
73
|
+
let grew = false;
|
|
74
|
+
for (const p of doomed) signal(p, 'SIGSTOP');
|
|
75
|
+
for (const [p, parent] of parents())
|
|
76
|
+
if (doomed.has(parent) && !doomed.has(p)) grew = Boolean(doomed.add(p));
|
|
77
|
+
|
|
78
|
+
return grew;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run `cmd` in a shell; past `ms` (0: never) it is killed with its whole tree. `exit`, not
|
|
83
|
+
* `close`: a helper the command left running may hold the inherited stdio open.
|
|
84
|
+
*/
|
|
85
|
+
function runCommand(cmd, cwd, stdio, ms) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const child = spawn(cmd, { cwd, shell: true, stdio });
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
const timer =
|
|
90
|
+
ms > 0
|
|
91
|
+
? setTimeout(() => {
|
|
92
|
+
timedOut = true;
|
|
93
|
+
killTree(child.pid);
|
|
94
|
+
}, ms)
|
|
95
|
+
: null;
|
|
96
|
+
child.on('error', (error) => {
|
|
97
|
+
clearTimeout(timer ?? undefined);
|
|
98
|
+
resolve({ error, timedOut });
|
|
99
|
+
});
|
|
100
|
+
child.on('exit', (status, signal) => {
|
|
101
|
+
clearTimeout(timer ?? undefined);
|
|
102
|
+
resolve({ status, signal, timedOut });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
11
107
|
/** Run a `dod.<key>` command in the project; `none` says the project has no such step. */
|
|
12
|
-
export function runDod(projectDir, config, key, stdio) {
|
|
108
|
+
export async function runDod(projectDir, config, key, stdio) {
|
|
13
109
|
if (config.dod !== undefined && (typeof config.dod !== 'object' || Array.isArray(config.dod)))
|
|
14
110
|
return { name: key, ok: false, problems: ['dod in gcf/config.yml must map test: and build:'] };
|
|
15
111
|
const cmd = config.dod?.[key];
|
|
@@ -20,12 +116,26 @@ export function runDod(projectDir, config, key, stdio) {
|
|
|
20
116
|
ok: false,
|
|
21
117
|
problems: [`dod.${key} is not set in gcf/config.yml — the project's command, or none`],
|
|
22
118
|
};
|
|
23
|
-
//
|
|
24
|
-
const
|
|
25
|
-
const
|
|
119
|
+
// stdin is closed; a command that never ends (a hung engine, a watch mode) meets the timeout.
|
|
120
|
+
const minutes = config.dod_timeout_min ?? 0;
|
|
121
|
+
const problem = failureOf(
|
|
122
|
+
key,
|
|
123
|
+
cmd,
|
|
124
|
+
minutes,
|
|
125
|
+
await runCommand(cmd, projectDir, stdio, minutes * 60000),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return { name: key, ok: !problem, problems: problem ? [problem] : [] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Why a finished DoD command failed, or null when it passed. */
|
|
132
|
+
function failureOf(key, cmd, minutes, res) {
|
|
133
|
+
if (res.timedOut)
|
|
134
|
+
return `dod.${key} timed out after ${minutes} min (dod_timeout_min), killed: ${cmd}`;
|
|
135
|
+
if (res.status === 0) return null;
|
|
26
136
|
const why = res.error ? res.error.message : `exit ${res.status ?? res.signal}`;
|
|
27
137
|
|
|
28
|
-
return
|
|
138
|
+
return `dod.${key} failed (${why}): ${cmd}`;
|
|
29
139
|
}
|
|
30
140
|
|
|
31
141
|
/**
|
|
@@ -64,8 +174,8 @@ export async function verifyItem(projectDir, id, { stdio = ['ignore', 2, 2] } =
|
|
|
64
174
|
const checks = [
|
|
65
175
|
{ name: 'gcf', ok: !problems.length, problems },
|
|
66
176
|
{ name: 'live', ok: !live.length, problems: live },
|
|
67
|
-
runDod(projectDir, config, 'build', stdio),
|
|
68
|
-
runDod(projectDir, config, 'test', stdio),
|
|
177
|
+
await runDod(projectDir, config, 'build', stdio),
|
|
178
|
+
await runDod(projectDir, config, 'test', stdio),
|
|
69
179
|
];
|
|
70
180
|
|
|
71
181
|
return { id, checks, ok: checks.every((c) => c.ok) };
|
package/lib/work-ops.js
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
} from './live.js';
|
|
38
38
|
import { dropInbox } from './inbox.js';
|
|
39
39
|
import { refuseByPhase } from './phase.js';
|
|
40
|
-
import {
|
|
40
|
+
import { locked, runHolder } from './main-lock.js';
|
|
41
41
|
import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -905,6 +905,8 @@ async function prioritizeItemsUnlocked(projectDir, ids, { commit = true } = {})
|
|
|
905
905
|
});
|
|
906
906
|
const hash = await commitMove(projectDir, `gcf(priority): ${ids.join(', ')}`, {
|
|
907
907
|
files: items.map((i) => i.file),
|
|
908
|
+
// An item made with `item new` and never committed rides along: all of it is new work.
|
|
909
|
+
inputs: items.map((i) => i.file).filter((f) => !tracked(projectDir, f)),
|
|
908
910
|
mutate: async () => {
|
|
909
911
|
for (const [n, i] of items.entries())
|
|
910
912
|
await atomicWrite(i.file, serialize({ ...i.data, priority: ranks[n] }, i.body));
|
|
@@ -954,16 +956,6 @@ async function newItemUnlocked(projectDir, spec) {
|
|
|
954
956
|
return { item: `gcf/work/${spec.id}.md`, hash };
|
|
955
957
|
}
|
|
956
958
|
|
|
957
|
-
/**
|
|
958
|
-
* A committed move holds the main lock from its first read to its commit (Methodology §7): one
|
|
959
|
-
* that read the ledger before taking it would write over a move committed meanwhile.
|
|
960
|
-
* @template T
|
|
961
|
-
* @param {string} projectDir
|
|
962
|
-
* @param {boolean} commit
|
|
963
|
-
* @param {() => Promise<T>} move
|
|
964
|
-
*/
|
|
965
|
-
const locked = (projectDir, commit, move) => (commit ? withMainLock(projectDir, move) : move());
|
|
966
|
-
|
|
967
959
|
/** @type {typeof submitItemUnlocked} */
|
|
968
960
|
export const submitItem = (projectDir, id, opts = {}) =>
|
|
969
961
|
locked(projectDir, opts.commit ?? true, () => submitItemUnlocked(projectDir, id, opts));
|
package/package.json
CHANGED
|
@@ -23,13 +23,14 @@ forbidden_behaviors:
|
|
|
23
23
|
workflow:
|
|
24
24
|
1. run `ai-factory gcf inbox --json`; read `gcf/LIVE.md` and `gcf/INDEX.md` to know what exists and what is in work
|
|
25
25
|
2. classify each entry: bug, tune of a live or unplayed item, idea for the design, request (an asset file, a question), duplicate, or noise; search `gcf/graveyard.md` for each idea and bug
|
|
26
|
-
3.
|
|
27
|
-
4.
|
|
28
|
-
5.
|
|
29
|
-
6. a
|
|
30
|
-
7. an
|
|
31
|
-
8.
|
|
32
|
-
9.
|
|
26
|
+
3. a lesson (`source: lesson`, with its `kind` and the `item` it came from) is what got in an agent's way: propose the fix it asks for - a project rule, a tech item, a note for the framework or the template - or noise
|
|
27
|
+
4. for each entry propose one call with its fields: a bug gets type `bug`, severity and a repro; a tune names the item and the note; an idea names the subsystem; a duplicate names the item it repeats
|
|
28
|
+
5. present the calls to the human, calls per message <= 5, and apply only the approved ones
|
|
29
|
+
6. a bug or other work: run `ai-factory gcf item new <id> --type <type> [--severity <level>] --from <in-id>`, then write its `intent:` and `repro:`
|
|
30
|
+
7. a tune of an item waiting for its verdict: keep the note for the verdict session and run `ai-factory gcf inbox drop <in-id>`; the note goes in through `/gcf-verdict … tune --note`
|
|
31
|
+
8. an idea for the design: write a unit with `TBD` slots in `gcf/design/<system>.md` and its backlog line, then run `ai-factory gcf inbox drop <in-id>`
|
|
32
|
+
9. a duplicate or noise: run `ai-factory gcf inbox drop <in-id>`
|
|
33
|
+
10. report what each entry became and how many entries remain
|
|
33
34
|
|
|
34
35
|
invocation:
|
|
35
36
|
- run after a playtest session, after a CI run that added entries, or when the user asks to triage
|
package/templates/config.yml
CHANGED
|
@@ -7,14 +7,25 @@ context_budget: 30000
|
|
|
7
7
|
pillars_budget: 1500
|
|
8
8
|
envelope_default_spread: 0.3
|
|
9
9
|
unplayed_warn_after: 5
|
|
10
|
-
plan_passes:
|
|
10
|
+
plan_passes: 3
|
|
11
11
|
dod_attempts: 2
|
|
12
|
+
# Minutes a dod.test or dod.build command may take before it is killed with all it started
|
|
13
|
+
# (0: no limit): a hung engine fails the DoD instead of holding the run.
|
|
14
|
+
dod_timeout_min: 60
|
|
12
15
|
# Agent runs (Harness-PRD §7): the share of the context window at which a run hands its item
|
|
13
16
|
# to a fresh session (0: never), the window when the model's is unknown (0: the model's), and
|
|
14
17
|
# the agent's auto-compaction share (0: its default; at least the handoff share + 15).
|
|
15
18
|
context_handoff_pct: 60
|
|
16
19
|
context_window: 0
|
|
17
20
|
auto_compact_pct: 0
|
|
21
|
+
# Lessons (Methodology §5.2.2): at the end of each session on an item its agent says what got in
|
|
22
|
+
# its way, into the inbox. `ai-factory gcf lessons prompt <id>` prints the questions. Uncomment to
|
|
23
|
+
# turn them off, to ask what the project's template should learn, or to add questions:
|
|
24
|
+
# lessons:
|
|
25
|
+
# off: true
|
|
26
|
+
# template: ../my-template
|
|
27
|
+
# questions:
|
|
28
|
+
# - which step of this item took the longest, and why?
|
|
18
29
|
# What agent runs may not do lives in an `agent:` section that `ai-factory gcf permissions`
|
|
19
30
|
# and the board write; without it the default deny groups hold.
|
|
20
31
|
# The project's own commands for the auto DoD (Methodology §6); the engine does not matter.
|