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.
Files changed (55) hide show
  1. package/README.md +7 -5
  2. package/commands/gcf.js +408 -73
  3. package/extension.json +1 -1
  4. package/hooks/pre-commit.js +47 -29
  5. package/injections/commit-status.md +1 -0
  6. package/injections/plan-context.md +3 -2
  7. package/injections/review-pillars.md +1 -1
  8. package/lib/anchors.js +110 -10
  9. package/lib/audit-core.js +175 -50
  10. package/lib/bundle.js +275 -0
  11. package/lib/context.js +55 -36
  12. package/lib/design-check.js +580 -0
  13. package/lib/design.js +179 -0
  14. package/lib/frontmatter.js +2 -1
  15. package/lib/gcf-paths.js +14 -3
  16. package/lib/git.js +85 -4
  17. package/lib/id.js +8 -14
  18. package/lib/index-gen.js +68 -16
  19. package/lib/ledger.js +92 -0
  20. package/lib/linter.js +91 -42
  21. package/lib/migrate.js +345 -0
  22. package/lib/playtest-list.js +1 -1
  23. package/lib/safe-path.js +23 -4
  24. package/lib/systems-doc.js +70 -0
  25. package/lib/transitions.js +1 -3
  26. package/lib/unit-ops.js +270 -0
  27. package/lib/validate-card.js +59 -14
  28. package/lib/verdict-fs.js +173 -96
  29. package/package.json +1 -1
  30. package/profiles/game.md +68 -0
  31. package/profiles/glossary.md +37 -0
  32. package/profiles/pillars.md +2 -1
  33. package/profiles/unit-constraint.md +42 -0
  34. package/profiles/unit-entity.md +38 -0
  35. package/profiles/unit-mechanic.md +106 -0
  36. package/profiles/unit-resource.md +86 -0
  37. package/profiles/unit-rule.md +42 -0
  38. package/schemas/card.schema.json +23 -7
  39. package/skills/gcf-adopt/SKILL.md +3 -3
  40. package/skills/gcf-audit/SKILL.md +4 -3
  41. package/skills/gcf-card/SKILL.md +26 -16
  42. package/skills/gcf-debate/SKILL.md +17 -14
  43. package/skills/gcf-ingest/SKILL.md +43 -16
  44. package/skills/gcf-init/SKILL.md +9 -8
  45. package/skills/gcf-verdict/SKILL.md +7 -6
  46. package/skills/gcf-vision/SKILL.md +17 -11
  47. package/templates/PILLARS.md +2 -0
  48. package/templates/backlog.md +2 -4
  49. package/templates/card.md +27 -10
  50. package/templates/cnlp.config.json +0 -1
  51. package/templates/design.md +59 -0
  52. package/templates/game.md +24 -0
  53. package/templates/glossary.md +11 -0
  54. package/templates/system.md +1 -1
  55. package/profiles/card.md +0 -65
package/lib/bundle.js ADDED
@@ -0,0 +1,275 @@
1
+ // The structured entry (GCF-PRD §11.10): `design export` glues game.md, glossary.md and the
2
+ // units into one bundle a designer edits; `design import` brings it back without a model.
3
+ // Each part carries the hash it was exported with, so an import tells a designer's edit from
4
+ // an edit made in gcf/ after the export — and never overwrites the second one silently.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import path from 'node:path';
9
+ import { readDesign, SLUG_RE } from './design.js';
10
+ import { buildLedger } from './ledger.js';
11
+ import { designCheck } from './design-check.js';
12
+ import { atomicWrite, withFileRollback, resolveInside } from './safe-path.js';
13
+ import { writeIndex } from './index-gen.js';
14
+
15
+ const DOCS = ['game', 'glossary'];
16
+ // A part opens with a marker comment, not with a heading: a `# notes` line inside game.md or a
17
+ // purpose is content, and a heading boundary would cut the document there.
18
+ const PART_RE =
19
+ /^<!-- gcf:part (doc|system|unit) (\S+)(?: system=(\S+))?(?: base=([0-9a-f]{8}))? -->$/;
20
+ const HELP =
21
+ '<!-- gcf:help edit inside a part; a new unit is a line ' +
22
+ '"gcf:part unit <id> system=<system>" in a comment like this one, ' +
23
+ 'then "## <id>" and its text -->';
24
+ export const hashOf = (text) =>
25
+ createHash('sha1').update(String(text).replace(/\r\n/g, '\n').trim()).digest('hex').slice(0, 8);
26
+
27
+ /** The text of one unit as it stands in its design file: header and body, no heading. */
28
+ function unitText(u) {
29
+ const header = Object.entries(u.header).map(([k, v]) => `${k}: ${v}`);
30
+
31
+ return `${header.join('\n')}\n\n${u.body.trim()}`.trim();
32
+ }
33
+
34
+ const purposeOf = (preamble) =>
35
+ preamble
36
+ .trimStart()
37
+ .replace(/^# .*\n?/, '')
38
+ .trim();
39
+
40
+ /** Glue the design into one bundle; `system` or `milestone` narrows which units go in. */
41
+ export async function exportBundle(projectDir, { system = null, milestone = null } = {}) {
42
+ const root = path.join(path.resolve(projectDir), 'gcf');
43
+ const design = (await readDesign(root)).filter((d) => !system || d.system === system);
44
+ const inScope = (u) => !milestone || u.header.milestone === milestone;
45
+ const ids = design.flatMap((d) => d.units.filter(inScope).map((u) => u.id));
46
+ const out = [`<!-- gcf:bundle ids=${ids.join(',')} -->`, HELP];
47
+ const part = (head, title, text) =>
48
+ out.push('', `<!-- gcf:part ${head} base=${hashOf(text)} -->`, ...title, text);
49
+ // A doc goes as it stands, its own heading included: a title added over it would be a second
50
+ // `# game` a designer deletes, and the import could not tell which one was the file's.
51
+ for (const name of DOCS) {
52
+ const file = path.join(root, `${name}.md`);
53
+ if (existsSync(file)) part(`doc ${name}`, [], (await readFile(file, 'utf8')).trim());
54
+ }
55
+
56
+ for (const d of design) {
57
+ part(`system ${d.system}`, [`# design: ${d.system}`, ''], purposeOf(d.preamble));
58
+ for (const u of d.units.filter(inScope)) {
59
+ part(`unit ${u.id} system=${d.system}`, [`## ${u.id}`, ''], unitText(u));
60
+ }
61
+ }
62
+
63
+ return `${out.join('\n')}\n`;
64
+ }
65
+
66
+ /** @typedef {{kind: string, name: string, system: ?string, base: ?string, lines: string[]}} Part */
67
+
68
+ /** The heading export writes under a marker, for a reader; import drops it when it is there. */
69
+ const titleOf = (p) =>
70
+ p.kind === 'doc' ? null : p.kind === 'system' ? `# design: ${p.name}` : `## ${p.name}`;
71
+
72
+ /**
73
+ * A marker line read into an empty part, or null; `system` is the system part above it.
74
+ * @returns {Part | null}
75
+ */
76
+ function partOf(line, system) {
77
+ const m = line.trimEnd().match(PART_RE);
78
+ if (!m) return null;
79
+ const owner = m[1] === 'unit' ? (m[3] ?? system) : m[1] === 'system' ? m[2] : null;
80
+
81
+ return { kind: m[1], name: m[2], system: owner, base: m[4] ?? null, lines: [] };
82
+ }
83
+
84
+ // Text no part owns — above the first marker, or a marker the editor mangled — would vanish
85
+ // without a word: it is reported instead.
86
+ const strayLine = (l, i, inPart) =>
87
+ /^<!-- gcf:part\b/.test(l) ||
88
+ (!inPart && i > 0 && l.trim() !== '' && !l.startsWith('<!-- gcf:help'));
89
+
90
+ /** A bundle read back: the exported ids, the docs, and per system its purpose and units. */
91
+ export function parseBundle(text) {
92
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
93
+ const ids = (lines[0].match(/^<!-- gcf:bundle ids=(.*) -->$/)?.[1] ?? '')
94
+ .split(',')
95
+ .filter(Boolean);
96
+ /** @type {Part[]} */
97
+ const parts = [];
98
+ const stray = [];
99
+ /** @type {?string} */
100
+ let system = null;
101
+ for (const [i, l] of lines.entries()) {
102
+ const p = partOf(l, system);
103
+ if (p) {
104
+ if (p.kind === 'system') system = p.name;
105
+ parts.push(p);
106
+ } else if (strayLine(l, i, parts.length > 0)) stray.push(i + 1);
107
+ else parts.at(-1)?.lines.push(l);
108
+ }
109
+
110
+ return {
111
+ ids,
112
+ stray,
113
+ parts: parts.map((p) => {
114
+ const body = p.lines.join('\n').trim().split('\n');
115
+ if (body[0]?.trim() === titleOf(p)) body.shift();
116
+
117
+ return { ...p, text: body.join('\n').trim() };
118
+ }),
119
+ };
120
+ }
121
+
122
+ /** Three-way: what the bundle changed, against what gcf/ changed since the export. */
123
+ function verdict(part, current) {
124
+ const mine = hashOf(part.text);
125
+ if (part.base === null) return current === null ? 'new' : 'taken';
126
+ if (mine === part.base) return 'same';
127
+ if (current === null) return 'gone';
128
+
129
+ return hashOf(current) === part.base ? 'apply' : 'conflict';
130
+ }
131
+
132
+ /** Current text of every importable part: docs, system purposes, units in design/. */
133
+ async function currentParts(root) {
134
+ const cur = new Map();
135
+ for (const name of DOCS) {
136
+ const file = path.join(root, `${name}.md`);
137
+ if (existsSync(file)) cur.set(`doc:${name}`, (await readFile(file, 'utf8')).trim());
138
+ }
139
+
140
+ for (const d of await readDesign(root)) {
141
+ cur.set(`system:${d.system}`, purposeOf(d.preamble));
142
+ for (const u of d.units) cur.set(`unit:${u.id}`, unitText(u));
143
+ }
144
+
145
+ return cur;
146
+ }
147
+
148
+ /** A design file with the `## <id>` section replaced by `text`, or appended when absent. */
149
+ function putUnit(fileText, id, text) {
150
+ const lines = fileText.split('\n');
151
+ const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
152
+ const section = [`## ${id}`, '', ...text.split('\n'), ''];
153
+ if (at < 0) return `${fileText.trimEnd()}\n\n${section.join('\n')}`;
154
+ let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
155
+ if (end < 0) end = lines.length;
156
+
157
+ return [...lines.slice(0, at), ...section, ...lines.slice(end)].join('\n');
158
+ }
159
+
160
+ /** A design file with its preamble purpose replaced; a title of the file's own stays. */
161
+ function putPurpose(fileText, system, purpose) {
162
+ const at = fileText.search(/^## \S/m);
163
+ const rest = at < 0 ? '' : fileText.slice(at);
164
+ const title = fileText.trimStart().match(/^# .*/)?.[0] ?? `# ${system}`;
165
+
166
+ return `${title}\n\n${purpose}\n${rest ? `\n${rest}` : ''}`;
167
+ }
168
+
169
+ /** A part's names become file names: a doc is one of DOCS, a system and a unit are slugs. */
170
+ const validName = (p) =>
171
+ p.kind === 'doc'
172
+ ? DOCS.includes(p.name)
173
+ : SLUG_RE.test(p.name) && SLUG_RE.test(String(p.system ?? ''));
174
+
175
+ /** Plan every part: which write it gets, or which report line. */
176
+ function plan(bundle, cur, ledger) {
177
+ const writes = [];
178
+ const note = `lines outside any part: ${bundle.stray.join(', ')} (a lost gcf:part marker?)`;
179
+ const report = bundle.stray.length ? [{ part: 'bundle', outcome: 'invalid', note }] : [];
180
+ for (const p of bundle.parts) {
181
+ const key = `${p.kind}:${p.name}`;
182
+ if (!validName(p)) {
183
+ report.push({ part: key, outcome: 'invalid', note: 'not a gcf doc, system or unit name' });
184
+ continue;
185
+ }
186
+
187
+ const v = verdict(p, cur.get(key) ?? null);
188
+ if (p.kind === 'unit' && v === 'new' && ledger.known(p.name)) {
189
+ report.push({ part: key, outcome: 'taken', note: 'the id is used: pick another slug' });
190
+ } else if (['apply', 'new'].includes(v)) writes.push({ ...p, outcome: v });
191
+ else if (v !== 'same') report.push({ part: key, outcome: v, note: NOTES[v] });
192
+ }
193
+
194
+ const kept = new Set(bundle.parts.filter((p) => p.kind === 'unit').map((p) => p.name));
195
+ for (const id of bundle.ids.filter((i) => !kept.has(i))) {
196
+ report.push({
197
+ part: `unit:${id}`,
198
+ outcome: 'deleted',
199
+ note: `confirm with gcf drop ${id} --reason "…"`,
200
+ });
201
+ }
202
+
203
+ return { writes, report };
204
+ }
205
+
206
+ const NOTES = {
207
+ conflict: 'changed in the bundle and in gcf/ since the export: a D1 for the human',
208
+ gone: 'no longer in design/ (taken, done or dropped): an edit to it is a D1',
209
+ taken: 'a part without a base hash that already exists: export again and edit that',
210
+ };
211
+
212
+ /** The file each write touches. */
213
+ function fileOf(root, w) {
214
+ // validName already holds a doc to DOCS; resolveInside is the second lock on the path.
215
+ if (w.kind === 'doc') return resolveInside(root, `${w.name}.md`);
216
+
217
+ return resolveInside(path.join(root, 'design'), `${w.kind === 'system' ? w.name : w.system}.md`);
218
+ }
219
+
220
+ async function applyWrite(root, w) {
221
+ const file = fileOf(root, w);
222
+ const text = existsSync(file)
223
+ ? await readFile(file, 'utf8')
224
+ : `# ${w.system ?? w.name}\n\npurpose: TBD\n`;
225
+ if (w.kind === 'doc') return atomicWrite(file, `${w.text}\n`);
226
+ if (w.kind === 'system') return atomicWrite(file, putPurpose(text, w.name, w.text));
227
+ await atomicWrite(file, putUnit(text, w.name, w.text));
228
+ if (w.outcome !== 'new') return;
229
+ const backlog = path.join(root, 'backlog.md');
230
+ const order = existsSync(backlog) ? (await readFile(backlog, 'utf8')).trimEnd() : '';
231
+ await atomicWrite(backlog, `${order}\n- ${w.name}\n`.replace(/^\n/, ''));
232
+ }
233
+
234
+ /**
235
+ * Bring a bundle back into gcf/. A part the bundle changed and gcf/ did not is written; a part
236
+ * both changed is reported, never merged. Writes land only if the design check after them has
237
+ * no blocking finding — otherwise every file goes back and the findings are the error.
238
+ */
239
+ export async function importBundle(projectDir, bundleText) {
240
+ const root = path.join(path.resolve(projectDir), 'gcf');
241
+ const bundle = parseBundle(bundleText);
242
+ const ledger = await buildLedger(projectDir);
243
+ const { writes, report } = plan(bundle, await currentParts(root), ledger);
244
+ const applied = writes.map((w) => `${w.kind}:${w.name} (${w.outcome})`);
245
+ if (!writes.length) return { applied, report };
246
+ // Only the findings the bundle brings refuse it: a project with open findings can still take
247
+ // a clean edit. A finding is keyed without its line, which an edit above it shifts, and
248
+ // counted, so a second finding with the same message in the same unit is still a new one.
249
+ const keyOf = (i) => `${i.check}|${i.file}|${i.id}|${i.message}`;
250
+ const blocking = async () =>
251
+ (await designCheck(projectDir)).issues.filter((i) => i.severity === 'block');
252
+ const before = new Map();
253
+ for (const i of await blocking()) before.set(keyOf(i), (before.get(keyOf(i)) ?? 0) + 1);
254
+ const files = [...new Set(writes.map((w) => fileOf(root, w)))];
255
+ await withFileRollback(
256
+ [...files, path.join(root, 'backlog.md'), path.join(root, 'INDEX.md')],
257
+ async () => {
258
+ for (const w of writes) await applyWrite(root, w);
259
+ const seen = new Map();
260
+ const fresh = (await blocking()).filter((i) => {
261
+ seen.set(keyOf(i), (seen.get(keyOf(i)) ?? 0) + 1);
262
+
263
+ return seen.get(keyOf(i)) > (before.get(keyOf(i)) ?? 0);
264
+ });
265
+ if (fresh.length) {
266
+ const first = fresh.slice(0, 3).map((i) => `${i.file}:${i.line} ${i.check} ${i.message}`);
267
+ throw new Error(`the bundle would break the design check:\n${first.join('\n')}`);
268
+ }
269
+
270
+ await writeIndex(root);
271
+ },
272
+ );
273
+
274
+ return { applied, report };
275
+ }
package/lib/context.js CHANGED
@@ -15,14 +15,35 @@ export async function buildContext(projectDir, cardId) {
15
15
  const card = cards.find((c) => c.data.id === cardId);
16
16
  if (!card) throw new Error(`Card not found: ${cardId}`);
17
17
 
18
+ const systems = card.data.system ?? [];
19
+ const files = await readsetFiles(root, card, cards);
20
+ const cardText =
21
+ `${card.data.id} ${card.data.title ?? ''} ${systems.join(' ')} ${card.body}`.toLowerCase();
22
+ const graveyard_hits = await graveyardHits(root, cardText);
23
+ const d2 = await d2Assumptions(root, systems);
24
+
25
+ const extraTokens = tokensEst(graveyard_hits.join('\n')) + tokensEst(d2.join('\n'));
26
+ const total_tokens_est = files.reduce((sum, f) => sum + f.tokens_est, 0) + extraTokens;
27
+
28
+ return {
29
+ card: cardId,
30
+ files,
31
+ graveyard_hits,
32
+ d2,
33
+ total_tokens_est,
34
+ budget: config.context_budget,
35
+ over_budget: total_tokens_est > config.context_budget,
36
+ };
37
+ }
38
+
39
+ /** PILLARS, touched systems, the card, its depends — in that order, skipping absent files. */
40
+ async function readsetFiles(root, card, cards) {
18
41
  const files = [];
19
42
  const push = async (rel) => {
20
43
  const abs = path.join(root, rel);
21
- if (!existsSync(abs)) return null;
44
+ if (!existsSync(abs)) return;
22
45
  const text = await readFile(abs, 'utf8');
23
- const entry = { path: `gcf/${rel.replaceAll('\\', '/')}`, tokens_est: tokensEst(text) };
24
- files.push(entry);
25
- return text;
46
+ files.push({ path: `gcf/${rel.replaceAll('\\', '/')}`, tokens_est: tokensEst(text) });
26
47
  };
27
48
 
28
49
  await push('PILLARS.md');
@@ -30,48 +51,46 @@ export async function buildContext(projectDir, cardId) {
30
51
  for (const s of (await listSystems(root)).filter((d) => systems.includes(d.data.system))) {
31
52
  await push(`systems/${s.name}`);
32
53
  }
54
+
33
55
  await push(`cards/${card.name}`);
34
56
  for (const depId of card.data.depends ?? []) {
35
57
  const dep = cards.find((c) => c.data.id === depId);
36
58
  if (dep) await push(`cards/${dep.name}`);
37
59
  }
38
60
 
39
- // Graveyard: headline tags only; a hit is a tag appearing in the card's own text.
40
- const graveyard_hits = [];
61
+ return files;
62
+ }
63
+
64
+ /** Graveyard: headline tags only; a hit is a tag appearing in the card's own text. */
65
+ async function graveyardHits(root, cardText) {
41
66
  const gyFile = path.join(root, 'graveyard.md');
42
- if (existsSync(gyFile)) {
43
- const cardText = `${card.data.slug} ${systems.join(' ')} ${card.body}`.toLowerCase();
44
- for (const line of (await readFile(gyFile, 'utf8')).split('\n')) {
45
- const m = line.match(/^- (card-\d{3}) \| .* \| tags: (.+)$/);
46
- if (!m) continue;
47
- const tags = m[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
48
- if (tags.some((t) => cardText.includes(t))) graveyard_hits.push(line.trim());
49
- }
67
+ if (!existsSync(gyFile)) return [];
68
+ const hits = [];
69
+ for (const line of (await readFile(gyFile, 'utf8')).split('\n')) {
70
+ const m = line.match(/^- (\S+) \| .* \| tags: (.+)$/);
71
+ if (!m) continue;
72
+ const tags = m[2]
73
+ .split(',')
74
+ .map((t) => t.trim().toLowerCase())
75
+ .filter(Boolean);
76
+ if (tags.some((t) => cardText.includes(t))) hits.push(line.trim());
50
77
  }
51
78
 
52
- // D2 assumptions whose sys: overlaps the card's systems (GCF-PRD §9.1).
53
- const d2 = [];
79
+ return hits;
80
+ }
81
+
82
+ /** D2 assumptions whose sys: overlaps the card's systems (GCF-PRD §9.1). */
83
+ async function d2Assumptions(root, systems) {
54
84
  const oqFile = path.join(root, 'open-questions.md');
55
- if (existsSync(oqFile)) {
56
- const records = (await readFile(oqFile, 'utf8')).split(/\n(?=- id:)/);
57
- for (const rec of records) {
58
- if (!/^\s*-?\s*id:/m.test(rec) || !/class:\s*D2/.test(rec)) continue;
85
+ if (!existsSync(oqFile)) return [];
86
+ const records = (await readFile(oqFile, 'utf8')).split(/\n(?=- id:)/);
87
+
88
+ return records
89
+ .filter((rec) => /^\s*-?\s*id:/m.test(rec) && /class:\s*D2/.test(rec))
90
+ .filter((rec) => {
59
91
  const sys = rec.match(/sys:\s*(.+)/)?.[1] ?? '';
60
- if (systems.some((s) => sys.includes(s))) {
61
- d2.push(rec.trim());
62
- }
63
- }
64
- }
65
92
 
66
- const extraTokens = tokensEst(graveyard_hits.join('\n')) + tokensEst(d2.join('\n'));
67
- const total_tokens_est = files.reduce((sum, f) => sum + f.tokens_est, 0) + extraTokens;
68
- return {
69
- card: cardId,
70
- files,
71
- graveyard_hits,
72
- d2,
73
- total_tokens_est,
74
- budget: config.context_budget,
75
- over_budget: total_tokens_est > config.context_budget,
76
- };
93
+ return systems.some((s) => sys.includes(s));
94
+ })
95
+ .map((rec) => rec.trim());
77
96
  }