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.
Files changed (63) hide show
  1. package/README.md +54 -10
  2. package/commands/gcf.js +452 -30
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +69 -41
  5. package/injections/commit-status.md +7 -4
  6. package/injections/plan-context.md +8 -4
  7. package/injections/review-pillars.md +4 -3
  8. package/lib/anchors.js +60 -35
  9. package/lib/audit-core.js +172 -55
  10. package/lib/bundle.js +278 -0
  11. package/lib/context.js +25 -23
  12. package/lib/design-check.js +631 -0
  13. package/lib/design.js +179 -0
  14. package/lib/gcf-paths.js +13 -2
  15. package/lib/git.js +63 -0
  16. package/lib/id.js +7 -15
  17. package/lib/inbox.js +54 -0
  18. package/lib/index-gen.js +62 -13
  19. package/lib/ledger.js +94 -0
  20. package/lib/linter.js +17 -5
  21. package/lib/live.js +179 -0
  22. package/lib/migrate.js +345 -0
  23. package/lib/plan-check.js +31 -0
  24. package/lib/playtest-list.js +9 -5
  25. package/lib/systems-doc.js +85 -0
  26. package/lib/transitions.js +36 -21
  27. package/lib/unit-ops.js +297 -0
  28. package/lib/validate-item.js +244 -0
  29. package/lib/verdict-fs.js +112 -165
  30. package/lib/work-ops.js +727 -0
  31. package/package.json +1 -1
  32. package/profiles/game.md +68 -0
  33. package/profiles/glossary.md +37 -0
  34. package/profiles/pillars.md +3 -2
  35. package/profiles/system.md +1 -1
  36. package/profiles/unit-constraint.md +52 -0
  37. package/profiles/unit-entity.md +48 -0
  38. package/profiles/unit-mechanic.md +111 -0
  39. package/profiles/unit-resource.md +91 -0
  40. package/profiles/unit-rule.md +52 -0
  41. package/schemas/item.schema.json +105 -0
  42. package/skills/gcf-adopt/SKILL.md +3 -3
  43. package/skills/gcf-audit/SKILL.md +7 -6
  44. package/skills/gcf-card/SKILL.md +31 -17
  45. package/skills/gcf-debate/SKILL.md +17 -14
  46. package/skills/gcf-ingest/SKILL.md +39 -23
  47. package/skills/gcf-init/SKILL.md +5 -5
  48. package/skills/gcf-triage/SKILL.md +35 -0
  49. package/skills/gcf-verdict/SKILL.md +19 -15
  50. package/skills/gcf-vision/SKILL.md +17 -11
  51. package/templates/PILLARS.md +2 -0
  52. package/templates/backlog.md +2 -5
  53. package/templates/cnlp.config.json +0 -1
  54. package/templates/config.yml +7 -0
  55. package/templates/design.md +59 -0
  56. package/templates/game.md +24 -0
  57. package/templates/glossary.md +11 -0
  58. package/templates/item.md +58 -0
  59. package/templates/system.md +1 -1
  60. package/lib/validate-card.js +0 -82
  61. package/profiles/card.md +0 -65
  62. package/schemas/card.schema.json +0 -30
  63. package/templates/card.md +0 -34
package/lib/live.js ADDED
@@ -0,0 +1,179 @@
1
+ // LIVE.md (Methodology §8): what the game is now, one short record per capability and per known
2
+ // issue, no history. Capabilities change only through the live: delta of an item, applied in the
3
+ // commit of the move that ends it; known issues are the parked bugs, written by `decide known`
4
+ // and taken out by any end of that bug.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { atomicWrite } from './safe-path.js';
9
+ import { listItems } from './gcf-paths.js';
10
+ import { summaryProblem } from './validate-item.js';
11
+
12
+ const SECTIONS = { capabilities: 'capabilities', 'known-issues': 'knownIssues' };
13
+ const NONE = {
14
+ capabilities: '- none: no capability recorded yet',
15
+ 'known-issues': '- none: no known issue',
16
+ };
17
+
18
+ /** @typedef {{id: string, summary: string, refs?: string}} LiveRecord */
19
+
20
+ /** Parse LIVE.md text: `{capabilities, knownIssues, problems}`. */
21
+ export function parseLive(text) {
22
+ /** @type {{capabilities: LiveRecord[], knownIssues: LiveRecord[], problems: string[]}} */
23
+ const out = { capabilities: [], knownIssues: [], problems: [] };
24
+ const body = String(text)
25
+ .replace(/\r\n/g, '\n')
26
+ .replace(/^---\n[\s\S]*?\n---\n?/, '');
27
+ /** @type {'capabilities' | 'knownIssues' | null} */
28
+ let section = null;
29
+ /** @type {LiveRecord | null} */
30
+ let rec = null;
31
+ body.split('\n').forEach((line, i) => {
32
+ const head = line.match(/^([a-z-]+):\s*$/);
33
+ if (head) {
34
+ section = SECTIONS[head[1]] ?? null;
35
+ if (!section) out.problems.push(`line ${i + 1}: unknown section ${head[1]}:`);
36
+ rec = null;
37
+
38
+ return;
39
+ }
40
+
41
+ const start = line.match(/^- id:\s*(\S+)\s*$/);
42
+ if (start && section) {
43
+ rec = { id: start[1], summary: '' };
44
+ out[section].push(rec);
45
+
46
+ return;
47
+ }
48
+
49
+ const field = line.match(/^ {2}(summary|refs):\s*(.+?)\s*$/);
50
+ if (field && rec) {
51
+ rec[field[1]] = field[2];
52
+
53
+ return;
54
+ }
55
+
56
+ if (line.trim() && !/^- none\b/.test(line) && !line.startsWith('#'))
57
+ out.problems.push(`line ${i + 1}: not a record line: ${line.trim()}`);
58
+ });
59
+
60
+ return out;
61
+ }
62
+
63
+ const recordText = (r) =>
64
+ [`- id: ${r.id}`, ` summary: ${r.summary}`, ...(r.refs ? [` refs: ${r.refs}`] : [])].join('\n');
65
+
66
+ const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
67
+
68
+ /** LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly. */
69
+ export function serializeLive({ capabilities, knownIssues }) {
70
+ const block = (key, list) =>
71
+ `${key}:\n${list.length ? [...list].sort(byId).map(recordText).join('\n') : NONE[key]}\n`;
72
+
73
+ return [
74
+ '---\ntype: live\n---\n',
75
+ block('capabilities', capabilities),
76
+ block('known-issues', knownIssues),
77
+ ].join('\n');
78
+ }
79
+
80
+ export const liveFile = (root) => path.join(root, 'LIVE.md');
81
+
82
+ export async function readLive(root) {
83
+ const file = liveFile(root);
84
+
85
+ return parseLive(existsSync(file) ? await readFile(file, 'utf8') : '');
86
+ }
87
+
88
+ export async function writeLive(root, live) {
89
+ await atomicWrite(liveFile(root), serializeLive(live));
90
+ }
91
+
92
+ /**
93
+ * The capabilities after `delta` (an item's `live:`), or a thrown reason. `none` changes nothing;
94
+ * `added` needs a new id, `modified` and `removed` an existing one.
95
+ */
96
+ export function applyDelta(capabilities, delta, itemId) {
97
+ if (!delta) throw new Error(`${itemId} has no live: delta — say what it changes, or none: <why>`);
98
+ if ('none' in delta) return capabilities;
99
+ let next = [...capabilities];
100
+ for (const c of delta.changes) {
101
+ const at = next.findIndex((r) => r.id === c.id);
102
+ if (c.change === 'added' && at >= 0)
103
+ throw new Error(`${itemId}: capability ${c.id} already exists — use change: modified`);
104
+ if (c.change !== 'added' && at < 0)
105
+ throw new Error(`${itemId}: capability ${c.id} is not in LIVE.md — nothing to ${c.change}`);
106
+ if (c.change === 'removed') next = next.filter((r) => r.id !== c.id);
107
+ else {
108
+ const rec = { id: c.id, summary: c.summary, ...(c.refs ? { refs: c.refs } : {}) };
109
+ if (at >= 0) next[at] = { ...next[at], ...rec };
110
+ else next.push(rec);
111
+ }
112
+ }
113
+
114
+ return next;
115
+ }
116
+
117
+ /**
118
+ * The capabilities without what `delta` added: an unplayed mechanic killed, dropped or sent back
119
+ * to tuning leaves the game, so its record leaves LIVE.md with it. `modified` and `removed`
120
+ * cannot be undone from the delta alone and stay as they are.
121
+ */
122
+ export function revertDelta(capabilities, delta) {
123
+ if (!delta || 'none' in delta) return capabilities;
124
+ const added = delta.changes.filter((c) => c.change === 'added').map((c) => c.id);
125
+
126
+ return capabilities.filter((r) => !added.includes(r.id));
127
+ }
128
+
129
+ /** The known-issue record of a parked bug: its title or first intent bullet, and its file. */
130
+ export function knownIssueOf(item) {
131
+ const intent = String(item.body ?? '').match(/^intent:\s*\n- (.+)$/m)?.[1];
132
+ const summary = String(item.data.title ?? intent ?? item.data.id).trim();
133
+
134
+ return { id: String(item.data.id), summary, refs: `gcf/work/${item.data.id}.md` };
135
+ }
136
+
137
+ /** The refs of a record that name no file of the project. */
138
+ const brokenRefs = (projectDir, r) =>
139
+ String(r.refs ?? '')
140
+ .split(',')
141
+ .map((s) => s.trim())
142
+ .filter((ref) => ref && !existsSync(path.join(projectDir, ref.split('#')[0])));
143
+
144
+ /**
145
+ * `live check`: record form, duplicate ids, refs that name no file, a known issue without its
146
+ * parked bug and a parked bug without its known issue.
147
+ */
148
+ export async function checkLive(projectDir) {
149
+ const root = path.join(path.resolve(projectDir), 'gcf');
150
+ const live = await readLive(root);
151
+ const issues = [...live.problems];
152
+ /** @type {Array<[string, LiveRecord[]]>} */
153
+ const kinds = [
154
+ ['capability', live.capabilities],
155
+ ['known issue', live.knownIssues],
156
+ ];
157
+ for (const [name, list] of kinds) {
158
+ const seen = new Set();
159
+ for (const r of list) {
160
+ if (seen.has(r.id)) issues.push(`${name} ${r.id} is listed twice`);
161
+ seen.add(r.id);
162
+ const bad = summaryProblem(r.summary);
163
+ if (bad) issues.push(`${name} ${r.id}: ${bad}`);
164
+ issues.push(
165
+ ...brokenRefs(projectDir, r).map((ref) => `${name} ${r.id}: refs ${ref} names no file`),
166
+ );
167
+ }
168
+ }
169
+
170
+ const parked = (await listItems(root)).filter((i) => i.data.status === 'parked');
171
+ const known = new Set(live.knownIssues.map((r) => r.id));
172
+ for (const p of parked)
173
+ if (!known.has(p.data.id)) issues.push(`parked bug ${p.data.id} has no known-issue record`);
174
+ for (const id of known)
175
+ if (!parked.some((p) => p.data.id === id))
176
+ issues.push(`known issue ${id} has no parked bug in gcf/work/`);
177
+
178
+ return { ...live, issues };
179
+ }
package/lib/migrate.js ADDED
@@ -0,0 +1,345 @@
1
+ // One-shot upgrade from the 0.2 backlog (GCF-PRD v5.8 §5.2) to the Design Model (v6.0): each
2
+ // `- cand-NNN | …` line becomes a unit in gcf/design/<sys>.md, the backlog keeps only the order.
3
+ // Deterministic: the line format was fixed, so nothing here needs a model. Only the quote is the
4
+ // GDD's; every slot the line did not carry reads TBD, because the description was a paraphrase.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { spawnSync } from 'node:child_process';
8
+ import path from 'node:path';
9
+ import { slugify } from './id.js';
10
+ import { buildLedger } from './ledger.js';
11
+ import { atomicWrite, withFileRollback, resolveInside } from './safe-path.js';
12
+ import { writeIndex } from './index-gen.js';
13
+ import { recordLine } from './verdict-fs.js';
14
+
15
+ // The id stops before a quote: a revert subject closes the quoted original right after it.
16
+ // A v5.8 verdict names the card file first (`keep card-012 tile-rotation → board`): the slug
17
+ // after it is the id v6 uses.
18
+ const MOVE_RE = /^gcf\((verdict|done)\): (?:(keep|kill|tune) )?(?:card-\d+ (?=[a-z0-9]))?([^\s"]+)/;
19
+
20
+ /** `Revert "…"` undoes once, `Reapply "…"` (a revert of a revert) twice: parity decides. */
21
+ function unwrap(subject) {
22
+ let s = subject;
23
+ let depth = 0;
24
+ for (;;) {
25
+ if (s.startsWith('Revert "')) [s, depth] = [s.slice(8), depth + 1];
26
+ else if (s.startsWith('Reapply "')) [s, depth] = [s.slice(9), depth + 2];
27
+ else return { s, undo: depth % 2 === 1 };
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Keeps and dones still in force in commit subjects, newest first, as `[id, event, index]`: an
33
+ * undo cancels the next older event of its kind for that id.
34
+ * @param {string[]} subjects
35
+ * @returns {[string, string, number][]}
36
+ */
37
+ export function lifeEvents(subjects) {
38
+ const cancels = new Map();
39
+ /** @type {[string, string, number][]} */
40
+ const out = [];
41
+ for (const [i, subject] of subjects.entries()) {
42
+ const { s, undo } = unwrap(subject);
43
+ const m = s.match(MOVE_RE);
44
+ const event = m && (m[1] === 'done' ? 'done' : m[2] === 'keep' ? 'collapsed' : null);
45
+ if (!m || !event) continue;
46
+ const key = `${event}:${m[3]}`;
47
+ if (undo) cancels.set(key, (cancels.get(key) ?? 0) + 1);
48
+ else if (cancels.get(key)) cancels.set(key, cancels.get(key) - 1);
49
+ else out.push([m[3], event, i]);
50
+ }
51
+
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * A project older than history.md keeps its keeps (v5.8 verdicts) and dones only in commit
57
+ * subjects: the `[id, event, date]` lines history.md lacks, to write once.
58
+ * @returns {Promise<[string, string, string][]>}
59
+ */
60
+ async function historySeed(projectDir, root) {
61
+ const res = spawnSync(
62
+ 'git',
63
+ ['log', '-E', '--grep', '^((Revert|Reapply) ")*gcf\\((verdict|done)\\):', '--format=%as %s'],
64
+ { cwd: projectDir, encoding: 'utf8' },
65
+ );
66
+ const log = res.status === 0 ? res.stdout.split('\n').filter(Boolean) : [];
67
+ const file = path.join(root, 'history.md');
68
+ const text = existsSync(file) ? await readFile(file, 'utf8') : '';
69
+ const have = new Set([...text.matchAll(/^- (\S+) \|/gm)].map((m) => m[1]));
70
+
71
+ return lifeEvents(log.map((l) => l.slice(11)))
72
+ .filter(([id]) => !have.has(id))
73
+ .map(([id, event, i]) => [id, event, log[i].slice(0, 10)]);
74
+ }
75
+
76
+ const SOFT = ['behavior', 'refusal', 'boundaries', 'persistence', 'feel_like', 'feel'];
77
+ const LIST_LENSES = ['refusal', 'boundaries'];
78
+
79
+ /** `soft (why)` → `{ hard, why }`; a missing route reads soft, the safe default. */
80
+ function routeOf(value) {
81
+ const m = String(value ?? '').match(/^(soft|hard)\s*\((.*)\)$/);
82
+
83
+ return { hard: m?.[1] === 'hard', why: m?.[2] || 'acceptance is a playtest' };
84
+ }
85
+
86
+ /** `- cand-NNN | name | route: soft (why) | sys: s | deps: a, b | src: X | q: "…" | … | descr` */
87
+ export function parseCandidate(line) {
88
+ const id = line.match(/^- (cand-\d+) \|/)?.[1];
89
+ if (!id) return null;
90
+ // The quote may hold `|`: cut it out before splitting the fields.
91
+ const quote = line.match(/\|\s*q:\s*"(.*?)"\s*(?=\||$)/)?.[1] ?? null;
92
+ const fields = line
93
+ .replace(/\|\s*q:\s*".*?"\s*(?=\||$)/, '')
94
+ .split(' | ')
95
+ .map((f) => f.trim());
96
+ const get = (key) =>
97
+ fields
98
+ .find((f) => f.startsWith(`${key}:`))
99
+ ?.slice(key.length + 1)
100
+ .trim();
101
+
102
+ return {
103
+ id,
104
+ name: fields[1],
105
+ ...routeOf(get('route')),
106
+ system: get('sys') || 'unassigned',
107
+ deps: (get('deps') ?? '-')
108
+ .split(',')
109
+ .map((d) => d.trim())
110
+ .filter((d) => d && d !== '-'),
111
+ source: get('src') || 'manual',
112
+ quote,
113
+ gaps: get('gaps') ?? '',
114
+ flags: ['impl?', 'src-changed', 'orphan'].filter((f) => fields.includes(f)),
115
+ description: fields[fields.length - 1],
116
+ };
117
+ }
118
+
119
+ /** `refusal (target overlaps a placed tile), state (…)` → { refusal: ['target overlaps…'] } */
120
+ function gapDetails(gaps) {
121
+ /** @type {Record<string, string[]>} */
122
+ const out = {};
123
+ for (const m of gaps.matchAll(/([a-z]+)(?:\s*\(([^)]*)\))?/g)) {
124
+ out[m[1]] = [...(out[m[1]] ?? []), ...(m[2] ? [m[2]] : [])];
125
+ }
126
+
127
+ return out;
128
+ }
129
+
130
+ const BEHAVIOR_KEYS = ['input', 'state', 'feedback'];
131
+ const KNOWN_LENSES = [...BEHAVIOR_KEYS, ...LIST_LENSES, 'persistence'];
132
+
133
+ /**
134
+ * Every gap note the line carried survives as a `TBD` in its slot: `state (anchor convention)`
135
+ * becomes `state: anchor convention: TBD`. A lens the slots do not name goes to `boundaries`.
136
+ */
137
+ function softBody(c) {
138
+ const d = gapDetails(c.gaps);
139
+ const note = (lens) => (d[lens]?.length ? `${d[lens].join('; ')}: TBD` : 'TBD');
140
+ const other = Object.keys(d).filter((k) => !KNOWN_LENSES.includes(k));
141
+ const items = (k) => [
142
+ ...(d[k] ?? []).map((x) => `- ${x}: TBD`),
143
+ ...(k === 'boundaries' ? other.map((o) => `- ${[o, ...(d[o] ?? [])].join(': ')}: TBD`) : []),
144
+ ];
145
+ const list = (k) => (items(k).length ? [`${k}:`, ...items(k), ''] : [`${k}: TBD`, '']);
146
+ const behavior = BEHAVIOR_KEYS.some((k) => d[k]?.length)
147
+ ? ['behavior:', ...BEHAVIOR_KEYS.map((k, i) => `${i ? ' ' : '- '}${k}: ${note(k)}`), '']
148
+ : ['behavior: TBD', ''];
149
+
150
+ return [
151
+ ...behavior,
152
+ ...list('refusal'),
153
+ ...list('boundaries'),
154
+ `persistence: ${note('persistence')}`,
155
+ '',
156
+ ...SOFT.filter((b) => b.startsWith('feel')).flatMap((b) => [`${b}: TBD`, '']),
157
+ ];
158
+ }
159
+
160
+ /** A hard unit keeps its gap notes as `TBD` statements beside the one it was cut from. */
161
+ const hardGaps = (c) =>
162
+ Object.entries(gapDetails(c.gaps)).map(
163
+ ([lens, notes]) => `- ${[lens, ...notes].join(': ')}: TBD`,
164
+ );
165
+
166
+ /** The `## <id>` section a candidate becomes. */
167
+ export function unitText(c, id, depends) {
168
+ const slot = c.hard ? 'statement' : 'intent';
169
+ const body = c.hard
170
+ ? ['statement:', `- ${c.description}`, ...hardGaps(c), '', 'check: TBD', '']
171
+ : [
172
+ 'intent:',
173
+ `- ${c.description}`,
174
+ '',
175
+ ...softBody(c),
176
+ 'constraints: TBD',
177
+ '',
178
+ 'miss_criterion: TBD',
179
+ '',
180
+ ];
181
+ const prov =
182
+ c.quote && c.source.startsWith('GDD#')
183
+ ? ['provenance:', `- slot: ${slot}`, ` src: ${c.source}`, ` quote: "${c.quote}"`, '']
184
+ : [];
185
+
186
+ return [
187
+ `## ${id}`,
188
+ '',
189
+ `type: ${c.hard ? 'rule' : 'mechanic'}`,
190
+ `title: ${c.name}`,
191
+ `route: ${c.hard ? 'hard' : 'soft'} (${c.why})`,
192
+ `system: [${c.system}]`,
193
+ 'milestone: TBD',
194
+ 'pillars: TBD',
195
+ `depends: ${depends.length ? `[${depends.join(', ')}]` : 'none'}`,
196
+ `source: ${c.source.startsWith('GDD#') ? c.source : 'manual'}`,
197
+ ...(c.flags.length ? [`flags: ${c.flags.join(', ')}`] : []),
198
+ '',
199
+ ...body,
200
+ ...prov,
201
+ ].join('\n');
202
+ }
203
+
204
+ /** The slug of a name, or null when the name has no latin letter or digit to slug. */
205
+ function safeSlug(name) {
206
+ try {
207
+ return slugify(name);
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /** cand-NNN → a slug no unit has used: the name, suffixed -2, -3… on a collision. */
214
+ function mintIds(cands, taken) {
215
+ const map = new Map();
216
+ for (const c of cands) {
217
+ const base = safeSlug(c.name) ?? c.id;
218
+ let id = base;
219
+ for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
220
+ taken.add(id);
221
+ map.set(c.id, id);
222
+ }
223
+
224
+ return map;
225
+ }
226
+
227
+ const rewriteIds = (text, map) => text.replace(/\bcand-\d+\b/g, (m) => map.get(m) ?? m);
228
+
229
+ /**
230
+ * The backlog in its old order: a candidate line becomes its new id, a skipped one goes, an
231
+ * id line stays; units already in design/ that the order lacks go to the end for the human.
232
+ */
233
+ async function newBacklog(lines, map, ledger) {
234
+ const order = [];
235
+ for (const l of lines) {
236
+ const id = l.match(/^- (\S+)/)?.[1];
237
+ if (!id) continue;
238
+ if (/^cand-\d+$/.test(id)) {
239
+ if (map.has(id)) order.push(map.get(id));
240
+ } else order.push(id);
241
+ }
242
+
243
+ for (const u of ledger.design.flatMap((d) => d.units)) order.push(u.id);
244
+ const header = await readFile(new URL('../templates/backlog.md', import.meta.url), 'utf8');
245
+
246
+ return `${header.trimEnd()}\n${[...new Set(order)].map((id) => `- ${id}`).join('\n')}\n`;
247
+ }
248
+
249
+ /**
250
+ * `cand-002`, `cand-002=board-grid` (converted by hand into that unit) or `cand-002=board+rules`
251
+ * (split by hand into several) → a map entry.
252
+ * @param {string} s
253
+ * @returns {[string, string[]]}
254
+ */
255
+ const skipEntry = (s) => {
256
+ const [cand, ids = ''] = s.split('=').map((x) => x.trim());
257
+
258
+ return [cand, ids.split('+').filter(Boolean)];
259
+ };
260
+
261
+ /** A dependency is a candidate (mapped to its new ids) or an id the project already knows. */
262
+ function depTarget(skipped, map, known) {
263
+ return (d) => {
264
+ if (skipped.get(d)?.length) return skipped.get(d);
265
+ if (map.has(d)) return [map.get(d)];
266
+
267
+ return known(d) ? [d] : [];
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Move every candidate line of gcf/backlog.md into gcf/design/. `skip` names candidates a
273
+ * human already converted by hand: their lines go, no unit is written for them, and with
274
+ * `cand-NNN=<id>` (or `=<id>+<id>` for a split) the dependencies on them follow to those units.
275
+ * No commit — the human reads the report and `design check`, fixes what they show, and commits.
276
+ */
277
+ export async function migrateBacklog(projectDir, { skip = [] } = {}) {
278
+ const root = path.join(path.resolve(projectDir), 'gcf');
279
+ const backlogFile = path.join(root, 'backlog.md');
280
+ const lines = (await readFile(backlogFile, 'utf8')).split('\n');
281
+ const skipped = new Map(skip.map(skipEntry));
282
+ const all = lines.flatMap((l) => parseCandidate(l) ?? []);
283
+ const cands = all.filter((c) => !skipped.has(c.id));
284
+ // An id a v5.8 verdict kept is known and is not minted again; its line lands with the rest.
285
+ const seed = await historySeed(projectDir, root);
286
+ const ledger = await buildLedger(projectDir);
287
+ const known = (d) => ledger.known(d) || seed.some(([id]) => id === d);
288
+ const handIds = new Set(ledger.design.flatMap((d) => d.units.map((u) => u.id)));
289
+ const map = mintIds(cands, new Set([...ledger.ids(), ...seed.map(([id]) => id)]));
290
+ // A reference to a split candidate names its first unit; a dependency on it, every unit.
291
+ for (const [cand, ids] of skipped) if (ids.length) map.set(cand, ids[0]);
292
+ const target = depTarget(skipped, map, known);
293
+ /** @type {{lost_depends: string[], maybe_duplicates: string[], unmoved_gaps: string[]}} */
294
+ const report = { lost_depends: [], maybe_duplicates: [], unmoved_gaps: [] };
295
+ // A hand-converted candidate's gap notes may not have made it into its unit: the human checks
296
+ // each one there.
297
+ for (const c of all.filter((x) => skipped.has(x.id) && hardGaps(x).length)) {
298
+ const notes = hardGaps(c).map((g) => g.slice(2));
299
+ report.unmoved_gaps.push(`${map.get(c.id) ?? c.id}: ${notes.join('; ')}`);
300
+ }
301
+
302
+ const bySystem = new Map();
303
+ for (const c of cands) {
304
+ const id = map.get(c.id);
305
+ const lost = c.deps.filter((d) => !target(d).length);
306
+ report.lost_depends.push(...lost.map((d) => `${id} depended on ${d}`));
307
+ const slug = safeSlug(c.name);
308
+ if (slug && handIds.has(slug)) report.maybe_duplicates.push(`${id} ≈ ${slug}`);
309
+ const text = unitText(c, id, [...new Set(c.deps.flatMap(target))]);
310
+ bySystem.set(c.system, [...(bySystem.get(c.system) ?? []), text]);
311
+ }
312
+
313
+ const designFiles = [...bySystem.keys()].map((s) =>
314
+ resolveInside(path.join(root, 'design'), `${s}.md`),
315
+ );
316
+ const extra = ['open-questions.md', 'graveyard.md']
317
+ .map((f) => path.join(root, f))
318
+ .filter(existsSync);
319
+ const history = path.join(root, 'history.md');
320
+ await withFileRollback(
321
+ [backlogFile, ...designFiles, ...extra, history, path.join(root, 'INDEX.md')],
322
+ async () => {
323
+ for (const [id, event, date] of seed) await recordLine(root, id, event, date);
324
+ for (const [system, texts] of bySystem) {
325
+ const file = resolveInside(path.join(root, 'design'), `${system}.md`);
326
+ const head = existsSync(file)
327
+ ? (await readFile(file, 'utf8')).trimEnd()
328
+ : `# ${system}\n\npurpose: TBD`;
329
+ await atomicWrite(file, `${head}\n\n${texts.join('\n')}`.trimEnd() + '\n');
330
+ }
331
+
332
+ await atomicWrite(backlogFile, await newBacklog(lines, map, ledger));
333
+ for (const f of extra) await atomicWrite(f, rewriteIds(await readFile(f, 'utf8'), map));
334
+ await writeIndex(root);
335
+ },
336
+ );
337
+
338
+ return {
339
+ units: cands.length,
340
+ skipped: skipped.size,
341
+ history: seed.length,
342
+ ids: Object.fromEntries(map),
343
+ ...report,
344
+ };
345
+ }
@@ -0,0 +1,31 @@
1
+ // `gcf plan check <id>` (Methodology §5.2.1): the deterministic check after each improve pass —
2
+ // the plan exists, it holds task checkboxes, and the item's readset fits the context budget.
3
+ // Whether the plan is any good is the improve passes' job; this only says it is usable.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { buildLedger } from './ledger.js';
8
+ import { buildContext } from './context.js';
9
+
10
+ export async function planCheck(projectDir, id) {
11
+ const item = (await buildLedger(projectDir)).item(id);
12
+ if (!item) throw new Error(`${id} is not a work item of gcf/work/`);
13
+ const plan = item.data.plan ?? `.ai-factory/plans/${id}.md`;
14
+ const file = path.resolve(projectDir, plan);
15
+ const problems = [];
16
+ let tasks = 0;
17
+ let done = 0;
18
+ if (!existsSync(file)) problems.push(`no plan at ${plan}`);
19
+ else {
20
+ const text = await readFile(file, 'utf8');
21
+ tasks = (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length;
22
+ done = (text.match(/^\s*- \[[xX]\] /gm) ?? []).length;
23
+ if (!tasks) problems.push(`${plan} has no task checkboxes (- [ ] …)`);
24
+ }
25
+
26
+ const ctx = await buildContext(projectDir, id);
27
+ if (ctx.over_budget)
28
+ problems.push(`readset ~${ctx.total_tokens_est} tokens is over the budget ${ctx.budget}`);
29
+
30
+ return { id, plan, tasks, done, problems, ok: problems.length === 0 };
31
+ }
@@ -1,12 +1,16 @@
1
- import { listCards, scalarOf } from './gcf-paths.js';
1
+ import { listItems, scalarOf } from './gcf-paths.js';
2
2
 
3
- /** Playtest list (GCF-PRD §10): every built card with its miss-criterion question. */
3
+ /**
4
+ * Playtest list (Methodology §5.3): every item waiting for its verdict, and every unplayed one,
5
+ * with the question the play must answer.
6
+ */
4
7
  export async function playtestList(root) {
5
- return (await listCards(root))
6
- .filter((c) => c.data.status === 'built')
8
+ return (await listItems(root))
9
+ .filter((c) => ['awaiting-verdict', 'unplayed'].includes(c.data.status))
7
10
  .map((c) => ({
8
11
  id: c.data.id,
9
- slug: c.data.slug,
12
+ title: c.data.title ?? null,
13
+ status: c.data.status,
10
14
  system: c.data.system ?? [],
11
15
  question: scalarOf(c.body, 'miss_criterion') || '(miss_criterion is empty)',
12
16
  }));
@@ -0,0 +1,85 @@
1
+ // Writing into systems/<name>.md (GCF-PRD §8): the keep merge and the hard `gcf done` line.
2
+ // Lines are carried verbatim and deduplicated; compressing them is the skill's job, before.
3
+ import { readFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { parseBlocks, itemsOf } from 'cnlp-kit';
7
+ import { parse } from './frontmatter.js';
8
+ import { scalarIn } from './design.js';
9
+
10
+ const NONE_LINE = '- none: no item of this subsystem has collapsed yet';
11
+
12
+ export function blockLines(body, key) {
13
+ const { lines, sections } = parseBlocks(body);
14
+ const at = sections.find((s) => s.key === key);
15
+
16
+ return at ? itemsOf(lines, at.line).map((i) => i.text) : [];
17
+ }
18
+
19
+ /**
20
+ * Append `addLines` to the `key:` bullet block of a body, skipping lines already there. A
21
+ * `- none: …` placeholder gives way: CNL-P reads it as the whole content or not at all.
22
+ */
23
+ export function upsertBlock(rawBody, key, addLines) {
24
+ // A CRLF checkout: `\r` before each `\n` would stop the block match at its first line and
25
+ // duplicate every line after it.
26
+ const body = lf(rawBody);
27
+ const existing = blockLines(body, key);
28
+ const fresh = addLines.filter((l) => !existing.includes(l));
29
+ if (!fresh.length) return body;
30
+ const kept = existing.filter((l) => !/^- none\b/.test(l));
31
+ const block = `${key}:\n${[...kept, ...fresh].join('\n')}\n`;
32
+ const re = new RegExp(`^${key}:[^\\n]*\\n(?:(?!\\S+:).*\\n?)*`, 'm');
33
+ // A replacer function: a string replacement would expand `$&` or `$'` inside a line.
34
+ if (re.test(body)) return body.replace(re, () => `${block}\n`).replace(/\n{3,}/g, '\n\n');
35
+
36
+ return `${body.trimEnd()}\n\n${block}`;
37
+ }
38
+
39
+ /**
40
+ * The `key:` bullet block without `dropLines` — the inverse of upsertBlock. A block left empty
41
+ * gets the `- none:` placeholder back, as a new systems doc starts.
42
+ */
43
+ export function removeLines(rawBody, key, dropLines) {
44
+ const body = lf(rawBody);
45
+ const existing = blockLines(body, key);
46
+ const kept = existing.filter((l) => !dropLines.includes(l));
47
+ if (kept.length === existing.length) return body;
48
+ const block = `${key}:\n${(kept.length ? kept : [NONE_LINE]).join('\n')}\n`;
49
+ const re = new RegExp(`^${key}:[^\\n]*\\n(?:(?!\\S+:).*\\n?)*`, 'm');
50
+
51
+ return body.replace(re, () => `${block}\n`).replace(/\n{3,}/g, '\n\n');
52
+ }
53
+
54
+ const lf = (text) => String(text).replace(/\r\n/g, '\n');
55
+
56
+ /** Purpose lines a new systems doc starts with: design/<sys>.md's `purpose:`, or a marker. */
57
+ async function purposeLines(root, system) {
58
+ const file = path.join(root, 'design', `${system}.md`);
59
+ const text = existsSync(file) ? lf(await readFile(file, 'utf8')) : '';
60
+ const pre = text.split(/^## /m)[0];
61
+ const bullets = blockLines(pre, 'purpose').filter((l) => !/^- (TBD|none\b)/.test(l));
62
+ if (bullets.length) return bullets;
63
+ const scalar = scalarIn(pre, 'purpose');
64
+ if (scalar && scalar !== 'TBD') return [`- ${scalar}`];
65
+
66
+ return [`- the purpose of ${system} is not written yet [until: manual(purpose of ${system})]`];
67
+ }
68
+
69
+ /**
70
+ * The systems doc of `system`, read, or started: purpose from design/, every other required
71
+ * block a `- none:` placeholder until a merge brings content.
72
+ */
73
+ export async function openSystemDoc(root, system) {
74
+ const file = path.join(root, 'systems', `${system}.md`);
75
+ if (existsSync(file)) return { file, ...parse(lf(await readFile(file, 'utf8'))) };
76
+ const purpose = (await purposeLines(root, system)).join('\n');
77
+ const body = [
78
+ `purpose:\n${purpose}`,
79
+ `code:\n${NONE_LINE}`,
80
+ `behavior:\n${NONE_LINE}`,
81
+ `constraints:\n${NONE_LINE}`,
82
+ ].join('\n\n');
83
+
84
+ return { file, data: { system }, body: `\n${body}\n` };
85
+ }