gcf-aif-ext 0.7.10 → 0.7.14

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) => {
@@ -442,31 +459,25 @@ function registerAnchor(gcf) {
442
459
  'Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta',
443
460
  )
444
461
  .option('--json', 'machine output')
462
+ .option('--done', 'the import is written: the next anchor counts its delta from this one')
445
463
  .action(async (file, opts) => {
446
- const anchors = await lib('anchors');
447
- const { splitSections, importSource, loadAnchorMap, classifyDelta } = anchors;
448
- const base = path.basename(file);
449
- const oldMap = await loadAnchorMap(process.cwd(), base);
450
- const { readFile } = await import('node:fs/promises');
451
- const sections = splitSections(await readFile(file, 'utf8'));
452
- const delta = oldMap?.anchors ? classifyDelta(oldMap.anchors, sections) : null;
453
- await importSource(process.cwd(), file);
454
- // A renamed heading moves its anchor: provenance and source: follow it.
455
- const rewritten = delta ? await anchors.rewriteAnchors(process.cwd(), delta.renamed) : [];
456
- const rebaselined = Boolean(oldMap?.rebaselined);
457
- const result = {
458
- file: `gcf/source/${base}`,
459
- sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading, lines: s.lines })),
460
- delta,
461
- rebaselined,
462
- rewritten,
463
- };
464
- out(opts.json, result, (r) =>
464
+ const { anchorImport, finishImport } = await lib('anchors');
465
+ if (opts.done) {
466
+ const done = await finishImport(process.cwd(), file);
467
+ console.log(done ? `import of ${path.basename(file)} finished` : 'no import to finish');
468
+
469
+ return;
470
+ }
471
+
472
+ out(opts.json, await anchorImport(process.cwd(), file), (r) =>
465
473
  [
466
474
  ...r.sections.map(
467
475
  (s) => `${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`,
468
476
  ),
469
477
  anchorDeltaLine(r),
478
+ ...(r.resumed
479
+ ? ['resumed: an earlier import of this file did not finish, the delta counts from it']
480
+ : []),
470
481
  ...r.rewritten.map((f) => `renamed anchors rewritten in gcf/${f}`),
471
482
  ].join('\n'),
472
483
  );
@@ -747,10 +758,11 @@ function registerItemMoves(gcf) {
747
758
  .command('submit <id>')
748
759
  .description('ready → awaiting-verdict / awaiting-decision (commit gcf(submit))')
749
760
  .option('--claimed', CLAIMED)
761
+ .option('--unverified <why>', UNVERIFIED)
750
762
  .option('--no-commit', 'skip the commit (tests only)')
751
763
  .action(async (id, o) => {
752
764
  claimedBy(o, id);
753
- const r = await (await ops()).submitItem(process.cwd(), id, { commit: o.commit });
765
+ const r = await (await ops()).submitItem(process.cwd(), id, verifiedMove(o));
754
766
  console.log(`${r.id} → ${r.status}${shortHash(r.hash)}`);
755
767
  });
756
768
  gcf
@@ -763,12 +775,13 @@ function registerItemMoves(gcf) {
763
775
  [],
764
776
  )
765
777
  .option('--claimed', CLAIMED)
778
+ .option('--unverified <why>', UNVERIFIED)
766
779
  .option('--no-commit', 'skip the commit (tests only)')
767
780
  .action(async (id, o) => {
768
781
  claimedBy(o, id);
769
782
  const r = await (
770
783
  await ops()
771
- ).closeItem(process.cwd(), id, { constraints: o.constraint, commit: o.commit });
784
+ ).closeItem(process.cwd(), id, { ...verifiedMove(o), constraints: o.constraint });
772
785
  console.log(`closed ${r.id}${r.unplayed ? ' → unplayed' : ''}${shortHash(r.hash)}`);
773
786
  });
774
787
  }
@@ -982,6 +995,10 @@ function registerPlan(gcf) {
982
995
  .action(async (id, o) => {
983
996
  await touchSession(id);
984
997
  const r = await (await lib('plan-check')).planCheck(process.cwd(), id);
998
+ // Harness-PRD §11: the checks a plan took to converge.
999
+ await (
1000
+ await lib('metrics')
1001
+ ).logMetric(process.cwd(), 'plan-log.csv', [id, r.ok ? 1 : 0, r.problems?.length ?? 0]);
985
1002
  out(o.json, r, (x) =>
986
1003
  x.ok ? `${x.plan}: ${x.done}/${x.tasks} tasks done` : x.problems.join('\n'),
987
1004
  );
@@ -1000,6 +1017,26 @@ function registerVerify(gcf) {
1000
1017
  const r = o.mark
1001
1018
  ? await (await lib('integrate')).markVerified(process.cwd(), id)
1002
1019
  : await (await lib('verify')).verifyItem(process.cwd(), id);
1020
+ // Methodology §6: a green DoD on main is what close and submit ask for, for this code.
1021
+ // A record git cannot write leaves the DoD unproven for the gate: the verify fails.
1022
+ if (r.ok && !o.mark)
1023
+ try {
1024
+ (await lib('verify-stamp')).stampVerified(process.cwd(), id);
1025
+ } catch (e) {
1026
+ r.ok = false;
1027
+ r.checks.push({ name: 'record', ok: false, problems: [e.message] });
1028
+ }
1029
+
1030
+ // Harness-PRD §11: DoD attempts and what failed them.
1031
+ const failed = r.checks.filter((c) => !c.ok).map((c) => c.name);
1032
+ await (
1033
+ await lib('metrics')
1034
+ ).logMetric(process.cwd(), 'verify-log.csv', [
1035
+ id,
1036
+ r.ok ? 1 : 0,
1037
+ failed.join(' '),
1038
+ o.mark ? 1 : 0,
1039
+ ]);
1003
1040
  out(o.json, r, (x) =>
1004
1041
  [
1005
1042
  ...x.checks.flatMap((c) =>
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.10",
4
+ "version": "0.7.14",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -1,9 +1,11 @@
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
+ - on a `work/<id>` branch (a harness worktree) make none of the moves below: the session marks the branch with `ai-factory gcf verify <item-id> --mark` once its work is done, a stop goes into `.gcf-session/outcome.json`, and `ai-factory gcf integrate` on main records the move
5
+ - 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
6
+ - gate `verdict` or `decision`: run `ai-factory gcf submit <item-id>` — it waits for the operator, `since:` records where the wait began
7
+ - gate `auto`, every check green: write the item's `live:` delta, then run `ai-factory gcf close <item-id>` — never edit `status:` by hand
8
+ - `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
9
+ - the work cannot go on: run `ai-factory gcf block <item-id> --reason "<why or the question>"`
10
+ - do not write a terminal status into an item file: a terminal move deletes the file and writes the history or graveyard line itself
11
+ - when the commit makes the tests of a hard unit still in `gcf/design/` green, run `ai-factory gcf done <id>` after it
@@ -2,7 +2,8 @@
2
2
 
3
3
  gcf_gate:
4
4
  - when `gcf/` exists and the task names a work item, run `ai-factory gcf context <item-id>` first
5
- - the returned file list is the readset: read those files and nothing else under `gcf/`; `gcf/LIVE.md` in it says what the game already does
5
+ - the returned file list is the readset: read those files, `gcf/INDEX.md` and `gcf/config.yml`, and nothing else under `gcf/`; `gcf/LIVE.md` in it says what the game already does
6
+ - its `graveyard_hits` and `d2` lines are context too: what was buried before, and the assumptions in force with their `until:`
6
7
  - `over_budget: true` in the result: stop and record a D1 question instead of planning
7
8
  - an id in `depends:` that lives in `gcf/design/`, or as an item in any status but unplayed, parked or awaiting-verdict, is not played: do not plan on top of it, stop
8
9
  - a dependency awaiting its verdict is planned on: the close of this item then waits for that verdict (`ai-factory gcf block <id> --reason "waits for <dep>"`)
@@ -6,3 +6,4 @@ gcf_review:
6
6
  - check the diff against each pillar's review question: a contradiction is a finding, not a note
7
7
  - numeric feel values in code outside the item's envelope: report the value and the envelope bounds
8
8
  - a capability the diff adds, changes or removes that the item's `live:` delta does not name: a finding
9
+ - a feature child with `live: none: parent` carries no delta of its own: check the capability against its parent feature's `live:`, and one the parent does not name is a finding on the parent
package/lib/anchors.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir, readdir, rm } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
@@ -230,3 +230,60 @@ export async function rewriteAnchors(projectDir, renamed) {
230
230
 
231
231
  return changed;
232
232
  }
233
+
234
+ /** `<base>.import.json`: the anchor map an import that has not finished started from. */
235
+ const heldFile = (projectDir, base) =>
236
+ path.join(path.resolve(projectDir), 'gcf', 'source', `${base}.import.json`);
237
+
238
+ /**
239
+ * `gcf anchor`: copy a GDD into gcf/source/, write its anchor map, and report its sections
240
+ * with their bodies and the delta. The delta counts from the map the last FINISHED import left:
241
+ * the map an import starts from is held until `finishImport`, so an import that died after
242
+ * anchoring reports the same delta again instead of an empty one (`resumed`).
243
+ */
244
+ export async function anchorImport(projectDir, file) {
245
+ const base = path.basename(file);
246
+ const held = heldFile(projectDir, base);
247
+ const resumed = existsSync(held);
248
+ const current = await loadAnchorMap(projectDir, base);
249
+ const start = resumed
250
+ ? JSON.parse(await readFile(held, 'utf8'))
251
+ : (current ?? { anchors: null, rebaselined: false });
252
+ // Read first: a wrong path must leave no held map behind to fake an unfinished import.
253
+ const sections = splitSections(await readFile(file, 'utf8'));
254
+ if (!resumed) {
255
+ await mkdir(path.dirname(held), { recursive: true });
256
+ await writeFile(held, `${JSON.stringify(start, null, 2)}\n`, 'utf8');
257
+ }
258
+
259
+ const delta = start.anchors ? classifyDelta(start.anchors, sections) : null;
260
+ await importSource(projectDir, file);
261
+ // A renamed heading moves its anchor: provenance and source: follow it, from the anchors
262
+ // they cite now - those of the last anchoring - and from the held start's too, for a file a
263
+ // rewrite that died left behind; a rename already applied finds nothing to change.
264
+ const moved = current?.anchors ? classifyDelta(current.anchors, sections).renamed : [];
265
+ const renamed = [...moved, ...(delta?.renamed ?? [])];
266
+
267
+ return {
268
+ file: `gcf/source/${base}`,
269
+ sections: sections.map(({ anchor, heading, lines, body }) => ({
270
+ anchor,
271
+ heading,
272
+ lines,
273
+ body,
274
+ })),
275
+ delta,
276
+ rebaselined: Boolean(start.rebaselined),
277
+ resumed,
278
+ rewritten: await rewriteAnchors(projectDir, renamed),
279
+ };
280
+ }
281
+
282
+ /** `gcf anchor <file> --done`: the import is written; the next delta counts from it. */
283
+ export async function finishImport(projectDir, file) {
284
+ const held = heldFile(projectDir, path.basename(file));
285
+ if (!existsSync(held)) return false;
286
+ await rm(held);
287
+
288
+ return true;
289
+ }
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,9 +240,14 @@ 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}`,
@@ -224,8 +263,14 @@ export async function board(projectDir) {
224
263
  const ledger = await buildLedger(projectDir);
225
264
  const verified = verifiedOf(projectDir);
226
265
  const items = [];
227
- 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));
228
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;
229
274
  const inbox = await listInbox(root);
230
275
  // Where agents struggle: the lessons in the inbox each item's sessions left.
231
276
  for (const i of items) i.lessons = inbox.filter((e) => e.item === i.id).length;
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/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
+ }
@@ -0,0 +1,102 @@
1
+ // The record of a green `gcf verify` on main (Methodology §6): `close` and `submit` need one for
2
+ // the code as it is now, so a DoD run some other way cannot stand in for it. The record holds
3
+ // the hash of the project's code, not of a commit: committing the verified code keeps it valid,
4
+ // and any edit after the verify (a review's fix) asks for a new one. In a worktree the
5
+ // `GCF-Verified` trailer does this job, and the integrator's moves need no record.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ // Not code: the framework's own files, the plans, the run files. A move or a plan tick there
12
+ // after the verify does not change what the DoD proved.
13
+ const NOT_CODE = ['gcf', '.ai-factory', '.gcf-session'];
14
+
15
+ const stampFile = (projectDir, id) =>
16
+ path.join(path.resolve(projectDir), '.gcf-session', 'runs', `${id}.verified`);
17
+
18
+ const git = (projectDir, args, env = {}) =>
19
+ spawnSync('git', args, { cwd: projectDir, encoding: 'utf8', env: { ...process.env, ...env } });
20
+
21
+ /**
22
+ * The git tree of the project's code as it is on disk, committed or not, new files included
23
+ * (ignored ones are not); null outside a repository. A git that fails on the code (a file it
24
+ * cannot read, `core.safecrlf`) throws: a gate that cannot see the code must not let it pass. A
25
+ * copy of the index keeps git's stat cache, so only changed files are hashed again.
26
+ */
27
+ export function codeTree(projectDir) {
28
+ const index = git(projectDir, ['rev-parse', '--git-path', 'index']);
29
+ if (index.status !== 0) return null;
30
+ const real = path.resolve(projectDir, index.stdout.trim());
31
+ const tmp = path.join(os.tmpdir(), `gcf-code-index-${process.pid}-${Date.now()}`);
32
+ const env = { GIT_INDEX_FILE: tmp };
33
+ const step = (args) => {
34
+ const r = git(projectDir, args, env);
35
+ if (r.status !== 0) throw new Error(`git ${args[0]} on the code failed: ${r.stderr.trim()}`);
36
+
37
+ return r.stdout.trim();
38
+ };
39
+
40
+ try {
41
+ if (existsSync(real)) copyFileSync(real, tmp);
42
+ step(['add', '-A', '--', '.', ...NOT_CODE.map((p) => `:(exclude)${p}`)]);
43
+ step(['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...NOT_CODE]);
44
+
45
+ return step(['write-tree']);
46
+ } finally {
47
+ rmSync(tmp, { force: true });
48
+ }
49
+ }
50
+
51
+ /** Record a green verify of the code as it is now. */
52
+ export function stampVerified(projectDir, id) {
53
+ const tree = codeTree(projectDir);
54
+ if (!tree) return;
55
+ const file = stampFile(projectDir, id);
56
+ mkdirSync(path.dirname(file), { recursive: true });
57
+ writeFileSync(file, `${JSON.stringify({ tree, at: new Date().toISOString() })}\n`);
58
+ }
59
+
60
+ /** A move ended the item's work: its record goes with it. */
61
+ export const dropStamp = (projectDir, id) => rmSync(stampFile(projectDir, id), { force: true });
62
+
63
+ /** The files that differ between two trees, the first few, for a refusal to name. */
64
+ function changed(projectDir, from, to) {
65
+ const r = git(projectDir, ['diff-tree', '-r', '--name-only', from, to]);
66
+ const files = r.status === 0 ? r.stdout.split('\n').filter(Boolean) : [];
67
+ const more = files.length > 5 ? ` and ${files.length - 5} more` : '';
68
+
69
+ return files.length ? ` (${files.slice(0, 5).join(', ')}${more})` : '';
70
+ }
71
+
72
+ /**
73
+ * Why `close` or `submit` may not take the item now, or null: no green verify on record, or
74
+ * the code changed since. A feature closes over its children and has no code of its own. The
75
+ * way past it is named as the human's: an agent reads this text.
76
+ */
77
+ export function unverifiedProblem(projectDir, id, type) {
78
+ if (type === 'feature') return null;
79
+ const tree = codeTree(projectDir);
80
+ if (!tree) return null;
81
+ const how =
82
+ `run ai-factory gcf verify ${id} until it is green; only the human may move it without ` +
83
+ 'one (--unverified "<why>")';
84
+ const stamp = readStamp(projectDir, id);
85
+ if (!stamp) return `${id}: no green gcf verify on record — ${how}`;
86
+ if (stamp.tree !== tree) {
87
+ const files = changed(projectDir, stamp.tree, tree);
88
+
89
+ return `${id}: the code changed since its green gcf verify${files} — ${how}`;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /** @returns {{tree: string, at: string} | null} the record, or null when there is none. */
96
+ function readStamp(projectDir, id) {
97
+ try {
98
+ return JSON.parse(readFileSync(stampFile(projectDir, id), 'utf8'));
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
package/lib/work-ops.js CHANGED
@@ -39,6 +39,7 @@ import { dropInbox } from './inbox.js';
39
39
  import { refuseByPhase } from './phase.js';
40
40
  import { locked, runHolder } from './main-lock.js';
41
41
  import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
42
+ import { dropStamp, unverifiedProblem } from './verify-stamp.js';
42
43
 
43
44
  /**
44
45
  * Options of a committed move; each move reads the ones it needs.
@@ -232,10 +233,38 @@ async function finish(m, move, tail, commit) {
232
233
  // and no rerun resumes it.
233
234
  releaseSession(m.projectDir, String(m.item.data.id));
234
235
  dropAgentSession(m.projectDir, String(m.item.data.id));
236
+ dropStamp(m.projectDir, String(m.item.data.id));
235
237
 
236
238
  return hash;
237
239
  }
238
240
 
241
+ /**
242
+ * `close` and `submit` from a command (Methodology §6): the code as it is now needs a green
243
+ * `gcf verify`, unless the operator says why not (`unverified`, written into the move's
244
+ * commit). The integrator's moves rest on the branch's trailer instead.
245
+ */
246
+ function needVerified(m, opts) {
247
+ if (!opts.requireVerify || opts.unverified) return;
248
+ const id = String(m.item.data.id);
249
+ const problem = verifyGateProblem(m.projectDir, id, m.item.data.type);
250
+ if (problem) throw new Error(problem);
251
+ }
252
+
253
+ /** The verify gate's refusal, or null; a git that cannot hash the code refuses too. */
254
+ function verifyGateProblem(projectDir, id, type) {
255
+ try {
256
+ return unverifiedProblem(projectDir, id, type);
257
+ } catch (e) {
258
+ return (
259
+ `${id}: cannot hash the code for the verify gate (${e.message}) — fix that, or the ` +
260
+ 'human passes --unverified "<why>"'
261
+ );
262
+ }
263
+ }
264
+
265
+ /** The commit's tail of a move made without a green verify. */
266
+ const unverifiedTail = (opts) => (opts.unverified ? ` (unverified: ${opts.unverified})` : '');
267
+
239
268
  const writeItem = (item, data, body = item.body) => atomicWrite(item.file, serialize(data, body));
240
269
 
241
270
  /** `data` without the keys named — a field that no longer applies leaves the frontmatter. */
@@ -481,11 +510,12 @@ async function submitItemUnlocked(projectDir, id, opts = {}) {
481
510
  if (m.item.data.gate === 'auto')
482
511
  throw new Error(`${id} has gate auto: nobody waits for it — close it with gcf close ${id}`);
483
512
  refuseOpenDepends(m, 'submit', { waiting: true });
513
+ needVerified(m, opts);
484
514
  const status = m.item.data.gate === 'verdict' ? 'awaiting-verdict' : 'awaiting-decision';
485
515
  const data = { ...without(m.item.data, 'blocked'), status, since: headHash(projectDir) };
486
516
  m.step(() => writeItem(m.item, data));
487
517
 
488
- return { id, status, hash: await finish(m, 'submit', id, commit) };
518
+ return { id, status, hash: await finish(m, 'submit', `${id}${unverifiedTail(opts)}`, commit) };
489
519
  }
490
520
 
491
521
  /** Why a soft gate-auto item cannot close unplayed, or null: only an `added` comes back out. */
@@ -556,11 +586,13 @@ async function closeItemUnlocked(projectDir, id, opts = {}) {
556
586
  if (m.item.data.gate !== 'auto')
557
587
  throw new Error(`${id} has gate ${m.item.data.gate}: submit it, the operator closes it`);
558
588
  refuseOpenDepends(m, 'close');
589
+ needVerified(m, opts);
559
590
  if (unplayed) closeUnplayed(m);
560
591
  else await closeEnding(m, constraints);
561
592
  }
562
593
 
563
- const hash = await finish(m, 'close', `${id}${unplayed ? ' → unplayed' : ''}`, commit);
594
+ const tail = `${id}${unplayed ? ' → unplayed' : ''}${unverifiedTail(opts)}`;
595
+ const hash = await finish(m, 'close', tail, commit);
564
596
 
565
597
  return { id, hash, unplayed };
566
598
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.7.10",
3
+ "version": "0.7.14",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@ sections:
25
25
  note: pillar count <= 7 and the token budget are enforced by the gcf audit core; id is the slug a design unit cites in pillars:
26
26
 
27
27
  custom_sections:
28
- - none: a pillar that needs more than its three keys is not a pillar yet
28
+ - none: a pillar that needs more than its statement, invariant, review question and id is not a pillar yet
29
29
 
30
30
  enforcement:
31
31
  - `npx --yes cnlp-kit check`, over the file a `cnlp.config.json` corpus entry maps to this profile