gcf-aif-ext 0.7.9 → 0.7.13
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 +223 -207
- package/commands/gcf.js +107 -4
- package/extension.json +1 -1
- package/injections/commit-status.md +10 -9
- package/lib/board.js +54 -4
- package/lib/config-set.js +5 -0
- package/lib/gcf-paths.js +3 -1
- package/lib/inbox.js +13 -3
- package/lib/integrate.js +23 -12
- package/lib/lessons.js +131 -0
- package/lib/metrics.js +41 -0
- package/lib/playtest-list.js +16 -2
- package/lib/verify-stamp.js +102 -0
- package/lib/verify.js +118 -9
- package/lib/work-ops.js +36 -2
- package/package.json +1 -1
- package/skills/gcf-triage/SKILL.md +8 -7
- package/templates/agents-block.md +42 -40
- package/templates/config.yml +12 -1
package/commands/gcf.js
CHANGED
|
@@ -19,6 +19,23 @@ const out = (json, value, human) =>
|
|
|
19
19
|
const HARNESS = 'gcf-harness-aif-ext';
|
|
20
20
|
|
|
21
21
|
const CLAIMED = 'the agent session that claimed the item (gcf claim) records its own move';
|
|
22
|
+
/** close and submit from a command: they need a green verify unless told why not. */
|
|
23
|
+
const verifiedMove = (o) => ({
|
|
24
|
+
commit: o.commit,
|
|
25
|
+
requireVerify: true,
|
|
26
|
+
unverified: o.unverified === undefined ? undefined : reasonOf(o.unverified),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** The reason of `--unverified`, one line in the commit's subject; an empty one is no reason. */
|
|
30
|
+
function reasonOf(text) {
|
|
31
|
+
const why = String(text).replace(/\s+/g, ' ').trim();
|
|
32
|
+
if (!why) throw new Error('--unverified needs the reason the move goes without a green verify');
|
|
33
|
+
|
|
34
|
+
return why;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const UNVERIFIED =
|
|
38
|
+
'move without a green gcf verify of the code as it is now; the reason goes into the commit';
|
|
22
39
|
|
|
23
40
|
/** `--claimed`: this move comes from the session that holds the item's mark. */
|
|
24
41
|
const claimedBy = (o, id) => {
|
|
@@ -308,7 +325,16 @@ function sessionHead(s) {
|
|
|
308
325
|
/** The session sheet: the build, then per item what to check and how to get there. */
|
|
309
326
|
function sessionText(s) {
|
|
310
327
|
const lines = sessionHead(s);
|
|
311
|
-
|
|
328
|
+
const decide = s.decisions.length
|
|
329
|
+
? [
|
|
330
|
+
'',
|
|
331
|
+
'## decide: no verdict, a go or a no; play it first where the build shows it',
|
|
332
|
+
...s.decisions.map(
|
|
333
|
+
(d) => `[ ] ${d.id} [${d.system.join(',')}] ${d.title ?? d.intent ?? ''}`,
|
|
334
|
+
),
|
|
335
|
+
]
|
|
336
|
+
: [];
|
|
337
|
+
if (!s.items.length) return [...lines, 'nothing waits for a playtest', ...decide].join('\n');
|
|
312
338
|
let group = null;
|
|
313
339
|
// Items come sorted by their first subsystem: one header each, so play goes system by system.
|
|
314
340
|
for (const c of s.items) {
|
|
@@ -326,8 +352,10 @@ function sessionText(s) {
|
|
|
326
352
|
|
|
327
353
|
return [
|
|
328
354
|
...lines,
|
|
355
|
+
...decide,
|
|
329
356
|
'',
|
|
330
357
|
'after play: a verdict per item — /gcf-verdict <id> keep|tune|kill;',
|
|
358
|
+
...(s.decisions.length ? ['a decision — /gcf-verdict <id> go|no;'] : []),
|
|
331
359
|
'free notes — ai-factory gcf inbox add --source playtest "<note>"',
|
|
332
360
|
].join('\n');
|
|
333
361
|
}
|
|
@@ -736,10 +764,11 @@ function registerItemMoves(gcf) {
|
|
|
736
764
|
.command('submit <id>')
|
|
737
765
|
.description('ready → awaiting-verdict / awaiting-decision (commit gcf(submit))')
|
|
738
766
|
.option('--claimed', CLAIMED)
|
|
767
|
+
.option('--unverified <why>', UNVERIFIED)
|
|
739
768
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
740
769
|
.action(async (id, o) => {
|
|
741
770
|
claimedBy(o, id);
|
|
742
|
-
const r = await (await ops()).submitItem(process.cwd(), id,
|
|
771
|
+
const r = await (await ops()).submitItem(process.cwd(), id, verifiedMove(o));
|
|
743
772
|
console.log(`${r.id} → ${r.status}${shortHash(r.hash)}`);
|
|
744
773
|
});
|
|
745
774
|
gcf
|
|
@@ -752,12 +781,13 @@ function registerItemMoves(gcf) {
|
|
|
752
781
|
[],
|
|
753
782
|
)
|
|
754
783
|
.option('--claimed', CLAIMED)
|
|
784
|
+
.option('--unverified <why>', UNVERIFIED)
|
|
755
785
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
756
786
|
.action(async (id, o) => {
|
|
757
787
|
claimedBy(o, id);
|
|
758
788
|
const r = await (
|
|
759
789
|
await ops()
|
|
760
|
-
).closeItem(process.cwd(), id, {
|
|
790
|
+
).closeItem(process.cwd(), id, { ...verifiedMove(o), constraints: o.constraint });
|
|
761
791
|
console.log(`closed ${r.id}${r.unplayed ? ' → unplayed' : ''}${shortHash(r.hash)}`);
|
|
762
792
|
});
|
|
763
793
|
}
|
|
@@ -846,7 +876,15 @@ function registerInbox(gcf) {
|
|
|
846
876
|
const { gcfRoot } = await lib('gcf-paths');
|
|
847
877
|
const list = await (await lib('inbox')).listInbox(gcfRoot());
|
|
848
878
|
out(o.json, list, (l) =>
|
|
849
|
-
l.length
|
|
879
|
+
l.length
|
|
880
|
+
? l
|
|
881
|
+
.map((e) => {
|
|
882
|
+
const source = e.kind ? `lesson:${e.kind}:${e.item}` : e.source;
|
|
883
|
+
|
|
884
|
+
return `${e.id} [${source}] ${e.text}`;
|
|
885
|
+
})
|
|
886
|
+
.join('\n')
|
|
887
|
+
: 'inbox empty',
|
|
850
888
|
);
|
|
851
889
|
});
|
|
852
890
|
inbox
|
|
@@ -881,6 +919,46 @@ function registerInbox(gcf) {
|
|
|
881
919
|
});
|
|
882
920
|
}
|
|
883
921
|
|
|
922
|
+
function registerLessons(gcf) {
|
|
923
|
+
const lessons = gcf
|
|
924
|
+
.command('lessons')
|
|
925
|
+
.description("What got in an agent's way, asked at the end of its session (into the inbox)");
|
|
926
|
+
lessons
|
|
927
|
+
.command('prompt <id>')
|
|
928
|
+
.description('Print the end-of-session questions for the agent that worked item <id>')
|
|
929
|
+
.option('--json', 'machine output: {off, prompt}, the prompt asking for a JSON reply')
|
|
930
|
+
.action(async (id, o) => {
|
|
931
|
+
const r = await (
|
|
932
|
+
await lib('lessons')
|
|
933
|
+
).lessonsPrompt(process.cwd(), id, { reply: o.json ? 'json' : 'cli' });
|
|
934
|
+
out(o.json, r, (p) => (p.off ? 'lessons are off (gcf/config.yml lessons.off)' : p.prompt));
|
|
935
|
+
});
|
|
936
|
+
lessons
|
|
937
|
+
.command('add <id>')
|
|
938
|
+
.description('Record lessons of item <id> as inbox entries')
|
|
939
|
+
.option('--kind <kind>', 'tool | rules | framework | template | other')
|
|
940
|
+
.option('--text <text>', 'one finding')
|
|
941
|
+
.option('--where <place>', 'file:line it is about')
|
|
942
|
+
.option('--from <file>', 'a JSON array [{kind, text, where}] instead of --kind/--text')
|
|
943
|
+
.option('--commit', 'commit it (gcf(lessons)): for a caller with no console, e.g. the harness')
|
|
944
|
+
.action(async (id, o) => {
|
|
945
|
+
const { addLessons, lessonsFile, KINDS } = await lib('lessons');
|
|
946
|
+
if (Boolean(o.from) === Boolean(o.text))
|
|
947
|
+
throw new Error('give the lessons once: --from <file>, or --kind and --text');
|
|
948
|
+
if (o.text && !KINDS.includes(o.kind)) throw new Error(`--kind must be ${KINDS.join(' | ')}`);
|
|
949
|
+
const list = o.from
|
|
950
|
+
? await lessonsFile(o.from)
|
|
951
|
+
: [{ kind: o.kind, text: o.text, where: o.where }];
|
|
952
|
+
if (!Array.isArray(list)) throw new Error(`${o.from}: lessons must be a JSON array`);
|
|
953
|
+
if (!list.length) return console.log(`recorded 0 lessons for ${id}`);
|
|
954
|
+
let n = 0;
|
|
955
|
+
const hash = await inboxMove(o.commit, `gcf(lessons): ${id}`, async (root) => {
|
|
956
|
+
n = (await addLessons(root, id, list)).length;
|
|
957
|
+
});
|
|
958
|
+
console.log(`recorded ${n} lessons for ${id}${shortHash(hash)}`);
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
884
962
|
function registerLive(gcf) {
|
|
885
963
|
const live = gcf
|
|
886
964
|
.command('live')
|
|
@@ -923,6 +1001,10 @@ function registerPlan(gcf) {
|
|
|
923
1001
|
.action(async (id, o) => {
|
|
924
1002
|
await touchSession(id);
|
|
925
1003
|
const r = await (await lib('plan-check')).planCheck(process.cwd(), id);
|
|
1004
|
+
// Harness-PRD §11: the checks a plan took to converge.
|
|
1005
|
+
await (
|
|
1006
|
+
await lib('metrics')
|
|
1007
|
+
).logMetric(process.cwd(), 'plan-log.csv', [id, r.ok ? 1 : 0, r.problems?.length ?? 0]);
|
|
926
1008
|
out(o.json, r, (x) =>
|
|
927
1009
|
x.ok ? `${x.plan}: ${x.done}/${x.tasks} tasks done` : x.problems.join('\n'),
|
|
928
1010
|
);
|
|
@@ -941,6 +1023,26 @@ function registerVerify(gcf) {
|
|
|
941
1023
|
const r = o.mark
|
|
942
1024
|
? await (await lib('integrate')).markVerified(process.cwd(), id)
|
|
943
1025
|
: await (await lib('verify')).verifyItem(process.cwd(), id);
|
|
1026
|
+
// Methodology §6: a green DoD on main is what close and submit ask for, for this code.
|
|
1027
|
+
// A record git cannot write leaves the DoD unproven for the gate: the verify fails.
|
|
1028
|
+
if (r.ok && !o.mark)
|
|
1029
|
+
try {
|
|
1030
|
+
(await lib('verify-stamp')).stampVerified(process.cwd(), id);
|
|
1031
|
+
} catch (e) {
|
|
1032
|
+
r.ok = false;
|
|
1033
|
+
r.checks.push({ name: 'record', ok: false, problems: [e.message] });
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Harness-PRD §11: DoD attempts and what failed them.
|
|
1037
|
+
const failed = r.checks.filter((c) => !c.ok).map((c) => c.name);
|
|
1038
|
+
await (
|
|
1039
|
+
await lib('metrics')
|
|
1040
|
+
).logMetric(process.cwd(), 'verify-log.csv', [
|
|
1041
|
+
id,
|
|
1042
|
+
r.ok ? 1 : 0,
|
|
1043
|
+
failed.join(' '),
|
|
1044
|
+
o.mark ? 1 : 0,
|
|
1045
|
+
]);
|
|
944
1046
|
out(o.json, r, (x) =>
|
|
945
1047
|
[
|
|
946
1048
|
...x.checks.flatMap((c) =>
|
|
@@ -1188,6 +1290,7 @@ export function register(program) {
|
|
|
1188
1290
|
registerSession(gcf);
|
|
1189
1291
|
registerDecide(gcf);
|
|
1190
1292
|
registerInbox(gcf);
|
|
1293
|
+
registerLessons(gcf);
|
|
1191
1294
|
registerLive(gcf);
|
|
1192
1295
|
registerPlan(gcf);
|
|
1193
1296
|
registerVerify(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.13",
|
|
5
5
|
"description": "Game Context Framework for AI Factory",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills/gcf-init",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
## GCF: work item status after commit (from gcf-aif-ext)
|
|
2
|
-
|
|
3
|
-
gcf_commit:
|
|
4
|
-
- when `gcf/` exists and the commit implements a work item, commit the implementation first, then move the item with its own command in a follow-up commit
|
|
5
|
-
- gate `verdict` or `decision`: run `ai-factory gcf submit <item-id>` — it waits for the operator, `since:` records where the wait began
|
|
6
|
-
- gate `auto`, every check green: write the item's `live:` delta, then run `ai-factory gcf close <item-id>` — never edit `status:` by hand
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
1
|
+
## GCF: work item status after commit (from gcf-aif-ext)
|
|
2
|
+
|
|
3
|
+
gcf_commit:
|
|
4
|
+
- when `gcf/` exists and the commit implements a work item, commit the implementation first, then move the item with its own command in a follow-up commit
|
|
5
|
+
- gate `verdict` or `decision`: run `ai-factory gcf submit <item-id>` — it waits for the operator, `since:` records where the wait began
|
|
6
|
+
- gate `auto`, every check green: write the item's `live:` delta, then run `ai-factory gcf close <item-id>` — never edit `status:` by hand
|
|
7
|
+
- `submit` and `close` refuse code that changed since its last green `ai-factory gcf verify <item-id>`: run it again after any fix; `--unverified` is the human's call
|
|
8
|
+
- the work cannot go on: run `ai-factory gcf block <item-id> --reason "<why or the question>"`
|
|
9
|
+
- do not write a terminal status into an item file: a terminal move deletes the file and writes the history or graveyard line itself
|
|
10
|
+
- when the commit makes the tests of a hard unit still in `gcf/design/` green, run `ai-factory gcf done <id>` after it
|
package/lib/board.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `gcf board --json` (Methodology §11): everything a board shows, in one read. The board is a
|
|
2
2
|
// view: it shows these data and runs the same commands as the console; nothing here is stored.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import { readFile } from 'node:fs/promises';
|
|
4
5
|
import { existsSync } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -14,6 +15,7 @@ import { wipOf } from './audit-core.js';
|
|
|
14
15
|
import { runHolder } from './main-lock.js';
|
|
15
16
|
import { ITEM_TYPES } from './validate-item.js';
|
|
16
17
|
import { movesOf } from './transitions.js';
|
|
18
|
+
import { openDepends } from './work-ops.js';
|
|
17
19
|
import { listQuestions } from './questions.js';
|
|
18
20
|
import { sessionActive } from './session-mark.js';
|
|
19
21
|
import { agentOf } from './agent-permissions.js';
|
|
@@ -90,6 +92,30 @@ const graveyardOf = async (root) =>
|
|
|
90
92
|
};
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The commit time of each `since` hash, by hash: one git call for the whole board. A hash git
|
|
97
|
+
* does not know (rebased away), a short one (the core writes it whole) or text that is no hash
|
|
98
|
+
* gets no time.
|
|
99
|
+
*/
|
|
100
|
+
function commitTimes(projectDir, hashes) {
|
|
101
|
+
const asked = [...new Set(hashes.filter((h) => /^([0-9a-f]{40}|[0-9a-f]{64})$/.test(h ?? '')))];
|
|
102
|
+
if (!asked.length) return new Map();
|
|
103
|
+
const res = spawnSync(
|
|
104
|
+
'git',
|
|
105
|
+
['log', '--no-walk=unsorted', '--ignore-missing', '--format=%H %cI', ...asked],
|
|
106
|
+
{ cwd: projectDir, encoding: 'utf8' },
|
|
107
|
+
);
|
|
108
|
+
const found = (res.status === 0 ? res.stdout : '').split('\n').filter(Boolean);
|
|
109
|
+
|
|
110
|
+
return new Map(
|
|
111
|
+
asked.flatMap((h) => {
|
|
112
|
+
const line = found.find((l) => l.split(' ')[0] === h);
|
|
113
|
+
|
|
114
|
+
return line ? [[h, line.split(' ')[1]]] : [];
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
/** Verified branches; none outside a repository. */
|
|
94
120
|
function verifiedOf(projectDir) {
|
|
95
121
|
try {
|
|
@@ -179,8 +205,15 @@ function inRunOrder(items) {
|
|
|
179
205
|
|
|
180
206
|
const LISTS = ['children', 'system', 'depends'];
|
|
181
207
|
|
|
208
|
+
/** `run`, `session` or null. A harness run's lock wins over the mark its run on main holds. */
|
|
209
|
+
function runningKind(projectDir, id) {
|
|
210
|
+
if (runHolder(projectDir, id) !== null) return 'run';
|
|
211
|
+
|
|
212
|
+
return sessionActive(projectDir, id) ? 'session' : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
/** One item of the board. */
|
|
183
|
-
async function itemOf(projectDir, c, verified) {
|
|
216
|
+
async function itemOf(projectDir, c, verified, ledger) {
|
|
184
217
|
const id = String(c.data.id);
|
|
185
218
|
// A trailer counts only while the item waits on main for its integrate.
|
|
186
219
|
const ready = c.data.status === 'ready' && verified.has(id);
|
|
@@ -188,7 +221,8 @@ async function itemOf(projectDir, c, verified) {
|
|
|
188
221
|
// is a run; a trailer is a passed DoD. A worktree with neither is a run that stopped (a
|
|
189
222
|
// timeout, a stop, a crash): the item is ready, and its next run resumes that worktree. A
|
|
190
223
|
// session the harness kept (`resume`) is resumed by the next run in either mode.
|
|
191
|
-
const
|
|
224
|
+
const kind = runningKind(projectDir, id);
|
|
225
|
+
const running = kind !== null;
|
|
192
226
|
const resume = c.data.status === 'ready' ? await resumeOf(projectDir, id) : null;
|
|
193
227
|
const resumable =
|
|
194
228
|
!running &&
|
|
@@ -206,13 +240,20 @@ async function itemOf(projectDir, c, verified) {
|
|
|
206
240
|
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
207
241
|
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
208
242
|
running,
|
|
243
|
+
// `run`: a harness run's lock; `session`: an operator's or agent's session mark (`gcf claim`).
|
|
244
|
+
running_kind: kind,
|
|
209
245
|
resumable,
|
|
210
246
|
resume,
|
|
211
247
|
verified: ready,
|
|
248
|
+
// The dependencies the run gate would refuse a ready item on now (Board-PRD §5.2).
|
|
249
|
+
waits_for:
|
|
250
|
+
c.data.status === 'ready' ? openDepends(ledger, c.data.depends ?? [], { waiting: true }) : [],
|
|
212
251
|
// What a board may offer (§4.1): nothing while a run drives the item.
|
|
213
252
|
moves: running ? [] : movesOf(c.data),
|
|
214
253
|
file: `gcf/work/${c.name}`,
|
|
215
254
|
log: logOf(projectDir, id),
|
|
255
|
+
// Filled by board() from the inbox.
|
|
256
|
+
lessons: 0,
|
|
216
257
|
...(await progress(projectDir, c)),
|
|
217
258
|
};
|
|
218
259
|
}
|
|
@@ -222,8 +263,17 @@ export async function board(projectDir) {
|
|
|
222
263
|
const ledger = await buildLedger(projectDir);
|
|
223
264
|
const verified = verifiedOf(projectDir);
|
|
224
265
|
const items = [];
|
|
225
|
-
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
|
|
266
|
+
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified, ledger));
|
|
226
267
|
inRunOrder(items);
|
|
268
|
+
// When the wait began: the time of the commit `since` names (Board-PRD §13).
|
|
269
|
+
const times = commitTimes(
|
|
270
|
+
projectDir,
|
|
271
|
+
items.map((i) => i.since),
|
|
272
|
+
);
|
|
273
|
+
for (const i of items) i.since_at = times.get(i.since) ?? null;
|
|
274
|
+
const inbox = await listInbox(root);
|
|
275
|
+
// Where agents struggle: the lessons in the inbox each item's sessions left.
|
|
276
|
+
for (const i of items) i.lessons = inbox.filter((e) => e.item === i.id).length;
|
|
227
277
|
|
|
228
278
|
const config = await readConfig(root);
|
|
229
279
|
const live = await readLive(root);
|
|
@@ -248,7 +298,7 @@ export async function board(projectDir) {
|
|
|
248
298
|
),
|
|
249
299
|
items,
|
|
250
300
|
wip: wipOf(ledger.items, config),
|
|
251
|
-
inbox
|
|
301
|
+
inbox,
|
|
252
302
|
questions: await listQuestions(root),
|
|
253
303
|
history: await historyOf(root),
|
|
254
304
|
graveyard: await graveyardOf(root),
|
package/lib/config-set.js
CHANGED
|
@@ -50,6 +50,11 @@ export const SETTINGS = [
|
|
|
50
50
|
label: 'improve passes a plan may take before the item blocks',
|
|
51
51
|
},
|
|
52
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
|
+
},
|
|
53
58
|
{
|
|
54
59
|
key: 'wip_built_limit',
|
|
55
60
|
kind: 'number',
|
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,
|
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/integrate.js
CHANGED
|
@@ -271,6 +271,9 @@ async function integrateOne(projectDir, id) {
|
|
|
271
271
|
* branch bringing its own lines could not merge over them. Union merge joins the lines.
|
|
272
272
|
*/
|
|
273
273
|
function commitMetrics(projectDir) {
|
|
274
|
+
// A log the harness started on main (run-log, guard-log) is new to git: add it first; only
|
|
275
|
+
// the logs, never an editor's swap file beside them.
|
|
276
|
+
git(projectDir, ['add', '--', 'gcf/.metrics/*.csv'], { check: false });
|
|
274
277
|
const dirty = git(projectDir, [
|
|
275
278
|
'status',
|
|
276
279
|
'--porcelain',
|
|
@@ -278,17 +281,23 @@ function commitMetrics(projectDir) {
|
|
|
278
281
|
'--',
|
|
279
282
|
'gcf/.metrics',
|
|
280
283
|
]);
|
|
284
|
+
// Best effort: a main mid-rebase or mid-merge keeps its logs for the next integrate, and a
|
|
285
|
+
// run still starts.
|
|
281
286
|
if (dirty.out)
|
|
282
|
-
git(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
287
|
+
git(
|
|
288
|
+
projectDir,
|
|
289
|
+
[
|
|
290
|
+
'commit',
|
|
291
|
+
'-q',
|
|
292
|
+
'--no-verify',
|
|
293
|
+
'-m',
|
|
294
|
+
'gcf(metrics): run logs',
|
|
295
|
+
'--only',
|
|
296
|
+
'--',
|
|
297
|
+
'gcf/.metrics',
|
|
298
|
+
],
|
|
299
|
+
{ check: false },
|
|
300
|
+
);
|
|
292
301
|
}
|
|
293
302
|
|
|
294
303
|
/**
|
|
@@ -302,11 +311,13 @@ export async function integrate(projectDir, ids = []) {
|
|
|
302
311
|
throw new Error(`integrate runs in the main checkout on its branch, not on ${branch}`);
|
|
303
312
|
|
|
304
313
|
return withMainLock(projectDir, async () => {
|
|
305
|
-
//
|
|
314
|
+
// The run logs go in at every integrate, a main-mode run's first one too: nothing else
|
|
315
|
+
// commits them there. Nothing to merge leaves the rest of main alone: a run's first
|
|
316
|
+
// integrate must not refuse a dirty tree.
|
|
317
|
+
commitMetrics(projectDir);
|
|
306
318
|
if (!ids.length && !verifiedBranches(projectDir).length) return [];
|
|
307
319
|
if (git(projectDir, ['status', '--porcelain', '--untracked-files=no', '--', '.', METRICS]).out)
|
|
308
320
|
throw new Error('main has uncommitted changes: commit or stash them before integrating');
|
|
309
|
-
commitMetrics(projectDir);
|
|
310
321
|
for (const id of ids)
|
|
311
322
|
if (!isVerified(projectDir, id))
|
|
312
323
|
throw new Error(
|
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/metrics.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The run logs this module writes to gcf/.metrics (Harness-PRD §11): append-only csv, one
|
|
2
|
+
// line per call, merged by union. A worktree's lines go to the main checkout's logs: the
|
|
3
|
+
// worktree's own gcf/.metrics is thrown away when its branch merges.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** gcf/.metrics of the main checkout of `projectDir`; its own outside a repository. */
|
|
10
|
+
export function metricsDir(projectDir) {
|
|
11
|
+
const res = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
12
|
+
cwd: projectDir,
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
});
|
|
15
|
+
const common = res.status === 0 ? path.resolve(projectDir, res.stdout.trim()) : '';
|
|
16
|
+
const main = path.basename(common) === '.git' ? path.dirname(common) : projectDir;
|
|
17
|
+
|
|
18
|
+
return path.join(main, 'gcf', '.metrics');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A field as csv takes it: no comma, no line break, at most 120 characters. */
|
|
22
|
+
const field = (v) =>
|
|
23
|
+
String(v ?? '')
|
|
24
|
+
.replace(/[,\r\n]+/g, ';')
|
|
25
|
+
.slice(0, 120);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Append one line to a log, timestamped; a project without gcf/ keeps no logs. Best effort: a
|
|
29
|
+
* log that cannot be written (a csv held open by a spreadsheet) never fails the command.
|
|
30
|
+
*/
|
|
31
|
+
export async function logMetric(projectDir, file, fields) {
|
|
32
|
+
try {
|
|
33
|
+
const dir = metricsDir(projectDir);
|
|
34
|
+
if (!existsSync(path.dirname(dir))) return;
|
|
35
|
+
await mkdir(dir, { recursive: true });
|
|
36
|
+
const line = [new Date().toISOString(), ...fields].map(field).join(',');
|
|
37
|
+
await appendFile(path.join(dir, file), `${line}\n`, 'utf8');
|
|
38
|
+
} catch {
|
|
39
|
+
// No line this time.
|
|
40
|
+
}
|
|
41
|
+
}
|