gcf-aif-ext 0.3.0 → 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.
Files changed (48) hide show
  1. package/README.md +53 -11
  2. package/commands/gcf.js +287 -42
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +55 -31
  5. package/injections/commit-status.md +7 -5
  6. package/injections/plan-context.md +8 -5
  7. package/injections/review-pillars.md +4 -3
  8. package/lib/anchors.js +27 -4
  9. package/lib/audit-core.js +135 -48
  10. package/lib/bundle.js +7 -4
  11. package/lib/context.js +24 -23
  12. package/lib/design-check.js +78 -27
  13. package/lib/design.js +2 -2
  14. package/lib/gcf-paths.js +13 -2
  15. package/lib/id.js +2 -2
  16. package/lib/inbox.js +54 -0
  17. package/lib/index-gen.js +23 -8
  18. package/lib/ledger.js +19 -17
  19. package/lib/linter.js +2 -1
  20. package/lib/live.js +179 -0
  21. package/lib/plan-check.js +31 -0
  22. package/lib/playtest-list.js +8 -4
  23. package/lib/systems-doc.js +16 -1
  24. package/lib/transitions.js +36 -21
  25. package/lib/unit-ops.js +124 -97
  26. package/lib/validate-item.js +244 -0
  27. package/lib/verdict-fs.js +61 -128
  28. package/lib/work-ops.js +727 -0
  29. package/package.json +1 -1
  30. package/profiles/pillars.md +1 -1
  31. package/profiles/system.md +1 -1
  32. package/profiles/unit-constraint.md +10 -0
  33. package/profiles/unit-entity.md +10 -0
  34. package/profiles/unit-mechanic.md +6 -1
  35. package/profiles/unit-resource.md +6 -1
  36. package/profiles/unit-rule.md +10 -0
  37. package/schemas/item.schema.json +105 -0
  38. package/skills/gcf-adopt/SKILL.md +2 -2
  39. package/skills/gcf-audit/SKILL.md +3 -3
  40. package/skills/gcf-card/SKILL.md +25 -20
  41. package/skills/gcf-ingest/SKILL.md +3 -3
  42. package/skills/gcf-init/SKILL.md +2 -2
  43. package/skills/gcf-triage/SKILL.md +35 -0
  44. package/skills/gcf-verdict/SKILL.md +19 -16
  45. package/templates/config.yml +7 -0
  46. package/templates/{card.md → item.md} +10 -3
  47. package/lib/validate-card.js +0 -93
  48. 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/ (GCF-PRD §4): `take` into a card, `done` into
2
- // tests and code, `drop` into the graveyard. Each one removes the section and its backlog line
3
- // in the same step, so an id never lives in two places (M12).
4
- import { readFile, unlink } from 'node:fs/promises';
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
- const lines = String(text).split('\n');
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
- async function designUnit(projectDir, id) {
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
- const unit = ledger.design.flatMap((d) => d.units).find((u) => u.id === id);
49
-
50
- return /** @type {import('./design.js').Unit} */ (unit);
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,118 @@ 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 card starts with: the unit's header plus the fields of the card stage. */
65
- function cardData(unit, today) {
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: 2,
75
+ schema: 3,
71
76
  id: unit.id,
72
77
  type: h.type,
78
+ gate,
73
79
  ...(h.title ? { title: h.title } : {}),
74
- status: 'proposed',
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
- tune_count: 0,
81
- built_at: null,
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
 
86
- /**
87
- * Take a ready soft unit into work: its section becomes cards/<id>.md with the same body.
88
- * No commit — `/gcf-card` adds `contract` and `interactions`, and the pre-commit gate holds
89
- * the card until it carries both.
90
- */
91
- export async function takeUnit(
92
- projectDir,
93
- id,
94
- { today = new Date().toISOString().slice(0, 10) } = {},
95
- ) {
96
- const root = path.join(path.resolve(projectDir), 'gcf');
97
- const unit = await designUnit(projectDir, id);
98
- if (!CARD_TYPES.includes(unit.header.type)) {
99
- throw new Error(
100
- `${id} is hard: close its TBD in place, run the AIF cycle, then gcf done ${id}`,
101
- );
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) };
102
107
  }
103
108
 
104
- const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
105
- if (!summary?.ready) {
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;
106
130
  const why = summary?.blocked_by.length
107
131
  ? `blocked by ${summary.blocked_by.join(', ')}`
108
132
  : `${summary?.tbd ?? '?'} TBD or a blocking design-check finding`;
109
- throw new Error(`${id} is not ready (${why}): answer it in design/ first`);
133
+ throw new Error(`${unit.id} is not ready (${why}): answer it in design/ first`);
110
134
  }
135
+ }
111
136
 
112
- const paths = { ...basePaths(root, unit), card: path.join(root, 'cards', `${id}.md`) };
113
- if (existsSync(paths.card)) throw new Error(`gcf/cards/${id}.md already exists`);
114
- await withFileRollback([...Object.values(paths), path.join(root, 'INDEX.md')], async () => {
115
- await atomicWrite(paths.card, serialize(cardData(unit, today), `\n${unit.body.trim()}\n`));
116
- await cutUnit(paths, id);
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);
117
172
  await writeIndex(root);
118
173
  });
119
174
 
120
- return { card: `gcf/cards/${id}.md` };
175
+ return { items: writes.map((w) => `gcf/work/${path.basename(w.file)}`) };
121
176
  }
122
177
 
123
178
  /** A file's text at a git revision spec (`HEAD:./x`, `:./x` for the index), '' when absent. */
124
- function gitText(projectDir, spec) {
179
+ export function gitText(projectDir, spec) {
125
180
  const res = spawnSync('git', ['show', spec], { cwd: projectDir, encoding: 'utf8' });
126
181
 
127
182
  return res.status === 0 ? res.stdout : '';
@@ -144,12 +199,18 @@ function uncommitted(projectDir, file) {
144
199
  * Run a move that ends with its fixed-format commit, all of it or none of it. The commit takes
145
200
  * whole files and deletes the unit's section, so any uncommitted edit in them refuses the move
146
201
  * 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.
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
148
207
  */
149
- async function commitMove(projectDir, message, { files, mutate, commit }) {
208
+ export async function commitMove(projectDir, message, { files, mutate, commit, inputs = [] }) {
150
209
  const busy = commit ? operationInProgress(projectDir) : null;
151
210
  if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
152
- const dirty = commit ? files.filter((f) => uncommitted(projectDir, f)) : [];
211
+ const dirty = commit
212
+ ? files.filter((f) => !inputs.includes(f) && uncommitted(projectDir, f))
213
+ : [];
153
214
  if (dirty.length) {
154
215
  const names = dirty.map((f) => path.relative(projectDir, f)).join(', ');
155
216
  throw new Error(
@@ -176,14 +237,14 @@ async function commitMove(projectDir, message, { files, mutate, commit }) {
176
237
  }
177
238
 
178
239
  /**
179
- * A hard unit is built and its tests are green: it leaves design/. A line every future reader
180
- * of the subsystem must remember goes to the systems doc's `constraints:` (GCF-PRD §6.3).
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:`.
181
242
  */
182
243
  export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
183
244
  const root = path.join(path.resolve(projectDir), 'gcf');
184
245
  const unit = await designUnit(projectDir, id);
185
246
  if (CARD_TYPES.includes(unit.header.type))
186
- throw new Error(`${id} is soft: it leaves design/ through a card and a verdict`);
247
+ throw new Error(`${id} is soft: it leaves design/ through a work item and a verdict`);
187
248
  const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
188
249
  if (summary?.tbd)
189
250
  throw new Error(`${id} still carries ${summary.tbd} TBD: it was built on a hole — answer it`);
@@ -194,13 +255,7 @@ export async function doneUnit(projectDir, id, { constraints = [], commit = true
194
255
  const mutate = async () => {
195
256
  await cutUnit(paths, id);
196
257
  await recordLine(root, id, 'done');
197
- if (!doc) return;
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));
258
+ if (doc) await addConstraints(doc, id, constraints);
204
259
  };
205
260
 
206
261
  return {
@@ -209,57 +264,29 @@ export async function doneUnit(projectDir, id, { constraints = [], commit = true
209
264
  };
210
265
  }
211
266
 
212
- /**
213
- * A card taken into work but never built is given up: no playtest, so no verdict — a drop. Its
214
- * file goes, the graveyard keeps the reason. A built card waits for its verdict instead.
215
- */
216
- async function dropCard(projectDir, card, { reason, tags, commit }) {
217
- const root = path.join(path.resolve(projectDir), 'gcf');
218
- const id = String(card.data.id);
219
- if (!['proposed', 'tuning'].includes(card.data.status))
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
- };
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));
239
275
  }
240
276
 
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
- */
277
+ /** A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9). */
245
278
  export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
246
279
  if (!String(reason ?? '').trim())
247
280
  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
281
  const root = path.join(path.resolve(projectDir), 'gcf');
257
282
  const unit = await designUnit(projectDir, id);
258
283
  const paths = basePaths(root, unit);
259
284
  const system = listOf(unit.header.system);
260
285
  const files = [paths.design, paths.backlog, path.join(root, 'graveyard.md')];
286
+ const type = unit.header.type ?? 'mechanic';
261
287
  const mutate = async () => {
262
- await buryLine(root, id, reason, tags.length ? tags : system === 'TBD' ? [] : system);
288
+ const lineTags = tags.length ? tags : system === 'TBD' ? [] : system;
289
+ await buryLine(root, id, { type, reason, tags: lineTags });
263
290
  await cutUnit(paths, id);
264
291
  };
265
292
 
@@ -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
+ }