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/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
- if (!s.items.length) return [...lines, 'nothing waits for a playtest'].join('\n');
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, { commit: o.commit });
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, { constraints: o.constraint, commit: o.commit });
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 ? l.map((e) => `${e.id} [${e.source}] ${e.text}`).join('\n') : 'inbox empty',
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.9",
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
- - the work cannot go on: run `ai-factory gcf block <item-id> --reason "<why or the question>"`
8
- - do not write a terminal status into an item file: a terminal move deletes the file and writes the history or graveyard line itself
9
- - when the commit makes the tests of a hard unit still in `gcf/design/` green, run `ai-factory gcf done <id>` after it
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 running = runHolder(projectDir, id) !== null || sessionActive(projectDir, id);
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: await listInbox(root),
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: 5,
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
- /** @returns {Promise<Array<{id: string, source: string, text: string}>>} */
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 m ? [{ id: m[1], source: m[2], text: m[3].trim() }] : [];
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)) throw new Error(`source must be ${SOURCES.join(' | ')}`);
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(projectDir, [
283
- 'commit',
284
- '-q',
285
- '--no-verify',
286
- '-m',
287
- 'gcf(metrics): run logs',
288
- '--only',
289
- '--',
290
- 'gcf/.metrics',
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
- // Nothing to merge leaves main alone: a run's first integrate must not refuse a dirty tree.
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
+ }