gcf-aif-ext 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -50,6 +50,29 @@ 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.5.0
54
+
55
+ The run layer of the methodology. Update both extensions together — `gcf-harness-aif-ext` 0.6.0 requires this core — and run `ai-factory gcf init` to refresh the agent block in `AGENTS.md`. New commands:
56
+
57
+ - `gcf verify <id>` — the auto-DoD in code: the commit gate's checks, the item's `live:` delta against `LIVE.md`, then `dod.build` and `dod.test`. Fill both in `gcf/config.yml`; an empty one fails, `none` says the project has no such step.
58
+ - `gcf phase` and `gcf phase next [--reason]` — the project phase (prototype → vertical-slice → alpha → beta → release → live), kept as `phase:` in the `LIVE.md` frontmatter, with exit criteria checked in code. `item new` and `take` refuse a type the phase does not take. **A project with no phase yet is in prototype, and a prototype takes no `asset` item**: move the phase on (`gcf phase next --reason "…"`) if your game is past it. A `phases:` list in `config.yml` replaces the set.
59
+ - A release item carries `version:` and `channel:` (`item new <id> --type release --release 1.0.0 --channel steam`); its end writes the `release:` block of `LIVE.md`. `item new --milestone <name>` tags an item.
60
+ - `gcf playtest session [--build]` — the build of main and a checklist per item: what to check, how to get there, what to feel.
61
+ - `gcf worktree <id>`, `gcf verify <id> --mark`, `gcf integrate [<id>…]` — an item in its own worktree, the `GCF-Verified` trailer, the merge into main and the item's move. Every committed move now holds `.gcf-session/main.lock`.
62
+ - `gcf board [--json]` — everything a board shows in one read.
63
+
64
+ A move that writes `LIVE.md` now refuses a malformed one instead of rewriting it without what it could not read: fix the file by hand in its own commit.
65
+
66
+ ### 0.4.1
67
+
68
+ A fix release over 0.4.0; update in place. What you may notice:
69
+
70
+ - 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.
71
+ - 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.
72
+ - `design check` blocks a `depends:` cycle and an id with two terminal lines (a merge of branches).
73
+ - The commit gate refuses a `LIVE.md` ref into `gcf/` that names no file.
74
+ - `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.
75
+
53
76
  ### Upgrading from 0.3.x
54
77
 
55
78
  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:
@@ -97,7 +120,9 @@ Removal leaves `gcf/` and `cnlp.config.json` in place. It also leaves the pre-co
97
120
 
98
121
  - work items — `take <ids...> [--feature <id>]`, `item new <id> --type <type>`, `submit`, `close`, `verdict <id> keep|tune|kill`, `decide <id> go|no|wontfix|known|close|continue|kill`, `block`, `unblock`, `drop`, `done`, `where`
99
122
  - inbox and snapshot — `inbox [add <text> | drop <in-id>]`, `live`, `live check`
100
- - runs and play — `context`, `plan check <id>`, `playtest`, `audit`
123
+ - runs and play — `context`, `plan check <id>`, `verify <id>`, `playtest`, `playtest session [--build]`, `audit`
124
+ - project phase and board — `phase`, `phase next [--reason]`, `board [--json]` (everything a board shows, in one read)
125
+ - branches — `worktree <id>`, `verify <id> --mark`, `integrate [<id>...]`: a run in its own worktree, the `GCF-Verified` trailer, the merge into main and the move; every committed move holds `.gcf-session/main.lock`
101
126
  - design and sources — `design check`, `design export`, `design import`, `design migrate`, `anchor`, `section`, `quotes`, `lint`
102
127
  - setup — `init`, `index`, `hooks-install`
103
128
 
package/commands/gcf.js CHANGED
@@ -74,6 +74,7 @@ const UNION = [
74
74
  'gcf/history.md merge=union',
75
75
  'gcf/graveyard.md merge=union',
76
76
  'gcf/inbox.md merge=union',
77
+ 'gcf/.metrics/*.csv merge=union',
77
78
  ];
78
79
 
79
80
  /**
@@ -151,7 +152,8 @@ function registerInit(gcf, program) {
151
152
  gcf
152
153
  .command('init')
153
154
  .description(
154
- 'Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness',
155
+ 'Create the gcf/ skeleton from templates (idempotent, never overwrites) and the agent ' +
156
+ 'instructions block in AGENTS.md (replaced in place); offers the harness',
155
157
  )
156
158
  .option('--harness', `also install ${HARNESS} and run gcf-harness init (Claude Code only)`)
157
159
  .option('--no-harness', 'do not offer the harness')
@@ -160,6 +162,10 @@ function registerInit(gcf, program) {
160
162
  await seedSkeleton(root);
161
163
  const { writeIndex } = await lib('index-gen');
162
164
  await writeIndex(root);
165
+ // Re-run on every update: the block is replaced in place, so it follows the extension.
166
+ const block = await readFile(path.join(EXT_ROOT, 'templates', 'agents-block.md'), 'utf8');
167
+ const agents = await (await lib('agents-block')).writeAgentsBlock(process.cwd(), block);
168
+ if (agents.length) console.log(`agent instructions written: ${agents.join(', ')}`);
163
169
  console.log(
164
170
  'gcf/ ready. Write PILLARS.md through /gcf-init, then import a GDD with /gcf-ingest ' +
165
171
  'or start the design with /gcf-vision.',
@@ -239,8 +245,44 @@ function registerAudit(gcf) {
239
245
  });
240
246
  }
241
247
 
248
+ /** How the session's build went: built, no step, failed, or left to the human. */
249
+ function buildText(b) {
250
+ if (!b) return 'build it from this commit';
251
+ if (!b.ok) return b.problems.join('; ');
252
+
253
+ return b.skipped ? 'no build step (dod.build: none)' : 'built with dod.build';
254
+ }
255
+
256
+ /** The session sheet: the build, then per item what to check and how to get there. */
257
+ function sessionText(s) {
258
+ const build = buildText(s.build);
259
+ const lines = [
260
+ s.commit ? `build: ${s.branch} @ ${s.commit} — ${build}` : 'build: no commit to build yet',
261
+ // shortcut: the main branch is named main; read the remote default when a project differs.
262
+ ...(s.branch !== 'main' ? ['warning: not on main — the session plays main'] : []),
263
+ ...(s.dirty ? ['warning: uncommitted changes — the build is not the commit'] : []),
264
+ ];
265
+ if (!s.items.length) return [...lines, 'nothing waits for a playtest'].join('\n');
266
+ for (const c of s.items) {
267
+ lines.push(
268
+ '',
269
+ `[ ] ${c.id} (${c.status}) [${c.system.join(',')}] ${c.title ?? c.intent ?? ''}`,
270
+ );
271
+ lines.push(` check: ${c.question}`);
272
+ for (const r of c.reach) lines.push(` try: ${r}`);
273
+ for (const f of c.feel) lines.push(` feel: ${f}`);
274
+ }
275
+
276
+ return [
277
+ ...lines,
278
+ '',
279
+ 'after play: a verdict per item — /gcf-verdict <id> keep|tune|kill;',
280
+ 'free notes — ai-factory gcf inbox add --source playtest "<note>"',
281
+ ].join('\n');
282
+ }
283
+
242
284
  function registerPlaytest(gcf) {
243
- gcf
285
+ const playtest = gcf
244
286
  .command('playtest')
245
287
  .description('List items awaiting a verdict and unplayed ones, with their questions')
246
288
  .option('--json', 'machine output')
@@ -254,6 +296,18 @@ function registerPlaytest(gcf) {
254
296
  : 'nothing waits for a playtest',
255
297
  );
256
298
  });
299
+ playtest
300
+ .command('session')
301
+ .description('A playtest session: the build of main and the checklist, built from the items')
302
+ .option('--build', 'run dod.build first')
303
+ .option('--json', 'machine output')
304
+ .action(async (o) => {
305
+ const r = await (
306
+ await lib('playtest-list')
307
+ ).playtestSession(process.cwd(), { build: Boolean(o.build) });
308
+ out(o.json, r, sessionText);
309
+ if (r.build && !r.build.ok) process.exitCode = 1;
310
+ });
257
311
  }
258
312
 
259
313
  const shortHash = (h) => (h ? ` (${h.slice(0, 7)})` : '');
@@ -570,6 +624,9 @@ function registerItem(gcf) {
570
624
  .option('--blocked <why>', 'start blocked, e.g. "file needed" for an asset')
571
625
  .option('--from <in-id>', 'the inbox entry it comes from: erased')
572
626
  .option('--reopens <id>', 'the ended id this item reworks')
627
+ .option('--milestone <name>', 'the milestone of game.md it belongs to')
628
+ .option('--release <version>', 'a release: the version it ships')
629
+ .option('--channel <name>', 'a release: where it ships, e.g. steam')
573
630
  .action(async (id, o) => {
574
631
  const { newItem } = await lib('work-ops');
575
632
  const r = await newItem(process.cwd(), {
@@ -584,6 +641,9 @@ function registerItem(gcf) {
584
641
  blocked: o.blocked,
585
642
  from: o.from,
586
643
  reopens: o.reopens,
644
+ milestone: o.milestone,
645
+ version: o.release,
646
+ channel: o.channel,
587
647
  });
588
648
  console.log(r.item);
589
649
  });
@@ -700,10 +760,16 @@ function registerLive(gcf) {
700
760
  const { gcfRoot } = await lib('gcf-paths');
701
761
  const l = await (await lib('live')).readLive(gcfRoot());
702
762
  const rows = (list) => list.map((r) => ` ${r.id}: ${r.summary}`);
763
+ const r = l.release;
703
764
  out(o.json, l, (x) =>
704
- ['capabilities:', ...rows(x.capabilities), 'known-issues:', ...rows(x.knownIssues)].join(
705
- '\n',
706
- ),
765
+ [
766
+ ...(x.phase ? [`phase: ${x.phase}`] : []),
767
+ ...(r ? [`release: ${r.version} to ${r.channel} on ${r.date}`] : []),
768
+ 'capabilities:',
769
+ ...rows(x.capabilities),
770
+ 'known-issues:',
771
+ ...rows(x.knownIssues),
772
+ ].join('\n'),
707
773
  );
708
774
  });
709
775
  live
@@ -733,6 +799,109 @@ function registerPlan(gcf) {
733
799
  });
734
800
  }
735
801
 
802
+ function registerVerify(gcf) {
803
+ gcf
804
+ .command('verify <id>')
805
+ .description('auto-DoD of a ready item: GCF checks, its live: delta, dod.build, dod.test')
806
+ .option('--mark', 'in its worktree: green and committed → commit the GCF-Verified trailer')
807
+ .option('--json', 'machine output')
808
+ .action(async (id, o) => {
809
+ const r = o.mark
810
+ ? await (await lib('integrate')).markVerified(process.cwd(), id)
811
+ : await (await lib('verify')).verifyItem(process.cwd(), id);
812
+ out(o.json, r, (x) =>
813
+ [
814
+ ...x.checks.flatMap((c) =>
815
+ c.ok
816
+ ? [`${c.name}: ${c.skipped ? 'none' : 'ok'}`]
817
+ : c.problems.map((p) => `${c.name}: ${p}`),
818
+ ),
819
+ ...(x.marked ? [`marked: GCF-Verified: ${id}`] : []),
820
+ ].join('\n'),
821
+ );
822
+ if (!r.ok) process.exitCode = 1;
823
+ });
824
+ }
825
+
826
+ function registerBoard(gcf) {
827
+ gcf
828
+ .command('board')
829
+ .description('Everything a board shows in one read: phase, columns, plans, inbox, questions')
830
+ .option('--json', 'machine output')
831
+ .action(async (o) => {
832
+ const r = await (await lib('board')).board(process.cwd());
833
+ const tag = (i) =>
834
+ `${i.id}${i.plan?.tasks !== undefined ? ` ${i.plan.done}/${i.plan.tasks}` : ''}` +
835
+ `${i.running ? ' (running)' : ''}${i.verified ? ' (verified)' : ''}`;
836
+ const byId = new Map(r.items.map((i) => [i.id, i]));
837
+ out(o.json, r, (x) =>
838
+ [
839
+ `phase: ${x.phase.phase ?? x.phase.error}`,
840
+ ...Object.entries(x.columns).map(
841
+ ([s, ids]) => `${s}: ${ids.map((id) => tag(byId.get(id))).join(', ') || '-'}`,
842
+ ),
843
+ `wip: ${x.wip.verdict}/${x.wip.limit}`,
844
+ `inbox: ${x.inbox.length}, open questions: ${x.questions.length}`,
845
+ ].join('\n'),
846
+ );
847
+ });
848
+ }
849
+
850
+ function registerIntegrate(gcf) {
851
+ gcf
852
+ .command('worktree <id>')
853
+ .description('A worktree for a ready item: .gcf-session/worktrees/<id> on branch work/<id>')
854
+ .option('--json', 'machine output')
855
+ .action(async (id, o) => {
856
+ const r = await (await lib('integrate')).addWorktree(process.cwd(), id);
857
+ out(o.json, r, (x) => `${x.path} (${x.branch}${x.created ? ', new' : ', resumed'})`);
858
+ });
859
+ gcf
860
+ .command('integrate [ids...]')
861
+ .description('Merge verified work/<id> branches into main and record each move; none: all')
862
+ .option('--json', 'machine output')
863
+ .action(async (ids, o) => {
864
+ const r = await (await lib('integrate')).integrate(process.cwd(), ids ?? []);
865
+ const line = (x) =>
866
+ x.skipped ?? x.conflict ?? `${x.id}: ${x.move}${x.reason ? ` (${x.reason})` : ''}`;
867
+ out(o.json, r, (l) => (l.length ? l.map(line).join('\n') : 'no verified branch'));
868
+ if (r.some((x) => x.conflict)) process.exitCode = 1;
869
+ });
870
+ }
871
+
872
+ function registerPhase(gcf) {
873
+ const phase = gcf
874
+ .command('phase')
875
+ .description('The project phase (Methodology §10): what it takes, its exit criteria')
876
+ .option('--json', 'machine output')
877
+ .action(async (o) => {
878
+ const r = await (await lib('phase')).phaseStatus(process.cwd());
879
+ const mark = { true: 'met', false: 'NOT met', null: 'check by hand' };
880
+ out(o.json, r, (x) =>
881
+ [
882
+ `phase: ${x.phase}${x.next ? ` (next: ${x.next})` : ' (last)'}`,
883
+ `takes: ${x.accepts.join(', ')}`,
884
+ ...(x.with_reopens.length ? [`with reopens: ${x.with_reopens.join(', ')}`] : []),
885
+ ...x.criteria.map((c) => `exit ${c.name}: ${mark[String(c.met)]} — ${c.detail}`),
886
+ ].join('\n'),
887
+ );
888
+ });
889
+ phase
890
+ .command('next')
891
+ .description('Move to the next phase (commit gcf(phase)); the operator decides')
892
+ .option('--reason <text>', 'why, when an exit criterion is not met')
893
+ .option('--no-commit', 'skip the commit (tests only)')
894
+ .action(async (o) => {
895
+ const r = await (
896
+ await lib('phase')
897
+ ).nextPhase(process.cwd(), { reason: o.reason ?? '', commit: o.commit });
898
+ console.log(
899
+ `${r.from} → ${r.to}${r.forced.length ? ` (unmet: ${r.forced.join(', ')})` : ''}` +
900
+ shortHash(r.hash),
901
+ );
902
+ });
903
+ }
904
+
736
905
  function registerHooksInstall(gcf) {
737
906
  gcf
738
907
  .command('hooks-install')
@@ -780,8 +949,36 @@ function registerLint(gcf) {
780
949
  });
781
950
  }
782
951
 
952
+ function registerPillar(gcf) {
953
+ const pillar = gcf
954
+ .command('pillar')
955
+ .description('Revise PILLARS.md where units and items cite it (the human decides)');
956
+ pillar
957
+ .command('rename <from> <to>')
958
+ .description('Rename a pillar id in PILLARS.md, units and items (commit gcf(pillar))')
959
+ .option('--no-commit', 'skip the commit (tests only)')
960
+ .action(async (from, to, o) => {
961
+ const r = await (await lib('pillars')).renamePillar(process.cwd(), from, to, o);
962
+ console.log(`renamed ${from} → ${to}${shortHash(r.hash)}`);
963
+ });
964
+ pillar
965
+ .command('retire <id>')
966
+ .description('Take a pillar out; units left with none read pillars: TBD (commit gcf(pillar))')
967
+ .requiredOption('--reason <text>', 'why the game no longer holds it')
968
+ .option('--no-commit', 'skip the commit (tests only)')
969
+ .action(async (id, o) => {
970
+ const r = await (await lib('pillars')).retirePillar(process.cwd(), id, o);
971
+ console.log(`retired ${id}${shortHash(r.hash)}`);
972
+ if (r.bare.length)
973
+ console.log(
974
+ `items now citing no pillar — give each one or accept it: ${r.bare.join(', ')}`,
975
+ );
976
+ });
977
+ }
978
+
783
979
  export function register(program) {
784
980
  const gcf = program.command('gcf').description('Game Context Framework core');
981
+ registerPillar(gcf);
785
982
  registerInit(gcf, program);
786
983
  registerIndex(gcf);
787
984
  registerContext(gcf);
@@ -800,6 +997,10 @@ export function register(program) {
800
997
  registerInbox(gcf);
801
998
  registerLive(gcf);
802
999
  registerPlan(gcf);
1000
+ registerVerify(gcf);
1001
+ registerIntegrate(gcf);
1002
+ registerBoard(gcf);
1003
+ registerPhase(gcf);
803
1004
  registerHooksInstall(gcf);
804
1005
  registerLint(gcf);
805
1006
  }
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.5.0",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -6,15 +6,8 @@
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, legacyCards } from '../lib/gcf-paths.js';
10
- import { validateItem, validateUntil } from '../lib/validate-item.js';
11
- import { stemMatches } from '../lib/id.js';
12
9
  import { writeIndex } from '../lib/index-gen.js';
13
- import { lint } from '../lib/linter.js';
14
- import { buildLedger } from '../lib/ledger.js';
15
- import { designCheck } from '../lib/design-check.js';
16
- import { bodyHash } from '../lib/work-ops.js';
17
- import { checkLive } from '../lib/live.js';
10
+ import { gateProblems } from '../lib/gate.js';
18
11
 
19
12
  const projectDir = process.cwd();
20
13
  const root = path.join(projectDir, 'gcf');
@@ -61,42 +54,11 @@ if (staged !== null) {
61
54
  }
62
55
  }
63
56
 
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
-
73
- const ledger = await buildLedger(projectDir);
74
-
75
- // 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
76
- for (const c of ledger.items) {
77
- const errors = [
78
- ...validateItem(c.data),
79
- ...validateUntil(c.body),
80
- ...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
81
- ];
82
- if (errors.length) fail(`work/${c.name}: ${errors[0]}`);
83
- }
84
-
85
- for (const s of await listSystems(root)) {
86
- const errors = validateUntil(s.body);
87
- if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
88
- }
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.
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`);
95
- }
57
+ const { problems, ledger } = await gateProblems(projectDir);
96
58
 
97
59
  // A unit or item leaving without its move loses its reason, not its integrity: one still citing
98
- // it fails M2 below, in this commit. A warning, not a refusal — a revert of the commit that
99
- // added it removes an id legitimately.
60
+ // it fails M2, in this commit. A warning, not a refusal — a revert of the commit that added it
61
+ // removes an id legitimately.
100
62
  for (const id of ledger.vanished()) {
101
63
  console.error(
102
64
  `gcf pre-commit: warning: ${id} left design/ or work/ without a move — ` +
@@ -104,25 +66,7 @@ for (const id of ledger.vanished()) {
104
66
  );
105
67
  }
106
68
 
107
- // Design model and item bodies: every finding of severity `block` refuses the commit.
108
- const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
109
- if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
110
-
111
- // Dependencies are held by the moves, not here (Methodology §4.1): `submit` refuses an unplayed
112
- // one, `close` and `keep` a not-yet-kept one. A gate check would refuse the tune or the kill of
113
- // a dependency whose dependent is already waiting for its verdict — a legal move, blocked.
114
-
115
- // LIVE.md form: a malformed record would hide from every reader of the snapshot.
116
- const live = await checkLive(projectDir);
117
- const form = live.problems[0] ?? live.issues.find((i) => /summary|twice/.test(i));
118
- if (form) fail(`LIVE.md: ${form}`);
119
-
120
- // 5: П2 linter over systems docs.
121
- for (const s of await listSystems(root)) {
122
- const { issues } = lint(s.body);
123
- if (issues.length)
124
- fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
125
- }
69
+ if (problems.length) fail(problems[0]);
126
70
 
127
71
  // 3: regenerate INDEX and stage it (deterministic artifact, not a human error).
128
72
  await writeIndex(root);
@@ -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/board.js ADDED
@@ -0,0 +1,113 @@
1
+ // `gcf board --json` (Methodology §11): everything a board shows, in one read. The board is a
2
+ // view: it shows these data and runs the same commands as the console; nothing here is stored.
3
+ import { readFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { buildLedger } from './ledger.js';
7
+ import { readConfig } from './gcf-paths.js';
8
+ import { readLive } from './live.js';
9
+ import { listInbox } from './inbox.js';
10
+ import { phaseStatus } from './phase.js';
11
+ import { verifiedBranches, worktreeOf } from './integrate.js';
12
+ import { resolveInside } from './safe-path.js';
13
+
14
+ const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
15
+
16
+ /**
17
+ * Plan progress `{tasks, done}` from its checkboxes, null without a plan file (§3.3); a plan:
18
+ * outside the project or not a file reads as `{error}`, the rest of the board still shows.
19
+ */
20
+ async function progress(projectDir, item) {
21
+ try {
22
+ const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
23
+ const file = resolveInside(projectDir, plan);
24
+ if (!existsSync(file)) return null;
25
+ const text = await readFile(file, 'utf8');
26
+
27
+ return {
28
+ tasks: (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length,
29
+ done: (text.match(/^\s*- \[[xX]\] /gm) ?? []).length,
30
+ };
31
+ } catch (e) {
32
+ return { error: e.message };
33
+ }
34
+ }
35
+
36
+ /** Verified branches; none outside a repository. */
37
+ function verifiedOf(projectDir) {
38
+ try {
39
+ return new Set(verifiedBranches(projectDir));
40
+ } catch {
41
+ return new Set();
42
+ }
43
+ }
44
+
45
+ /** Open question records of open-questions.md: id and class. */
46
+ async function questions(root) {
47
+ const file = path.join(root, 'open-questions.md');
48
+ if (!existsSync(file)) return [];
49
+
50
+ return (await readFile(file, 'utf8'))
51
+ .replace(/\r\n/g, '\n')
52
+ .split(/\n(?=- id:)/)
53
+ .flatMap((rec) => {
54
+ const id = rec.match(/^- id:\s*(\S+)/)?.[1];
55
+
56
+ return id ? [{ id, class: rec.match(/^\s+class:\s*(\S+)/m)?.[1] ?? null }] : [];
57
+ });
58
+ }
59
+
60
+ /** The phase status, or the reason it cannot be read: the board still shows the rest. */
61
+ async function phaseOf(projectDir) {
62
+ try {
63
+ return await phaseStatus(projectDir);
64
+ } catch (e) {
65
+ return { error: e.message };
66
+ }
67
+ }
68
+
69
+ export async function board(projectDir) {
70
+ const root = path.join(path.resolve(projectDir), 'gcf');
71
+ const ledger = await buildLedger(projectDir);
72
+ const verified = verifiedOf(projectDir);
73
+ const items = [];
74
+ for (const c of ledger.items) {
75
+ const id = String(c.data.id);
76
+ // A trailer counts only while the item waits on main for its integrate.
77
+ const ready = c.data.status === 'ready' && verified.has(id);
78
+ items.push({
79
+ id,
80
+ type: c.data.type,
81
+ gate: c.data.gate,
82
+ status: c.data.status,
83
+ title: c.data.title ?? null,
84
+ parent: c.data.parent ?? null,
85
+ children: c.data.children ?? [],
86
+ system: c.data.system ?? [],
87
+ ...(c.data.severity ? { severity: c.data.severity } : {}),
88
+ ...(c.data.blocked ? { blocked: c.data.blocked } : {}),
89
+ // `running` and `verify` are not stored (§4): a worktree is a run, a trailer a passed DoD.
90
+ running: existsSync(worktreeOf(projectDir, id)) && !ready,
91
+ verified: ready,
92
+ plan: await progress(projectDir, c),
93
+ });
94
+ }
95
+
96
+ const config = await readConfig(root);
97
+ const live = await readLive(root);
98
+
99
+ return {
100
+ phase: await phaseOf(projectDir),
101
+ release: live.release,
102
+ columns: Object.fromEntries(
103
+ COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
104
+ ),
105
+ items,
106
+ wip: {
107
+ verdict: items.filter((i) => i.status === 'awaiting-verdict').length,
108
+ limit: config.wip_built_limit,
109
+ },
110
+ inbox: await listInbox(root),
111
+ questions: await questions(root),
112
+ };
113
+ }
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}`);