gcf-aif-ext 0.3.0 → 0.4.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 +63 -11
- package/commands/gcf.js +321 -43
- package/extension.json +22 -5
- package/hooks/pre-commit.js +60 -31
- package/injections/commit-status.md +7 -5
- package/injections/plan-context.md +8 -5
- package/injections/review-pillars.md +4 -3
- package/lib/agents-block.js +66 -0
- package/lib/anchors.js +27 -4
- package/lib/audit-core.js +135 -48
- package/lib/bundle.js +7 -4
- package/lib/context.js +33 -25
- package/lib/design-check.js +149 -40
- package/lib/design.js +2 -2
- package/lib/gcf-paths.js +13 -2
- package/lib/id.js +2 -2
- package/lib/inbox.js +54 -0
- package/lib/index-gen.js +23 -8
- package/lib/ledger.js +19 -17
- package/lib/linter.js +2 -1
- package/lib/live.js +191 -0
- package/lib/pillars.js +113 -0
- package/lib/plan-check.js +31 -0
- package/lib/playtest-list.js +8 -4
- package/lib/systems-doc.js +16 -1
- package/lib/transitions.js +36 -21
- package/lib/unit-ops.js +125 -97
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +67 -128
- package/lib/work-ops.js +759 -0
- package/package.json +4 -3
- package/profiles/pillars.md +1 -1
- package/profiles/system.md +1 -1
- package/profiles/unit-constraint.md +10 -0
- package/profiles/unit-entity.md +10 -0
- package/profiles/unit-mechanic.md +6 -1
- package/profiles/unit-resource.md +6 -1
- package/profiles/unit-rule.md +10 -0
- package/schemas/item.schema.json +105 -0
- package/skills/gcf-adopt/SKILL.md +2 -2
- package/skills/gcf-audit/SKILL.md +3 -3
- package/skills/gcf-card/SKILL.md +27 -20
- package/skills/gcf-ingest/SKILL.md +3 -3
- package/skills/gcf-init/SKILL.md +8 -5
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -16
- package/templates/agents-block.md +28 -0
- package/templates/config.yml +7 -0
- package/templates/{card.md → item.md} +10 -3
- package/lib/validate-card.js +0 -93
- package/schemas/card.schema.json +0 -46
package/lib/unit-ops.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// The moves a design unit makes out of design/ (
|
|
2
|
-
// tests and code, `drop` into the graveyard. Each one removes the section and its
|
|
3
|
-
// in the same step, so an id never lives in two places (M12).
|
|
4
|
-
import { readFile
|
|
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
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { serialize } from './frontmatter.js';
|
|
9
|
-
import { CARD_TYPES, listOf } from './design.js';
|
|
9
|
+
import { CARD_TYPES, SLUG_RE, listOf } from './design.js';
|
|
10
10
|
import { buildLedger } from './ledger.js';
|
|
11
11
|
import { designCheck } from './design-check.js';
|
|
12
12
|
import { atomicWrite, withFileRollback } from './safe-path.js';
|
|
@@ -14,10 +14,13 @@ import { writeIndex } from './index-gen.js';
|
|
|
14
14
|
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
|
+
import { DEFAULT_GATE } from './validate-item.js';
|
|
17
18
|
|
|
18
19
|
/** A design file's text without the `## <id>` section. */
|
|
19
20
|
export function removeUnit(text, id) {
|
|
20
|
-
|
|
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');
|
|
21
24
|
const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
|
|
22
25
|
if (at < 0) return text;
|
|
23
26
|
let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
|
|
@@ -26,7 +29,7 @@ export function removeUnit(text, id) {
|
|
|
26
29
|
return `${[...lines.slice(0, at), ...lines.slice(end)]
|
|
27
30
|
.join('\n')
|
|
28
31
|
.replace(/\n{3,}/g, '\n\n')
|
|
29
|
-
.trimEnd()}\n
|
|
32
|
+
.trimEnd()}\n`.replace(/\n/g, eol);
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/** backlog.md without the line of `id`. */
|
|
@@ -37,19 +40,20 @@ export const backlogWithout = (text, id) =>
|
|
|
37
40
|
.join('\n');
|
|
38
41
|
|
|
39
42
|
/** The single design unit `id`, or an error naming where the id lives instead. */
|
|
40
|
-
|
|
41
|
-
const ledger = await buildLedger(projectDir);
|
|
43
|
+
function designUnitOf(ledger, id) {
|
|
42
44
|
const { live, terminal } = ledger.where(id);
|
|
43
45
|
if (live.length !== 1 || live[0].state !== 'design') {
|
|
44
46
|
const at = live.map((p) => p.file).join(', ') || terminal.join(', ') || 'nowhere';
|
|
45
47
|
throw new Error(`${id} is not a single unit of gcf/design/ (it is: ${at})`);
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
return /** @type {import('./design.js').Unit} */ (
|
|
51
|
+
ledger.design.flatMap((d) => d.units).find((u) => u.id === id)
|
|
52
|
+
);
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
const designUnit = async (projectDir, id) => designUnitOf(await buildLedger(projectDir), id);
|
|
56
|
+
|
|
53
57
|
/** Files a move rewrites: the design file, the backlog, then whatever the move adds. */
|
|
54
58
|
function basePaths(root, unit) {
|
|
55
59
|
return { design: path.join(root, unit.file), backlog: path.join(root, 'backlog.md') };
|
|
@@ -61,67 +65,119 @@ async function cutUnit(paths, id) {
|
|
|
61
65
|
await atomicWrite(paths.backlog, backlogWithout(await readFile(paths.backlog, 'utf8'), id));
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
/** The frontmatter a
|
|
65
|
-
function
|
|
68
|
+
/** The frontmatter a work item starts with: the unit's header plus the fields of work. */
|
|
69
|
+
function unitItemData(unit, today, parent) {
|
|
66
70
|
const h = unit.header;
|
|
67
71
|
const pillars = listOf(h.pillars);
|
|
72
|
+
const gate = DEFAULT_GATE[h.type];
|
|
68
73
|
|
|
69
74
|
return {
|
|
70
|
-
schema:
|
|
75
|
+
schema: 3,
|
|
71
76
|
id: unit.id,
|
|
72
77
|
type: h.type,
|
|
78
|
+
gate,
|
|
73
79
|
...(h.title ? { title: h.title } : {}),
|
|
74
|
-
status: '
|
|
80
|
+
status: 'ready',
|
|
81
|
+
...(parent ? { parent } : {}),
|
|
75
82
|
system: listOf(h.system),
|
|
76
83
|
depends: listOf(h.depends),
|
|
77
84
|
pillars: pillars === 'TBD' ? [] : pillars,
|
|
78
85
|
source: h.source,
|
|
79
86
|
...(h.reopens ? { reopens: h.reopens } : {}),
|
|
80
|
-
|
|
81
|
-
|
|
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' } } : {}),
|
|
82
93
|
created: today,
|
|
83
94
|
};
|
|
84
95
|
}
|
|
85
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${summary?.pillars_owed ? ', pillars among them' : ''} ` +
|
|
133
|
+
'or a blocking design-check finding';
|
|
134
|
+
throw new Error(`${unit.id} is not ready (${why}): answer it in design/ first`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
/**
|
|
87
|
-
* Take
|
|
88
|
-
*
|
|
89
|
-
* the
|
|
139
|
+
* Take ready design units into work: each section becomes gcf/work/<id>.md with the same body.
|
|
140
|
+
* One unit stands alone; `feature` groups them under a feature item, new or live. No commit —
|
|
141
|
+
* `/gcf-card` adds `contract` and `interactions`, and the pre-commit gate holds the item until
|
|
142
|
+
* it carries both.
|
|
90
143
|
*/
|
|
91
|
-
export async function
|
|
144
|
+
export async function takeUnits(
|
|
92
145
|
projectDir,
|
|
93
|
-
|
|
94
|
-
{ today = new Date().toISOString().slice(0, 10) } = {},
|
|
146
|
+
ids,
|
|
147
|
+
{ feature = null, today = new Date().toISOString().slice(0, 10) } = {},
|
|
95
148
|
) {
|
|
96
149
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
throw new Error(`${id} is not ready (${why}): answer it in design/ first`);
|
|
150
|
+
const ledger = await buildLedger(projectDir);
|
|
151
|
+
const units = ids.map((id) => designUnitOf(ledger, id));
|
|
152
|
+
refuseUnready(units, (await designCheck(projectDir)).units);
|
|
153
|
+
const writes = units.map((unit) => ({
|
|
154
|
+
file: path.join(root, 'work', `${unit.id}.md`),
|
|
155
|
+
text: serialize(unitItemData(unit, today, feature), `\n${unit.body.trim()}\n`),
|
|
156
|
+
}));
|
|
157
|
+
for (const w of writes)
|
|
158
|
+
if (existsSync(w.file)) throw new Error(`gcf/work/${path.basename(w.file)} already exists`);
|
|
159
|
+
if (feature) {
|
|
160
|
+
const systems = units.flatMap((u) => listOf(u.header.system)).filter((s) => s !== 'TBD');
|
|
161
|
+
writes.push(featureFor(ledger, root, { id: feature, unitIds: ids, systems, today }));
|
|
110
162
|
}
|
|
111
163
|
|
|
112
|
-
const paths =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
164
|
+
const paths = units.map((u) => basePaths(root, u));
|
|
165
|
+
const touched = [
|
|
166
|
+
...writes.map((w) => w.file),
|
|
167
|
+
...paths.flatMap((p) => [p.design, p.backlog]),
|
|
168
|
+
path.join(root, 'INDEX.md'),
|
|
169
|
+
];
|
|
170
|
+
await withFileRollback(touched, async () => {
|
|
171
|
+
for (const w of writes) await atomicWrite(w.file, w.text);
|
|
172
|
+
for (const [i, unit] of units.entries()) await cutUnit(paths[i], unit.id);
|
|
117
173
|
await writeIndex(root);
|
|
118
174
|
});
|
|
119
175
|
|
|
120
|
-
return {
|
|
176
|
+
return { items: writes.map((w) => `gcf/work/${path.basename(w.file)}`) };
|
|
121
177
|
}
|
|
122
178
|
|
|
123
179
|
/** A file's text at a git revision spec (`HEAD:./x`, `:./x` for the index), '' when absent. */
|
|
124
|
-
function gitText(projectDir, spec) {
|
|
180
|
+
export function gitText(projectDir, spec) {
|
|
125
181
|
const res = spawnSync('git', ['show', spec], { cwd: projectDir, encoding: 'utf8' });
|
|
126
182
|
|
|
127
183
|
return res.status === 0 ? res.stdout : '';
|
|
@@ -144,12 +200,18 @@ function uncommitted(projectDir, file) {
|
|
|
144
200
|
* Run a move that ends with its fixed-format commit, all of it or none of it. The commit takes
|
|
145
201
|
* whole files and deletes the unit's section, so any uncommitted edit in them refuses the move
|
|
146
202
|
* up front — an answer in the unit itself included: it would leave in a commit that never held
|
|
147
|
-
* it, and no history would keep it.
|
|
203
|
+
* it, and no history would keep it. `inputs` are the item files the move reads and rewrites:
|
|
204
|
+
* their own uncommitted edits (an answer, a live: delta) ride along on purpose.
|
|
205
|
+
* @param {string} projectDir
|
|
206
|
+
* @param {string} message
|
|
207
|
+
* @param {{files: string[], mutate: () => Promise<void>, commit: boolean, inputs?: string[]}} move
|
|
148
208
|
*/
|
|
149
|
-
async function commitMove(projectDir, message, { files, mutate, commit }) {
|
|
209
|
+
export async function commitMove(projectDir, message, { files, mutate, commit, inputs = [] }) {
|
|
150
210
|
const busy = commit ? operationInProgress(projectDir) : null;
|
|
151
211
|
if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
|
|
152
|
-
const dirty = commit
|
|
212
|
+
const dirty = commit
|
|
213
|
+
? files.filter((f) => !inputs.includes(f) && uncommitted(projectDir, f))
|
|
214
|
+
: [];
|
|
153
215
|
if (dirty.length) {
|
|
154
216
|
const names = dirty.map((f) => path.relative(projectDir, f)).join(', ');
|
|
155
217
|
throw new Error(
|
|
@@ -176,14 +238,14 @@ async function commitMove(projectDir, message, { files, mutate, commit }) {
|
|
|
176
238
|
}
|
|
177
239
|
|
|
178
240
|
/**
|
|
179
|
-
* A hard unit is built and its tests are green: it leaves design
|
|
180
|
-
* of the subsystem must remember goes to the systems doc's `constraints
|
|
241
|
+
* A hard unit is built and its tests are green: it leaves design/ without a work item. A line
|
|
242
|
+
* every future reader of the subsystem must remember goes to the systems doc's `constraints:`.
|
|
181
243
|
*/
|
|
182
244
|
export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
|
|
183
245
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
184
246
|
const unit = await designUnit(projectDir, id);
|
|
185
247
|
if (CARD_TYPES.includes(unit.header.type))
|
|
186
|
-
throw new Error(`${id} is soft: it leaves design/ through a
|
|
248
|
+
throw new Error(`${id} is soft: it leaves design/ through a work item and a verdict`);
|
|
187
249
|
const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
|
|
188
250
|
if (summary?.tbd)
|
|
189
251
|
throw new Error(`${id} still carries ${summary.tbd} TBD: it was built on a hole — answer it`);
|
|
@@ -194,13 +256,7 @@ export async function doneUnit(projectDir, id, { constraints = [], commit = true
|
|
|
194
256
|
const mutate = async () => {
|
|
195
257
|
await cutUnit(paths, id);
|
|
196
258
|
await recordLine(root, id, 'done');
|
|
197
|
-
if (
|
|
198
|
-
const body = upsertBlock(
|
|
199
|
-
doc.body,
|
|
200
|
-
'constraints',
|
|
201
|
-
constraints.map((c) => `- ${c}`),
|
|
202
|
-
);
|
|
203
|
-
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
|
|
259
|
+
if (doc) await addConstraints(doc, id, constraints);
|
|
204
260
|
};
|
|
205
261
|
|
|
206
262
|
return {
|
|
@@ -209,57 +265,29 @@ export async function doneUnit(projectDir, id, { constraints = [], commit = true
|
|
|
209
265
|
};
|
|
210
266
|
}
|
|
211
267
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
throw new Error(`${id} is ${card.data.status}: play it and give the verdict, kill included`);
|
|
221
|
-
const rel = `./${path.relative(projectDir, card.file).replaceAll('\\', '/')}`;
|
|
222
|
-
if (commit && !gitText(projectDir, `HEAD:${rel}`)) {
|
|
223
|
-
throw new Error(
|
|
224
|
-
`${id} was taken but its card is not committed: commit it first, or undo the take ` +
|
|
225
|
-
`(delete the card, restore its section in gcf/design/ and its backlog line)`,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const files = [card.file, path.join(root, 'graveyard.md')];
|
|
230
|
-
const mutate = async () => {
|
|
231
|
-
await buryLine(root, id, reason, tags.length ? tags : (card.data.system ?? []));
|
|
232
|
-
await unlink(card.file);
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
return {
|
|
236
|
-
id,
|
|
237
|
-
hash: await commitMove(projectDir, `gcf(drop): ${id}`, { files, mutate, commit }),
|
|
238
|
-
};
|
|
268
|
+
/** Lines every reader of a subsystem must remember, into its systems doc's `constraints:`. */
|
|
269
|
+
export async function addConstraints(doc, id, constraints) {
|
|
270
|
+
const body = upsertBlock(
|
|
271
|
+
doc.body,
|
|
272
|
+
'constraints',
|
|
273
|
+
constraints.map((c) => `- ${c}`),
|
|
274
|
+
);
|
|
275
|
+
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
|
|
239
276
|
}
|
|
240
277
|
|
|
241
|
-
/**
|
|
242
|
-
* A unit — or a card not yet built — is dropped before any playtest: a graveyard line keeps the
|
|
243
|
-
* reason (GCF-PRD §5.6).
|
|
244
|
-
*/
|
|
278
|
+
/** A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9). */
|
|
245
279
|
export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
|
|
246
280
|
if (!String(reason ?? '').trim())
|
|
247
281
|
throw new Error('a drop needs a reason: the graveyard keeps it');
|
|
248
|
-
const ledger = await buildLedger(projectDir);
|
|
249
|
-
const live = ledger.where(id).live;
|
|
250
|
-
if (live.length === 1 && live[0].state === 'card') {
|
|
251
|
-
const card = ledger.cards.find((c) => c.data.id === id);
|
|
252
|
-
|
|
253
|
-
return dropCard(projectDir, card, { reason, tags, commit });
|
|
254
|
-
}
|
|
255
|
-
|
|
256
282
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
257
283
|
const unit = await designUnit(projectDir, id);
|
|
258
284
|
const paths = basePaths(root, unit);
|
|
259
285
|
const system = listOf(unit.header.system);
|
|
260
286
|
const files = [paths.design, paths.backlog, path.join(root, 'graveyard.md')];
|
|
287
|
+
const type = unit.header.type ?? 'mechanic';
|
|
261
288
|
const mutate = async () => {
|
|
262
|
-
|
|
289
|
+
const lineTags = tags.length ? tags : system === 'TBD' ? [] : system;
|
|
290
|
+
await buryLine(root, id, { type, reason, tags: lineTags });
|
|
263
291
|
await cutUnit(paths, id);
|
|
264
292
|
};
|
|
265
293
|
|
|
@@ -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
|
+
}
|