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
@@ -0,0 +1,270 @@
1
+ // The moves a design unit makes out of design/ (GCF-PRD §4): `take` into a card, `done` into
2
+ // tests and code, `drop` into the graveyard. Each one removes the section and its backlog line
3
+ // in the same step, so an id never lives in two places (M12).
4
+ import { readFile, unlink } from 'node:fs/promises';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
7
+ import path from 'node:path';
8
+ import { serialize } from './frontmatter.js';
9
+ import { CARD_TYPES, listOf } from './design.js';
10
+ import { buildLedger } from './ledger.js';
11
+ import { designCheck } from './design-check.js';
12
+ import { atomicWrite, withFileRollback } from './safe-path.js';
13
+ import { writeIndex } from './index-gen.js';
14
+ import { operationInProgress, commitPaths } from './git.js';
15
+ import { upsertBlock, openSystemDoc } from './systems-doc.js';
16
+ import { buryLine, recordLine } from './verdict-fs.js';
17
+
18
+ /** A design file's text without the `## <id>` section. */
19
+ export function removeUnit(text, id) {
20
+ const lines = String(text).split('\n');
21
+ const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
22
+ if (at < 0) return text;
23
+ let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
24
+ if (end < 0) end = lines.length;
25
+
26
+ return `${[...lines.slice(0, at), ...lines.slice(end)]
27
+ .join('\n')
28
+ .replace(/\n{3,}/g, '\n\n')
29
+ .trimEnd()}\n`;
30
+ }
31
+
32
+ /** backlog.md without the line of `id`. */
33
+ export const backlogWithout = (text, id) =>
34
+ String(text)
35
+ .split('\n')
36
+ .filter((l) => l.match(/^- (\S+)/)?.[1] !== id)
37
+ .join('\n');
38
+
39
+ /** The single design unit `id`, or an error naming where the id lives instead. */
40
+ async function designUnit(projectDir, id) {
41
+ const ledger = await buildLedger(projectDir);
42
+ const { live, terminal } = ledger.where(id);
43
+ if (live.length !== 1 || live[0].state !== 'design') {
44
+ const at = live.map((p) => p.file).join(', ') || terminal.join(', ') || 'nowhere';
45
+ throw new Error(`${id} is not a single unit of gcf/design/ (it is: ${at})`);
46
+ }
47
+
48
+ const unit = ledger.design.flatMap((d) => d.units).find((u) => u.id === id);
49
+
50
+ return /** @type {import('./design.js').Unit} */ (unit);
51
+ }
52
+
53
+ /** Files a move rewrites: the design file, the backlog, then whatever the move adds. */
54
+ function basePaths(root, unit) {
55
+ return { design: path.join(root, unit.file), backlog: path.join(root, 'backlog.md') };
56
+ }
57
+
58
+ async function cutUnit(paths, id) {
59
+ await atomicWrite(paths.design, removeUnit(await readFile(paths.design, 'utf8'), id));
60
+ if (existsSync(paths.backlog))
61
+ await atomicWrite(paths.backlog, backlogWithout(await readFile(paths.backlog, 'utf8'), id));
62
+ }
63
+
64
+ /** The frontmatter a card starts with: the unit's header plus the fields of the card stage. */
65
+ function cardData(unit, today) {
66
+ const h = unit.header;
67
+ const pillars = listOf(h.pillars);
68
+
69
+ return {
70
+ schema: 2,
71
+ id: unit.id,
72
+ type: h.type,
73
+ ...(h.title ? { title: h.title } : {}),
74
+ status: 'proposed',
75
+ system: listOf(h.system),
76
+ depends: listOf(h.depends),
77
+ pillars: pillars === 'TBD' ? [] : pillars,
78
+ source: h.source,
79
+ ...(h.reopens ? { reopens: h.reopens } : {}),
80
+ tune_count: 0,
81
+ built_at: null,
82
+ created: today,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Take a ready soft unit into work: its section becomes cards/<id>.md with the same body.
88
+ * No commit — `/gcf-card` adds `contract` and `interactions`, and the pre-commit gate holds
89
+ * the card until it carries both.
90
+ */
91
+ export async function takeUnit(
92
+ projectDir,
93
+ id,
94
+ { today = new Date().toISOString().slice(0, 10) } = {},
95
+ ) {
96
+ const root = path.join(path.resolve(projectDir), 'gcf');
97
+ const unit = await designUnit(projectDir, id);
98
+ if (!CARD_TYPES.includes(unit.header.type)) {
99
+ throw new Error(
100
+ `${id} is hard: close its TBD in place, run the AIF cycle, then gcf done ${id}`,
101
+ );
102
+ }
103
+
104
+ const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
105
+ if (!summary?.ready) {
106
+ const why = summary?.blocked_by.length
107
+ ? `blocked by ${summary.blocked_by.join(', ')}`
108
+ : `${summary?.tbd ?? '?'} TBD or a blocking design-check finding`;
109
+ throw new Error(`${id} is not ready (${why}): answer it in design/ first`);
110
+ }
111
+
112
+ const paths = { ...basePaths(root, unit), card: path.join(root, 'cards', `${id}.md`) };
113
+ if (existsSync(paths.card)) throw new Error(`gcf/cards/${id}.md already exists`);
114
+ await withFileRollback([...Object.values(paths), path.join(root, 'INDEX.md')], async () => {
115
+ await atomicWrite(paths.card, serialize(cardData(unit, today), `\n${unit.body.trim()}\n`));
116
+ await cutUnit(paths, id);
117
+ await writeIndex(root);
118
+ });
119
+
120
+ return { card: `gcf/cards/${id}.md` };
121
+ }
122
+
123
+ /** A file's text at a git revision spec (`HEAD:./x`, `:./x` for the index), '' when absent. */
124
+ function gitText(projectDir, spec) {
125
+ const res = spawnSync('git', ['show', spec], { cwd: projectDir, encoding: 'utf8' });
126
+
127
+ return res.status === 0 ? res.stdout : '';
128
+ }
129
+
130
+ /**
131
+ * True when `file` differs from HEAD in the index or on disk. Line endings do not count: a file
132
+ * stored with CRLF and checked out with LF is not an edit.
133
+ */
134
+ function uncommitted(projectDir, file) {
135
+ const rel = `./${path.relative(projectDir, file).replaceAll('\\', '/')}`;
136
+ const norm = (t) => String(t).replace(/\r\n/g, '\n').trimEnd();
137
+ const head = norm(gitText(projectDir, `HEAD:${rel}`));
138
+ const disk = norm(existsSync(file) ? readFileSync(file, 'utf8') : '');
139
+
140
+ return head !== norm(gitText(projectDir, `:${rel}`)) || head !== disk;
141
+ }
142
+
143
+ /**
144
+ * Run a move that ends with its fixed-format commit, all of it or none of it. The commit takes
145
+ * whole files and deletes the unit's section, so any uncommitted edit in them refuses the move
146
+ * up front — an answer in the unit itself included: it would leave in a commit that never held
147
+ * it, and no history would keep it.
148
+ */
149
+ async function commitMove(projectDir, message, { files, mutate, commit }) {
150
+ const busy = commit ? operationInProgress(projectDir) : null;
151
+ if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
152
+ const dirty = commit ? files.filter((f) => uncommitted(projectDir, f)) : [];
153
+ if (dirty.length) {
154
+ const names = dirty.map((f) => path.relative(projectDir, f)).join(', ');
155
+ throw new Error(
156
+ `commit or stash these first (an answered unit is committed before its move), ` +
157
+ `${message} would carry them: ${names}`,
158
+ );
159
+ }
160
+
161
+ const root = path.join(path.resolve(projectDir), 'gcf');
162
+ const touched = [...files, path.join(root, 'INDEX.md')];
163
+ /** @type {string | null} */
164
+ let hash = null;
165
+ await withFileRollback(
166
+ touched,
167
+ async () => {
168
+ await mutate();
169
+ await writeIndex(root);
170
+ if (commit) hash = commitPaths(projectDir, message, touched);
171
+ },
172
+ { gitCwd: commit ? path.resolve(projectDir) : null },
173
+ );
174
+
175
+ return hash;
176
+ }
177
+
178
+ /**
179
+ * A hard unit is built and its tests are green: it leaves design/. A line every future reader
180
+ * of the subsystem must remember goes to the systems doc's `constraints:` (GCF-PRD §6.3).
181
+ */
182
+ export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
183
+ const root = path.join(path.resolve(projectDir), 'gcf');
184
+ const unit = await designUnit(projectDir, id);
185
+ if (CARD_TYPES.includes(unit.header.type))
186
+ throw new Error(`${id} is soft: it leaves design/ through a card and a verdict`);
187
+ const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
188
+ if (summary?.tbd)
189
+ throw new Error(`${id} still carries ${summary.tbd} TBD: it was built on a hole — answer it`);
190
+ const paths = basePaths(root, unit);
191
+ const doc = constraints.length ? await openSystemDoc(root, unit.system) : null;
192
+ const files = [paths.design, paths.backlog, path.join(root, 'history.md')];
193
+ if (doc) files.push(doc.file);
194
+ const mutate = async () => {
195
+ await cutUnit(paths, id);
196
+ await recordLine(root, id, 'done');
197
+ if (!doc) return;
198
+ const body = upsertBlock(
199
+ doc.body,
200
+ 'constraints',
201
+ constraints.map((c) => `- ${c}`),
202
+ );
203
+ await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
204
+ };
205
+
206
+ return {
207
+ id,
208
+ hash: await commitMove(projectDir, `gcf(done): ${id}`, { files, mutate, commit }),
209
+ };
210
+ }
211
+
212
+ /**
213
+ * A card taken into work but never built is given up: no playtest, so no verdict — a drop. Its
214
+ * file goes, the graveyard keeps the reason. A built card waits for its verdict instead.
215
+ */
216
+ async function dropCard(projectDir, card, { reason, tags, commit }) {
217
+ const root = path.join(path.resolve(projectDir), 'gcf');
218
+ const id = String(card.data.id);
219
+ if (!['proposed', 'tuning'].includes(card.data.status))
220
+ throw new Error(`${id} is ${card.data.status}: play it and give the verdict, kill included`);
221
+ const rel = `./${path.relative(projectDir, card.file).replaceAll('\\', '/')}`;
222
+ if (commit && !gitText(projectDir, `HEAD:${rel}`)) {
223
+ throw new Error(
224
+ `${id} was taken but its card is not committed: commit it first, or undo the take ` +
225
+ `(delete the card, restore its section in gcf/design/ and its backlog line)`,
226
+ );
227
+ }
228
+
229
+ const files = [card.file, path.join(root, 'graveyard.md')];
230
+ const mutate = async () => {
231
+ await buryLine(root, id, reason, tags.length ? tags : (card.data.system ?? []));
232
+ await unlink(card.file);
233
+ };
234
+
235
+ return {
236
+ id,
237
+ hash: await commitMove(projectDir, `gcf(drop): ${id}`, { files, mutate, commit }),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * A unit — or a card not yet built — is dropped before any playtest: a graveyard line keeps the
243
+ * reason (GCF-PRD §5.6).
244
+ */
245
+ export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
246
+ if (!String(reason ?? '').trim())
247
+ throw new Error('a drop needs a reason: the graveyard keeps it');
248
+ const ledger = await buildLedger(projectDir);
249
+ const live = ledger.where(id).live;
250
+ if (live.length === 1 && live[0].state === 'card') {
251
+ const card = ledger.cards.find((c) => c.data.id === id);
252
+
253
+ return dropCard(projectDir, card, { reason, tags, commit });
254
+ }
255
+
256
+ const root = path.join(path.resolve(projectDir), 'gcf');
257
+ const unit = await designUnit(projectDir, id);
258
+ const paths = basePaths(root, unit);
259
+ const system = listOf(unit.header.system);
260
+ const files = [paths.design, paths.backlog, path.join(root, 'graveyard.md')];
261
+ const mutate = async () => {
262
+ await buryLine(root, id, reason, tags.length ? tags : system === 'TBD' ? [] : system);
263
+ await cutUnit(paths, id);
264
+ };
265
+
266
+ return {
267
+ id,
268
+ hash: await commitMove(projectDir, `gcf(drop): ${id}`, { files, mutate, commit }),
269
+ };
270
+ }
@@ -1,37 +1,77 @@
1
1
  // Runtime mirror of schemas/card.schema.json for the pre-commit hook (no ajv in
2
2
  // production deps). The parity test keeps the two in agreement.
3
- const FIELDS = ['schema', 'id', 'slug', 'status', 'route', 'system', 'depends', 'source', 'tune_count', 'built_at', 'created'];
4
- const REQUIRED = FIELDS.filter((f) => f !== 'built_at');
3
+ const FIELDS = [
4
+ 'schema',
5
+ 'id',
6
+ 'type',
7
+ 'title',
8
+ 'status',
9
+ 'system',
10
+ 'depends',
11
+ 'pillars',
12
+ 'source',
13
+ 'reopens',
14
+ 'tune_count',
15
+ 'built_at',
16
+ 'created',
17
+ ];
18
+ const OPTIONAL = ['title', 'reopens', 'built_at'];
19
+ const REQUIRED = FIELDS.filter((f) => !OPTIONAL.includes(f));
20
+ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
21
+ const slugList = (v) => Array.isArray(v) && v.every((s) => SLUG.test(s));
22
+ const text = (v) => (typeof v === 'string' && v.length > 0) || 'must be a non-empty string';
5
23
 
6
24
  const RULES = {
7
- schema: (v) => v === 1 || 'schema must be 1',
8
- id: (v) => /^card-\d{3}$/.test(v) || 'id must match card-NNN',
9
- slug: (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) || 'slug must be a lowercase hyphen slug',
10
- status: (v) => ['proposed', 'built', 'tuning'].includes(v) || `status "${v}" is not storable (П1: terminal statuses never live in a file)`,
11
- route: (v) => ['soft', 'hard'].includes(v) || 'route must be soft or hard',
12
- system: (v) => (Array.isArray(v) && v.length >= 1 && v.every((s) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(s))) || 'system must list >=1 subsystem slugs',
13
- depends: (v) => (Array.isArray(v) && v.every((d) => /^card-\d{3}$/.test(d))) || 'depends must list card-NNN ids',
14
- source: (v) => /^(GDD#[A-Za-z0-9_-]+|manual|inferred)$/.test(v) || 'source must be GDD#<id>, manual or inferred',
25
+ // Schema 1 cards (`card-NNN` ids, `slug`, `route`) predate the Design Model: say how to move.
26
+ schema: (v) =>
27
+ v === 2 ||
28
+ (v === 1
29
+ ? 'schema 1 card: rename the file to cards/<slug>.md, set id: <slug>, drop slug and route, ' +
30
+ 'add type: mechanic and pillars: [], set schema: 2'
31
+ : 'schema must be 2'),
32
+ id: (v) => SLUG.test(v) || 'id must be a lowercase hyphen slug',
33
+ type: (v) =>
34
+ ['mechanic', 'resource'].includes(v) ||
35
+ 'type must be mechanic or resource (a hard unit never becomes a card)',
36
+ title: (v) => text(v) === true || `title ${text(v)}`,
37
+ status: (v) =>
38
+ ['proposed', 'built', 'tuning'].includes(v) ||
39
+ `status "${v}" is not storable (П1: terminal statuses never live in a file)`,
40
+ system: (v) => (slugList(v) && v.length >= 1) || 'system must list >=1 subsystem slugs',
41
+ depends: (v) => slugList(v) || 'depends must list unit ids',
42
+ pillars: (v) => slugList(v) || 'pillars must list pillar ids',
43
+ source: (v) => /^(GDD#[A-Za-z0-9_-]+|manual)$/.test(v) || 'source must be GDD#<id> or manual',
44
+ reopens: (v) => text(v) === true || `reopens ${text(v)}`,
15
45
  tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
16
- built_at: (v) => v === null || v === undefined || /^[0-9a-f]{7,40}$/.test(v) || 'built_at must be null or a commit hash',
46
+ built_at: (v) =>
47
+ v === null ||
48
+ v === undefined ||
49
+ /^[0-9a-f]{7,40}$/.test(v) ||
50
+ 'built_at must be null or a commit hash',
17
51
  created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
18
52
  };
19
53
 
20
54
  const dateStr = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(v));
21
55
 
22
- const UNTIL_OK = /^\[until:\s*(verdict\(card-\d{3}\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]$/;
56
+ const UNTIL_OK = /^\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]$/;
23
57
 
24
58
  /** Validate card frontmatter. Returns a list of one-line errors (empty = valid). */
25
59
  export function validateCard(data) {
26
60
  const errors = [];
61
+ if (data.schema !== undefined && data.schema !== 2) return [RULES.schema(data.schema)];
27
62
  for (const key of REQUIRED) if (!(key in data)) errors.push(`missing field: ${key}`);
28
63
  for (const key of Object.keys(data)) {
29
- if (!FIELDS.includes(key)) errors.push(`unknown field: ${key}${key === 'until' ? ' (until: is a line annotation, not frontmatter)' : ''}`);
64
+ if (!FIELDS.includes(key))
65
+ errors.push(
66
+ `unknown field: ${key}` +
67
+ (key === 'until' ? ' (until: is a line annotation, not frontmatter)' : ''),
68
+ );
30
69
  else if (key in RULES) {
31
70
  const res = RULES[key](data[key]);
32
71
  if (res !== true) errors.push(res);
33
72
  }
34
73
  }
74
+
35
75
  return errors;
36
76
  }
37
77
 
@@ -41,8 +81,13 @@ export function validateUntil(body) {
41
81
  const lines = String(body).split('\n');
42
82
  lines.forEach((line, i) => {
43
83
  for (const m of line.matchAll(/\[until:[^\]]*\]/g)) {
44
- if (!UNTIL_OK.test(m[0])) errors.push(`line ${i + 1}: bad until annotation ${m[0]} (use verdict(card-NNN), retune(system) or manual(text))`);
84
+ if (!UNTIL_OK.test(m[0]))
85
+ errors.push(
86
+ `line ${i + 1}: bad until annotation ${m[0]} ` +
87
+ '(use verdict(<id>), retune(system) or manual(text))',
88
+ );
45
89
  }
46
90
  });
91
+
47
92
  return errors;
48
93
  }