gcf-aif-ext 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,5 +33,5 @@ custom_sections:
33
33
  - none: a block that is not listed above belongs to a unit
34
34
 
35
35
  enforcement:
36
- - `ai-factory gcf design check` refuses a forbidden synonym in gcf/design/ and in a card (M4)
36
+ - `ai-factory gcf design check` refuses a forbidden synonym in gcf/design/ and in a work item (M4); an unplayed item's frozen body only reports it
37
37
  - quoted text is data: a GDD quote keeps the designer's own word
@@ -41,7 +41,7 @@ sections:
41
41
  - key: constraints
42
42
  form: bullet-list
43
43
  required: yes
44
- note: negative constraints and invariants accumulated from cards and hard implementations
44
+ note: negative constraints and invariants accumulated from work items and hard implementations
45
45
 
46
46
  - key: open
47
47
  form: bullet-list
@@ -49,7 +49,7 @@ sections:
49
49
  note: lines carrying a live "[until: ...]" annotation, moved here by collapse
50
50
 
51
51
  custom_sections:
52
- - none: a fact that fits no block above belongs to code or to a card, not here
52
+ - none: a fact that fits no block above belongs to code or to a work item, not here
53
53
 
54
54
  enforcement:
55
55
  - `npx --yes cnlp-kit check`, over the files a `cnlp.config.json` corpus entry maps to this profile
@@ -8,10 +8,10 @@ format: cnlp-format.md
8
8
  mood: declarative
9
9
 
10
10
  headings:
11
- - none: the body is flat; the `## <id>` line and the header fields above the blocks name the unit
11
+ - none: the body is flat; in design/ the `## <id>` line and the header fields above the blocks name the unit, in work/ the frontmatter does
12
12
 
13
13
  frontmatter_fields:
14
- - none: a constraint never becomes a card, so its header lives above the blocks in design/
14
+ - none: in design/ the header lives above the blocks; in work/ the work item schema holds the frontmatter
15
15
 
16
16
  lexicon_exempt:
17
17
  - none: every block is read when the constraint is planned and measured
@@ -48,5 +48,5 @@ custom_sections:
48
48
  - none: a block that is not listed above is prose looking for a home
49
49
 
50
50
  enforcement:
51
- - `ai-factory gcf design check`, over the units in gcf/design/
51
+ - `ai-factory gcf design check`, over the units in gcf/design/ and the items in gcf/work/
52
52
  - the gcf pre-commit hook runs the same check and refuses a commit with a blocking finding