gcf-aif-ext 0.3.0 → 0.4.1

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 (51) hide show
  1. package/README.md +63 -11
  2. package/commands/gcf.js +321 -43
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +60 -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/agents-block.js +66 -0
  9. package/lib/anchors.js +27 -4
  10. package/lib/audit-core.js +135 -48
  11. package/lib/bundle.js +7 -4
  12. package/lib/context.js +33 -25
  13. package/lib/design-check.js +149 -40
  14. package/lib/design.js +2 -2
  15. package/lib/gcf-paths.js +13 -2
  16. package/lib/id.js +2 -2
  17. package/lib/inbox.js +54 -0
  18. package/lib/index-gen.js +23 -8
  19. package/lib/ledger.js +19 -17
  20. package/lib/linter.js +2 -1
  21. package/lib/live.js +191 -0
  22. package/lib/pillars.js +113 -0
  23. package/lib/plan-check.js +31 -0
  24. package/lib/playtest-list.js +8 -4
  25. package/lib/systems-doc.js +16 -1
  26. package/lib/transitions.js +36 -21
  27. package/lib/unit-ops.js +125 -97
  28. package/lib/validate-item.js +244 -0
  29. package/lib/verdict-fs.js +67 -128
  30. package/lib/work-ops.js +759 -0
  31. package/package.json +4 -3
  32. package/profiles/pillars.md +1 -1
  33. package/profiles/system.md +1 -1
  34. package/profiles/unit-constraint.md +10 -0
  35. package/profiles/unit-entity.md +10 -0
  36. package/profiles/unit-mechanic.md +6 -1
  37. package/profiles/unit-resource.md +6 -1
  38. package/profiles/unit-rule.md +10 -0
  39. package/schemas/item.schema.json +105 -0
  40. package/skills/gcf-adopt/SKILL.md +2 -2
  41. package/skills/gcf-audit/SKILL.md +3 -3
  42. package/skills/gcf-card/SKILL.md +27 -20
  43. package/skills/gcf-ingest/SKILL.md +3 -3
  44. package/skills/gcf-init/SKILL.md +8 -5
  45. package/skills/gcf-triage/SKILL.md +35 -0
  46. package/skills/gcf-verdict/SKILL.md +19 -16
  47. package/templates/agents-block.md +28 -0
  48. package/templates/config.yml +7 -0
  49. package/templates/{card.md → item.md} +10 -3
  50. package/lib/validate-card.js +0 -93
  51. package/schemas/card.schema.json +0 -46
@@ -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,15 @@ 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
+ // An id ends once; two ends (a merge of branches that each ended it) leave its dependents
500
+ // counting it as played or dead depending on which line wins.
501
+ if (terminal.length > 1)
502
+ add(`${id} ended ${terminal.join(' and ')}: keep one line in history.md or graveyard.md`);
503
+ if (live.length && terminal.length) {
444
504
  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)',
505
+ `${id} is ${terminal.join(' and ')} already: ids are never reused — give the new one ` +
506
+ `its own id and "reopens: ${id}" (an undone move: take its line out of history.md ` +
507
+ 'or graveyard.md)',
447
508
  );
448
509
  }
449
510
  }
@@ -451,11 +512,47 @@ function ledgerIssues(ledger) {
451
512
  return out;
452
513
  }
453
514
 
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);
515
+ /**
516
+ * M2 over `depends:` of units and items together: a cycle never gets played — every id in it
517
+ * waits for the next one. Each cycle is reported once, from the id DFS meets it at.
518
+ */
519
+ export function dependsCycles(ledger) {
520
+ /** @type {Map<string, string[]>} */
521
+ const graph = new Map();
522
+ for (const u of ledger.design.flatMap((d) => d.units)) {
523
+ const list = listOf(u.header.depends ?? '');
524
+ graph.set(u.id, Array.isArray(list) ? list : []);
525
+ }
457
526
 
458
- return Boolean(unit?.header.reopens || card?.data.reopens);
527
+ for (const c of ledger.items) graph.set(String(c.data.id), c.data.depends ?? []);
528
+ /** @type {Issue[]} */
529
+ const out = [];
530
+ const state = new Map(); // id → 'open' while on the DFS path, 'done' after
531
+ const visit = (id, trail) => {
532
+ if (state.get(id) === 'done') return;
533
+ if (state.get(id) === 'open') {
534
+ const cycle = [...trail.slice(trail.indexOf(id)), id];
535
+ const file = ledger.where(id).live[0]?.file ?? 'design';
536
+ out.push({
537
+ check: 'M2',
538
+ severity: 'block',
539
+ file,
540
+ line: 1,
541
+ id,
542
+ message: `depends cycle ${cycle.join(' -> ')}: none of them can be played first`,
543
+ });
544
+
545
+ return;
546
+ }
547
+
548
+ state.set(id, 'open');
549
+ for (const d of graph.get(id) ?? []) visit(d, [...trail, id]);
550
+ state.set(id, 'done');
551
+ };
552
+
553
+ for (const id of graph.keys()) visit(id, []);
554
+
555
+ return out;
459
556
  }
460
557
 
461
558
  /** M14: backlog.md is the order of the units in design/, each exactly once. */
@@ -527,6 +624,31 @@ function pillarReport(units, ctx) {
527
624
  };
528
625
  }
529
626
 
627
+ /** A unit's line in the report: its TBD count and whether it is ready to take. */
628
+ function unitSummary(u, own, values, ctx) {
629
+ const pillars = listOf(u.header.pillars ?? '');
630
+ // Once PILLARS.md exists, a mechanic or resource says which pillar it serves before work:
631
+ // `take` would turn the TBD into an empty list and the unit would serve nothing silently.
632
+ const pillarsTbd =
633
+ pillars === 'TBD' && ctx.pillarIds.length && CARD_TYPES.includes(u.header.type) ? 1 : 0;
634
+ const tbd = values.filter((v) => isTbd(v.value)).length + pillarsTbd;
635
+ const blockedBy = ctx.questions.d1.get(u.id) ?? [];
636
+
637
+ return {
638
+ id: u.id,
639
+ file: u.file,
640
+ system: u.system,
641
+ type: u.header.type ?? null,
642
+ milestone: u.header.milestone ?? 'TBD',
643
+ pillars,
644
+ pillars_owed: Boolean(pillarsTbd),
645
+ values: values.length,
646
+ tbd,
647
+ blocked_by: blockedBy,
648
+ ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
649
+ };
650
+ }
651
+
530
652
  /**
531
653
  * Run every design check over a project. `blocking` counts the issues that refuse a commit;
532
654
  * the rest of the report (completeness, readiness, pillars) is a reading, not a gate.
@@ -541,27 +663,14 @@ export async function designCheck(projectDir) {
541
663
  for (const u of ledger.design.flatMap((d) => d.units)) {
542
664
  const { issues: own, values } = await checkUnit(u, ctx, ledger);
543
665
  issues.push(...own);
544
- const tbd = values.filter((v) => isTbd(v.value)).length;
545
- const blockedBy = ctx.questions.d1.get(u.id) ?? [];
546
- const pillars = listOf(u.header.pillars ?? '');
547
- units.push({
548
- id: u.id,
549
- file: u.file,
550
- system: u.system,
551
- type: u.header.type ?? null,
552
- milestone: u.header.milestone ?? 'TBD',
553
- pillars,
554
- values: values.length,
555
- tbd,
556
- blocked_by: blockedBy,
557
- ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
558
- });
666
+ units.push(unitSummary(u, own, values, ctx));
559
667
  }
560
668
 
561
- for (const c of ledger.cards) issues.push(...(await checkCard(c, ctx)));
669
+ for (const c of ledger.items) issues.push(...(await checkItem(c, ctx, ledger)));
670
+ issues.push(...(await unplayedIssues(root, ledger)));
562
671
  for (const doc of ['game', 'glossary']) issues.push(...(await checkDoc(root, doc, ctx)));
563
672
  issues.push(...preambleIssues(ledger.design));
564
- issues.push(...ledgerIssues(ledger), ...questionIssues(ctx, ledger));
673
+ issues.push(...ledgerIssues(ledger), ...dependsCycles(ledger), ...questionIssues(ctx, ledger));
565
674
  if (ledger.design.length) {
566
675
  const backlog = await readIf(path.join(root, 'backlog.md'));
567
676
  issues.push(...backlogIssues(backlog, new Set(units.map((u) => u.id))));
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