gcf-aif-ext 0.2.5 → 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,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,7 +58,7 @@ 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`, `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
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.
64
64
 
package/commands/gcf.js CHANGED
@@ -7,13 +7,14 @@ 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,92 @@ 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
+ 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
+ }
59
106
 
60
- gcf.command('init')
61
- .description('Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness')
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
+ )
62
129
  .option('--harness', `also install ${HARNESS} and run gcf-harness init (Claude Code only)`)
63
130
  .option('--no-harness', 'do not offer the harness')
64
131
  .action(async (opts) => {
65
132
  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);
133
+ await seedSkeleton(root);
85
134
  const { writeIndex } = await lib('index-gen');
86
135
  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']);
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);
98
141
  });
142
+ }
99
143
 
100
- gcf.command('index')
144
+ function registerIndex(gcf) {
145
+ gcf
146
+ .command('index')
101
147
  .description('Regenerate gcf/INDEX.md (deterministic)')
102
148
  .action(async () => {
103
149
  const { gcfRoot } = await lib('gcf-paths');
@@ -105,8 +151,11 @@ export function register(program) {
105
151
  await writeIndex(gcfRoot());
106
152
  console.log('INDEX.md regenerated');
107
153
  });
154
+ }
108
155
 
109
- gcf.command('context <card-id>')
156
+ function registerContext(gcf) {
157
+ gcf
158
+ .command('context <card-id>')
110
159
  .description('Assemble the readset for a card (GCF-PRD §9.1)')
111
160
  .option('--json', 'machine output')
112
161
  .action(async (cardId, opts) => {
@@ -116,60 +165,106 @@ export function register(program) {
116
165
  const { appendFile, mkdir } = await import('node:fs/promises');
117
166
  const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
118
167
  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');
168
+ await appendFile(
169
+ path.join(metricsDir, 'context-log.csv'),
170
+ `${today()},${cardId},${ctx.files.length},${ctx.total_tokens_est}\n`,
171
+ 'utf8',
172
+ );
121
173
  out(opts.json, ctx, (c) =>
122
174
  [
123
175
  ...c.files.map((f) => `${f.path} (${f.tokens_est})`),
124
176
  ...(c.graveyard_hits.length ? ['graveyard hits:', ...c.graveyard_hits] : []),
125
177
  `total ~${c.total_tokens_est} / ${c.budget}${c.over_budget ? ' OVER BUDGET' : ''}`,
126
- ].join('\n'));
178
+ ].join('\n'),
179
+ );
127
180
  if (ctx.over_budget) process.exitCode = 2;
128
181
  });
182
+ }
129
183
 
130
- gcf.command('audit')
184
+ function registerAudit(gcf) {
185
+ gcf
186
+ .command('audit')
131
187
  .description('Deterministic consistency audit (GCF-PRD §10.1)')
132
188
  .option('--json', 'machine output')
133
189
  .action(async (opts) => {
134
190
  const { audit } = await lib('audit-core');
135
191
  const report = await audit(process.cwd());
136
192
  // 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;
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;
139
198
  const { appendFile, mkdir } = await import('node:fs/promises');
140
199
  const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
141
200
  if (existsSync(path.join(process.cwd(), 'gcf'))) {
142
201
  await mkdir(metricsDir, { recursive: true });
143
- 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
+ );
144
207
  }
208
+
145
209
  out(opts.json, report, (r) => r.first_line);
146
210
  if (report.first_line !== 'clean') process.exitCode = 1;
147
211
  });
212
+ }
148
213
 
149
- gcf.command('playtest')
214
+ function registerPlaytest(gcf) {
215
+ gcf
216
+ .command('playtest')
150
217
  .description('List built cards with their miss-criterion questions')
151
218
  .option('--json', 'machine output')
152
219
  .action(async (opts) => {
153
220
  const { gcfRoot } = await lib('gcf-paths');
154
221
  const { playtestList } = await lib('playtest-list');
155
222
  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'));
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
+ );
159
228
  });
229
+ }
160
230
 
161
- gcf.command('verdict <card-id> <verdict>')
231
+ function registerVerdict(gcf) {
232
+ gcf
233
+ .command('verdict <card-id> <verdict>')
162
234
  .description('Apply a human verdict: keep | tune | kill (GCF-PRD §8)')
163
235
  .option('--note <text>', 'tune remark or kill reason')
164
236
  .option('--no-commit', 'skip the verdict commit (tests only)')
165
237
  .action(async (cardId, verdict, opts) => {
166
238
  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)})` : ''}`);
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}`);
169
245
  });
246
+ }
247
+
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
+ }
170
256
 
171
- gcf.command('anchor <file>')
172
- .description('Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta')
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
+ )
173
268
  .option('--json', 'machine output')
174
269
  .action(async (file, opts) => {
175
270
  const { splitSections, importSource, loadAnchorMap, classifyDelta } = await lib('anchors');
@@ -180,40 +275,88 @@ export function register(program) {
180
275
  const delta = oldMap?.anchors ? classifyDelta(oldMap.anchors, sections) : null;
181
276
  await importSource(process.cwd(), file);
182
277
  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'));
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
+ );
190
292
  });
293
+ }
191
294
 
192
- 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')
193
326
  .description('Install the gcf pre-commit gate into the repository hooks dir (idempotent)')
194
327
  .action(async () => {
195
328
  const { writeFile } = await import('node:fs/promises');
196
329
  // --git-path resolves the hooks dir for a plain repo, a worktree and a submodule alike,
197
330
  // 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' });
331
+ const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], {
332
+ cwd: process.cwd(),
333
+ encoding: 'utf8',
334
+ });
199
335
  if (res.status !== 0) throw new Error('not a git repository');
200
336
  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`;
337
+ const gateLine = `node "${hookScript.replaceAll('\\', '/')}" || exit 1`;
338
+ const shim = `#!/bin/sh\n# ${MARKER}\n${gateLine}\n`;
202
339
  const target = path.resolve(process.cwd(), res.stdout.trim(), 'pre-commit');
203
340
  if (existsSync(target) && !(await readFile(target, 'utf8')).includes(MARKER)) {
204
341
  // Brownfield repos usually arrive with husky or lefthook already owning pre-commit,
205
342
  // 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`);
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
+ );
208
347
  }
348
+
209
349
  await mkdir(path.dirname(target), { recursive: true });
210
350
  // Mode on creation, not a chmod afterwards that can fail with the hook already in place.
211
351
  // A file being replaced is our own earlier shim, already executable.
212
352
  await writeFile(target, shim, { encoding: 'utf8', mode: 0o755 });
213
353
  console.log(`pre-commit hook installed → ${target}`);
214
354
  });
355
+ }
215
356
 
216
- gcf.command('lint <file>')
357
+ function registerLint(gcf) {
358
+ gcf
359
+ .command('lint <file>')
217
360
  .description('П2 linter over one document (GCF-PRD §13.2)')
218
361
  .action(async (file) => {
219
362
  const { lint } = await lib('linter');
@@ -223,3 +366,18 @@ export function register(program) {
223
366
  else console.log('clean');
224
367
  });
225
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.5",
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