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
@@ -4,14 +4,15 @@
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 { listCards, listSystems } from '../lib/gcf-paths.js';
9
+ import { listSystems } from '../lib/gcf-paths.js';
11
10
  import { validateCard, validateUntil } from '../lib/validate-card.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';
15
16
 
16
17
  const projectDir = process.cwd();
17
18
  const root = path.join(projectDir, 'gcf');
@@ -26,68 +27,85 @@ const fail = (msg) => {
26
27
  // rather than letting a broken probe wave a commit past every check.
27
28
  function git(args) {
28
29
  const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
30
+
29
31
  return res.status === 0 ? res.stdout.trim() : null;
30
32
  }
31
33
 
32
34
  const staged = git(['diff', '--cached', '--name-only', '--', root]);
33
35
  if (staged === '') process.exit(0); // this commit does not touch gcf/
34
36
  if (staged !== null) {
35
- // Untracked counts: listCards reads the directory, so an uncommitted card file would be
36
- // validated and written into the staged INDEX while not being in the repository at all.
37
+ // Untracked counts: cards and units are found by reading directories, so an uncommitted file
38
+ // would be validated and written into the staged INDEX while not being in the repository.
37
39
  const unstaged = git(['diff', '--name-only', '--', root]);
38
40
  const untracked = git(['ls-files', '--others', '--exclude-standard', '--', root]);
39
41
  if (unstaged || untracked) {
40
- fail('gcf/ has changes outside the index — stage them, or commit gcf/ separately (the gate reads the working tree)');
42
+ fail(
43
+ 'gcf/ has changes outside the index — stage them, or commit gcf/ separately ' +
44
+ '(the gate reads the working tree)',
45
+ );
41
46
  }
42
47
  }
43
48
 
44
- const cards = await listCards(root);
45
- const cardIds = new Set(cards.map((c) => c.data.id));
46
- const systemDocs = await listSystems(root);
49
+ const ledger = await buildLedger(projectDir);
47
50
 
48
51
  // 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
49
- for (const c of cards) {
52
+ for (const c of ledger.cards) {
50
53
  const errors = [
51
54
  ...validateCard(c.data),
52
55
  ...validateUntil(c.body),
53
- ...(stemMatches(c.file, c.data.id, c.data.slug) ? [] : [`filename must be ${c.data.id}-${c.data.slug}.md`]),
56
+ ...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
54
57
  ];
55
58
  if (errors.length) fail(`${c.name}: ${errors[0]}`);
56
59
  }
57
- for (const s of systemDocs) {
60
+
61
+ for (const s of await listSystems(root)) {
58
62
  const errors = validateUntil(s.body);
59
63
  if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
60
64
  }
61
65
 
62
- // 4: depends reference known cards; a built card must not sit on un-collapsed depends.
63
- // A collapsed card leaves no file, so its trace is the verdict commit or the graveyard line.
64
- const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s']) ?? '';
65
- const gyFile = path.join(root, 'graveyard.md');
66
- const graveyard = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
67
- const collapsed = (id) => new RegExp(`^gcf\\(verdict\\): \\w+ ${id}\\b`, 'm').test(verdictLog)
68
- || new RegExp(`^- ${id} \\|`, 'm').test(graveyard);
66
+ // A unit or card leaving without its move (take, done, drop, a verdict) loses its reason, not
67
+ // its integrity: a card or unit still citing it fails M2 below, in this commit. A warning, not a
68
+ // refusal — a revert of the commit that added it removes an id legitimately.
69
+ for (const id of ledger.vanished()) {
70
+ console.error(
71
+ `gcf pre-commit: warning: ${id} left design/ or cards/ without done, drop or a verdict — ` +
72
+ 'no history or graveyard line keeps why',
73
+ );
74
+ }
75
+
76
+ // Design model and card bodies: every finding of severity `block` refuses the commit.
77
+ const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
78
+ if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
69
79
 
70
- for (const c of cards) {
80
+ // 4: a built card must not sit on an unplayed dependency — a live card, or a unit still in
81
+ // design/. A collapsed, done or killed id is played; an unknown one is a typo (design check).
82
+ for (const c of ledger.cards.filter((x) => x.data.status === 'built')) {
71
83
  for (const dep of c.data.depends ?? []) {
72
- if (cardIds.has(dep)) {
73
- if (c.data.status === 'built') {
74
- fail(`${c.data.id} is built on top of unplayed ${dep} (collapse it first — GCF-PRD §7.2)`);
75
- }
76
- } else if (!collapsed(dep)) {
77
- fail(`${c.data.id} depends on unknown ${dep} (no card, no verdict, no graveyard line)`);
78
- }
84
+ const live = ledger.where(dep).live[0];
85
+ if (live)
86
+ fail(`${c.data.id} is built on top of unplayed ${dep} (${live.state} — GCF-PRD §7.2)`);
79
87
  }
80
88
  }
81
89
 
90
+ for (const c of ledger.cards) {
91
+ const unknown = (c.data.depends ?? []).find((d) => !ledger.known(d));
92
+ if (unknown)
93
+ fail(`${c.data.id} depends on unknown ${unknown} (no unit, card, history or graveyard line)`);
94
+ }
95
+
82
96
  // 5: П2 linter over systems docs.
83
- for (const s of systemDocs) {
97
+ for (const s of await listSystems(root)) {
84
98
  const { issues } = lint(s.body);
85
- if (issues.length) fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
99
+ if (issues.length)
100
+ fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
86
101
  }
87
102
 
88
103
  // 3: regenerate INDEX and stage it (deterministic artifact, not a human error).
89
104
  await writeIndex(root);
90
105
  // An unstaged INDEX (gitignored, locked index) would leave the commit without the row it validated.
91
- const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], { cwd: projectDir, stdio: 'ignore' });
106
+ const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], {
107
+ cwd: projectDir,
108
+ stdio: 'ignore',
109
+ });
92
110
  if (added.status !== 0) fail('could not stage gcf/INDEX.md — is it gitignored?');
93
111
  process.exit(0);
@@ -4,3 +4,4 @@ gcf_commit:
4
4
  - when `gcf/` exists and the commit implements a card, verify the card frontmatter is current
5
5
  - an implementation commit that completes a build flips `status:` to `built` and records `built_at:` with the commit hash in the same commit
6
6
  - do not commit a card file whose `status:` is `validated` or `killed`: those are terminal and never stored
7
+ - when the commit makes a hard unit's tests green, run `ai-factory gcf done <id>` after it: the unit leaves `gcf/design/` in its own fixed-format commit
@@ -4,5 +4,6 @@ gcf_gate:
4
4
  - when `gcf/` exists and the task names a card, run `ai-factory gcf context <card-id>` first
5
5
  - the returned file list is the readset: read those files and nothing else under `gcf/`
6
6
  - `over_budget: true` in the result: stop and record a D1 question instead of planning
7
- - a card in `depends:` that still exists as a file is unplayed: do not plan on top of it, stop
8
- - shape the plan by the card slots: behavior, contract, feel envelope, interactions, negative constraints, miss criterion
7
+ - an id in `depends:` that still lives as a card or as a unit of `gcf/design/` is unplayed: do not plan on top of it, stop
8
+ - shape the plan by the card slots: intent, behavior, refusal, boundaries, persistence, feel envelopes, contract, interactions, constraints, miss criterion
9
+ - when the task names a hard unit (rule, constraint, entity), there is no card: read its section only, from the file `ai-factory gcf where <id>` names, and plan its tests from `statement`, `threshold` or `fields`
@@ -1,7 +1,7 @@
1
1
  ## GCF: pillar and constraint review (from gcf-aif-ext)
2
2
 
3
3
  gcf_review:
4
- - when `gcf/` exists and the change implements a card, read `gcf/PILLARS.md` and the card
4
+ - when `gcf/` exists and the change implements a card, read the card and `gcf/PILLARS.md` when it exists (it stays absent until the pillars interview)
5
5
  - check the diff against every negative constraint of the card: name each violation with file and line
6
6
  - check the diff against each pillar's review question: a contradiction is a finding, not a note
7
7
  - numeric feel values in code outside the card's envelope: report the value and the envelope bounds
package/lib/anchors.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
@@ -7,45 +7,67 @@ import path from 'node:path';
7
7
  // stable under reorder, not under rename. Re-import matches orphaned anchors by body
8
8
  // hash and reports a rename instead of delete+add.
9
9
 
10
- const norm = (s) => String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
10
+ const norm = (s) =>
11
+ String(s)
12
+ .toLowerCase()
13
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
14
+ .trim();
11
15
  const h8 = (s) => createHash('sha1').update(s, 'utf8').digest('hex').slice(0, 8);
12
16
  // A body keeps its punctuation and structure, unlike a heading: `norm` would hash
13
17
  // `- a` + `- b` the same as `a, b`, hiding a real edit from classifyDelta.
14
- const bodyNorm = (s) => String(s).replace(/\r\n/g, '\n').replace(/[ \t]+$/gm, '').trim();
18
+ const bodyNorm = (s) =>
19
+ String(s)
20
+ .replace(/\r\n/g, '\n')
21
+ .replace(/[ \t]+$/gm, '')
22
+ .trim();
15
23
  const EMPTY_HASH = h8('');
16
24
  // Bumped whenever body hashing changes: a stored map from an older scheme cannot be
17
25
  // compared against fresh hashes, and reporting every section as changed would flood the
18
26
  // human with D1 questions about a GDD nobody edited.
19
27
  export const HASH_VERSION = 2;
20
28
 
21
- /** Split markdown into heading-addressed sections. Text before the first heading is `GDD#preamble`. */
29
+ /**
30
+ * Split markdown into heading-addressed sections.
31
+ * Text before the first heading is `GDD#preamble`.
32
+ */
22
33
  export function splitSections(markdown) {
23
34
  const lines = String(markdown).split('\n');
35
+ /**
36
+ * @typedef {{ heading: string | null, level: number, start: number, lines: string[] }} RawSection
37
+ */
38
+ /** @type {RawSection[]} */
24
39
  const sections = [];
25
- let current = { heading: null, level: 0, lines: [] };
40
+ /** @type {RawSection} */
41
+ let current = { heading: null, level: 0, start: 1, lines: [] };
26
42
  const flush = () => {
27
43
  if (current.heading === null && !current.lines.join('').trim()) return;
28
44
  sections.push(current);
29
45
  };
30
- for (const line of lines) {
46
+
47
+ for (const [i, line] of lines.entries()) {
31
48
  const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
32
49
  if (m) {
33
50
  flush();
34
- current = { heading: m[2], level: m[1].length, lines: [] };
51
+ current = { heading: m[2], level: m[1].length, start: i + 1, lines: [] };
35
52
  } else current.lines.push(line);
36
53
  }
54
+
37
55
  flush();
38
56
 
39
57
  const seen = new Map();
58
+
40
59
  return sections.map((s) => {
41
60
  const base = s.heading === null ? 'preamble' : h8(norm(s.heading));
42
61
  const n = seen.get(base) ?? 0;
43
62
  seen.set(base, n + 1);
44
63
  const body = s.lines.join('\n');
64
+
45
65
  return {
46
66
  anchor: `GDD#${n ? `${base}-${n}` : base}`,
47
67
  heading: s.heading,
48
68
  level: s.level,
69
+ // 1-based and inclusive, heading line first: a reader opens the section, not the file.
70
+ lines: [s.start, s.start + s.lines.length - (s.heading === null ? 1 : 0)],
49
71
  body,
50
72
  body_hash: h8(bodyNorm(body)),
51
73
  };
@@ -60,9 +82,16 @@ export async function importSource(projectDir, file) {
60
82
  const base = path.basename(file);
61
83
  await mkdir(path.join(root, 'source'), { recursive: true });
62
84
  await writeFile(path.join(root, 'source', base), content, 'utf8');
63
- const anchors = Object.fromEntries(sections.map((s) => [s.anchor, { heading: s.heading, body_hash: s.body_hash }]));
85
+ const anchors = Object.fromEntries(
86
+ sections.map((s) => [s.anchor, { heading: s.heading, body_hash: s.body_hash }]),
87
+ );
64
88
  const map = { hash_version: HASH_VERSION, anchors };
65
- await writeFile(path.join(root, 'source', `${base}.anchors.json`), JSON.stringify(map, null, 2), 'utf8');
89
+ await writeFile(
90
+ path.join(root, 'source', `${base}.anchors.json`),
91
+ JSON.stringify(map, null, 2),
92
+ 'utf8',
93
+ );
94
+
66
95
  return { file: `gcf/source/${base}`, sections };
67
96
  }
68
97
 
@@ -71,6 +100,10 @@ export async function importSource(projectDir, file) {
71
100
  * A removed anchor whose body hash reappears under a new anchor is a rename.
72
101
  */
73
102
  export function classifyDelta(oldMap, sections) {
103
+ /**
104
+ * @type {{ added: string[], changed: string[], removed: string[],
105
+ * renamed: { from: string, to: string }[] }}
106
+ */
74
107
  const delta = { added: [], changed: [], removed: [], renamed: [] };
75
108
  const newByAnchor = new Map(sections.map((s) => [s.anchor, s]));
76
109
 
@@ -79,6 +112,7 @@ export function classifyDelta(oldMap, sections) {
79
112
  if (!old) continue;
80
113
  if (old.body_hash !== s.body_hash) delta.changed.push(s.anchor);
81
114
  }
115
+
82
116
  const missing = Object.entries(oldMap).filter(([a]) => !newByAnchor.has(a));
83
117
  const fresh = sections.filter((s) => !(s.anchor in oldMap));
84
118
  // Each fresh section can be claimed once: without consuming the match, one section
@@ -86,12 +120,15 @@ export function classifyDelta(oldMap, sections) {
86
120
  const pool = [...fresh];
87
121
  for (const [anchor, old] of missing) {
88
122
  // An empty body hashes the same everywhere, so it is evidence of nothing.
89
- const i = old.body_hash === EMPTY_HASH ? -1 : pool.findIndex((s) => s.body_hash === old.body_hash);
123
+ const i =
124
+ old.body_hash === EMPTY_HASH ? -1 : pool.findIndex((s) => s.body_hash === old.body_hash);
90
125
  if (i >= 0) delta.renamed.push({ from: anchor, to: pool.splice(i, 1)[0].anchor });
91
126
  else delta.removed.push(anchor);
92
127
  }
128
+
93
129
  const renamedTo = new Set(delta.renamed.map((r) => r.to));
94
130
  delta.added = fresh.map((s) => s.anchor).filter((a) => !renamedTo.has(a));
131
+
95
132
  return delta;
96
133
  }
97
134
 
@@ -105,5 +142,68 @@ export async function loadAnchorMap(projectDir, baseName) {
105
142
  if (!existsSync(file)) return null;
106
143
  const raw = JSON.parse(await readFile(file, 'utf8'));
107
144
  if (raw.hash_version !== HASH_VERSION) return { anchors: null, rebaselined: true };
145
+
108
146
  return { anchors: raw.anchors ?? {}, rebaselined: false };
109
147
  }
148
+
149
+ /** The section an anchor names in one gcf/source/ file, or null. */
150
+ export async function readSection(projectDir, baseName, anchor) {
151
+ const file = path.join(path.resolve(projectDir), 'gcf', 'source', path.basename(baseName));
152
+ if (!existsSync(file))
153
+ throw new Error(`no gcf/source/${path.basename(baseName)} - anchor it first`);
154
+
155
+ return splitSections(await readFile(file, 'utf8')).find((s) => s.anchor === anchor) ?? null;
156
+ }
157
+
158
+ const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
159
+
160
+ /** anchor -> whitespace-collapsed bodies across every gcf/source/ file */
161
+ export async function sourceBodies(sourceDir) {
162
+ /** @type {Map<string, string[]>} */
163
+ const bodies = new Map();
164
+ if (!existsSync(sourceDir)) return bodies;
165
+ for (const name of (await readdir(sourceDir)).filter((n) => n.endsWith('.md'))) {
166
+ for (const s of splitSections(await readFile(path.join(sourceDir, name), 'utf8'))) {
167
+ bodies.set(s.anchor, [...(bodies.get(s.anchor) ?? []), ws(s.body)]);
168
+ }
169
+ }
170
+
171
+ return bodies;
172
+ }
173
+
174
+ /** gcf-relative files that cite anchors: units, cards, and the `sections:` of questions. */
175
+ async function anchorCiters(root) {
176
+ const out = [];
177
+ for (const dir of ['design', 'cards']) {
178
+ if (!existsSync(path.join(root, dir))) continue;
179
+ const names = (await readdir(path.join(root, dir))).filter((n) => n.endsWith('.md'));
180
+ out.push(...names.map((n) => `${dir}/${n}`));
181
+ }
182
+
183
+ if (existsSync(path.join(root, 'open-questions.md'))) out.push('open-questions.md');
184
+
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * A renamed heading changes its anchor (GCF-PRD §11.6): rewrite `GDD#<from>` to `GDD#<to>` in
190
+ * design/, cards/ and open-questions.md, so provenance, `source:` and `sections:` follow the
191
+ * section instead of pointing at an anchor that no longer exists. Returns the files it changed.
192
+ */
193
+ export async function rewriteAnchors(projectDir, renamed) {
194
+ const root = path.join(path.resolve(projectDir), 'gcf');
195
+ const changed = [];
196
+ for (const rel of renamed.length ? await anchorCiters(root) : []) {
197
+ const file = path.join(root, rel);
198
+ const text = await readFile(file, 'utf8');
199
+ const next = renamed.reduce(
200
+ (t, r) => t.replace(new RegExp(`${r.from}(?![\\w-])`, 'g'), () => r.to),
201
+ text,
202
+ );
203
+ if (next === text) continue;
204
+ await writeFile(file, next, 'utf8');
205
+ changed.push(rel);
206
+ }
207
+
208
+ return changed;
209
+ }
package/lib/audit-core.js CHANGED
@@ -5,13 +5,36 @@ import { spawnSync } from 'node:child_process';
5
5
  import { listCards, listSystems, readConfig, tokensEst } from './gcf-paths.js';
6
6
  import { lint } from './linter.js';
7
7
  import { drifted } from './index-gen.js';
8
+ import { designCheck } from './design-check.js';
9
+ import { buildLedger } from './ledger.js';
8
10
 
9
11
  function git(args, cwd) {
10
12
  const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
13
+
11
14
  return res.status === 0 ? res.stdout.trim() : null;
12
15
  }
13
16
 
14
- const UNTIL_RE = /\[until:\s*(verdict\(card-\d{3}\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]/g;
17
+ const UNTIL_RE = /\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]/g;
18
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ const LEGAL_STATUS = ['proposed', 'built', 'tuning'];
20
+
21
+ /**
22
+ * @typedef {object} AuditReport
23
+ * @property {string} first_line
24
+ * @property {Array<{id: string, commits: number | null, limit: number}>} stale_built
25
+ * @property {Array<{id: string, tune_count: number, limit: number}>} tune_limit
26
+ * @property {Array<{id: string, depends: string[]}>} open_depends
27
+ * @property {{count: number, limit: number, over: boolean}} wip
28
+ * @property {{tokens: number, budget: number, over: boolean} | null} pillars
29
+ * @property {Array<{file: string, trigger: string, status: string}>} until
30
+ * @property {Record<string, object[]>} linter
31
+ * @property {object[]} pragmas
32
+ * @property {boolean} index_drift
33
+ * @property {Array<{id: string, status: string}>} illegal_status
34
+ * @property {number} [schema]
35
+ * @property {Array<{id: string, status: string, system: string[], depends: string[]}>} [cards]
36
+ * @property {{blocking: number, units: number, ready: number}} [design]
37
+ */
15
38
 
16
39
  /**
17
40
  * Deterministic audit core (GCF-PRD §10.1): until triggers, built staleness, tune
@@ -22,86 +45,188 @@ export async function audit(projectDir) {
22
45
  const root = path.join(path.resolve(projectDir), 'gcf');
23
46
  const config = await readConfig(root);
24
47
  const cards = await listCards(root);
25
- const report = { first_line: '', stale_built: [], tune_limit: [], open_depends: [], wip: null, pillars: null, until: [], linter: {}, pragmas: [], index_drift: false, illegal_status: [] };
26
- const liveIds = new Set(cards.map((c) => c.data.id));
48
+ const systems = await listSystems(root);
49
+ /** @type {AuditReport} */
50
+ const report = {
51
+ first_line: '',
52
+ stale_built: [],
53
+ tune_limit: [],
54
+ open_depends: [],
55
+ wip: wipOf(cards, config),
56
+ pillars: await pillarsOf(root, config),
57
+ until: untilRecords(cards, systems, projectDir, await buildLedger(projectDir)),
58
+ linter: {},
59
+ pragmas: [],
60
+ index_drift: await drifted(root),
61
+ illegal_status: [],
62
+ };
63
+ // The harness gate reads everything from this one report — it never parses gcf/ itself.
64
+ report.schema = config.schema;
65
+ report.cards = cards.map((c) => ({
66
+ id: c.data.id,
67
+ status: c.data.status,
68
+ system: c.data.system ?? [],
69
+ depends: c.data.depends ?? [],
70
+ }));
71
+ const design = await designCheck(projectDir);
72
+ checkCards(report, cards, config, { projectDir, designIds: design.units.map((u) => u.id) });
73
+ report.design = {
74
+ blocking: design.blocking,
75
+ units: design.units.length,
76
+ ready: design.units.filter((u) => u.ready).length,
77
+ };
78
+ lintSystems(report, systems);
79
+ report.first_line = firstLine(report);
27
80
 
81
+ return report;
82
+ }
83
+
84
+ /**
85
+ * Per-card checks: illegal status, built staleness, tune limit, open depends. `designIds` are
86
+ * units still in gcf/design/: a card depending on one builds on what nobody has played yet.
87
+ */
88
+ function checkCards(report, cards, config, { projectDir, designIds }) {
89
+ const liveIds = new Set([...cards.map((c) => c.data.id), ...designIds]);
28
90
  // Staleness is measured in commits, so outside a repository there is no answer to give.
29
91
  // Without this probe every built card would read as stale and stop the harness.
30
92
  const inRepo = git(['rev-parse', '--git-dir'], projectDir) !== null;
31
93
 
32
- // Verdict history from commit messages of the fixed format (GCF-PRD §8).
33
- const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s'], projectDir) ?? '';
34
- const verdictFired = (cardId) => new RegExp(`^gcf\\(verdict\\): \\w+ ${cardId}\\b`, 'm').test(verdictLog);
35
- // ponytail: retune(sys) fires on any tune verdict naming the system; the PRD's
36
- // feel-section-diff refinement can replace this when it proves too coarse.
37
- const retuneFired = (system) => new RegExp(`^gcf\\(verdict\\): tune .*→ .*${system}`, 'm').test(verdictLog);
38
-
39
94
  for (const c of cards) {
40
- if (!['proposed', 'built', 'tuning'].includes(c.data.status)) {
95
+ if (!LEGAL_STATUS.includes(c.data.status)) {
41
96
  report.illegal_status.push({ id: c.data.id, status: c.data.status });
42
97
  }
43
- if (inRepo && c.data.status === 'built' && c.data.built_at) {
44
- // git() is null when the ref no longer resolves (rebase, squash, amend, shallow clone).
45
- // Number(null) is 0, which reads as "fresh" — the one answer that must never be guessed.
46
- const raw = git(['rev-list', '--count', `${c.data.built_at}..HEAD`], projectDir);
47
- const n = raw === null ? NaN : Number(raw);
48
- if (!Number.isFinite(n) || n > config.built_stale_after_commits) {
49
- report.stale_built.push({ id: c.data.id, commits: Number.isFinite(n) ? n : null, limit: config.built_stale_after_commits });
50
- }
51
- }
98
+
99
+ const stale = inRepo ? staleEntry(c, config, projectDir) : null;
100
+ if (stale) report.stale_built.push(stale);
101
+
52
102
  if ((c.data.tune_count ?? 0) >= config.tune_limit) {
53
- report.tune_limit.push({ id: c.data.id, tune_count: c.data.tune_count, limit: config.tune_limit });
103
+ report.tune_limit.push({
104
+ id: c.data.id,
105
+ tune_count: c.data.tune_count,
106
+ limit: config.tune_limit,
107
+ });
54
108
  }
109
+
55
110
  // A depends id that still has a card file is unplayed (GCF-PRD §7.2). Data for the
56
111
  // harness gate, not an alert: a proposed card waiting on its dependency is normal.
57
112
  const open = (c.data.depends ?? []).filter((d) => liveIds.has(d));
58
113
  if (open.length) report.open_depends.push({ id: c.data.id, depends: open });
59
114
  }
115
+ }
116
+
117
+ /** A stale_built entry for a built card past the commit limit, else null. */
118
+ function staleEntry(c, config, projectDir) {
119
+ if (c.data.status !== 'built' || !c.data.built_at) return null;
120
+ // git() is null when the ref no longer resolves (rebase, squash, amend, shallow clone).
121
+ // Number(null) is 0, which reads as "fresh" — the one answer that must never be guessed.
122
+ const raw = git(['rev-list', '--count', `${c.data.built_at}..HEAD`], projectDir);
123
+ const n = raw === null ? NaN : Number(raw);
124
+ if (!Number.isFinite(n) || n > config.built_stale_after_commits) {
125
+ return {
126
+ id: c.data.id,
127
+ commits: Number.isFinite(n) ? n : null,
128
+ limit: config.built_stale_after_commits,
129
+ };
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ function wipOf(cards, config) {
136
+ const count = cards.filter((c) => c.data.status === 'built').length;
60
137
 
61
- const wipCount = cards.filter((c) => c.data.status === 'built').length;
62
- report.wip = { count: wipCount, limit: config.wip_built_limit, over: wipCount > config.wip_built_limit };
138
+ return { count, limit: config.wip_built_limit, over: count > config.wip_built_limit };
139
+ }
63
140
 
141
+ async function pillarsOf(root, config) {
64
142
  const pillarsFile = path.join(root, 'PILLARS.md');
65
- if (existsSync(pillarsFile)) {
66
- const tokens = tokensEst(await readFile(pillarsFile, 'utf8'));
67
- report.pillars = { tokens, budget: config.pillars_budget, over: tokens > config.pillars_budget };
143
+ if (!existsSync(pillarsFile)) return null;
144
+ const tokens = tokensEst(await readFile(pillarsFile, 'utf8'));
145
+
146
+ return { tokens, budget: config.pillars_budget, over: tokens > config.pillars_budget };
147
+ }
148
+
149
+ /**
150
+ * Whether a verdict fired. `verdict(id)` reads the files, as the ledger does (GCF-PRD §5.8): a
151
+ * keep or kill left a history or graveyard line, a tune left the card's tune_count — so a
152
+ * squash or a shallow clone does not hide it, and a revert takes it back.
153
+ */
154
+ function verdictMatchers(projectDir, ledger, cards) {
155
+ const tuned = cards.filter((c) => (c.data.tune_count ?? 0) > 0);
156
+ const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s'], projectDir) ?? '';
157
+
158
+ return {
159
+ verdict: (id) =>
160
+ ledger.where(id).terminal.some((t) => t !== 'done') || tuned.some((c) => c.data.id === id),
161
+ // shortcut: a kept card's tunes leave no file behind, so retune(sys) also greps the verdict
162
+ // subjects; upgrade when tunes get their own history line.
163
+ retune: (system) =>
164
+ tuned.some((c) => (c.data.system ?? []).includes(system)) ||
165
+ new RegExp(`^gcf\\(verdict\\): tune .*→ (?:.*,)?${escapeRe(system)}(?:,|$)`, 'm').test(
166
+ verdictLog,
167
+ ),
168
+ };
169
+ }
170
+
171
+ function untilStatus(trigger, fired) {
172
+ if (trigger.startsWith('verdict(')) {
173
+ return fired.verdict(trigger.slice(8, -1)) ? 'stale' : 'open';
68
174
  }
69
175
 
70
- // Until annotations across cards and systems docs.
176
+ if (trigger.startsWith('retune(')) {
177
+ return fired.retune(trigger.slice(7, -1)) ? 'stale' : 'open';
178
+ }
179
+
180
+ return 'manual';
181
+ }
182
+
183
+ /** Until annotations across cards and systems docs. */
184
+ function untilRecords(cards, systems, projectDir, ledger) {
185
+ const fired = verdictMatchers(projectDir, ledger, cards);
71
186
  const docs = [
72
187
  ...cards.map((c) => ({ rel: `cards/${c.name}`, body: c.body })),
73
- ...(await listSystems(root)).map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
188
+ ...systems.map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
74
189
  ];
190
+ const until = [];
75
191
  for (const doc of docs) {
76
192
  for (const m of doc.body.matchAll(UNTIL_RE)) {
77
- const trigger = m[1];
78
- let status = 'open';
79
- if (trigger.startsWith('verdict(')) status = verdictFired(trigger.slice(8, -1)) ? 'stale' : 'open';
80
- else if (trigger.startsWith('retune(')) status = retuneFired(trigger.slice(7, -1)) ? 'stale' : 'open';
81
- else status = 'manual';
82
- report.until.push({ file: `gcf/${doc.rel}`, trigger, status });
193
+ until.push({ file: `gcf/${doc.rel}`, trigger: m[1], status: untilStatus(m[1], fired) });
83
194
  }
84
195
  }
85
196
 
86
- // П2 linter over systems docs.
87
- for (const s of await listSystems(root)) {
197
+ return until;
198
+ }
199
+
200
+ /** П2 linter over systems docs. */
201
+ function lintSystems(report, systems) {
202
+ for (const s of systems) {
88
203
  const { issues, pragmas } = lint(s.body);
89
204
  if (issues.length) report.linter[`gcf/systems/${s.name}`] = issues;
90
205
  report.pragmas.push(...pragmas.map((p) => ({ file: `gcf/systems/${s.name}`, ...p })));
91
206
  }
207
+ }
92
208
 
93
- report.index_drift = await drifted(root);
94
-
95
- const alerts = [
96
- report.illegal_status.length && `${report.illegal_status.length} card(s) in illegal status`,
97
- report.stale_built.length && `${report.stale_built.length} built card(s) stale — playtest first`,
98
- report.tune_limit.length && `${report.tune_limit.length} card(s) at tune limit — kill or redesign`,
99
- report.wip.over && `WIP over limit (${report.wip.count}/${report.wip.limit})`,
100
- report.until.filter((u) => u.status === 'stale').length && 'stale until-records present',
101
- report.pillars?.over && 'PILLARS over token budget',
102
- Object.keys(report.linter).length && 'П2 linter findings in systems docs',
103
- report.index_drift && 'INDEX drift (regenerate)',
104
- ].filter(Boolean);
105
- report.first_line = alerts.length ? alerts.join('; ') : 'clean';
106
- return report;
209
+ /** Alerts about cards in flight, which lead the line. @param {AuditReport} report */
210
+ const cardAlerts = (report) => [
211
+ report.illegal_status.length && `${report.illegal_status.length} card(s) in illegal status`,
212
+ report.stale_built.length && `${report.stale_built.length} built card(s) stale — playtest first`,
213
+ report.tune_limit.length &&
214
+ `${report.tune_limit.length} card(s) at tune limit — kill or redesign`,
215
+ report.wip.over && `WIP over limit (${report.wip.count}/${report.wip.limit})`,
216
+ ];
217
+
218
+ /** Alerts about the documents. @param {AuditReport} report */
219
+ const docAlerts = (report) => [
220
+ report.until.filter((u) => u.status === 'stale').length && 'stale until-records present',
221
+ report.pillars?.over && 'PILLARS over token budget',
222
+ Object.keys(report.linter).length && 'П2 linter findings in systems docs',
223
+ report.index_drift && 'INDEX drift (regenerate)',
224
+ report.design?.blocking && `${report.design.blocking} blocking design finding(s)`,
225
+ ];
226
+
227
+ /** @param {AuditReport} report */
228
+ function firstLine(report) {
229
+ const alerts = [...cardAlerts(report), ...docAlerts(report)].filter(Boolean);
230
+
231
+ return alerts.length ? alerts.join('; ') : 'clean';
107
232
  }