gcf-aif-ext 0.3.0 → 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 (48) hide show
  1. package/README.md +53 -11
  2. package/commands/gcf.js +287 -42
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +55 -31
  5. package/injections/commit-status.md +7 -5
  6. package/injections/plan-context.md +8 -5
  7. package/injections/review-pillars.md +4 -3
  8. package/lib/anchors.js +27 -4
  9. package/lib/audit-core.js +135 -48
  10. package/lib/bundle.js +7 -4
  11. package/lib/context.js +24 -23
  12. package/lib/design-check.js +78 -27
  13. package/lib/design.js +2 -2
  14. package/lib/gcf-paths.js +13 -2
  15. package/lib/id.js +2 -2
  16. package/lib/inbox.js +54 -0
  17. package/lib/index-gen.js +23 -8
  18. package/lib/ledger.js +19 -17
  19. package/lib/linter.js +2 -1
  20. package/lib/live.js +179 -0
  21. package/lib/plan-check.js +31 -0
  22. package/lib/playtest-list.js +8 -4
  23. package/lib/systems-doc.js +16 -1
  24. package/lib/transitions.js +36 -21
  25. package/lib/unit-ops.js +124 -97
  26. package/lib/validate-item.js +244 -0
  27. package/lib/verdict-fs.js +61 -128
  28. package/lib/work-ops.js +727 -0
  29. package/package.json +1 -1
  30. package/profiles/pillars.md +1 -1
  31. package/profiles/system.md +1 -1
  32. package/profiles/unit-constraint.md +10 -0
  33. package/profiles/unit-entity.md +10 -0
  34. package/profiles/unit-mechanic.md +6 -1
  35. package/profiles/unit-resource.md +6 -1
  36. package/profiles/unit-rule.md +10 -0
  37. package/schemas/item.schema.json +105 -0
  38. package/skills/gcf-adopt/SKILL.md +2 -2
  39. package/skills/gcf-audit/SKILL.md +3 -3
  40. package/skills/gcf-card/SKILL.md +25 -20
  41. package/skills/gcf-ingest/SKILL.md +3 -3
  42. package/skills/gcf-init/SKILL.md +2 -2
  43. package/skills/gcf-triage/SKILL.md +35 -0
  44. package/skills/gcf-verdict/SKILL.md +19 -16
  45. package/templates/config.yml +7 -0
  46. package/templates/{card.md → item.md} +10 -3
  47. package/lib/validate-card.js +0 -93
  48. package/schemas/card.schema.json +0 -46
package/lib/context.js CHANGED
@@ -1,32 +1,32 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { listCards, listSystems, readConfig, tokensEst } from './gcf-paths.js';
4
+ import { listItems, listSystems, readConfig, tokensEst } from './gcf-paths.js';
5
5
 
6
6
  /**
7
7
  * Readset assembly — the whole command is deterministic (GCF-PRD §9.1, §10.1):
8
- * PILLARS + touched systems + the card + its depends + graveyard tag hits +
8
+ * PILLARS + LIVE.md + touched systems + the item + its depends + graveyard tag hits +
9
9
  * D2 assumptions whose sys overlaps. Never over_budget-throws: the caller gates.
10
10
  */
11
- export async function buildContext(projectDir, cardId) {
11
+ export async function buildContext(projectDir, itemId) {
12
12
  const root = path.join(path.resolve(projectDir), 'gcf');
13
13
  const config = await readConfig(root);
14
- const cards = await listCards(root);
15
- const card = cards.find((c) => c.data.id === cardId);
16
- if (!card) throw new Error(`Card not found: ${cardId}`);
14
+ const items = await listItems(root);
15
+ const item = items.find((c) => c.data.id === itemId);
16
+ if (!item) throw new Error(`Work item not found: ${itemId}`);
17
17
 
18
- const systems = card.data.system ?? [];
19
- const files = await readsetFiles(root, card, cards);
20
- const cardText =
21
- `${card.data.id} ${card.data.title ?? ''} ${systems.join(' ')} ${card.body}`.toLowerCase();
22
- const graveyard_hits = await graveyardHits(root, cardText);
18
+ const systems = item.data.system ?? [];
19
+ const files = await readsetFiles(root, item, items);
20
+ const itemText =
21
+ `${item.data.id} ${item.data.title ?? ''} ${systems.join(' ')} ${item.body}`.toLowerCase();
22
+ const graveyard_hits = await graveyardHits(root, itemText);
23
23
  const d2 = await d2Assumptions(root, systems);
24
24
 
25
25
  const extraTokens = tokensEst(graveyard_hits.join('\n')) + tokensEst(d2.join('\n'));
26
26
  const total_tokens_est = files.reduce((sum, f) => sum + f.tokens_est, 0) + extraTokens;
27
27
 
28
28
  return {
29
- card: cardId,
29
+ item: itemId,
30
30
  files,
31
31
  graveyard_hits,
32
32
  d2,
@@ -36,8 +36,8 @@ export async function buildContext(projectDir, cardId) {
36
36
  };
37
37
  }
38
38
 
39
- /** PILLARS, touched systems, the card, its depends — in that order, skipping absent files. */
40
- async function readsetFiles(root, card, cards) {
39
+ /** PILLARS, LIVE.md, touched systems, the item, its depends — in order, skipping absent files. */
40
+ async function readsetFiles(root, item, items) {
41
41
  const files = [];
42
42
  const push = async (rel) => {
43
43
  const abs = path.join(root, rel);
@@ -47,22 +47,23 @@ async function readsetFiles(root, card, cards) {
47
47
  };
48
48
 
49
49
  await push('PILLARS.md');
50
- const systems = card.data.system ?? [];
50
+ await push('LIVE.md');
51
+ const systems = item.data.system ?? [];
51
52
  for (const s of (await listSystems(root)).filter((d) => systems.includes(d.data.system))) {
52
53
  await push(`systems/${s.name}`);
53
54
  }
54
55
 
55
- await push(`cards/${card.name}`);
56
- for (const depId of card.data.depends ?? []) {
57
- const dep = cards.find((c) => c.data.id === depId);
58
- if (dep) await push(`cards/${dep.name}`);
56
+ await push(`work/${item.name}`);
57
+ for (const depId of item.data.depends ?? []) {
58
+ const dep = items.find((c) => c.data.id === depId);
59
+ if (dep) await push(`work/${dep.name}`);
59
60
  }
60
61
 
61
62
  return files;
62
63
  }
63
64
 
64
- /** Graveyard: headline tags only; a hit is a tag appearing in the card's own text. */
65
- async function graveyardHits(root, cardText) {
65
+ /** Graveyard: headline tags only; a hit is a tag appearing in the item's own text. */
66
+ async function graveyardHits(root, itemText) {
66
67
  const gyFile = path.join(root, 'graveyard.md');
67
68
  if (!existsSync(gyFile)) return [];
68
69
  const hits = [];
@@ -73,13 +74,13 @@ async function graveyardHits(root, cardText) {
73
74
  .split(',')
74
75
  .map((t) => t.trim().toLowerCase())
75
76
  .filter(Boolean);
76
- if (tags.some((t) => cardText.includes(t))) hits.push(line.trim());
77
+ if (tags.some((t) => itemText.includes(t))) hits.push(line.trim());
77
78
  }
78
79
 
79
80
  return hits;
80
81
  }
81
82
 
82
- /** D2 assumptions whose sys: overlaps the card's systems (GCF-PRD §9.1). */
83
+ /** D2 assumptions whose sys: overlaps the item's systems (GCF-PRD §9.1). */
83
84
  async function d2Assumptions(root, systems) {
84
85
  const oqFile = path.join(root, 'open-questions.md');
85
86
  if (!existsSync(oqFile)) return [];
@@ -18,6 +18,7 @@ import {
18
18
  } from './design.js';
19
19
  import { buildLedger } from './ledger.js';
20
20
  import { sourceBodies } from './anchors.js';
21
+ import { collapseLines } from './verdict-fs.js';
21
22
 
22
23
  // Units a number may carry. A project adds its own in the `units:` block of glossary.md.
23
24
  // Bare `px` is absent on purpose: its size depends on the screen density.
@@ -34,6 +35,7 @@ const COMPARISON_RE = new RegExp(
34
35
  '(?:\\s+(\\S+)(?: per \\S+)?)?$', // and its unit, closing the line
35
36
  );
36
37
  const ENVELOPE_BLOCKS = ['feel', 'sources', 'sinks'];
38
+ const OPERATOR_BLOCKS = ['tuning', 'notes'];
37
39
  const FLAGS = ['src-changed', 'orphan', 'impl?'];
38
40
  const REQUIRED_HEADER = ['type', 'route', 'system', 'milestone', 'pillars', 'depends', 'source'];
39
41
  const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
@@ -116,7 +118,8 @@ function itemsIn(text, key) {
116
118
  return at ? itemsOf(lines, at.line) : [];
117
119
  }
118
120
 
119
- const recordsIn = (text, key) => readRecords(itemsIn(text, key)).records.map((r) => r.values);
121
+ export const recordsIn = (text, key) =>
122
+ readRecords(itemsIn(text, key)).records.map((r) => r.values);
120
123
 
121
124
  /** `blocks:` of open-questions.md: every reference, and the D1 ones per blocked id. */
122
125
  export function questionBlocks(text) {
@@ -172,9 +175,14 @@ const HEADER_RULES = {
172
175
 
173
176
  return [
174
177
  'M1',
175
- `a ${u.header.type} is ${soft ? 'soft' : 'hard'}: only mechanic and resource get a card`,
178
+ `a ${u.header.type} is ${soft ? 'soft' : 'hard'}: only mechanic and resource wait for a ` +
179
+ 'verdict by default',
176
180
  ];
177
181
  },
182
+ reopens: (v, u, ctx, ledger) =>
183
+ ledger.where(v).terminal.length
184
+ ? null
185
+ : ['M2', `reopens names ${v}, which has not ended (no history or graveyard line)`],
178
186
  system: (v, u) => {
179
187
  const list = listOf(v);
180
188
  if (list === 'TBD' || !list.length || !list.every((s) => SLUG_RE.test(s)))
@@ -400,36 +408,84 @@ function preambleIssues(design) {
400
408
  }));
401
409
  }
402
410
 
411
+ /** M2 over a work item's references: depends, parent, children, reopens. */
412
+ function itemRefIssues(item, ledger, add) {
413
+ const d = item.data;
414
+ const unknown = (d.depends ?? []).filter((x) => x === d.id || !ledger.known(x));
415
+ if (unknown.length) add('M2', 1, `depends on unknown ${unknown.join(', ')}`);
416
+ // A parent that already ended is fine: a parked bug may outlive its feature.
417
+ const parent = d.parent ? ledger.item(d.parent) : null;
418
+ if (d.parent && !(parent ? parent.data.type === 'feature' : ledger.known(d.parent)))
419
+ add('M2', 1, `parent ${d.parent} is neither a feature item nor an ended id`);
420
+ const lost = (d.children ?? []).filter((c) => !ledger.known(c));
421
+ if (lost.length) add('M2', 1, `children unknown: ${lost.join(', ')}`);
422
+ if (d.reopens && !ledger.where(d.reopens).terminal.length)
423
+ add('M2', 1, `reopens names ${d.reopens}, which has not ended (no history or graveyard line)`);
424
+ }
425
+
403
426
  /**
404
- * A card is a unit in work: the same profile, plus what the card stage owes — no TBD left,
405
- * `contract` and `interactions` written. Its frontmatter is validated by the schema separately.
427
+ * A work item. A design-unit item carries the unit's body: the same profile, plus what work
428
+ * owes — no TBD left, `contract` and `interactions` written. Every item's references are known.
429
+ * Its frontmatter is validated by the schema separately.
406
430
  */
407
- async function checkCard(card, ctx) {
431
+ async function checkItem(item, ctx, ledger) {
408
432
  /** @type {Issue[]} */
409
433
  const issues = [];
410
- const file = `cards/${card.name}`;
411
- const raw = await readFile(card.file, 'utf8');
412
- const offset = raw.split('\n').length - card.body.split('\n').length;
434
+ const file = `work/${item.name}`;
435
+ const raw = await readFile(item.file, 'utf8');
436
+ const offset = raw.split('\n').length - item.body.split('\n').length;
413
437
  const add = (check, line, message, severity = 'block') =>
414
- issues.push({ check, severity, file, line: line + offset, id: card.data.id, message });
415
- if (!CARD_TYPES.includes(card.data.type)) return issues;
438
+ issues.push({ check, severity, file, line: line + offset, id: item.data.id, message });
439
+ itemRefIssues(item, ledger, add);
440
+ if (!UNIT_TYPES.includes(item.data.type)) return issues;
416
441
 
417
- const { profile, issues: shape } = await bodyProblems(card.body, card.data.type);
442
+ const { profile, issues: shape } = await bodyProblems(item.body, item.data.type);
418
443
  for (const i of shape) add('M1', i.line, i.message);
419
- const values = slotValues(card.body, profile);
444
+ // `tuning:` and `notes:` are the operator's words to the next build, not design slots: a
445
+ // "dash 20% shorter" there is a remark, not a number outside an envelope.
446
+ const values = slotValues(item.body, profile).filter((v) => !OPERATOR_BLOCKS.includes(v.block));
420
447
  for (const v of values.filter((x) => isTbd(x.value)))
421
- add('M1', v.line, `a card carries no TBD: ${v.block}`);
448
+ add('M1', v.line, `a unit in work carries no TBD: ${v.block}`);
422
449
  for (const [c, line, m] of valueIssues(values, ctx)) add(c, line, m);
423
- for (const r of provenanceOf(card.body)) {
450
+ for (const r of provenanceOf(item.body)) {
424
451
  const problem = recordProblem(r, ctx);
425
- // A card's quotes go stale when the GDD moves on: that is a D1 for the human, not a gate.
452
+ // An item's quotes go stale when the GDD moves on: that is a D1 for the human, not a gate.
426
453
  if (problem) add('M9', r.line, problem, isDrift(problem) ? 'report' : 'block');
427
454
  }
428
455
 
429
456
  return issues;
430
457
  }
431
458
 
432
- /** M12: one live place per id, and no silent return from a terminal state. */
459
+ /**
460
+ * M15: an unplayed item's lines are all in its systems docs — the body is what a tune or a kill
461
+ * takes back out (Methodology §5.4), so a line edited away there could never leave.
462
+ */
463
+ async function unplayedIssues(root, ledger) {
464
+ /** @type {Issue[]} */
465
+ const out = [];
466
+ for (const item of ledger.items.filter((i) => i.data.status === 'unplayed')) {
467
+ for (const s of item.data.system ?? []) {
468
+ const text = await readIf(path.join(root, 'systems', `${s}.md`));
469
+ const doc = String(text ?? '').replace(/\r\n/g, '\n');
470
+ const missing = Object.values(collapseLines(item.body))
471
+ .flat()
472
+ .find((l) => !doc.split('\n').includes(l));
473
+ if (missing)
474
+ out.push({
475
+ check: 'M15',
476
+ severity: 'block',
477
+ file: `work/${item.name}`,
478
+ line: 1,
479
+ id: item.data.id,
480
+ message: `unplayed ${item.data.id}: systems/${s}.md lost its line "${missing}"`,
481
+ });
482
+ }
483
+ }
484
+
485
+ return out;
486
+ }
487
+
488
+ /** M12: one live place per id, and no return from a terminal state — ids are never reused. */
433
489
  function ledgerIssues(ledger) {
434
490
  /** @type {Issue[]} */
435
491
  const out = [];
@@ -440,10 +496,11 @@ function ledgerIssues(ledger) {
440
496
  out.push({ check: 'M12', severity: 'block', file, line: 1, id, message });
441
497
  if (live.length > 1)
442
498
  add(`${id} lives in ${live.length} places: ${live.map((p) => p.file).join(', ')}`);
443
- if (live.length && terminal.length && !reopens(ledger, id)) {
499
+ if (live.length && terminal.length) {
444
500
  add(
445
- `${id} is ${terminal.join(' and ')} already: a live copy needs "reopens: <why>" ` +
446
- '(an undone move: take its line out of history.md or graveyard.md)',
501
+ `${id} is ${terminal.join(' and ')} already: ids are never reused — give the new one ` +
502
+ `its own id and "reopens: ${id}" (an undone move: take its line out of history.md ` +
503
+ 'or graveyard.md)',
447
504
  );
448
505
  }
449
506
  }
@@ -451,13 +508,6 @@ function ledgerIssues(ledger) {
451
508
  return out;
452
509
  }
453
510
 
454
- function reopens(ledger, id) {
455
- const unit = ledger.design.flatMap((d) => d.units).find((u) => u.id === id);
456
- const card = ledger.cards.find((c) => c.data.id === id);
457
-
458
- return Boolean(unit?.header.reopens || card?.data.reopens);
459
- }
460
-
461
511
  /** M14: backlog.md is the order of the units in design/, each exactly once. */
462
512
  function backlogIssues(text, unitIds) {
463
513
  /** @type {Issue[]} */
@@ -558,7 +608,8 @@ export async function designCheck(projectDir) {
558
608
  });
559
609
  }
560
610
 
561
- for (const c of ledger.cards) issues.push(...(await checkCard(c, ctx)));
611
+ for (const c of ledger.items) issues.push(...(await checkItem(c, ctx, ledger)));
612
+ issues.push(...(await unplayedIssues(root, ledger)));
562
613
  for (const doc of ['game', 'glossary']) issues.push(...(await checkDoc(root, doc, ctx)));
563
614
  issues.push(...preambleIssues(ledger.design));
564
615
  issues.push(...ledgerIssues(ledger), ...questionIssues(ctx, ledger));
package/lib/design.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Design Model (GCF-PRD §5.7): gcf/design/<system>.md holds design units, one `## <id>`
2
- // section each. A unit's body is the body its card carries once it is taken into work,
2
+ // section each. A unit's body is the body its work item carries once it is taken into work,
3
3
  // so one CNL-P profile per type serves both places.
4
4
  import { readFile, readdir } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
@@ -9,7 +9,7 @@ import { parseBlocks, itemsOf, readRecords, loadProfileAt } from 'cnlp-kit';
9
9
 
10
10
  export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
11
11
  export const UNIT_TYPES = ['mechanic', 'resource', 'rule', 'constraint', 'entity'];
12
- // Only a soft unit becomes a card; a hard one leaves design/ through `gcf done`.
12
+ // A soft unit waits for a verdict by default; a hard one closes by its tests.
13
13
  export const CARD_TYPES = ['mechanic', 'resource'];
14
14
  export const HEADER_KEYS = [
15
15
  'type',
package/lib/gcf-paths.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { parse } from './frontmatter.js';
@@ -13,6 +13,9 @@ const DEFAULT_CONFIG = {
13
13
  context_budget: 30000,
14
14
  pillars_budget: 1500,
15
15
  envelope_default_spread: 0.3,
16
+ unplayed_warn_after: 5,
17
+ plan_passes: 5,
18
+ dod_attempts: 2,
16
19
  };
17
20
 
18
21
  /** `gcf/` lives at the repository root (GCF-PRD §5.1). Throws when absent. */
@@ -64,7 +67,15 @@ async function listDocs(dir) {
64
67
  return out;
65
68
  }
66
69
 
67
- export const listCards = (root) => listDocs(path.join(root, 'cards'));
70
+ export const listItems = (root) => listDocs(path.join(root, 'work'));
71
+
72
+ /** Schema 2 card files left in gcf/cards/: GCF 0.4 reads gcf/work/ and does not migrate them. */
73
+ export function legacyCards(root) {
74
+ const dir = path.join(root, 'cards');
75
+
76
+ return existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.md')) : [];
77
+ }
78
+
68
79
  export const listSystems = (root) => listDocs(path.join(root, 'systems'));
69
80
 
70
81
  /** First bullet of a `key:` block in a CNL-P body, for one-line descriptions. */
package/lib/id.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  // GCF-PRD §5.8: a unit's id is a slug, one for every stage — the `## <id>` section in design/,
4
- // the card file cards/<id>.md, the graveyard line, the verdict commit. It is frozen once made.
4
+ // the work item work/<id>.md, the history or graveyard line, the move's commit. Frozen once made.
5
5
  const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
6
6
 
7
7
  /** Derive a slug from a free-text name. Created once; never re-derived. */
@@ -19,7 +19,7 @@ export function isValidId(id) {
19
19
  return ID_RE.test(String(id ?? ''));
20
20
  }
21
21
 
22
- /** Card filename invariant: `cards/<id>.md`. */
22
+ /** Work item filename invariant: `work/<id>.md`. */
23
23
  export function stemMatches(file, id) {
24
24
  return path.basename(file, '.md') === id;
25
25
  }
package/lib/inbox.js ADDED
@@ -0,0 +1,54 @@
1
+ // gcf/inbox.md (Methodology §5.1): raw input waiting for triage — playtest notes, CI failures,
2
+ // the agent's ideas and requests. One line per entry, `- <in-id> | <source> | <text>`; a triaged
3
+ // line is erased, not marked: the file holds what is still waiting, not what happened to it.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import { createHash } from 'node:crypto';
7
+ import path from 'node:path';
8
+ import { atomicWrite } from './safe-path.js';
9
+
10
+ export const INBOX_HEAD =
11
+ '# Inbox — `- <in-id> | <source> | <text>`, raw input waiting for triage; a triaged line ' +
12
+ 'is erased\n';
13
+ export const SOURCES = ['playtest', 'ci', 'agent', 'operator'];
14
+ const LINE = /^- (in-[0-9a-f]+) \| (\S+) \| (.+)$/;
15
+
16
+ const inboxFile = (root) => path.join(root, 'inbox.md');
17
+
18
+ /** @returns {Promise<Array<{id: string, source: string, text: string}>>} */
19
+ export async function listInbox(root) {
20
+ const file = inboxFile(root);
21
+ if (!existsSync(file)) return [];
22
+
23
+ return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
24
+ const m = l.match(LINE);
25
+
26
+ return m ? [{ id: m[1], source: m[2], text: m[3].trim() }] : [];
27
+ });
28
+ }
29
+
30
+ /** Append an entry; its id hashes the text and the moment, so two branches never collide. */
31
+ export async function addInbox(root, text, source = 'operator', now = new Date().toISOString()) {
32
+ const clean = String(text).replace(/\s+/g, ' ').trim();
33
+ if (!clean) throw new Error('an inbox entry needs text');
34
+ if (!SOURCES.includes(source)) throw new Error(`source must be ${SOURCES.join(' | ')}`);
35
+ const id = `in-${createHash('sha1').update(`${now}\n${clean}`).digest('hex').slice(0, 8)}`;
36
+ const file = inboxFile(root);
37
+ const old = existsSync(file) ? await readFile(file, 'utf8') : INBOX_HEAD;
38
+ const eol = old.includes('\r\n') ? '\r\n' : '\n';
39
+ const head = old.endsWith('\n') ? old : old + eol;
40
+ await atomicWrite(file, `${head}- ${id} | ${source} | ${clean}${eol}`);
41
+
42
+ return { id, source, text: clean };
43
+ }
44
+
45
+ /** Erase entry `id`; refuses an id the inbox does not hold. */
46
+ export async function dropInbox(root, id) {
47
+ const file = inboxFile(root);
48
+ const text = existsSync(file) ? await readFile(file, 'utf8') : '';
49
+ const lines = text.split(/(?<=\n)/);
50
+ // Each split line keeps its own ending, and `$` without the m flag does not stop before it.
51
+ const kept = lines.filter((l) => l.replace(/\r?\n$/, '').match(LINE)?.[1] !== id);
52
+ if (kept.length === lines.length) throw new Error(`${id} is not in gcf/inbox.md`);
53
+ await atomicWrite(file, kept.join(''));
54
+ }
package/lib/index-gen.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { listCards, listSystems, firstBullet } from './gcf-paths.js';
4
+ import { listItems, listSystems, firstBullet } from './gcf-paths.js';
5
+ import { parseLive } from './live.js';
5
6
  import { atomicWrite } from './safe-path.js';
6
7
  import { readDesign, slotValues, loadDocProfile, isTbd, UNIT_TYPES } from './design.js';
7
8
 
@@ -40,11 +41,7 @@ export async function generate(root) {
40
41
  row(['system', `systems/${s.name}`, '-', s.data.system ?? '-', purpose]);
41
42
  }
42
43
 
43
- for (const c of await listCards(root)) {
44
- const system = (c.data.system ?? []).join(',');
45
- row(['card', `cards/${c.name}`, c.data.status ?? '?', system, firstBullet(c.body, 'intent')]);
46
- }
47
-
44
+ await workRows(root, row);
48
45
  await endRows(root, row);
49
46
 
50
47
  const txt = await readIfExists(root, 'open-questions.md');
@@ -57,11 +54,29 @@ export async function generate(root) {
57
54
  return `${lines.join('\n')}\n`;
58
55
  }
59
56
 
57
+ /** One row per work item, then the inbox and LIVE.md counts. */
58
+ async function workRows(root, row) {
59
+ for (const c of await listItems(root)) {
60
+ const system = (c.data.system ?? []).join(',') || '-';
61
+ const kind = `${c.data.type ?? '?'}/${c.data.gate ?? '?'}`;
62
+ const descr = `${kind}: ${c.data.title ?? firstBullet(c.body, 'intent')}`;
63
+ row(['item', `work/${c.name}`, c.data.status ?? '?', system, descr]);
64
+ }
65
+
66
+ const inbox = await readIfExists(root, 'inbox.md');
67
+ if (inbox !== null)
68
+ row(['inbox', 'inbox.md', '-', '-', `${count(inbox, /^- in-\S+ \|/gm)} entries to triage`]);
69
+ const live = await readIfExists(root, 'LIVE.md');
70
+ if (live === null) return;
71
+ const { capabilities: caps, knownIssues: known } = parseLive(live);
72
+ row(['live', 'LIVE.md', '-', '-', `${caps.length} capabilities, ${known.length} known issues`]);
73
+ }
74
+
60
75
  /** The rows of the files that record how lives ended: one count per file. */
61
76
  async function endRows(root, row) {
62
77
  for (const [type, what] of [
63
- ['history', 'built or kept'],
64
- ['graveyard', 'killed ideas'],
78
+ ['history', 'ended accepted'],
79
+ ['graveyard', 'killed or dropped'],
65
80
  ]) {
66
81
  const text = await readIfExists(root, `${type}.md`);
67
82
  if (text !== null) row([type, `${type}.md`, '-', '-', `${count(text, /^- \S+ \|/gm)} ${what}`]);
package/lib/ledger.js CHANGED
@@ -1,15 +1,16 @@
1
- // Where an id lives (GCF-PRD §5.8): a live place is a section of design/ or a file of cards/,
2
- // an end is the line the move wrote in the commit that ended the life — history.md for done and
3
- // keep, graveyard.md for drop and kill. Files, not commit messages: a squash, a shallow clone or
4
- // a rewritten history keeps them, and a revert of the move takes its line out with it.
1
+ // Where an id lives (Methodology §2, §9): a live place is a section of design/ or a work item of
2
+ // work/, an end is the line the move wrote in the commit that ended the life — history.md for
3
+ // done, collapsed and decided, graveyard.md for every drop and kill. Files, not commit messages:
4
+ // a squash, a shallow clone or a rewritten history keeps them, and a revert of the move takes
5
+ // its line out with it.
5
6
  import { readFile } from 'node:fs/promises';
6
7
  import { existsSync } from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { spawnSync } from 'node:child_process';
9
10
  import { readDesign } from './design.js';
10
- import { listCards } from './gcf-paths.js';
11
+ import { listItems } from './gcf-paths.js';
11
12
 
12
- const HISTORY_EVENTS = ['done', 'collapsed'];
13
+ export const HISTORY_EVENTS = ['done', 'collapsed', 'decided'];
13
14
 
14
15
  /** `[id, second field]` of each line `- <id> | <field> | …` of a gcf file. */
15
16
  async function recordLines(root, name) {
@@ -29,18 +30,18 @@ const gitOut = (args, cwd) => {
29
30
  return res.status === 0 ? res.stdout : null;
30
31
  };
31
32
 
32
- /** Ids HEAD holds as a card file or a design section. */
33
+ /** Ids HEAD holds as a work item file or a design section. */
33
34
  function headIds(projectDir) {
34
35
  const files = gitOut(
35
- ['ls-tree', '-r', '--name-only', 'HEAD', '--', 'gcf/cards', 'gcf/design'],
36
+ ['ls-tree', '-r', '--name-only', 'HEAD', '--', 'gcf/work', 'gcf/design'],
36
37
  projectDir,
37
38
  );
38
39
  const ids = new Set();
39
- // Top level only, as readDesign and listCards read: a nested archive file is not a unit.
40
+ // Top level only, as readDesign and listItems read: a nested archive file is not a unit.
40
41
  for (const f of (files ?? '')
41
42
  .split('\n')
42
- .filter((x) => /^gcf\/(cards|design)\/[^/]+\.md$/.test(x))) {
43
- if (f.startsWith('gcf/cards/')) ids.add(path.basename(f, '.md'));
43
+ .filter((x) => /^gcf\/(work|design)\/[^/]+\.md$/.test(x))) {
44
+ if (f.startsWith('gcf/work/')) ids.add(path.basename(f, '.md'));
44
45
  // `./` makes the path relative to the cwd, as ls-tree printed it.
45
46
  else
46
47
  for (const m of (gitOut(['show', `HEAD:./${f}`], projectDir) ?? '').matchAll(/^## (\S+)/gm))
@@ -52,23 +53,23 @@ function headIds(projectDir) {
52
53
 
53
54
  /**
54
55
  * @typedef {object} Place
55
- * @property {'design' | 'card'} state
56
+ * @property {'design' | 'item'} state
56
57
  * @property {string} file gcf-relative
57
58
  */
58
59
 
59
60
  /**
60
61
  * Build the ledger of a project: `where(id)` answers `{ live, terminal }` — the live places
61
- * (a design section, a card file) and the terminal events (collapsed, killed, done).
62
+ * (a design section, a work item) and the terminal events (done, collapsed, decided, killed).
62
63
  */
63
64
  export async function buildLedger(projectDir) {
64
65
  const root = path.join(path.resolve(projectDir), 'gcf');
65
66
  const design = await readDesign(root);
66
- const cards = await listCards(root);
67
+ const items = await listItems(root);
67
68
  /** @type {Map<string, Place[]>} */
68
69
  const live = new Map();
69
70
  const add = (id, place) => live.set(id, [...(live.get(id) ?? []), place]);
70
71
  for (const d of design) for (const u of d.units) add(u.id, { state: 'design', file: d.file });
71
- for (const c of cards) add(String(c.data.id), { state: 'card', file: `cards/${c.name}` });
72
+ for (const c of items) add(String(c.data.id), { state: 'item', file: `work/${c.name}` });
72
73
 
73
74
  /** @type {Map<string, Set<string>>} */
74
75
  const terminal = new Map();
@@ -77,13 +78,14 @@ export async function buildLedger(projectDir) {
77
78
  if (HISTORY_EVENTS.includes(event)) mark(id, event);
78
79
  for (const [id] of await recordLines(root, 'graveyard.md')) mark(id, 'killed');
79
80
  // An id HEAD holds that has no live place and no terminal line is vanishing in this commit:
80
- // it is not known, so a card or unit still citing it fails M2 in this very commit, and
81
+ // it is not known, so an item or unit still citing it fails M2 in this very commit, and
81
82
  // `vanished()` lets the gate warn that its reason was lost.
82
83
  const vanished = [...headIds(projectDir)].filter((id) => !live.has(id) && !terminal.has(id));
83
84
 
84
85
  return {
85
86
  design,
86
- cards,
87
+ items,
88
+ item: (id) => items.find((c) => c.data.id === id),
87
89
  where: (id) => ({ id, live: live.get(id) ?? [], terminal: [...(terminal.get(id) ?? [])] }),
88
90
  known: (id) => live.has(id) || terminal.has(id),
89
91
  ids: () => [...new Set([...live.keys(), ...terminal.keys()])].sort(),
package/lib/linter.js CHANGED
@@ -8,7 +8,8 @@ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
8
  // a unit is any non-letter in any script: `\w` is ASCII-only, and "5 слотов" is not "5 с".
9
9
  const UNIT_WORDS = [...UNITS, 'мс', 'с', 'сек', 'dmg', 'hp', 'px'].map(esc).join('|');
10
10
  const UNIT_NUM = new RegExp(`\\d+(?:[.,]\\d+)?\\s?(?:${UNIT_WORDS})(?![\\p{L}\\p{N}_-])`, 'u');
11
- const SIGNATURE = /\w+\s*\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
11
+ // No space before the paren: "respawn the player (out of the arena)" is prose, not a call.
12
+ const SIGNATURE = /\w+\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
12
13
  const CAMEL = /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/;
13
14
  const ENVELOPE_FIELD = /^\s*(?:- )?(?:question|assumed|proposed):/;
14
15