gcf-aif-ext 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/phase.js ADDED
@@ -0,0 +1,228 @@
1
+ // Phases (Methodology §10): the project's stage decides which item types it takes, and each
2
+ // stage has exit criteria. The current phase is `phase:` in the LIVE.md frontmatter, the first
3
+ // phase while it is absent; the set comes from code, and `phases:` in gcf/config.yml replaces it.
4
+ // Only the operator moves the phase on: `phase next`, against unmet criteria with a reason.
5
+ import path from 'node:path';
6
+ import { readConfig } from './gcf-paths.js';
7
+ import { readLiveStrict, writeLive, liveFile } from './live.js';
8
+ import { buildLedger } from './ledger.js';
9
+ import { ITEM_TYPES } from './validate-item.js';
10
+ import { commitMove } from './unit-ops.js';
11
+
12
+ const ALL = ITEM_TYPES;
13
+ const SOFT = ['mechanic', 'resource'];
14
+
15
+ /**
16
+ * @typedef {{name: string, accepts: string[], with_reopens: string[], exit: string[],
17
+ * milestone?: string}} Phase
18
+ */
19
+
20
+ /** @type {Phase[]} */
21
+ export const DEFAULT_PHASES = [
22
+ // Final assets are not requested in a prototype (§5.6): placeholders need no item.
23
+ {
24
+ name: 'prototype',
25
+ accepts: ALL.filter((t) => t !== 'asset'),
26
+ with_reopens: [],
27
+ exit: ['mechanic-kept'],
28
+ },
29
+ {
30
+ name: 'vertical-slice',
31
+ accepts: ALL,
32
+ with_reopens: [],
33
+ exit: ['milestone-done', 'no-placeholders'],
34
+ },
35
+ { name: 'alpha', accepts: ALL, with_reopens: [], exit: ['features-done'] },
36
+ {
37
+ name: 'beta',
38
+ accepts: ['bug', 'perf', 'content', 'asset', 'release'],
39
+ with_reopens: SOFT,
40
+ exit: ['no-open-bugs'],
41
+ },
42
+ { name: 'release', accepts: ['bug', 'release'], with_reopens: [], exit: ['release-done'] },
43
+ { name: 'live', accepts: ['bug', 'content', 'perf', 'release'], with_reopens: SOFT, exit: [] },
44
+ ];
45
+
46
+ const finished = ['unplayed', 'parked'];
47
+ const milestoneOf = (phase) => phase.milestone ?? phase.name;
48
+
49
+ /**
50
+ * Exit criteria: `true` met, `false` not, `null` for one only the operator can see. Each gets
51
+ * the ledger, LIVE.md and the phase, and returns `[met, detail]`.
52
+ */
53
+ const CRITERIA = {
54
+ 'mechanic-kept': (ledger) => {
55
+ const kept = ledger.ids().filter((id) => ledger.where(id).terminal.includes('collapsed'));
56
+
57
+ return [
58
+ kept.length > 0,
59
+ kept.length ? `kept: ${kept.join(', ')}` : 'no mechanic or resource kept yet',
60
+ ];
61
+ },
62
+ // An ended item leaves no file, so "nothing open" cannot tell a done milestone from one never
63
+ // tagged: the operator reads that case, the code only names what is still open.
64
+ 'milestone-done': (ledger, live, phase) => {
65
+ const m = milestoneOf(phase);
66
+ const open = [
67
+ ...ledger.design.flatMap((d) => d.units).filter((u) => u.header.milestone === m),
68
+ ...ledger.items
69
+ .filter((c) => c.data.milestone === m && !finished.includes(c.data.status))
70
+ .map((c) => ({ id: c.data.id })),
71
+ ].map((x) => x.id);
72
+
73
+ return open.length
74
+ ? [false, `milestone ${m} still open: ${open.join(', ')}`]
75
+ : [null, `nothing open carries milestone ${m}: check by hand it is done, not untagged`];
76
+ },
77
+ // Feature complete: a live feature of this milestone, or one with no milestone at all, is open.
78
+ 'features-done': (ledger, live, phase) => {
79
+ const m = milestoneOf(phase);
80
+ const open = ledger.items
81
+ .filter((c) => c.data.type === 'feature' && [m, null, undefined].includes(c.data.milestone))
82
+ .map((c) => c.data.id);
83
+
84
+ return [
85
+ !open.length,
86
+ open.length ? `features open: ${open.join(', ')}` : `no feature of ${m} or untagged open`,
87
+ ];
88
+ },
89
+ 'no-open-bugs': (ledger) => {
90
+ const open = ledger.items
91
+ .filter(
92
+ (c) =>
93
+ c.data.type === 'bug' &&
94
+ ['critical', 'high'].includes(c.data.severity) &&
95
+ c.data.status !== 'parked',
96
+ )
97
+ .map((c) => c.data.id);
98
+
99
+ return [
100
+ !open.length,
101
+ open.length ? `critical/high bugs open: ${open.join(', ')}` : 'no critical or high bug open',
102
+ ];
103
+ },
104
+ // Only a release shipped in this phase: a demo shipped in alpha does not end the release phase.
105
+ 'release-done': (ledger, live) => {
106
+ const r = live.release;
107
+ const ours = Boolean(r) && (r.phase ?? null) === live.phase;
108
+
109
+ return [
110
+ ours,
111
+ ours
112
+ ? `released ${r.version} to ${r.channel}`
113
+ : 'no release of this phase in LIVE.md: its release item has not ended yet',
114
+ ];
115
+ },
116
+ 'no-placeholders': () => [null, 'check by hand: the slice holds no placeholder'],
117
+ };
118
+ export const CRITERIA_NAMES = Object.keys(CRITERIA);
119
+
120
+ const isList = (v, of) => Array.isArray(v) && v.every((x) => of.includes(x));
121
+ const isSlug = (v) => typeof v === 'string' && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v);
122
+
123
+ /** The phases of a project: `phases:` of gcf/config.yml, checked, or the default set. */
124
+ export function phasesOf(config) {
125
+ const list = config.phases;
126
+ if (list === undefined) return DEFAULT_PHASES;
127
+ const bad = (msg) => new Error(`gcf/config.yml phases: ${msg}`);
128
+ if (!Array.isArray(list) || !list.length) throw bad('a non-empty list of phases');
129
+ const names = list.map((p) => p?.name);
130
+ const twice = names.find((n, i) => names.indexOf(n) !== i);
131
+ if (twice !== undefined) throw bad(`${twice} is listed twice`);
132
+
133
+ return list.map((p, i) => {
134
+ const name = p?.name;
135
+ if (!isSlug(name)) throw bad(`entry ${i + 1} needs a slug name`);
136
+ if (p.milestone !== undefined && !isSlug(p.milestone))
137
+ throw bad(`${name}: milestone is a milestone name`);
138
+ const accepts = p.accepts === 'all' ? ALL : p.accepts;
139
+ if (!isList(accepts, ALL)) throw bad(`${name}: accepts is all or a list of ${ALL.join(', ')}`);
140
+ const withReopens = p.with_reopens ?? [];
141
+ if (!isList(withReopens, ALL)) throw bad(`${name}: with_reopens is a list of item types`);
142
+ const exit = p.exit ?? [];
143
+ if (!isList(exit, CRITERIA_NAMES))
144
+ throw bad(`${name}: exit is a list of ${CRITERIA_NAMES.join(', ')}`);
145
+
146
+ return {
147
+ name,
148
+ accepts,
149
+ with_reopens: withReopens,
150
+ exit,
151
+ ...(p.milestone ? { milestone: p.milestone } : {}),
152
+ };
153
+ });
154
+ }
155
+
156
+ /** The phases, the current one and LIVE.md. A `phase:` the set does not name is an error. */
157
+ async function load(projectDir) {
158
+ const root = path.join(path.resolve(projectDir), 'gcf');
159
+ const phases = phasesOf(await readConfig(root));
160
+ const live = await readLiveStrict(root);
161
+ const at = live.phase === null ? 0 : phases.findIndex((p) => p.name === live.phase);
162
+ if (at < 0) {
163
+ const names = phases.map((p) => p.name).join(', ');
164
+ throw new Error(`LIVE.md phase ${live.phase} is not a phase of the project (${names})`);
165
+ }
166
+
167
+ return { root, phases, at, live };
168
+ }
169
+
170
+ /** Refuse a new item of `type` the current phase does not take. */
171
+ export async function refuseByPhase(projectDir, type, reopens = null) {
172
+ const { phases, at } = await load(projectDir);
173
+ const phase = phases[at];
174
+ if (phase.accepts.includes(type) || (reopens && phase.with_reopens.includes(type))) return;
175
+ const takes = phase.with_reopens.length
176
+ ? `${phase.accepts.join(', ')}; ${phase.with_reopens.join(', ')} only with reopens`
177
+ : phase.accepts.join(', ');
178
+ throw new Error(
179
+ `phase ${phase.name} takes no ${type} item (it takes ${takes}) — the operator moves the ` +
180
+ 'phase on with gcf phase next',
181
+ );
182
+ }
183
+
184
+ /** `gcf phase`: the current phase, what it takes, its exit criteria and the next phase. */
185
+ export async function phaseStatus(projectDir) {
186
+ const { phases, at, live } = await load(projectDir);
187
+ const phase = phases[at];
188
+ const ledger = await buildLedger(projectDir);
189
+ const criteria = phase.exit.map((name) => {
190
+ const [met, detail] = CRITERIA[name](ledger, live, phase);
191
+
192
+ return { name, met, detail };
193
+ });
194
+
195
+ return {
196
+ phase: phase.name,
197
+ accepts: phase.accepts,
198
+ with_reopens: phase.with_reopens,
199
+ criteria,
200
+ next: phases[at + 1]?.name ?? null,
201
+ ready: criteria.every((c) => c.met !== false),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * The operator moves the phase on, commit `gcf(phase): <from> → <to>`. An unmet criterion needs
207
+ * `reason`, which the commit carries; the move itself is the operator's word on one that only
208
+ * the operator can check.
209
+ */
210
+ export async function nextPhase(projectDir, { reason = '', commit = true } = {}) {
211
+ const status = await phaseStatus(projectDir);
212
+ if (!status.next) throw new Error(`${status.phase} is the last phase`);
213
+ const unmet = status.criteria.filter((c) => c.met === false);
214
+ if (unmet.length && !reason.trim())
215
+ throw new Error(
216
+ `${status.phase} exit criteria not met (${unmet.map((c) => c.detail).join('; ')}) — ` +
217
+ 'move on anyway with --reason "<why>"',
218
+ );
219
+ const { root, live } = await load(projectDir);
220
+ const tail = `${status.phase} → ${status.next}${reason.trim() ? `: ${reason.trim()}` : ''}`;
221
+ const hash = await commitMove(projectDir, `gcf(phase): ${tail}`, {
222
+ files: [liveFile(root)],
223
+ mutate: () => writeLive(root, { ...live, phase: status.next }),
224
+ commit,
225
+ });
226
+
227
+ return { from: status.phase, to: status.next, forced: unmet.map((c) => c.name), hash };
228
+ }
@@ -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) {
@@ -150,6 +152,8 @@ export async function takeUnits(
150
152
  const ledger = await buildLedger(projectDir);
151
153
  const units = ids.map((id) => designUnitOf(ledger, id));
152
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');
153
157
  const writes = units.map((unit) => ({
154
158
  file: path.join(root, 'work', `${unit.id}.md`),
155
159
  text: serialize(unitItemData(unit, today, feature), `\n${unit.body.trim()}\n`),
@@ -206,7 +210,28 @@ function uncommitted(projectDir, file) {
206
210
  * @param {string} message
207
211
  * @param {{files: string[], mutate: () => Promise<void>, commit: boolean, inputs?: string[]}} move
208
212
  */
209
- 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
+ );
210
235
  const busy = commit ? operationInProgress(projectDir) : null;
211
236
  if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
212
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
 
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.
@@ -149,7 +157,7 @@ async function begin(projectDir, id, move) {
149
157
  run: async () => {
150
158
  for (const s of steps) await s();
151
159
  if (!liveOps.length) return;
152
- let live = await readLive(root);
160
+ let live = await readLiveStrict(root);
153
161
  for (const op of liveOps) live = op(live);
154
162
  await writeLive(root, live);
155
163
  },
@@ -259,6 +267,7 @@ const detailOf = (item, archived) =>
259
267
  */
260
268
  function endToHistory(m, item, event, { applyLive = true } = {}) {
261
269
  const id = String(item.data.id);
270
+ if (item.data.type === 'release') recordRelease(m, item);
262
271
  m.touch(path.join(m.root, 'history.md'));
263
272
  m.step(() => recordLine(m.root, id, event));
264
273
  const archived = archivePlan(m, item);
@@ -271,6 +280,24 @@ function endToHistory(m, item, event, { applyLive = true } = {}) {
271
280
  m.step(() => unlink(item.file));
272
281
  }
273
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
+
274
301
  /** A fresh id from `base`: base, base-2, base-3 … — ids are never reused. */
275
302
  function freshId(ledger, base) {
276
303
  let id = base;
@@ -392,6 +419,17 @@ export async function submitItem(projectDir, id, opts = {}) {
392
419
  return { id, status, hash: await finish(m, 'submit', id, commit) };
393
420
  }
394
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
+
395
433
  /**
396
434
  * A soft unit closed without a playtest (Methodology §5.4): its lines go into its systems docs,
397
435
  * its capability into LIVE.md, and the file stays unplayed with its body frozen by hash — that
@@ -400,11 +438,8 @@ export async function submitItem(projectDir, id, opts = {}) {
400
438
  function closeUnplayed(m) {
401
439
  const { item } = m;
402
440
  const id = String(item.data.id);
403
- if ((item.data.live?.changes ?? []).some((c) => c.change !== 'added'))
404
- throw new Error(
405
- `${id}: an unplayed close can only add capabilities (a modified or removed one cannot be ` +
406
- 'taken back) — set gate: verdict and submit it',
407
- );
441
+ const refused = unplayedCloseProblem(item);
442
+ if (refused) throw new Error(refused);
408
443
  const systems = item.data.system ?? [];
409
444
  m.touch(...systemFiles(m.root, systems));
410
445
  m.step(async () => {
@@ -654,6 +689,19 @@ export async function dropItem(projectDir, id, opts = {}) {
654
689
  return { id, hash: await finish(m, 'drop', id, commit), removal };
655
690
  }
656
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
+
657
705
  /** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
658
706
  function checkNew(ledger, spec) {
659
707
  const { id, type, gate, severity } = spec;
@@ -667,6 +715,7 @@ function checkNew(ledger, spec) {
667
715
  if (type === 'feature' && g !== 'auto') throw new Error('a feature always has gate auto');
668
716
  if (type === 'bug' && !SEVERITIES.includes(severity))
669
717
  throw new Error(`a bug needs --severity ${SEVERITIES.join('|')}`);
718
+ checkRelease(spec);
670
719
 
671
720
  return g;
672
721
  }
@@ -678,6 +727,7 @@ const NO_CAPABILITY = {
678
727
  spike: 'a spike answers a question, its code never ships',
679
728
  asset: 'an asset dresses a capability, it adds none',
680
729
  perf: 'a budget met changes no capability',
730
+ release: 'a release ships what is built, it adds no capability',
681
731
  };
682
732
 
683
733
  /** The `live:` a new item starts with, or null when its author owes one. */
@@ -699,6 +749,9 @@ function optionalFields(spec, gate, since) {
699
749
  ...(reopens ? { reopens } : {}),
700
750
  ...(gate === 'verdict' ? { tune_count: 0 } : {}),
701
751
  ...(type === 'bug' ? { severity } : {}),
752
+ ...(spec.milestone ? { milestone: spec.milestone } : {}),
753
+ ...(spec.version ? { version: spec.version } : {}),
754
+ ...(spec.channel ? { channel: spec.channel } : {}),
702
755
  ...(live ? { live } : {}),
703
756
  };
704
757
  }
@@ -732,6 +785,7 @@ export async function newItem(projectDir, spec) {
732
785
  const root = gcfOf(projectDir);
733
786
  const ledger = await buildLedger(projectDir);
734
787
  const gate = checkNew(ledger, spec);
788
+ await refuseByPhase(projectDir, spec.type, spec.reopens);
735
789
  if (spec.reopens && !ledger.where(spec.reopens).terminal.length)
736
790
  throw new Error(`reopens names ${spec.reopens}, which has not ended`);
737
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.1",
3
+ "version": "0.5.0",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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,8 +31,8 @@ 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
@@ -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