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 +2 -2
- package/commands/gcf.js +229 -71
- 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,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) =>
|
|
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(
|
|
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(
|
|
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], {
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
120
|
-
|
|
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
|
-
|
|
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 =
|
|
138
|
-
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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, {
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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 = {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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'], {
|
|
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
|
|
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(
|
|
207
|
-
|
|
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
|
-
|
|
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
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
|