gcf-aif-ext 0.2.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -10
- package/commands/gcf.js +452 -30
- package/extension.json +22 -5
- package/hooks/pre-commit.js +69 -41
- package/injections/commit-status.md +7 -4
- package/injections/plan-context.md +8 -4
- package/injections/review-pillars.md +4 -3
- package/lib/anchors.js +60 -35
- package/lib/audit-core.js +172 -55
- package/lib/bundle.js +278 -0
- package/lib/context.js +25 -23
- package/lib/design-check.js +631 -0
- package/lib/design.js +179 -0
- package/lib/gcf-paths.js +13 -2
- package/lib/git.js +63 -0
- package/lib/id.js +7 -15
- package/lib/inbox.js +54 -0
- package/lib/index-gen.js +62 -13
- package/lib/ledger.js +94 -0
- package/lib/linter.js +17 -5
- package/lib/live.js +179 -0
- package/lib/migrate.js +345 -0
- package/lib/plan-check.js +31 -0
- package/lib/playtest-list.js +9 -5
- package/lib/systems-doc.js +85 -0
- package/lib/transitions.js +36 -21
- package/lib/unit-ops.js +297 -0
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +112 -165
- package/lib/work-ops.js +727 -0
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +3 -2
- package/profiles/system.md +1 -1
- package/profiles/unit-constraint.md +52 -0
- package/profiles/unit-entity.md +48 -0
- package/profiles/unit-mechanic.md +111 -0
- package/profiles/unit-resource.md +91 -0
- package/profiles/unit-rule.md +52 -0
- package/schemas/item.schema.json +105 -0
- package/skills/gcf-adopt/SKILL.md +3 -3
- package/skills/gcf-audit/SKILL.md +7 -6
- package/skills/gcf-card/SKILL.md +31 -17
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +39 -23
- package/skills/gcf-init/SKILL.md +5 -5
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -15
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -5
- package/templates/cnlp.config.json +0 -1
- package/templates/config.yml +7 -0
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/item.md +58 -0
- package/templates/system.md +1 -1
- package/lib/validate-card.js +0 -82
- package/profiles/card.md +0 -65
- package/schemas/card.schema.json +0 -30
- package/templates/card.md +0 -34
package/lib/work-ops.js
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
// The moves of a work item (Methodology §4.1): submit, close, verdict, decide, block, unblock,
|
|
2
|
+
// drop — plus `item new`. Each committed move is one fixed-format commit `gcf(<move>): <id>`
|
|
3
|
+
// that carries every file it touched: the item, history or graveyard, LIVE.md, the systems
|
|
4
|
+
// docs, the parent feature, a removal item, the archived plan. Atomic: a refused commit rolls
|
|
5
|
+
// every file back.
|
|
6
|
+
import { readFile, unlink } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { serialize } from './frontmatter.js';
|
|
12
|
+
import { buildLedger } from './ledger.js';
|
|
13
|
+
import { readConfig } from './gcf-paths.js';
|
|
14
|
+
import { atomicWrite, withFileRollback } from './safe-path.js';
|
|
15
|
+
import { writeIndex } from './index-gen.js';
|
|
16
|
+
import { allowedFrom, fromOf } from './transitions.js';
|
|
17
|
+
import { ITEM_TYPES, DEFAULT_GATE, GATES, SEVERITIES, UNIT_ITEM_TYPES } from './validate-item.js';
|
|
18
|
+
import { isValidId } from './id.js';
|
|
19
|
+
import { commitMove, addConstraints } from './unit-ops.js';
|
|
20
|
+
import { openSystemDoc, upsertBlock } from './systems-doc.js';
|
|
21
|
+
import {
|
|
22
|
+
collapseLines,
|
|
23
|
+
mergeIntoSystem,
|
|
24
|
+
unmergeFromSystem,
|
|
25
|
+
systemFiles,
|
|
26
|
+
buryLine,
|
|
27
|
+
recordLine,
|
|
28
|
+
} from './verdict-fs.js';
|
|
29
|
+
import { readLive, writeLive, liveFile, applyDelta, revertDelta, knownIssueOf } from './live.js';
|
|
30
|
+
import { dropInbox } from './inbox.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options of a committed move; each move reads the ones it needs.
|
|
34
|
+
* @typedef {object} MoveOpts
|
|
35
|
+
* @property {string} [note]
|
|
36
|
+
* @property {string} [reason]
|
|
37
|
+
* @property {string[]} [tags]
|
|
38
|
+
* @property {boolean} [remove] create a removal item; absent = the move's default
|
|
39
|
+
* @property {string[]} [constraints]
|
|
40
|
+
* @property {boolean} [commit]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const SOFT = ['mechanic', 'resource'];
|
|
44
|
+
const HARD = ['rule', 'constraint', 'entity'];
|
|
45
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
46
|
+
const gcfOf = (projectDir) => path.join(path.resolve(projectDir), 'gcf');
|
|
47
|
+
|
|
48
|
+
/** HEAD's hash, null outside a repository or before the first commit. */
|
|
49
|
+
function headHash(projectDir) {
|
|
50
|
+
const res = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: projectDir, encoding: 'utf8' });
|
|
51
|
+
|
|
52
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The live work item `id`, or an error naming where the id lives instead. */
|
|
56
|
+
function itemOf(ledger, id) {
|
|
57
|
+
const item = ledger.item(id);
|
|
58
|
+
if (item) return item;
|
|
59
|
+
const { live, terminal } = ledger.where(id);
|
|
60
|
+
const at = live.map((p) => p.file).join(', ') || terminal.join(', ') || 'nowhere';
|
|
61
|
+
throw new Error(`${id} is not a work item of gcf/work/ (it is: ${at})`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function need(move, item) {
|
|
65
|
+
if (allowedFrom(move, item.data.status)) return;
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${move.replace(':', ' ')} ${item.data.id}: it is ${item.data.status}, the move needs ` +
|
|
68
|
+
`${fromOf(move).join(' or ')}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Dependencies not yet played (Methodology §4.1): a dependency counts once its id ended in
|
|
74
|
+
* history, or is unplayed or parked. A design unit or an item in any other status is open.
|
|
75
|
+
* `waiting` also lets through one awaiting its verdict: two linked mechanics are played in the
|
|
76
|
+
* same session, so the second may be submitted while the first waits.
|
|
77
|
+
*/
|
|
78
|
+
export function openDepends(ledger, depends = [], { waiting = false } = {}) {
|
|
79
|
+
const played = waiting ? ['unplayed', 'parked', 'awaiting-verdict'] : ['unplayed', 'parked'];
|
|
80
|
+
|
|
81
|
+
return depends.filter((d) => {
|
|
82
|
+
const { live, terminal } = ledger.where(d);
|
|
83
|
+
const place = live[0];
|
|
84
|
+
// Built on something killed: not played, never will be — take it out of depends, or drop.
|
|
85
|
+
// Unknown anywhere is not played either: a dependency that vanished cannot be built on.
|
|
86
|
+
if (!place) return terminal.every((t) => t === 'killed');
|
|
87
|
+
if (place.state === 'design') return true;
|
|
88
|
+
|
|
89
|
+
return !played.includes(ledger.item(d)?.data.status);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The hash an unplayed item's body is frozen at (Methodology §5.4). */
|
|
94
|
+
export const bodyHash = (body) =>
|
|
95
|
+
createHash('sha1').update(String(body).replace(/\r\n/g, '\n').trim()).digest('hex');
|
|
96
|
+
|
|
97
|
+
/** Children of a feature that are not finished: live in any status but unplayed and parked. */
|
|
98
|
+
function unfinishedChildren(ledger, feature) {
|
|
99
|
+
return (feature.data.children ?? []).filter((c) => {
|
|
100
|
+
const child = ledger.item(c);
|
|
101
|
+
|
|
102
|
+
return child && !['unplayed', 'parked'].includes(child.data.status);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Open a move on item `id` from the statuses `move` allows: the files it touches, its steps,
|
|
108
|
+
* and the LIVE.md edits folded into a single write at the end.
|
|
109
|
+
*/
|
|
110
|
+
async function begin(projectDir, id, move) {
|
|
111
|
+
const root = gcfOf(projectDir);
|
|
112
|
+
const ledger = await buildLedger(projectDir);
|
|
113
|
+
const item = itemOf(ledger, id);
|
|
114
|
+
need(move, item);
|
|
115
|
+
const files = [item.file];
|
|
116
|
+
const steps = [];
|
|
117
|
+
const liveOps = [];
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
projectDir,
|
|
121
|
+
root,
|
|
122
|
+
ledger,
|
|
123
|
+
item,
|
|
124
|
+
/** @type {Set<string>} ids leaving systems/ in this move */
|
|
125
|
+
leaving: new Set(),
|
|
126
|
+
files,
|
|
127
|
+
touch: (...f) => files.push(...f.filter((x) => !files.includes(x))),
|
|
128
|
+
step: (fn) => steps.push(fn),
|
|
129
|
+
live: (fn) => {
|
|
130
|
+
if (!files.includes(liveFile(root))) files.push(liveFile(root));
|
|
131
|
+
liveOps.push(fn);
|
|
132
|
+
},
|
|
133
|
+
run: async () => {
|
|
134
|
+
for (const s of steps) await s();
|
|
135
|
+
if (!liveOps.length) return;
|
|
136
|
+
let live = await readLive(root);
|
|
137
|
+
for (const op of liveOps) live = op(live);
|
|
138
|
+
await writeLive(root, live);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Commit the move as `gcf(<move>): <tail>` with every file it touched (item edits included). */
|
|
144
|
+
const finish = (m, move, tail, commit) =>
|
|
145
|
+
commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
|
|
146
|
+
files: m.files,
|
|
147
|
+
mutate: m.run,
|
|
148
|
+
commit,
|
|
149
|
+
inputs: [m.item.file],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const writeItem = (item, data, body = item.body) => atomicWrite(item.file, serialize(data, body));
|
|
153
|
+
|
|
154
|
+
/** `data` without the keys named — a field that no longer applies leaves the frontmatter. */
|
|
155
|
+
const without = (data, ...keys) =>
|
|
156
|
+
Object.fromEntries(Object.entries(data).filter(([k]) => !keys.includes(k)));
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The item's lines leave its systems docs and its added capability leaves LIVE.md. A line
|
|
160
|
+
* another unplayed item also wrote stays: that item's body still owes it to systems/.
|
|
161
|
+
*/
|
|
162
|
+
function unplay(m, item) {
|
|
163
|
+
const systems = item.data.system ?? [];
|
|
164
|
+
m.leaving.add(String(item.data.id));
|
|
165
|
+
m.touch(...systemFiles(m.root, systems));
|
|
166
|
+
m.step(async () => {
|
|
167
|
+
// Read at run time: siblings leaving in the same move (a feature kill) owe nothing either.
|
|
168
|
+
const keep = m.ledger.items
|
|
169
|
+
.filter((i) => i.data.status === 'unplayed' && !m.leaving.has(String(i.data.id)))
|
|
170
|
+
.flatMap((i) => Object.values(collapseLines(i.body)).flat());
|
|
171
|
+
for (const s of systems) await unmergeFromSystem(m.root, s, item, keep);
|
|
172
|
+
});
|
|
173
|
+
m.live((l) => ({ ...l, capabilities: revertDelta(l.capabilities, item.data.live) }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const dropKnownIssue = (m, id) =>
|
|
177
|
+
m.live((l) => ({ ...l, knownIssues: l.knownIssues.filter((r) => r.id !== id) }));
|
|
178
|
+
|
|
179
|
+
/** True when git tracks `file`: an ignored or new plan stays out of the move's commit. */
|
|
180
|
+
function tracked(projectDir, file) {
|
|
181
|
+
const res = spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
|
|
182
|
+
cwd: projectDir,
|
|
183
|
+
stdio: 'ignore',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return res.status === 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The item's plan moves to the AIF archive in the move's own commit (Methodology §3.3), so a
|
|
191
|
+
* LIVE.md ref to it holds the moment the item ends. No tracked plan — nothing to do. Returns
|
|
192
|
+
* the archived path, or null.
|
|
193
|
+
*/
|
|
194
|
+
function archivePlan(m, item) {
|
|
195
|
+
const rel = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
196
|
+
const src = path.resolve(m.projectDir, rel);
|
|
197
|
+
if (!existsSync(src) || !tracked(m.projectDir, src)) return null;
|
|
198
|
+
const destRel = `.ai-factory/archive/plans/${path.basename(src)}`;
|
|
199
|
+
const dest = path.resolve(m.projectDir, destRel);
|
|
200
|
+
if (existsSync(dest)) return null;
|
|
201
|
+
m.touch(src, dest);
|
|
202
|
+
m.step(async () => {
|
|
203
|
+
await atomicWrite(dest, await readFile(src, 'utf8'));
|
|
204
|
+
await unlink(src);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return destRel;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A delta whose changes name no refs points at where the item's detail now lives. */
|
|
211
|
+
function withRefs(delta, refs) {
|
|
212
|
+
if (!delta || 'none' in delta || !refs) return delta;
|
|
213
|
+
const ref = (c) => (c.refs || c.change === 'removed' ? c : { ...c, refs });
|
|
214
|
+
|
|
215
|
+
return { changes: delta.changes.map(ref) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Where a capability of `item` is described in detail: its systems doc or its archived plan. */
|
|
219
|
+
const detailOf = (item, archived) =>
|
|
220
|
+
SOFT.includes(item.data.type) && item.data.system?.length
|
|
221
|
+
? `gcf/systems/${item.data.system[0]}.md`
|
|
222
|
+
: archived;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* An item ends accepted: its history line, its LIVE.md delta, its plan archived, its file
|
|
226
|
+
* gone. An unplayed item's delta went into LIVE.md at its close already.
|
|
227
|
+
*/
|
|
228
|
+
function endToHistory(m, item, event, { applyLive = true } = {}) {
|
|
229
|
+
const id = String(item.data.id);
|
|
230
|
+
m.touch(path.join(m.root, 'history.md'));
|
|
231
|
+
m.step(() => recordLine(m.root, id, event));
|
|
232
|
+
const archived = archivePlan(m, item);
|
|
233
|
+
if (applyLive) {
|
|
234
|
+
const delta = withRefs(item.data.live, detailOf(item, archived));
|
|
235
|
+
m.live((l) => ({ ...l, capabilities: applyDelta(l.capabilities, delta, id) }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
dropKnownIssue(m, id);
|
|
239
|
+
m.step(() => unlink(item.file));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A fresh id from `base`: base, base-2, base-3 … — ids are never reused. */
|
|
243
|
+
function freshId(ledger, base) {
|
|
244
|
+
let id = base;
|
|
245
|
+
for (let n = 2; ledger.known(id); n++) id = `${base}-${n}`;
|
|
246
|
+
|
|
247
|
+
return id;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The item that takes the code of a dead id out of main (Methodology §4.1). */
|
|
251
|
+
function removalItem(m, item, reason) {
|
|
252
|
+
const id = freshId(m.ledger, `remove-${item.data.id}`);
|
|
253
|
+
const file = path.join(m.root, 'work', `${id}.md`);
|
|
254
|
+
const data = {
|
|
255
|
+
schema: 3,
|
|
256
|
+
id,
|
|
257
|
+
type: 'tech',
|
|
258
|
+
gate: 'auto',
|
|
259
|
+
status: 'ready',
|
|
260
|
+
system: item.data.system ?? [],
|
|
261
|
+
depends: [],
|
|
262
|
+
reopens: String(item.data.id),
|
|
263
|
+
live: { none: `removes the code of ${item.data.id}, which never reached LIVE.md or left it` },
|
|
264
|
+
created: today(),
|
|
265
|
+
};
|
|
266
|
+
const body = `\nintent:\n- take the code of ${item.data.id} out of the game: ${reason}\n`;
|
|
267
|
+
m.touch(file);
|
|
268
|
+
m.step(() => atomicWrite(file, serialize(data, body)));
|
|
269
|
+
|
|
270
|
+
return id;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A child went to the graveyard: its live feature waits for the operator's decision (§3.2). */
|
|
274
|
+
function parentWaits(m, item) {
|
|
275
|
+
const parent = item.data.parent ? m.ledger.item(item.data.parent) : null;
|
|
276
|
+
if (!parent || parent.data.type !== 'feature') return;
|
|
277
|
+
const { status } = parent.data;
|
|
278
|
+
if (status !== 'ready' && status !== 'blocked') return;
|
|
279
|
+
m.touch(parent.file);
|
|
280
|
+
// A blocked feature keeps its reason: the dead child is added to it, unblock decides later.
|
|
281
|
+
const data =
|
|
282
|
+
status === 'blocked'
|
|
283
|
+
? { ...parent.data, blocked: `${parent.data.blocked}; child ${item.data.id} died` }
|
|
284
|
+
: { ...parent.data, status: 'awaiting-decision', since: headHash(m.projectDir) };
|
|
285
|
+
m.step(() => writeItem(parent, data));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* An item ends dead: its graveyard line, its systems lines out if unplayed, its plan archived,
|
|
290
|
+
* its file gone; a removal item when its code is in main. `quiet` keeps the parent as it is —
|
|
291
|
+
* the parent is going down in the same move.
|
|
292
|
+
*/
|
|
293
|
+
function endToGraveyard(
|
|
294
|
+
m,
|
|
295
|
+
item,
|
|
296
|
+
{ reason, tags = /** @type {string[]} */ ([]), removal, quiet = false },
|
|
297
|
+
) {
|
|
298
|
+
const id = String(item.data.id);
|
|
299
|
+
const why = String(reason ?? '').trim();
|
|
300
|
+
if (!why) throw new Error(`${id}: the graveyard keeps a reason — give one`);
|
|
301
|
+
m.touch(path.join(m.root, 'graveyard.md'), item.file);
|
|
302
|
+
const lineTags = tags.length ? tags : (item.data.system ?? []);
|
|
303
|
+
m.step(() => buryLine(m.root, id, { type: item.data.type, reason: why, tags: lineTags }));
|
|
304
|
+
if (item.data.status === 'unplayed') unplay(m, item);
|
|
305
|
+
archivePlan(m, item);
|
|
306
|
+
dropKnownIssue(m, id);
|
|
307
|
+
if (!quiet) parentWaits(m, item);
|
|
308
|
+
const removed = removal ? removalItem(m, item, why) : null;
|
|
309
|
+
m.step(() => unlink(item.file));
|
|
310
|
+
|
|
311
|
+
return removed;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Whether a dead item's code is in main by default: it waited for the operator, was closed,
|
|
316
|
+
* was built before a tune — or, in 0.4 where the agent works on main, it got blocked after its
|
|
317
|
+
* code was committed. `--no-remove` overrides it.
|
|
318
|
+
*/
|
|
319
|
+
const IN_MAIN = ['awaiting-verdict', 'awaiting-decision', 'unplayed', 'blocked', 'parked'];
|
|
320
|
+
const codeInMain = (item) =>
|
|
321
|
+
item.data.type !== 'spike' &&
|
|
322
|
+
(IN_MAIN.includes(item.data.status) || (item.data.tune_count ?? 0) > 0);
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A feature's own status says nothing about its code: it is in main when a child ended
|
|
326
|
+
* accepted, or a live child's code is.
|
|
327
|
+
*/
|
|
328
|
+
const featureShipped = (ledger, feature) =>
|
|
329
|
+
(feature.data.children ?? []).some((c) => {
|
|
330
|
+
const child = ledger.item(c);
|
|
331
|
+
|
|
332
|
+
return child ? codeInMain(child) : ledger.where(c).terminal.some((t) => t !== 'killed');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
function refuseOpenDepends(m, move, opts = {}) {
|
|
336
|
+
const open = openDepends(m.ledger, m.item.data.depends ?? [], opts);
|
|
337
|
+
if (!open.length) return;
|
|
338
|
+
const id = m.item.data.id;
|
|
339
|
+
throw new Error(
|
|
340
|
+
`${move} ${id}: it depends on ${open.join(', ')}, not played yet ` +
|
|
341
|
+
'(a dependency counts once it ended in history, or is unplayed or parked; ' +
|
|
342
|
+
'a killed one never will — take it out of depends, or drop)' +
|
|
343
|
+
(move === 'close' ? `; done but waiting: gcf block ${id} --reason "waits for …"` : ''),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** ready → awaiting-verdict / awaiting-decision; `since` marks where the wait began.
|
|
348
|
+
* @param {MoveOpts} [opts]
|
|
349
|
+
*/
|
|
350
|
+
export async function submitItem(projectDir, id, opts = {}) {
|
|
351
|
+
const { commit = true } = opts;
|
|
352
|
+
const m = await begin(projectDir, id, 'submit');
|
|
353
|
+
if (m.item.data.gate === 'auto')
|
|
354
|
+
throw new Error(`${id} has gate auto: nobody waits for it — close it with gcf close ${id}`);
|
|
355
|
+
refuseOpenDepends(m, 'submit', { waiting: true });
|
|
356
|
+
const status = m.item.data.gate === 'verdict' ? 'awaiting-verdict' : 'awaiting-decision';
|
|
357
|
+
const data = { ...without(m.item.data, 'blocked'), status, since: headHash(projectDir) };
|
|
358
|
+
m.step(() => writeItem(m.item, data));
|
|
359
|
+
|
|
360
|
+
return { id, status, hash: await finish(m, 'submit', id, commit) };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A soft unit closed without a playtest (Methodology §5.4): its lines go into its systems docs,
|
|
365
|
+
* its capability into LIVE.md, and the file stays unplayed with its body frozen by hash — that
|
|
366
|
+
* body is what a tune or a kill takes back out. Only an `added` delta can be taken back.
|
|
367
|
+
*/
|
|
368
|
+
function closeUnplayed(m) {
|
|
369
|
+
const { item } = m;
|
|
370
|
+
const id = String(item.data.id);
|
|
371
|
+
if ((item.data.live?.changes ?? []).some((c) => c.change !== 'added'))
|
|
372
|
+
throw new Error(
|
|
373
|
+
`${id}: an unplayed close can only add capabilities (a modified or removed one cannot be ` +
|
|
374
|
+
'taken back) — set gate: verdict and submit it',
|
|
375
|
+
);
|
|
376
|
+
const systems = item.data.system ?? [];
|
|
377
|
+
m.touch(...systemFiles(m.root, systems));
|
|
378
|
+
m.step(async () => {
|
|
379
|
+
for (const s of systems) await mergeIntoSystem(m.root, s, item);
|
|
380
|
+
});
|
|
381
|
+
const delta = withRefs(item.data.live, detailOf(item, null));
|
|
382
|
+
m.live((l) => ({ ...l, capabilities: applyDelta(l.capabilities, delta, id) }));
|
|
383
|
+
m.step(() => writeItem(item, { ...item.data, status: 'unplayed', frozen: bodyHash(item.body) }));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Any other gate-auto item ends in history; a hard unit may leave lines in `constraints:`. */
|
|
387
|
+
async function closeEnding(m, constraints) {
|
|
388
|
+
if (constraints.length) {
|
|
389
|
+
const { id, type, system = [] } = m.item.data;
|
|
390
|
+
if (!HARD.includes(type) || !system.length)
|
|
391
|
+
throw new Error(`${id}: --constraint belongs to a hard unit (rule, constraint, entity)`);
|
|
392
|
+
const doc = await openSystemDoc(m.root, system[0]);
|
|
393
|
+
m.touch(doc.file);
|
|
394
|
+
m.step(() => addConstraints(doc, String(m.item.data.id), constraints));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
endToHistory(m, m.item, 'done');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A feature closes once every child is finished: ended, unplayed or parked (§3.2). */
|
|
401
|
+
function closeFeature(m) {
|
|
402
|
+
refuseOpenDepends(m, 'close');
|
|
403
|
+
const open = unfinishedChildren(m.ledger, m.item);
|
|
404
|
+
if (open.length)
|
|
405
|
+
throw new Error(`${m.item.data.id}: children not finished yet: ${open.join(', ')}`);
|
|
406
|
+
endToHistory(m, m.item, 'done');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A gate-auto item passed its DoD. A feature closes once its children are finished; a soft
|
|
411
|
+
* unit collapses into its systems docs and stays as unplayed; anything else ends in history.
|
|
412
|
+
* @param {MoveOpts} [opts]
|
|
413
|
+
*/
|
|
414
|
+
export async function closeItem(projectDir, id, opts = {}) {
|
|
415
|
+
const { constraints = [], commit = true } = opts;
|
|
416
|
+
const m = await begin(projectDir, id, 'close');
|
|
417
|
+
const unplayed = SOFT.includes(m.item.data.type);
|
|
418
|
+
if (m.item.data.type === 'feature') closeFeature(m);
|
|
419
|
+
else {
|
|
420
|
+
if (m.item.data.gate !== 'auto')
|
|
421
|
+
throw new Error(`${id} has gate ${m.item.data.gate}: submit it, the operator closes it`);
|
|
422
|
+
refuseOpenDepends(m, 'close');
|
|
423
|
+
if (unplayed) closeUnplayed(m);
|
|
424
|
+
else await closeEnding(m, constraints);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const hash = await finish(m, 'close', `${id}${unplayed ? ' → unplayed' : ''}`, commit);
|
|
428
|
+
|
|
429
|
+
return { id, hash, unplayed };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* keep: an unplayed item is in systems/ and LIVE.md already; a soft unit collapses now; anything
|
|
434
|
+
* else ends done. A keep needs its dependencies played — they shared the session, not the verdict.
|
|
435
|
+
*/
|
|
436
|
+
async function keep(m, constraints) {
|
|
437
|
+
const { item } = m;
|
|
438
|
+
refuseOpenDepends(m, 'verdict keep');
|
|
439
|
+
if (item.data.status === 'unplayed') {
|
|
440
|
+
endToHistory(m, item, 'collapsed', { applyLive: false });
|
|
441
|
+
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!SOFT.includes(item.data.type)) {
|
|
446
|
+
// A hard unit played by a human still leaves its constraint lines, as its close would.
|
|
447
|
+
await closeEnding(m, constraints);
|
|
448
|
+
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const systems = item.data.system ?? [];
|
|
453
|
+
m.touch(...systemFiles(m.root, systems));
|
|
454
|
+
m.step(async () => {
|
|
455
|
+
for (const s of systems) await mergeIntoSystem(m.root, s, item);
|
|
456
|
+
});
|
|
457
|
+
endToHistory(m, item, 'collapsed');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** tune: back to ready with the note; an unplayed item's lines and capability come back out. */
|
|
461
|
+
async function tune(m, note) {
|
|
462
|
+
const { item } = m;
|
|
463
|
+
// Only a tune reads the config: a broken config.yml must not block keep or kill.
|
|
464
|
+
const tunes = item.data.tune_count ?? 0;
|
|
465
|
+
const { tune_limit: limit } = await readConfig(m.root);
|
|
466
|
+
if (tunes >= limit)
|
|
467
|
+
throw new Error(
|
|
468
|
+
`${item.data.id} is at the tune limit (tune_count ${tunes} of ${limit}) — kill it; a ` +
|
|
469
|
+
'redesigned envelope is a new item with reopens: <this id>',
|
|
470
|
+
);
|
|
471
|
+
if (item.data.status === 'unplayed') unplay(m, item);
|
|
472
|
+
const body = note ? upsertBlock(item.body, 'tuning', [`- ${note}`]) : item.body;
|
|
473
|
+
const data = {
|
|
474
|
+
...without(item.data, 'frozen'),
|
|
475
|
+
status: 'ready',
|
|
476
|
+
tune_count: tunes + 1,
|
|
477
|
+
since: null,
|
|
478
|
+
};
|
|
479
|
+
m.step(() => writeItem(item, data, body));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** keep | tune | kill on an item waiting for its verdict or unplayed (Methodology §5.4).
|
|
483
|
+
* @param {MoveOpts} [opts]
|
|
484
|
+
*/
|
|
485
|
+
export async function verdictItem(projectDir, id, verdict, opts = {}) {
|
|
486
|
+
const { note = '', tags = [], remove, constraints = [], commit = true } = opts;
|
|
487
|
+
if (!['keep', 'tune', 'kill'].includes(verdict)) throw new Error(`Unknown verdict: ${verdict}`);
|
|
488
|
+
const m = await begin(projectDir, id, `verdict:${verdict}`);
|
|
489
|
+
const systems = m.item.data.system ?? [];
|
|
490
|
+
let removal = null;
|
|
491
|
+
if (verdict === 'keep') await keep(m, constraints);
|
|
492
|
+
else if (verdict === 'tune') await tune(m, note);
|
|
493
|
+
else removal = endToGraveyard(m, m.item, { reason: note, tags, removal: remove ?? true });
|
|
494
|
+
const hash = await finish(m, 'verdict', `${verdict} ${id} → ${systems.join(',')}`, commit);
|
|
495
|
+
|
|
496
|
+
return { verdict, id, systems, hash, removal };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** decide kill: the live children go down with the feature, without removal items of their own. */
|
|
500
|
+
function killFeature(m, { reason, remove }) {
|
|
501
|
+
const { item, ledger } = m;
|
|
502
|
+
const shipped = featureShipped(ledger, item);
|
|
503
|
+
for (const c of item.data.children ?? []) {
|
|
504
|
+
const child = ledger.item(c);
|
|
505
|
+
if (!child) continue;
|
|
506
|
+
endToGraveyard(m, child, {
|
|
507
|
+
reason: `feature ${item.data.id} killed: ${reason}`,
|
|
508
|
+
removal: false,
|
|
509
|
+
quiet: true,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return endToGraveyard(m, item, { reason, removal: remove ?? shipped });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function park(m) {
|
|
517
|
+
const rec = knownIssueOf(m.item);
|
|
518
|
+
m.step(() => writeItem(m.item, { ...without(m.item.data, 'blocked'), status: 'parked' }));
|
|
519
|
+
m.live((l) => ({ ...l, knownIssues: [...l.knownIssues.filter((r) => r.id !== rec.id), rec] }));
|
|
520
|
+
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// What each decision does; it returns the removal item it created, or null.
|
|
525
|
+
const DECIDE = {
|
|
526
|
+
go: (m) => endToHistory(m, m.item, 'decided') ?? null,
|
|
527
|
+
no: (m, o) =>
|
|
528
|
+
endToGraveyard(m, m.item, { reason: o.reason, removal: o.remove ?? codeInMain(m.item) }),
|
|
529
|
+
// A bug not to be fixed has fix code in main only when a started fix got blocked.
|
|
530
|
+
wontfix: (m, o) =>
|
|
531
|
+
endToGraveyard(m, m.item, {
|
|
532
|
+
reason: o.reason,
|
|
533
|
+
removal: o.remove ?? m.item.data.status === 'blocked',
|
|
534
|
+
}),
|
|
535
|
+
known: park,
|
|
536
|
+
close: (m) => closeFeature(m) ?? null,
|
|
537
|
+
continue: (m) => {
|
|
538
|
+
m.step(() => writeItem(m.item, { ...m.item.data, status: 'ready' }));
|
|
539
|
+
|
|
540
|
+
return null;
|
|
541
|
+
},
|
|
542
|
+
kill: killFeature,
|
|
543
|
+
};
|
|
544
|
+
const FEATURE_ONLY = ['close', 'continue', 'kill'];
|
|
545
|
+
|
|
546
|
+
/** Refuse a decision that does not fit the item's type. */
|
|
547
|
+
function fitDecision(choice, item) {
|
|
548
|
+
const feature = item.data.type === 'feature';
|
|
549
|
+
if (FEATURE_ONLY.includes(choice) !== feature)
|
|
550
|
+
throw new Error(
|
|
551
|
+
feature
|
|
552
|
+
? `${item.data.id} is a feature: decide close | continue | kill`
|
|
553
|
+
: `decide ${choice} is for a feature; ${item.data.id} is a ${item.data.type}`,
|
|
554
|
+
);
|
|
555
|
+
if (['wontfix', 'known'].includes(choice) && item.data.type !== 'bug')
|
|
556
|
+
throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
|
|
560
|
+
* @param {MoveOpts} [opts]
|
|
561
|
+
*/
|
|
562
|
+
export async function decideItem(projectDir, id, choice, opts = {}) {
|
|
563
|
+
const { reason = '', remove, commit = true } = opts;
|
|
564
|
+
if (!DECIDE[choice])
|
|
565
|
+
throw new Error(`Unknown decision: ${choice} (${Object.keys(DECIDE).join(' | ')})`);
|
|
566
|
+
const m = await begin(projectDir, id, `decide:${choice}`);
|
|
567
|
+
fitDecision(choice, m.item);
|
|
568
|
+
const removal = DECIDE[choice](m, { reason, remove }) ?? null;
|
|
569
|
+
const hash = await finish(m, 'decide', `${choice} ${id}`, commit);
|
|
570
|
+
|
|
571
|
+
return { id, choice, hash, removal };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** ready → blocked, with the reason or the question for the operator.
|
|
575
|
+
* @param {MoveOpts} [opts]
|
|
576
|
+
*/
|
|
577
|
+
export async function blockItem(projectDir, id, opts = {}) {
|
|
578
|
+
const { reason, commit = true } = opts;
|
|
579
|
+
const why = String(reason ?? '').trim();
|
|
580
|
+
if (!why) throw new Error('a block needs a reason or a question');
|
|
581
|
+
const m = await begin(projectDir, id, 'block');
|
|
582
|
+
const data = { ...m.item.data, status: 'blocked', blocked: why, since: headHash(projectDir) };
|
|
583
|
+
m.step(() => writeItem(m.item, data));
|
|
584
|
+
|
|
585
|
+
return { id, hash: await finish(m, 'block', id, commit) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** blocked | parked → ready; a note (an answer, a delivered asset file) goes to `notes:`.
|
|
589
|
+
* @param {MoveOpts} [opts]
|
|
590
|
+
*/
|
|
591
|
+
export async function unblockItem(projectDir, id, opts = {}) {
|
|
592
|
+
const { note = '', commit = true } = opts;
|
|
593
|
+
const m = await begin(projectDir, id, 'unblock');
|
|
594
|
+
if (m.item.data.status === 'parked') dropKnownIssue(m, id);
|
|
595
|
+
const body = note ? upsertBlock(m.item.body, 'notes', [`- ${note}`]) : m.item.body;
|
|
596
|
+
// A feature whose child died while it was blocked owes the operator that decision first; the
|
|
597
|
+
// death is the one parentWaits wrote into `blocked:`, not any death of the feature's past.
|
|
598
|
+
const childDied = / child \S+ died/.test(String(m.item.data.blocked ?? ''));
|
|
599
|
+
const waits = m.item.data.type === 'feature' && childDied;
|
|
600
|
+
const data = {
|
|
601
|
+
...without(m.item.data, 'blocked'),
|
|
602
|
+
status: waits ? 'awaiting-decision' : 'ready',
|
|
603
|
+
since: waits ? headHash(projectDir) : null,
|
|
604
|
+
};
|
|
605
|
+
m.step(() => writeItem(m.item, data, body));
|
|
606
|
+
|
|
607
|
+
return { id, hash: await finish(m, 'unblock', id, commit) };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Any stored status → graveyard; an unplayed item leaves as a kill does.
|
|
611
|
+
* @param {MoveOpts} [opts]
|
|
612
|
+
*/
|
|
613
|
+
export async function dropItem(projectDir, id, opts = {}) {
|
|
614
|
+
const { reason, tags = [], remove, commit = true } = opts;
|
|
615
|
+
const m = await begin(projectDir, id, 'drop');
|
|
616
|
+
if (m.item.data.type === 'feature' && (m.item.data.children ?? []).some((c) => m.ledger.item(c)))
|
|
617
|
+
throw new Error(`${id} still has live children: drop them first, or decide kill ${id}`);
|
|
618
|
+
const shipped =
|
|
619
|
+
m.item.data.type === 'feature' ? featureShipped(m.ledger, m.item) : codeInMain(m.item);
|
|
620
|
+
const removal = endToGraveyard(m, m.item, { reason, tags, removal: remove ?? shipped });
|
|
621
|
+
|
|
622
|
+
return { id, hash: await finish(m, 'drop', id, commit), removal };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
|
|
626
|
+
function checkNew(ledger, spec) {
|
|
627
|
+
const { id, type, gate, severity } = spec;
|
|
628
|
+
if (!isValidId(id)) throw new Error(`${id} is not a lowercase hyphen slug`);
|
|
629
|
+
if (ledger.known(id)) throw new Error(`${id} is taken already: ids are never reused`);
|
|
630
|
+
if (!ITEM_TYPES.includes(type)) throw new Error(`type must be ${ITEM_TYPES.join(' | ')}`);
|
|
631
|
+
if (UNIT_ITEM_TYPES.includes(type))
|
|
632
|
+
throw new Error(`a ${type} is a design unit: write it in gcf/design/, then gcf take ${id}`);
|
|
633
|
+
const g = gate ?? DEFAULT_GATE[type];
|
|
634
|
+
if (!GATES.includes(g)) throw new Error(`gate must be ${GATES.join(' | ')}`);
|
|
635
|
+
if (type === 'feature' && g !== 'auto') throw new Error('a feature always has gate auto');
|
|
636
|
+
if (type === 'bug' && !SEVERITIES.includes(severity))
|
|
637
|
+
throw new Error(`a bug needs --severity ${SEVERITIES.join('|')}`);
|
|
638
|
+
|
|
639
|
+
return g;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Fields a new item carries only in some cases. */
|
|
643
|
+
// Types that change no capability of the game (Methodology §3.1): their delta is known upfront.
|
|
644
|
+
const NO_CAPABILITY = {
|
|
645
|
+
bug: 'a fix restores a capability, it adds none',
|
|
646
|
+
spike: 'a spike answers a question, its code never ships',
|
|
647
|
+
asset: 'an asset dresses a capability, it adds none',
|
|
648
|
+
perf: 'a budget met changes no capability',
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
/** The `live:` a new item starts with, or null when its author owes one. */
|
|
652
|
+
function startingLive({ parent, type }) {
|
|
653
|
+
// A child's capability is its feature's: the feature writes the one LIVE.md record.
|
|
654
|
+
if (parent) return { none: 'parent' };
|
|
655
|
+
|
|
656
|
+
return NO_CAPABILITY[type] ? { none: NO_CAPABILITY[type] } : null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function optionalFields(spec, gate, since) {
|
|
660
|
+
const { title, parent, blocked, type, severity, reopens } = spec;
|
|
661
|
+
const live = startingLive(spec);
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
...(title ? { title } : {}),
|
|
665
|
+
...(blocked ? { blocked, since } : {}),
|
|
666
|
+
...(parent ? { parent } : {}),
|
|
667
|
+
...(reopens ? { reopens } : {}),
|
|
668
|
+
...(gate === 'verdict' ? { tune_count: 0 } : {}),
|
|
669
|
+
...(type === 'bug' ? { severity } : {}),
|
|
670
|
+
...(live ? { live } : {}),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The frontmatter and body of a new item. */
|
|
675
|
+
function newItemText(spec, gate, since) {
|
|
676
|
+
const { id, type, title, blocked } = spec;
|
|
677
|
+
const data = {
|
|
678
|
+
schema: 3,
|
|
679
|
+
id,
|
|
680
|
+
type,
|
|
681
|
+
gate,
|
|
682
|
+
status: blocked ? 'blocked' : 'ready',
|
|
683
|
+
system: spec.system ?? [],
|
|
684
|
+
depends: spec.depends ?? [],
|
|
685
|
+
...optionalFields(spec, gate, since),
|
|
686
|
+
created: spec.date ?? today(),
|
|
687
|
+
};
|
|
688
|
+
const body =
|
|
689
|
+
`\nintent:\n- ${title ?? 'TBD: what this item changes'}\n` +
|
|
690
|
+
(type === 'bug' ? '\nrepro:\n- TBD: the steps that show the bug\n' : '');
|
|
691
|
+
|
|
692
|
+
return serialize(data, body);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
|
|
697
|
+
* not here: its body is the unit's slots. `from` erases the inbox entry it came from.
|
|
698
|
+
*/
|
|
699
|
+
export async function newItem(projectDir, spec) {
|
|
700
|
+
const root = gcfOf(projectDir);
|
|
701
|
+
const ledger = await buildLedger(projectDir);
|
|
702
|
+
const gate = checkNew(ledger, spec);
|
|
703
|
+
if (spec.reopens && !ledger.where(spec.reopens).terminal.length)
|
|
704
|
+
throw new Error(`reopens names ${spec.reopens}, which has not ended`);
|
|
705
|
+
const file = path.join(root, 'work', `${spec.id}.md`);
|
|
706
|
+
if (existsSync(file)) throw new Error(`gcf/work/${spec.id}.md already exists`);
|
|
707
|
+
|
|
708
|
+
const writes = [[file, newItemText(spec, gate, headHash(projectDir))]];
|
|
709
|
+
if (spec.parent) {
|
|
710
|
+
const p = ledger.item(spec.parent);
|
|
711
|
+
if (!p || p.data.type !== 'feature')
|
|
712
|
+
throw new Error(`${spec.parent} is not a live feature item`);
|
|
713
|
+
const children = [...new Set([...(p.data.children ?? []), spec.id])];
|
|
714
|
+
writes.push([p.file, serialize({ ...p.data, children }, p.body)]);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
await withFileRollback(
|
|
718
|
+
[...writes.map((w) => w[0]), path.join(root, 'inbox.md'), path.join(root, 'INDEX.md')],
|
|
719
|
+
async () => {
|
|
720
|
+
for (const [f, text] of writes) await atomicWrite(f, text);
|
|
721
|
+
if (spec.from) await dropInbox(root, spec.from);
|
|
722
|
+
await writeIndex(root);
|
|
723
|
+
},
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
return { item: `gcf/work/${spec.id}.md` };
|
|
727
|
+
}
|