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.
@@ -1,4 +1,24 @@
1
- import { listItems, scalarOf } from './gcf-paths.js';
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 or a blocking design-check finding`;
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
- export async function commitMove(projectDir, message, { files, mutate, commit, inputs = [] }) {
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
@@ -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
- function recordsOf(body, key) {
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
- const why = String(reason || 'no reason recorded').replace(/\s*\|\s*/g, '; ');
118
- const line = `- ${id} | ${type} | ${why} | tags: ${tags.join(', ')}${eol}`;
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 { readLive, writeLive, liveFile, applyDelta, revertDelta, knownIssueOf } from './live.js';
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
- /** The hash an unplayed item's body is frozen at (Methodology §5.4). */
94
- export const bodyHash = (body) =>
95
- createHash('sha1').update(String(body).replace(/\r\n/g, '\n').trim()).digest('hex');
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 readLive(root);
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) => ({ ...l, capabilities: revertDelta(l.capabilities, item.data.live) }));
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
- if ((item.data.live?.changes ?? []).some((c) => c.change !== 'added'))
372
- throw new Error(
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: bodyHash(item.body) }));
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.4.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",
@@ -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
  }
@@ -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. write each answer into the unit in place, with its `answer:` provenance record
43
- 11. run `ai-factory gcf design check --json` until the unit's entry in `units` reads `ready: true`, then commit the answered unit
44
- 12. 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
45
- 13. 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
46
- 14. 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
47
- 15. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
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
@@ -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 or when the user says "set up 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`, so no example ever reads as the game's truth
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 - that list, awaiting-verdict and unplayed items, is the session's playtest sheet
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
@@ -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: