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.
Files changed (51) hide show
  1. package/README.md +63 -11
  2. package/commands/gcf.js +321 -43
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +60 -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/agents-block.js +66 -0
  9. package/lib/anchors.js +27 -4
  10. package/lib/audit-core.js +135 -48
  11. package/lib/bundle.js +7 -4
  12. package/lib/context.js +33 -25
  13. package/lib/design-check.js +149 -40
  14. package/lib/design.js +2 -2
  15. package/lib/gcf-paths.js +13 -2
  16. package/lib/id.js +2 -2
  17. package/lib/inbox.js +54 -0
  18. package/lib/index-gen.js +23 -8
  19. package/lib/ledger.js +19 -17
  20. package/lib/linter.js +2 -1
  21. package/lib/live.js +191 -0
  22. package/lib/pillars.js +113 -0
  23. package/lib/plan-check.js +31 -0
  24. package/lib/playtest-list.js +8 -4
  25. package/lib/systems-doc.js +16 -1
  26. package/lib/transitions.js +36 -21
  27. package/lib/unit-ops.js +125 -97
  28. package/lib/validate-item.js +244 -0
  29. package/lib/verdict-fs.js +67 -128
  30. package/lib/work-ops.js +759 -0
  31. package/package.json +4 -3
  32. package/profiles/pillars.md +1 -1
  33. package/profiles/system.md +1 -1
  34. package/profiles/unit-constraint.md +10 -0
  35. package/profiles/unit-entity.md +10 -0
  36. package/profiles/unit-mechanic.md +6 -1
  37. package/profiles/unit-resource.md +6 -1
  38. package/profiles/unit-rule.md +10 -0
  39. package/schemas/item.schema.json +105 -0
  40. package/skills/gcf-adopt/SKILL.md +2 -2
  41. package/skills/gcf-audit/SKILL.md +3 -3
  42. package/skills/gcf-card/SKILL.md +27 -20
  43. package/skills/gcf-ingest/SKILL.md +3 -3
  44. package/skills/gcf-init/SKILL.md +8 -5
  45. package/skills/gcf-triage/SKILL.md +35 -0
  46. package/skills/gcf-verdict/SKILL.md +19 -16
  47. package/templates/agents-block.md +28 -0
  48. package/templates/config.yml +7 -0
  49. package/templates/{card.md → item.md} +10 -3
  50. package/lib/validate-card.js +0 -93
  51. 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,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 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
 
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 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.
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 takeUnit(
144
+ export async function takeUnits(
92
145
  projectDir,
93
- id,
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 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
- );
102
- }
103
-
104
- const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
105
- if (!summary?.ready) {
106
- const why = summary?.blocked_by.length
107
- ? `blocked by ${summary.blocked_by.join(', ')}`
108
- : `${summary?.tbd ?? '?'} TBD or a blocking design-check finding`;
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 = { ...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);
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 { card: `gcf/cards/${id}.md` };
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 ? files.filter((f) => uncommitted(projectDir, f)) : [];
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/. A line every future reader
180
- * of the subsystem must remember goes to the systems doc's `constraints:` (GCF-PRD §6.3).
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 card and a verdict`);
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 (!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));
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
- * 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
- };
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
- await buryLine(root, id, reason, tags.length ? tags : system === 'TBD' ? [] : system);
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
+ }