gcf-aif-ext 0.4.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.
package/README.md CHANGED
@@ -50,6 +50,16 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
50
50
 
51
51
  On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
52
52
 
53
+ ### 0.4.1
54
+
55
+ A fix release over 0.4.0; update in place. What you may notice:
56
+
57
+ - An unplayed item's `frozen:` now covers its `live:` delta as well as its body. Items closed by 0.4.0 keep working. `tune`, `kill` and `drop` refuse an unplayed item edited since its close: restore the file first.
58
+ - With `PILLARS.md` present, a mechanic or resource with `pillars: TBD` is not ready to `take`. `gcf pillar rename` and `gcf pillar retire` revise a pillar where units and items cite it.
59
+ - `design check` blocks a `depends:` cycle and an id with two terminal lines (a merge of branches).
60
+ - The commit gate refuses a `LIVE.md` ref into `gcf/` that names no file.
61
+ - `gcf init` writes an agent instructions block into `AGENTS.md` (and into a `CLAUDE.md` that does not import it), between `<!-- gcf:start -->` and `<!-- gcf:end -->`. Any agent session then knows where work lives and which commands own it. Run `gcf init` again after an update to refresh it.
62
+
53
63
  ### Upgrading from 0.3.x
54
64
 
55
65
  0.4.0 replaces the cards with work items and does **not migrate** them. A schema 2 card in `gcf/cards/` is no longer read: while any is left there, the pre-commit gate refuses every commit that touches `gcf/`, and `gcf audit` reports it first. In order:
package/commands/gcf.js CHANGED
@@ -151,7 +151,8 @@ function registerInit(gcf, program) {
151
151
  gcf
152
152
  .command('init')
153
153
  .description(
154
- 'Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness',
154
+ 'Create the gcf/ skeleton from templates (idempotent, never overwrites) and the agent ' +
155
+ 'instructions block in AGENTS.md (replaced in place); offers the harness',
155
156
  )
156
157
  .option('--harness', `also install ${HARNESS} and run gcf-harness init (Claude Code only)`)
157
158
  .option('--no-harness', 'do not offer the harness')
@@ -160,6 +161,10 @@ function registerInit(gcf, program) {
160
161
  await seedSkeleton(root);
161
162
  const { writeIndex } = await lib('index-gen');
162
163
  await writeIndex(root);
164
+ // Re-run on every update: the block is replaced in place, so it follows the extension.
165
+ const block = await readFile(path.join(EXT_ROOT, 'templates', 'agents-block.md'), 'utf8');
166
+ const agents = await (await lib('agents-block')).writeAgentsBlock(process.cwd(), block);
167
+ if (agents.length) console.log(`agent instructions written: ${agents.join(', ')}`);
163
168
  console.log(
164
169
  'gcf/ ready. Write PILLARS.md through /gcf-init, then import a GDD with /gcf-ingest ' +
165
170
  'or start the design with /gcf-vision.',
@@ -780,8 +785,36 @@ function registerLint(gcf) {
780
785
  });
781
786
  }
782
787
 
788
+ function registerPillar(gcf) {
789
+ const pillar = gcf
790
+ .command('pillar')
791
+ .description('Revise PILLARS.md where units and items cite it (the human decides)');
792
+ pillar
793
+ .command('rename <from> <to>')
794
+ .description('Rename a pillar id in PILLARS.md, units and items (commit gcf(pillar))')
795
+ .option('--no-commit', 'skip the commit (tests only)')
796
+ .action(async (from, to, o) => {
797
+ const r = await (await lib('pillars')).renamePillar(process.cwd(), from, to, o);
798
+ console.log(`renamed ${from} → ${to}${shortHash(r.hash)}`);
799
+ });
800
+ pillar
801
+ .command('retire <id>')
802
+ .description('Take a pillar out; units left with none read pillars: TBD (commit gcf(pillar))')
803
+ .requiredOption('--reason <text>', 'why the game no longer holds it')
804
+ .option('--no-commit', 'skip the commit (tests only)')
805
+ .action(async (id, o) => {
806
+ const r = await (await lib('pillars')).retirePillar(process.cwd(), id, o);
807
+ console.log(`retired ${id}${shortHash(r.hash)}`);
808
+ if (r.bare.length)
809
+ console.log(
810
+ `items now citing no pillar — give each one or accept it: ${r.bare.join(', ')}`,
811
+ );
812
+ });
813
+ }
814
+
783
815
  export function register(program) {
784
816
  const gcf = program.command('gcf').description('Game Context Framework core');
817
+ registerPillar(gcf);
785
818
  registerInit(gcf, program);
786
819
  registerIndex(gcf);
787
820
  registerContext(gcf);
package/extension.json CHANGED
@@ -1,7 +1,7 @@
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.4.0",
4
+ "version": "0.4.1",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -13,7 +13,7 @@ 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 { bodyHash } from '../lib/work-ops.js';
16
+ import { matchesFrozen } from '../lib/work-ops.js';
17
17
  import { checkLive } from '../lib/live.js';
18
18
 
19
19
  const projectDir = process.cwd();
@@ -87,11 +87,14 @@ for (const s of await listSystems(root)) {
87
87
  if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
88
88
  }
89
89
 
90
- // An unplayed item's body is the source of its systems/ lines (Methodology §5.4): edited after
91
- // its close, a tune or a kill would take the wrong lines out. `close` froze it by hash.
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
92
  for (const c of ledger.items.filter((x) => x.data.status === 'unplayed')) {
93
- if (bodyHash(c.body) !== c.data.frozen)
94
- fail(`work/${c.name}: an unplayed item's body is frozen — tune it to change it`);
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
+ );
95
98
  }
96
99
 
97
100
  // A unit or item leaving without its move loses its reason, not its integrity: one still citing
@@ -112,9 +115,11 @@ if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`)
112
115
  // one, `close` and `keep` a not-yet-kept one. A gate check would refuse the tune or the kill of
113
116
  // a dependency whose dependent is already waiting for its verdict — a legal move, blocked.
114
117
 
115
- // LIVE.md form: a malformed record would hide from every reader of the snapshot.
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.
116
121
  const live = await checkLive(projectDir);
117
- const form = live.problems[0] ?? live.issues.find((i) => /summary|twice/.test(i));
122
+ const form = live.problems[0] ?? live.issues.find((i) => /summary|twice|refs gcf\//.test(i));
118
123
  if (form) fail(`LIVE.md: ${form}`);
119
124
 
120
125
  // 5: П2 linter over systems docs.
@@ -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/context.js CHANGED
@@ -5,7 +5,7 @@ import { listItems, listSystems, readConfig, tokensEst } from './gcf-paths.js';
5
5
 
6
6
  /**
7
7
  * Readset assembly — the whole command is deterministic (GCF-PRD §9.1, §10.1):
8
- * PILLARS + LIVE.md + touched systems + the item + its depends + graveyard tag hits +
8
+ * PILLARS + LIVE.md + touched systems + the item + its parent + its depends + graveyard tag hits +
9
9
  * D2 assumptions whose sys overlaps. Never over_budget-throws: the caller gates.
10
10
  */
11
11
  export async function buildContext(projectDir, itemId) {
@@ -36,14 +36,19 @@ export async function buildContext(projectDir, itemId) {
36
36
  };
37
37
  }
38
38
 
39
- /** PILLARS, LIVE.md, touched systems, the item, its depends — in order, skipping absent files. */
39
+ /**
40
+ * PILLARS, LIVE.md, touched systems, the item, its parent feature (the goal a child serves),
41
+ * its depends — in order, skipping absent files.
42
+ */
40
43
  async function readsetFiles(root, item, items) {
41
44
  const files = [];
42
45
  const push = async (rel) => {
43
46
  const abs = path.join(root, rel);
44
- if (!existsSync(abs)) return;
47
+ const at = `gcf/${rel.replaceAll('\\', '/')}`;
48
+ // A parent that is also a dependency is read once and counted once against the budget.
49
+ if (!existsSync(abs) || files.some((f) => f.path === at)) return;
45
50
  const text = await readFile(abs, 'utf8');
46
- files.push({ path: `gcf/${rel.replaceAll('\\', '/')}`, tokens_est: tokensEst(text) });
51
+ files.push({ path: at, tokens_est: tokensEst(text) });
47
52
  };
48
53
 
49
54
  await push('PILLARS.md');
@@ -54,6 +59,8 @@ async function readsetFiles(root, item, items) {
54
59
  }
55
60
 
56
61
  await push(`work/${item.name}`);
62
+ const parent = items.find((c) => c.data.id === item.data.parent);
63
+ if (parent) await push(`work/${parent.name}`);
57
64
  for (const depId of item.data.depends ?? []) {
58
65
  const dep = items.find((c) => c.data.id === depId);
59
66
  if (dep) await push(`work/${dep.name}`);
@@ -496,6 +496,10 @@ function ledgerIssues(ledger) {
496
496
  out.push({ check: 'M12', severity: 'block', file, line: 1, id, message });
497
497
  if (live.length > 1)
498
498
  add(`${id} lives in ${live.length} places: ${live.map((p) => p.file).join(', ')}`);
499
+ // An id ends once; two ends (a merge of branches that each ended it) leave its dependents
500
+ // counting it as played or dead depending on which line wins.
501
+ if (terminal.length > 1)
502
+ add(`${id} ended ${terminal.join(' and ')}: keep one line in history.md or graveyard.md`);
499
503
  if (live.length && terminal.length) {
500
504
  add(
501
505
  `${id} is ${terminal.join(' and ')} already: ids are never reused — give the new one ` +
@@ -508,6 +512,49 @@ function ledgerIssues(ledger) {
508
512
  return out;
509
513
  }
510
514
 
515
+ /**
516
+ * M2 over `depends:` of units and items together: a cycle never gets played — every id in it
517
+ * waits for the next one. Each cycle is reported once, from the id DFS meets it at.
518
+ */
519
+ export function dependsCycles(ledger) {
520
+ /** @type {Map<string, string[]>} */
521
+ const graph = new Map();
522
+ for (const u of ledger.design.flatMap((d) => d.units)) {
523
+ const list = listOf(u.header.depends ?? '');
524
+ graph.set(u.id, Array.isArray(list) ? list : []);
525
+ }
526
+
527
+ for (const c of ledger.items) graph.set(String(c.data.id), c.data.depends ?? []);
528
+ /** @type {Issue[]} */
529
+ const out = [];
530
+ const state = new Map(); // id → 'open' while on the DFS path, 'done' after
531
+ const visit = (id, trail) => {
532
+ if (state.get(id) === 'done') return;
533
+ if (state.get(id) === 'open') {
534
+ const cycle = [...trail.slice(trail.indexOf(id)), id];
535
+ const file = ledger.where(id).live[0]?.file ?? 'design';
536
+ out.push({
537
+ check: 'M2',
538
+ severity: 'block',
539
+ file,
540
+ line: 1,
541
+ id,
542
+ message: `depends cycle ${cycle.join(' -> ')}: none of them can be played first`,
543
+ });
544
+
545
+ return;
546
+ }
547
+
548
+ state.set(id, 'open');
549
+ for (const d of graph.get(id) ?? []) visit(d, [...trail, id]);
550
+ state.set(id, 'done');
551
+ };
552
+
553
+ for (const id of graph.keys()) visit(id, []);
554
+
555
+ return out;
556
+ }
557
+
511
558
  /** M14: backlog.md is the order of the units in design/, each exactly once. */
512
559
  function backlogIssues(text, unitIds) {
513
560
  /** @type {Issue[]} */
@@ -577,6 +624,31 @@ function pillarReport(units, ctx) {
577
624
  };
578
625
  }
579
626
 
627
+ /** A unit's line in the report: its TBD count and whether it is ready to take. */
628
+ function unitSummary(u, own, values, ctx) {
629
+ const pillars = listOf(u.header.pillars ?? '');
630
+ // Once PILLARS.md exists, a mechanic or resource says which pillar it serves before work:
631
+ // `take` would turn the TBD into an empty list and the unit would serve nothing silently.
632
+ const pillarsTbd =
633
+ pillars === 'TBD' && ctx.pillarIds.length && CARD_TYPES.includes(u.header.type) ? 1 : 0;
634
+ const tbd = values.filter((v) => isTbd(v.value)).length + pillarsTbd;
635
+ const blockedBy = ctx.questions.d1.get(u.id) ?? [];
636
+
637
+ return {
638
+ id: u.id,
639
+ file: u.file,
640
+ system: u.system,
641
+ type: u.header.type ?? null,
642
+ milestone: u.header.milestone ?? 'TBD',
643
+ pillars,
644
+ pillars_owed: Boolean(pillarsTbd),
645
+ values: values.length,
646
+ tbd,
647
+ blocked_by: blockedBy,
648
+ ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
649
+ };
650
+ }
651
+
580
652
  /**
581
653
  * Run every design check over a project. `blocking` counts the issues that refuse a commit;
582
654
  * the rest of the report (completeness, readiness, pillars) is a reading, not a gate.
@@ -591,28 +663,14 @@ export async function designCheck(projectDir) {
591
663
  for (const u of ledger.design.flatMap((d) => d.units)) {
592
664
  const { issues: own, values } = await checkUnit(u, ctx, ledger);
593
665
  issues.push(...own);
594
- const tbd = values.filter((v) => isTbd(v.value)).length;
595
- const blockedBy = ctx.questions.d1.get(u.id) ?? [];
596
- const pillars = listOf(u.header.pillars ?? '');
597
- units.push({
598
- id: u.id,
599
- file: u.file,
600
- system: u.system,
601
- type: u.header.type ?? null,
602
- milestone: u.header.milestone ?? 'TBD',
603
- pillars,
604
- values: values.length,
605
- tbd,
606
- blocked_by: blockedBy,
607
- ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
608
- });
666
+ units.push(unitSummary(u, own, values, ctx));
609
667
  }
610
668
 
611
669
  for (const c of ledger.items) issues.push(...(await checkItem(c, ctx, ledger)));
612
670
  issues.push(...(await unplayedIssues(root, ledger)));
613
671
  for (const doc of ['game', 'glossary']) issues.push(...(await checkDoc(root, doc, ctx)));
614
672
  issues.push(...preambleIssues(ledger.design));
615
- issues.push(...ledgerIssues(ledger), ...questionIssues(ctx, ledger));
673
+ issues.push(...ledgerIssues(ledger), ...dependsCycles(ledger), ...questionIssues(ctx, ledger));
616
674
  if (ledger.design.length) {
617
675
  const backlog = await readIf(path.join(root, 'backlog.md'));
618
676
  issues.push(...backlogIssues(backlog, new Set(units.map((u) => u.id))));
package/lib/live.js CHANGED
@@ -117,13 +117,25 @@ export function applyDelta(capabilities, delta, itemId) {
117
117
  /**
118
118
  * The capabilities without what `delta` added: an unplayed mechanic killed, dropped or sent back
119
119
  * to tuning leaves the game, so its record leaves LIVE.md with it. `modified` and `removed`
120
- * cannot be undone from the delta alone and stay as they are.
120
+ * cannot be undone from the delta alone and stay as they are. A record another item modified
121
+ * since is refused: taking it out would erase that item's accepted change.
121
122
  */
122
- export function revertDelta(capabilities, delta) {
123
+ export function revertDelta(capabilities, delta, itemId) {
123
124
  if (!delta || 'none' in delta) return capabilities;
124
- const added = delta.changes.filter((c) => c.change === 'added').map((c) => c.id);
125
+ const added = delta.changes.filter((c) => c.change === 'added');
126
+ for (const c of added) {
127
+ const now = capabilities.find((r) => r.id === c.id);
128
+ // LIVE.md keeps fields trimmed: compare in that form, or a trailing space reads as an edit.
129
+ const same = (a, b) => String(a ?? '').trim() === String(b ?? '').trim();
130
+ if (now && !(same(now.summary, c.summary) && same(now.refs, c.refs)))
131
+ throw new Error(
132
+ `${itemId}: capability ${c.id} was modified after ${itemId} added it — taking it out ` +
133
+ 'would erase that change: remove the record from LIVE.md by hand in its own commit ' +
134
+ 'and repeat, or keep the item',
135
+ );
136
+ }
125
137
 
126
- return capabilities.filter((r) => !added.includes(r.id));
138
+ return capabilities.filter((r) => !added.some((c) => c.id === r.id));
127
139
  }
128
140
 
129
141
  /** The known-issue record of a parked bug: its title or first intent bullet, and its file. */
package/lib/pillars.js ADDED
@@ -0,0 +1,113 @@
1
+ // Revising PILLARS.md after units and items cite it: a rename follows the id everywhere it is
2
+ // cited, a retire takes it out and sends every unit left without a pillar back to the human.
3
+ // Pillars are human authorship (gcf-init): these moves only carry out the human's decision.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { serialize } from './frontmatter.js';
8
+ import { SLUG_RE, listOf, parseDesign } from './design.js';
9
+ import { recordsIn } from './design-check.js';
10
+ import { buildLedger } from './ledger.js';
11
+ import { atomicWrite } from './safe-path.js';
12
+ import { commitMove } from './unit-ops.js';
13
+
14
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+
16
+ /** The `id: <id>` line of a PILLARS.md record; `\r` and the line break stay outside it. */
17
+ const idLine = (id) =>
18
+ new RegExp(`^([ \\t]*(?:- )?id:[ \\t]*)${escapeRe(id)}[ \\t]*(?=\\r?$)`, 'm');
19
+
20
+ /**
21
+ * The design file with `edit` applied to each unit's header `pillars:` line — the header only:
22
+ * a `pillars:` line in a body or the preamble is not a citation. TBD stays TBD; a list left
23
+ * empty reads TBD, so the unit is not ready until the human gives it a pillar again.
24
+ */
25
+ function rewriteHeaders(text, edit) {
26
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
27
+ const lines = text.split(/\r?\n/);
28
+ for (const u of parseDesign(text, '').units) {
29
+ const at = u.headerLines.pillars;
30
+ const list = listOf(u.header.pillars ?? '');
31
+ if (at === undefined || list === 'TBD') continue;
32
+ const next = edit(list);
33
+ if (next.join() !== list.join())
34
+ lines[at - 1] = `pillars: ${next.length ? `[${next.join(', ')}]` : 'TBD'}`;
35
+ }
36
+
37
+ return lines.join(eol);
38
+ }
39
+
40
+ /**
41
+ * Apply `edit` to every citing list — unit headers in design/, item frontmatter in work/ — and
42
+ * `editPillars` to PILLARS.md, in one commit `gcf(pillar): <tail>`. Returns the items left
43
+ * citing no pillar: an item in work has no TBD to fall back to, so the human hears about it.
44
+ */
45
+ async function revise(projectDir, id, { edit, editPillars, tail, commit }) {
46
+ const root = path.join(path.resolve(projectDir), 'gcf');
47
+ const pillarsFile = path.join(root, 'PILLARS.md');
48
+ const text = existsSync(pillarsFile) ? await readFile(pillarsFile, 'utf8') : '';
49
+ const ids = recordsIn(text, 'pillars').map((p) => p.id);
50
+ if (!ids.includes(id)) throw new Error(`${id} is not a pillar of gcf/PILLARS.md`);
51
+ const ledger = await buildLedger(projectDir);
52
+ const designFiles = ledger.design.map((d) => path.join(root, d.file));
53
+ const items = ledger.items.filter((c) => (c.data.pillars ?? []).includes(id));
54
+ const files = [pillarsFile, ...designFiles, ...items.map((c) => c.file)];
55
+ const mutate = async () => {
56
+ await atomicWrite(pillarsFile, editPillars(text));
57
+ for (const f of designFiles) {
58
+ const old = await readFile(f, 'utf8');
59
+ const next = rewriteHeaders(old, edit);
60
+ if (next !== old) await atomicWrite(f, next);
61
+ }
62
+
63
+ for (const c of items) {
64
+ await atomicWrite(c.file, serialize({ ...c.data, pillars: edit(c.data.pillars) }, c.body));
65
+ }
66
+ };
67
+
68
+ return {
69
+ id,
70
+ hash: await commitMove(projectDir, `gcf(pillar): ${tail}`, { files, mutate, commit }),
71
+ bare: items.filter((c) => !edit(c.data.pillars).length).map((c) => String(c.data.id)),
72
+ };
73
+ }
74
+
75
+ /** `pillar rename <from> <to>`: the id changes in PILLARS.md and every unit and item citing it. */
76
+ export async function renamePillar(projectDir, from, to, { commit = true } = {}) {
77
+ if (!SLUG_RE.test(to)) throw new Error(`${to}: a pillar id is a lowercase hyphen slug`);
78
+ const text = await readFile(path.join(projectDir, 'gcf', 'PILLARS.md'), 'utf8').catch(() => '');
79
+ if (recordsIn(text, 'pillars').some((p) => p.id === to))
80
+ throw new Error(`${to} is a pillar already`);
81
+
82
+ return revise(projectDir, from, {
83
+ edit: (list) => list.map((p) => (p === from ? to : p)),
84
+ editPillars: (t) => t.replace(idLine(from), (_, head) => `${head}${to}`),
85
+ tail: `rename ${from} → ${to}`,
86
+ commit,
87
+ });
88
+ }
89
+
90
+ /**
91
+ * `pillar retire <id> --reason`: the record leaves PILLARS.md and the id leaves every list. A
92
+ * unit left citing no pillar reads `pillars: TBD` again — it is not ready until the human gives
93
+ * it one, or decides it is out of scope. The reason is kept in the commit.
94
+ */
95
+ export async function retirePillar(projectDir, id, { reason, commit = true }) {
96
+ const why = String(reason ?? '')
97
+ .replace(/\s+/g, ' ')
98
+ .trim();
99
+ if (!why) throw new Error('a retire needs a reason: the commit keeps it');
100
+ const own = idLine(id);
101
+
102
+ return revise(projectDir, id, {
103
+ edit: (list) => list.filter((p) => p !== id),
104
+ // A record starts at a `- ` line; the one holding `id: <id>` goes, blank lines and all.
105
+ editPillars: (t) =>
106
+ t
107
+ .split(/\n(?=- )/)
108
+ .filter((rec) => !own.test(rec))
109
+ .join('\n'),
110
+ tail: `retire ${id}: ${why}`,
111
+ commit,
112
+ });
113
+ }
package/lib/unit-ops.js CHANGED
@@ -129,7 +129,8 @@ function refuseUnready(units, summaries) {
129
129
  if (summary?.ready) continue;
130
130
  const why = summary?.blocked_by.length
131
131
  ? `blocked by ${summary.blocked_by.join(', ')}`
132
- : `${summary?.tbd ?? '?'} TBD or a blocking design-check finding`;
132
+ : `${summary?.tbd ?? '?'} TBD${summary?.pillars_owed ? ', pillars among them' : ''} ` +
133
+ 'or a blocking design-check finding';
133
134
  throw new Error(`${unit.id} is not ready (${why}): answer it in design/ first`);
134
135
  }
135
136
  }
package/lib/verdict-fs.js CHANGED
@@ -114,8 +114,14 @@ export async function buryLine(root, id, { type, reason, tags }) {
114
114
  // A hand-edited file may end without a newline: the line would glue onto the last one.
115
115
  const eol = existing.match(/\r?\n/)?.[0] ?? '\n';
116
116
  const head = existing && !existing.endsWith('\n') ? existing + eol : existing;
117
- const why = String(reason || 'no reason recorded').replace(/\s*\|\s*/g, '; ');
118
- const line = `- ${id} | ${type} | ${why} | tags: ${tags.join(', ')}${eol}`;
117
+ // One record per line: a line break would split the reason from its tags, a pipe its fields.
118
+ const flat = (s) =>
119
+ String(s)
120
+ .replace(/\s*\|\s*/g, '; ')
121
+ .replace(/\s*\r?\n\s*/g, ' ')
122
+ .trim();
123
+ const why = flat(reason || 'no reason recorded');
124
+ const line = `- ${id} | ${type} | ${why} | tags: ${tags.map(flat).join(', ')}${eol}`;
119
125
  await atomicWrite(gyFile, head + line);
120
126
  }
121
127
 
package/lib/work-ops.js CHANGED
@@ -90,9 +90,25 @@ export function openDepends(ledger, depends = [], { waiting = false } = {}) {
90
90
  });
91
91
  }
92
92
 
93
- /** The hash an unplayed item's body is frozen at (Methodology §5.4). */
94
- export const bodyHash = (body) =>
95
- createHash('sha1').update(String(body).replace(/\r\n/g, '\n').trim()).digest('hex');
93
+ /**
94
+ * The hash an unplayed item is frozen at (Methodology §5.4): its body and its `live:` delta —
95
+ * the two things a tune or a kill takes back out of systems/ and LIVE.md.
96
+ */
97
+ export const frozenHash = (item) =>
98
+ createHash('sha1')
99
+ .update(String(item.body).replace(/\r\n/g, '\n').trim())
100
+ .update(`\nlive: ${JSON.stringify(item.data.live ?? null)}`)
101
+ .digest('hex');
102
+
103
+ /**
104
+ * Whether an unplayed item still matches its `frozen:`. An item closed by 0.4.0 holds the sha1
105
+ * of its body alone: it still guards the body. shortcut: such an item's live: stays unguarded
106
+ * until it is closed again; drop the legacy form once no 0.4.0 unplayed item is left.
107
+ */
108
+ export const matchesFrozen = (item) =>
109
+ item.data.frozen === frozenHash(item) ||
110
+ item.data.frozen ===
111
+ createHash('sha1').update(String(item.body).replace(/\r\n/g, '\n').trim()).digest('hex');
96
112
 
97
113
  /** Children of a feature that are not finished: live in any status but unplayed and parked. */
98
114
  function unfinishedChildren(ledger, feature) {
@@ -160,6 +176,14 @@ const without = (data, ...keys) =>
160
176
  * another unplayed item also wrote stays: that item's body still owes it to systems/.
161
177
  */
162
178
  function unplay(m, item) {
179
+ // The gate checks `frozen` only on a file that stays unplayed: a tune or a kill of an edited
180
+ // item would take the edited lines out and leave the frozen ones in systems/ for good.
181
+ if (!matchesFrozen(item))
182
+ throw new Error(
183
+ `${item.data.id}: an unplayed item's body and live: are frozen, and this one was edited — ` +
184
+ `save the edit aside, restore the file (git restore gcf/work/${item.data.id}.md ` +
185
+ 'discards it), make the move, then apply the edit to the ready item',
186
+ );
163
187
  const systems = item.data.system ?? [];
164
188
  m.leaving.add(String(item.data.id));
165
189
  m.touch(...systemFiles(m.root, systems));
@@ -170,7 +194,15 @@ function unplay(m, item) {
170
194
  .flatMap((i) => Object.values(collapseLines(i.body)).flat());
171
195
  for (const s of systems) await unmergeFromSystem(m.root, s, item, keep);
172
196
  });
173
- m.live((l) => ({ ...l, capabilities: revertDelta(l.capabilities, item.data.live) }));
197
+ m.live((l) => ({
198
+ ...l,
199
+ // The delta as the close applied it, refs included, so a later refs-only edit shows.
200
+ capabilities: revertDelta(
201
+ l.capabilities,
202
+ withRefs(item.data.live, detailOf(item, null)),
203
+ item.data.id,
204
+ ),
205
+ }));
174
206
  }
175
207
 
176
208
  const dropKnownIssue = (m, id) =>
@@ -380,7 +412,7 @@ function closeUnplayed(m) {
380
412
  });
381
413
  const delta = withRefs(item.data.live, detailOf(item, null));
382
414
  m.live((l) => ({ ...l, capabilities: applyDelta(l.capabilities, delta, id) }));
383
- m.step(() => writeItem(item, { ...item.data, status: 'unplayed', frozen: bodyHash(item.body) }));
415
+ m.step(() => writeItem(item, { ...item.data, status: 'unplayed', frozen: frozenHash(item) }));
384
416
  }
385
417
 
386
418
  /** Any other gate-auto item ends in history; a hard unit may leave lines in `constraints:`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,8 +24,9 @@
24
24
  "node": ">=22.14"
25
25
  },
26
26
  "scripts": {
27
- "test": "node --test",
28
- "prepack": "node -e \"const p=require('./package.json'),e=require('./extension.json');if(p.version!==e.version)throw new Error('version mismatch: package.json '+p.version+' vs extension.json '+e.version)\""
27
+ "test": "node --test && npm run test:gate",
28
+ "prepack": "node -e \"const p=require('./package.json'),e=require('./extension.json');if(p.version!==e.version)throw new Error('version mismatch: package.json '+p.version+' vs extension.json '+e.version)\"",
29
+ "test:gate": "node -e \"process.env.GCF_TEST_GATE='1';require('node:child_process').execFileSync(process.execPath,['--test'],{stdio:'inherit'})\""
29
30
  },
30
31
  "files": [
31
32
  "extension.json",
@@ -39,12 +39,14 @@ workflow:
39
39
  7. for a unit with `flags: src-changed` re-read each changed anchor with `ai-factory gcf section`, rebuild the slots whose quote moved, and remove the flag
40
40
  8. confirm the route: ask "can an autotest accept this without a human playing?"; yes = hard, no or mixed = soft; a mixed unit splits into a soft mechanic and a hard rule before work
41
41
  9. ask the human one question per `TBD` slot, each with your proposed answer, questions per message <= 2
42
- 10. write each answer into the unit in place, with its `answer:` provenance record
43
- 11. run `ai-factory gcf design check --json` until the unit's entry in `units` reads `ready: true`, then commit the answered unit
44
- 12. run `ai-factory gcf take <id>` for one unit, or `ai-factory gcf take <id> <id> --feature <feature-id>` to group several under a feature; it writes `gcf/work/<id>.md` and removes the sections and backlog lines in one step
45
- 13. in each design-unit item write `contract:` from the code tree and `interactions:` from the units and items that write the same state; a soft unit whose human wants no playtest gets `gate: auto` - it will close as unplayed
46
- 14. write the item's `live:` delta (a feature's children keep `none: parent`; the feature writes the capability), run `ai-factory gcf design check`, and commit
47
- 15. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
42
+ 10. for `pillars: TBD` propose the ids from `gcf/PILLARS.md` whose statement the unit serves and whose invariant it keeps
43
+ 11. when no pillar fits, ask whether the unit is out of scope or a pillar is missing, and write a D1 question for the answer
44
+ 12. write each answer into the unit in place, with its `answer:` provenance record
45
+ 13. run `ai-factory gcf design check --json` until the unit's entry in `units` reads `ready: true`, then commit the answered unit
46
+ 14. run `ai-factory gcf take <id>` for one unit, or `ai-factory gcf take <id> <id> --feature <feature-id>` to group several under a feature; it writes `gcf/work/<id>.md` and removes the sections and backlog lines in one step
47
+ 15. in each design-unit item write `contract:` from the code tree and `interactions:` from the units and items that write the same state; a soft unit whose human wants no playtest gets `gate: auto` - it will close as unplayed
48
+ 16. write the item's `live:` delta (a feature's children keep `none: parent`; the feature writes the capability), run `ai-factory gcf design check`, and commit
49
+ 17. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
48
50
 
49
51
  invocation:
50
52
  - run when work starts on a unit or a group of units, when non-design work needs an item, or when a fresh idea needs a home
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gcf-init
3
- description: Bootstrap the gcf/ documentary layer - skeleton, pillars interview, hooks reminder. Use when a game project adopts GCF or when the user says "set up gcf".
3
+ description: Bootstrap the gcf/ documentary layer - skeleton, pillars interview, hooks reminder - and revise pillars later. Use when a game project adopts GCF, when the user says "set up gcf", or "поменяем столпы" / "revise pillars".
4
4
  ---
5
5
 
6
6
  mode: bootstrap
@@ -21,13 +21,16 @@ forbidden_behaviors:
21
21
 
22
22
  workflow:
23
23
  1. when `.ai-factory/DESCRIPTION.md` is absent, tell the human to run `/aif` first: `aif-plan` reads it, and a work item cannot reach a plan without it
24
- 2. run `ai-factory gcf init` and report which files it created, `gcf/design/` among them; it writes no `PILLARS.md`, so no example ever reads as the game's truth
24
+ 2. run `ai-factory gcf init` and report which files it created, `gcf/design/` and the agent instructions in `AGENTS.md` among them; it writes no `PILLARS.md`
25
25
  3. when the human is about to import a GDD, leave `PILLARS.md` absent: the import proposes pillar candidates, and the interview runs after it
26
26
  4. otherwise ask the human for the game's core promise, then for 2..7 pillars: each needs an id (a lowercase hyphen slug the design units cite), a statement, a negative invariant and a review question
27
27
  5. write `gcf/PILLARS.md` with the confirmed pillars in the record-list form of `.ai-factory/extensions/gcf-aif-ext/templates/PILLARS.md`
28
28
  6. run `npx --yes cnlp-kit check` and fix form issues it reports
29
29
  7. ask the human to run `ai-factory gcf hooks-install`: the commit gate is theirs to arm
30
30
  8. state the next step: import a GDD with `/gcf-ingest`, or start the design with `/gcf-vision` when there is none
31
+ 9. to add one pillar later, ask only for its id, statement, invariant and review question; append it as one record to `gcf/PILLARS.md`, keep the others, keep the count <= 7, then run step 6
32
+ 10. to rename a pillar, run `ai-factory gcf pillar rename <from> <to>`: every unit and item citing it follows
33
+ 11. to drop a pillar, run `ai-factory gcf pillar retire <id> --reason "<why>"`; a unit it leaves at `pillars: TBD` goes back through the pillars steps of the gcf-card skill
31
34
 
32
35
  invocation:
33
- - run when the user asks to set up gcf in a project
36
+ - run when the user asks to set up gcf in a project, or to revise its pillars
@@ -0,0 +1,28 @@
1
+ ## Game Context Framework (from gcf-aif-ext)
2
+
3
+ gcf_context:
4
+ - the game's design and its work live in `gcf/`, and the `ai-factory gcf` commands and the gcf skills change them
5
+ - read `gcf/INDEX.md` before anything else under `gcf/`: it lists every subsystem, design unit and work item
6
+ - `gcf/LIVE.md` says what the game does now, and `gcf/PILLARS.md` says what it must stay
7
+
8
+ gcf_work:
9
+ - every piece of work is one work item `gcf/work/<id>.md`: find the item before writing code for a feature, a bug or a tech task, or create it with the gcf-card skill
10
+ - a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later
11
+ - before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
12
+ - drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext, otherwise with aif-plan and aif-implement
13
+ - end the work on an item with its command: `ai-factory gcf submit <id>` for gate verdict or decision, `ai-factory gcf close <id>` for gate auto with every check green
14
+ - work that cannot go on: run `ai-factory gcf block <id> --reason "<why or the question>"`
15
+ - a design question only the human can answer becomes a D1 record in `gcf/open-questions.md`
16
+
17
+ gcf_forbidden:
18
+ - do not edit `status:`, `frozen:`, `gcf/LIVE.md`, `gcf/history.md`, `gcf/graveyard.md` or `gcf/INDEX.md` by hand: the commands own them
19
+ - do not give a verdict or a decision: `verdict` and `decide` belong to the human, through the gcf-verdict skill
20
+ - do not answer a `TBD` of a design unit yourself: ask the human and offer your draft as the proposed answer
21
+ - do not reuse an ended id: rework gets a new id with `reopens: <old id>`
22
+ - do not commit past the gate with `--no-verify`: a refused commit names what to fix
23
+
24
+ gcf_skills:
25
+ - gcf-vision starts a game that has no design yet, and gcf-ingest imports a GDD into `gcf/design/`
26
+ - gcf-card puts work into `gcf/work/`, and gcf-triage sorts `gcf/inbox.md`
27
+ - gcf-verdict records the human's verdict or decision, and gcf-audit reports the health of `gcf/`
28
+ - gcf-debate stresses the design, gcf-adopt puts existing code on GCF, and gcf-init sets up `gcf/` and revises the pillars