gcf-aif-ext 0.4.1 → 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,19 @@ 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
+
53
66
  ### 0.4.1
54
67
 
55
68
  A fix release over 0.4.0; update in place. What you may notice:
@@ -107,7 +120,9 @@ Removal leaves `gcf/` and `cnlp.config.json` in place. It also leaves the pre-co
107
120
 
108
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`
109
122
  - inbox and snapshot — `inbox [add <text> | drop <in-id>]`, `live`, `live check`
110
- - 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`
111
126
  - design and sources — `design check`, `design export`, `design import`, `design migrate`, `anchor`, `section`, `quotes`, `lint`
112
127
  - setup — `init`, `index`, `hooks-install`
113
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
  /**
@@ -244,8 +245,44 @@ function registerAudit(gcf) {
244
245
  });
245
246
  }
246
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
+
247
284
  function registerPlaytest(gcf) {
248
- gcf
285
+ const playtest = gcf
249
286
  .command('playtest')
250
287
  .description('List items awaiting a verdict and unplayed ones, with their questions')
251
288
  .option('--json', 'machine output')
@@ -259,6 +296,18 @@ function registerPlaytest(gcf) {
259
296
  : 'nothing waits for a playtest',
260
297
  );
261
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
+ });
262
311
  }
263
312
 
264
313
  const shortHash = (h) => (h ? ` (${h.slice(0, 7)})` : '');
@@ -575,6 +624,9 @@ function registerItem(gcf) {
575
624
  .option('--blocked <why>', 'start blocked, e.g. "file needed" for an asset')
576
625
  .option('--from <in-id>', 'the inbox entry it comes from: erased')
577
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')
578
630
  .action(async (id, o) => {
579
631
  const { newItem } = await lib('work-ops');
580
632
  const r = await newItem(process.cwd(), {
@@ -589,6 +641,9 @@ function registerItem(gcf) {
589
641
  blocked: o.blocked,
590
642
  from: o.from,
591
643
  reopens: o.reopens,
644
+ milestone: o.milestone,
645
+ version: o.release,
646
+ channel: o.channel,
592
647
  });
593
648
  console.log(r.item);
594
649
  });
@@ -705,10 +760,16 @@ function registerLive(gcf) {
705
760
  const { gcfRoot } = await lib('gcf-paths');
706
761
  const l = await (await lib('live')).readLive(gcfRoot());
707
762
  const rows = (list) => list.map((r) => ` ${r.id}: ${r.summary}`);
763
+ const r = l.release;
708
764
  out(o.json, l, (x) =>
709
- ['capabilities:', ...rows(x.capabilities), 'known-issues:', ...rows(x.knownIssues)].join(
710
- '\n',
711
- ),
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'),
712
773
  );
713
774
  });
714
775
  live
@@ -738,6 +799,109 @@ function registerPlan(gcf) {
738
799
  });
739
800
  }
740
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
+
741
905
  function registerHooksInstall(gcf) {
742
906
  gcf
743
907
  .command('hooks-install')
@@ -833,6 +997,10 @@ export function register(program) {
833
997
  registerInbox(gcf);
834
998
  registerLive(gcf);
835
999
  registerPlan(gcf);
1000
+ registerVerify(gcf);
1001
+ registerIntegrate(gcf);
1002
+ registerBoard(gcf);
1003
+ registerPhase(gcf);
836
1004
  registerHooksInstall(gcf);
837
1005
  registerLint(gcf);
838
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.1",
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 { matchesFrozen } 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,45 +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 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
- }
57
+ const { problems, ledger } = await gateProblems(projectDir);
99
58
 
100
59
  // 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.
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.
103
62
  for (const id of ledger.vanished()) {
104
63
  console.error(
105
64
  `gcf pre-commit: warning: ${id} left design/ or work/ without a move — ` +
@@ -107,27 +66,7 @@ for (const id of ledger.vanished()) {
107
66
  );
108
67
  }
109
68
 
110
- // Design model and item bodies: every finding of severity `block` refuses the commit.
111
- const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
112
- if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
113
-
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.
117
-
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}`);
124
-
125
- // 5: П2 linter over systems docs.
126
- for (const s of await listSystems(root)) {
127
- const { issues } = lint(s.body);
128
- if (issues.length)
129
- fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
130
- }
69
+ if (problems.length) fail(problems[0]);
131
70
 
132
71
  // 3: regenerate INDEX and stage it (deterministic artifact, not a human error).
133
72
  await writeIndex(root);
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/gate.js ADDED
@@ -0,0 +1,106 @@
1
+ // The GCF checks of the pre-commit gate (GCF-PRD §13.1) over the working tree, cheap ones first.
2
+ // The gate refuses on the first problem; `gcf verify` reports them all as the GCF part of the DoD.
3
+ import path from 'node:path';
4
+ import { listSystems, legacyCards, readConfig } from './gcf-paths.js';
5
+ import { phasesOf } from './phase.js';
6
+ import { validateItem, validateUntil } from './validate-item.js';
7
+ import { stemMatches } from './id.js';
8
+ import { lint } from './linter.js';
9
+ import { buildLedger } from './ledger.js';
10
+ import { designCheck } from './design-check.js';
11
+ import { matchesFrozen } from './work-ops.js';
12
+ import { checkLive } from './live.js';
13
+
14
+ /** Problems of form: legacy cards, item frontmatter and filenames, `until` grammar. */
15
+ function formProblems(root, ledger, systems) {
16
+ const problems = [];
17
+ // Schema 2 cards are not read by 0.4: left in place they would silently stop being checked.
18
+ const legacy = legacyCards(root);
19
+ if (legacy.length)
20
+ problems.push(
21
+ `gcf/cards/ holds schema 2 card(s) (${legacy.join(', ')}): GCF 0.4 keeps work in ` +
22
+ 'gcf/work/<id>.md with schema 3 and does not migrate — put each card back into ' +
23
+ 'gcf/design/ as a unit and run ai-factory gcf take, then delete gcf/cards/',
24
+ );
25
+
26
+ // 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
27
+ for (const c of ledger.items) {
28
+ const errors = [
29
+ ...validateItem(c.data),
30
+ ...validateUntil(c.body),
31
+ ...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
32
+ ];
33
+ if (errors.length) problems.push(`work/${c.name}: ${errors[0]}`);
34
+ }
35
+
36
+ for (const s of systems) {
37
+ const errors = validateUntil(s.body);
38
+ if (errors.length) problems.push(`systems/${s.name}: ${errors[0]}`);
39
+ }
40
+
41
+ return problems;
42
+ }
43
+
44
+ /** The `phases:` of config.yml, and the LIVE.md phase among them (Methodology §10). */
45
+ async function phaseProblems(root, phase) {
46
+ let names;
47
+ try {
48
+ names = phasesOf(await readConfig(root)).map((p) => p.name);
49
+ } catch {
50
+ // A broken config is for the command that reads it to refuse: a kill must still commit.
51
+ return [];
52
+ }
53
+
54
+ return phase && !names.includes(phase)
55
+ ? [`LIVE.md: phase ${phase} is not a phase of the project (${names.join(', ')})`]
56
+ : [];
57
+ }
58
+
59
+ /** Every problem the gate refuses a commit for, one line each, and the ledger it read. */
60
+ export async function gateProblems(projectDir) {
61
+ const root = path.join(path.resolve(projectDir), 'gcf');
62
+ const ledger = await buildLedger(projectDir);
63
+ const systems = await listSystems(root);
64
+ const problems = formProblems(root, ledger, systems);
65
+ // The checks below read these files and trust their form.
66
+ if (problems.length) return { problems, ledger };
67
+
68
+ // An unplayed item's body and live: are the source of its systems/ lines and its capability
69
+ // (Methodology §5.4): edited after its close, a tune or a kill would take the wrong ones out.
70
+ for (const c of ledger.items.filter((x) => x.data.status === 'unplayed')) {
71
+ if (!matchesFrozen(c))
72
+ problems.push(
73
+ `work/${c.name}: an unplayed item's body and live: are frozen — restore them, tune it, ` +
74
+ 'then edit',
75
+ );
76
+ }
77
+
78
+ // Design model and item bodies: every finding of severity `block` refuses the commit.
79
+ for (const i of (await designCheck(projectDir)).issues.filter((x) => x.severity === 'block'))
80
+ problems.push(`${i.file}:${i.line}: ${i.check}: ${i.message}`);
81
+
82
+ // Dependencies are held by the moves, not here (Methodology §4.1): `submit` refuses an unplayed
83
+ // one, `close` and `keep` a not-yet-kept one. A gate check would refuse the tune or the kill of
84
+ // a dependency whose dependent is already waiting for its verdict — a legal move, blocked.
85
+
86
+ // LIVE.md form: a malformed record would hide from every reader of the snapshot. A broken ref
87
+ // into gcf/ is this commit's own doing (a move deleted the file); one into the code is not
88
+ // refused — a code commit outside gcf/ may have moved it and the gate never saw that.
89
+ const live = await checkLive(projectDir);
90
+ const form = [
91
+ ...live.problems,
92
+ ...live.issues.filter((i) => !live.problems.includes(i) && /summary|twice|refs gcf\//.test(i)),
93
+ ];
94
+ problems.push(...form.map((f) => `LIVE.md: ${f}`));
95
+ // A phase the project does not name would refuse every later item new and take.
96
+ problems.push(...(await phaseProblems(root, live.phase)));
97
+
98
+ // 5: П2 linter over systems docs.
99
+ for (const s of systems) {
100
+ const { issues } = lint(s.body);
101
+ if (issues.length)
102
+ problems.push(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
103
+ }
104
+
105
+ return { problems, ledger };
106
+ }