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.
- package/README.md +6 -4
- package/commands/gcf.js +190 -13
- package/extension.json +1 -1
- package/hooks/pre-commit.js +34 -30
- package/injections/commit-status.md +1 -0
- package/injections/plan-context.md +3 -2
- package/lib/anchors.js +36 -34
- package/lib/audit-core.js +56 -26
- package/lib/bundle.js +275 -0
- package/lib/context.js +3 -2
- package/lib/design-check.js +580 -0
- package/lib/design.js +179 -0
- package/lib/git.js +63 -0
- package/lib/id.js +7 -15
- package/lib/index-gen.js +42 -8
- package/lib/ledger.js +92 -0
- package/lib/linter.js +14 -3
- package/lib/migrate.js +345 -0
- package/lib/playtest-list.js +1 -1
- package/lib/systems-doc.js +70 -0
- package/lib/unit-ops.js +270 -0
- package/lib/validate-card.js +29 -18
- package/lib/verdict-fs.js +102 -88
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +2 -1
- package/profiles/unit-constraint.md +42 -0
- package/profiles/unit-entity.md +38 -0
- package/profiles/unit-mechanic.md +106 -0
- package/profiles/unit-resource.md +86 -0
- package/profiles/unit-rule.md +42 -0
- package/schemas/card.schema.json +23 -7
- package/skills/gcf-adopt/SKILL.md +1 -1
- package/skills/gcf-audit/SKILL.md +4 -3
- package/skills/gcf-card/SKILL.md +25 -16
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +39 -23
- package/skills/gcf-init/SKILL.md +3 -3
- package/skills/gcf-verdict/SKILL.md +6 -5
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -5
- package/templates/card.md +27 -10
- package/templates/cnlp.config.json +0 -1
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/system.md +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
['
|
|
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
|
|
138
|
-
'or
|
|
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}
|
|
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
|
|
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('
|
|
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 {
|
|
315
|
-
const problems = await
|
|
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.
|
|
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
package/hooks/pre-commit.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
37
|
-
// validated and written into the staged INDEX while not being in the repository
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
8
|
-
- shape the plan by the card slots: behavior,
|
|
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
|
-
/**
|
|
192
|
-
function
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
}
|