gcf-aif-ext 0.2.7 → 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.
- package/README.md +6 -4
- package/commands/gcf.js +190 -13
- package/extension.json +1 -1
- package/hooks/pre-commit.js +34 -30
- package/injections/commit-status.md +1 -0
- package/injections/plan-context.md +3 -2
- package/lib/anchors.js +36 -34
- package/lib/audit-core.js +56 -26
- package/lib/bundle.js +275 -0
- package/lib/context.js +3 -2
- package/lib/design-check.js +580 -0
- package/lib/design.js +179 -0
- package/lib/git.js +63 -0
- package/lib/id.js +7 -15
- package/lib/index-gen.js +42 -8
- package/lib/ledger.js +92 -0
- package/lib/linter.js +14 -3
- package/lib/migrate.js +345 -0
- package/lib/playtest-list.js +1 -1
- package/lib/systems-doc.js +70 -0
- package/lib/unit-ops.js +270 -0
- package/lib/validate-card.js +29 -18
- package/lib/verdict-fs.js +102 -88
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +2 -1
- package/profiles/unit-constraint.md +42 -0
- package/profiles/unit-entity.md +38 -0
- package/profiles/unit-mechanic.md +106 -0
- package/profiles/unit-resource.md +86 -0
- package/profiles/unit-rule.md +42 -0
- package/schemas/card.schema.json +23 -7
- package/skills/gcf-adopt/SKILL.md +1 -1
- package/skills/gcf-audit/SKILL.md +4 -3
- package/skills/gcf-card/SKILL.md +25 -16
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +39 -23
- package/skills/gcf-init/SKILL.md +3 -3
- package/skills/gcf-verdict/SKILL.md +6 -5
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -5
- package/templates/card.md +27 -10
- package/templates/cnlp.config.json +0 -1
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/system.md +1 -1
- package/profiles/card.md +0 -65
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
|
+
}
|
package/lib/playtest-list.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}));
|
|
@@ -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
|
+
}
|
package/lib/unit-ops.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
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';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { serialize } from './frontmatter.js';
|
|
9
|
+
import { CARD_TYPES, listOf } from './design.js';
|
|
10
|
+
import { buildLedger } from './ledger.js';
|
|
11
|
+
import { designCheck } from './design-check.js';
|
|
12
|
+
import { atomicWrite, withFileRollback } from './safe-path.js';
|
|
13
|
+
import { writeIndex } from './index-gen.js';
|
|
14
|
+
import { operationInProgress, commitPaths } from './git.js';
|
|
15
|
+
import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
16
|
+
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
|
+
|
|
18
|
+
/** A design file's text without the `## <id>` section. */
|
|
19
|
+
export function removeUnit(text, id) {
|
|
20
|
+
const lines = String(text).split('\n');
|
|
21
|
+
const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
|
|
22
|
+
if (at < 0) return text;
|
|
23
|
+
let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
|
|
24
|
+
if (end < 0) end = lines.length;
|
|
25
|
+
|
|
26
|
+
return `${[...lines.slice(0, at), ...lines.slice(end)]
|
|
27
|
+
.join('\n')
|
|
28
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
29
|
+
.trimEnd()}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** backlog.md without the line of `id`. */
|
|
33
|
+
export const backlogWithout = (text, id) =>
|
|
34
|
+
String(text)
|
|
35
|
+
.split('\n')
|
|
36
|
+
.filter((l) => l.match(/^- (\S+)/)?.[1] !== id)
|
|
37
|
+
.join('\n');
|
|
38
|
+
|
|
39
|
+
/** 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);
|
|
42
|
+
const { live, terminal } = ledger.where(id);
|
|
43
|
+
if (live.length !== 1 || live[0].state !== 'design') {
|
|
44
|
+
const at = live.map((p) => p.file).join(', ') || terminal.join(', ') || 'nowhere';
|
|
45
|
+
throw new Error(`${id} is not a single unit of gcf/design/ (it is: ${at})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const unit = ledger.design.flatMap((d) => d.units).find((u) => u.id === id);
|
|
49
|
+
|
|
50
|
+
return /** @type {import('./design.js').Unit} */ (unit);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Files a move rewrites: the design file, the backlog, then whatever the move adds. */
|
|
54
|
+
function basePaths(root, unit) {
|
|
55
|
+
return { design: path.join(root, unit.file), backlog: path.join(root, 'backlog.md') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function cutUnit(paths, id) {
|
|
59
|
+
await atomicWrite(paths.design, removeUnit(await readFile(paths.design, 'utf8'), id));
|
|
60
|
+
if (existsSync(paths.backlog))
|
|
61
|
+
await atomicWrite(paths.backlog, backlogWithout(await readFile(paths.backlog, 'utf8'), id));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The frontmatter a card starts with: the unit's header plus the fields of the card stage. */
|
|
65
|
+
function cardData(unit, today) {
|
|
66
|
+
const h = unit.header;
|
|
67
|
+
const pillars = listOf(h.pillars);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
schema: 2,
|
|
71
|
+
id: unit.id,
|
|
72
|
+
type: h.type,
|
|
73
|
+
...(h.title ? { title: h.title } : {}),
|
|
74
|
+
status: 'proposed',
|
|
75
|
+
system: listOf(h.system),
|
|
76
|
+
depends: listOf(h.depends),
|
|
77
|
+
pillars: pillars === 'TBD' ? [] : pillars,
|
|
78
|
+
source: h.source,
|
|
79
|
+
...(h.reopens ? { reopens: h.reopens } : {}),
|
|
80
|
+
tune_count: 0,
|
|
81
|
+
built_at: null,
|
|
82
|
+
created: today,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
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
|
+
);
|
|
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`);
|
|
110
|
+
}
|
|
111
|
+
|
|
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);
|
|
117
|
+
await writeIndex(root);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return { card: `gcf/cards/${id}.md` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A file's text at a git revision spec (`HEAD:./x`, `:./x` for the index), '' when absent. */
|
|
124
|
+
function gitText(projectDir, spec) {
|
|
125
|
+
const res = spawnSync('git', ['show', spec], { cwd: projectDir, encoding: 'utf8' });
|
|
126
|
+
|
|
127
|
+
return res.status === 0 ? res.stdout : '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* True when `file` differs from HEAD in the index or on disk. Line endings do not count: a file
|
|
132
|
+
* stored with CRLF and checked out with LF is not an edit.
|
|
133
|
+
*/
|
|
134
|
+
function uncommitted(projectDir, file) {
|
|
135
|
+
const rel = `./${path.relative(projectDir, file).replaceAll('\\', '/')}`;
|
|
136
|
+
const norm = (t) => String(t).replace(/\r\n/g, '\n').trimEnd();
|
|
137
|
+
const head = norm(gitText(projectDir, `HEAD:${rel}`));
|
|
138
|
+
const disk = norm(existsSync(file) ? readFileSync(file, 'utf8') : '');
|
|
139
|
+
|
|
140
|
+
return head !== norm(gitText(projectDir, `:${rel}`)) || head !== disk;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run a move that ends with its fixed-format commit, all of it or none of it. The commit takes
|
|
145
|
+
* whole files and deletes the unit's section, so any uncommitted edit in them refuses the move
|
|
146
|
+
* 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.
|
|
148
|
+
*/
|
|
149
|
+
async function commitMove(projectDir, message, { files, mutate, commit }) {
|
|
150
|
+
const busy = commit ? operationInProgress(projectDir) : null;
|
|
151
|
+
if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
|
|
152
|
+
const dirty = commit ? files.filter((f) => uncommitted(projectDir, f)) : [];
|
|
153
|
+
if (dirty.length) {
|
|
154
|
+
const names = dirty.map((f) => path.relative(projectDir, f)).join(', ');
|
|
155
|
+
throw new Error(
|
|
156
|
+
`commit or stash these first (an answered unit is committed before its move), ` +
|
|
157
|
+
`${message} would carry them: ${names}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
162
|
+
const touched = [...files, path.join(root, 'INDEX.md')];
|
|
163
|
+
/** @type {string | null} */
|
|
164
|
+
let hash = null;
|
|
165
|
+
await withFileRollback(
|
|
166
|
+
touched,
|
|
167
|
+
async () => {
|
|
168
|
+
await mutate();
|
|
169
|
+
await writeIndex(root);
|
|
170
|
+
if (commit) hash = commitPaths(projectDir, message, touched);
|
|
171
|
+
},
|
|
172
|
+
{ gitCwd: commit ? path.resolve(projectDir) : null },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return hash;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
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).
|
|
181
|
+
*/
|
|
182
|
+
export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
|
|
183
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
184
|
+
const unit = await designUnit(projectDir, id);
|
|
185
|
+
if (CARD_TYPES.includes(unit.header.type))
|
|
186
|
+
throw new Error(`${id} is soft: it leaves design/ through a card and a verdict`);
|
|
187
|
+
const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
|
|
188
|
+
if (summary?.tbd)
|
|
189
|
+
throw new Error(`${id} still carries ${summary.tbd} TBD: it was built on a hole — answer it`);
|
|
190
|
+
const paths = basePaths(root, unit);
|
|
191
|
+
const doc = constraints.length ? await openSystemDoc(root, unit.system) : null;
|
|
192
|
+
const files = [paths.design, paths.backlog, path.join(root, 'history.md')];
|
|
193
|
+
if (doc) files.push(doc.file);
|
|
194
|
+
const mutate = async () => {
|
|
195
|
+
await cutUnit(paths, id);
|
|
196
|
+
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));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
hash: await commitMove(projectDir, `gcf(done): ${id}`, { files, mutate, commit }),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
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
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
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
|
+
*/
|
|
245
|
+
export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
|
|
246
|
+
if (!String(reason ?? '').trim())
|
|
247
|
+
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
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
257
|
+
const unit = await designUnit(projectDir, id);
|
|
258
|
+
const paths = basePaths(root, unit);
|
|
259
|
+
const system = listOf(unit.header.system);
|
|
260
|
+
const files = [paths.design, paths.backlog, path.join(root, 'graveyard.md')];
|
|
261
|
+
const mutate = async () => {
|
|
262
|
+
await buryLine(root, id, reason, tags.length ? tags : system === 'TBD' ? [] : system);
|
|
263
|
+
await cutUnit(paths, id);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
id,
|
|
268
|
+
hash: await commitMove(projectDir, `gcf(drop): ${id}`, { files, mutate, commit }),
|
|
269
|
+
};
|
|
270
|
+
}
|