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/transitions.js
CHANGED
|
@@ -1,28 +1,43 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
const STORED =
|
|
5
|
-
|
|
1
|
+
// Methodology §4. A work item FILE carries one of the stored statuses; `running` and `verify` are
|
|
2
|
+
// read from the run (worktree + session), never written. A terminal move deletes the file and
|
|
3
|
+
// writes a history or graveyard line in the same commit, so no terminal status is ever stored.
|
|
4
|
+
export const STORED = [
|
|
5
|
+
'ready',
|
|
6
|
+
'awaiting-verdict',
|
|
7
|
+
'awaiting-decision',
|
|
8
|
+
'blocked',
|
|
9
|
+
'parked',
|
|
10
|
+
'unplayed',
|
|
11
|
+
];
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'
|
|
13
|
-
'
|
|
14
|
-
]
|
|
13
|
+
// The status each command takes an item from (§4.1). `drop` leaves from any stored status.
|
|
14
|
+
const FROM = {
|
|
15
|
+
submit: ['ready'],
|
|
16
|
+
close: ['ready'],
|
|
17
|
+
'verdict:keep': ['awaiting-verdict', 'unplayed'],
|
|
18
|
+
'verdict:tune': ['awaiting-verdict', 'unplayed'],
|
|
19
|
+
'verdict:kill': ['awaiting-verdict', 'unplayed'],
|
|
20
|
+
'decide:go': ['awaiting-decision'],
|
|
21
|
+
'decide:no': ['awaiting-decision', 'ready'],
|
|
22
|
+
'decide:wontfix': ['ready', 'blocked', 'parked'],
|
|
23
|
+
'decide:known': ['ready', 'blocked'],
|
|
24
|
+
'decide:close': ['awaiting-decision'],
|
|
25
|
+
'decide:continue': ['awaiting-decision'],
|
|
26
|
+
'decide:kill': ['awaiting-decision', 'ready'],
|
|
27
|
+
block: ['ready'],
|
|
28
|
+
unblock: ['blocked', 'parked'],
|
|
29
|
+
drop: STORED,
|
|
30
|
+
};
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
/** True when `move` (`close`, `verdict:keep`, …) may start from `status`. */
|
|
33
|
+
export function allowedFrom(move, status) {
|
|
34
|
+
return (FROM[move] ?? []).includes(status);
|
|
18
35
|
}
|
|
19
36
|
|
|
20
|
-
/** Statuses
|
|
21
|
-
export
|
|
22
|
-
return [...LEGAL].filter((t) => t.startsWith(`${from}>`)).map((t) => t.split('>')[1]);
|
|
23
|
-
}
|
|
37
|
+
/** Statuses `move` starts from, for error messages. */
|
|
38
|
+
export const fromOf = (move) => FROM[move] ?? [];
|
|
24
39
|
|
|
25
|
-
/** A status
|
|
40
|
+
/** A status an item FILE may carry. */
|
|
26
41
|
export function isStorable(status) {
|
|
27
|
-
return STORED.
|
|
42
|
+
return STORED.includes(status);
|
|
28
43
|
}
|
package/lib/unit-ops.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// The moves a design unit makes out of design/ (Methodology §2): `take` into a work item, `done`
|
|
2
|
+
// straight into tests and code, `drop` into the graveyard. Each one removes the section and its
|
|
3
|
+
// backlog line in the same step, so an id never lives in two places (M12).
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { serialize } from './frontmatter.js';
|
|
9
|
+
import { CARD_TYPES, SLUG_RE, listOf } from './design.js';
|
|
10
|
+
import { buildLedger } from './ledger.js';
|
|
11
|
+
import { designCheck } from './design-check.js';
|
|
12
|
+
import { atomicWrite, withFileRollback } from './safe-path.js';
|
|
13
|
+
import { writeIndex } from './index-gen.js';
|
|
14
|
+
import { operationInProgress, commitPaths } from './git.js';
|
|
15
|
+
import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
16
|
+
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
|
+
import { DEFAULT_GATE } from './validate-item.js';
|
|
18
|
+
|
|
19
|
+
/** A design file's text without the `## <id>` section. */
|
|
20
|
+
export function removeUnit(text, id) {
|
|
21
|
+
// A file kept with CRLF stays CRLF: work in LF, write back in the file's own line ending.
|
|
22
|
+
const eol = String(text).match(/\r?\n/)?.[0] ?? '\n';
|
|
23
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
24
|
+
const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
|
|
25
|
+
if (at < 0) return text;
|
|
26
|
+
let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
|
|
27
|
+
if (end < 0) end = lines.length;
|
|
28
|
+
|
|
29
|
+
return `${[...lines.slice(0, at), ...lines.slice(end)]
|
|
30
|
+
.join('\n')
|
|
31
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
32
|
+
.trimEnd()}\n`.replace(/\n/g, eol);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** backlog.md without the line of `id`. */
|
|
36
|
+
export const backlogWithout = (text, id) =>
|
|
37
|
+
String(text)
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter((l) => l.match(/^- (\S+)/)?.[1] !== id)
|
|
40
|
+
.join('\n');
|
|
41
|
+
|
|
42
|
+
/** The single design unit `id`, or an error naming where the id lives instead. */
|
|
43
|
+
function designUnitOf(ledger, id) {
|
|
44
|
+
const { live, terminal } = ledger.where(id);
|
|
45
|
+
if (live.length !== 1 || live[0].state !== 'design') {
|
|
46
|
+
const at = live.map((p) => p.file).join(', ') || terminal.join(', ') || 'nowhere';
|
|
47
|
+
throw new Error(`${id} is not a single unit of gcf/design/ (it is: ${at})`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return /** @type {import('./design.js').Unit} */ (
|
|
51
|
+
ledger.design.flatMap((d) => d.units).find((u) => u.id === id)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const designUnit = async (projectDir, id) => designUnitOf(await buildLedger(projectDir), id);
|
|
56
|
+
|
|
57
|
+
/** Files a move rewrites: the design file, the backlog, then whatever the move adds. */
|
|
58
|
+
function basePaths(root, unit) {
|
|
59
|
+
return { design: path.join(root, unit.file), backlog: path.join(root, 'backlog.md') };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function cutUnit(paths, id) {
|
|
63
|
+
await atomicWrite(paths.design, removeUnit(await readFile(paths.design, 'utf8'), id));
|
|
64
|
+
if (existsSync(paths.backlog))
|
|
65
|
+
await atomicWrite(paths.backlog, backlogWithout(await readFile(paths.backlog, 'utf8'), id));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The frontmatter a work item starts with: the unit's header plus the fields of work. */
|
|
69
|
+
function unitItemData(unit, today, parent) {
|
|
70
|
+
const h = unit.header;
|
|
71
|
+
const pillars = listOf(h.pillars);
|
|
72
|
+
const gate = DEFAULT_GATE[h.type];
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
schema: 3,
|
|
76
|
+
id: unit.id,
|
|
77
|
+
type: h.type,
|
|
78
|
+
gate,
|
|
79
|
+
...(h.title ? { title: h.title } : {}),
|
|
80
|
+
status: 'ready',
|
|
81
|
+
...(parent ? { parent } : {}),
|
|
82
|
+
system: listOf(h.system),
|
|
83
|
+
depends: listOf(h.depends),
|
|
84
|
+
pillars: pillars === 'TBD' ? [] : pillars,
|
|
85
|
+
source: h.source,
|
|
86
|
+
...(h.reopens ? { reopens: h.reopens } : {}),
|
|
87
|
+
...(SLUG_RE.test(h.milestone ?? '') && h.milestone !== 'later'
|
|
88
|
+
? { milestone: h.milestone }
|
|
89
|
+
: {}),
|
|
90
|
+
...(gate === 'verdict' ? { tune_count: 0 } : {}),
|
|
91
|
+
// A child's capability is its feature's: the feature writes the one LIVE.md record.
|
|
92
|
+
...(parent ? { live: { none: 'parent' } } : {}),
|
|
93
|
+
created: today,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The feature `id` to group the taken units under: the live one, or a new one. */
|
|
98
|
+
function featureFor(ledger, root, { id, unitIds, systems, today }) {
|
|
99
|
+
const file = path.join(root, 'work', `${id}.md`);
|
|
100
|
+
const live = ledger.item(id);
|
|
101
|
+
if (live) {
|
|
102
|
+
if (live.data.type !== 'feature')
|
|
103
|
+
throw new Error(`${id} is a ${live.data.type}, not a feature`);
|
|
104
|
+
const children = [...new Set([...(live.data.children ?? []), ...unitIds])];
|
|
105
|
+
|
|
106
|
+
return { file, text: serialize({ ...live.data, children }, live.body) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (ledger.known(id)) throw new Error(`${id} is taken already: ids are never reused`);
|
|
110
|
+
const data = {
|
|
111
|
+
schema: 3,
|
|
112
|
+
id,
|
|
113
|
+
type: 'feature',
|
|
114
|
+
gate: 'auto',
|
|
115
|
+
status: 'ready',
|
|
116
|
+
children: unitIds,
|
|
117
|
+
system: [...new Set(systems)],
|
|
118
|
+
depends: [],
|
|
119
|
+
created: today,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return { file, text: serialize(data, `\nintent:\n- TBD: what ${id} gives the player\n`) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A unit goes into work only ready: no TBD, no blocking D1, no blocking finding. */
|
|
126
|
+
function refuseUnready(units, summaries) {
|
|
127
|
+
for (const unit of units) {
|
|
128
|
+
const summary = summaries.find((u) => u.id === unit.id);
|
|
129
|
+
if (summary?.ready) continue;
|
|
130
|
+
const why = summary?.blocked_by.length
|
|
131
|
+
? `blocked by ${summary.blocked_by.join(', ')}`
|
|
132
|
+
: `${summary?.tbd ?? '?'} TBD or a blocking design-check finding`;
|
|
133
|
+
throw new Error(`${unit.id} is not ready (${why}): answer it in design/ first`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Take ready design units into work: each section becomes gcf/work/<id>.md with the same body.
|
|
139
|
+
* One unit stands alone; `feature` groups them under a feature item, new or live. No commit —
|
|
140
|
+
* `/gcf-card` adds `contract` and `interactions`, and the pre-commit gate holds the item until
|
|
141
|
+
* it carries both.
|
|
142
|
+
*/
|
|
143
|
+
export async function takeUnits(
|
|
144
|
+
projectDir,
|
|
145
|
+
ids,
|
|
146
|
+
{ feature = null, today = new Date().toISOString().slice(0, 10) } = {},
|
|
147
|
+
) {
|
|
148
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
149
|
+
const ledger = await buildLedger(projectDir);
|
|
150
|
+
const units = ids.map((id) => designUnitOf(ledger, id));
|
|
151
|
+
refuseUnready(units, (await designCheck(projectDir)).units);
|
|
152
|
+
const writes = units.map((unit) => ({
|
|
153
|
+
file: path.join(root, 'work', `${unit.id}.md`),
|
|
154
|
+
text: serialize(unitItemData(unit, today, feature), `\n${unit.body.trim()}\n`),
|
|
155
|
+
}));
|
|
156
|
+
for (const w of writes)
|
|
157
|
+
if (existsSync(w.file)) throw new Error(`gcf/work/${path.basename(w.file)} already exists`);
|
|
158
|
+
if (feature) {
|
|
159
|
+
const systems = units.flatMap((u) => listOf(u.header.system)).filter((s) => s !== 'TBD');
|
|
160
|
+
writes.push(featureFor(ledger, root, { id: feature, unitIds: ids, systems, today }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const paths = units.map((u) => basePaths(root, u));
|
|
164
|
+
const touched = [
|
|
165
|
+
...writes.map((w) => w.file),
|
|
166
|
+
...paths.flatMap((p) => [p.design, p.backlog]),
|
|
167
|
+
path.join(root, 'INDEX.md'),
|
|
168
|
+
];
|
|
169
|
+
await withFileRollback(touched, async () => {
|
|
170
|
+
for (const w of writes) await atomicWrite(w.file, w.text);
|
|
171
|
+
for (const [i, unit] of units.entries()) await cutUnit(paths[i], unit.id);
|
|
172
|
+
await writeIndex(root);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return { items: writes.map((w) => `gcf/work/${path.basename(w.file)}`) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A file's text at a git revision spec (`HEAD:./x`, `:./x` for the index), '' when absent. */
|
|
179
|
+
export function gitText(projectDir, spec) {
|
|
180
|
+
const res = spawnSync('git', ['show', spec], { cwd: projectDir, encoding: 'utf8' });
|
|
181
|
+
|
|
182
|
+
return res.status === 0 ? res.stdout : '';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* True when `file` differs from HEAD in the index or on disk. Line endings do not count: a file
|
|
187
|
+
* stored with CRLF and checked out with LF is not an edit.
|
|
188
|
+
*/
|
|
189
|
+
function uncommitted(projectDir, file) {
|
|
190
|
+
const rel = `./${path.relative(projectDir, file).replaceAll('\\', '/')}`;
|
|
191
|
+
const norm = (t) => String(t).replace(/\r\n/g, '\n').trimEnd();
|
|
192
|
+
const head = norm(gitText(projectDir, `HEAD:${rel}`));
|
|
193
|
+
const disk = norm(existsSync(file) ? readFileSync(file, 'utf8') : '');
|
|
194
|
+
|
|
195
|
+
return head !== norm(gitText(projectDir, `:${rel}`)) || head !== disk;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Run a move that ends with its fixed-format commit, all of it or none of it. The commit takes
|
|
200
|
+
* whole files and deletes the unit's section, so any uncommitted edit in them refuses the move
|
|
201
|
+
* up front — an answer in the unit itself included: it would leave in a commit that never held
|
|
202
|
+
* it, and no history would keep it. `inputs` are the item files the move reads and rewrites:
|
|
203
|
+
* their own uncommitted edits (an answer, a live: delta) ride along on purpose.
|
|
204
|
+
* @param {string} projectDir
|
|
205
|
+
* @param {string} message
|
|
206
|
+
* @param {{files: string[], mutate: () => Promise<void>, commit: boolean, inputs?: string[]}} move
|
|
207
|
+
*/
|
|
208
|
+
export async function commitMove(projectDir, message, { files, mutate, commit, inputs = [] }) {
|
|
209
|
+
const busy = commit ? operationInProgress(projectDir) : null;
|
|
210
|
+
if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
|
|
211
|
+
const dirty = commit
|
|
212
|
+
? files.filter((f) => !inputs.includes(f) && uncommitted(projectDir, f))
|
|
213
|
+
: [];
|
|
214
|
+
if (dirty.length) {
|
|
215
|
+
const names = dirty.map((f) => path.relative(projectDir, f)).join(', ');
|
|
216
|
+
throw new Error(
|
|
217
|
+
`commit or stash these first (an answered unit is committed before its move), ` +
|
|
218
|
+
`${message} would carry them: ${names}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
223
|
+
const touched = [...files, path.join(root, 'INDEX.md')];
|
|
224
|
+
/** @type {string | null} */
|
|
225
|
+
let hash = null;
|
|
226
|
+
await withFileRollback(
|
|
227
|
+
touched,
|
|
228
|
+
async () => {
|
|
229
|
+
await mutate();
|
|
230
|
+
await writeIndex(root);
|
|
231
|
+
if (commit) hash = commitPaths(projectDir, message, touched);
|
|
232
|
+
},
|
|
233
|
+
{ gitCwd: commit ? path.resolve(projectDir) : null },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return hash;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A hard unit is built and its tests are green: it leaves design/ without a work item. A line
|
|
241
|
+
* every future reader of the subsystem must remember goes to the systems doc's `constraints:`.
|
|
242
|
+
*/
|
|
243
|
+
export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
|
|
244
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
245
|
+
const unit = await designUnit(projectDir, id);
|
|
246
|
+
if (CARD_TYPES.includes(unit.header.type))
|
|
247
|
+
throw new Error(`${id} is soft: it leaves design/ through a work item and a verdict`);
|
|
248
|
+
const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
|
|
249
|
+
if (summary?.tbd)
|
|
250
|
+
throw new Error(`${id} still carries ${summary.tbd} TBD: it was built on a hole — answer it`);
|
|
251
|
+
const paths = basePaths(root, unit);
|
|
252
|
+
const doc = constraints.length ? await openSystemDoc(root, unit.system) : null;
|
|
253
|
+
const files = [paths.design, paths.backlog, path.join(root, 'history.md')];
|
|
254
|
+
if (doc) files.push(doc.file);
|
|
255
|
+
const mutate = async () => {
|
|
256
|
+
await cutUnit(paths, id);
|
|
257
|
+
await recordLine(root, id, 'done');
|
|
258
|
+
if (doc) await addConstraints(doc, id, constraints);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
id,
|
|
263
|
+
hash: await commitMove(projectDir, `gcf(done): ${id}`, { files, mutate, commit }),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Lines every reader of a subsystem must remember, into its systems doc's `constraints:`. */
|
|
268
|
+
export async function addConstraints(doc, id, constraints) {
|
|
269
|
+
const body = upsertBlock(
|
|
270
|
+
doc.body,
|
|
271
|
+
'constraints',
|
|
272
|
+
constraints.map((c) => `- ${c}`),
|
|
273
|
+
);
|
|
274
|
+
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9). */
|
|
278
|
+
export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
|
|
279
|
+
if (!String(reason ?? '').trim())
|
|
280
|
+
throw new Error('a drop needs a reason: the graveyard keeps it');
|
|
281
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
282
|
+
const unit = await designUnit(projectDir, id);
|
|
283
|
+
const paths = basePaths(root, unit);
|
|
284
|
+
const system = listOf(unit.header.system);
|
|
285
|
+
const files = [paths.design, paths.backlog, path.join(root, 'graveyard.md')];
|
|
286
|
+
const type = unit.header.type ?? 'mechanic';
|
|
287
|
+
const mutate = async () => {
|
|
288
|
+
const lineTags = tags.length ? tags : system === 'TBD' ? [] : system;
|
|
289
|
+
await buryLine(root, id, { type, reason, tags: lineTags });
|
|
290
|
+
await cutUnit(paths, id);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
id,
|
|
295
|
+
hash: await commitMove(projectDir, `gcf(drop): ${id}`, { files, mutate, commit }),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Runtime mirror of schemas/item.schema.json for the pre-commit hook (no ajv in production
|
|
2
|
+
// deps). `validateFields` is the per-field part the parity test holds against the schema; the
|
|
3
|
+
// cross-field rules (which status goes with which gate or type) live only here.
|
|
4
|
+
import { STORED } from './transitions.js';
|
|
5
|
+
|
|
6
|
+
export const ITEM_TYPES = [
|
|
7
|
+
'feature',
|
|
8
|
+
'mechanic',
|
|
9
|
+
'resource',
|
|
10
|
+
'rule',
|
|
11
|
+
'constraint',
|
|
12
|
+
'entity',
|
|
13
|
+
'tech',
|
|
14
|
+
'bug',
|
|
15
|
+
'spike',
|
|
16
|
+
'asset',
|
|
17
|
+
'content',
|
|
18
|
+
'perf',
|
|
19
|
+
'release',
|
|
20
|
+
];
|
|
21
|
+
// A work item that carries a design unit's body: checked against the unit's profile.
|
|
22
|
+
export const UNIT_ITEM_TYPES = ['mechanic', 'resource', 'rule', 'constraint', 'entity'];
|
|
23
|
+
// Methodology §3.1: who closes an item by default; the operator flips it per item.
|
|
24
|
+
export const DEFAULT_GATE = {
|
|
25
|
+
feature: 'auto',
|
|
26
|
+
mechanic: 'verdict',
|
|
27
|
+
resource: 'verdict',
|
|
28
|
+
rule: 'auto',
|
|
29
|
+
constraint: 'auto',
|
|
30
|
+
entity: 'auto',
|
|
31
|
+
tech: 'auto',
|
|
32
|
+
bug: 'auto',
|
|
33
|
+
spike: 'decision',
|
|
34
|
+
asset: 'verdict',
|
|
35
|
+
content: 'auto',
|
|
36
|
+
perf: 'auto',
|
|
37
|
+
release: 'decision',
|
|
38
|
+
};
|
|
39
|
+
export const GATES = ['verdict', 'auto', 'decision'];
|
|
40
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
41
|
+
const CHANGES = ['added', 'modified', 'removed'];
|
|
42
|
+
|
|
43
|
+
const FIELDS = [
|
|
44
|
+
'schema',
|
|
45
|
+
'id',
|
|
46
|
+
'type',
|
|
47
|
+
'gate',
|
|
48
|
+
'title',
|
|
49
|
+
'status',
|
|
50
|
+
'parent',
|
|
51
|
+
'children',
|
|
52
|
+
'system',
|
|
53
|
+
'depends',
|
|
54
|
+
'pillars',
|
|
55
|
+
'source',
|
|
56
|
+
'reopens',
|
|
57
|
+
'milestone',
|
|
58
|
+
'parallel',
|
|
59
|
+
'plan',
|
|
60
|
+
'tune_count',
|
|
61
|
+
'severity',
|
|
62
|
+
'blocked',
|
|
63
|
+
'since',
|
|
64
|
+
'frozen',
|
|
65
|
+
'live',
|
|
66
|
+
'model',
|
|
67
|
+
'effort',
|
|
68
|
+
'created',
|
|
69
|
+
];
|
|
70
|
+
const REQUIRED = ['schema', 'id', 'type', 'gate', 'status', 'created'];
|
|
71
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
72
|
+
// YAML reads `id: 2024` as a number; RegExp.test would pass it and every string lookup miss it.
|
|
73
|
+
const isSlug = (v) => typeof v === 'string' && SLUG.test(v);
|
|
74
|
+
const slugList = (v) => Array.isArray(v) && v.every(isSlug);
|
|
75
|
+
const isText = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
76
|
+
const nullable = (check) => (v) => v === null || v === undefined || check(v);
|
|
77
|
+
const LIVE_SHAPE = 'live must be {none: <why>} or {changes: [...]} with at least one change';
|
|
78
|
+
|
|
79
|
+
/** At most two sentences and 250 characters: a table of contents, not a description. */
|
|
80
|
+
export function summaryProblem(s) {
|
|
81
|
+
if (!isText(s)) return 'summary must be text';
|
|
82
|
+
if (s.length > 250) return `summary is ${s.length} characters, at most 250`;
|
|
83
|
+
if ((s.match(/[.!?](\s|$)/g) ?? []).length > 2) return 'summary is more than two sentences';
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One `changes:` entry of a live: delta, or null when it is well formed. */
|
|
89
|
+
function changeProblem(c) {
|
|
90
|
+
if (typeof c !== 'object' || c === null) return 'each live change is {id, change, summary}';
|
|
91
|
+
const extra = Object.keys(c).find((k) => !['id', 'change', 'summary', 'refs'].includes(k));
|
|
92
|
+
if (extra) return `unknown live change field: ${extra}`;
|
|
93
|
+
if (!isSlug(c.id)) return 'a live change id must be a lowercase hyphen slug';
|
|
94
|
+
if (!CHANGES.includes(c.change)) return `live change must be ${CHANGES.join(' | ')}`;
|
|
95
|
+
if (c.change !== 'removed' && !isText(c.summary)) return `live change ${c.id} needs a summary`;
|
|
96
|
+
const bad = c.summary === undefined ? null : summaryProblem(c.summary);
|
|
97
|
+
if (bad) return `live change ${c.id}: ${bad}`;
|
|
98
|
+
|
|
99
|
+
return c.refs === undefined || isText(c.refs) ? null : `live change ${c.id}: refs must be text`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A live: delta (Methodology §8): `{none: <why>}` or `{changes: [{id, change, summary}]}`. */
|
|
103
|
+
export function liveProblem(v) {
|
|
104
|
+
if (v === null || v === undefined) return null;
|
|
105
|
+
if (typeof v !== 'object' || Array.isArray(v)) return LIVE_SHAPE;
|
|
106
|
+
const [key, ...more] = Object.keys(v);
|
|
107
|
+
if (more.length) return LIVE_SHAPE;
|
|
108
|
+
if (key === 'none')
|
|
109
|
+
return isText(v.none) ? null : 'live.none must say why the item changes no capability';
|
|
110
|
+
const changes = key === 'changes' ? v.changes : null;
|
|
111
|
+
if (!Array.isArray(changes) || !changes.length) return LIVE_SHAPE;
|
|
112
|
+
|
|
113
|
+
return changes.map(changeProblem).find(Boolean) ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hash = (min, max) => (x) =>
|
|
117
|
+
typeof x === 'string' && new RegExp(`^[0-9a-f]{${min},${max}}$`).test(x);
|
|
118
|
+
|
|
119
|
+
const RULES = {
|
|
120
|
+
schema: (v) =>
|
|
121
|
+
v === 3 ||
|
|
122
|
+
(v === 2 || v === 1
|
|
123
|
+
? `schema ${v} card: GCF 0.4 keeps work items in gcf/work/<id>.md with schema: 3, ` +
|
|
124
|
+
'type and gate — there is no migration: put a card back into gcf/design/ as a unit ' +
|
|
125
|
+
'and run ai-factory gcf take'
|
|
126
|
+
: 'schema must be 3'),
|
|
127
|
+
id: (v) =>
|
|
128
|
+
isSlug(v) ||
|
|
129
|
+
(typeof v === 'number'
|
|
130
|
+
? 'id must be a string: YAML read it as a number, put the slug in quotes'
|
|
131
|
+
: 'id must be a lowercase hyphen slug'),
|
|
132
|
+
type: (v) => ITEM_TYPES.includes(v) || `type must be ${ITEM_TYPES.join(' | ')}`,
|
|
133
|
+
gate: (v) => GATES.includes(v) || `gate must be ${GATES.join(' | ')}`,
|
|
134
|
+
title: (v) => isText(v) || 'title must be a non-empty string',
|
|
135
|
+
status: (v) =>
|
|
136
|
+
STORED.includes(v) ||
|
|
137
|
+
`status "${v}" is not storable (${STORED.join(', ')}; a terminal move deletes the file)`,
|
|
138
|
+
parent: (v) => nullable(isSlug)(v) || 'parent must be an item id or null',
|
|
139
|
+
children: (v) => slugList(v) || 'children must list item ids',
|
|
140
|
+
system: (v) => slugList(v) || 'system must list subsystem slugs',
|
|
141
|
+
depends: (v) => slugList(v) || 'depends must list ids',
|
|
142
|
+
pillars: (v) => slugList(v) || 'pillars must list pillar ids',
|
|
143
|
+
source: (v) => /^(GDD#[A-Za-z0-9_-]+|manual)$/.test(v) || 'source must be GDD#<id> or manual',
|
|
144
|
+
reopens: (v) => nullable(isSlug)(v) || 'reopens must name the id it reworks, or null',
|
|
145
|
+
milestone: (v) => nullable(isSlug)(v) || 'milestone must be a milestone name or null',
|
|
146
|
+
parallel: (v) => ['ok', 'serial'].includes(v) || 'parallel must be ok or serial',
|
|
147
|
+
plan: (v) => nullable(isText)(v) || 'plan must be a path or null',
|
|
148
|
+
tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
|
|
149
|
+
severity: (v) =>
|
|
150
|
+
nullable((x) => SEVERITIES.includes(x))(v) || `severity must be ${SEVERITIES.join(' | ')}`,
|
|
151
|
+
blocked: (v) => nullable(isText)(v) || 'blocked must be text or null',
|
|
152
|
+
since: (v) => nullable(hash(7, 64))(v) || 'since must be null or a commit hash',
|
|
153
|
+
frozen: (v) => hash(40, 40)(v) || 'frozen must be the sha1 of the body',
|
|
154
|
+
live: (v) => liveProblem(v) ?? true,
|
|
155
|
+
model: (v) => nullable(isText)(v) || 'model must be text or null',
|
|
156
|
+
effort: (v) => nullable(isText)(v) || 'effort must be text or null',
|
|
157
|
+
created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const dateStr = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(v));
|
|
161
|
+
|
|
162
|
+
/** Per-field errors only — what item.schema.json also decides. */
|
|
163
|
+
export function validateFields(data) {
|
|
164
|
+
if (data.schema !== undefined && data.schema !== 3) return [RULES.schema(data.schema)];
|
|
165
|
+
const errors = [];
|
|
166
|
+
for (const key of REQUIRED) if (!(key in data)) errors.push(`missing field: ${key}`);
|
|
167
|
+
for (const key of Object.keys(data)) {
|
|
168
|
+
if (!FIELDS.includes(key))
|
|
169
|
+
errors.push(
|
|
170
|
+
`unknown field: ${key}` +
|
|
171
|
+
(key === 'until' ? ' (until: is a line annotation, not frontmatter)' : '') +
|
|
172
|
+
(key === 'built_at' ? ' (schema 3 records since: when the item waits for you)' : ''),
|
|
173
|
+
);
|
|
174
|
+
else {
|
|
175
|
+
const res = RULES[key](data[key]);
|
|
176
|
+
if (res !== true) errors.push(res);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return errors;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isUnit = (d) => UNIT_ITEM_TYPES.includes(d.type);
|
|
184
|
+
|
|
185
|
+
// Which status, gate and type go together (Methodology §4): [broken when, message].
|
|
186
|
+
/** @type {Array<[(d: any) => boolean, string]>} */
|
|
187
|
+
const CROSS = [
|
|
188
|
+
[
|
|
189
|
+
(d) => d.status === 'awaiting-verdict' && d.gate !== 'verdict',
|
|
190
|
+
'awaiting-verdict needs gate: verdict',
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
(d) => d.status === 'awaiting-decision' && d.gate !== 'decision' && d.type !== 'feature',
|
|
194
|
+
'awaiting-decision needs gate: decision (or a feature whose child died)',
|
|
195
|
+
],
|
|
196
|
+
[(d) => d.type === 'feature' && d.gate !== 'auto', 'a feature always has gate: auto'],
|
|
197
|
+
[(d) => d.status === 'parked' && d.type !== 'bug', 'only a bug is parked'],
|
|
198
|
+
[
|
|
199
|
+
(d) => d.status === 'unplayed' && !['mechanic', 'resource'].includes(d.type),
|
|
200
|
+
'only a mechanic or a resource is unplayed',
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
(d) => (d.status === 'unplayed') !== (d.frozen !== undefined),
|
|
204
|
+
'frozen: belongs to an unplayed item',
|
|
205
|
+
],
|
|
206
|
+
[(d) => d.status === 'blocked' && !isText(d.blocked), 'a blocked item says why in blocked:'],
|
|
207
|
+
[(d) => d.status !== 'blocked' && isText(d.blocked), 'blocked: is set but status is not blocked'],
|
|
208
|
+
[(d) => d.type === 'bug' && !SEVERITIES.includes(d.severity), 'a bug needs severity'],
|
|
209
|
+
[(d) => d.type !== 'bug' && Boolean(d.severity), 'severity belongs to a bug'],
|
|
210
|
+
[(d) => d.children !== undefined && d.type !== 'feature', 'only a feature has children'],
|
|
211
|
+
[
|
|
212
|
+
(d) => isUnit(d) && !(Array.isArray(d.system) && d.system.length),
|
|
213
|
+
'a design-unit item lists >=1 subsystem in system:',
|
|
214
|
+
],
|
|
215
|
+
[(d) => isUnit(d) && d.source === undefined, 'a design-unit item keeps its source:'],
|
|
216
|
+
[(d) => Boolean(d.parent) && d.parent === d.id, 'an item cannot be its own parent'],
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/** Validate work item frontmatter. Returns a list of one-line errors (empty = valid). */
|
|
220
|
+
export function validateItem(data) {
|
|
221
|
+
const fields = validateFields(data);
|
|
222
|
+
if (fields.length) return fields;
|
|
223
|
+
|
|
224
|
+
return CROSS.filter(([broken]) => broken(data)).map(([, message]) => message);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const UNTIL_OK = /^\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]$/;
|
|
228
|
+
|
|
229
|
+
/** Validate every `[until: ...]` annotation in a body against the trigger grammar (§7.3). */
|
|
230
|
+
export function validateUntil(body) {
|
|
231
|
+
const errors = [];
|
|
232
|
+
const lines = String(body).split('\n');
|
|
233
|
+
lines.forEach((line, i) => {
|
|
234
|
+
for (const m of line.matchAll(/\[until:[^\]]*\]/g)) {
|
|
235
|
+
if (!UNTIL_OK.test(m[0]))
|
|
236
|
+
errors.push(
|
|
237
|
+
`line ${i + 1}: bad until annotation ${m[0]} ` +
|
|
238
|
+
'(use verdict(<id>), retune(system) or manual(text))',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return errors;
|
|
244
|
+
}
|