gcf-aif-ext 0.2.5 → 0.3.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 (55) hide show
  1. package/README.md +7 -5
  2. package/commands/gcf.js +408 -73
  3. package/extension.json +1 -1
  4. package/hooks/pre-commit.js +47 -29
  5. package/injections/commit-status.md +1 -0
  6. package/injections/plan-context.md +3 -2
  7. package/injections/review-pillars.md +1 -1
  8. package/lib/anchors.js +110 -10
  9. package/lib/audit-core.js +175 -50
  10. package/lib/bundle.js +275 -0
  11. package/lib/context.js +55 -36
  12. package/lib/design-check.js +580 -0
  13. package/lib/design.js +179 -0
  14. package/lib/frontmatter.js +2 -1
  15. package/lib/gcf-paths.js +14 -3
  16. package/lib/git.js +85 -4
  17. package/lib/id.js +8 -14
  18. package/lib/index-gen.js +68 -16
  19. package/lib/ledger.js +92 -0
  20. package/lib/linter.js +91 -42
  21. package/lib/migrate.js +345 -0
  22. package/lib/playtest-list.js +1 -1
  23. package/lib/safe-path.js +23 -4
  24. package/lib/systems-doc.js +70 -0
  25. package/lib/transitions.js +1 -3
  26. package/lib/unit-ops.js +270 -0
  27. package/lib/validate-card.js +59 -14
  28. package/lib/verdict-fs.js +173 -96
  29. package/package.json +1 -1
  30. package/profiles/game.md +68 -0
  31. package/profiles/glossary.md +37 -0
  32. package/profiles/pillars.md +2 -1
  33. package/profiles/unit-constraint.md +42 -0
  34. package/profiles/unit-entity.md +38 -0
  35. package/profiles/unit-mechanic.md +106 -0
  36. package/profiles/unit-resource.md +86 -0
  37. package/profiles/unit-rule.md +42 -0
  38. package/schemas/card.schema.json +23 -7
  39. package/skills/gcf-adopt/SKILL.md +3 -3
  40. package/skills/gcf-audit/SKILL.md +4 -3
  41. package/skills/gcf-card/SKILL.md +26 -16
  42. package/skills/gcf-debate/SKILL.md +17 -14
  43. package/skills/gcf-ingest/SKILL.md +43 -16
  44. package/skills/gcf-init/SKILL.md +9 -8
  45. package/skills/gcf-verdict/SKILL.md +7 -6
  46. package/skills/gcf-vision/SKILL.md +17 -11
  47. package/templates/PILLARS.md +2 -0
  48. package/templates/backlog.md +2 -4
  49. package/templates/card.md +27 -10
  50. package/templates/cnlp.config.json +0 -1
  51. package/templates/design.md +59 -0
  52. package/templates/game.md +24 -0
  53. package/templates/glossary.md +11 -0
  54. package/templates/system.md +1 -1
  55. package/profiles/card.md +0 -65
package/lib/linter.js CHANGED
@@ -1,10 +1,24 @@
1
1
  // П2 linter (GCF-PRD §13.2): explicit patterns over systems docs, no ML.
2
2
  // `<!-- gcf:allow <reason> -->` on the preceding line suppresses the next line's finding;
3
3
  // a pragma without a reason is itself a finding. cnlp check runs separately (form/lexicon).
4
+ import { UNITS, closingComparison } from './design-check.js';
4
5
 
5
- const UNIT_NUM = /\d+(?:[.,]\d+)?\s?(?:ms|мс|s|с|dmg|hp|px|сек)\b/;
6
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ // One unit list with the design check (M5), plus the units balance values come in. The end of
8
+ // a unit is any non-letter in any script: `\w` is ASCII-only, and "5 слотов" is not "5 с".
9
+ const UNIT_WORDS = [...UNITS, 'мс', 'с', 'сек', 'dmg', 'hp', 'px'].map(esc).join('|');
10
+ const UNIT_NUM = new RegExp(`\\d+(?:[.,]\\d+)?\\s?(?:${UNIT_WORDS})(?![\\p{L}\\p{N}_-])`, 'u');
6
11
  const SIGNATURE = /\w+\s*\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
7
12
  const CAMEL = /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/;
13
+ const ENVELOPE_FIELD = /^\s*(?:- )?(?:question|assumed|proposed):/;
14
+
15
+ /**
16
+ * @typedef {object} LintState
17
+ * @property {string[]} lines
18
+ * @property {Array<{line: number, rule: string, text: string}>} issues
19
+ * @property {Array<{line: number, reason: string | null}>} pragmas
20
+ * @property {number} fenceStart line index of the open fence, -1 when outside one
21
+ */
8
22
 
9
23
  /** Which `key:` block a line sits in (flat CNL-P body). */
10
24
  function blockOf(lines, i) {
@@ -12,50 +26,85 @@ function blockOf(lines, i) {
12
26
  const m = lines[j].match(/^([a-z_]+):\s*$/) ?? lines[j].match(/^([a-z_]+):\s+\S/);
13
27
  if (m) return m[1];
14
28
  }
29
+
15
30
  return null;
16
31
  }
17
32
 
18
33
  export function lint(content) {
19
- const lines = String(content).split('\n');
20
- const issues = [];
21
- const pragmas = [];
22
- let fenceStart = -1;
23
-
24
- for (let i = 0; i < lines.length; i++) {
25
- const line = lines[i];
26
- const prev = lines[i - 1] ?? '';
27
-
28
- const pragma = prev.match(/<!--\s*gcf:allow\s*(.*?)\s*-->/);
29
- if (pragma && !pragma[1]) issues.push({ line: i, rule: 'pragma-without-reason', text: prev.trim() });
30
- const allowed = Boolean(pragma && pragma[1]);
31
- if (pragma) pragmas.push({ line: i, reason: pragma[1] || null });
32
-
33
- if (line.startsWith('```')) {
34
- if (fenceStart < 0) fenceStart = i;
35
- else {
36
- if (i - fenceStart - 1 > 3) issues.push({ line: fenceStart + 1, rule: 'fenced-code-over-3-lines', text: `${i - fenceStart - 1} lines` });
37
- fenceStart = -1;
38
- }
39
- continue;
40
- }
41
- if (fenceStart >= 0 || allowed) continue;
42
-
43
- const block = blockOf(lines, i);
44
- if (block !== 'feel' && UNIT_NUM.test(line)) {
45
- issues.push({ line: i + 1, rule: 'number-with-unit-outside-feel', text: line.trim() });
46
- }
47
- if (SIGNATURE.test(line)) {
48
- issues.push({ line: i + 1, rule: 'signature', text: line.trim() });
49
- }
50
- // Three or more consecutive bullets each naming CamelCase identifiers = a member list.
51
- if (/^- /.test(line) && CAMEL.test(line)
52
- && /^- /.test(prev) && CAMEL.test(prev)
53
- && /^- /.test(lines[i - 2] ?? '') && CAMEL.test(lines[i - 2] ?? '')) {
54
- issues.push({ line: i + 1, rule: 'camelcase-member-list', text: line.trim() });
55
- }
34
+ // CRLF from a Windows checkout: a trailing `\r` would defeat every end-anchored pattern.
35
+ const lines = String(content).replace(/\r\n/g, '\n').split('\n');
36
+ /** @type {LintState} */
37
+ const st = { lines, issues: [], pragmas: [], fenceStart: -1 };
38
+
39
+ for (let i = 0; i < st.lines.length; i++) {
40
+ const allowed = readPragma(st, i);
41
+ if (st.lines[i].startsWith('```')) toggleFence(st, i);
42
+ else if (st.fenceStart < 0 && !allowed) checkLine(st, i);
43
+ }
44
+
45
+ // An unclosed fence leaves fenceStart set, so the loop above skipped every remaining
46
+ // line: one stray fence would otherwise disable П2 for the rest of the file.
47
+ if (st.fenceStart >= 0)
48
+ st.issues.push({
49
+ line: st.fenceStart + 1,
50
+ rule: 'unterminated-fence',
51
+ text: 'code fence never closed',
52
+ });
53
+
54
+ return { issues: st.issues, pragmas: st.pragmas };
55
+ }
56
+
57
+ /** Records a pragma on the previous line; true when it carries a reason and so allows line i. */
58
+ function readPragma(st, i) {
59
+ const prev = st.lines[i - 1] ?? '';
60
+ const pragma = prev.match(/<!--\s*gcf:allow\s*(.*?)\s*-->/);
61
+ if (!pragma) return false;
62
+ if (!pragma[1]) st.issues.push({ line: i, rule: 'pragma-without-reason', text: prev.trim() });
63
+ st.pragmas.push({ line: i, reason: pragma[1] || null });
64
+
65
+ return Boolean(pragma[1]);
66
+ }
67
+
68
+ /** Opens a fence, or closes it and flags a body over 3 lines. */
69
+ function toggleFence(st, i) {
70
+ if (st.fenceStart < 0) {
71
+ st.fenceStart = i;
72
+
73
+ return;
74
+ }
75
+
76
+ const size = i - st.fenceStart - 1;
77
+ if (size > 3)
78
+ st.issues.push({
79
+ line: st.fenceStart + 1,
80
+ rule: 'fenced-code-over-3-lines',
81
+ text: `${size} lines`,
82
+ });
83
+ st.fenceStart = -1;
84
+ }
85
+
86
+ const memberBullet = (line) => /^- /.test(line) && CAMEL.test(line);
87
+
88
+ /** Pattern rules for one prose line (outside fences, not pragma-allowed). */
89
+ function checkLine(st, i) {
90
+ const line = st.lines[i];
91
+ const flag = (rule) => st.issues.push({ line: i + 1, rule, text: line.trim() });
92
+
93
+ // Envelopes are legal where the spec puts them: the feel block, and the question/assumed/
94
+ // proposed fields of an open-questions record, where import parks D2 envelopes for the human.
95
+ // An exact limit is legal as a closing comparison in any block (GCF-PRD §11.3), as in the
96
+ // card M5 accepted: a boundary collapses into `behavior:` and must not fail its own verdict.
97
+ const block = blockOf(st.lines, i);
98
+ const limit = closingComparison(line);
99
+ if (block !== 'feel' && !limit && !ENVELOPE_FIELD.test(line) && UNIT_NUM.test(line)) {
100
+ flag('number-with-unit-outside-feel');
101
+ }
102
+
103
+ if (SIGNATURE.test(line)) flag('signature');
104
+
105
+ // Three or more consecutive bullets each naming CamelCase identifiers = a member list.
106
+ const prev = st.lines[i - 1] ?? '';
107
+ if (memberBullet(line) && memberBullet(prev) && memberBullet(st.lines[i - 2] ?? '')) {
108
+ flag('camelcase-member-list');
56
109
  }
57
- // An unclosed fence leaves fenceStart set, so the loop above `continue`d past every
58
- // remaining line: one stray fence would otherwise disable П2 for the rest of the file.
59
- if (fenceStart >= 0) issues.push({ line: fenceStart + 1, rule: 'unterminated-fence', text: 'code fence never closed' });
60
- return { issues, pragmas };
61
110
  }
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
+ }
@@ -6,7 +6,7 @@ export async function playtestList(root) {
6
6
  .filter((c) => c.data.status === 'built')
7
7
  .map((c) => ({
8
8
  id: c.data.id,
9
- slug: c.data.slug,
9
+ title: c.data.title ?? null,
10
10
  system: c.data.system ?? [],
11
11
  question: scalarOf(c.body, 'miss_criterion') || '(miss_criterion is empty)',
12
12
  }));
package/lib/safe-path.js CHANGED
@@ -21,6 +21,7 @@ export function resolveInside(projectDir, target) {
21
21
  ' next: pass a path within the project root',
22
22
  );
23
23
  }
24
+
24
25
  return resolved;
25
26
  }
26
27
 
@@ -39,9 +40,21 @@ export async function atomicWrite(file, data) {
39
40
  }
40
41
  }
41
42
 
43
+ /** Put one snapshotted file back: `null` content means it did not exist before. */
44
+ async function restoreSnapshot(file, content) {
45
+ if (content === null)
46
+ await unlink(file).catch((e) => {
47
+ if (e.code !== 'ENOENT') throw e;
48
+ });
49
+ else await atomicWrite(file, content);
50
+ }
51
+
42
52
  /**
43
53
  * Restore every listed file to its original bytes when `action` fails. With `gitCwd` (an
44
54
  * existing directory in the work tree) the files' git index entries are restored as well.
55
+ * @param {string[]} files
56
+ * @param {() => Promise<any>} action
57
+ * @param {{ gitCwd?: string | null }} [options]
45
58
  */
46
59
  export async function withFileRollback(files, action, { gitCwd = null } = {}) {
47
60
  const unique = [...new Set(files)];
@@ -62,16 +75,22 @@ export async function withFileRollback(files, action, { gitCwd = null } = {}) {
62
75
  const rollbackErrors = [];
63
76
  for (const [file, content] of snapshots) {
64
77
  try {
65
- if (content === null) await unlink(file).catch((e) => { if (e.code !== 'ENOENT') throw e; });
66
- else await atomicWrite(file, content);
78
+ await restoreSnapshot(file, content);
67
79
  } catch (rollbackError) {
68
80
  rollbackErrors.push(rollbackError);
69
81
  }
70
82
  }
83
+
71
84
  // An inner step may already have staged its writes. Re-adding the restored files would
72
- // fold the user's unstaged edits into the index; putting back the old entries keeps them apart.
85
+ // fold the user's unstaged edits into the index; putting back the old entries keeps
86
+ // them apart.
73
87
  rollbackErrors.push(...restoreIndex(index));
74
- if (rollbackErrors.length) throw new AggregateError([err, ...rollbackErrors], `Operation failed and rollback was incomplete: ${err.message}`);
88
+ if (rollbackErrors.length)
89
+ throw new AggregateError(
90
+ [err, ...rollbackErrors],
91
+ `Operation failed and rollback was incomplete: ${err.message}`,
92
+ { cause: err },
93
+ );
75
94
  throw err;
76
95
  }
77
96
  }
@@ -0,0 +1,70 @@
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 card 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
+ const lf = (text) => String(text).replace(/\r\n/g, '\n');
40
+
41
+ /** Purpose lines a new systems doc starts with: design/<sys>.md's `purpose:`, or a marker. */
42
+ async function purposeLines(root, system) {
43
+ const file = path.join(root, 'design', `${system}.md`);
44
+ const text = existsSync(file) ? lf(await readFile(file, 'utf8')) : '';
45
+ const pre = text.split(/^## /m)[0];
46
+ const bullets = blockLines(pre, 'purpose').filter((l) => !/^- (TBD|none\b)/.test(l));
47
+ if (bullets.length) return bullets;
48
+ const scalar = scalarIn(pre, 'purpose');
49
+ if (scalar && scalar !== 'TBD') return [`- ${scalar}`];
50
+
51
+ return [`- the purpose of ${system} is not written yet [until: manual(purpose of ${system})]`];
52
+ }
53
+
54
+ /**
55
+ * The systems doc of `system`, read, or started: purpose from design/, every other required
56
+ * block a `- none:` placeholder until a merge brings content.
57
+ */
58
+ export async function openSystemDoc(root, system) {
59
+ const file = path.join(root, 'systems', `${system}.md`);
60
+ if (existsSync(file)) return { file, ...parse(lf(await readFile(file, 'utf8'))) };
61
+ const purpose = (await purposeLines(root, system)).join('\n');
62
+ const body = [
63
+ `purpose:\n${purpose}`,
64
+ `code:\n${NONE_LINE}`,
65
+ `behavior:\n${NONE_LINE}`,
66
+ `constraints:\n${NONE_LINE}`,
67
+ ].join('\n\n');
68
+
69
+ return { file, data: { system }, body: `\n${body}\n` };
70
+ }
@@ -19,9 +19,7 @@ export function isLegal(from, to) {
19
19
 
20
20
  /** Statuses reachable from `from`, for error messages. */
21
21
  export function legalTargets(from) {
22
- return [...LEGAL]
23
- .filter((t) => t.startsWith(`${from}>`))
24
- .map((t) => t.split('>')[1]);
22
+ return [...LEGAL].filter((t) => t.startsWith(`${from}>`)).map((t) => t.split('>')[1]);
25
23
  }
26
24
 
27
25
  /** A status a card FILE may carry. Terminal statuses exist only in the transition itself. */