gcf-aif-ext 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +6 -4
  2. package/commands/gcf.js +190 -13
  3. package/extension.json +1 -1
  4. package/hooks/pre-commit.js +34 -30
  5. package/injections/commit-status.md +1 -0
  6. package/injections/plan-context.md +3 -2
  7. package/lib/anchors.js +36 -34
  8. package/lib/audit-core.js +56 -26
  9. package/lib/bundle.js +275 -0
  10. package/lib/context.js +3 -2
  11. package/lib/design-check.js +580 -0
  12. package/lib/design.js +179 -0
  13. package/lib/git.js +63 -0
  14. package/lib/id.js +7 -15
  15. package/lib/index-gen.js +42 -8
  16. package/lib/ledger.js +92 -0
  17. package/lib/linter.js +14 -3
  18. package/lib/migrate.js +345 -0
  19. package/lib/playtest-list.js +1 -1
  20. package/lib/systems-doc.js +70 -0
  21. package/lib/unit-ops.js +270 -0
  22. package/lib/validate-card.js +29 -18
  23. package/lib/verdict-fs.js +102 -88
  24. package/package.json +1 -1
  25. package/profiles/game.md +68 -0
  26. package/profiles/glossary.md +37 -0
  27. package/profiles/pillars.md +2 -1
  28. package/profiles/unit-constraint.md +42 -0
  29. package/profiles/unit-entity.md +38 -0
  30. package/profiles/unit-mechanic.md +106 -0
  31. package/profiles/unit-resource.md +86 -0
  32. package/profiles/unit-rule.md +42 -0
  33. package/schemas/card.schema.json +23 -7
  34. package/skills/gcf-adopt/SKILL.md +1 -1
  35. package/skills/gcf-audit/SKILL.md +4 -3
  36. package/skills/gcf-card/SKILL.md +25 -16
  37. package/skills/gcf-debate/SKILL.md +17 -14
  38. package/skills/gcf-ingest/SKILL.md +39 -23
  39. package/skills/gcf-init/SKILL.md +3 -3
  40. package/skills/gcf-verdict/SKILL.md +6 -5
  41. package/skills/gcf-vision/SKILL.md +17 -11
  42. package/templates/PILLARS.md +2 -0
  43. package/templates/backlog.md +2 -5
  44. package/templates/card.md +27 -10
  45. package/templates/cnlp.config.json +0 -1
  46. package/templates/design.md +59 -0
  47. package/templates/game.md +24 -0
  48. package/templates/glossary.md +11 -0
  49. package/templates/system.md +1 -1
  50. package/profiles/card.md +0 -65
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Game Context Framework as an [AI Factory](https://github.com/lee-to/ai-factory) extension: a documentary layer for building games with an AI agent, where the documents hold **state instead of history**.
4
4
 
5
- One overwritable document per subsystem. Feature cards that collapse into those documents on a human playtest verdict and then cease to exist. A graveyard that keeps the reasons things were killed. Read rules that keep the token cost of a task flat as the project ages.
5
+ A Design Model: the design lives in `gcf/design/<system>.md` as units with typed slots — what the GDD says, quoted and anchored, and `TBD` for everything it does not. A soft unit taken into work becomes a feature card with the same body; the card collapses into its subsystem's document on a human playtest verdict and then ceases to exist. A hard unit leaves through its tests. A graveyard keeps the reasons things were killed. Read rules keep the token cost of a task flat as the project ages.
6
6
 
7
7
  Runtime-agnostic — everything here works on any AI Factory runtime. The Claude-Code-specific orchestration lives in [`gcf-harness-aif-ext`](../gcf-harness-aif-ext), which is optional.
8
8
 
@@ -58,11 +58,13 @@ Removal leaves `gcf/` and `cnlp.config.json` in place. It also leaves the pre-co
58
58
 
59
59
  **Skills** — `gcf-init`, `gcf-adopt`, `gcf-card`, `gcf-verdict`, `gcf-audit`, `gcf-vision`, `gcf-debate`, `gcf-ingest`.
60
60
 
61
- **A deterministic core** behind `ai-factory gcf …` — `init`, `index`, `context`, `audit`, `playtest`, `verdict`, `anchor`, `section`, `quotes`, `lint`, `hooks-install`. These are plain Node scripts with no LLM in the loop: the same functions the skills call, the pre-commit gate runs, and the harness gates against.
61
+ **A deterministic core** behind `ai-factory gcf …` — `init`, `index`, `context`, `audit`, `playtest`, `verdict`, `anchor`, `section`, `quotes`, `lint`, `design check`, `where`, `take`, `done`, `drop`, `hooks-install`. These are plain Node scripts with no LLM in the loop: the same functions the skills call, the pre-commit gate runs, and the harness gates against.
62
62
 
63
- **A pre-commit gate** that breaks the commit on a terminal status stored in a card file, a malformed `[until:]` annotation, a card built on an unplayed dependency, a dependency that names no known card, or a П2 linter finding in a subsystem document. It regenerates `gcf/INDEX.md` and stages it for you.
63
+ **A pre-commit gate** that breaks the commit on a terminal status stored in a card file, a malformed `[until:]` annotation, a card built on an unplayed dependency, a dependency that names no known id, a П2 linter finding in a subsystem document, or a blocking design-check finding: a malformed unit or card, a digit in prose, a forbidden synonym, a quote that is not verbatim in its GDD section, an id living in two places, a backlog out of step with `gcf/design/`. An unfinished unit (`TBD`) never breaks a commit. The gate regenerates `gcf/INDEX.md` and stages it for you.
64
64
 
65
- **CNLP profiles and a card schema** for the documents the agent reads, plus injections into `aif-plan`, `aif-review` and `aif-commit`.
65
+ **CNLP profiles and a card schema** for the documents the agent reads — one profile per unit type, shared by a unit and its card — plus injections into `aif-plan`, `aif-review` and `aif-commit`.
66
+
67
+ **Upgrading from 0.2.x:** a card is schema 2 now — `cards/<id>.md` with a slug id, `type`, `pillars`, no `slug` and no `route`; the gate names the steps for a schema 1 card. Backlog candidate lines (`cand-NNN | …`) become units in `gcf/design/` with `ai-factory gcf design migrate` (deterministic, no commit): it keeps the backlog order, writes the quote as provenance and every slot the line did not carry as `TBD`, rewrites `cand-NNN` references in `open-questions.md` and `graveyard.md`, and copies the keeps and dones of old commit messages into `gcf/history.md` once — the ledger reads that file, not the log. Then run `ai-factory gcf design check`, fix what it reports — typically a digit in a description — and commit.
66
68
 
67
69
  ## Docs
68
70
 
package/commands/gcf.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // CLI wrapper over the deterministic core (GCF-PRD §10.1): harness and human call
2
2
  // the same functions. Registered by AIF via extension.json `commands`.
3
- import { mkdir, copyFile, readFile } from 'node:fs/promises';
3
+ import { mkdir, copyFile, readFile, writeFile } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import path from 'node:path';
@@ -70,8 +70,24 @@ function aif(args) {
70
70
 
71
71
  const today = () => new Date().toISOString().slice(0, 10);
72
72
 
73
+ const UNION = ['gcf/history.md merge=union', 'gcf/graveyard.md merge=union'];
74
+
75
+ /**
76
+ * Two branches that end different lives may add their lines at the same spot of history.md or
77
+ * graveyard.md: a union merge keeps both instead of a conflict whose "ours" loses one. Adds the
78
+ * missing rules to `<dir>/.gitattributes`; a project that ran an older init gets them on re-run.
79
+ */
80
+ export async function unionAttributes(dir) {
81
+ const file = path.join(dir, '.gitattributes');
82
+ const text = existsSync(file) ? await readFile(file, 'utf8') : '';
83
+ const missing = UNION.filter((rule) => !text.split(/\r?\n/).includes(rule));
84
+ if (!missing.length) return;
85
+ const sep = text && !text.endsWith('\n') ? '\n' : '';
86
+ await writeFile(file, `${text}${sep}${missing.join('\n')}\n`, 'utf8');
87
+ }
88
+
73
89
  async function seedSkeleton(root) {
74
- for (const dir of ['cards', 'systems', 'source', '.metrics']) {
90
+ for (const dir of ['design', 'cards', 'systems', 'source', '.metrics']) {
75
91
  await mkdir(path.join(root, dir), { recursive: true });
76
92
  }
77
93
 
@@ -86,8 +102,14 @@ async function seedSkeleton(root) {
86
102
  if (!existsSync(target)) await copyFile(path.join(EXT_ROOT, 'templates', tpl), target);
87
103
  }
88
104
 
105
+ const { HISTORY_HEAD } = await lib('verdict-fs');
89
106
  for (const [name, header] of [
90
- ['graveyard.md', '# Graveyard — one line per killed idea, written by gcf verdict kill\n'],
107
+ ['history.md', HISTORY_HEAD],
108
+ [
109
+ 'graveyard.md',
110
+ '# Graveyard — `- <id> | <reason> | tags: <tags>`, one line per idea killed by a verdict ' +
111
+ 'or dropped before work\n',
112
+ ],
91
113
  [
92
114
  'open-questions.md',
93
115
  '# Open questions — records with id/lens/sections/sys/class/question/proposed, ' +
@@ -95,13 +117,13 @@ async function seedSkeleton(root) {
95
117
  ],
96
118
  ]) {
97
119
  const target = path.join(root, name);
98
- if (!existsSync(target))
99
- await (await import('node:fs/promises')).writeFile(target, header, 'utf8');
120
+ if (!existsSync(target)) await writeFile(target, header, 'utf8');
100
121
  }
101
122
 
102
123
  const cnlpCfg = path.join(process.cwd(), 'cnlp.config.json');
103
124
  if (!existsSync(cnlpCfg))
104
125
  await copyFile(path.join(EXT_ROOT, 'templates', 'cnlp.config.json'), cnlpCfg);
126
+ await unionAttributes(process.cwd());
105
127
  }
106
128
 
107
129
  async function offerHarness(program, opts) {
@@ -134,8 +156,8 @@ function registerInit(gcf, program) {
134
156
  const { writeIndex } = await lib('index-gen');
135
157
  await writeIndex(root);
136
158
  console.log(
137
- 'gcf/ ready. Write PILLARS.md through /gcf-init, then add candidates to backlog.md ' +
138
- 'or import a GDD with /gcf-ingest.',
159
+ 'gcf/ ready. Write PILLARS.md through /gcf-init, then import a GDD with /gcf-ingest ' +
160
+ 'or start the design with /gcf-vision.',
139
161
  );
140
162
  await offerHarness(program, opts);
141
163
  });
@@ -222,7 +244,7 @@ function registerPlaytest(gcf) {
222
244
  const list = await playtestList(gcfRoot());
223
245
  out(opts.json, list, (l) =>
224
246
  l.length
225
- ? l.map((c) => `${c.id} ${c.slug} [${c.system.join(',')}]: ${c.question}`).join('\n')
247
+ ? l.map((c) => `${c.id} [${c.system.join(',')}]: ${c.question}`).join('\n')
226
248
  : 'nothing in built — nothing to playtest',
227
249
  );
228
250
  });
@@ -267,19 +289,23 @@ function registerAnchor(gcf) {
267
289
  )
268
290
  .option('--json', 'machine output')
269
291
  .action(async (file, opts) => {
270
- const { splitSections, importSource, loadAnchorMap, classifyDelta } = await lib('anchors');
292
+ const anchors = await lib('anchors');
293
+ const { splitSections, importSource, loadAnchorMap, classifyDelta } = anchors;
271
294
  const base = path.basename(file);
272
295
  const oldMap = await loadAnchorMap(process.cwd(), base);
273
296
  const { readFile } = await import('node:fs/promises');
274
297
  const sections = splitSections(await readFile(file, 'utf8'));
275
298
  const delta = oldMap?.anchors ? classifyDelta(oldMap.anchors, sections) : null;
276
299
  await importSource(process.cwd(), file);
300
+ // A renamed heading moves its anchor: provenance and source: follow it.
301
+ const rewritten = delta ? await anchors.rewriteAnchors(process.cwd(), delta.renamed) : [];
277
302
  const rebaselined = Boolean(oldMap?.rebaselined);
278
303
  const result = {
279
304
  file: `gcf/source/${base}`,
280
305
  sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading, lines: s.lines })),
281
306
  delta,
282
307
  rebaselined,
308
+ rewritten,
283
309
  };
284
310
  out(opts.json, result, (r) =>
285
311
  [
@@ -287,6 +313,7 @@ function registerAnchor(gcf) {
287
313
  (s) => `${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`,
288
314
  ),
289
315
  anchorDeltaLine(r),
316
+ ...r.rewritten.map((f) => `renamed anchors rewritten in gcf/${f}`),
290
317
  ].join('\n'),
291
318
  );
292
319
  });
@@ -308,18 +335,165 @@ function registerSection(gcf) {
308
335
  function registerQuotes(gcf) {
309
336
  gcf
310
337
  .command('quotes')
311
- .description('Verify each backlog q: quote is verbatim in its src: section (GCF-PRD §11.2)')
338
+ .description('Provenance only: every quote verbatim in its src: section (design check M9)')
312
339
  .option('--json', 'machine output')
313
340
  .action(async (opts) => {
314
- const { checkQuotes } = await lib('anchors');
315
- const problems = await checkQuotes(process.cwd());
341
+ const { designCheck } = await lib('design-check');
342
+ const problems = (await designCheck(process.cwd())).issues.filter((i) => i.check === 'M9');
316
343
  out(opts.json, problems, (l) =>
317
- l.length ? l.map((p) => `${p.id} ${p.anchor}: ${p.problem}`).join('\n') : 'clean',
344
+ l.length ? l.map((p) => `${p.file}:${p.line} ${p.id}: ${p.message}`).join('\n') : 'clean',
318
345
  );
319
346
  if (problems.length) process.exitCode = 1;
320
347
  });
321
348
  }
322
349
 
350
+ const issueLine = (i) => `${i.severity} ${i.check} ${i.file}:${i.line} ${i.id ?? ''} ${i.message}`;
351
+
352
+ function designSummary(r) {
353
+ const rows = Object.entries(r.systems).map(
354
+ ([s, t]) => `${s}: ${t.units} units, ${t.ready} ready, ${t.tbd}/${t.values} TBD`,
355
+ );
356
+ const blocking = r.issues.filter((i) => i.severity === 'block');
357
+ const notes = r.issues.filter((i) => i.severity === 'report');
358
+
359
+ return [
360
+ ...blocking.map(issueLine),
361
+ ...notes.map(issueLine),
362
+ ...rows,
363
+ blocking.length ? `${blocking.length} blocking finding(s)` : 'no blocking findings',
364
+ ].join('\n');
365
+ }
366
+
367
+ function registerDesign(gcf) {
368
+ const design = gcf.command('design').description('Design Model: units in gcf/design/');
369
+ design
370
+ .command('check')
371
+ .description('Mechanical checks over gcf/design/ and the cards; exit 1 on a blocking finding')
372
+ .option('--json', 'machine output')
373
+ .action(async (opts) => {
374
+ const { designCheck } = await lib('design-check');
375
+ const report = await designCheck(process.cwd());
376
+ out(opts.json, report, designSummary);
377
+ if (report.blocking) process.exitCode = 1;
378
+ });
379
+ design
380
+ .command('export')
381
+ .description('Glue game.md, glossary.md and the units into one bundle a designer edits')
382
+ .option('--system <name>', 'only this subsystem')
383
+ .option('--milestone <name>', 'only the units of this milestone')
384
+ .option('--out <file>', 'write the bundle here instead of stdout')
385
+ .action(async (opts) => {
386
+ const { exportBundle } = await lib('bundle');
387
+ const text = await exportBundle(process.cwd(), opts);
388
+ if (!opts.out) return void process.stdout.write(text);
389
+ const { writeFile } = await import('node:fs/promises');
390
+ await writeFile(opts.out, text, 'utf8');
391
+ console.log(`bundle written → ${opts.out}`);
392
+ });
393
+ design
394
+ .command('import <bundle>')
395
+ .description('Bring an edited bundle back: 3-way by base hash, conflicts reported, no commit')
396
+ .option('--json', 'machine output')
397
+ .action(async (bundle, opts) => {
398
+ const { importBundle } = await lib('bundle');
399
+ const r = await importBundle(process.cwd(), await readFile(bundle, 'utf8'));
400
+ out(opts.json, r, (x) =>
401
+ [
402
+ ...x.applied.map((a) => `applied ${a}`),
403
+ ...x.report.map((p) => `${p.outcome} ${p.part}: ${p.note}`),
404
+ x.applied.length || x.report.length ? '' : 'nothing changed',
405
+ ]
406
+ .filter(Boolean)
407
+ .join('\n'),
408
+ );
409
+ if (r.report.some((p) => p.outcome !== 'deleted')) process.exitCode = 1;
410
+ });
411
+ registerMigrate(design);
412
+ }
413
+
414
+ function registerMigrate(design) {
415
+ design
416
+ .command('migrate')
417
+ .description('One-shot: backlog cand-NNN lines become units in gcf/design/ (no commit)')
418
+ .option(
419
+ '--skip <ids>',
420
+ 'converted by hand, comma-separated: cand-NNN, cand-NNN=<id> or cand-NNN=<id>+<id>',
421
+ )
422
+ .action(async (opts) => {
423
+ const { migrateBacklog } = await lib('migrate');
424
+ const skip = opts.skip ? opts.skip.split(',').map((s) => s.trim()) : [];
425
+ const r = await migrateBacklog(process.cwd(), { skip });
426
+ for (const l of r.lost_depends) console.log(`lost depends: ${l} (map it: --skip cand=id)`);
427
+ for (const l of r.maybe_duplicates) console.log(`maybe a duplicate: ${l}`);
428
+ for (const l of r.unmoved_gaps) console.log(`check its unit holds these gaps: ${l}`);
429
+ console.log(
430
+ `${r.units} units written, ${r.skipped} skipped, ${r.history} history lines from git; ` +
431
+ 'run ai-factory gcf design check',
432
+ );
433
+ });
434
+ }
435
+
436
+ const placeLines = (x) => [...x.live.map((p) => `${p.state} gcf/${p.file}`), ...x.terminal];
437
+
438
+ function registerWhere(gcf) {
439
+ gcf
440
+ .command('where [id]')
441
+ .description('Where an id lives: a design section, a card, a terminal event; no id lists all')
442
+ .option('--json', 'machine output')
443
+ .action(async (id, opts) => {
444
+ const { buildLedger } = await lib('ledger');
445
+ const ledger = await buildLedger(process.cwd());
446
+ // Without an id: every id the project has used, so an import never mints a taken one.
447
+ if (!id) {
448
+ const all = ledger.ids().map((i) => ledger.where(i));
449
+ out(opts.json, all, (l) => l.map((x) => `${x.id}: ${placeLines(x).join(', ')}`).join('\n'));
450
+
451
+ return;
452
+ }
453
+
454
+ out(opts.json, ledger.where(id), (x) => placeLines(x).join('\n') || `${id} is unknown`);
455
+ });
456
+ }
457
+
458
+ function registerMoves(gcf) {
459
+ const ops = () => lib('unit-ops');
460
+ gcf
461
+ .command('take <id>')
462
+ .description('Move a ready soft unit from gcf/design/ into cards/<id>.md (no commit)')
463
+ .action(async (id) => console.log((await (await ops()).takeUnit(process.cwd(), id)).card));
464
+ gcf
465
+ .command('done <id>')
466
+ .description('A hard unit is built and tested: it leaves gcf/design/ (commit gcf(done))')
467
+ .option(
468
+ '--constraint <line>',
469
+ 'a line every reader of the subsystem must remember',
470
+ (v, p) => [...p, v],
471
+ [],
472
+ )
473
+ .option('--no-commit', 'skip the commit (tests only)')
474
+ .action(async (id, o) => {
475
+ const r = await (
476
+ await ops()
477
+ ).doneUnit(process.cwd(), id, { constraints: o.constraint, commit: o.commit });
478
+ console.log(`done ${r.id}${r.hash ? ` (${r.hash.slice(0, 7)})` : ''}`);
479
+ });
480
+ gcf
481
+ .command('drop <id>')
482
+ .description(
483
+ 'Drop a unit before any work: a graveyard line keeps the reason (commit gcf(drop))',
484
+ )
485
+ .requiredOption('--reason <text>', 'why the idea dies')
486
+ .option('--tags <list>', 'graveyard tags, comma-separated (default: its systems)')
487
+ .option('--no-commit', 'skip the commit (tests only)')
488
+ .action(async (id, o) => {
489
+ const tags = o.tags ? o.tags.split(',').map((t) => t.trim()) : [];
490
+ const r = await (
491
+ await ops()
492
+ ).dropUnit(process.cwd(), id, { reason: o.reason, tags, commit: o.commit });
493
+ console.log(`dropped ${r.id}${r.hash ? ` (${r.hash.slice(0, 7)})` : ''}`);
494
+ });
495
+ }
496
+
323
497
  function registerHooksInstall(gcf) {
324
498
  gcf
325
499
  .command('hooks-install')
@@ -378,6 +552,9 @@ export function register(program) {
378
552
  registerAnchor(gcf);
379
553
  registerSection(gcf);
380
554
  registerQuotes(gcf);
555
+ registerDesign(gcf);
556
+ registerWhere(gcf);
557
+ registerMoves(gcf);
381
558
  registerHooksInstall(gcf);
382
559
  registerLint(gcf);
383
560
  }
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.2.7",
4
+ "version": "0.3.0",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -4,14 +4,15 @@
4
4
  // The gate reads the WORKING TREE, so it only runs when the commit touches gcf/ and
5
5
  // refuses to judge a gcf/ whose working tree and index disagree.
6
6
  import { existsSync } from 'node:fs';
7
- import { readFile } from 'node:fs/promises';
8
7
  import { spawnSync } from 'node:child_process';
9
8
  import path from 'node:path';
10
- import { listCards, listSystems } from '../lib/gcf-paths.js';
9
+ import { listSystems } from '../lib/gcf-paths.js';
11
10
  import { validateCard, validateUntil } from '../lib/validate-card.js';
12
11
  import { stemMatches } from '../lib/id.js';
13
12
  import { writeIndex } from '../lib/index-gen.js';
14
13
  import { lint } from '../lib/linter.js';
14
+ import { buildLedger } from '../lib/ledger.js';
15
+ import { designCheck } from '../lib/design-check.js';
15
16
 
16
17
  const projectDir = process.cwd();
17
18
  const root = path.join(projectDir, 'gcf');
@@ -33,8 +34,8 @@ function git(args) {
33
34
  const staged = git(['diff', '--cached', '--name-only', '--', root]);
34
35
  if (staged === '') process.exit(0); // this commit does not touch gcf/
35
36
  if (staged !== null) {
36
- // Untracked counts: listCards reads the directory, so an uncommitted card file would be
37
- // validated and written into the staged INDEX while not being in the repository at all.
37
+ // Untracked counts: cards and units are found by reading directories, so an uncommitted file
38
+ // would be validated and written into the staged INDEX while not being in the repository.
38
39
  const unstaged = git(['diff', '--name-only', '--', root]);
39
40
  const untracked = git(['ls-files', '--others', '--exclude-standard', '--', root]);
40
41
  if (unstaged || untracked) {
@@ -45,52 +46,55 @@ if (staged !== null) {
45
46
  }
46
47
  }
47
48
 
48
- const cards = await listCards(root);
49
- const cardIds = new Set(cards.map((c) => c.data.id));
50
- const systemDocs = await listSystems(root);
49
+ const ledger = await buildLedger(projectDir);
51
50
 
52
51
  // 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
53
- for (const c of cards) {
52
+ for (const c of ledger.cards) {
54
53
  const errors = [
55
54
  ...validateCard(c.data),
56
55
  ...validateUntil(c.body),
57
- ...(stemMatches(c.file, c.data.id, c.data.slug)
58
- ? []
59
- : [`filename must be ${c.data.id}-${c.data.slug}.md`]),
56
+ ...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
60
57
  ];
61
58
  if (errors.length) fail(`${c.name}: ${errors[0]}`);
62
59
  }
63
60
 
64
- for (const s of systemDocs) {
61
+ for (const s of await listSystems(root)) {
65
62
  const errors = validateUntil(s.body);
66
63
  if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
67
64
  }
68
65
 
69
- // 4: depends reference known cards; a built card must not sit on un-collapsed depends.
70
- // A collapsed card leaves no file, so its trace is the verdict commit or the graveyard line.
71
- const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s']) ?? '';
72
- const gyFile = path.join(root, 'graveyard.md');
73
- const graveyard = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
74
- const collapsed = (id) =>
75
- new RegExp(`^gcf\\(verdict\\): \\w+ ${id}\\b`, 'm').test(verdictLog) ||
76
- new RegExp(`^- ${id} \\|`, 'm').test(graveyard);
66
+ // A unit or card leaving without its move (take, done, drop, a verdict) loses its reason, not
67
+ // its integrity: a card or unit still citing it fails M2 below, in this commit. A warning, not a
68
+ // refusal — a revert of the commit that added it removes an id legitimately.
69
+ for (const id of ledger.vanished()) {
70
+ console.error(
71
+ `gcf pre-commit: warning: ${id} left design/ or cards/ without done, drop or a verdict — ` +
72
+ 'no history or graveyard line keeps why',
73
+ );
74
+ }
75
+
76
+ // Design model and card bodies: every finding of severity `block` refuses the commit.
77
+ const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
78
+ if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
77
79
 
78
- function checkDep(c, dep) {
79
- if (cardIds.has(dep)) {
80
- if (c.data.status === 'built') {
81
- fail(`${c.data.id} is built on top of unplayed ${dep} (collapse it first — GCF-PRD §7.2)`);
82
- }
83
- } else if (!collapsed(dep)) {
84
- fail(`${c.data.id} depends on unknown ${dep} (no card, no verdict, no graveyard line)`);
80
+ // 4: a built card must not sit on an unplayed dependency — a live card, or a unit still in
81
+ // design/. A collapsed, done or killed id is played; an unknown one is a typo (design check).
82
+ for (const c of ledger.cards.filter((x) => x.data.status === 'built')) {
83
+ for (const dep of c.data.depends ?? []) {
84
+ const live = ledger.where(dep).live[0];
85
+ if (live)
86
+ fail(`${c.data.id} is built on top of unplayed ${dep} (${live.state} — GCF-PRD §7.2)`);
85
87
  }
86
88
  }
87
89
 
88
- for (const c of cards) {
89
- for (const dep of c.data.depends ?? []) checkDep(c, dep);
90
+ for (const c of ledger.cards) {
91
+ const unknown = (c.data.depends ?? []).find((d) => !ledger.known(d));
92
+ if (unknown)
93
+ fail(`${c.data.id} depends on unknown ${unknown} (no unit, card, history or graveyard line)`);
90
94
  }
91
95
 
92
96
  // 5: П2 linter over systems docs.
93
- for (const s of systemDocs) {
97
+ for (const s of await listSystems(root)) {
94
98
  const { issues } = lint(s.body);
95
99
  if (issues.length)
96
100
  fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
@@ -4,3 +4,4 @@ gcf_commit:
4
4
  - when `gcf/` exists and the commit implements a card, verify the card frontmatter is current
5
5
  - an implementation commit that completes a build flips `status:` to `built` and records `built_at:` with the commit hash in the same commit
6
6
  - do not commit a card file whose `status:` is `validated` or `killed`: those are terminal and never stored
7
+ - when the commit makes a hard unit's tests green, run `ai-factory gcf done <id>` after it: the unit leaves `gcf/design/` in its own fixed-format commit
@@ -4,5 +4,6 @@ gcf_gate:
4
4
  - when `gcf/` exists and the task names a card, run `ai-factory gcf context <card-id>` first
5
5
  - the returned file list is the readset: read those files and nothing else under `gcf/`
6
6
  - `over_budget: true` in the result: stop and record a D1 question instead of planning
7
- - a card in `depends:` that still exists as a file is unplayed: do not plan on top of it, stop
8
- - shape the plan by the card slots: behavior, contract, feel envelope, interactions, negative constraints, miss criterion
7
+ - an id in `depends:` that still lives as a card or as a unit of `gcf/design/` is unplayed: do not plan on top of it, stop
8
+ - shape the plan by the card slots: intent, behavior, refusal, boundaries, persistence, feel envelopes, contract, interactions, constraints, miss criterion
9
+ - when the task names a hard unit (rule, constraint, entity), there is no card: read its section only, from the file `ai-factory gcf where <id>` names, and plan its tests from `statement`, `threshold` or `fields`
package/lib/anchors.js CHANGED
@@ -155,27 +155,10 @@ export async function readSection(projectDir, baseName, anchor) {
155
155
  return splitSections(await readFile(file, 'utf8')).find((s) => s.anchor === anchor) ?? null;
156
156
  }
157
157
 
158
- /**
159
- * Provenance check over gcf/backlog.md (GCF-PRD §11.2.3): every line citing `src: GDD#…`
160
- * must carry `q: "…"`, and the quote must be a verbatim substring of that section in some
161
- * gcf/source/ file. Line wraps in the GDD are not edits, so runs of whitespace compare equal.
162
- */
163
- export async function checkQuotes(projectDir) {
164
- const root = path.join(path.resolve(projectDir), 'gcf');
165
- const backlog = path.join(root, 'backlog.md');
166
- if (!existsSync(backlog)) return [];
167
- const bodies = await sourceBodies(path.join(root, 'source'));
168
-
169
- return (await readFile(backlog, 'utf8'))
170
- .split('\n')
171
- .map((line) => quoteProblem(line, bodies))
172
- .filter((p) => p !== null);
173
- }
174
-
175
158
  const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
176
159
 
177
160
  /** anchor -> whitespace-collapsed bodies across every gcf/source/ file */
178
- async function sourceBodies(sourceDir) {
161
+ export async function sourceBodies(sourceDir) {
179
162
  /** @type {Map<string, string[]>} */
180
163
  const bodies = new Map();
181
164
  if (!existsSync(sourceDir)) return bodies;
@@ -188,20 +171,39 @@ async function sourceBodies(sourceDir) {
188
171
  return bodies;
189
172
  }
190
173
 
191
- /** The provenance problem of one backlog line, or null when it has none or cites no GDD. */
192
- function quoteProblem(line, bodies) {
193
- const id = line.match(/^- (cand-\d+)/)?.[1];
194
- const anchor = line.match(/\|\s*src:\s*(GDD#[\w-]+)/)?.[1];
195
- if (!id || !anchor) return null;
196
- // The closing quote is the one before the next field, so a quote may itself hold `"`.
197
- const quote = line.match(/\|\s*q:\s*"(.*?)"\s*(?:\||$)/)?.[1];
198
- const found = bodies.get(anchor);
199
- /** @type {string | null} */
200
- let problem = null;
201
- if (!found) problem = 'anchor not found in gcf/source/';
202
- else if (!quote) problem = 'no q: "<verbatim quote>"';
203
- else if (!found.some((b) => b.includes(ws(quote))))
204
- problem = 'quote is not verbatim in the section';
205
-
206
- return problem && { id, anchor, problem };
174
+ /** gcf-relative files that cite anchors: units, cards, and the `sections:` of questions. */
175
+ async function anchorCiters(root) {
176
+ const out = [];
177
+ for (const dir of ['design', 'cards']) {
178
+ if (!existsSync(path.join(root, dir))) continue;
179
+ const names = (await readdir(path.join(root, dir))).filter((n) => n.endsWith('.md'));
180
+ out.push(...names.map((n) => `${dir}/${n}`));
181
+ }
182
+
183
+ if (existsSync(path.join(root, 'open-questions.md'))) out.push('open-questions.md');
184
+
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * A renamed heading changes its anchor (GCF-PRD §11.6): rewrite `GDD#<from>` to `GDD#<to>` in
190
+ * design/, cards/ and open-questions.md, so provenance, `source:` and `sections:` follow the
191
+ * section instead of pointing at an anchor that no longer exists. Returns the files it changed.
192
+ */
193
+ export async function rewriteAnchors(projectDir, renamed) {
194
+ const root = path.join(path.resolve(projectDir), 'gcf');
195
+ const changed = [];
196
+ for (const rel of renamed.length ? await anchorCiters(root) : []) {
197
+ const file = path.join(root, rel);
198
+ const text = await readFile(file, 'utf8');
199
+ const next = renamed.reduce(
200
+ (t, r) => t.replace(new RegExp(`${r.from}(?![\\w-])`, 'g'), () => r.to),
201
+ text,
202
+ );
203
+ if (next === text) continue;
204
+ await writeFile(file, next, 'utf8');
205
+ changed.push(rel);
206
+ }
207
+
208
+ return changed;
207
209
  }