gcf-aif-ext 0.2.5 → 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 (55) hide show
  1. package/README.md +7 -5
  2. package/commands/gcf.js +408 -73
  3. package/extension.json +1 -1
  4. package/hooks/pre-commit.js +47 -29
  5. package/injections/commit-status.md +1 -0
  6. package/injections/plan-context.md +3 -2
  7. package/injections/review-pillars.md +1 -1
  8. package/lib/anchors.js +110 -10
  9. package/lib/audit-core.js +175 -50
  10. package/lib/bundle.js +275 -0
  11. package/lib/context.js +55 -36
  12. package/lib/design-check.js +580 -0
  13. package/lib/design.js +179 -0
  14. package/lib/frontmatter.js +2 -1
  15. package/lib/gcf-paths.js +14 -3
  16. package/lib/git.js +85 -4
  17. package/lib/id.js +8 -14
  18. package/lib/index-gen.js +68 -16
  19. package/lib/ledger.js +92 -0
  20. package/lib/linter.js +91 -42
  21. package/lib/migrate.js +345 -0
  22. package/lib/playtest-list.js +1 -1
  23. package/lib/safe-path.js +23 -4
  24. package/lib/systems-doc.js +70 -0
  25. package/lib/transitions.js +1 -3
  26. package/lib/unit-ops.js +270 -0
  27. package/lib/validate-card.js +59 -14
  28. package/lib/verdict-fs.js +173 -96
  29. package/package.json +1 -1
  30. package/profiles/game.md +68 -0
  31. package/profiles/glossary.md +37 -0
  32. package/profiles/pillars.md +2 -1
  33. package/profiles/unit-constraint.md +42 -0
  34. package/profiles/unit-entity.md +38 -0
  35. package/profiles/unit-mechanic.md +106 -0
  36. package/profiles/unit-resource.md +86 -0
  37. package/profiles/unit-rule.md +42 -0
  38. package/schemas/card.schema.json +23 -7
  39. package/skills/gcf-adopt/SKILL.md +3 -3
  40. package/skills/gcf-audit/SKILL.md +4 -3
  41. package/skills/gcf-card/SKILL.md +26 -16
  42. package/skills/gcf-debate/SKILL.md +17 -14
  43. package/skills/gcf-ingest/SKILL.md +43 -16
  44. package/skills/gcf-init/SKILL.md +9 -8
  45. package/skills/gcf-verdict/SKILL.md +7 -6
  46. package/skills/gcf-vision/SKILL.md +17 -11
  47. package/templates/PILLARS.md +2 -0
  48. package/templates/backlog.md +2 -4
  49. package/templates/card.md +27 -10
  50. package/templates/cnlp.config.json +0 -1
  51. package/templates/design.md +59 -0
  52. package/templates/game.md +24 -0
  53. package/templates/glossary.md +11 -0
  54. package/templates/system.md +1 -1
  55. 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
 
@@ -25,7 +25,7 @@ ai-factory extension add gcf-aif-ext # npm
25
25
  This installs the 8 skills into each configured runtime, registers the `gcf` command and appends the injections to `aif-plan`, `aif-review` and `aif-commit`. Then set up the project — two steps, run once:
26
26
 
27
27
  ```bash
28
- ai-factory gcf init # seeds gcf/ and cnlp.config.json
28
+ ai-factory gcf init # seeds gcf/ and cnlp.config.json (PILLARS.md is written by /gcf-init)
29
29
  ai-factory gcf hooks-install # arms the pre-commit gate
30
30
  ```
31
31
 
@@ -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`, `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,19 +1,20 @@
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';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
9
  const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
10
- const lib = (name) => import(new URL(`../lib/${name}.js`, import.meta.url));
10
+ const lib = (name) => import(new URL(`../lib/${name}.js`, import.meta.url).href);
11
11
 
12
12
  // Must stay byte-identical to the comment line inside the shim below: hooks-install
13
13
  // refuses to overwrite a pre-commit hook that does not carry it.
14
14
  const MARKER = 'gcf-aif-ext pre-commit gate';
15
15
 
16
- const out = (json, value, human) => console.log(json ? JSON.stringify(value, null, 2) : human(value));
16
+ const out = (json, value, human) =>
17
+ console.log(json ? JSON.stringify(value, null, 2) : human(value));
17
18
 
18
19
  const HARNESS = 'gcf-harness-aif-ext';
19
20
 
@@ -24,12 +25,16 @@ const HARNESS = 'gcf-harness-aif-ext';
24
25
  */
25
26
  export async function harnessWanted(flag, ask, canAsk) {
26
27
  if (flag !== undefined) return flag;
28
+
27
29
  return canAsk ? ask() : false;
28
30
  }
29
31
 
30
32
  async function claudeConfigured() {
31
33
  try {
32
- const { agents } = JSON.parse(await readFile(path.join(process.cwd(), '.ai-factory.json'), 'utf8'));
34
+ const { agents } = JSON.parse(
35
+ await readFile(path.join(process.cwd(), '.ai-factory.json'), 'utf8'),
36
+ );
37
+
33
38
  return agents.some((a) => a?.id === 'claude');
34
39
  } catch {
35
40
  return true; // unknown config shape: let the human decide
@@ -40,7 +45,10 @@ async function askHarness() {
40
45
  const { createInterface } = await import('node:readline/promises');
41
46
  const rl = createInterface({ input: process.stdin, output: process.stdout });
42
47
  try {
43
- const answer = await rl.question(`Also install ${HARNESS} (orchestration + readset guard, Claude Code only)? [y/N] `);
48
+ const answer = await rl.question(
49
+ `Also install ${HARNESS} (orchestration + readset guard, Claude Code only)? [y/N] `,
50
+ );
51
+
44
52
  return /^y(es)?$/i.test(answer.trim());
45
53
  } finally {
46
54
  rl.close();
@@ -50,54 +58,114 @@ async function askHarness() {
50
58
  // Re-enter the AIF CLI that is running this command, not whatever `ai-factory` PATH holds
51
59
  // (an npx run has none on PATH).
52
60
  function aif(args) {
53
- const res = spawnSync(process.execPath, [process.argv[1], ...args], { cwd: process.cwd(), stdio: 'inherit' });
54
- if (res.status !== 0) throw new Error(`ai-factory ${args.join(' ')} failed — gcf/ is ready; retry that command by hand`);
61
+ const res = spawnSync(process.execPath, [process.argv[1], ...args], {
62
+ cwd: process.cwd(),
63
+ stdio: 'inherit',
64
+ });
65
+ if (res.status !== 0)
66
+ throw new Error(
67
+ `ai-factory ${args.join(' ')} failed — gcf/ is ready; retry that command by hand`,
68
+ );
55
69
  }
56
70
 
57
- export function register(program) {
58
- const gcf = program.command('gcf').description('Game Context Framework core');
71
+ const today = () => new Date().toISOString().slice(0, 10);
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
+
89
+ async function seedSkeleton(root) {
90
+ for (const dir of ['design', 'cards', 'systems', 'source', '.metrics']) {
91
+ await mkdir(path.join(root, dir), { recursive: true });
92
+ }
93
+
94
+ // No PILLARS.md: a seeded example would be read as the game's truth in every readset
95
+ // until someone replaced it. The pillars interview writes the file; until then it is absent.
96
+ const seed = [
97
+ ['backlog.md', 'backlog.md'],
98
+ ['config.yml', 'config.yml'],
99
+ ];
100
+ for (const [tpl, dst] of seed) {
101
+ const target = path.join(root, dst);
102
+ if (!existsSync(target)) await copyFile(path.join(EXT_ROOT, 'templates', tpl), target);
103
+ }
104
+
105
+ const { HISTORY_HEAD } = await lib('verdict-fs');
106
+ for (const [name, header] of [
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
+ ],
113
+ [
114
+ 'open-questions.md',
115
+ '# Open questions — records with id/lens/sections/sys/class/question/proposed, ' +
116
+ 'a D2 also assumed/until; answers move out, records die\n',
117
+ ],
118
+ ]) {
119
+ const target = path.join(root, name);
120
+ if (!existsSync(target)) await writeFile(target, header, 'utf8');
121
+ }
122
+
123
+ const cnlpCfg = path.join(process.cwd(), 'cnlp.config.json');
124
+ if (!existsSync(cnlpCfg))
125
+ await copyFile(path.join(EXT_ROOT, 'templates', 'cnlp.config.json'), cnlpCfg);
126
+ await unionAttributes(process.cwd());
127
+ }
128
+
129
+ async function offerHarness(program, opts) {
130
+ const installed = program.commands?.some(
131
+ (c) => (typeof c?.name === 'function' ? c.name() : c?.name) === 'gcf-harness',
132
+ );
133
+ if (installed) {
134
+ if (opts.harness) aif(['gcf-harness', 'init']);
135
+
136
+ return;
137
+ }
138
+
139
+ const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY) && (await claudeConfigured());
140
+ if (!(await harnessWanted(opts.harness, askHarness, canAsk))) return;
141
+ aif(['extension', 'add', HARNESS]);
142
+ aif(['gcf-harness', 'init']);
143
+ }
59
144
 
60
- gcf.command('init')
61
- .description('Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness')
145
+ function registerInit(gcf, program) {
146
+ gcf
147
+ .command('init')
148
+ .description(
149
+ 'Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness',
150
+ )
62
151
  .option('--harness', `also install ${HARNESS} and run gcf-harness init (Claude Code only)`)
63
152
  .option('--no-harness', 'do not offer the harness')
64
153
  .action(async (opts) => {
65
154
  const root = path.join(process.cwd(), 'gcf');
66
- for (const dir of ['cards', 'systems', 'source', '.metrics']) {
67
- await mkdir(path.join(root, dir), { recursive: true });
68
- }
69
- const seed = [
70
- ['PILLARS.md', 'PILLARS.md'], ['backlog.md', 'backlog.md'], ['config.yml', 'config.yml'],
71
- ];
72
- for (const [tpl, dst] of seed) {
73
- const target = path.join(root, dst);
74
- if (!existsSync(target)) await copyFile(path.join(EXT_ROOT, 'templates', tpl), target);
75
- }
76
- for (const [name, header] of [
77
- ['graveyard.md', '# Graveyard — one line per killed idea, written by gcf verdict kill\n'],
78
- ['open-questions.md', '# Open questions — records with id/lens/sections/sys/class/proposed; answers move out, records die\n'],
79
- ]) {
80
- const target = path.join(root, name);
81
- if (!existsSync(target)) await (await import('node:fs/promises')).writeFile(target, header, 'utf8');
82
- }
83
- const cnlpCfg = path.join(process.cwd(), 'cnlp.config.json');
84
- if (!existsSync(cnlpCfg)) await copyFile(path.join(EXT_ROOT, 'templates', 'cnlp.config.json'), cnlpCfg);
155
+ await seedSkeleton(root);
85
156
  const { writeIndex } = await lib('index-gen');
86
157
  await writeIndex(root);
87
- console.log('gcf/ ready. Edit PILLARS.md, then add candidates to backlog.md.');
88
-
89
- const installed = program.commands?.some((c) => (typeof c?.name === 'function' ? c.name() : c?.name) === 'gcf-harness');
90
- if (installed) {
91
- if (opts.harness) aif(['gcf-harness', 'init']);
92
- return;
93
- }
94
- const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY) && (await claudeConfigured());
95
- if (!(await harnessWanted(opts.harness, askHarness, canAsk))) return;
96
- aif(['extension', 'add', HARNESS]);
97
- aif(['gcf-harness', 'init']);
158
+ console.log(
159
+ 'gcf/ ready. Write PILLARS.md through /gcf-init, then import a GDD with /gcf-ingest ' +
160
+ 'or start the design with /gcf-vision.',
161
+ );
162
+ await offerHarness(program, opts);
98
163
  });
164
+ }
99
165
 
100
- gcf.command('index')
166
+ function registerIndex(gcf) {
167
+ gcf
168
+ .command('index')
101
169
  .description('Regenerate gcf/INDEX.md (deterministic)')
102
170
  .action(async () => {
103
171
  const { gcfRoot } = await lib('gcf-paths');
@@ -105,8 +173,11 @@ export function register(program) {
105
173
  await writeIndex(gcfRoot());
106
174
  console.log('INDEX.md regenerated');
107
175
  });
176
+ }
108
177
 
109
- gcf.command('context <card-id>')
178
+ function registerContext(gcf) {
179
+ gcf
180
+ .command('context <card-id>')
110
181
  .description('Assemble the readset for a card (GCF-PRD §9.1)')
111
182
  .option('--json', 'machine output')
112
183
  .action(async (cardId, opts) => {
@@ -116,104 +187,350 @@ export function register(program) {
116
187
  const { appendFile, mkdir } = await import('node:fs/promises');
117
188
  const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
118
189
  await mkdir(metricsDir, { recursive: true });
119
- await appendFile(path.join(metricsDir, 'context-log.csv'),
120
- `${new Date().toISOString().slice(0, 10)},${cardId},${ctx.files.length},${ctx.total_tokens_est}\n`, 'utf8');
190
+ await appendFile(
191
+ path.join(metricsDir, 'context-log.csv'),
192
+ `${today()},${cardId},${ctx.files.length},${ctx.total_tokens_est}\n`,
193
+ 'utf8',
194
+ );
121
195
  out(opts.json, ctx, (c) =>
122
196
  [
123
197
  ...c.files.map((f) => `${f.path} (${f.tokens_est})`),
124
198
  ...(c.graveyard_hits.length ? ['graveyard hits:', ...c.graveyard_hits] : []),
125
199
  `total ~${c.total_tokens_est} / ${c.budget}${c.over_budget ? ' OVER BUDGET' : ''}`,
126
- ].join('\n'));
200
+ ].join('\n'),
201
+ );
127
202
  if (ctx.over_budget) process.exitCode = 2;
128
203
  });
204
+ }
129
205
 
130
- gcf.command('audit')
206
+ function registerAudit(gcf) {
207
+ gcf
208
+ .command('audit')
131
209
  .description('Deterministic consistency audit (GCF-PRD §10.1)')
132
210
  .option('--json', 'machine output')
133
211
  .action(async (opts) => {
134
212
  const { audit } = await lib('audit-core');
135
213
  const report = await audit(process.cwd());
136
214
  // Metric 2 (GCF-PRD §14): findings per audit run.
137
- const findings = report.stale_built.length + report.tune_limit.length
138
- + report.until.filter((u) => u.status === 'stale').length + Object.keys(report.linter).length;
215
+ const findings =
216
+ report.stale_built.length +
217
+ report.tune_limit.length +
218
+ report.until.filter((u) => u.status === 'stale').length +
219
+ Object.keys(report.linter).length;
139
220
  const { appendFile, mkdir } = await import('node:fs/promises');
140
221
  const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
141
222
  if (existsSync(path.join(process.cwd(), 'gcf'))) {
142
223
  await mkdir(metricsDir, { recursive: true });
143
- await appendFile(path.join(metricsDir, 'audit-log.csv'), `${new Date().toISOString().slice(0, 10)},${findings}\n`, 'utf8');
224
+ await appendFile(
225
+ path.join(metricsDir, 'audit-log.csv'),
226
+ `${today()},${findings}\n`,
227
+ 'utf8',
228
+ );
144
229
  }
230
+
145
231
  out(opts.json, report, (r) => r.first_line);
146
232
  if (report.first_line !== 'clean') process.exitCode = 1;
147
233
  });
234
+ }
148
235
 
149
- gcf.command('playtest')
236
+ function registerPlaytest(gcf) {
237
+ gcf
238
+ .command('playtest')
150
239
  .description('List built cards with their miss-criterion questions')
151
240
  .option('--json', 'machine output')
152
241
  .action(async (opts) => {
153
242
  const { gcfRoot } = await lib('gcf-paths');
154
243
  const { playtestList } = await lib('playtest-list');
155
244
  const list = await playtestList(gcfRoot());
156
- out(opts.json, list, (l) => (l.length
157
- ? l.map((c) => `${c.id} ${c.slug} [${c.system.join(',')}]: ${c.question}`).join('\n')
158
- : 'nothing in built — nothing to playtest'));
245
+ out(opts.json, list, (l) =>
246
+ l.length
247
+ ? l.map((c) => `${c.id} [${c.system.join(',')}]: ${c.question}`).join('\n')
248
+ : 'nothing in built — nothing to playtest',
249
+ );
159
250
  });
251
+ }
160
252
 
161
- gcf.command('verdict <card-id> <verdict>')
253
+ function registerVerdict(gcf) {
254
+ gcf
255
+ .command('verdict <card-id> <verdict>')
162
256
  .description('Apply a human verdict: keep | tune | kill (GCF-PRD §8)')
163
257
  .option('--note <text>', 'tune remark or kill reason')
164
258
  .option('--no-commit', 'skip the verdict commit (tests only)')
165
259
  .action(async (cardId, verdict, opts) => {
166
260
  const { applyVerdict } = await lib('verdict-fs');
167
- const res = await applyVerdict(process.cwd(), cardId, verdict, { note: opts.note ?? '', commit: opts.commit });
168
- console.log(`${res.verdict} ${res.card} → ${res.systems.join(',')}${res.hash ? ` (${res.hash.slice(0, 7)})` : ''}`);
261
+ const res = await applyVerdict(process.cwd(), cardId, verdict, {
262
+ note: opts.note ?? '',
263
+ commit: opts.commit,
264
+ });
265
+ const hash = res.hash ? ` (${res.hash.slice(0, 7)})` : '';
266
+ console.log(`${res.verdict} ${res.card} → ${res.systems.join(',')}${hash}`);
169
267
  });
268
+ }
269
+
270
+ function anchorDeltaLine(r) {
271
+ if (r.delta) {
272
+ const { added, changed, removed, renamed } = r.delta;
170
273
 
171
- gcf.command('anchor <file>')
172
- .description('Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta')
274
+ return (
275
+ `delta: +${added.length} ~${changed.length} -${removed.length} ` + `renamed ${renamed.length}`
276
+ );
277
+ }
278
+
279
+ return r.rebaselined
280
+ ? 're-baselined: the stored anchor map predates the current hashing, no delta computed'
281
+ : 'first import';
282
+ }
283
+
284
+ function registerAnchor(gcf) {
285
+ gcf
286
+ .command('anchor <file>')
287
+ .description(
288
+ 'Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta',
289
+ )
173
290
  .option('--json', 'machine output')
174
291
  .action(async (file, opts) => {
175
- const { splitSections, importSource, loadAnchorMap, classifyDelta } = await lib('anchors');
292
+ const anchors = await lib('anchors');
293
+ const { splitSections, importSource, loadAnchorMap, classifyDelta } = anchors;
176
294
  const base = path.basename(file);
177
295
  const oldMap = await loadAnchorMap(process.cwd(), base);
178
296
  const { readFile } = await import('node:fs/promises');
179
297
  const sections = splitSections(await readFile(file, 'utf8'));
180
298
  const delta = oldMap?.anchors ? classifyDelta(oldMap.anchors, sections) : null;
181
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) : [];
182
302
  const rebaselined = Boolean(oldMap?.rebaselined);
183
- const result = { file: `gcf/source/${base}`, sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading })), delta, rebaselined };
184
- out(opts.json, result, (r) => [
185
- ...r.sections.map((s) => `${s.anchor} ${s.heading ?? '(preamble)'}`),
186
- r.delta
187
- ? `delta: +${r.delta.added.length} ~${r.delta.changed.length} -${r.delta.removed.length} renamed ${r.delta.renamed.length}`
188
- : (r.rebaselined ? 're-baselined: the stored anchor map predates the current hashing, no delta computed' : 'first import'),
189
- ].join('\n'));
303
+ const result = {
304
+ file: `gcf/source/${base}`,
305
+ sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading, lines: s.lines })),
306
+ delta,
307
+ rebaselined,
308
+ rewritten,
309
+ };
310
+ out(opts.json, result, (r) =>
311
+ [
312
+ ...r.sections.map(
313
+ (s) => `${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`,
314
+ ),
315
+ anchorDeltaLine(r),
316
+ ...r.rewritten.map((f) => `renamed anchors rewritten in gcf/${f}`),
317
+ ].join('\n'),
318
+ );
190
319
  });
320
+ }
191
321
 
192
- gcf.command('hooks-install')
322
+ function registerSection(gcf) {
323
+ gcf
324
+ .command('section <source> <anchor>')
325
+ .description('Print one anchored section of a gcf/source/ file (read by anchor, not whole)')
326
+ .action(async (source, anchor) => {
327
+ const { readSection } = await lib('anchors');
328
+ const s = await readSection(process.cwd(), source, anchor);
329
+ if (!s) throw new Error(`${anchor} is not a section of gcf/source/${path.basename(source)}`);
330
+ console.log(`${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`);
331
+ console.log(s.body);
332
+ });
333
+ }
334
+
335
+ function registerQuotes(gcf) {
336
+ gcf
337
+ .command('quotes')
338
+ .description('Provenance only: every quote verbatim in its src: section (design check M9)')
339
+ .option('--json', 'machine output')
340
+ .action(async (opts) => {
341
+ const { designCheck } = await lib('design-check');
342
+ const problems = (await designCheck(process.cwd())).issues.filter((i) => i.check === 'M9');
343
+ out(opts.json, problems, (l) =>
344
+ l.length ? l.map((p) => `${p.file}:${p.line} ${p.id}: ${p.message}`).join('\n') : 'clean',
345
+ );
346
+ if (problems.length) process.exitCode = 1;
347
+ });
348
+ }
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
+
497
+ function registerHooksInstall(gcf) {
498
+ gcf
499
+ .command('hooks-install')
193
500
  .description('Install the gcf pre-commit gate into the repository hooks dir (idempotent)')
194
501
  .action(async () => {
195
502
  const { writeFile } = await import('node:fs/promises');
196
503
  // --git-path resolves the hooks dir for a plain repo, a worktree and a submodule alike,
197
504
  // where a bare existsSync('.git') passes on a worktree and then fails with ENOTDIR.
198
- const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: process.cwd(), encoding: 'utf8' });
505
+ const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], {
506
+ cwd: process.cwd(),
507
+ encoding: 'utf8',
508
+ });
199
509
  if (res.status !== 0) throw new Error('not a git repository');
200
510
  const hookScript = path.join(EXT_ROOT, 'hooks', 'pre-commit.js');
201
- const shim = `#!/bin/sh\n# gcf-aif-ext pre-commit gate\nnode "${hookScript.replaceAll('\\', '/')}" || exit 1\n`;
511
+ const gateLine = `node "${hookScript.replaceAll('\\', '/')}" || exit 1`;
512
+ const shim = `#!/bin/sh\n# ${MARKER}\n${gateLine}\n`;
202
513
  const target = path.resolve(process.cwd(), res.stdout.trim(), 'pre-commit');
203
514
  if (existsSync(target) && !(await readFile(target, 'utf8')).includes(MARKER)) {
204
515
  // Brownfield repos usually arrive with husky or lefthook already owning pre-commit,
205
516
  // so the refusal hands over the exact line to chain instead of just naming the file.
206
- throw new Error(`${target} already exists and is not ours - back it up, or chain the gate from it with:\n`
207
- + `node "${hookScript.replaceAll('\\', '/')}" || exit 1`);
517
+ throw new Error(
518
+ `${target} already exists and is not ours - back it up, ` +
519
+ `or chain the gate from it with:\n${gateLine}`,
520
+ );
208
521
  }
522
+
209
523
  await mkdir(path.dirname(target), { recursive: true });
210
524
  // Mode on creation, not a chmod afterwards that can fail with the hook already in place.
211
525
  // A file being replaced is our own earlier shim, already executable.
212
526
  await writeFile(target, shim, { encoding: 'utf8', mode: 0o755 });
213
527
  console.log(`pre-commit hook installed → ${target}`);
214
528
  });
529
+ }
215
530
 
216
- gcf.command('lint <file>')
531
+ function registerLint(gcf) {
532
+ gcf
533
+ .command('lint <file>')
217
534
  .description('П2 linter over one document (GCF-PRD §13.2)')
218
535
  .action(async (file) => {
219
536
  const { lint } = await lib('linter');
@@ -223,3 +540,21 @@ export function register(program) {
223
540
  else console.log('clean');
224
541
  });
225
542
  }
543
+
544
+ export function register(program) {
545
+ const gcf = program.command('gcf').description('Game Context Framework core');
546
+ registerInit(gcf, program);
547
+ registerIndex(gcf);
548
+ registerContext(gcf);
549
+ registerAudit(gcf);
550
+ registerPlaytest(gcf);
551
+ registerVerdict(gcf);
552
+ registerAnchor(gcf);
553
+ registerSection(gcf);
554
+ registerQuotes(gcf);
555
+ registerDesign(gcf);
556
+ registerWhere(gcf);
557
+ registerMoves(gcf);
558
+ registerHooksInstall(gcf);
559
+ registerLint(gcf);
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.5",
4
+ "version": "0.3.0",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",