gcf-aif-ext 0.2.4 → 0.2.7

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 CHANGED
@@ -25,12 +25,14 @@ 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
 
32
32
  `gcf init` is idempotent: it creates what is missing and overwrites nothing.
33
33
 
34
+ It then offers the optional [harness](../gcf-harness-aif-ext) — orchestration and a readset guard for **Claude Code only** — and on yes runs `ai-factory extension add gcf-harness-aif-ext` and `ai-factory gcf-harness init`. It asks only in a terminal and only when `.ai-factory.json` lists the Claude Code agent; an agent or CI run is never asked and installs nothing. `--harness` installs it without asking, `--no-harness` skips the question.
35
+
34
36
  `hooks-install` is a separate step because an AI Factory manifest can declare skills, commands and injections but cannot install a git hook — and because arming a gate that can break your commits is your call, not the extension's. It needs a git repository, and it will not overwrite a `pre-commit` it did not write: if husky or lefthook already owns the hook, it prints the one line to chain from it instead. Without the hook GCF still works; nothing enforces it at commit time.
35
37
 
36
38
  Then `/gcf-vision` for a brand-new game, `/gcf-adopt` for a project that already has code, `/gcf-ingest <gdd.md>` for an existing design doc, or `/gcf-card` to start a feature by hand.
@@ -56,7 +58,7 @@ Removal leaves `gcf/` and `cnlp.config.json` in place. It also leaves the pre-co
56
58
 
57
59
  **Skills** — `gcf-init`, `gcf-adopt`, `gcf-card`, `gcf-verdict`, `gcf-audit`, `gcf-vision`, `gcf-debate`, `gcf-ingest`.
58
60
 
59
- **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`, `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.
60
62
 
61
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.
62
64
 
package/commands/gcf.js CHANGED
@@ -7,46 +7,143 @@ 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
- export function register(program) {
19
- const gcf = program.command('gcf').description('Game Context Framework core');
19
+ const HARNESS = 'gcf-harness-aif-ext';
20
20
 
21
- gcf.command('init')
22
- .description('Create the gcf/ skeleton from templates (idempotent, never overwrites)')
23
- .action(async () => {
21
+ /**
22
+ * `--harness` / `--no-harness` decide. Without either, ask only when someone can answer
23
+ * and the answer matters: an agent, CI or MinTTY run has no terminal, and a project
24
+ * without the Claude Code agent has no use for a Claude-Code-only extension.
25
+ */
26
+ export async function harnessWanted(flag, ask, canAsk) {
27
+ if (flag !== undefined) return flag;
28
+
29
+ return canAsk ? ask() : false;
30
+ }
31
+
32
+ async function claudeConfigured() {
33
+ try {
34
+ const { agents } = JSON.parse(
35
+ await readFile(path.join(process.cwd(), '.ai-factory.json'), 'utf8'),
36
+ );
37
+
38
+ return agents.some((a) => a?.id === 'claude');
39
+ } catch {
40
+ return true; // unknown config shape: let the human decide
41
+ }
42
+ }
43
+
44
+ async function askHarness() {
45
+ const { createInterface } = await import('node:readline/promises');
46
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
47
+ try {
48
+ const answer = await rl.question(
49
+ `Also install ${HARNESS} (orchestration + readset guard, Claude Code only)? [y/N] `,
50
+ );
51
+
52
+ return /^y(es)?$/i.test(answer.trim());
53
+ } finally {
54
+ rl.close();
55
+ }
56
+ }
57
+
58
+ // Re-enter the AIF CLI that is running this command, not whatever `ai-factory` PATH holds
59
+ // (an npx run has none on PATH).
60
+ function aif(args) {
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
+ );
69
+ }
70
+
71
+ const today = () => new Date().toISOString().slice(0, 10);
72
+
73
+ async function seedSkeleton(root) {
74
+ for (const dir of ['cards', 'systems', 'source', '.metrics']) {
75
+ await mkdir(path.join(root, dir), { recursive: true });
76
+ }
77
+
78
+ // No PILLARS.md: a seeded example would be read as the game's truth in every readset
79
+ // until someone replaced it. The pillars interview writes the file; until then it is absent.
80
+ const seed = [
81
+ ['backlog.md', 'backlog.md'],
82
+ ['config.yml', 'config.yml'],
83
+ ];
84
+ for (const [tpl, dst] of seed) {
85
+ const target = path.join(root, dst);
86
+ if (!existsSync(target)) await copyFile(path.join(EXT_ROOT, 'templates', tpl), target);
87
+ }
88
+
89
+ for (const [name, header] of [
90
+ ['graveyard.md', '# Graveyard — one line per killed idea, written by gcf verdict kill\n'],
91
+ [
92
+ 'open-questions.md',
93
+ '# Open questions — records with id/lens/sections/sys/class/question/proposed, ' +
94
+ 'a D2 also assumed/until; answers move out, records die\n',
95
+ ],
96
+ ]) {
97
+ const target = path.join(root, name);
98
+ if (!existsSync(target))
99
+ await (await import('node:fs/promises')).writeFile(target, header, 'utf8');
100
+ }
101
+
102
+ const cnlpCfg = path.join(process.cwd(), 'cnlp.config.json');
103
+ if (!existsSync(cnlpCfg))
104
+ await copyFile(path.join(EXT_ROOT, 'templates', 'cnlp.config.json'), cnlpCfg);
105
+ }
106
+
107
+ async function offerHarness(program, opts) {
108
+ const installed = program.commands?.some(
109
+ (c) => (typeof c?.name === 'function' ? c.name() : c?.name) === 'gcf-harness',
110
+ );
111
+ if (installed) {
112
+ if (opts.harness) aif(['gcf-harness', 'init']);
113
+
114
+ return;
115
+ }
116
+
117
+ const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY) && (await claudeConfigured());
118
+ if (!(await harnessWanted(opts.harness, askHarness, canAsk))) return;
119
+ aif(['extension', 'add', HARNESS]);
120
+ aif(['gcf-harness', 'init']);
121
+ }
122
+
123
+ function registerInit(gcf, program) {
124
+ gcf
125
+ .command('init')
126
+ .description(
127
+ 'Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness',
128
+ )
129
+ .option('--harness', `also install ${HARNESS} and run gcf-harness init (Claude Code only)`)
130
+ .option('--no-harness', 'do not offer the harness')
131
+ .action(async (opts) => {
24
132
  const root = path.join(process.cwd(), 'gcf');
25
- for (const dir of ['cards', 'systems', 'source', '.metrics']) {
26
- await mkdir(path.join(root, dir), { recursive: true });
27
- }
28
- const seed = [
29
- ['PILLARS.md', 'PILLARS.md'], ['backlog.md', 'backlog.md'], ['config.yml', 'config.yml'],
30
- ];
31
- for (const [tpl, dst] of seed) {
32
- const target = path.join(root, dst);
33
- if (!existsSync(target)) await copyFile(path.join(EXT_ROOT, 'templates', tpl), target);
34
- }
35
- for (const [name, header] of [
36
- ['graveyard.md', '# Graveyard — one line per killed idea, written by gcf verdict kill\n'],
37
- ['open-questions.md', '# Open questions — records with id/lens/sections/sys/class/proposed; answers move out, records die\n'],
38
- ]) {
39
- const target = path.join(root, name);
40
- if (!existsSync(target)) await (await import('node:fs/promises')).writeFile(target, header, 'utf8');
41
- }
42
- const cnlpCfg = path.join(process.cwd(), 'cnlp.config.json');
43
- if (!existsSync(cnlpCfg)) await copyFile(path.join(EXT_ROOT, 'templates', 'cnlp.config.json'), cnlpCfg);
133
+ await seedSkeleton(root);
44
134
  const { writeIndex } = await lib('index-gen');
45
135
  await writeIndex(root);
46
- console.log('gcf/ ready. Edit PILLARS.md, then add candidates to backlog.md.');
136
+ 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.',
139
+ );
140
+ await offerHarness(program, opts);
47
141
  });
142
+ }
48
143
 
49
- gcf.command('index')
144
+ function registerIndex(gcf) {
145
+ gcf
146
+ .command('index')
50
147
  .description('Regenerate gcf/INDEX.md (deterministic)')
51
148
  .action(async () => {
52
149
  const { gcfRoot } = await lib('gcf-paths');
@@ -54,8 +151,11 @@ export function register(program) {
54
151
  await writeIndex(gcfRoot());
55
152
  console.log('INDEX.md regenerated');
56
153
  });
154
+ }
57
155
 
58
- gcf.command('context <card-id>')
156
+ function registerContext(gcf) {
157
+ gcf
158
+ .command('context <card-id>')
59
159
  .description('Assemble the readset for a card (GCF-PRD §9.1)')
60
160
  .option('--json', 'machine output')
61
161
  .action(async (cardId, opts) => {
@@ -65,60 +165,106 @@ export function register(program) {
65
165
  const { appendFile, mkdir } = await import('node:fs/promises');
66
166
  const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
67
167
  await mkdir(metricsDir, { recursive: true });
68
- await appendFile(path.join(metricsDir, 'context-log.csv'),
69
- `${new Date().toISOString().slice(0, 10)},${cardId},${ctx.files.length},${ctx.total_tokens_est}\n`, 'utf8');
168
+ await appendFile(
169
+ path.join(metricsDir, 'context-log.csv'),
170
+ `${today()},${cardId},${ctx.files.length},${ctx.total_tokens_est}\n`,
171
+ 'utf8',
172
+ );
70
173
  out(opts.json, ctx, (c) =>
71
174
  [
72
175
  ...c.files.map((f) => `${f.path} (${f.tokens_est})`),
73
176
  ...(c.graveyard_hits.length ? ['graveyard hits:', ...c.graveyard_hits] : []),
74
177
  `total ~${c.total_tokens_est} / ${c.budget}${c.over_budget ? ' OVER BUDGET' : ''}`,
75
- ].join('\n'));
178
+ ].join('\n'),
179
+ );
76
180
  if (ctx.over_budget) process.exitCode = 2;
77
181
  });
182
+ }
78
183
 
79
- gcf.command('audit')
184
+ function registerAudit(gcf) {
185
+ gcf
186
+ .command('audit')
80
187
  .description('Deterministic consistency audit (GCF-PRD §10.1)')
81
188
  .option('--json', 'machine output')
82
189
  .action(async (opts) => {
83
190
  const { audit } = await lib('audit-core');
84
191
  const report = await audit(process.cwd());
85
192
  // Metric 2 (GCF-PRD §14): findings per audit run.
86
- const findings = report.stale_built.length + report.tune_limit.length
87
- + report.until.filter((u) => u.status === 'stale').length + Object.keys(report.linter).length;
193
+ const findings =
194
+ report.stale_built.length +
195
+ report.tune_limit.length +
196
+ report.until.filter((u) => u.status === 'stale').length +
197
+ Object.keys(report.linter).length;
88
198
  const { appendFile, mkdir } = await import('node:fs/promises');
89
199
  const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
90
200
  if (existsSync(path.join(process.cwd(), 'gcf'))) {
91
201
  await mkdir(metricsDir, { recursive: true });
92
- await appendFile(path.join(metricsDir, 'audit-log.csv'), `${new Date().toISOString().slice(0, 10)},${findings}\n`, 'utf8');
202
+ await appendFile(
203
+ path.join(metricsDir, 'audit-log.csv'),
204
+ `${today()},${findings}\n`,
205
+ 'utf8',
206
+ );
93
207
  }
208
+
94
209
  out(opts.json, report, (r) => r.first_line);
95
210
  if (report.first_line !== 'clean') process.exitCode = 1;
96
211
  });
212
+ }
97
213
 
98
- gcf.command('playtest')
214
+ function registerPlaytest(gcf) {
215
+ gcf
216
+ .command('playtest')
99
217
  .description('List built cards with their miss-criterion questions')
100
218
  .option('--json', 'machine output')
101
219
  .action(async (opts) => {
102
220
  const { gcfRoot } = await lib('gcf-paths');
103
221
  const { playtestList } = await lib('playtest-list');
104
222
  const list = await playtestList(gcfRoot());
105
- out(opts.json, list, (l) => (l.length
106
- ? l.map((c) => `${c.id} ${c.slug} [${c.system.join(',')}]: ${c.question}`).join('\n')
107
- : 'nothing in built — nothing to playtest'));
223
+ out(opts.json, list, (l) =>
224
+ l.length
225
+ ? l.map((c) => `${c.id} ${c.slug} [${c.system.join(',')}]: ${c.question}`).join('\n')
226
+ : 'nothing in built — nothing to playtest',
227
+ );
108
228
  });
229
+ }
109
230
 
110
- gcf.command('verdict <card-id> <verdict>')
231
+ function registerVerdict(gcf) {
232
+ gcf
233
+ .command('verdict <card-id> <verdict>')
111
234
  .description('Apply a human verdict: keep | tune | kill (GCF-PRD §8)')
112
235
  .option('--note <text>', 'tune remark or kill reason')
113
236
  .option('--no-commit', 'skip the verdict commit (tests only)')
114
237
  .action(async (cardId, verdict, opts) => {
115
238
  const { applyVerdict } = await lib('verdict-fs');
116
- const res = await applyVerdict(process.cwd(), cardId, verdict, { note: opts.note ?? '', commit: opts.commit });
117
- console.log(`${res.verdict} ${res.card} → ${res.systems.join(',')}${res.hash ? ` (${res.hash.slice(0, 7)})` : ''}`);
239
+ const res = await applyVerdict(process.cwd(), cardId, verdict, {
240
+ note: opts.note ?? '',
241
+ commit: opts.commit,
242
+ });
243
+ const hash = res.hash ? ` (${res.hash.slice(0, 7)})` : '';
244
+ console.log(`${res.verdict} ${res.card} → ${res.systems.join(',')}${hash}`);
118
245
  });
246
+ }
119
247
 
120
- gcf.command('anchor <file>')
121
- .description('Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta')
248
+ function anchorDeltaLine(r) {
249
+ if (r.delta) {
250
+ const { added, changed, removed, renamed } = r.delta;
251
+
252
+ return (
253
+ `delta: +${added.length} ~${changed.length} -${removed.length} ` + `renamed ${renamed.length}`
254
+ );
255
+ }
256
+
257
+ return r.rebaselined
258
+ ? 're-baselined: the stored anchor map predates the current hashing, no delta computed'
259
+ : 'first import';
260
+ }
261
+
262
+ function registerAnchor(gcf) {
263
+ gcf
264
+ .command('anchor <file>')
265
+ .description(
266
+ 'Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta',
267
+ )
122
268
  .option('--json', 'machine output')
123
269
  .action(async (file, opts) => {
124
270
  const { splitSections, importSource, loadAnchorMap, classifyDelta } = await lib('anchors');
@@ -129,40 +275,88 @@ export function register(program) {
129
275
  const delta = oldMap?.anchors ? classifyDelta(oldMap.anchors, sections) : null;
130
276
  await importSource(process.cwd(), file);
131
277
  const rebaselined = Boolean(oldMap?.rebaselined);
132
- const result = { file: `gcf/source/${base}`, sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading })), delta, rebaselined };
133
- out(opts.json, result, (r) => [
134
- ...r.sections.map((s) => `${s.anchor} ${s.heading ?? '(preamble)'}`),
135
- r.delta
136
- ? `delta: +${r.delta.added.length} ~${r.delta.changed.length} -${r.delta.removed.length} renamed ${r.delta.renamed.length}`
137
- : (r.rebaselined ? 're-baselined: the stored anchor map predates the current hashing, no delta computed' : 'first import'),
138
- ].join('\n'));
278
+ const result = {
279
+ file: `gcf/source/${base}`,
280
+ sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading, lines: s.lines })),
281
+ delta,
282
+ rebaselined,
283
+ };
284
+ out(opts.json, result, (r) =>
285
+ [
286
+ ...r.sections.map(
287
+ (s) => `${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`,
288
+ ),
289
+ anchorDeltaLine(r),
290
+ ].join('\n'),
291
+ );
139
292
  });
293
+ }
140
294
 
141
- gcf.command('hooks-install')
295
+ function registerSection(gcf) {
296
+ gcf
297
+ .command('section <source> <anchor>')
298
+ .description('Print one anchored section of a gcf/source/ file (read by anchor, not whole)')
299
+ .action(async (source, anchor) => {
300
+ const { readSection } = await lib('anchors');
301
+ const s = await readSection(process.cwd(), source, anchor);
302
+ if (!s) throw new Error(`${anchor} is not a section of gcf/source/${path.basename(source)}`);
303
+ console.log(`${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`);
304
+ console.log(s.body);
305
+ });
306
+ }
307
+
308
+ function registerQuotes(gcf) {
309
+ gcf
310
+ .command('quotes')
311
+ .description('Verify each backlog q: quote is verbatim in its src: section (GCF-PRD §11.2)')
312
+ .option('--json', 'machine output')
313
+ .action(async (opts) => {
314
+ const { checkQuotes } = await lib('anchors');
315
+ const problems = await checkQuotes(process.cwd());
316
+ out(opts.json, problems, (l) =>
317
+ l.length ? l.map((p) => `${p.id} ${p.anchor}: ${p.problem}`).join('\n') : 'clean',
318
+ );
319
+ if (problems.length) process.exitCode = 1;
320
+ });
321
+ }
322
+
323
+ function registerHooksInstall(gcf) {
324
+ gcf
325
+ .command('hooks-install')
142
326
  .description('Install the gcf pre-commit gate into the repository hooks dir (idempotent)')
143
327
  .action(async () => {
144
328
  const { writeFile } = await import('node:fs/promises');
145
329
  // --git-path resolves the hooks dir for a plain repo, a worktree and a submodule alike,
146
330
  // where a bare existsSync('.git') passes on a worktree and then fails with ENOTDIR.
147
- const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: process.cwd(), encoding: 'utf8' });
331
+ const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], {
332
+ cwd: process.cwd(),
333
+ encoding: 'utf8',
334
+ });
148
335
  if (res.status !== 0) throw new Error('not a git repository');
149
336
  const hookScript = path.join(EXT_ROOT, 'hooks', 'pre-commit.js');
150
- const shim = `#!/bin/sh\n# gcf-aif-ext pre-commit gate\nnode "${hookScript.replaceAll('\\', '/')}" || exit 1\n`;
337
+ const gateLine = `node "${hookScript.replaceAll('\\', '/')}" || exit 1`;
338
+ const shim = `#!/bin/sh\n# ${MARKER}\n${gateLine}\n`;
151
339
  const target = path.resolve(process.cwd(), res.stdout.trim(), 'pre-commit');
152
340
  if (existsSync(target) && !(await readFile(target, 'utf8')).includes(MARKER)) {
153
341
  // Brownfield repos usually arrive with husky or lefthook already owning pre-commit,
154
342
  // so the refusal hands over the exact line to chain instead of just naming the file.
155
- throw new Error(`${target} already exists and is not ours - back it up, or chain the gate from it with:\n`
156
- + `node "${hookScript.replaceAll('\\', '/')}" || exit 1`);
343
+ throw new Error(
344
+ `${target} already exists and is not ours - back it up, ` +
345
+ `or chain the gate from it with:\n${gateLine}`,
346
+ );
157
347
  }
348
+
158
349
  await mkdir(path.dirname(target), { recursive: true });
159
350
  // Mode on creation, not a chmod afterwards that can fail with the hook already in place.
160
351
  // A file being replaced is our own earlier shim, already executable.
161
352
  await writeFile(target, shim, { encoding: 'utf8', mode: 0o755 });
162
353
  console.log(`pre-commit hook installed → ${target}`);
163
354
  });
355
+ }
164
356
 
165
- gcf.command('lint <file>')
357
+ function registerLint(gcf) {
358
+ gcf
359
+ .command('lint <file>')
166
360
  .description('П2 linter over one document (GCF-PRD §13.2)')
167
361
  .action(async (file) => {
168
362
  const { lint } = await lib('linter');
@@ -172,3 +366,18 @@ export function register(program) {
172
366
  else console.log('clean');
173
367
  });
174
368
  }
369
+
370
+ export function register(program) {
371
+ const gcf = program.command('gcf').description('Game Context Framework core');
372
+ registerInit(gcf, program);
373
+ registerIndex(gcf);
374
+ registerContext(gcf);
375
+ registerAudit(gcf);
376
+ registerPlaytest(gcf);
377
+ registerVerdict(gcf);
378
+ registerAnchor(gcf);
379
+ registerSection(gcf);
380
+ registerQuotes(gcf);
381
+ registerHooksInstall(gcf);
382
+ registerLint(gcf);
383
+ }
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.4",
4
+ "version": "0.2.7",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -26,6 +26,7 @@ const fail = (msg) => {
26
26
  // rather than letting a broken probe wave a commit past every check.
27
27
  function git(args) {
28
28
  const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
29
+
29
30
  return res.status === 0 ? res.stdout.trim() : null;
30
31
  }
31
32
 
@@ -37,7 +38,10 @@ if (staged !== null) {
37
38
  const unstaged = git(['diff', '--name-only', '--', root]);
38
39
  const untracked = git(['ls-files', '--others', '--exclude-standard', '--', root]);
39
40
  if (unstaged || untracked) {
40
- fail('gcf/ has changes outside the index — stage them, or commit gcf/ separately (the gate reads the working tree)');
41
+ fail(
42
+ 'gcf/ has changes outside the index — stage them, or commit gcf/ separately ' +
43
+ '(the gate reads the working tree)',
44
+ );
41
45
  }
42
46
  }
43
47
 
@@ -50,10 +54,13 @@ for (const c of cards) {
50
54
  const errors = [
51
55
  ...validateCard(c.data),
52
56
  ...validateUntil(c.body),
53
- ...(stemMatches(c.file, c.data.id, c.data.slug) ? [] : [`filename must be ${c.data.id}-${c.data.slug}.md`]),
57
+ ...(stemMatches(c.file, c.data.id, c.data.slug)
58
+ ? []
59
+ : [`filename must be ${c.data.id}-${c.data.slug}.md`]),
54
60
  ];
55
61
  if (errors.length) fail(`${c.name}: ${errors[0]}`);
56
62
  }
63
+
57
64
  for (const s of systemDocs) {
58
65
  const errors = validateUntil(s.body);
59
66
  if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
@@ -64,30 +71,37 @@ for (const s of systemDocs) {
64
71
  const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s']) ?? '';
65
72
  const gyFile = path.join(root, 'graveyard.md');
66
73
  const graveyard = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
67
- const collapsed = (id) => new RegExp(`^gcf\\(verdict\\): \\w+ ${id}\\b`, 'm').test(verdictLog)
68
- || new RegExp(`^- ${id} \\|`, 'm').test(graveyard);
74
+ const collapsed = (id) =>
75
+ new RegExp(`^gcf\\(verdict\\): \\w+ ${id}\\b`, 'm').test(verdictLog) ||
76
+ new RegExp(`^- ${id} \\|`, 'm').test(graveyard);
69
77
 
70
- for (const c of cards) {
71
- for (const dep of c.data.depends ?? []) {
72
- if (cardIds.has(dep)) {
73
- if (c.data.status === 'built') {
74
- fail(`${c.data.id} is built on top of unplayed ${dep} (collapse it first — GCF-PRD §7.2)`);
75
- }
76
- } else if (!collapsed(dep)) {
77
- fail(`${c.data.id} depends on unknown ${dep} (no card, no verdict, no graveyard line)`);
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)`);
78
82
  }
83
+ } else if (!collapsed(dep)) {
84
+ fail(`${c.data.id} depends on unknown ${dep} (no card, no verdict, no graveyard line)`);
79
85
  }
80
86
  }
81
87
 
88
+ for (const c of cards) {
89
+ for (const dep of c.data.depends ?? []) checkDep(c, dep);
90
+ }
91
+
82
92
  // 5: П2 linter over systems docs.
83
93
  for (const s of systemDocs) {
84
94
  const { issues } = lint(s.body);
85
- if (issues.length) fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
95
+ if (issues.length)
96
+ fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
86
97
  }
87
98
 
88
99
  // 3: regenerate INDEX and stage it (deterministic artifact, not a human error).
89
100
  await writeIndex(root);
90
101
  // An unstaged INDEX (gitignored, locked index) would leave the commit without the row it validated.
91
- const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], { cwd: projectDir, stdio: 'ignore' });
102
+ const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], {
103
+ cwd: projectDir,
104
+ stdio: 'ignore',
105
+ });
92
106
  if (added.status !== 0) fail('could not stage gcf/INDEX.md — is it gitignored?');
93
107
  process.exit(0);
@@ -1,7 +1,7 @@
1
1
  ## GCF: pillar and constraint review (from gcf-aif-ext)
2
2
 
3
3
  gcf_review:
4
- - when `gcf/` exists and the change implements a card, read `gcf/PILLARS.md` and the card
4
+ - when `gcf/` exists and the change implements a card, read the card and `gcf/PILLARS.md` when it exists (it stays absent until the pillars interview)
5
5
  - check the diff against every negative constraint of the card: name each violation with file and line
6
6
  - check the diff against each pillar's review question: a contradiction is a finding, not a note
7
7
  - numeric feel values in code outside the card's envelope: report the value and the envelope bounds