gcf-aif-ext 0.3.0 → 0.4.1

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 (51) hide show
  1. package/README.md +63 -11
  2. package/commands/gcf.js +321 -43
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +60 -31
  5. package/injections/commit-status.md +7 -5
  6. package/injections/plan-context.md +8 -5
  7. package/injections/review-pillars.md +4 -3
  8. package/lib/agents-block.js +66 -0
  9. package/lib/anchors.js +27 -4
  10. package/lib/audit-core.js +135 -48
  11. package/lib/bundle.js +7 -4
  12. package/lib/context.js +33 -25
  13. package/lib/design-check.js +149 -40
  14. package/lib/design.js +2 -2
  15. package/lib/gcf-paths.js +13 -2
  16. package/lib/id.js +2 -2
  17. package/lib/inbox.js +54 -0
  18. package/lib/index-gen.js +23 -8
  19. package/lib/ledger.js +19 -17
  20. package/lib/linter.js +2 -1
  21. package/lib/live.js +191 -0
  22. package/lib/pillars.js +113 -0
  23. package/lib/plan-check.js +31 -0
  24. package/lib/playtest-list.js +8 -4
  25. package/lib/systems-doc.js +16 -1
  26. package/lib/transitions.js +36 -21
  27. package/lib/unit-ops.js +125 -97
  28. package/lib/validate-item.js +244 -0
  29. package/lib/verdict-fs.js +67 -128
  30. package/lib/work-ops.js +759 -0
  31. package/package.json +4 -3
  32. package/profiles/pillars.md +1 -1
  33. package/profiles/system.md +1 -1
  34. package/profiles/unit-constraint.md +10 -0
  35. package/profiles/unit-entity.md +10 -0
  36. package/profiles/unit-mechanic.md +6 -1
  37. package/profiles/unit-resource.md +6 -1
  38. package/profiles/unit-rule.md +10 -0
  39. package/schemas/item.schema.json +105 -0
  40. package/skills/gcf-adopt/SKILL.md +2 -2
  41. package/skills/gcf-audit/SKILL.md +3 -3
  42. package/skills/gcf-card/SKILL.md +27 -20
  43. package/skills/gcf-ingest/SKILL.md +3 -3
  44. package/skills/gcf-init/SKILL.md +8 -5
  45. package/skills/gcf-triage/SKILL.md +35 -0
  46. package/skills/gcf-verdict/SKILL.md +19 -16
  47. package/templates/agents-block.md +28 -0
  48. package/templates/config.yml +7 -0
  49. package/templates/{card.md → item.md} +10 -3
  50. package/lib/validate-card.js +0 -93
  51. package/schemas/card.schema.json +0 -46
package/extension.json CHANGED
@@ -1,24 +1,41 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.3.0",
4
+ "version": "0.4.1",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
8
8
  "skills/gcf-adopt",
9
9
  "skills/gcf-card",
10
10
  "skills/gcf-verdict",
11
+ "skills/gcf-triage",
11
12
  "skills/gcf-audit",
12
13
  "skills/gcf-vision",
13
14
  "skills/gcf-debate",
14
15
  "skills/gcf-ingest"
15
16
  ],
16
17
  "commands": [
17
- { "name": "gcf", "description": "Game Context Framework core", "module": "commands/gcf.js" }
18
+ {
19
+ "name": "gcf",
20
+ "description": "Game Context Framework core",
21
+ "module": "commands/gcf.js"
22
+ }
18
23
  ],
19
24
  "injections": [
20
- { "target": "aif-plan", "position": "append", "file": "./injections/plan-context.md" },
21
- { "target": "aif-review", "position": "append", "file": "./injections/review-pillars.md" },
22
- { "target": "aif-commit", "position": "append", "file": "./injections/commit-status.md" }
25
+ {
26
+ "target": "aif-plan",
27
+ "position": "append",
28
+ "file": "./injections/plan-context.md"
29
+ },
30
+ {
31
+ "target": "aif-review",
32
+ "position": "append",
33
+ "file": "./injections/review-pillars.md"
34
+ },
35
+ {
36
+ "target": "aif-commit",
37
+ "position": "append",
38
+ "file": "./injections/commit-status.md"
39
+ }
23
40
  ]
24
41
  }
@@ -6,13 +6,15 @@
6
6
  import { existsSync } from 'node:fs';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import path from 'node:path';
9
- import { listSystems } from '../lib/gcf-paths.js';
10
- import { validateCard, validateUntil } from '../lib/validate-card.js';
9
+ import { listSystems, legacyCards } from '../lib/gcf-paths.js';
10
+ import { validateItem, validateUntil } from '../lib/validate-item.js';
11
11
  import { stemMatches } from '../lib/id.js';
12
12
  import { writeIndex } from '../lib/index-gen.js';
13
13
  import { lint } from '../lib/linter.js';
14
14
  import { buildLedger } from '../lib/ledger.js';
15
15
  import { designCheck } from '../lib/design-check.js';
16
+ import { matchesFrozen } from '../lib/work-ops.js';
17
+ import { checkLive } from '../lib/live.js';
16
18
 
17
19
  const projectDir = process.cwd();
18
20
  const root = path.join(projectDir, 'gcf');
@@ -26,36 +28,58 @@ const fail = (msg) => {
26
28
  // null when git itself failed (unborn HEAD, no work tree) — then fall through to the gate
27
29
  // rather than letting a broken probe wave a commit past every check.
28
30
  function git(args) {
29
- const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
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
+ });
30
36
 
31
37
  return res.status === 0 ? res.stdout.trim() : null;
32
38
  }
33
39
 
34
- const staged = git(['diff', '--cached', '--name-only', '--', root]);
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]);
35
44
  if (staged === '') process.exit(0); // this commit does not touch gcf/
36
45
  if (staged !== null) {
37
- // Untracked counts: cards and units are found by reading directories, so an uncommitted file
46
+ // Untracked counts: items and units are found by reading directories, so an uncommitted file
38
47
  // would be validated and written into the staged INDEX while not being in the repository.
39
- const unstaged = git(['diff', '--name-only', '--', root]);
40
- const untracked = git(['ls-files', '--others', '--exclude-standard', '--', root]);
41
- if (unstaged || untracked) {
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.
42
56
  fail(
43
- 'gcf/ has changes outside the index — stage them, or commit gcf/ separately ' +
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 ' +
44
59
  '(the gate reads the working tree)',
45
60
  );
46
61
  }
47
62
  }
48
63
 
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
+
49
73
  const ledger = await buildLedger(projectDir);
50
74
 
51
75
  // 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
52
- for (const c of ledger.cards) {
76
+ for (const c of ledger.items) {
53
77
  const errors = [
54
- ...validateCard(c.data),
78
+ ...validateItem(c.data),
55
79
  ...validateUntil(c.body),
56
80
  ...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
57
81
  ];
58
- if (errors.length) fail(`${c.name}: ${errors[0]}`);
82
+ if (errors.length) fail(`work/${c.name}: ${errors[0]}`);
59
83
  }
60
84
 
61
85
  for (const s of await listSystems(root)) {
@@ -63,35 +87,40 @@ for (const s of await listSystems(root)) {
63
87
  if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
64
88
  }
65
89
 
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.
90
+ // An unplayed item's body and live: are the source of its systems/ lines and its capability
91
+ // (Methodology §5.4): edited after its close, a tune or a kill would take the wrong ones out.
92
+ for (const c of ledger.items.filter((x) => x.data.status === 'unplayed')) {
93
+ if (!matchesFrozen(c))
94
+ fail(
95
+ `work/${c.name}: an unplayed item's body and live: are frozen — restore them, tune it, ` +
96
+ 'then edit',
97
+ );
98
+ }
99
+
100
+ // A unit or item leaving without its move loses its reason, not its integrity: one still citing
101
+ // it fails M2 below, in this commit. A warning, not a refusal — a revert of the commit that
102
+ // added it removes an id legitimately.
69
103
  for (const id of ledger.vanished()) {
70
104
  console.error(
71
- `gcf pre-commit: warning: ${id} left design/ or cards/ without done, drop or a verdict — ` +
105
+ `gcf pre-commit: warning: ${id} left design/ or work/ without a move — ` +
72
106
  'no history or graveyard line keeps why',
73
107
  );
74
108
  }
75
109
 
76
- // Design model and card bodies: every finding of severity `block` refuses the commit.
110
+ // Design model and item bodies: every finding of severity `block` refuses the commit.
77
111
  const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
78
112
  if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
79
113
 
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')) {
83
- for (const dep of c.data.depends ?? []) {
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)`);
87
- }
88
- }
114
+ // Dependencies are held by the moves, not here (Methodology §4.1): `submit` refuses an unplayed
115
+ // one, `close` and `keep` a not-yet-kept one. A gate check would refuse the tune or the kill of
116
+ // a dependency whose dependent is already waiting for its verdict — a legal move, blocked.
89
117
 
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
- }
118
+ // LIVE.md form: a malformed record would hide from every reader of the snapshot. A broken ref
119
+ // into gcf/ is this commit's own doing (a move deleted the file); one into the code is not
120
+ // refused — a code commit outside gcf/ may have moved it and the gate never saw that.
121
+ const live = await checkLive(projectDir);
122
+ const form = live.problems[0] ?? live.issues.find((i) => /summary|twice|refs gcf\//.test(i));
123
+ if (form) fail(`LIVE.md: ${form}`);
95
124
 
96
125
  // 5: П2 linter over systems docs.
97
126
  for (const s of await listSystems(root)) {
@@ -1,7 +1,9 @@
1
- ## GCF: card status before commit (from gcf-aif-ext)
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 card, verify the card frontmatter is current
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
- - 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
+ - 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,9 +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 card, run `ai-factory gcf context <card-id>` first
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
- - 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`
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 card, read the card and `gcf/PILLARS.md` when it exists (it stays absent until the pillars interview)
5
- - check the diff against every negative constraint of the card: name each violation with file and line
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 card's envelope: report the value and the envelope bounds
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
@@ -0,0 +1,66 @@
1
+ // The project's instructions for any agent (Methodology §1): a session that runs no gcf skill
2
+ // still has to know that work lives in gcf/ and which commands own it. `gcf init` keeps one
3
+ // marked block in AGENTS.md, and in a CLAUDE.md that does not import AGENTS.md already.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { atomicWrite } from './safe-path.js';
8
+
9
+ export const START = '<!-- gcf:start -->';
10
+ export const END = '<!-- gcf:end -->';
11
+
12
+ /** `text` with the marked block put in place of the old one, or appended. */
13
+ export function withBlock(text, block) {
14
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
15
+ const body = `${START}${eol}${block.trim().replace(/\r?\n/g, eol)}${eol}${END}`;
16
+ const at = text.indexOf(START);
17
+ const end = text.indexOf(END, at);
18
+ if (at >= 0 && end > at) return text.slice(0, at) + body + text.slice(end + END.length);
19
+ // One blank line between the old text and the block, however the text ended.
20
+ const sep = !text || text.endsWith(eol + eol) ? '' : eol.repeat(text.endsWith(eol) ? 1 : 2);
21
+
22
+ return `${text}${sep}${body}${eol}`;
23
+ }
24
+
25
+ /** Whether the AIF project lists Claude Code among its agents (`.ai-factory.json`). */
26
+ async function claudeAgent(projectDir) {
27
+ const file = path.join(projectDir, '.ai-factory.json');
28
+ if (!existsSync(file)) return false;
29
+ try {
30
+ const cfg = JSON.parse(await readFile(file, 'utf8'));
31
+
32
+ return (cfg.agents ?? []).some((a) => a?.id === 'claude');
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Write the block where agents read it. AGENTS.md always gets it (created when absent — /aif
40
+ * writes that file, gcf-init runs after /aif). A CLAUDE.md that imports `@AGENTS.md` sees it
41
+ * there; one that does not gets its own copy. With Claude among the AIF agents and no
42
+ * CLAUDE.md, one is created importing AGENTS.md. Returns the files written, relative.
43
+ */
44
+ export async function writeAgentsBlock(projectDir, block) {
45
+ const written = [];
46
+ const put = async (name, base) => {
47
+ const file = path.join(projectDir, name);
48
+ const old = existsSync(file) ? await readFile(file, 'utf8') : base;
49
+ const next = withBlock(old, block);
50
+ if (next === old && existsSync(file)) return;
51
+ await atomicWrite(file, next);
52
+ written.push(name);
53
+ };
54
+
55
+ await put('AGENTS.md', '');
56
+ const claude = path.join(projectDir, 'CLAUDE.md');
57
+ if (existsSync(claude)) {
58
+ const text = await readFile(claude, 'utf8');
59
+ if (!/^@AGENTS\.md\s*$/m.test(text) || text.includes(START)) await put('CLAUDE.md', text);
60
+ } else if (await claudeAgent(projectDir)) {
61
+ await atomicWrite(claude, '@AGENTS.md\n');
62
+ written.push('CLAUDE.md');
63
+ }
64
+
65
+ return written;
66
+ }
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: [] };
@@ -171,10 +194,10 @@ export async function sourceBodies(sourceDir) {
171
194
  return bodies;
172
195
  }
173
196
 
174
- /** gcf-relative files that cite anchors: units, cards, and the `sections:` of questions. */
197
+ /** gcf-relative files that cite anchors: units, work items, and the `sections:` of questions. */
175
198
  async function anchorCiters(root) {
176
199
  const out = [];
177
- for (const dir of ['design', 'cards']) {
200
+ for (const dir of ['design', 'work']) {
178
201
  if (!existsSync(path.join(root, dir))) continue;
179
202
  const names = (await readdir(path.join(root, dir))).filter((n) => n.endsWith('.md'));
180
203
  out.push(...names.map((n) => `${dir}/${n}`));
@@ -187,7 +210,7 @@ async function anchorCiters(root) {
187
210
 
188
211
  /**
189
212
  * 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
213
+ * design/, work/ and open-questions.md, so provenance, `source:` and `sections:` follow the
191
214
  * section instead of pointing at an anchor that no longer exists. Returns the files it changed.
192
215
  */
193
216
  export async function rewriteAnchors(projectDir, renamed) {