gcf-aif-ext 0.4.1 → 0.5.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 +22 -1
- package/commands/gcf.js +172 -4
- package/extension.json +1 -1
- package/hooks/pre-commit.js +5 -66
- package/lib/board.js +113 -0
- package/lib/design-check.js +24 -6
- package/lib/gate.js +106 -0
- package/lib/integrate.js +322 -0
- package/lib/live.js +82 -23
- package/lib/main-lock.js +112 -0
- package/lib/phase.js +228 -0
- package/lib/playtest-list.js +60 -1
- package/lib/unit-ops.js +26 -1
- package/lib/validate-item.js +10 -0
- package/lib/verdict-fs.js +2 -1
- package/lib/verify.js +72 -0
- package/lib/work-ops.js +61 -7
- package/package.json +1 -1
- package/profiles/glossary.md +1 -1
- package/profiles/system.md +2 -2
- package/profiles/unit-constraint.md +3 -3
- package/profiles/unit-entity.md +3 -3
- package/profiles/unit-mechanic.md +6 -18
- package/profiles/unit-resource.md +6 -18
- package/profiles/unit-rule.md +3 -3
- package/schemas/item.schema.json +4 -0
- package/skills/gcf-card/SKILL.md +5 -4
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/agents-block.md +11 -4
- package/templates/config.yml +2 -0
package/README.md
CHANGED
|
@@ -50,6 +50,25 @@ 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.1
|
|
54
|
+
|
|
55
|
+
- A provenance record `assumed: q-NNN` must name a record still in `gcf/open-questions.md` (M9). A D2 record lives until its `until:` fires; once the human confirms the value, cite `answer: q-NNN` instead and remove it. After updating, run `ai-factory gcf design check`: every citation of a removed question blocks the commit gate until it is rewritten.
|
|
56
|
+
- An unplayed item's body is frozen, so a word added to the glossary or a removed question is reported there, not blocked.
|
|
57
|
+
- The `AGENTS.md` block has no `##` heading any more: a CNL-P guide is flat. A project that checks `AGENTS.md` with `cnlp check` adds `gcf_context`, `gcf_work`, `gcf_forbidden` and `gcf_skills` to `custom_sections` of its guide profile.
|
|
58
|
+
|
|
59
|
+
### 0.5.0
|
|
60
|
+
|
|
61
|
+
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:
|
|
62
|
+
|
|
63
|
+
- `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.
|
|
64
|
+
- `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.
|
|
65
|
+
- 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.
|
|
66
|
+
- `gcf playtest session [--build]` — the build of main and a checklist per item: what to check, how to get there, what to feel.
|
|
67
|
+
- `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`.
|
|
68
|
+
- `gcf board [--json]` — everything a board shows in one read.
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
53
72
|
### 0.4.1
|
|
54
73
|
|
|
55
74
|
A fix release over 0.4.0; update in place. What you may notice:
|
|
@@ -107,7 +126,9 @@ Removal leaves `gcf/` and `cnlp.config.json` in place. It also leaves the pre-co
|
|
|
107
126
|
|
|
108
127
|
- 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
128
|
- inbox and snapshot — `inbox [add <text> | drop <in-id>]`, `live`, `live check`
|
|
110
|
-
- runs and play — `context`, `plan check <id>`, `playtest`, `audit`
|
|
129
|
+
- runs and play — `context`, `plan check <id>`, `verify <id>`, `playtest`, `playtest session [--build]`, `audit`
|
|
130
|
+
- project phase and board — `phase`, `phase next [--reason]`, `board [--json]` (everything a board shows, in one read)
|
|
131
|
+
- 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
132
|
- design and sources — `design check`, `design export`, `design import`, `design migrate`, `anchor`, `section`, `quotes`, `lint`
|
|
112
133
|
- setup — `init`, `index`, `hooks-install`
|
|
113
134
|
|
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
|
-
[
|
|
710
|
-
|
|
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
package/hooks/pre-commit.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
102
|
-
//
|
|
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
|
-
|
|
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/design-check.js
CHANGED
|
@@ -121,12 +121,14 @@ function itemsIn(text, key) {
|
|
|
121
121
|
export const recordsIn = (text, key) =>
|
|
122
122
|
readRecords(itemsIn(text, key)).records.map((r) => r.values);
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/** open-questions.md: every record id, every `blocks:` reference, the D1 ones per blocked id. */
|
|
125
125
|
export function questionBlocks(text) {
|
|
126
126
|
/** @type {Map<string, string[]>} */
|
|
127
127
|
const d1 = new Map();
|
|
128
128
|
const refs = [];
|
|
129
|
-
|
|
129
|
+
const src = String(text ?? '');
|
|
130
|
+
const qids = new Set([...src.matchAll(/^\s*- id:\s*(\S+)/gm)].map((m) => m[1]));
|
|
131
|
+
for (const rec of src.split(/\n(?=- id:)/)) {
|
|
130
132
|
const qid = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
131
133
|
const ids = listOf(rec.match(/^\s+blocks:\s*(.+)$/m)?.[1] ?? '');
|
|
132
134
|
if (!qid || ids === 'TBD' || !ids.length) continue;
|
|
@@ -135,7 +137,7 @@ export function questionBlocks(text) {
|
|
|
135
137
|
for (const id of ids) d1.set(id, [...(d1.get(id) ?? []), qid]);
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
return { d1, refs };
|
|
140
|
+
return { d1, refs, qids };
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
/** Everything outside a unit that its references resolve against. */
|
|
@@ -296,7 +298,15 @@ function recordProblem(r, ctx) {
|
|
|
296
298
|
}
|
|
297
299
|
|
|
298
300
|
if (r.answer) return /^(q-\d+|manual)$/.test(r.answer) ? null : 'answer is q-NNN or manual';
|
|
299
|
-
if (r.assumed)
|
|
301
|
+
if (r.assumed) {
|
|
302
|
+
if (!/^q-\d+$/.test(r.assumed)) return 'assumed is q-NNN';
|
|
303
|
+
|
|
304
|
+
// The D2 record is what the readset and the human's skim list see: it outlives its citation.
|
|
305
|
+
return ctx.questions.qids.has(r.assumed)
|
|
306
|
+
? null
|
|
307
|
+
: `assumed: ${r.assumed} is not in open-questions.md - a D2 record stays until its ` +
|
|
308
|
+
`until: fires; once the human confirms the value, cite answer: ${r.assumed} instead`;
|
|
309
|
+
}
|
|
300
310
|
|
|
301
311
|
return 'a record carries src + quote, answer, or assumed';
|
|
302
312
|
}
|
|
@@ -326,6 +336,9 @@ function uncovered(values, records) {
|
|
|
326
336
|
export const isDrift = (problem) =>
|
|
327
337
|
/^(quote is not verbatim|GDD#\S+ is not an anchor)/.test(problem ?? '');
|
|
328
338
|
|
|
339
|
+
/** The D2 record an `assumed:` cites left open-questions.md. */
|
|
340
|
+
const isGone = (problem) => /^assumed: q-\d+ is not in open-questions/.test(problem ?? '');
|
|
341
|
+
|
|
329
342
|
/** M9: provenance records are well formed, quotes verbatim, every GDD-sourced slot backed. */
|
|
330
343
|
function provenanceIssues(u, values, ctx) {
|
|
331
344
|
const records = provenanceOf(u.body);
|
|
@@ -446,11 +459,16 @@ async function checkItem(item, ctx, ledger) {
|
|
|
446
459
|
const values = slotValues(item.body, profile).filter((v) => !OPERATOR_BLOCKS.includes(v.block));
|
|
447
460
|
for (const v of values.filter((x) => isTbd(x.value)))
|
|
448
461
|
add('M1', v.line, `a unit in work carries no TBD: ${v.block}`);
|
|
449
|
-
|
|
462
|
+
// An unplayed body is frozen (§5.4): a later glossary word or unit, or a gone question, cannot
|
|
463
|
+
// be fixed there, only reported until a tune or a kill takes the item back.
|
|
464
|
+
const frozen = item.data.status === 'unplayed' ? 'report' : 'block';
|
|
465
|
+
for (const [c, line, m] of valueIssues(values, ctx)) add(c, line, m, frozen);
|
|
450
466
|
for (const r of provenanceOf(item.body)) {
|
|
451
467
|
const problem = recordProblem(r, ctx);
|
|
452
468
|
// An item's quotes go stale when the GDD moves on: that is a D1 for the human, not a gate.
|
|
453
|
-
|
|
469
|
+
// A gone question is outside the frozen body too; a malformed record was malformed at close.
|
|
470
|
+
const sev = isDrift(problem) ? 'report' : isGone(problem) ? frozen : 'block';
|
|
471
|
+
if (problem) add('M9', r.line, problem, sev);
|
|
454
472
|
}
|
|
455
473
|
|
|
456
474
|
return issues;
|
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
|
+
}
|