gcf-aif-ext 0.2.7 → 0.4.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 +54 -10
- package/commands/gcf.js +452 -30
- package/extension.json +22 -5
- package/hooks/pre-commit.js +69 -41
- package/injections/commit-status.md +7 -4
- package/injections/plan-context.md +8 -4
- package/injections/review-pillars.md +4 -3
- package/lib/anchors.js +60 -35
- package/lib/audit-core.js +172 -55
- package/lib/bundle.js +278 -0
- package/lib/context.js +25 -23
- package/lib/design-check.js +631 -0
- package/lib/design.js +179 -0
- package/lib/gcf-paths.js +13 -2
- package/lib/git.js +63 -0
- package/lib/id.js +7 -15
- package/lib/inbox.js +54 -0
- package/lib/index-gen.js +62 -13
- package/lib/ledger.js +94 -0
- package/lib/linter.js +17 -5
- package/lib/live.js +179 -0
- package/lib/migrate.js +345 -0
- package/lib/plan-check.js +31 -0
- package/lib/playtest-list.js +9 -5
- package/lib/systems-doc.js +85 -0
- package/lib/transitions.js +36 -21
- package/lib/unit-ops.js +297 -0
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +112 -165
- package/lib/work-ops.js +727 -0
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +3 -2
- package/profiles/system.md +1 -1
- package/profiles/unit-constraint.md +52 -0
- package/profiles/unit-entity.md +48 -0
- package/profiles/unit-mechanic.md +111 -0
- package/profiles/unit-resource.md +91 -0
- package/profiles/unit-rule.md +52 -0
- package/schemas/item.schema.json +105 -0
- package/skills/gcf-adopt/SKILL.md +3 -3
- package/skills/gcf-audit/SKILL.md +7 -6
- package/skills/gcf-card/SKILL.md +31 -17
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +39 -23
- package/skills/gcf-init/SKILL.md +5 -5
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -15
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -5
- package/templates/cnlp.config.json +0 -1
- package/templates/config.yml +7 -0
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/item.md +58 -0
- package/templates/system.md +1 -1
- package/lib/validate-card.js +0 -82
- package/profiles/card.md +0 -65
- package/schemas/card.schema.json +0 -30
- package/templates/card.md +0 -34
package/hooks/pre-commit.js
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
// The gate reads the WORKING TREE, so it only runs when the commit touches gcf/ and
|
|
5
5
|
// refuses to judge a gcf/ whose working tree and index disagree.
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
-
import { readFile } from 'node:fs/promises';
|
|
8
7
|
import { spawnSync } from 'node:child_process';
|
|
9
8
|
import path from 'node:path';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { listSystems, legacyCards } from '../lib/gcf-paths.js';
|
|
10
|
+
import { validateItem, validateUntil } from '../lib/validate-item.js';
|
|
12
11
|
import { stemMatches } from '../lib/id.js';
|
|
13
12
|
import { writeIndex } from '../lib/index-gen.js';
|
|
14
13
|
import { lint } from '../lib/linter.js';
|
|
14
|
+
import { buildLedger } from '../lib/ledger.js';
|
|
15
|
+
import { designCheck } from '../lib/design-check.js';
|
|
16
|
+
import { bodyHash } from '../lib/work-ops.js';
|
|
17
|
+
import { checkLive } from '../lib/live.js';
|
|
15
18
|
|
|
16
19
|
const projectDir = process.cwd();
|
|
17
20
|
const root = path.join(projectDir, 'gcf');
|
|
@@ -25,72 +28,97 @@ const fail = (msg) => {
|
|
|
25
28
|
// null when git itself failed (unborn HEAD, no work tree) — then fall through to the gate
|
|
26
29
|
// rather than letting a broken probe wave a commit past every check.
|
|
27
30
|
function git(args) {
|
|
28
|
-
|
|
31
|
+
// quotePath off: a non-ASCII path in the refusal reads as itself, not as octal escapes.
|
|
32
|
+
const res = spawnSync('git', ['-c', 'core.quotePath=false', ...args], {
|
|
33
|
+
cwd: projectDir,
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
});
|
|
29
36
|
|
|
30
37
|
return res.status === 0 ? res.stdout.trim() : null;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
// gcf/.metrics is left out: `gcf audit` and `gcf context` append to it on every run, and the
|
|
41
|
+
// gate reads none of it. Hooks run from the repository root, so the pathspecs are relative.
|
|
42
|
+
const scope = ['--', 'gcf', ':(exclude)gcf/.metrics'];
|
|
43
|
+
const staged = git(['diff', '--cached', '--name-only', ...scope]);
|
|
34
44
|
if (staged === '') process.exit(0); // this commit does not touch gcf/
|
|
35
45
|
if (staged !== null) {
|
|
36
|
-
// Untracked counts:
|
|
37
|
-
// validated and written into the staged INDEX while not being in the repository
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
// Untracked counts: items and units are found by reading directories, so an uncommitted file
|
|
47
|
+
// would be validated and written into the staged INDEX while not being in the repository.
|
|
48
|
+
const outside = [
|
|
49
|
+
git(['diff', '--name-only', ...scope]),
|
|
50
|
+
git(['ls-files', '--others', '--exclude-standard', ...scope]),
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join('\n');
|
|
54
|
+
if (outside) {
|
|
55
|
+
// A move (verdict, take, close, drop…) commits only its own files, so staging does not help it.
|
|
41
56
|
fail(
|
|
42
|
-
|
|
57
|
+
`gcf/ has changes outside the index (${outside.split('\n').join(', ')}) — stage them, ` +
|
|
58
|
+
'or commit gcf/ separately; before a gcf move, commit or stash them ' +
|
|
43
59
|
'(the gate reads the working tree)',
|
|
44
60
|
);
|
|
45
61
|
}
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
64
|
+
// Schema 2 cards are not read by 0.4: left in place they would silently stop being checked.
|
|
65
|
+
const legacy = legacyCards(root);
|
|
66
|
+
if (legacy.length)
|
|
67
|
+
fail(
|
|
68
|
+
`gcf/cards/ holds schema 2 card(s) (${legacy.join(', ')}): GCF 0.4 keeps work in ` +
|
|
69
|
+
'gcf/work/<id>.md with schema 3 and does not migrate — put each card back into ' +
|
|
70
|
+
'gcf/design/ as a unit and run ai-factory gcf take, then delete gcf/cards/',
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const ledger = await buildLedger(projectDir);
|
|
51
74
|
|
|
52
75
|
// 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
|
|
53
|
-
for (const c of
|
|
76
|
+
for (const c of ledger.items) {
|
|
54
77
|
const errors = [
|
|
55
|
-
...
|
|
78
|
+
...validateItem(c.data),
|
|
56
79
|
...validateUntil(c.body),
|
|
57
|
-
...(stemMatches(c.file, c.data.id
|
|
58
|
-
? []
|
|
59
|
-
: [`filename must be ${c.data.id}-${c.data.slug}.md`]),
|
|
80
|
+
...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
|
|
60
81
|
];
|
|
61
|
-
if (errors.length) fail(
|
|
82
|
+
if (errors.length) fail(`work/${c.name}: ${errors[0]}`);
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
for (const s of
|
|
85
|
+
for (const s of await listSystems(root)) {
|
|
65
86
|
const errors = validateUntil(s.body);
|
|
66
87
|
if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
|
|
67
88
|
}
|
|
68
89
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const collapsed = (id) =>
|
|
75
|
-
new RegExp(`^gcf\\(verdict\\): \\w+ ${id}\\b`, 'm').test(verdictLog) ||
|
|
76
|
-
new RegExp(`^- ${id} \\|`, 'm').test(graveyard);
|
|
77
|
-
|
|
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)`);
|
|
82
|
-
}
|
|
83
|
-
} else if (!collapsed(dep)) {
|
|
84
|
-
fail(`${c.data.id} depends on unknown ${dep} (no card, no verdict, no graveyard line)`);
|
|
85
|
-
}
|
|
90
|
+
// An unplayed item's body is the source of its systems/ lines (Methodology §5.4): edited after
|
|
91
|
+
// its close, a tune or a kill would take the wrong lines out. `close` froze it by hash.
|
|
92
|
+
for (const c of ledger.items.filter((x) => x.data.status === 'unplayed')) {
|
|
93
|
+
if (bodyHash(c.body) !== c.data.frozen)
|
|
94
|
+
fail(`work/${c.name}: an unplayed item's body is frozen — tune it to change it`);
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
// A unit or item leaving without its move loses its reason, not its integrity: one still citing
|
|
98
|
+
// it fails M2 below, in this commit. A warning, not a refusal — a revert of the commit that
|
|
99
|
+
// added it removes an id legitimately.
|
|
100
|
+
for (const id of ledger.vanished()) {
|
|
101
|
+
console.error(
|
|
102
|
+
`gcf pre-commit: warning: ${id} left design/ or work/ without a move — ` +
|
|
103
|
+
'no history or graveyard line keeps why',
|
|
104
|
+
);
|
|
90
105
|
}
|
|
91
106
|
|
|
107
|
+
// Design model and item bodies: every finding of severity `block` refuses the commit.
|
|
108
|
+
const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
|
|
109
|
+
if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
|
|
110
|
+
|
|
111
|
+
// Dependencies are held by the moves, not here (Methodology §4.1): `submit` refuses an unplayed
|
|
112
|
+
// one, `close` and `keep` a not-yet-kept one. A gate check would refuse the tune or the kill of
|
|
113
|
+
// a dependency whose dependent is already waiting for its verdict — a legal move, blocked.
|
|
114
|
+
|
|
115
|
+
// LIVE.md form: a malformed record would hide from every reader of the snapshot.
|
|
116
|
+
const live = await checkLive(projectDir);
|
|
117
|
+
const form = live.problems[0] ?? live.issues.find((i) => /summary|twice/.test(i));
|
|
118
|
+
if (form) fail(`LIVE.md: ${form}`);
|
|
119
|
+
|
|
92
120
|
// 5: П2 linter over systems docs.
|
|
93
|
-
for (const s of
|
|
121
|
+
for (const s of await listSystems(root)) {
|
|
94
122
|
const { issues } = lint(s.body);
|
|
95
123
|
if (issues.length)
|
|
96
124
|
fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
## GCF:
|
|
1
|
+
## GCF: work item status after commit (from gcf-aif-ext)
|
|
2
2
|
|
|
3
3
|
gcf_commit:
|
|
4
|
-
- when `gcf/` exists and the commit implements a
|
|
5
|
-
-
|
|
6
|
-
-
|
|
4
|
+
- when `gcf/` exists and the commit implements a work item, commit the implementation first, then move the item with its own command in a follow-up commit
|
|
5
|
+
- gate `verdict` or `decision`: run `ai-factory gcf submit <item-id>` — it waits for the operator, `since:` records where the wait began
|
|
6
|
+
- gate `auto`, every check green: write the item's `live:` delta, then run `ai-factory gcf close <item-id>` — never edit `status:` by hand
|
|
7
|
+
- the work cannot go on: run `ai-factory gcf block <item-id> --reason "<why or the question>"`
|
|
8
|
+
- do not write a terminal status into an item file: a terminal move deletes the file and writes the history or graveyard line itself
|
|
9
|
+
- when the commit makes the tests of a hard unit still in `gcf/design/` green, run `ai-factory gcf done <id>` after it
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
## GCF: readset before planning (from gcf-aif-ext)
|
|
2
2
|
|
|
3
3
|
gcf_gate:
|
|
4
|
-
- when `gcf/` exists and the task names a
|
|
5
|
-
- the returned file list is the readset: read those files and nothing else under `gcf
|
|
4
|
+
- when `gcf/` exists and the task names a work item, run `ai-factory gcf context <item-id>` first
|
|
5
|
+
- the returned file list is the readset: read those files and nothing else under `gcf/`; `gcf/LIVE.md` in it says what the game already does
|
|
6
6
|
- `over_budget: true` in the result: stop and record a D1 question instead of planning
|
|
7
|
-
-
|
|
8
|
-
-
|
|
7
|
+
- an id in `depends:` that lives in `gcf/design/`, or as an item in any status but unplayed, parked or awaiting-verdict, is not played: do not plan on top of it, stop
|
|
8
|
+
- a dependency awaiting its verdict is planned on: the close of this item then waits for that verdict (`ai-factory gcf block <id> --reason "waits for <dep>"`)
|
|
9
|
+
- write the plan to the item's `plan:` path, by default `.ai-factory/plans/<item-id>.md`, with task checkboxes
|
|
10
|
+
- a design-unit item: shape the plan by its slots — intent, behavior, refusal, boundaries, persistence, feel envelopes, contract, interactions, constraints, miss criterion; a hard unit plans its tests from `statement`, `threshold` or `fields`
|
|
11
|
+
- a bug item: the first task writes the test that reproduces `repro:`, the fix comes after it
|
|
12
|
+
- after the plan, run `ai-factory gcf plan check <item-id>`: a failure is fixed in the plan before implementation
|
|
@@ -1,7 +1,8 @@
|
|
|
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
|
|
5
|
-
- check the diff against every negative constraint of the
|
|
4
|
+
- when `gcf/` exists and the change implements a work item, read the item and `gcf/PILLARS.md` when it exists (it stays absent until the pillars interview)
|
|
5
|
+
- check the diff against every negative constraint of the item: 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
|
-
- numeric feel values in code outside the
|
|
7
|
+
- numeric feel values in code outside the item's envelope: report the value and the envelope bounds
|
|
8
|
+
- a capability the diff adds, changes or removes that the item's `live:` delta does not name: a finding
|
package/lib/anchors.js
CHANGED
|
@@ -26,6 +26,28 @@ const EMPTY_HASH = h8('');
|
|
|
26
26
|
// human with D1 questions about a GDD nobody edited.
|
|
27
27
|
export const HASH_VERSION = 2;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Line indexes inside a closed fenced block: a `# comment` there is code, not a heading. A fence
|
|
31
|
+
* that never closes is ignored — a stray ``` must not swallow every heading after it.
|
|
32
|
+
*/
|
|
33
|
+
function fencedLines(lines) {
|
|
34
|
+
const inside = new Set();
|
|
35
|
+
let open = -1;
|
|
36
|
+
let fence = '';
|
|
37
|
+
for (const [i, line] of lines.entries()) {
|
|
38
|
+
const f = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
39
|
+
if (!f) continue;
|
|
40
|
+
if (open < 0) [open, fence] = [i, f[1]];
|
|
41
|
+
// CommonMark: the closing fence is the same character, at least as long, with no info string.
|
|
42
|
+
else if (f[1][0] === fence[0] && f[1].length >= fence.length && !f[2].trim()) {
|
|
43
|
+
for (let j = open + 1; j < i; j++) inside.add(j);
|
|
44
|
+
open = -1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return inside;
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/**
|
|
30
52
|
* Split markdown into heading-addressed sections.
|
|
31
53
|
* Text before the first heading is `GDD#preamble`.
|
|
@@ -44,8 +66,9 @@ export function splitSections(markdown) {
|
|
|
44
66
|
sections.push(current);
|
|
45
67
|
};
|
|
46
68
|
|
|
69
|
+
const fenced = fencedLines(lines);
|
|
47
70
|
for (const [i, line] of lines.entries()) {
|
|
48
|
-
const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
71
|
+
const m = !fenced.has(i) && line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
49
72
|
if (m) {
|
|
50
73
|
flush();
|
|
51
74
|
current = { heading: m[2], level: m[1].length, start: i + 1, lines: [] };
|
|
@@ -155,27 +178,10 @@ export async function readSection(projectDir, baseName, anchor) {
|
|
|
155
178
|
return splitSections(await readFile(file, 'utf8')).find((s) => s.anchor === anchor) ?? null;
|
|
156
179
|
}
|
|
157
180
|
|
|
158
|
-
/**
|
|
159
|
-
* Provenance check over gcf/backlog.md (GCF-PRD §11.2.3): every line citing `src: GDD#…`
|
|
160
|
-
* must carry `q: "…"`, and the quote must be a verbatim substring of that section in some
|
|
161
|
-
* gcf/source/ file. Line wraps in the GDD are not edits, so runs of whitespace compare equal.
|
|
162
|
-
*/
|
|
163
|
-
export async function checkQuotes(projectDir) {
|
|
164
|
-
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
165
|
-
const backlog = path.join(root, 'backlog.md');
|
|
166
|
-
if (!existsSync(backlog)) return [];
|
|
167
|
-
const bodies = await sourceBodies(path.join(root, 'source'));
|
|
168
|
-
|
|
169
|
-
return (await readFile(backlog, 'utf8'))
|
|
170
|
-
.split('\n')
|
|
171
|
-
.map((line) => quoteProblem(line, bodies))
|
|
172
|
-
.filter((p) => p !== null);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
181
|
const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
|
|
176
182
|
|
|
177
183
|
/** anchor -> whitespace-collapsed bodies across every gcf/source/ file */
|
|
178
|
-
async function sourceBodies(sourceDir) {
|
|
184
|
+
export async function sourceBodies(sourceDir) {
|
|
179
185
|
/** @type {Map<string, string[]>} */
|
|
180
186
|
const bodies = new Map();
|
|
181
187
|
if (!existsSync(sourceDir)) return bodies;
|
|
@@ -188,20 +194,39 @@ async function sourceBodies(sourceDir) {
|
|
|
188
194
|
return bodies;
|
|
189
195
|
}
|
|
190
196
|
|
|
191
|
-
/**
|
|
192
|
-
function
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
197
|
+
/** gcf-relative files that cite anchors: units, work items, and the `sections:` of questions. */
|
|
198
|
+
async function anchorCiters(root) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const dir of ['design', 'work']) {
|
|
201
|
+
if (!existsSync(path.join(root, dir))) continue;
|
|
202
|
+
const names = (await readdir(path.join(root, dir))).filter((n) => n.endsWith('.md'));
|
|
203
|
+
out.push(...names.map((n) => `${dir}/${n}`));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (existsSync(path.join(root, 'open-questions.md'))) out.push('open-questions.md');
|
|
207
|
+
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A renamed heading changes its anchor (GCF-PRD §11.6): rewrite `GDD#<from>` to `GDD#<to>` in
|
|
213
|
+
* design/, work/ and open-questions.md, so provenance, `source:` and `sections:` follow the
|
|
214
|
+
* section instead of pointing at an anchor that no longer exists. Returns the files it changed.
|
|
215
|
+
*/
|
|
216
|
+
export async function rewriteAnchors(projectDir, renamed) {
|
|
217
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
218
|
+
const changed = [];
|
|
219
|
+
for (const rel of renamed.length ? await anchorCiters(root) : []) {
|
|
220
|
+
const file = path.join(root, rel);
|
|
221
|
+
const text = await readFile(file, 'utf8');
|
|
222
|
+
const next = renamed.reduce(
|
|
223
|
+
(t, r) => t.replace(new RegExp(`${r.from}(?![\\w-])`, 'g'), () => r.to),
|
|
224
|
+
text,
|
|
225
|
+
);
|
|
226
|
+
if (next === text) continue;
|
|
227
|
+
await writeFile(file, next, 'utf8');
|
|
228
|
+
changed.push(rel);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return changed;
|
|
207
232
|
}
|