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 +4 -2
- package/commands/gcf.js +267 -58
- package/extension.json +1 -1
- package/hooks/pre-commit.js +28 -14
- package/injections/review-pillars.md +1 -1
- package/lib/anchors.js +108 -10
- package/lib/audit-core.js +136 -41
- package/lib/context.js +54 -36
- package/lib/frontmatter.js +2 -1
- package/lib/gcf-paths.js +14 -3
- package/lib/git.js +22 -4
- package/lib/id.js +2 -0
- package/lib/index-gen.js +33 -15
- package/lib/linter.js +79 -41
- package/lib/safe-path.js +23 -4
- package/lib/transitions.js +1 -3
- package/lib/validate-card.js +42 -8
- package/lib/verdict-fs.js +124 -61
- package/package.json +1 -1
- package/skills/gcf-adopt/SKILL.md +2 -2
- package/skills/gcf-card/SKILL.md +11 -10
- package/skills/gcf-debate/SKILL.md +1 -1
- package/skills/gcf-ingest/SKILL.md +24 -13
- package/skills/gcf-init/SKILL.md +9 -8
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/backlog.md +4 -3
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) =>
|
|
16
|
+
const out = (json, value, human) =>
|
|
17
|
+
console.log(json ? JSON.stringify(value, null, 2) : human(value));
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
const gcf = program.command('gcf').description('Game Context Framework core');
|
|
19
|
+
const HARNESS = 'gcf-harness-aif-ext';
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
69
|
-
|
|
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
|
-
|
|
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 =
|
|
87
|
-
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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, {
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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 = {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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'], {
|
|
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
|
|
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(
|
|
156
|
-
|
|
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
|
-
|
|
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
package/hooks/pre-commit.js
CHANGED
|
@@ -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(
|
|
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)
|
|
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) =>
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
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)
|
|
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')], {
|
|
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`
|
|
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
|