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/README.md +22 -1
- package/commands/gcf.js +172 -4
- package/extension.json +1 -1
- package/hooks/pre-commit.js +5 -66
- package/lib/board.js +113 -0
- package/lib/design-check.js +24 -6
- package/lib/gate.js +106 -0
- package/lib/integrate.js +322 -0
- package/lib/live.js +82 -23
- package/lib/main-lock.js +112 -0
- package/lib/phase.js +228 -0
- package/lib/playtest-list.js +60 -1
- package/lib/unit-ops.js +26 -1
- package/lib/validate-item.js +10 -0
- package/lib/verdict-fs.js +2 -1
- package/lib/verify.js +72 -0
- package/lib/work-ops.js +61 -7
- package/package.json +1 -1
- package/profiles/glossary.md +1 -1
- package/profiles/system.md +2 -2
- package/profiles/unit-constraint.md +3 -3
- package/profiles/unit-entity.md +3 -3
- package/profiles/unit-mechanic.md +6 -18
- package/profiles/unit-resource.md +6 -18
- package/profiles/unit-rule.md +3 -3
- package/schemas/item.schema.json +4 -0
- package/skills/gcf-card/SKILL.md +5 -4
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/agents-block.md +11 -4
- package/templates/config.yml +2 -0
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
|
+
}
|
package/lib/playtest-list.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { listItems, scalarOf, readConfig, firstBullet } from './gcf-paths.js';
|
|
4
|
+
import { recordsOf } from './verdict-fs.js';
|
|
5
|
+
import { runDod } from './verify.js';
|
|
6
|
+
|
|
7
|
+
const ENVELOPE = /^\S+\.\.\S+ /;
|
|
8
|
+
|
|
9
|
+
/** What the play should feel: a mechanic's feel envelopes, a resource's rate envelopes. */
|
|
10
|
+
function feelOf(body) {
|
|
11
|
+
const feel = recordsOf(body, 'feel')
|
|
12
|
+
.filter((r) => r.name && r.envelope)
|
|
13
|
+
.map((r) => `${r.name}: ${r.envelope}`);
|
|
14
|
+
const rates = ['sources', 'sinks'].flatMap((key) =>
|
|
15
|
+
recordsOf(body, key)
|
|
16
|
+
.filter((r) => r.name && ENVELOPE.test(`${r.rate} `))
|
|
17
|
+
.map((r) => `${r.name} rate: ${r.rate}`),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return [...feel, ...rates];
|
|
21
|
+
}
|
|
2
22
|
|
|
3
23
|
/**
|
|
4
24
|
* Playtest list (Methodology §5.3): every item waiting for its verdict, and every unplayed one,
|
|
@@ -13,5 +33,44 @@ export async function playtestList(root) {
|
|
|
13
33
|
status: c.data.status,
|
|
14
34
|
system: c.data.system ?? [],
|
|
15
35
|
question: scalarOf(c.body, 'miss_criterion') || '(miss_criterion is empty)',
|
|
36
|
+
reach: recordsOf(c.body, 'behavior')
|
|
37
|
+
.map((r) => r.input)
|
|
38
|
+
.filter(Boolean),
|
|
39
|
+
feel: feelOf(c.body),
|
|
40
|
+
intent: firstBullet(c.body, 'intent') || null,
|
|
16
41
|
}));
|
|
17
42
|
}
|
|
43
|
+
|
|
44
|
+
const git = (projectDir, args) => {
|
|
45
|
+
const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
|
|
46
|
+
|
|
47
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `playtest session` (Methodology §5.3): one build of main and the checklist of everything that
|
|
52
|
+
* waits for play, grouped by subsystem — built from the items on every call, no file of its own.
|
|
53
|
+
* `build` runs the project's `dod.build` first.
|
|
54
|
+
*/
|
|
55
|
+
export async function playtestSession(
|
|
56
|
+
projectDir,
|
|
57
|
+
{ build = false, stdio = ['ignore', 2, 2] } = {},
|
|
58
|
+
) {
|
|
59
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
60
|
+
const items = (await playtestList(root)).sort(
|
|
61
|
+
(a, b) => (a.system[0] ?? '').localeCompare(b.system[0] ?? '') || a.id.localeCompare(b.id),
|
|
62
|
+
);
|
|
63
|
+
// gcf/.metrics changes on every audit and context run: it does not make the build differ.
|
|
64
|
+
const dirty = git(projectDir, ['status', '--porcelain', '--', '.', ':(exclude)gcf/.metrics']);
|
|
65
|
+
const session = {
|
|
66
|
+
branch: git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
67
|
+
commit: git(projectDir, ['rev-parse', '--short', 'HEAD']),
|
|
68
|
+
// No commit (no repository, or none yet): the build is unknown, not clean.
|
|
69
|
+
dirty: dirty === null ? null : Boolean(dirty),
|
|
70
|
+
build: null,
|
|
71
|
+
items,
|
|
72
|
+
};
|
|
73
|
+
if (build) session.build = runDod(projectDir, await readConfig(root), 'build', stdio);
|
|
74
|
+
|
|
75
|
+
return session;
|
|
76
|
+
}
|
package/lib/unit-ops.js
CHANGED
|
@@ -15,6 +15,8 @@ import { operationInProgress, commitPaths } from './git.js';
|
|
|
15
15
|
import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
16
16
|
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
17
|
import { DEFAULT_GATE } from './validate-item.js';
|
|
18
|
+
import { refuseByPhase } from './phase.js';
|
|
19
|
+
import { withMainLock } from './main-lock.js';
|
|
18
20
|
|
|
19
21
|
/** A design file's text without the `## <id>` section. */
|
|
20
22
|
export function removeUnit(text, id) {
|
|
@@ -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
|
-
|
|
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
|
package/lib/validate-item.js
CHANGED
|
@@ -65,6 +65,8 @@ const FIELDS = [
|
|
|
65
65
|
'live',
|
|
66
66
|
'model',
|
|
67
67
|
'effort',
|
|
68
|
+
'version',
|
|
69
|
+
'channel',
|
|
68
70
|
'created',
|
|
69
71
|
];
|
|
70
72
|
const REQUIRED = ['schema', 'id', 'type', 'gate', 'status', 'created'];
|
|
@@ -154,6 +156,10 @@ const RULES = {
|
|
|
154
156
|
live: (v) => liveProblem(v) ?? true,
|
|
155
157
|
model: (v) => nullable(isText)(v) || 'model must be text or null',
|
|
156
158
|
effort: (v) => nullable(isText)(v) || 'effort must be text or null',
|
|
159
|
+
version: (v) =>
|
|
160
|
+
nullable((x) => typeof x === 'string' && /^\S+$/.test(x))(v) ||
|
|
161
|
+
'version must be one word of text (quote a number: "1.0")',
|
|
162
|
+
channel: (v) => nullable(isSlug)(v) || 'channel must be a slug, e.g. steam',
|
|
157
163
|
created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
|
|
158
164
|
};
|
|
159
165
|
|
|
@@ -214,6 +220,10 @@ const CROSS = [
|
|
|
214
220
|
],
|
|
215
221
|
[(d) => isUnit(d) && d.source === undefined, 'a design-unit item keeps its source:'],
|
|
216
222
|
[(d) => Boolean(d.parent) && d.parent === d.id, 'an item cannot be its own parent'],
|
|
223
|
+
[
|
|
224
|
+
(d) => d.type !== 'release' && Boolean(d.version || d.channel),
|
|
225
|
+
'version and channel belong to a release',
|
|
226
|
+
],
|
|
217
227
|
];
|
|
218
228
|
|
|
219
229
|
/** Validate work item frontmatter. Returns a list of one-line errors (empty = valid). */
|
package/lib/verdict-fs.js
CHANGED
|
@@ -12,7 +12,8 @@ import { blockLines, upsertBlock, removeLines, openSystemDoc } from './systems-d
|
|
|
12
12
|
const ENVELOPE = /^\S+\.\.\S+ /;
|
|
13
13
|
const real = (lines) => lines.filter((l) => !/^- none\b/.test(l));
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** The records of a `key:` block, each as `{field: value}`. */
|
|
16
|
+
export function recordsOf(body, key) {
|
|
16
17
|
const { lines, sections } = parseBlocks(body);
|
|
17
18
|
const at = sections.find((s) => s.key === key && !s.value);
|
|
18
19
|
|
package/lib/verify.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `gcf verify <id>` (Methodology §6): the auto-DoD checks code can run — the GCF checks of the
|
|
2
|
+
// pre-commit gate, the item's live: delta against LIVE.md, then the project's `dod.build` and
|
|
3
|
+
// `dod.test`. The second-agent review is the run's job; the attempt count lives in the run.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { readConfig } from './gcf-paths.js';
|
|
7
|
+
import { readLive, applyDelta } from './live.js';
|
|
8
|
+
import { gateProblems } from './gate.js';
|
|
9
|
+
import { unplayedCloseProblem } from './work-ops.js';
|
|
10
|
+
|
|
11
|
+
/** Run a `dod.<key>` command in the project; `none` says the project has no such step. */
|
|
12
|
+
export function runDod(projectDir, config, key, stdio) {
|
|
13
|
+
if (config.dod !== undefined && (typeof config.dod !== 'object' || Array.isArray(config.dod)))
|
|
14
|
+
return { name: key, ok: false, problems: ['dod in gcf/config.yml must map test: and build:'] };
|
|
15
|
+
const cmd = config.dod?.[key];
|
|
16
|
+
if (cmd === 'none') return { name: key, ok: true, problems: [], skipped: true };
|
|
17
|
+
if (typeof cmd !== 'string' || !cmd.trim())
|
|
18
|
+
return {
|
|
19
|
+
name: key,
|
|
20
|
+
ok: false,
|
|
21
|
+
problems: [`dod.${key} is not set in gcf/config.yml — the project's command, or none`],
|
|
22
|
+
};
|
|
23
|
+
// shortcut: no timeout, a command that waits for input hangs the run; add one when it bites.
|
|
24
|
+
const res = spawnSync(cmd, { cwd: projectDir, shell: true, stdio });
|
|
25
|
+
const ok = res.status === 0;
|
|
26
|
+
const why = res.error ? res.error.message : `exit ${res.status ?? res.signal}`;
|
|
27
|
+
|
|
28
|
+
return { name: key, ok, problems: ok ? [] : [`dod.${key} failed (${why}): ${cmd}`] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why the item's `live:` delta would fail its end against this checkout's LIVE.md: it does not
|
|
33
|
+
* apply, or an unplayed close could not take it back.
|
|
34
|
+
*/
|
|
35
|
+
export async function deltaProblems(projectDir, item) {
|
|
36
|
+
const problems = [];
|
|
37
|
+
try {
|
|
38
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
39
|
+
applyDelta((await readLive(root)).capabilities, item.data.live, String(item.data.id));
|
|
40
|
+
} catch (e) {
|
|
41
|
+
problems.push(e.message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const unplayed = unplayedCloseProblem(item);
|
|
45
|
+
if (unplayed) problems.push(unplayed);
|
|
46
|
+
|
|
47
|
+
return problems;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every DoD check over item `id`, each run even when an earlier one failed: one attempt sees
|
|
52
|
+
* all it has to fix. The commands' own output goes to `stdio` (stderr by default).
|
|
53
|
+
*/
|
|
54
|
+
export async function verifyItem(projectDir, id, { stdio = ['ignore', 2, 2] } = {}) {
|
|
55
|
+
const { problems, ledger } = await gateProblems(projectDir);
|
|
56
|
+
const item = ledger.item(id);
|
|
57
|
+
if (!item) throw new Error(`${id} is not a work item of gcf/work/`);
|
|
58
|
+
if (item.data.status !== 'ready')
|
|
59
|
+
throw new Error(`${id} is ${item.data.status}: the DoD runs on a ready item, before its move`);
|
|
60
|
+
|
|
61
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
62
|
+
const live = await deltaProblems(projectDir, item);
|
|
63
|
+
const config = await readConfig(root);
|
|
64
|
+
const checks = [
|
|
65
|
+
{ name: 'gcf', ok: !problems.length, problems },
|
|
66
|
+
{ name: 'live', ok: !live.length, problems: live },
|
|
67
|
+
runDod(projectDir, config, 'build', stdio),
|
|
68
|
+
runDod(projectDir, config, 'test', stdio),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
return { id, checks, ok: checks.every((c) => c.ok) };
|
|
72
|
+
}
|
package/lib/work-ops.js
CHANGED
|
@@ -26,8 +26,16 @@ import {
|
|
|
26
26
|
buryLine,
|
|
27
27
|
recordLine,
|
|
28
28
|
} from './verdict-fs.js';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
readLiveStrict,
|
|
31
|
+
writeLive,
|
|
32
|
+
liveFile,
|
|
33
|
+
applyDelta,
|
|
34
|
+
revertDelta,
|
|
35
|
+
knownIssueOf,
|
|
36
|
+
} from './live.js';
|
|
30
37
|
import { dropInbox } from './inbox.js';
|
|
38
|
+
import { refuseByPhase } from './phase.js';
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
41
|
* Options of a committed move; each move reads the ones it needs.
|
|
@@ -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
|
|
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
|
-
|
|
404
|
-
|
|
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
package/profiles/glossary.md
CHANGED
|
@@ -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
|
|
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
|
package/profiles/system.md
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|