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 +26 -1
- package/commands/gcf.js +206 -5
- package/extension.json +1 -1
- package/hooks/pre-commit.js +5 -61
- package/lib/agents-block.js +66 -0
- package/lib/board.js +113 -0
- package/lib/context.js +11 -4
- package/lib/design-check.js +74 -16
- package/lib/gate.js +106 -0
- package/lib/integrate.js +322 -0
- package/lib/live.js +98 -27
- package/lib/main-lock.js +112 -0
- package/lib/phase.js +228 -0
- package/lib/pillars.js +113 -0
- package/lib/playtest-list.js +60 -1
- package/lib/unit-ops.js +28 -2
- package/lib/validate-item.js +10 -0
- package/lib/verdict-fs.js +10 -3
- package/lib/verify.js +72 -0
- package/lib/work-ops.js +98 -12
- package/package.json +4 -3
- package/schemas/item.schema.json +4 -0
- package/skills/gcf-card/SKILL.md +10 -8
- package/skills/gcf-init/SKILL.md +6 -3
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/agents-block.md +36 -0
- package/templates/config.yml +2 -0
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)
|
|
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
|
-
[
|
|
705
|
-
|
|
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
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 { 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
|
-
|
|
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
|
|
99
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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}`);
|