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/lib/playtest-list.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { listItems, scalarOf, readConfig, firstBullet } from './gcf-paths.js';
|
|
4
|
+
import { recordsOf } from './verdict-fs.js';
|
|
5
|
+
import { runDod } from './verify.js';
|
|
6
|
+
|
|
7
|
+
const ENVELOPE = /^\S+\.\.\S+ /;
|
|
8
|
+
|
|
9
|
+
/** What the play should feel: a mechanic's feel envelopes, a resource's rate envelopes. */
|
|
10
|
+
function feelOf(body) {
|
|
11
|
+
const feel = recordsOf(body, 'feel')
|
|
12
|
+
.filter((r) => r.name && r.envelope)
|
|
13
|
+
.map((r) => `${r.name}: ${r.envelope}`);
|
|
14
|
+
const rates = ['sources', 'sinks'].flatMap((key) =>
|
|
15
|
+
recordsOf(body, key)
|
|
16
|
+
.filter((r) => r.name && ENVELOPE.test(`${r.rate} `))
|
|
17
|
+
.map((r) => `${r.name} rate: ${r.rate}`),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return [...feel, ...rates];
|
|
21
|
+
}
|
|
2
22
|
|
|
3
23
|
/**
|
|
4
24
|
* Playtest list (Methodology §5.3): every item waiting for its verdict, and every unplayed one,
|
|
@@ -13,5 +33,44 @@ export async function playtestList(root) {
|
|
|
13
33
|
status: c.data.status,
|
|
14
34
|
system: c.data.system ?? [],
|
|
15
35
|
question: scalarOf(c.body, 'miss_criterion') || '(miss_criterion is empty)',
|
|
36
|
+
reach: recordsOf(c.body, 'behavior')
|
|
37
|
+
.map((r) => r.input)
|
|
38
|
+
.filter(Boolean),
|
|
39
|
+
feel: feelOf(c.body),
|
|
40
|
+
intent: firstBullet(c.body, 'intent') || null,
|
|
16
41
|
}));
|
|
17
42
|
}
|
|
43
|
+
|
|
44
|
+
const git = (projectDir, args) => {
|
|
45
|
+
const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
|
|
46
|
+
|
|
47
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `playtest session` (Methodology §5.3): one build of main and the checklist of everything that
|
|
52
|
+
* waits for play, grouped by subsystem — built from the items on every call, no file of its own.
|
|
53
|
+
* `build` runs the project's `dod.build` first.
|
|
54
|
+
*/
|
|
55
|
+
export async function playtestSession(
|
|
56
|
+
projectDir,
|
|
57
|
+
{ build = false, stdio = ['ignore', 2, 2] } = {},
|
|
58
|
+
) {
|
|
59
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
60
|
+
const items = (await playtestList(root)).sort(
|
|
61
|
+
(a, b) => (a.system[0] ?? '').localeCompare(b.system[0] ?? '') || a.id.localeCompare(b.id),
|
|
62
|
+
);
|
|
63
|
+
// gcf/.metrics changes on every audit and context run: it does not make the build differ.
|
|
64
|
+
const dirty = git(projectDir, ['status', '--porcelain', '--', '.', ':(exclude)gcf/.metrics']);
|
|
65
|
+
const session = {
|
|
66
|
+
branch: git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
67
|
+
commit: git(projectDir, ['rev-parse', '--short', 'HEAD']),
|
|
68
|
+
// No commit (no repository, or none yet): the build is unknown, not clean.
|
|
69
|
+
dirty: dirty === null ? null : Boolean(dirty),
|
|
70
|
+
build: null,
|
|
71
|
+
items,
|
|
72
|
+
};
|
|
73
|
+
if (build) session.build = runDod(projectDir, await readConfig(root), 'build', stdio);
|
|
74
|
+
|
|
75
|
+
return session;
|
|
76
|
+
}
|
package/lib/unit-ops.js
CHANGED
|
@@ -15,6 +15,8 @@ import { operationInProgress, commitPaths } from './git.js';
|
|
|
15
15
|
import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
16
16
|
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
17
|
import { DEFAULT_GATE } from './validate-item.js';
|
|
18
|
+
import { refuseByPhase } from './phase.js';
|
|
19
|
+
import { withMainLock } from './main-lock.js';
|
|
18
20
|
|
|
19
21
|
/** A design file's text without the `## <id>` section. */
|
|
20
22
|
export function removeUnit(text, id) {
|
|
@@ -129,7 +131,8 @@ function refuseUnready(units, summaries) {
|
|
|
129
131
|
if (summary?.ready) continue;
|
|
130
132
|
const why = summary?.blocked_by.length
|
|
131
133
|
? `blocked by ${summary.blocked_by.join(', ')}`
|
|
132
|
-
: `${summary?.tbd ?? '?'} TBD
|
|
134
|
+
: `${summary?.tbd ?? '?'} TBD${summary?.pillars_owed ? ', pillars among them' : ''} ` +
|
|
135
|
+
'or a blocking design-check finding';
|
|
133
136
|
throw new Error(`${unit.id} is not ready (${why}): answer it in design/ first`);
|
|
134
137
|
}
|
|
135
138
|
}
|
|
@@ -149,6 +152,8 @@ export async function takeUnits(
|
|
|
149
152
|
const ledger = await buildLedger(projectDir);
|
|
150
153
|
const units = ids.map((id) => designUnitOf(ledger, id));
|
|
151
154
|
refuseUnready(units, (await designCheck(projectDir)).units);
|
|
155
|
+
for (const u of units) await refuseByPhase(projectDir, u.header.type, u.header.reopens ?? null);
|
|
156
|
+
if (feature && !ledger.item(feature)) await refuseByPhase(projectDir, 'feature');
|
|
152
157
|
const writes = units.map((unit) => ({
|
|
153
158
|
file: path.join(root, 'work', `${unit.id}.md`),
|
|
154
159
|
text: serialize(unitItemData(unit, today, feature), `\n${unit.body.trim()}\n`),
|
|
@@ -205,7 +210,28 @@ function uncommitted(projectDir, file) {
|
|
|
205
210
|
* @param {string} message
|
|
206
211
|
* @param {{files: string[], mutate: () => Promise<void>, commit: boolean, inputs?: string[]}} move
|
|
207
212
|
*/
|
|
208
|
-
|
|
213
|
+
/** A worktree of the integrator (Methodology §7): its gcf/ state is main's to move. */
|
|
214
|
+
const onWorkBranch = (projectDir) =>
|
|
215
|
+
/^work\//.test(
|
|
216
|
+
spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
217
|
+
cwd: projectDir,
|
|
218
|
+
encoding: 'utf8',
|
|
219
|
+
}).stdout?.trim() ?? '',
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
export async function commitMove(projectDir, message, move) {
|
|
223
|
+
// A move that commits holds the main lock while it writes and commits (Methodology §7).
|
|
224
|
+
return move.commit
|
|
225
|
+
? withMainLock(projectDir, () => record(projectDir, message, move))
|
|
226
|
+
: record(projectDir, message, move);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function record(projectDir, message, { files, mutate, commit, inputs = [] }) {
|
|
230
|
+
if (commit && onWorkBranch(projectDir))
|
|
231
|
+
throw new Error(
|
|
232
|
+
`cannot record ${message} on a work/ branch: a worktree records no move — the run ` +
|
|
233
|
+
'leaves .gcf-session/outcome.json, and the main checkout records it',
|
|
234
|
+
);
|
|
209
235
|
const busy = commit ? operationInProgress(projectDir) : null;
|
|
210
236
|
if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
|
|
211
237
|
const dirty = commit
|
package/lib/validate-item.js
CHANGED
|
@@ -65,6 +65,8 @@ const FIELDS = [
|
|
|
65
65
|
'live',
|
|
66
66
|
'model',
|
|
67
67
|
'effort',
|
|
68
|
+
'version',
|
|
69
|
+
'channel',
|
|
68
70
|
'created',
|
|
69
71
|
];
|
|
70
72
|
const REQUIRED = ['schema', 'id', 'type', 'gate', 'status', 'created'];
|
|
@@ -154,6 +156,10 @@ const RULES = {
|
|
|
154
156
|
live: (v) => liveProblem(v) ?? true,
|
|
155
157
|
model: (v) => nullable(isText)(v) || 'model must be text or null',
|
|
156
158
|
effort: (v) => nullable(isText)(v) || 'effort must be text or null',
|
|
159
|
+
version: (v) =>
|
|
160
|
+
nullable((x) => typeof x === 'string' && /^\S+$/.test(x))(v) ||
|
|
161
|
+
'version must be one word of text (quote a number: "1.0")',
|
|
162
|
+
channel: (v) => nullable(isSlug)(v) || 'channel must be a slug, e.g. steam',
|
|
157
163
|
created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
|
|
158
164
|
};
|
|
159
165
|
|
|
@@ -214,6 +220,10 @@ const CROSS = [
|
|
|
214
220
|
],
|
|
215
221
|
[(d) => isUnit(d) && d.source === undefined, 'a design-unit item keeps its source:'],
|
|
216
222
|
[(d) => Boolean(d.parent) && d.parent === d.id, 'an item cannot be its own parent'],
|
|
223
|
+
[
|
|
224
|
+
(d) => d.type !== 'release' && Boolean(d.version || d.channel),
|
|
225
|
+
'version and channel belong to a release',
|
|
226
|
+
],
|
|
217
227
|
];
|
|
218
228
|
|
|
219
229
|
/** Validate work item frontmatter. Returns a list of one-line errors (empty = valid). */
|
package/lib/verdict-fs.js
CHANGED
|
@@ -12,7 +12,8 @@ import { blockLines, upsertBlock, removeLines, openSystemDoc } from './systems-d
|
|
|
12
12
|
const ENVELOPE = /^\S+\.\.\S+ /;
|
|
13
13
|
const real = (lines) => lines.filter((l) => !/^- none\b/.test(l));
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** The records of a `key:` block, each as `{field: value}`. */
|
|
16
|
+
export function recordsOf(body, key) {
|
|
16
17
|
const { lines, sections } = parseBlocks(body);
|
|
17
18
|
const at = sections.find((s) => s.key === key && !s.value);
|
|
18
19
|
|
|
@@ -114,8 +115,14 @@ export async function buryLine(root, id, { type, reason, tags }) {
|
|
|
114
115
|
// A hand-edited file may end without a newline: the line would glue onto the last one.
|
|
115
116
|
const eol = existing.match(/\r?\n/)?.[0] ?? '\n';
|
|
116
117
|
const head = existing && !existing.endsWith('\n') ? existing + eol : existing;
|
|
117
|
-
|
|
118
|
-
const
|
|
118
|
+
// One record per line: a line break would split the reason from its tags, a pipe its fields.
|
|
119
|
+
const flat = (s) =>
|
|
120
|
+
String(s)
|
|
121
|
+
.replace(/\s*\|\s*/g, '; ')
|
|
122
|
+
.replace(/\s*\r?\n\s*/g, ' ')
|
|
123
|
+
.trim();
|
|
124
|
+
const why = flat(reason || 'no reason recorded');
|
|
125
|
+
const line = `- ${id} | ${type} | ${why} | tags: ${tags.map(flat).join(', ')}${eol}`;
|
|
119
126
|
await atomicWrite(gyFile, head + line);
|
|
120
127
|
}
|
|
121
128
|
|
package/lib/verify.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `gcf verify <id>` (Methodology §6): the auto-DoD checks code can run — the GCF checks of the
|
|
2
|
+
// pre-commit gate, the item's live: delta against LIVE.md, then the project's `dod.build` and
|
|
3
|
+
// `dod.test`. The second-agent review is the run's job; the attempt count lives in the run.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { readConfig } from './gcf-paths.js';
|
|
7
|
+
import { readLive, applyDelta } from './live.js';
|
|
8
|
+
import { gateProblems } from './gate.js';
|
|
9
|
+
import { unplayedCloseProblem } from './work-ops.js';
|
|
10
|
+
|
|
11
|
+
/** Run a `dod.<key>` command in the project; `none` says the project has no such step. */
|
|
12
|
+
export function runDod(projectDir, config, key, stdio) {
|
|
13
|
+
if (config.dod !== undefined && (typeof config.dod !== 'object' || Array.isArray(config.dod)))
|
|
14
|
+
return { name: key, ok: false, problems: ['dod in gcf/config.yml must map test: and build:'] };
|
|
15
|
+
const cmd = config.dod?.[key];
|
|
16
|
+
if (cmd === 'none') return { name: key, ok: true, problems: [], skipped: true };
|
|
17
|
+
if (typeof cmd !== 'string' || !cmd.trim())
|
|
18
|
+
return {
|
|
19
|
+
name: key,
|
|
20
|
+
ok: false,
|
|
21
|
+
problems: [`dod.${key} is not set in gcf/config.yml — the project's command, or none`],
|
|
22
|
+
};
|
|
23
|
+
// shortcut: no timeout, a command that waits for input hangs the run; add one when it bites.
|
|
24
|
+
const res = spawnSync(cmd, { cwd: projectDir, shell: true, stdio });
|
|
25
|
+
const ok = res.status === 0;
|
|
26
|
+
const why = res.error ? res.error.message : `exit ${res.status ?? res.signal}`;
|
|
27
|
+
|
|
28
|
+
return { name: key, ok, problems: ok ? [] : [`dod.${key} failed (${why}): ${cmd}`] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why the item's `live:` delta would fail its end against this checkout's LIVE.md: it does not
|
|
33
|
+
* apply, or an unplayed close could not take it back.
|
|
34
|
+
*/
|
|
35
|
+
export async function deltaProblems(projectDir, item) {
|
|
36
|
+
const problems = [];
|
|
37
|
+
try {
|
|
38
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
39
|
+
applyDelta((await readLive(root)).capabilities, item.data.live, String(item.data.id));
|
|
40
|
+
} catch (e) {
|
|
41
|
+
problems.push(e.message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const unplayed = unplayedCloseProblem(item);
|
|
45
|
+
if (unplayed) problems.push(unplayed);
|
|
46
|
+
|
|
47
|
+
return problems;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every DoD check over item `id`, each run even when an earlier one failed: one attempt sees
|
|
52
|
+
* all it has to fix. The commands' own output goes to `stdio` (stderr by default).
|
|
53
|
+
*/
|
|
54
|
+
export async function verifyItem(projectDir, id, { stdio = ['ignore', 2, 2] } = {}) {
|
|
55
|
+
const { problems, ledger } = await gateProblems(projectDir);
|
|
56
|
+
const item = ledger.item(id);
|
|
57
|
+
if (!item) throw new Error(`${id} is not a work item of gcf/work/`);
|
|
58
|
+
if (item.data.status !== 'ready')
|
|
59
|
+
throw new Error(`${id} is ${item.data.status}: the DoD runs on a ready item, before its move`);
|
|
60
|
+
|
|
61
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
62
|
+
const live = await deltaProblems(projectDir, item);
|
|
63
|
+
const config = await readConfig(root);
|
|
64
|
+
const checks = [
|
|
65
|
+
{ name: 'gcf', ok: !problems.length, problems },
|
|
66
|
+
{ name: 'live', ok: !live.length, problems: live },
|
|
67
|
+
runDod(projectDir, config, 'build', stdio),
|
|
68
|
+
runDod(projectDir, config, 'test', stdio),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
return { id, checks, ok: checks.every((c) => c.ok) };
|
|
72
|
+
}
|
package/lib/work-ops.js
CHANGED
|
@@ -26,8 +26,16 @@ import {
|
|
|
26
26
|
buryLine,
|
|
27
27
|
recordLine,
|
|
28
28
|
} from './verdict-fs.js';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
readLiveStrict,
|
|
31
|
+
writeLive,
|
|
32
|
+
liveFile,
|
|
33
|
+
applyDelta,
|
|
34
|
+
revertDelta,
|
|
35
|
+
knownIssueOf,
|
|
36
|
+
} from './live.js';
|
|
30
37
|
import { dropInbox } from './inbox.js';
|
|
38
|
+
import { refuseByPhase } from './phase.js';
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
41
|
* Options of a committed move; each move reads the ones it needs.
|
|
@@ -90,9 +98,25 @@ export function openDepends(ledger, depends = [], { waiting = false } = {}) {
|
|
|
90
98
|
});
|
|
91
99
|
}
|
|
92
100
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The hash an unplayed item is frozen at (Methodology §5.4): its body and its `live:` delta —
|
|
103
|
+
* the two things a tune or a kill takes back out of systems/ and LIVE.md.
|
|
104
|
+
*/
|
|
105
|
+
export const frozenHash = (item) =>
|
|
106
|
+
createHash('sha1')
|
|
107
|
+
.update(String(item.body).replace(/\r\n/g, '\n').trim())
|
|
108
|
+
.update(`\nlive: ${JSON.stringify(item.data.live ?? null)}`)
|
|
109
|
+
.digest('hex');
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether an unplayed item still matches its `frozen:`. An item closed by 0.4.0 holds the sha1
|
|
113
|
+
* of its body alone: it still guards the body. shortcut: such an item's live: stays unguarded
|
|
114
|
+
* until it is closed again; drop the legacy form once no 0.4.0 unplayed item is left.
|
|
115
|
+
*/
|
|
116
|
+
export const matchesFrozen = (item) =>
|
|
117
|
+
item.data.frozen === frozenHash(item) ||
|
|
118
|
+
item.data.frozen ===
|
|
119
|
+
createHash('sha1').update(String(item.body).replace(/\r\n/g, '\n').trim()).digest('hex');
|
|
96
120
|
|
|
97
121
|
/** Children of a feature that are not finished: live in any status but unplayed and parked. */
|
|
98
122
|
function unfinishedChildren(ledger, feature) {
|
|
@@ -133,7 +157,7 @@ async function begin(projectDir, id, move) {
|
|
|
133
157
|
run: async () => {
|
|
134
158
|
for (const s of steps) await s();
|
|
135
159
|
if (!liveOps.length) return;
|
|
136
|
-
let live = await
|
|
160
|
+
let live = await readLiveStrict(root);
|
|
137
161
|
for (const op of liveOps) live = op(live);
|
|
138
162
|
await writeLive(root, live);
|
|
139
163
|
},
|
|
@@ -160,6 +184,14 @@ const without = (data, ...keys) =>
|
|
|
160
184
|
* another unplayed item also wrote stays: that item's body still owes it to systems/.
|
|
161
185
|
*/
|
|
162
186
|
function unplay(m, item) {
|
|
187
|
+
// The gate checks `frozen` only on a file that stays unplayed: a tune or a kill of an edited
|
|
188
|
+
// item would take the edited lines out and leave the frozen ones in systems/ for good.
|
|
189
|
+
if (!matchesFrozen(item))
|
|
190
|
+
throw new Error(
|
|
191
|
+
`${item.data.id}: an unplayed item's body and live: are frozen, and this one was edited — ` +
|
|
192
|
+
`save the edit aside, restore the file (git restore gcf/work/${item.data.id}.md ` +
|
|
193
|
+
'discards it), make the move, then apply the edit to the ready item',
|
|
194
|
+
);
|
|
163
195
|
const systems = item.data.system ?? [];
|
|
164
196
|
m.leaving.add(String(item.data.id));
|
|
165
197
|
m.touch(...systemFiles(m.root, systems));
|
|
@@ -170,7 +202,15 @@ function unplay(m, item) {
|
|
|
170
202
|
.flatMap((i) => Object.values(collapseLines(i.body)).flat());
|
|
171
203
|
for (const s of systems) await unmergeFromSystem(m.root, s, item, keep);
|
|
172
204
|
});
|
|
173
|
-
m.live((l) => ({
|
|
205
|
+
m.live((l) => ({
|
|
206
|
+
...l,
|
|
207
|
+
// The delta as the close applied it, refs included, so a later refs-only edit shows.
|
|
208
|
+
capabilities: revertDelta(
|
|
209
|
+
l.capabilities,
|
|
210
|
+
withRefs(item.data.live, detailOf(item, null)),
|
|
211
|
+
item.data.id,
|
|
212
|
+
),
|
|
213
|
+
}));
|
|
174
214
|
}
|
|
175
215
|
|
|
176
216
|
const dropKnownIssue = (m, id) =>
|
|
@@ -227,6 +267,7 @@ const detailOf = (item, archived) =>
|
|
|
227
267
|
*/
|
|
228
268
|
function endToHistory(m, item, event, { applyLive = true } = {}) {
|
|
229
269
|
const id = String(item.data.id);
|
|
270
|
+
if (item.data.type === 'release') recordRelease(m, item);
|
|
230
271
|
m.touch(path.join(m.root, 'history.md'));
|
|
231
272
|
m.step(() => recordLine(m.root, id, event));
|
|
232
273
|
const archived = archivePlan(m, item);
|
|
@@ -239,6 +280,24 @@ function endToHistory(m, item, event, { applyLive = true } = {}) {
|
|
|
239
280
|
m.step(() => unlink(item.file));
|
|
240
281
|
}
|
|
241
282
|
|
|
283
|
+
/**
|
|
284
|
+
* A release item that ends writes the `release:` block of LIVE.md (Methodology §13): its
|
|
285
|
+
* version, channel and the date; the previous release stays only in history.md.
|
|
286
|
+
*/
|
|
287
|
+
function recordRelease(m, item) {
|
|
288
|
+
const { id, version, channel } = item.data;
|
|
289
|
+
if (!version || !channel)
|
|
290
|
+
throw new Error(`${id} is a release: write its version: and channel: before it ends`);
|
|
291
|
+
// The item file leaves in this commit: the gate never sees a hand-edited version: here.
|
|
292
|
+
if (typeof version !== 'string' || !/^\S+$/.test(version) || !isValidId(channel))
|
|
293
|
+
throw new Error(`${id}: version: is one quoted word ("1.10"), channel: a slug (steam)`);
|
|
294
|
+
// The phase it shipped in: the release phase exits on a release of its own (Methodology §10).
|
|
295
|
+
m.live((l) => ({
|
|
296
|
+
...l,
|
|
297
|
+
release: { version, channel, date: today(), ...(l.phase ? { phase: l.phase } : {}) },
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
242
301
|
/** A fresh id from `base`: base, base-2, base-3 … — ids are never reused. */
|
|
243
302
|
function freshId(ledger, base) {
|
|
244
303
|
let id = base;
|
|
@@ -360,6 +419,17 @@ export async function submitItem(projectDir, id, opts = {}) {
|
|
|
360
419
|
return { id, status, hash: await finish(m, 'submit', id, commit) };
|
|
361
420
|
}
|
|
362
421
|
|
|
422
|
+
/** Why a soft gate-auto item cannot close unplayed, or null: only an `added` comes back out. */
|
|
423
|
+
export function unplayedCloseProblem(item) {
|
|
424
|
+
if (!SOFT.includes(item.data.type) || item.data.gate !== 'auto') return null;
|
|
425
|
+
if (!(item.data.live?.changes ?? []).some((c) => c.change !== 'added')) return null;
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
`${item.data.id}: an unplayed close can only add capabilities (a modified or removed one ` +
|
|
429
|
+
'cannot be taken back) — set gate: verdict and submit it'
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
363
433
|
/**
|
|
364
434
|
* A soft unit closed without a playtest (Methodology §5.4): its lines go into its systems docs,
|
|
365
435
|
* its capability into LIVE.md, and the file stays unplayed with its body frozen by hash — that
|
|
@@ -368,11 +438,8 @@ export async function submitItem(projectDir, id, opts = {}) {
|
|
|
368
438
|
function closeUnplayed(m) {
|
|
369
439
|
const { item } = m;
|
|
370
440
|
const id = String(item.data.id);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
`${id}: an unplayed close can only add capabilities (a modified or removed one cannot be ` +
|
|
374
|
-
'taken back) — set gate: verdict and submit it',
|
|
375
|
-
);
|
|
441
|
+
const refused = unplayedCloseProblem(item);
|
|
442
|
+
if (refused) throw new Error(refused);
|
|
376
443
|
const systems = item.data.system ?? [];
|
|
377
444
|
m.touch(...systemFiles(m.root, systems));
|
|
378
445
|
m.step(async () => {
|
|
@@ -380,7 +447,7 @@ function closeUnplayed(m) {
|
|
|
380
447
|
});
|
|
381
448
|
const delta = withRefs(item.data.live, detailOf(item, null));
|
|
382
449
|
m.live((l) => ({ ...l, capabilities: applyDelta(l.capabilities, delta, id) }));
|
|
383
|
-
m.step(() => writeItem(item, { ...item.data, status: 'unplayed', frozen:
|
|
450
|
+
m.step(() => writeItem(item, { ...item.data, status: 'unplayed', frozen: frozenHash(item) }));
|
|
384
451
|
}
|
|
385
452
|
|
|
386
453
|
/** Any other gate-auto item ends in history; a hard unit may leave lines in `constraints:`. */
|
|
@@ -622,6 +689,19 @@ export async function dropItem(projectDir, id, opts = {}) {
|
|
|
622
689
|
return { id, hash: await finish(m, 'drop', id, commit), removal };
|
|
623
690
|
}
|
|
624
691
|
|
|
692
|
+
/** The version and channel of a new item: well-formed, and only on a release. */
|
|
693
|
+
function checkRelease(spec) {
|
|
694
|
+
const { type, version, channel } = spec;
|
|
695
|
+
if (version !== undefined && !/^\S+$/.test(version))
|
|
696
|
+
throw new Error('--release is one word, e.g. 1.2.0');
|
|
697
|
+
if (spec.milestone !== undefined && !isValidId(spec.milestone))
|
|
698
|
+
throw new Error('--milestone is a milestone name of game.md');
|
|
699
|
+
if (channel !== undefined && !isValidId(channel))
|
|
700
|
+
throw new Error('--channel is a lowercase hyphen slug, e.g. steam');
|
|
701
|
+
if ((version || channel) && type !== 'release')
|
|
702
|
+
throw new Error('--release and --channel belong to a release item');
|
|
703
|
+
}
|
|
704
|
+
|
|
625
705
|
/** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
|
|
626
706
|
function checkNew(ledger, spec) {
|
|
627
707
|
const { id, type, gate, severity } = spec;
|
|
@@ -635,6 +715,7 @@ function checkNew(ledger, spec) {
|
|
|
635
715
|
if (type === 'feature' && g !== 'auto') throw new Error('a feature always has gate auto');
|
|
636
716
|
if (type === 'bug' && !SEVERITIES.includes(severity))
|
|
637
717
|
throw new Error(`a bug needs --severity ${SEVERITIES.join('|')}`);
|
|
718
|
+
checkRelease(spec);
|
|
638
719
|
|
|
639
720
|
return g;
|
|
640
721
|
}
|
|
@@ -646,6 +727,7 @@ const NO_CAPABILITY = {
|
|
|
646
727
|
spike: 'a spike answers a question, its code never ships',
|
|
647
728
|
asset: 'an asset dresses a capability, it adds none',
|
|
648
729
|
perf: 'a budget met changes no capability',
|
|
730
|
+
release: 'a release ships what is built, it adds no capability',
|
|
649
731
|
};
|
|
650
732
|
|
|
651
733
|
/** The `live:` a new item starts with, or null when its author owes one. */
|
|
@@ -667,6 +749,9 @@ function optionalFields(spec, gate, since) {
|
|
|
667
749
|
...(reopens ? { reopens } : {}),
|
|
668
750
|
...(gate === 'verdict' ? { tune_count: 0 } : {}),
|
|
669
751
|
...(type === 'bug' ? { severity } : {}),
|
|
752
|
+
...(spec.milestone ? { milestone: spec.milestone } : {}),
|
|
753
|
+
...(spec.version ? { version: spec.version } : {}),
|
|
754
|
+
...(spec.channel ? { channel: spec.channel } : {}),
|
|
670
755
|
...(live ? { live } : {}),
|
|
671
756
|
};
|
|
672
757
|
}
|
|
@@ -700,6 +785,7 @@ export async function newItem(projectDir, spec) {
|
|
|
700
785
|
const root = gcfOf(projectDir);
|
|
701
786
|
const ledger = await buildLedger(projectDir);
|
|
702
787
|
const gate = checkNew(ledger, spec);
|
|
788
|
+
await refuseByPhase(projectDir, spec.type, spec.reopens);
|
|
703
789
|
if (spec.reopens && !ledger.where(spec.reopens).terminal.length)
|
|
704
790
|
throw new Error(`reopens names ${spec.reopens}, which has not ended`);
|
|
705
791
|
const file = path.join(root, 'work', `${spec.id}.md`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gcf-aif-ext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Game Context Framework for AI Factory",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
"node": ">=22.14"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"test": "node --test",
|
|
28
|
-
"prepack": "node -e \"const p=require('./package.json'),e=require('./extension.json');if(p.version!==e.version)throw new Error('version mismatch: package.json '+p.version+' vs extension.json '+e.version)\""
|
|
27
|
+
"test": "node --test && npm run test:gate",
|
|
28
|
+
"prepack": "node -e \"const p=require('./package.json'),e=require('./extension.json');if(p.version!==e.version)throw new Error('version mismatch: package.json '+p.version+' vs extension.json '+e.version)\"",
|
|
29
|
+
"test:gate": "node -e \"process.env.GCF_TEST_GATE='1';require('node:child_process').execFileSync(process.execPath,['--test'],{stdio:'inherit'})\""
|
|
29
30
|
},
|
|
30
31
|
"files": [
|
|
31
32
|
"extension.json",
|
package/schemas/item.schema.json
CHANGED
|
@@ -100,6 +100,10 @@
|
|
|
100
100
|
},
|
|
101
101
|
"model": { "$ref": "#/definitions/textOrNull" },
|
|
102
102
|
"effort": { "$ref": "#/definitions/textOrNull" },
|
|
103
|
+
"version": {
|
|
104
|
+
"anyOf": [{ "type": "null" }, { "type": "string", "pattern": "^\\S+$" }]
|
|
105
|
+
},
|
|
106
|
+
"channel": { "$ref": "#/definitions/slugOrNull" },
|
|
103
107
|
"created": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }
|
|
104
108
|
}
|
|
105
109
|
}
|
package/skills/gcf-card/SKILL.md
CHANGED
|
@@ -31,20 +31,22 @@ outputs:
|
|
|
31
31
|
|
|
32
32
|
workflow:
|
|
33
33
|
1. for a free-text design idea: ask "into work now, or park it?"; write a unit in the matching `gcf/design/<system>.md` with `source: manual` and `TBD` slots, append `- <id>` to `gcf/backlog.md`, and on "park" stop here
|
|
34
|
-
2. for non-design work: pick the type - tech, bug, spike, asset, content, perf, release - and propose the gate its type gives; the human may flip it (`--gate auto` is "no playtest")
|
|
35
|
-
3. a bug needs `--severity` and a `repro:` the human confirms
|
|
34
|
+
2. for non-design work: pick the type - tech, bug, spike, asset, content, perf, release - among those `ai-factory gcf phase` says the phase takes, and propose the gate its type gives; the human may flip it (`--gate auto` is "no playtest")
|
|
35
|
+
3. a bug needs `--severity` and a `repro:` the human confirms; a release needs `--release <version> --channel <name>` from the human
|
|
36
36
|
4. run `ai-factory gcf item new <id> --type <type> [--gate <gate>] [--parent <feature>] [--title "<line>"]`, add `--from <in-id>` when it comes from the inbox, `--blocked "file needed"` for a final asset; then write `intent:` and `live:` and stop
|
|
37
37
|
5. for a design unit: run `ai-factory gcf where <id>`; the id must be a single unit of `gcf/design/`; read its section from the file the command names
|
|
38
38
|
6. for a unit with `flags: impl?` ask the human first whether the mechanic already ships; on "yes" run `ai-factory gcf drop <id> --reason "already ships"` and stop
|
|
39
39
|
7. for a unit with `flags: src-changed` re-read each changed anchor with `ai-factory gcf section`, rebuild the slots whose quote moved, and remove the flag
|
|
40
40
|
8. confirm the route: ask "can an autotest accept this without a human playing?"; yes = hard, no or mixed = soft; a mixed unit splits into a soft mechanic and a hard rule before work
|
|
41
41
|
9. ask the human one question per `TBD` slot, each with your proposed answer, questions per message <= 2
|
|
42
|
-
10.
|
|
43
|
-
11.
|
|
44
|
-
12.
|
|
45
|
-
13.
|
|
46
|
-
14.
|
|
47
|
-
15.
|
|
42
|
+
10. for `pillars: TBD` propose the ids from `gcf/PILLARS.md` whose statement the unit serves and whose invariant it keeps
|
|
43
|
+
11. when no pillar fits, ask whether the unit is out of scope or a pillar is missing, and write a D1 question for the answer
|
|
44
|
+
12. write each answer into the unit in place, with its `answer:` provenance record
|
|
45
|
+
13. run `ai-factory gcf design check --json` until the unit's entry in `units` reads `ready: true`, then commit the answered unit
|
|
46
|
+
14. run `ai-factory gcf take <id>` for one unit, or `ai-factory gcf take <id> <id> --feature <feature-id>` to group several under a feature; it writes `gcf/work/<id>.md` and removes the sections and backlog lines in one step
|
|
47
|
+
15. in each design-unit item write `contract:` from the code tree and `interactions:` from the units and items that write the same state; a soft unit whose human wants no playtest gets `gate: auto` - it will close as unplayed
|
|
48
|
+
16. write the item's `live:` delta (a feature's children keep `none: parent`; the feature writes the capability), run `ai-factory gcf design check`, and commit
|
|
49
|
+
17. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
|
|
48
50
|
|
|
49
51
|
invocation:
|
|
50
52
|
- run when work starts on a unit or a group of units, when non-design work needs an item, or when a fresh idea needs a home
|
package/skills/gcf-init/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gcf-init
|
|
3
|
-
description: Bootstrap the gcf/ documentary layer - skeleton, pillars interview, hooks reminder. Use when a game project adopts GCF
|
|
3
|
+
description: Bootstrap the gcf/ documentary layer - skeleton, pillars interview, hooks reminder - and revise pillars later. Use when a game project adopts GCF, when the user says "set up gcf", or "поменяем столпы" / "revise pillars".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
mode: bootstrap
|
|
@@ -21,13 +21,16 @@ forbidden_behaviors:
|
|
|
21
21
|
|
|
22
22
|
workflow:
|
|
23
23
|
1. when `.ai-factory/DESCRIPTION.md` is absent, tell the human to run `/aif` first: `aif-plan` reads it, and a work item cannot reach a plan without it
|
|
24
|
-
2. run `ai-factory gcf init` and report which files it created, `gcf/design/` among them; it writes no `PILLARS.md
|
|
24
|
+
2. run `ai-factory gcf init` and report which files it created, `gcf/design/` and the agent instructions in `AGENTS.md` among them; it writes no `PILLARS.md`
|
|
25
25
|
3. when the human is about to import a GDD, leave `PILLARS.md` absent: the import proposes pillar candidates, and the interview runs after it
|
|
26
26
|
4. otherwise ask the human for the game's core promise, then for 2..7 pillars: each needs an id (a lowercase hyphen slug the design units cite), a statement, a negative invariant and a review question
|
|
27
27
|
5. write `gcf/PILLARS.md` with the confirmed pillars in the record-list form of `.ai-factory/extensions/gcf-aif-ext/templates/PILLARS.md`
|
|
28
28
|
6. run `npx --yes cnlp-kit check` and fix form issues it reports
|
|
29
29
|
7. ask the human to run `ai-factory gcf hooks-install`: the commit gate is theirs to arm
|
|
30
30
|
8. state the next step: import a GDD with `/gcf-ingest`, or start the design with `/gcf-vision` when there is none
|
|
31
|
+
9. to add one pillar later, ask only for its id, statement, invariant and review question; append it as one record to `gcf/PILLARS.md`, keep the others, keep the count <= 7, then run step 6
|
|
32
|
+
10. to rename a pillar, run `ai-factory gcf pillar rename <from> <to>`: every unit and item citing it follows
|
|
33
|
+
11. to drop a pillar, run `ai-factory gcf pillar retire <id> --reason "<why>"`; a unit it leaves at `pillars: TBD` goes back through the pillars steps of the gcf-card skill
|
|
31
34
|
|
|
32
35
|
invocation:
|
|
33
|
-
- run when the user asks to set up gcf in a project
|
|
36
|
+
- run when the user asks to set up gcf in a project, or to revise its pillars
|
|
@@ -21,7 +21,7 @@ forbidden_behaviors:
|
|
|
21
21
|
- do not edit systems docs, `LIVE.md`, history or graveyard by hand: the commands own them
|
|
22
22
|
|
|
23
23
|
workflow:
|
|
24
|
-
1. with no arguments: run `ai-factory gcf playtest` and stop -
|
|
24
|
+
1. with no arguments: run `ai-factory gcf playtest session` and stop - its build line and checklist of awaiting-verdict and unplayed items are the session's playtest sheet
|
|
25
25
|
2. read the item; for a verdict ask the human the miss_criterion question and record the answer in one line
|
|
26
26
|
3. for `tune` at the tune limit (`tune_limit` in `gcf/config.yml`, 3 by default): the command refuses; the way out is a kill, and a redesigned envelope becomes a new item with `reopens: <id>`
|
|
27
27
|
4. for `keep` of a design-unit item still awaiting its verdict: compress it first - the collapse copies lines verbatim, 1 line per behavior record, refusal and boundary
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
## Game Context Framework (from gcf-aif-ext)
|
|
2
|
+
|
|
3
|
+
gcf_context:
|
|
4
|
+
- the game's design and its work live in `gcf/`, and the `ai-factory gcf` commands and the gcf skills change them
|
|
5
|
+
- read `gcf/INDEX.md` before anything else under `gcf/`: it lists every subsystem, design unit and work item
|
|
6
|
+
- `gcf/LIVE.md` says what the game does now, and `gcf/PILLARS.md` says what it must stay
|
|
7
|
+
- `ai-factory gcf board --json` gives the whole state in one read: the phase, the items by status with plan progress, the inbox and the open questions
|
|
8
|
+
|
|
9
|
+
gcf_work:
|
|
10
|
+
- every piece of work is one work item `gcf/work/<id>.md`: find the item before writing code for a feature, a bug or a tech task, or create it with the gcf-card skill
|
|
11
|
+
- a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later
|
|
12
|
+
- before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
|
|
13
|
+
- drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext (a batch at once: `ai-factory gcf-harness run <ids...>`), otherwise with aif-plan and aif-implement
|
|
14
|
+
- after the code, write the item's `live:` delta and run `ai-factory gcf verify <id>`: fix what it reports and run it again until every check is green
|
|
15
|
+
- then end the work on the item with its command: `ai-factory gcf submit <id>` for gate verdict or decision, `ai-factory gcf close <id>` for gate auto
|
|
16
|
+
- work that cannot go on: run `ai-factory gcf block <id> --reason "<why or the question>"`
|
|
17
|
+
- in a worktree on branch `work/<id>` change no `gcf/` file but the item's `live:`, commit all, then run `ai-factory gcf verify <id> --mark`: `ai-factory gcf integrate` in the main checkout merges it and records the move
|
|
18
|
+
- a design question only the human can answer becomes a D1 record in `gcf/open-questions.md`
|
|
19
|
+
- the human plays from `ai-factory gcf playtest session`; each free note from that play goes to `ai-factory gcf inbox add --source playtest "<note>"`
|
|
20
|
+
- a release item carries `version:` and `channel:` (`item new <id> --type release --release <v> --channel <c>`): its end writes the `release:` block of `gcf/LIVE.md`
|
|
21
|
+
- `item new` and `take` refuse a type the current phase does not take: run `ai-factory gcf phase` and bring the refusal to the human
|
|
22
|
+
- a pillar changes only on the human's word: `ai-factory gcf pillar rename <from> <to>`, or `ai-factory gcf pillar retire <id> --reason "<why>"`
|
|
23
|
+
|
|
24
|
+
gcf_forbidden:
|
|
25
|
+
- do not edit `status:`, `frozen:`, `gcf/LIVE.md`, `gcf/history.md`, `gcf/graveyard.md` or `gcf/INDEX.md` by hand: the commands own them
|
|
26
|
+
- do not give a verdict or a decision: `verdict` and `decide` belong to the human, through the gcf-verdict skill
|
|
27
|
+
- do not answer a `TBD` of a design unit yourself: ask the human and offer your draft as the proposed answer
|
|
28
|
+
- do not run `ai-factory gcf phase next`: the phase moves on the human's word only
|
|
29
|
+
- do not reuse an ended id: rework gets a new id with `reopens: <old id>`
|
|
30
|
+
- do not commit past the gate with `--no-verify`: a refused commit names what to fix
|
|
31
|
+
|
|
32
|
+
gcf_skills:
|
|
33
|
+
- gcf-vision starts a game that has no design yet, and gcf-ingest imports a GDD into `gcf/design/`
|
|
34
|
+
- gcf-card puts work into `gcf/work/`, and gcf-triage sorts `gcf/inbox.md`
|
|
35
|
+
- gcf-verdict records the human's verdict or decision, and gcf-audit reports the health of `gcf/`
|
|
36
|
+
- gcf-debate stresses the design, gcf-adopt puts existing code on GCF, and gcf-init sets up `gcf/` and revises the pillars
|
package/templates/config.yml
CHANGED
|
@@ -10,6 +10,8 @@ unplayed_warn_after: 5
|
|
|
10
10
|
plan_passes: 5
|
|
11
11
|
dod_attempts: 2
|
|
12
12
|
# The project's own commands for the auto DoD (Methodology §6); the engine does not matter.
|
|
13
|
+
# gcf verify fails on an empty one; `none` says the project has no such step.
|
|
14
|
+
# Phases (Methodology §10) come from code; a `phases:` list here replaces the whole set.
|
|
13
15
|
dod:
|
|
14
16
|
test:
|
|
15
17
|
build:
|