gcf-aif-ext 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/commands/gcf.js +408 -73
- package/extension.json +1 -1
- package/hooks/pre-commit.js +47 -29
- package/injections/commit-status.md +1 -0
- package/injections/plan-context.md +3 -2
- package/injections/review-pillars.md +1 -1
- package/lib/anchors.js +110 -10
- package/lib/audit-core.js +175 -50
- package/lib/bundle.js +275 -0
- package/lib/context.js +55 -36
- package/lib/design-check.js +580 -0
- package/lib/design.js +179 -0
- package/lib/frontmatter.js +2 -1
- package/lib/gcf-paths.js +14 -3
- package/lib/git.js +85 -4
- package/lib/id.js +8 -14
- package/lib/index-gen.js +68 -16
- package/lib/ledger.js +92 -0
- package/lib/linter.js +91 -42
- package/lib/migrate.js +345 -0
- package/lib/playtest-list.js +1 -1
- package/lib/safe-path.js +23 -4
- package/lib/systems-doc.js +70 -0
- package/lib/transitions.js +1 -3
- package/lib/unit-ops.js +270 -0
- package/lib/validate-card.js +59 -14
- package/lib/verdict-fs.js +173 -96
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +2 -1
- package/profiles/unit-constraint.md +42 -0
- package/profiles/unit-entity.md +38 -0
- package/profiles/unit-mechanic.md +106 -0
- package/profiles/unit-resource.md +86 -0
- package/profiles/unit-rule.md +42 -0
- package/schemas/card.schema.json +23 -7
- package/skills/gcf-adopt/SKILL.md +3 -3
- package/skills/gcf-audit/SKILL.md +4 -3
- package/skills/gcf-card/SKILL.md +26 -16
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +43 -16
- package/skills/gcf-init/SKILL.md +9 -8
- package/skills/gcf-verdict/SKILL.md +7 -6
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -4
- package/templates/card.md +27 -10
- package/templates/cnlp.config.json +0 -1
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/system.md +1 -1
- package/profiles/card.md +0 -65
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The Game Context Framework as an [AI Factory](https://github.com/lee-to/ai-factory) extension: a documentary layer for building games with an AI agent, where the documents hold **state instead of history**.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A Design Model: the design lives in `gcf/design/<system>.md` as units with typed slots — what the GDD says, quoted and anchored, and `TBD` for everything it does not. A soft unit taken into work becomes a feature card with the same body; the card collapses into its subsystem's document on a human playtest verdict and then ceases to exist. A hard unit leaves through its tests. A graveyard keeps the reasons things were killed. Read rules keep the token cost of a task flat as the project ages.
|
|
6
6
|
|
|
7
7
|
Runtime-agnostic — everything here works on any AI Factory runtime. The Claude-Code-specific orchestration lives in [`gcf-harness-aif-ext`](../gcf-harness-aif-ext), which is optional.
|
|
8
8
|
|
|
@@ -25,7 +25,7 @@ ai-factory extension add gcf-aif-ext # npm
|
|
|
25
25
|
This installs the 8 skills into each configured runtime, registers the `gcf` command and appends the injections to `aif-plan`, `aif-review` and `aif-commit`. Then set up the project — two steps, run once:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
ai-factory gcf init # seeds gcf/ and cnlp.config.json
|
|
28
|
+
ai-factory gcf init # seeds gcf/ and cnlp.config.json (PILLARS.md is written by /gcf-init)
|
|
29
29
|
ai-factory gcf hooks-install # arms the pre-commit gate
|
|
30
30
|
```
|
|
31
31
|
|
|
@@ -58,11 +58,13 @@ Removal leaves `gcf/` and `cnlp.config.json` in place. It also leaves the pre-co
|
|
|
58
58
|
|
|
59
59
|
**Skills** — `gcf-init`, `gcf-adopt`, `gcf-card`, `gcf-verdict`, `gcf-audit`, `gcf-vision`, `gcf-debate`, `gcf-ingest`.
|
|
60
60
|
|
|
61
|
-
**A deterministic core** behind `ai-factory gcf …` — `init`, `index`, `context`, `audit`, `playtest`, `verdict`, `anchor`, `lint`, `hooks-install`. These are plain Node scripts with no LLM in the loop: the same functions the skills call, the pre-commit gate runs, and the harness gates against.
|
|
61
|
+
**A deterministic core** behind `ai-factory gcf …` — `init`, `index`, `context`, `audit`, `playtest`, `verdict`, `anchor`, `section`, `quotes`, `lint`, `design check`, `where`, `take`, `done`, `drop`, `hooks-install`. These are plain Node scripts with no LLM in the loop: the same functions the skills call, the pre-commit gate runs, and the harness gates against.
|
|
62
62
|
|
|
63
|
-
**A pre-commit gate** that breaks the commit on a terminal status stored in a card file, a malformed `[until:]` annotation, a card built on an unplayed dependency, a dependency that names no known
|
|
63
|
+
**A pre-commit gate** that breaks the commit on a terminal status stored in a card file, a malformed `[until:]` annotation, a card built on an unplayed dependency, a dependency that names no known id, a П2 linter finding in a subsystem document, or a blocking design-check finding: a malformed unit or card, a digit in prose, a forbidden synonym, a quote that is not verbatim in its GDD section, an id living in two places, a backlog out of step with `gcf/design/`. An unfinished unit (`TBD`) never breaks a commit. The gate regenerates `gcf/INDEX.md` and stages it for you.
|
|
64
64
|
|
|
65
|
-
**CNLP profiles and a card schema** for the documents the agent reads, plus injections into `aif-plan`, `aif-review` and `aif-commit`.
|
|
65
|
+
**CNLP profiles and a card schema** for the documents the agent reads — one profile per unit type, shared by a unit and its card — plus injections into `aif-plan`, `aif-review` and `aif-commit`.
|
|
66
|
+
|
|
67
|
+
**Upgrading from 0.2.x:** a card is schema 2 now — `cards/<id>.md` with a slug id, `type`, `pillars`, no `slug` and no `route`; the gate names the steps for a schema 1 card. Backlog candidate lines (`cand-NNN | …`) become units in `gcf/design/` with `ai-factory gcf design migrate` (deterministic, no commit): it keeps the backlog order, writes the quote as provenance and every slot the line did not carry as `TBD`, rewrites `cand-NNN` references in `open-questions.md` and `graveyard.md`, and copies the keeps and dones of old commit messages into `gcf/history.md` once — the ledger reads that file, not the log. Then run `ai-factory gcf design check`, fix what it reports — typically a digit in a description — and commit.
|
|
66
68
|
|
|
67
69
|
## Docs
|
|
68
70
|
|
package/commands/gcf.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
// CLI wrapper over the deterministic core (GCF-PRD §10.1): harness and human call
|
|
2
2
|
// the same functions. Registered by AIF via extension.json `commands`.
|
|
3
|
-
import { mkdir, copyFile, readFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, copyFile, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
9
|
const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
-
const lib = (name) => import(new URL(`../lib/${name}.js`, import.meta.url));
|
|
10
|
+
const lib = (name) => import(new URL(`../lib/${name}.js`, import.meta.url).href);
|
|
11
11
|
|
|
12
12
|
// Must stay byte-identical to the comment line inside the shim below: hooks-install
|
|
13
13
|
// refuses to overwrite a pre-commit hook that does not carry it.
|
|
14
14
|
const MARKER = 'gcf-aif-ext pre-commit gate';
|
|
15
15
|
|
|
16
|
-
const out = (json, value, human) =>
|
|
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,114 @@ async function askHarness() {
|
|
|
50
58
|
// Re-enter the AIF CLI that is running this command, not whatever `ai-factory` PATH holds
|
|
51
59
|
// (an npx run has none on PATH).
|
|
52
60
|
function aif(args) {
|
|
53
|
-
const res = spawnSync(process.execPath, [process.argv[1], ...args], {
|
|
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
|
+
const UNION = ['gcf/history.md merge=union', 'gcf/graveyard.md merge=union'];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Two branches that end different lives may add their lines at the same spot of history.md or
|
|
77
|
+
* graveyard.md: a union merge keeps both instead of a conflict whose "ours" loses one. Adds the
|
|
78
|
+
* missing rules to `<dir>/.gitattributes`; a project that ran an older init gets them on re-run.
|
|
79
|
+
*/
|
|
80
|
+
export async function unionAttributes(dir) {
|
|
81
|
+
const file = path.join(dir, '.gitattributes');
|
|
82
|
+
const text = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
83
|
+
const missing = UNION.filter((rule) => !text.split(/\r?\n/).includes(rule));
|
|
84
|
+
if (!missing.length) return;
|
|
85
|
+
const sep = text && !text.endsWith('\n') ? '\n' : '';
|
|
86
|
+
await writeFile(file, `${text}${sep}${missing.join('\n')}\n`, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function seedSkeleton(root) {
|
|
90
|
+
for (const dir of ['design', 'cards', 'systems', 'source', '.metrics']) {
|
|
91
|
+
await mkdir(path.join(root, dir), { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// No PILLARS.md: a seeded example would be read as the game's truth in every readset
|
|
95
|
+
// until someone replaced it. The pillars interview writes the file; until then it is absent.
|
|
96
|
+
const seed = [
|
|
97
|
+
['backlog.md', 'backlog.md'],
|
|
98
|
+
['config.yml', 'config.yml'],
|
|
99
|
+
];
|
|
100
|
+
for (const [tpl, dst] of seed) {
|
|
101
|
+
const target = path.join(root, dst);
|
|
102
|
+
if (!existsSync(target)) await copyFile(path.join(EXT_ROOT, 'templates', tpl), target);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { HISTORY_HEAD } = await lib('verdict-fs');
|
|
106
|
+
for (const [name, header] of [
|
|
107
|
+
['history.md', HISTORY_HEAD],
|
|
108
|
+
[
|
|
109
|
+
'graveyard.md',
|
|
110
|
+
'# Graveyard — `- <id> | <reason> | tags: <tags>`, one line per idea killed by a verdict ' +
|
|
111
|
+
'or dropped before work\n',
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
'open-questions.md',
|
|
115
|
+
'# Open questions — records with id/lens/sections/sys/class/question/proposed, ' +
|
|
116
|
+
'a D2 also assumed/until; answers move out, records die\n',
|
|
117
|
+
],
|
|
118
|
+
]) {
|
|
119
|
+
const target = path.join(root, name);
|
|
120
|
+
if (!existsSync(target)) await writeFile(target, header, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cnlpCfg = path.join(process.cwd(), 'cnlp.config.json');
|
|
124
|
+
if (!existsSync(cnlpCfg))
|
|
125
|
+
await copyFile(path.join(EXT_ROOT, 'templates', 'cnlp.config.json'), cnlpCfg);
|
|
126
|
+
await unionAttributes(process.cwd());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function offerHarness(program, opts) {
|
|
130
|
+
const installed = program.commands?.some(
|
|
131
|
+
(c) => (typeof c?.name === 'function' ? c.name() : c?.name) === 'gcf-harness',
|
|
132
|
+
);
|
|
133
|
+
if (installed) {
|
|
134
|
+
if (opts.harness) aif(['gcf-harness', 'init']);
|
|
135
|
+
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY) && (await claudeConfigured());
|
|
140
|
+
if (!(await harnessWanted(opts.harness, askHarness, canAsk))) return;
|
|
141
|
+
aif(['extension', 'add', HARNESS]);
|
|
142
|
+
aif(['gcf-harness', 'init']);
|
|
143
|
+
}
|
|
59
144
|
|
|
60
|
-
|
|
61
|
-
|
|
145
|
+
function registerInit(gcf, program) {
|
|
146
|
+
gcf
|
|
147
|
+
.command('init')
|
|
148
|
+
.description(
|
|
149
|
+
'Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness',
|
|
150
|
+
)
|
|
62
151
|
.option('--harness', `also install ${HARNESS} and run gcf-harness init (Claude Code only)`)
|
|
63
152
|
.option('--no-harness', 'do not offer the harness')
|
|
64
153
|
.action(async (opts) => {
|
|
65
154
|
const root = path.join(process.cwd(), 'gcf');
|
|
66
|
-
|
|
67
|
-
await mkdir(path.join(root, dir), { recursive: true });
|
|
68
|
-
}
|
|
69
|
-
const seed = [
|
|
70
|
-
['PILLARS.md', 'PILLARS.md'], ['backlog.md', 'backlog.md'], ['config.yml', 'config.yml'],
|
|
71
|
-
];
|
|
72
|
-
for (const [tpl, dst] of seed) {
|
|
73
|
-
const target = path.join(root, dst);
|
|
74
|
-
if (!existsSync(target)) await copyFile(path.join(EXT_ROOT, 'templates', tpl), target);
|
|
75
|
-
}
|
|
76
|
-
for (const [name, header] of [
|
|
77
|
-
['graveyard.md', '# Graveyard — one line per killed idea, written by gcf verdict kill\n'],
|
|
78
|
-
['open-questions.md', '# Open questions — records with id/lens/sections/sys/class/proposed; answers move out, records die\n'],
|
|
79
|
-
]) {
|
|
80
|
-
const target = path.join(root, name);
|
|
81
|
-
if (!existsSync(target)) await (await import('node:fs/promises')).writeFile(target, header, 'utf8');
|
|
82
|
-
}
|
|
83
|
-
const cnlpCfg = path.join(process.cwd(), 'cnlp.config.json');
|
|
84
|
-
if (!existsSync(cnlpCfg)) await copyFile(path.join(EXT_ROOT, 'templates', 'cnlp.config.json'), cnlpCfg);
|
|
155
|
+
await seedSkeleton(root);
|
|
85
156
|
const { writeIndex } = await lib('index-gen');
|
|
86
157
|
await writeIndex(root);
|
|
87
|
-
console.log(
|
|
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']);
|
|
158
|
+
console.log(
|
|
159
|
+
'gcf/ ready. Write PILLARS.md through /gcf-init, then import a GDD with /gcf-ingest ' +
|
|
160
|
+
'or start the design with /gcf-vision.',
|
|
161
|
+
);
|
|
162
|
+
await offerHarness(program, opts);
|
|
98
163
|
});
|
|
164
|
+
}
|
|
99
165
|
|
|
100
|
-
|
|
166
|
+
function registerIndex(gcf) {
|
|
167
|
+
gcf
|
|
168
|
+
.command('index')
|
|
101
169
|
.description('Regenerate gcf/INDEX.md (deterministic)')
|
|
102
170
|
.action(async () => {
|
|
103
171
|
const { gcfRoot } = await lib('gcf-paths');
|
|
@@ -105,8 +173,11 @@ export function register(program) {
|
|
|
105
173
|
await writeIndex(gcfRoot());
|
|
106
174
|
console.log('INDEX.md regenerated');
|
|
107
175
|
});
|
|
176
|
+
}
|
|
108
177
|
|
|
109
|
-
|
|
178
|
+
function registerContext(gcf) {
|
|
179
|
+
gcf
|
|
180
|
+
.command('context <card-id>')
|
|
110
181
|
.description('Assemble the readset for a card (GCF-PRD §9.1)')
|
|
111
182
|
.option('--json', 'machine output')
|
|
112
183
|
.action(async (cardId, opts) => {
|
|
@@ -116,104 +187,350 @@ export function register(program) {
|
|
|
116
187
|
const { appendFile, mkdir } = await import('node:fs/promises');
|
|
117
188
|
const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
|
|
118
189
|
await mkdir(metricsDir, { recursive: true });
|
|
119
|
-
await appendFile(
|
|
120
|
-
|
|
190
|
+
await appendFile(
|
|
191
|
+
path.join(metricsDir, 'context-log.csv'),
|
|
192
|
+
`${today()},${cardId},${ctx.files.length},${ctx.total_tokens_est}\n`,
|
|
193
|
+
'utf8',
|
|
194
|
+
);
|
|
121
195
|
out(opts.json, ctx, (c) =>
|
|
122
196
|
[
|
|
123
197
|
...c.files.map((f) => `${f.path} (${f.tokens_est})`),
|
|
124
198
|
...(c.graveyard_hits.length ? ['graveyard hits:', ...c.graveyard_hits] : []),
|
|
125
199
|
`total ~${c.total_tokens_est} / ${c.budget}${c.over_budget ? ' OVER BUDGET' : ''}`,
|
|
126
|
-
].join('\n')
|
|
200
|
+
].join('\n'),
|
|
201
|
+
);
|
|
127
202
|
if (ctx.over_budget) process.exitCode = 2;
|
|
128
203
|
});
|
|
204
|
+
}
|
|
129
205
|
|
|
130
|
-
|
|
206
|
+
function registerAudit(gcf) {
|
|
207
|
+
gcf
|
|
208
|
+
.command('audit')
|
|
131
209
|
.description('Deterministic consistency audit (GCF-PRD §10.1)')
|
|
132
210
|
.option('--json', 'machine output')
|
|
133
211
|
.action(async (opts) => {
|
|
134
212
|
const { audit } = await lib('audit-core');
|
|
135
213
|
const report = await audit(process.cwd());
|
|
136
214
|
// Metric 2 (GCF-PRD §14): findings per audit run.
|
|
137
|
-
const findings =
|
|
138
|
-
|
|
215
|
+
const findings =
|
|
216
|
+
report.stale_built.length +
|
|
217
|
+
report.tune_limit.length +
|
|
218
|
+
report.until.filter((u) => u.status === 'stale').length +
|
|
219
|
+
Object.keys(report.linter).length;
|
|
139
220
|
const { appendFile, mkdir } = await import('node:fs/promises');
|
|
140
221
|
const metricsDir = path.join(process.cwd(), 'gcf', '.metrics');
|
|
141
222
|
if (existsSync(path.join(process.cwd(), 'gcf'))) {
|
|
142
223
|
await mkdir(metricsDir, { recursive: true });
|
|
143
|
-
await appendFile(
|
|
224
|
+
await appendFile(
|
|
225
|
+
path.join(metricsDir, 'audit-log.csv'),
|
|
226
|
+
`${today()},${findings}\n`,
|
|
227
|
+
'utf8',
|
|
228
|
+
);
|
|
144
229
|
}
|
|
230
|
+
|
|
145
231
|
out(opts.json, report, (r) => r.first_line);
|
|
146
232
|
if (report.first_line !== 'clean') process.exitCode = 1;
|
|
147
233
|
});
|
|
234
|
+
}
|
|
148
235
|
|
|
149
|
-
|
|
236
|
+
function registerPlaytest(gcf) {
|
|
237
|
+
gcf
|
|
238
|
+
.command('playtest')
|
|
150
239
|
.description('List built cards with their miss-criterion questions')
|
|
151
240
|
.option('--json', 'machine output')
|
|
152
241
|
.action(async (opts) => {
|
|
153
242
|
const { gcfRoot } = await lib('gcf-paths');
|
|
154
243
|
const { playtestList } = await lib('playtest-list');
|
|
155
244
|
const list = await playtestList(gcfRoot());
|
|
156
|
-
out(opts.json, list, (l) =>
|
|
157
|
-
|
|
158
|
-
|
|
245
|
+
out(opts.json, list, (l) =>
|
|
246
|
+
l.length
|
|
247
|
+
? l.map((c) => `${c.id} [${c.system.join(',')}]: ${c.question}`).join('\n')
|
|
248
|
+
: 'nothing in built — nothing to playtest',
|
|
249
|
+
);
|
|
159
250
|
});
|
|
251
|
+
}
|
|
160
252
|
|
|
161
|
-
|
|
253
|
+
function registerVerdict(gcf) {
|
|
254
|
+
gcf
|
|
255
|
+
.command('verdict <card-id> <verdict>')
|
|
162
256
|
.description('Apply a human verdict: keep | tune | kill (GCF-PRD §8)')
|
|
163
257
|
.option('--note <text>', 'tune remark or kill reason')
|
|
164
258
|
.option('--no-commit', 'skip the verdict commit (tests only)')
|
|
165
259
|
.action(async (cardId, verdict, opts) => {
|
|
166
260
|
const { applyVerdict } = await lib('verdict-fs');
|
|
167
|
-
const res = await applyVerdict(process.cwd(), cardId, verdict, {
|
|
168
|
-
|
|
261
|
+
const res = await applyVerdict(process.cwd(), cardId, verdict, {
|
|
262
|
+
note: opts.note ?? '',
|
|
263
|
+
commit: opts.commit,
|
|
264
|
+
});
|
|
265
|
+
const hash = res.hash ? ` (${res.hash.slice(0, 7)})` : '';
|
|
266
|
+
console.log(`${res.verdict} ${res.card} → ${res.systems.join(',')}${hash}`);
|
|
169
267
|
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function anchorDeltaLine(r) {
|
|
271
|
+
if (r.delta) {
|
|
272
|
+
const { added, changed, removed, renamed } = r.delta;
|
|
170
273
|
|
|
171
|
-
|
|
172
|
-
|
|
274
|
+
return (
|
|
275
|
+
`delta: +${added.length} ~${changed.length} -${removed.length} ` + `renamed ${renamed.length}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return r.rebaselined
|
|
280
|
+
? 're-baselined: the stored anchor map predates the current hashing, no delta computed'
|
|
281
|
+
: 'first import';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function registerAnchor(gcf) {
|
|
285
|
+
gcf
|
|
286
|
+
.command('anchor <file>')
|
|
287
|
+
.description(
|
|
288
|
+
'Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta',
|
|
289
|
+
)
|
|
173
290
|
.option('--json', 'machine output')
|
|
174
291
|
.action(async (file, opts) => {
|
|
175
|
-
const
|
|
292
|
+
const anchors = await lib('anchors');
|
|
293
|
+
const { splitSections, importSource, loadAnchorMap, classifyDelta } = anchors;
|
|
176
294
|
const base = path.basename(file);
|
|
177
295
|
const oldMap = await loadAnchorMap(process.cwd(), base);
|
|
178
296
|
const { readFile } = await import('node:fs/promises');
|
|
179
297
|
const sections = splitSections(await readFile(file, 'utf8'));
|
|
180
298
|
const delta = oldMap?.anchors ? classifyDelta(oldMap.anchors, sections) : null;
|
|
181
299
|
await importSource(process.cwd(), file);
|
|
300
|
+
// A renamed heading moves its anchor: provenance and source: follow it.
|
|
301
|
+
const rewritten = delta ? await anchors.rewriteAnchors(process.cwd(), delta.renamed) : [];
|
|
182
302
|
const rebaselined = Boolean(oldMap?.rebaselined);
|
|
183
|
-
const result = {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
303
|
+
const result = {
|
|
304
|
+
file: `gcf/source/${base}`,
|
|
305
|
+
sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading, lines: s.lines })),
|
|
306
|
+
delta,
|
|
307
|
+
rebaselined,
|
|
308
|
+
rewritten,
|
|
309
|
+
};
|
|
310
|
+
out(opts.json, result, (r) =>
|
|
311
|
+
[
|
|
312
|
+
...r.sections.map(
|
|
313
|
+
(s) => `${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`,
|
|
314
|
+
),
|
|
315
|
+
anchorDeltaLine(r),
|
|
316
|
+
...r.rewritten.map((f) => `renamed anchors rewritten in gcf/${f}`),
|
|
317
|
+
].join('\n'),
|
|
318
|
+
);
|
|
190
319
|
});
|
|
320
|
+
}
|
|
191
321
|
|
|
192
|
-
|
|
322
|
+
function registerSection(gcf) {
|
|
323
|
+
gcf
|
|
324
|
+
.command('section <source> <anchor>')
|
|
325
|
+
.description('Print one anchored section of a gcf/source/ file (read by anchor, not whole)')
|
|
326
|
+
.action(async (source, anchor) => {
|
|
327
|
+
const { readSection } = await lib('anchors');
|
|
328
|
+
const s = await readSection(process.cwd(), source, anchor);
|
|
329
|
+
if (!s) throw new Error(`${anchor} is not a section of gcf/source/${path.basename(source)}`);
|
|
330
|
+
console.log(`${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`);
|
|
331
|
+
console.log(s.body);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function registerQuotes(gcf) {
|
|
336
|
+
gcf
|
|
337
|
+
.command('quotes')
|
|
338
|
+
.description('Provenance only: every quote verbatim in its src: section (design check M9)')
|
|
339
|
+
.option('--json', 'machine output')
|
|
340
|
+
.action(async (opts) => {
|
|
341
|
+
const { designCheck } = await lib('design-check');
|
|
342
|
+
const problems = (await designCheck(process.cwd())).issues.filter((i) => i.check === 'M9');
|
|
343
|
+
out(opts.json, problems, (l) =>
|
|
344
|
+
l.length ? l.map((p) => `${p.file}:${p.line} ${p.id}: ${p.message}`).join('\n') : 'clean',
|
|
345
|
+
);
|
|
346
|
+
if (problems.length) process.exitCode = 1;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const issueLine = (i) => `${i.severity} ${i.check} ${i.file}:${i.line} ${i.id ?? ''} ${i.message}`;
|
|
351
|
+
|
|
352
|
+
function designSummary(r) {
|
|
353
|
+
const rows = Object.entries(r.systems).map(
|
|
354
|
+
([s, t]) => `${s}: ${t.units} units, ${t.ready} ready, ${t.tbd}/${t.values} TBD`,
|
|
355
|
+
);
|
|
356
|
+
const blocking = r.issues.filter((i) => i.severity === 'block');
|
|
357
|
+
const notes = r.issues.filter((i) => i.severity === 'report');
|
|
358
|
+
|
|
359
|
+
return [
|
|
360
|
+
...blocking.map(issueLine),
|
|
361
|
+
...notes.map(issueLine),
|
|
362
|
+
...rows,
|
|
363
|
+
blocking.length ? `${blocking.length} blocking finding(s)` : 'no blocking findings',
|
|
364
|
+
].join('\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function registerDesign(gcf) {
|
|
368
|
+
const design = gcf.command('design').description('Design Model: units in gcf/design/');
|
|
369
|
+
design
|
|
370
|
+
.command('check')
|
|
371
|
+
.description('Mechanical checks over gcf/design/ and the cards; exit 1 on a blocking finding')
|
|
372
|
+
.option('--json', 'machine output')
|
|
373
|
+
.action(async (opts) => {
|
|
374
|
+
const { designCheck } = await lib('design-check');
|
|
375
|
+
const report = await designCheck(process.cwd());
|
|
376
|
+
out(opts.json, report, designSummary);
|
|
377
|
+
if (report.blocking) process.exitCode = 1;
|
|
378
|
+
});
|
|
379
|
+
design
|
|
380
|
+
.command('export')
|
|
381
|
+
.description('Glue game.md, glossary.md and the units into one bundle a designer edits')
|
|
382
|
+
.option('--system <name>', 'only this subsystem')
|
|
383
|
+
.option('--milestone <name>', 'only the units of this milestone')
|
|
384
|
+
.option('--out <file>', 'write the bundle here instead of stdout')
|
|
385
|
+
.action(async (opts) => {
|
|
386
|
+
const { exportBundle } = await lib('bundle');
|
|
387
|
+
const text = await exportBundle(process.cwd(), opts);
|
|
388
|
+
if (!opts.out) return void process.stdout.write(text);
|
|
389
|
+
const { writeFile } = await import('node:fs/promises');
|
|
390
|
+
await writeFile(opts.out, text, 'utf8');
|
|
391
|
+
console.log(`bundle written → ${opts.out}`);
|
|
392
|
+
});
|
|
393
|
+
design
|
|
394
|
+
.command('import <bundle>')
|
|
395
|
+
.description('Bring an edited bundle back: 3-way by base hash, conflicts reported, no commit')
|
|
396
|
+
.option('--json', 'machine output')
|
|
397
|
+
.action(async (bundle, opts) => {
|
|
398
|
+
const { importBundle } = await lib('bundle');
|
|
399
|
+
const r = await importBundle(process.cwd(), await readFile(bundle, 'utf8'));
|
|
400
|
+
out(opts.json, r, (x) =>
|
|
401
|
+
[
|
|
402
|
+
...x.applied.map((a) => `applied ${a}`),
|
|
403
|
+
...x.report.map((p) => `${p.outcome} ${p.part}: ${p.note}`),
|
|
404
|
+
x.applied.length || x.report.length ? '' : 'nothing changed',
|
|
405
|
+
]
|
|
406
|
+
.filter(Boolean)
|
|
407
|
+
.join('\n'),
|
|
408
|
+
);
|
|
409
|
+
if (r.report.some((p) => p.outcome !== 'deleted')) process.exitCode = 1;
|
|
410
|
+
});
|
|
411
|
+
registerMigrate(design);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function registerMigrate(design) {
|
|
415
|
+
design
|
|
416
|
+
.command('migrate')
|
|
417
|
+
.description('One-shot: backlog cand-NNN lines become units in gcf/design/ (no commit)')
|
|
418
|
+
.option(
|
|
419
|
+
'--skip <ids>',
|
|
420
|
+
'converted by hand, comma-separated: cand-NNN, cand-NNN=<id> or cand-NNN=<id>+<id>',
|
|
421
|
+
)
|
|
422
|
+
.action(async (opts) => {
|
|
423
|
+
const { migrateBacklog } = await lib('migrate');
|
|
424
|
+
const skip = opts.skip ? opts.skip.split(',').map((s) => s.trim()) : [];
|
|
425
|
+
const r = await migrateBacklog(process.cwd(), { skip });
|
|
426
|
+
for (const l of r.lost_depends) console.log(`lost depends: ${l} (map it: --skip cand=id)`);
|
|
427
|
+
for (const l of r.maybe_duplicates) console.log(`maybe a duplicate: ${l}`);
|
|
428
|
+
for (const l of r.unmoved_gaps) console.log(`check its unit holds these gaps: ${l}`);
|
|
429
|
+
console.log(
|
|
430
|
+
`${r.units} units written, ${r.skipped} skipped, ${r.history} history lines from git; ` +
|
|
431
|
+
'run ai-factory gcf design check',
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const placeLines = (x) => [...x.live.map((p) => `${p.state} gcf/${p.file}`), ...x.terminal];
|
|
437
|
+
|
|
438
|
+
function registerWhere(gcf) {
|
|
439
|
+
gcf
|
|
440
|
+
.command('where [id]')
|
|
441
|
+
.description('Where an id lives: a design section, a card, a terminal event; no id lists all')
|
|
442
|
+
.option('--json', 'machine output')
|
|
443
|
+
.action(async (id, opts) => {
|
|
444
|
+
const { buildLedger } = await lib('ledger');
|
|
445
|
+
const ledger = await buildLedger(process.cwd());
|
|
446
|
+
// Without an id: every id the project has used, so an import never mints a taken one.
|
|
447
|
+
if (!id) {
|
|
448
|
+
const all = ledger.ids().map((i) => ledger.where(i));
|
|
449
|
+
out(opts.json, all, (l) => l.map((x) => `${x.id}: ${placeLines(x).join(', ')}`).join('\n'));
|
|
450
|
+
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
out(opts.json, ledger.where(id), (x) => placeLines(x).join('\n') || `${id} is unknown`);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function registerMoves(gcf) {
|
|
459
|
+
const ops = () => lib('unit-ops');
|
|
460
|
+
gcf
|
|
461
|
+
.command('take <id>')
|
|
462
|
+
.description('Move a ready soft unit from gcf/design/ into cards/<id>.md (no commit)')
|
|
463
|
+
.action(async (id) => console.log((await (await ops()).takeUnit(process.cwd(), id)).card));
|
|
464
|
+
gcf
|
|
465
|
+
.command('done <id>')
|
|
466
|
+
.description('A hard unit is built and tested: it leaves gcf/design/ (commit gcf(done))')
|
|
467
|
+
.option(
|
|
468
|
+
'--constraint <line>',
|
|
469
|
+
'a line every reader of the subsystem must remember',
|
|
470
|
+
(v, p) => [...p, v],
|
|
471
|
+
[],
|
|
472
|
+
)
|
|
473
|
+
.option('--no-commit', 'skip the commit (tests only)')
|
|
474
|
+
.action(async (id, o) => {
|
|
475
|
+
const r = await (
|
|
476
|
+
await ops()
|
|
477
|
+
).doneUnit(process.cwd(), id, { constraints: o.constraint, commit: o.commit });
|
|
478
|
+
console.log(`done ${r.id}${r.hash ? ` (${r.hash.slice(0, 7)})` : ''}`);
|
|
479
|
+
});
|
|
480
|
+
gcf
|
|
481
|
+
.command('drop <id>')
|
|
482
|
+
.description(
|
|
483
|
+
'Drop a unit before any work: a graveyard line keeps the reason (commit gcf(drop))',
|
|
484
|
+
)
|
|
485
|
+
.requiredOption('--reason <text>', 'why the idea dies')
|
|
486
|
+
.option('--tags <list>', 'graveyard tags, comma-separated (default: its systems)')
|
|
487
|
+
.option('--no-commit', 'skip the commit (tests only)')
|
|
488
|
+
.action(async (id, o) => {
|
|
489
|
+
const tags = o.tags ? o.tags.split(',').map((t) => t.trim()) : [];
|
|
490
|
+
const r = await (
|
|
491
|
+
await ops()
|
|
492
|
+
).dropUnit(process.cwd(), id, { reason: o.reason, tags, commit: o.commit });
|
|
493
|
+
console.log(`dropped ${r.id}${r.hash ? ` (${r.hash.slice(0, 7)})` : ''}`);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function registerHooksInstall(gcf) {
|
|
498
|
+
gcf
|
|
499
|
+
.command('hooks-install')
|
|
193
500
|
.description('Install the gcf pre-commit gate into the repository hooks dir (idempotent)')
|
|
194
501
|
.action(async () => {
|
|
195
502
|
const { writeFile } = await import('node:fs/promises');
|
|
196
503
|
// --git-path resolves the hooks dir for a plain repo, a worktree and a submodule alike,
|
|
197
504
|
// where a bare existsSync('.git') passes on a worktree and then fails with ENOTDIR.
|
|
198
|
-
const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
|
505
|
+
const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
|
506
|
+
cwd: process.cwd(),
|
|
507
|
+
encoding: 'utf8',
|
|
508
|
+
});
|
|
199
509
|
if (res.status !== 0) throw new Error('not a git repository');
|
|
200
510
|
const hookScript = path.join(EXT_ROOT, 'hooks', 'pre-commit.js');
|
|
201
|
-
const
|
|
511
|
+
const gateLine = `node "${hookScript.replaceAll('\\', '/')}" || exit 1`;
|
|
512
|
+
const shim = `#!/bin/sh\n# ${MARKER}\n${gateLine}\n`;
|
|
202
513
|
const target = path.resolve(process.cwd(), res.stdout.trim(), 'pre-commit');
|
|
203
514
|
if (existsSync(target) && !(await readFile(target, 'utf8')).includes(MARKER)) {
|
|
204
515
|
// Brownfield repos usually arrive with husky or lefthook already owning pre-commit,
|
|
205
516
|
// so the refusal hands over the exact line to chain instead of just naming the file.
|
|
206
|
-
throw new Error(
|
|
207
|
-
|
|
517
|
+
throw new Error(
|
|
518
|
+
`${target} already exists and is not ours - back it up, ` +
|
|
519
|
+
`or chain the gate from it with:\n${gateLine}`,
|
|
520
|
+
);
|
|
208
521
|
}
|
|
522
|
+
|
|
209
523
|
await mkdir(path.dirname(target), { recursive: true });
|
|
210
524
|
// Mode on creation, not a chmod afterwards that can fail with the hook already in place.
|
|
211
525
|
// A file being replaced is our own earlier shim, already executable.
|
|
212
526
|
await writeFile(target, shim, { encoding: 'utf8', mode: 0o755 });
|
|
213
527
|
console.log(`pre-commit hook installed → ${target}`);
|
|
214
528
|
});
|
|
529
|
+
}
|
|
215
530
|
|
|
216
|
-
|
|
531
|
+
function registerLint(gcf) {
|
|
532
|
+
gcf
|
|
533
|
+
.command('lint <file>')
|
|
217
534
|
.description('П2 linter over one document (GCF-PRD §13.2)')
|
|
218
535
|
.action(async (file) => {
|
|
219
536
|
const { lint } = await lib('linter');
|
|
@@ -223,3 +540,21 @@ export function register(program) {
|
|
|
223
540
|
else console.log('clean');
|
|
224
541
|
});
|
|
225
542
|
}
|
|
543
|
+
|
|
544
|
+
export function register(program) {
|
|
545
|
+
const gcf = program.command('gcf').description('Game Context Framework core');
|
|
546
|
+
registerInit(gcf, program);
|
|
547
|
+
registerIndex(gcf);
|
|
548
|
+
registerContext(gcf);
|
|
549
|
+
registerAudit(gcf);
|
|
550
|
+
registerPlaytest(gcf);
|
|
551
|
+
registerVerdict(gcf);
|
|
552
|
+
registerAnchor(gcf);
|
|
553
|
+
registerSection(gcf);
|
|
554
|
+
registerQuotes(gcf);
|
|
555
|
+
registerDesign(gcf);
|
|
556
|
+
registerWhere(gcf);
|
|
557
|
+
registerMoves(gcf);
|
|
558
|
+
registerHooksInstall(gcf);
|
|
559
|
+
registerLint(gcf);
|
|
560
|
+
}
|
package/extension.json
CHANGED