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/audit-core.js CHANGED
@@ -2,91 +2,127 @@ import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { listCards, listSystems, readConfig, tokensEst } from './gcf-paths.js';
5
+ import { listItems, listSystems, readConfig, tokensEst, legacyCards } from './gcf-paths.js';
6
6
  import { lint } from './linter.js';
7
7
  import { drifted } from './index-gen.js';
8
+ import { designCheck, recordsIn } from './design-check.js';
9
+ import { buildLedger } from './ledger.js';
10
+ import { STORED } from './transitions.js';
11
+ import { openDepends } from './work-ops.js';
8
12
 
9
13
  function git(args, cwd) {
10
- const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
14
+ // A blame of a long file overflows the 1 MiB default, and an overflow reads as a git failure.
15
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
11
16
 
12
17
  return res.status === 0 ? res.stdout.trim() : null;
13
18
  }
14
19
 
15
- const UNTIL_RE = /\[until:\s*(verdict\(card-\d{3}\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]/g;
16
- const LEGAL_STATUS = ['proposed', 'built', 'tuning'];
20
+ const UNTIL_RE = /\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]/g;
21
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
22
 
18
23
  /**
19
24
  * @typedef {object} AuditReport
20
25
  * @property {string} first_line
21
- * @property {Array<{id: string, commits: number | null, limit: number}>} stale_built
26
+ * @property {Array<{id: string, status: string, commits: number | null, limit: number}>}
27
+ * stale_waiting
22
28
  * @property {Array<{id: string, tune_count: number, limit: number}>} tune_limit
23
29
  * @property {Array<{id: string, depends: string[]}>} open_depends
24
30
  * @property {{count: number, limit: number, over: boolean}} wip
25
- * @property {{tokens: number, budget: number, over: boolean} | null} pillars
31
+ * @property {{count: number, warn_after: number, over: boolean}} unplayed
32
+ * @property {string[]} legacy_cards
33
+ * @property {{tokens: number, budget: number, over: boolean, count: number, too_many: boolean}
34
+ * | null} pillars
26
35
  * @property {Array<{file: string, trigger: string, status: string}>} until
27
36
  * @property {Record<string, object[]>} linter
28
37
  * @property {object[]} pragmas
29
38
  * @property {boolean} index_drift
30
39
  * @property {Array<{id: string, status: string}>} illegal_status
40
+ * @property {Array<{id: string, commits: number | null, limit: number}>} stale_questions
31
41
  * @property {number} [schema]
32
- * @property {Array<{id: string, status: string, system: string[], depends: string[]}>} [cards]
42
+ * @property {Array<{id: string, type: string, gate: string, status: string, system: string[],
43
+ * depends: string[], parent: string | null, tune_count: number}>} [items]
44
+ * @property {{blocking: number, units: number, ready: number}} [design]
33
45
  */
34
46
 
35
47
  /**
36
- * Deterministic audit core (GCF-PRD §10.1): until triggers, built staleness, tune
37
- * limits, WIP, PILLARS budget, П2 linter, INDEX drift. No LLM anywhere; the
48
+ * Deterministic audit core (GCF-PRD §10.1): until triggers, waiting staleness, tune limits,
49
+ * WIP, unplayed count, PILLARS budget, П2 linter, INDEX drift. No LLM anywhere; the
38
50
  * contradiction hunt is a separate optional skill pass.
39
51
  */
40
52
  export async function audit(projectDir) {
41
53
  const root = path.join(path.resolve(projectDir), 'gcf');
42
54
  const config = await readConfig(root);
43
- const cards = await listCards(root);
55
+ const items = await listItems(root);
44
56
  const systems = await listSystems(root);
57
+ const ledger = await buildLedger(projectDir);
58
+ const unplayed = items.filter((c) => c.data.status === 'unplayed').length;
45
59
  /** @type {AuditReport} */
46
60
  const report = {
47
61
  first_line: '',
48
- stale_built: [],
62
+ stale_waiting: [],
49
63
  tune_limit: [],
50
64
  open_depends: [],
51
- wip: wipOf(cards, config),
65
+ wip: wipOf(items, config),
66
+ unplayed: {
67
+ count: unplayed,
68
+ warn_after: config.unplayed_warn_after,
69
+ over: unplayed > config.unplayed_warn_after,
70
+ },
71
+ legacy_cards: legacyCards(root),
52
72
  pillars: await pillarsOf(root, config),
53
- until: untilRecords(cards, systems, projectDir),
73
+ until: untilRecords(items, systems, projectDir, ledger),
54
74
  linter: {},
55
75
  pragmas: [],
56
76
  index_drift: await drifted(root),
57
77
  illegal_status: [],
78
+ stale_questions: staleQuestions(projectDir, root, config),
58
79
  };
59
80
  // The harness gate reads everything from this one report — it never parses gcf/ itself.
60
81
  report.schema = config.schema;
61
- report.cards = cards.map((c) => ({
82
+ report.items = items.map((c) => ({
62
83
  id: c.data.id,
84
+ type: c.data.type,
85
+ gate: c.data.gate,
63
86
  status: c.data.status,
64
87
  system: c.data.system ?? [],
65
88
  depends: c.data.depends ?? [],
89
+ parent: c.data.parent ?? null,
90
+ tune_count: c.data.tune_count ?? 0,
91
+ parallel: c.data.parallel ?? 'serial',
66
92
  }));
67
- checkCards(report, cards, config, projectDir);
93
+ const design = await designCheck(projectDir);
94
+ checkItems(report, items, config, { projectDir, ledger });
95
+ report.design = {
96
+ blocking: design.blocking,
97
+ units: design.units.length,
98
+ ready: design.units.filter((u) => u.ready).length,
99
+ };
68
100
  lintSystems(report, systems);
69
101
  report.first_line = firstLine(report);
70
102
 
71
103
  return report;
72
104
  }
73
105
 
74
- /** Per-card checks: illegal status, built staleness, tune limit, open depends. */
75
- function checkCards(report, cards, config, projectDir) {
76
- const liveIds = new Set(cards.map((c) => c.data.id));
106
+ /**
107
+ * Per-item checks: illegal status, waiting staleness, tune limit, open depends — a dependency
108
+ * still in design/ or in work, not unplayed or parked, is what nobody has played yet.
109
+ */
110
+ function checkItems(report, items, config, { projectDir, ledger }) {
77
111
  // Staleness is measured in commits, so outside a repository there is no answer to give.
78
- // Without this probe every built card would read as stale and stop the harness.
112
+ // Without this probe every waiting item would read as stale and stop the harness.
79
113
  const inRepo = git(['rev-parse', '--git-dir'], projectDir) !== null;
80
114
 
81
- for (const c of cards) {
82
- if (!LEGAL_STATUS.includes(c.data.status)) {
115
+ for (const c of items) {
116
+ if (!STORED.includes(c.data.status)) {
83
117
  report.illegal_status.push({ id: c.data.id, status: c.data.status });
84
118
  }
85
119
 
86
120
  const stale = inRepo ? staleEntry(c, config, projectDir) : null;
87
- if (stale) report.stale_built.push(stale);
121
+ if (stale) report.stale_waiting.push(stale);
88
122
 
89
- if ((c.data.tune_count ?? 0) >= config.tune_limit) {
123
+ // At the limit, only the next verdict matters: a ready item still gets its last build.
124
+ const verdictDue = ['awaiting-verdict', 'unplayed'].includes(c.data.status);
125
+ if (verdictDue && (c.data.tune_count ?? 0) >= config.tune_limit) {
90
126
  report.tune_limit.push({
91
127
  id: c.data.id,
92
128
  tune_count: c.data.tune_count,
@@ -94,23 +130,26 @@ function checkCards(report, cards, config, projectDir) {
94
130
  });
95
131
  }
96
132
 
97
- // A depends id that still has a card file is unplayed (GCF-PRD §7.2). Data for the
98
- // harness gate, not an alert: a proposed card waiting on its dependency is normal.
99
- const open = (c.data.depends ?? []).filter((d) => liveIds.has(d));
133
+ // Data for the harness gate, not an alert: a ready item waiting on its dependency is normal.
134
+ // A run may start on a dependency awaiting its verdict (Methodology §3.2); close and keep
135
+ // check the stricter rule themselves.
136
+ const open = openDepends(ledger, c.data.depends ?? [], { waiting: true });
100
137
  if (open.length) report.open_depends.push({ id: c.data.id, depends: open });
101
138
  }
102
139
  }
103
140
 
104
- /** A stale_built entry for a built card past the commit limit, else null. */
141
+ /** A stale_waiting entry for an item waiting on the operator past the commit limit, else null. */
105
142
  function staleEntry(c, config, projectDir) {
106
- if (c.data.status !== 'built' || !c.data.built_at) return null;
143
+ const waits = ['awaiting-verdict', 'awaiting-decision', 'blocked'].includes(c.data.status);
144
+ if (!waits || !c.data.since) return null;
107
145
  // git() is null when the ref no longer resolves (rebase, squash, amend, shallow clone).
108
146
  // Number(null) is 0, which reads as "fresh" — the one answer that must never be guessed.
109
- const raw = git(['rev-list', '--count', `${c.data.built_at}..HEAD`], projectDir);
147
+ const raw = git(['rev-list', '--count', `${c.data.since}..HEAD`], projectDir);
110
148
  const n = raw === null ? NaN : Number(raw);
111
149
  if (!Number.isFinite(n) || n > config.built_stale_after_commits) {
112
150
  return {
113
151
  id: c.data.id,
152
+ status: c.data.status,
114
153
  commits: Number.isFinite(n) ? n : null,
115
154
  limit: config.built_stale_after_commits,
116
155
  };
@@ -119,29 +158,90 @@ function staleEntry(c, config, projectDir) {
119
158
  return null;
120
159
  }
121
160
 
122
- function wipOf(cards, config) {
123
- const count = cards.filter((c) => c.data.status === 'built').length;
161
+ /** WIP counts what waits for the playtest; unplayed items have their own warning (§4). */
162
+ function wipOf(items, config) {
163
+ const count = items.filter((c) => c.data.status === 'awaiting-verdict').length;
124
164
 
125
165
  return { count, limit: config.wip_built_limit, over: count > config.wip_built_limit };
126
166
  }
127
167
 
168
+ // GCF-PRD §5.3: no more than 7 pillars.
169
+ const PILLARS_MAX = 7;
170
+
128
171
  async function pillarsOf(root, config) {
129
172
  const pillarsFile = path.join(root, 'PILLARS.md');
130
173
  if (!existsSync(pillarsFile)) return null;
131
- const tokens = tokensEst(await readFile(pillarsFile, 'utf8'));
174
+ const text = await readFile(pillarsFile, 'utf8');
175
+ const tokens = tokensEst(text);
176
+ const count = recordsIn(text, 'pillars').length;
132
177
 
133
- return { tokens, budget: config.pillars_budget, over: tokens > config.pillars_budget };
178
+ return {
179
+ tokens,
180
+ budget: config.pillars_budget,
181
+ over: tokens > config.pillars_budget,
182
+ count,
183
+ too_many: count > PILLARS_MAX,
184
+ };
134
185
  }
135
186
 
136
- /** Verdict history from commit messages of the fixed format (GCF-PRD §8). */
137
- function verdictMatchers(projectDir) {
187
+ /**
188
+ * Open questions older than the limit, aged by the last commit that changed their `- id:` line
189
+ * beyond whitespace (GCF-PRD §7.4): a pile of unanswered ones means the human has dropped out
190
+ * of the loop.
191
+ */
192
+ function staleQuestions(projectDir, root, config) {
193
+ if (!existsSync(path.join(root, 'open-questions.md'))) return [];
194
+ // null for an untracked file or outside a repository: no commit to age from. -w: a respaced
195
+ // or re-ended (CRLF/LF) line keeps the commit that wrote it.
196
+ const blame = git(['blame', '-w', '--porcelain', '--', 'gcf/open-questions.md'], projectDir);
197
+ if (blame === null) return [];
198
+ const added = [];
199
+ let hash = '';
200
+ for (const line of blame.split('\n')) {
201
+ const head = line.match(/^([0-9a-f]{40,64}) \d+ \d+/);
202
+ if (head) hash = head[1];
203
+ const id = line.match(/^\t\uFEFF?- id:\s*(\S+)/)?.[1];
204
+ // An uncommitted line blames to the all-zero hash: it is new, not old.
205
+ if (id && !/^0+$/.test(hash)) added.push({ id, hash });
206
+ }
207
+
208
+ const ages = new Map();
209
+ const ageOf = (h) => {
210
+ if (!ages.has(h)) {
211
+ const raw = git(['rev-list', '--count', `${h}..HEAD`], projectDir);
212
+ // Number(null) is 0, "fresh" — the one answer that must never be guessed (see staleEntry).
213
+ ages.set(h, raw === null ? null : Number(raw));
214
+ }
215
+
216
+ return ages.get(h);
217
+ };
218
+
219
+ const limit = config.question_stale_after_commits;
220
+
221
+ return added
222
+ .map(({ id, hash: h }) => ({ id, commits: ageOf(h), limit }))
223
+ .filter((q) => q.commits === null || q.commits > limit);
224
+ }
225
+
226
+ /**
227
+ * Whether a verdict fired. `verdict(id)` reads the files, as the ledger does (GCF-PRD §5.8): a
228
+ * keep or kill left a history or graveyard line, a tune left the item's tune_count — so a
229
+ * squash or a shallow clone does not hide it, and a revert takes it back.
230
+ */
231
+ function verdictMatchers(projectDir, ledger, items) {
232
+ const tuned = items.filter((c) => (c.data.tune_count ?? 0) > 0);
138
233
  const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s'], projectDir) ?? '';
139
234
 
140
235
  return {
141
- verdict: (cardId) => new RegExp(`^gcf\\(verdict\\): \\w+ ${cardId}\\b`, 'm').test(verdictLog),
142
- // ponytail: retune(sys) fires on any tune verdict naming the system; the PRD's
143
- // feel-section-diff refinement can replace this when it proves too coarse.
144
- retune: (system) => new RegExp(`^gcf\\(verdict\\): tune .*→ .*${system}`, 'm').test(verdictLog),
236
+ verdict: (id) =>
237
+ ledger.where(id).terminal.some((t) => t !== 'done') || tuned.some((c) => c.data.id === id),
238
+ // shortcut: a kept item's tunes leave no file behind, so retune(sys) also greps the verdict
239
+ // subjects; upgrade when tunes get their own history line.
240
+ retune: (system) =>
241
+ tuned.some((c) => (c.data.system ?? []).includes(system)) ||
242
+ new RegExp(`^gcf\\(verdict\\): tune .*→ (?:.*,)?${escapeRe(system)}(?:,|$)`, 'm').test(
243
+ verdictLog,
244
+ ),
145
245
  };
146
246
  }
147
247
 
@@ -157,11 +257,11 @@ function untilStatus(trigger, fired) {
157
257
  return 'manual';
158
258
  }
159
259
 
160
- /** Until annotations across cards and systems docs. */
161
- function untilRecords(cards, systems, projectDir) {
162
- const fired = verdictMatchers(projectDir);
260
+ /** Until annotations across work items and systems docs. */
261
+ function untilRecords(items, systems, projectDir, ledger) {
262
+ const fired = verdictMatchers(projectDir, ledger, items);
163
263
  const docs = [
164
- ...cards.map((c) => ({ rel: `cards/${c.name}`, body: c.body })),
264
+ ...items.map((c) => ({ rel: `work/${c.name}`, body: c.body })),
165
265
  ...systems.map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
166
266
  ];
167
267
  const until = [];
@@ -183,20 +283,37 @@ function lintSystems(report, systems) {
183
283
  }
184
284
  }
185
285
 
286
+ /** Alerts about items in flight, which lead the line. @param {AuditReport} report */
287
+ const itemAlerts = (report) => [
288
+ report.legacy_cards.length &&
289
+ `${report.legacy_cards.length} schema 2 card(s) in gcf/cards/ — back to gcf/design/, then take`,
290
+ report.illegal_status.length && `${report.illegal_status.length} item(s) in illegal status`,
291
+ report.stale_waiting.length &&
292
+ `${report.stale_waiting.length} item(s) waiting on you too long — playtest or answer first`,
293
+ report.tune_limit.length &&
294
+ `${report.tune_limit.length} item(s) at tune limit — kill or redesign`,
295
+ report.wip.over && `WIP over limit (${report.wip.count}/${report.wip.limit})`,
296
+ report.unplayed.over &&
297
+ `${report.unplayed.count} unplayed item(s) (warn after ` +
298
+ `${report.unplayed.warn_after}) — play them`,
299
+ ];
300
+
301
+ /** Alerts about the documents. @param {AuditReport} report */
302
+ const docAlerts = (report) => [
303
+ report.until.filter((u) => u.status === 'stale').length && 'stale until-records present',
304
+ report.pillars?.over && 'PILLARS over token budget',
305
+ report.pillars?.too_many && `PILLARS has ${report.pillars.count} pillars (max ${PILLARS_MAX})`,
306
+ report.stale_questions.length &&
307
+ `${report.stale_questions.length} open question(s) unanswered for over ` +
308
+ `${report.stale_questions[0].limit} commits`,
309
+ Object.keys(report.linter).length && 'П2 linter findings in systems docs',
310
+ report.index_drift && 'INDEX drift (regenerate)',
311
+ report.design?.blocking && `${report.design.blocking} blocking design finding(s)`,
312
+ ];
313
+
186
314
  /** @param {AuditReport} report */
187
315
  function firstLine(report) {
188
- const alerts = [
189
- report.illegal_status.length && `${report.illegal_status.length} card(s) in illegal status`,
190
- report.stale_built.length &&
191
- `${report.stale_built.length} built card(s) stale — playtest first`,
192
- report.tune_limit.length &&
193
- `${report.tune_limit.length} card(s) at tune limit — kill or redesign`,
194
- report.wip.over && `WIP over limit (${report.wip.count}/${report.wip.limit})`,
195
- report.until.filter((u) => u.status === 'stale').length && 'stale until-records present',
196
- report.pillars?.over && 'PILLARS over token budget',
197
- Object.keys(report.linter).length && 'П2 linter findings in systems docs',
198
- report.index_drift && 'INDEX drift (regenerate)',
199
- ].filter(Boolean);
316
+ const alerts = [...itemAlerts(report), ...docAlerts(report)].filter(Boolean);
200
317
 
201
318
  return alerts.length ? alerts.join('; ') : 'clean';
202
319
  }
package/lib/bundle.js ADDED
@@ -0,0 +1,278 @@
1
+ // The structured entry (GCF-PRD §11.10): `design export` glues game.md, glossary.md and the
2
+ // units into one bundle a designer edits; `design import` brings it back without a model.
3
+ // Each part carries the hash it was exported with, so an import tells a designer's edit from
4
+ // an edit made in gcf/ after the export — and never overwrites the second one silently.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import path from 'node:path';
9
+ import { readDesign, SLUG_RE } from './design.js';
10
+ import { buildLedger } from './ledger.js';
11
+ import { designCheck } from './design-check.js';
12
+ import { atomicWrite, withFileRollback, resolveInside } from './safe-path.js';
13
+ import { writeIndex } from './index-gen.js';
14
+
15
+ const DOCS = ['game', 'glossary'];
16
+ // A part opens with a marker comment, not with a heading: a `# notes` line inside game.md or a
17
+ // purpose is content, and a heading boundary would cut the document there.
18
+ const PART_RE =
19
+ /^<!-- gcf:part (doc|system|unit) (\S+)(?: system=(\S+))?(?: base=([0-9a-f]{8}))? -->$/;
20
+ const HELP =
21
+ '<!-- gcf:help edit inside a part; a new unit is a line ' +
22
+ '"gcf:part unit <id> system=<system>" in a comment like this one, ' +
23
+ 'then "## <id>" and its text -->';
24
+ export const hashOf = (text) =>
25
+ createHash('sha1').update(String(text).replace(/\r\n/g, '\n').trim()).digest('hex').slice(0, 8);
26
+
27
+ /** The text of one unit as it stands in its design file: header and body, no heading. */
28
+ function unitText(u) {
29
+ const header = Object.entries(u.header).map(([k, v]) => `${k}: ${v}`);
30
+
31
+ return `${header.join('\n')}\n\n${u.body.trim()}`.trim();
32
+ }
33
+
34
+ const purposeOf = (preamble) =>
35
+ preamble
36
+ .trimStart()
37
+ .replace(/^# .*\n?/, '')
38
+ .trim();
39
+
40
+ /** Glue the design into one bundle; `system` or `milestone` narrows which units go in. */
41
+ export async function exportBundle(projectDir, { system = null, milestone = null } = {}) {
42
+ const root = path.join(path.resolve(projectDir), 'gcf');
43
+ const design = (await readDesign(root)).filter((d) => !system || d.system === system);
44
+ const inScope = (u) => !milestone || u.header.milestone === milestone;
45
+ const ids = design.flatMap((d) => d.units.filter(inScope).map((u) => u.id));
46
+ const out = [`<!-- gcf:bundle ids=${ids.join(',')} -->`, HELP];
47
+ const part = (head, title, text) =>
48
+ out.push('', `<!-- gcf:part ${head} base=${hashOf(text)} -->`, ...title, text);
49
+ // A doc goes as it stands, its own heading included: a title added over it would be a second
50
+ // `# game` a designer deletes, and the import could not tell which one was the file's.
51
+ for (const name of DOCS) {
52
+ const file = path.join(root, `${name}.md`);
53
+ if (existsSync(file)) part(`doc ${name}`, [], (await readFile(file, 'utf8')).trim());
54
+ }
55
+
56
+ for (const d of design) {
57
+ part(`system ${d.system}`, [`# design: ${d.system}`, ''], purposeOf(d.preamble));
58
+ for (const u of d.units.filter(inScope)) {
59
+ part(`unit ${u.id} system=${d.system}`, [`## ${u.id}`, ''], unitText(u));
60
+ }
61
+ }
62
+
63
+ return `${out.join('\n')}\n`;
64
+ }
65
+
66
+ /** @typedef {{kind: string, name: string, system: ?string, base: ?string, lines: string[]}} Part */
67
+
68
+ /** The heading export writes under a marker, for a reader; import drops it when it is there. */
69
+ const titleOf = (p) =>
70
+ p.kind === 'doc' ? null : p.kind === 'system' ? `# design: ${p.name}` : `## ${p.name}`;
71
+
72
+ /**
73
+ * A marker line read into an empty part, or null; `system` is the system part above it.
74
+ * @returns {Part | null}
75
+ */
76
+ function partOf(line, system) {
77
+ const m = line.trimEnd().match(PART_RE);
78
+ if (!m) return null;
79
+ const owner = m[1] === 'unit' ? (m[3] ?? system) : m[1] === 'system' ? m[2] : null;
80
+
81
+ return { kind: m[1], name: m[2], system: owner, base: m[4] ?? null, lines: [] };
82
+ }
83
+
84
+ // Text no part owns — above the first marker, or a marker the editor mangled — would vanish
85
+ // without a word: it is reported instead.
86
+ const strayLine = (l, i, inPart) =>
87
+ /^<!-- gcf:part\b/.test(l) ||
88
+ (!inPart && i > 0 && l.trim() !== '' && !l.startsWith('<!-- gcf:help'));
89
+
90
+ /** A bundle read back: the exported ids, the docs, and per system its purpose and units. */
91
+ export function parseBundle(text) {
92
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
93
+ const ids = (lines[0].match(/^<!-- gcf:bundle ids=(.*) -->$/)?.[1] ?? '')
94
+ .split(',')
95
+ .filter(Boolean);
96
+ /** @type {Part[]} */
97
+ const parts = [];
98
+ const stray = [];
99
+ /** @type {?string} */
100
+ let system = null;
101
+ for (const [i, l] of lines.entries()) {
102
+ const p = partOf(l, system);
103
+ if (p) {
104
+ if (p.kind === 'system') system = p.name;
105
+ parts.push(p);
106
+ } else if (strayLine(l, i, parts.length > 0)) stray.push(i + 1);
107
+ else parts.at(-1)?.lines.push(l);
108
+ }
109
+
110
+ return {
111
+ ids,
112
+ stray,
113
+ parts: parts.map((p) => {
114
+ const body = p.lines.join('\n').trim().split('\n');
115
+ if (body[0]?.trim() === titleOf(p)) body.shift();
116
+
117
+ return { ...p, text: body.join('\n').trim() };
118
+ }),
119
+ };
120
+ }
121
+
122
+ /** Three-way: what the bundle changed, against what gcf/ changed since the export. */
123
+ function verdict(part, current) {
124
+ const mine = hashOf(part.text);
125
+ if (part.base === null) return current === null ? 'new' : 'taken';
126
+ if (mine === part.base) return 'same';
127
+ if (current === null) return 'gone';
128
+
129
+ return hashOf(current) === part.base ? 'apply' : 'conflict';
130
+ }
131
+
132
+ /** Current text of every importable part: docs, system purposes, units in design/. */
133
+ async function currentParts(root) {
134
+ const cur = new Map();
135
+ for (const name of DOCS) {
136
+ const file = path.join(root, `${name}.md`);
137
+ if (existsSync(file)) cur.set(`doc:${name}`, (await readFile(file, 'utf8')).trim());
138
+ }
139
+
140
+ for (const d of await readDesign(root)) {
141
+ cur.set(`system:${d.system}`, purposeOf(d.preamble));
142
+ for (const u of d.units) cur.set(`unit:${u.id}`, unitText(u));
143
+ }
144
+
145
+ return cur;
146
+ }
147
+
148
+ /** A design file with the `## <id>` section replaced by `text`, or appended when absent. */
149
+ function putUnit(fileText, id, text) {
150
+ // A file kept with CRLF stays CRLF: work in LF, write back in the file's own line ending.
151
+ const eol = fileText.match(/\r?\n/)?.[0] ?? '\n';
152
+ const lf = fileText.replace(/\r\n/g, '\n');
153
+ const lines = lf.split('\n');
154
+ const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
155
+ const section = [`## ${id}`, '', ...text.replace(/\r\n/g, '\n').split('\n'), ''];
156
+ if (at < 0) return `${lf.trimEnd()}\n\n${section.join('\n')}`.replace(/\n/g, eol);
157
+ let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
158
+ if (end < 0) end = lines.length;
159
+
160
+ return [...lines.slice(0, at), ...section, ...lines.slice(end)].join('\n').replace(/\n/g, eol);
161
+ }
162
+
163
+ /** A design file with its preamble purpose replaced; a title of the file's own stays. */
164
+ function putPurpose(fileText, system, purpose) {
165
+ const at = fileText.search(/^## \S/m);
166
+ const rest = at < 0 ? '' : fileText.slice(at);
167
+ const title = fileText.trimStart().match(/^# .*/)?.[0] ?? `# ${system}`;
168
+
169
+ return `${title}\n\n${purpose}\n${rest ? `\n${rest}` : ''}`;
170
+ }
171
+
172
+ /** A part's names become file names: a doc is one of DOCS, a system and a unit are slugs. */
173
+ const validName = (p) =>
174
+ p.kind === 'doc'
175
+ ? DOCS.includes(p.name)
176
+ : SLUG_RE.test(p.name) && SLUG_RE.test(String(p.system ?? ''));
177
+
178
+ /** Plan every part: which write it gets, or which report line. */
179
+ function plan(bundle, cur, ledger) {
180
+ const writes = [];
181
+ const note = `lines outside any part: ${bundle.stray.join(', ')} (a lost gcf:part marker?)`;
182
+ const report = bundle.stray.length ? [{ part: 'bundle', outcome: 'invalid', note }] : [];
183
+ for (const p of bundle.parts) {
184
+ const key = `${p.kind}:${p.name}`;
185
+ if (!validName(p)) {
186
+ report.push({ part: key, outcome: 'invalid', note: 'not a gcf doc, system or unit name' });
187
+ continue;
188
+ }
189
+
190
+ const v = verdict(p, cur.get(key) ?? null);
191
+ if (p.kind === 'unit' && v === 'new' && ledger.known(p.name)) {
192
+ report.push({ part: key, outcome: 'taken', note: 'the id is used: pick another slug' });
193
+ } else if (['apply', 'new'].includes(v)) writes.push({ ...p, outcome: v });
194
+ else if (v !== 'same') report.push({ part: key, outcome: v, note: NOTES[v] });
195
+ }
196
+
197
+ const kept = new Set(bundle.parts.filter((p) => p.kind === 'unit').map((p) => p.name));
198
+ for (const id of bundle.ids.filter((i) => !kept.has(i))) {
199
+ report.push({
200
+ part: `unit:${id}`,
201
+ outcome: 'deleted',
202
+ note: `confirm with gcf drop ${id} --reason "…"`,
203
+ });
204
+ }
205
+
206
+ return { writes, report };
207
+ }
208
+
209
+ const NOTES = {
210
+ conflict: 'changed in the bundle and in gcf/ since the export: a D1 for the human',
211
+ gone: 'no longer in design/ (taken, done or dropped): an edit to it is a D1',
212
+ taken: 'a part without a base hash that already exists: export again and edit that',
213
+ };
214
+
215
+ /** The file each write touches. */
216
+ function fileOf(root, w) {
217
+ // validName already holds a doc to DOCS; resolveInside is the second lock on the path.
218
+ if (w.kind === 'doc') return resolveInside(root, `${w.name}.md`);
219
+
220
+ return resolveInside(path.join(root, 'design'), `${w.kind === 'system' ? w.name : w.system}.md`);
221
+ }
222
+
223
+ async function applyWrite(root, w) {
224
+ const file = fileOf(root, w);
225
+ const text = existsSync(file)
226
+ ? await readFile(file, 'utf8')
227
+ : `# ${w.system ?? w.name}\n\npurpose: TBD\n`;
228
+ if (w.kind === 'doc') return atomicWrite(file, `${w.text}\n`);
229
+ if (w.kind === 'system') return atomicWrite(file, putPurpose(text, w.name, w.text));
230
+ await atomicWrite(file, putUnit(text, w.name, w.text));
231
+ if (w.outcome !== 'new') return;
232
+ const backlog = path.join(root, 'backlog.md');
233
+ const order = existsSync(backlog) ? (await readFile(backlog, 'utf8')).trimEnd() : '';
234
+ await atomicWrite(backlog, `${order}\n- ${w.name}\n`.replace(/^\n/, ''));
235
+ }
236
+
237
+ /**
238
+ * Bring a bundle back into gcf/. A part the bundle changed and gcf/ did not is written; a part
239
+ * both changed is reported, never merged. Writes land only if the design check after them has
240
+ * no blocking finding — otherwise every file goes back and the findings are the error.
241
+ */
242
+ export async function importBundle(projectDir, bundleText) {
243
+ const root = path.join(path.resolve(projectDir), 'gcf');
244
+ const bundle = parseBundle(bundleText);
245
+ const ledger = await buildLedger(projectDir);
246
+ const { writes, report } = plan(bundle, await currentParts(root), ledger);
247
+ const applied = writes.map((w) => `${w.kind}:${w.name} (${w.outcome})`);
248
+ if (!writes.length) return { applied, report };
249
+ // Only the findings the bundle brings refuse it: a project with open findings can still take
250
+ // a clean edit. A finding is keyed without its line, which an edit above it shifts, and
251
+ // counted, so a second finding with the same message in the same unit is still a new one.
252
+ const keyOf = (i) => `${i.check}|${i.file}|${i.id}|${i.message}`;
253
+ const blocking = async () =>
254
+ (await designCheck(projectDir)).issues.filter((i) => i.severity === 'block');
255
+ const before = new Map();
256
+ for (const i of await blocking()) before.set(keyOf(i), (before.get(keyOf(i)) ?? 0) + 1);
257
+ const files = [...new Set(writes.map((w) => fileOf(root, w)))];
258
+ await withFileRollback(
259
+ [...files, path.join(root, 'backlog.md'), path.join(root, 'INDEX.md')],
260
+ async () => {
261
+ for (const w of writes) await applyWrite(root, w);
262
+ const seen = new Map();
263
+ const fresh = (await blocking()).filter((i) => {
264
+ seen.set(keyOf(i), (seen.get(keyOf(i)) ?? 0) + 1);
265
+
266
+ return seen.get(keyOf(i)) > (before.get(keyOf(i)) ?? 0);
267
+ });
268
+ if (fresh.length) {
269
+ const first = fresh.slice(0, 3).map((i) => `${i.file}:${i.line} ${i.check} ${i.message}`);
270
+ throw new Error(`the bundle would break the design check:\n${first.join('\n')}`);
271
+ }
272
+
273
+ await writeIndex(root);
274
+ },
275
+ );
276
+
277
+ return { applied, report };
278
+ }