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/design.js ADDED
@@ -0,0 +1,179 @@
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 work item carries once it is taken into work,
3
+ // so one CNL-P profile per type serves both places.
4
+ import { readFile, readdir } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { parseBlocks, itemsOf, readRecords, loadProfileAt } from 'cnlp-kit';
9
+
10
+ export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
11
+ export const UNIT_TYPES = ['mechanic', 'resource', 'rule', 'constraint', 'entity'];
12
+ // A soft unit waits for a verdict by default; a hard one closes by its tests.
13
+ export const CARD_TYPES = ['mechanic', 'resource'];
14
+ export const HEADER_KEYS = [
15
+ 'type',
16
+ 'title',
17
+ 'route',
18
+ 'system',
19
+ 'milestone',
20
+ 'pillars',
21
+ 'depends',
22
+ 'source',
23
+ 'flags',
24
+ 'reopens',
25
+ ];
26
+ // Engineering decisions of the agent, not words of the designer: no provenance needed.
27
+ export const AGENT_SLOTS = ['check', 'measure', 'contract', 'interactions'];
28
+
29
+ const PROFILES_DIR = fileURLToPath(new URL('../profiles/', import.meta.url));
30
+
31
+ /** The CNL-P profile of a document kind shipped in profiles/ (`unit-mechanic`, `game`, …). */
32
+ export const loadDocProfile = (name) => loadProfileAt(path.join(PROFILES_DIR, `${name}.md`));
33
+
34
+ /** `[a, b]`, `a, b` → ['a', 'b']; `none` and `[]` → []; `TBD` stays the string 'TBD'. */
35
+ export function listOf(value) {
36
+ const v = String(value ?? '').trim();
37
+ if (v === 'TBD') return 'TBD';
38
+ const inner = v.replace(/^\[|\]$/g, '').trim();
39
+ if (!inner || inner === 'none') return [];
40
+
41
+ return inner
42
+ .split(',')
43
+ .map((s) => s.trim())
44
+ .filter(Boolean);
45
+ }
46
+
47
+ /**
48
+ * @typedef {object} Unit
49
+ * @property {string} id
50
+ * @property {string} file gcf-relative path of the design file
51
+ * @property {string} system the subsystem the file names
52
+ * @property {number} line 1-based line of the `## <id>` heading
53
+ * @property {Record<string, string>} header
54
+ * @property {Record<string, number>} headerLines
55
+ * @property {string} body the blocks below the header
56
+ * @property {number} bodyLine 1-based file line the body starts at
57
+ */
58
+
59
+ /** Header fields at the top of a unit: `key: value` lines until the first other key. */
60
+ function splitHeader(lines, start) {
61
+ /** @type {Record<string, string>} */
62
+ const header = {};
63
+ /** @type {Record<string, number>} */
64
+ const headerLines = {};
65
+ let i = 0;
66
+ for (; i < lines.length; i++) {
67
+ if (!lines[i].trim()) continue;
68
+ const m = lines[i].match(/^([a-z_]+):\s*(.*)$/);
69
+ if (!m || !HEADER_KEYS.includes(m[1]) || m[1] in header) break;
70
+ header[m[1]] = m[2].trim();
71
+ headerLines[m[1]] = start + i;
72
+ }
73
+
74
+ return { header, headerLines, rest: i };
75
+ }
76
+
77
+ /**
78
+ * One design file → `{ system, file, preamble, units }`. The preamble is everything above the
79
+ * first `## <id>`: the `# <system>` title and the subsystem's `purpose:` block.
80
+ */
81
+ export function parseDesign(text, system, file = `design/${system}.md`) {
82
+ const lines = String(text).split(/\r?\n/);
83
+ const starts = lines.flatMap((l, i) => (/^## \S/.test(l) ? [i] : []));
84
+ const preamble = lines.slice(0, starts[0] ?? lines.length).join('\n');
85
+ /** @type {Unit[]} */
86
+ const units = starts.map((at, n) => {
87
+ const chunk = lines.slice(at + 1, starts[n + 1] ?? lines.length);
88
+ const { header, headerLines, rest } = splitHeader(chunk, at + 2);
89
+
90
+ return {
91
+ id: lines[at].slice(3).trim(),
92
+ file,
93
+ system,
94
+ line: at + 1,
95
+ header,
96
+ headerLines,
97
+ body: chunk.slice(rest).join('\n'),
98
+ bodyLine: at + 2 + rest,
99
+ };
100
+ });
101
+
102
+ return { system, file, preamble, units };
103
+ }
104
+
105
+ /** Every design file of a project, parsed; [] when gcf/design/ does not exist. */
106
+ export async function readDesign(root) {
107
+ const dir = path.join(root, 'design');
108
+ if (!existsSync(dir)) return [];
109
+ const names = (await readdir(dir)).filter((n) => n.endsWith('.md')).sort();
110
+
111
+ return Promise.all(
112
+ names.map(async (n) =>
113
+ parseDesign(await readFile(path.join(dir, n), 'utf8'), n.slice(0, -3), `design/${n}`),
114
+ ),
115
+ );
116
+ }
117
+
118
+ /** A scalar `key:` value from a preamble or a body. */
119
+ export function scalarIn(text, key) {
120
+ return parseBlocks(text).sections.find((s) => s.key === key)?.value ?? '';
121
+ }
122
+
123
+ /**
124
+ * Every value a body's slots carry: a scalar, a bullet, or a record value other than `name`
125
+ * (a record's name addresses it, it states nothing). `provenance` is metadata, not a slot.
126
+ */
127
+ export function slotValues(body, profile) {
128
+ const { lines, sections } = parseBlocks(body);
129
+ const forms = new Map(profile.sections.map((s) => [s.key, s.form]));
130
+ const out = [];
131
+ for (const s of sections.filter((x) => x.key !== 'provenance')) {
132
+ if (s.value) {
133
+ out.push({ block: s.key, value: s.value, line: s.line, name: null });
134
+ continue;
135
+ }
136
+
137
+ const items = itemsOf(lines, s.line);
138
+ if (forms.get(s.key) === 'record-list') out.push(...recordValues(s.key, items));
139
+ else
140
+ out.push(
141
+ ...items.map((i) => ({
142
+ block: s.key,
143
+ value: i.text.replace(/^- /, ''),
144
+ line: i.line,
145
+ name: null,
146
+ })),
147
+ );
148
+ }
149
+
150
+ return out;
151
+ }
152
+
153
+ function recordValues(block, items) {
154
+ const out = [];
155
+ for (const rec of readRecords(items).records) {
156
+ const name = rec.values.name ?? null;
157
+ for (const k of rec.keys.filter((x) => x !== 'name')) {
158
+ out.push({ block, value: rec.values[k], line: rec.line, name });
159
+ }
160
+ }
161
+
162
+ return out;
163
+ }
164
+
165
+ /** The provenance records of a body: `{ slot, src, quote, answer, assumed, line }`. */
166
+ export function provenanceOf(body) {
167
+ const { lines, sections } = parseBlocks(body);
168
+ const at = sections.find((s) => s.key === 'provenance');
169
+ if (!at) return [];
170
+
171
+ return readRecords(itemsOf(lines, at.line)).records.map((r) => ({
172
+ ...r.values,
173
+ quote: r.values.quote?.replace(/^"/, '').replace(/"$/, ''),
174
+ line: r.line,
175
+ }));
176
+ }
177
+
178
+ /** A value that says "not known yet": the whole value, or the tail of `- <subject>: TBD`. */
179
+ export const isTbd = (v) => v === 'TBD' || /: TBD$/.test(v);
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/git.js CHANGED
@@ -1,6 +1,69 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
5
+ // `git commit --only <paths>` is a partial commit, which git refuses outright while a merge
6
+ // or a sequencer operation is in progress. Detect it up front so the failure has a reason.
7
+ const IN_PROGRESS = [
8
+ 'MERGE_HEAD',
9
+ 'CHERRY_PICK_HEAD',
10
+ 'REVERT_HEAD',
11
+ 'rebase-merge',
12
+ 'rebase-apply',
13
+ ];
14
+
15
+ /** The name of the merge or sequencer operation in progress, or null. */
16
+ export function operationInProgress(projectDir) {
17
+ const gitDir = spawnSync('git', ['rev-parse', '--git-dir'], {
18
+ cwd: projectDir,
19
+ encoding: 'utf8',
20
+ });
21
+ if (gitDir.status !== 0) return null;
22
+ const dir = path.resolve(projectDir, gitDir.stdout.trim());
23
+
24
+ return IN_PROGRESS.find((name) => existsSync(path.join(dir, name))) ?? null;
25
+ }
26
+
27
+ const tracked = (projectDir, p) =>
28
+ spawnSync('git', ['ls-files', '--error-unmatch', '--', p], { cwd: projectDir, stdio: 'ignore' })
29
+ .status === 0;
30
+
31
+ /**
32
+ * Paths with uncommitted changes, staged or not. A move stages whole files, so a user's own
33
+ * edit in one of them would ride along in the move's commit unseen.
34
+ */
35
+ export function dirtyPaths(projectDir, paths) {
36
+ // No existence filter: a staged deletion is neither on disk nor in the index, and a move
37
+ // writing that file anew would commit away every line the deleted file held.
38
+ if (!paths.length) return [];
39
+ const res = spawnSync('git', ['status', '--porcelain', '--', ...paths], {
40
+ cwd: projectDir,
41
+ encoding: 'utf8',
42
+ });
43
+
44
+ return res.status === 0 ? res.stdout.split('\n').filter(Boolean) : [];
45
+ }
46
+
47
+ /**
48
+ * Stage and commit exactly `paths`: `git add -- gcf/` would stage a user's unrelated edits
49
+ * under gcf/, and a rollback only restores what it snapshotted. A path that neither exists nor
50
+ * is tracked is left out — git refuses a pathspec that matches nothing. Returns the new HEAD.
51
+ */
52
+ export function commitPaths(projectDir, message, paths) {
53
+ const real = paths.filter((p) => existsSync(p) || tracked(projectDir, p));
54
+ for (const p of real) spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
55
+ const res = spawnSync('git', ['commit', '-m', message, '--only', '--', ...real], {
56
+ cwd: projectDir,
57
+ encoding: 'utf8',
58
+ });
59
+ if (res.status !== 0) throw new Error(`commit failed: ${res.stderr || res.stdout}`);
60
+
61
+ return spawnSync('git', ['rev-parse', 'HEAD'], {
62
+ cwd: projectDir,
63
+ encoding: 'utf8',
64
+ }).stdout.trim();
65
+ }
66
+
4
67
  /**
5
68
  * The stage-0 index entry (mode + blob) of each file, or `entry: null` when the index
6
69
  * holds none. Paths are taken relative to `cwd` (an existing directory inside the work
package/lib/id.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import path from 'node:path';
2
2
 
3
- // GCF-PRD §5.2/§5.5: candidates are `cand-NNN`, cards are `card-NNN`; a card keeps the
4
- // candidate's number on materialization. The slug lives next to the id, not inside it.
5
- const ID_RE = /^(card|cand)-\d{3}$/;
3
+ // GCF-PRD §5.8: a unit's id is a slug, one for every stage — the `## <id>` section in design/,
4
+ // the work item work/<id>.md, the history or graveyard line, the move's commit. Frozen once made.
5
+ const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
6
6
 
7
- /** Derive a stable slug from a free-text name. Created once; never re-derived. */
7
+ /** Derive a slug from a free-text name. Created once; never re-derived. */
8
8
  export function slugify(name) {
9
9
  const slug = String(name)
10
10
  .toLowerCase()
@@ -19,15 +19,7 @@ export function isValidId(id) {
19
19
  return ID_RE.test(String(id ?? ''));
20
20
  }
21
21
 
22
- /** `cand-012` → `card-012`: materialization keeps the number (GCF-PRD §5.2). */
23
- export function candToCard(candId) {
24
- const m = String(candId).match(/^cand-(\d{3})$/);
25
- if (!m) throw new Error(`Not a candidate id: ${candId}`);
26
-
27
- return `card-${m[1]}`;
28
- }
29
-
30
- /** Card filename invariant: `cards/<id>-<slug>.md`. */
31
- export function stemMatches(file, id, slug) {
32
- return path.basename(file, '.md') === `${id}-${slug}`;
22
+ /** Work item filename invariant: `work/<id>.md`. */
23
+ export function stemMatches(file, id) {
24
+ return path.basename(file, '.md') === id;
33
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,23 +1,39 @@
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';
7
+ import { readDesign, slotValues, loadDocProfile, isTbd, UNIT_TYPES } from './design.js';
6
8
 
7
9
  /**
8
10
  * INDEX.md is a deterministic function of gcf/ contents (GCF-PRD §5.1): generated,
9
11
  * never hand-written. The pre-commit hook regenerates it and stages the result.
10
12
  */
13
+ const SINGLETONS = [
14
+ ['pillars', 'PILLARS.md', 'pillars and negative invariants'],
15
+ ['game', 'game.md', 'vision, scope and milestones'],
16
+ ['glossary', 'glossary.md', 'terms and forbidden synonyms'],
17
+ ];
18
+
11
19
  export async function generate(root) {
12
20
  const lines = ['# INDEX — generated, do not edit', ''];
13
21
  const row = ([type, file, status, system, descr]) =>
14
22
  lines.push(`${type} | gcf/${file} | ${status} | ${system} | ${descr}`);
15
23
 
16
- if (existsSync(path.join(root, 'PILLARS.md')))
17
- row(['pillars', 'PILLARS.md', '-', '-', 'pillars and negative invariants']);
24
+ for (const [type, file, descr] of SINGLETONS) {
25
+ if (existsSync(path.join(root, file))) row([type, file, '-', '-', descr]);
26
+ }
27
+
18
28
  const backlog = await readIfExists(root, 'backlog.md');
19
29
  if (backlog !== null) {
20
- row(['backlog', 'backlog.md', '-', '-', `${count(backlog, /^- cand-/gm)} candidates`]);
30
+ row(['backlog', 'backlog.md', '-', '-', `${count(backlog, /^- \S+/gm)} units in order`]);
31
+ }
32
+
33
+ // One row per subsystem, not per unit: INDEX is the one file read whole.
34
+ for (const d of await readDesign(root)) {
35
+ const tbd = await tbdCount(d.units);
36
+ row(['design', d.file, '-', d.system, `${d.units.length} units, ${tbd} TBD`]);
21
37
  }
22
38
 
23
39
  for (const s of await listSystems(root)) {
@@ -25,15 +41,8 @@ export async function generate(root) {
25
41
  row(['system', `systems/${s.name}`, '-', s.data.system ?? '-', purpose]);
26
42
  }
27
43
 
28
- for (const c of await listCards(root)) {
29
- const system = (c.data.system ?? []).join(',');
30
- row(['card', `cards/${c.name}`, c.data.status ?? '?', system, firstBullet(c.body, 'behavior')]);
31
- }
32
-
33
- const graveyard = await readIfExists(root, 'graveyard.md');
34
- if (graveyard !== null) {
35
- row(['graveyard', 'graveyard.md', '-', '-', `${count(graveyard, /^- card-/gm)} killed ideas`]);
36
- }
44
+ await workRows(root, row);
45
+ await endRows(root, row);
37
46
 
38
47
  const txt = await readIfExists(root, 'open-questions.md');
39
48
  if (txt !== null) {
@@ -45,6 +54,35 @@ export async function generate(root) {
45
54
  return `${lines.join('\n')}\n`;
46
55
  }
47
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
+
75
+ /** The rows of the files that record how lives ended: one count per file. */
76
+ async function endRows(root, row) {
77
+ for (const [type, what] of [
78
+ ['history', 'ended accepted'],
79
+ ['graveyard', 'killed or dropped'],
80
+ ]) {
81
+ const text = await readIfExists(root, `${type}.md`);
82
+ if (text !== null) row([type, `${type}.md`, '-', '-', `${count(text, /^- \S+ \|/gm)} ${what}`]);
83
+ }
84
+ }
85
+
48
86
  async function readIfExists(root, rel) {
49
87
  const file = path.join(root, rel);
50
88
 
@@ -53,6 +91,17 @@ async function readIfExists(root, rel) {
53
91
 
54
92
  const count = (text, re) => (text.match(re) ?? []).length;
55
93
 
94
+ /** TBD values across units, read with each unit's own profile; an unknown type counts none. */
95
+ async function tbdCount(units) {
96
+ let n = 0;
97
+ for (const u of units.filter((x) => UNIT_TYPES.includes(x.header.type))) {
98
+ const values = slotValues(u.body, await loadDocProfile(`unit-${u.header.type}`));
99
+ n += values.filter((v) => isTbd(v.value)).length;
100
+ }
101
+
102
+ return n;
103
+ }
104
+
56
105
  export async function writeIndex(root) {
57
106
  const content = await generate(root);
58
107
  await atomicWrite(path.join(root, 'INDEX.md'), content);
package/lib/ledger.js ADDED
@@ -0,0 +1,94 @@
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.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { readDesign } from './design.js';
11
+ import { listItems } from './gcf-paths.js';
12
+
13
+ export const HISTORY_EVENTS = ['done', 'collapsed', 'decided'];
14
+
15
+ /** `[id, second field]` of each line `- <id> | <field> | …` of a gcf file. */
16
+ async function recordLines(root, name) {
17
+ const file = path.join(root, name);
18
+ if (!existsSync(file)) return [];
19
+
20
+ return (await readFile(file, 'utf8')).split('\n').flatMap((l) => {
21
+ const m = l.match(/^- (\S+) \|\s*([^|]*?)\s*(?:\||$)/);
22
+
23
+ return m ? [[m[1], m[2]]] : [];
24
+ });
25
+ }
26
+
27
+ const gitOut = (args, cwd) => {
28
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
29
+
30
+ return res.status === 0 ? res.stdout : null;
31
+ };
32
+
33
+ /** Ids HEAD holds as a work item file or a design section. */
34
+ function headIds(projectDir) {
35
+ const files = gitOut(
36
+ ['ls-tree', '-r', '--name-only', 'HEAD', '--', 'gcf/work', 'gcf/design'],
37
+ projectDir,
38
+ );
39
+ const ids = new Set();
40
+ // Top level only, as readDesign and listItems read: a nested archive file is not a unit.
41
+ for (const f of (files ?? '')
42
+ .split('\n')
43
+ .filter((x) => /^gcf\/(work|design)\/[^/]+\.md$/.test(x))) {
44
+ if (f.startsWith('gcf/work/')) ids.add(path.basename(f, '.md'));
45
+ // `./` makes the path relative to the cwd, as ls-tree printed it.
46
+ else
47
+ for (const m of (gitOut(['show', `HEAD:./${f}`], projectDir) ?? '').matchAll(/^## (\S+)/gm))
48
+ ids.add(m[1]);
49
+ }
50
+
51
+ return ids;
52
+ }
53
+
54
+ /**
55
+ * @typedef {object} Place
56
+ * @property {'design' | 'item'} state
57
+ * @property {string} file gcf-relative
58
+ */
59
+
60
+ /**
61
+ * Build the ledger of a project: `where(id)` answers `{ live, terminal }` — the live places
62
+ * (a design section, a work item) and the terminal events (done, collapsed, decided, killed).
63
+ */
64
+ export async function buildLedger(projectDir) {
65
+ const root = path.join(path.resolve(projectDir), 'gcf');
66
+ const design = await readDesign(root);
67
+ const items = await listItems(root);
68
+ /** @type {Map<string, Place[]>} */
69
+ const live = new Map();
70
+ const add = (id, place) => live.set(id, [...(live.get(id) ?? []), place]);
71
+ for (const d of design) for (const u of d.units) add(u.id, { state: 'design', file: d.file });
72
+ for (const c of items) add(String(c.data.id), { state: 'item', file: `work/${c.name}` });
73
+
74
+ /** @type {Map<string, Set<string>>} */
75
+ const terminal = new Map();
76
+ const mark = (id, event) => terminal.set(id, (terminal.get(id) ?? new Set()).add(event));
77
+ for (const [id, event] of await recordLines(root, 'history.md'))
78
+ if (HISTORY_EVENTS.includes(event)) mark(id, event);
79
+ for (const [id] of await recordLines(root, 'graveyard.md')) mark(id, 'killed');
80
+ // An id HEAD holds that has no live place and no terminal line is vanishing in this commit:
81
+ // it is not known, so an item or unit still citing it fails M2 in this very commit, and
82
+ // `vanished()` lets the gate warn that its reason was lost.
83
+ const vanished = [...headIds(projectDir)].filter((id) => !live.has(id) && !terminal.has(id));
84
+
85
+ return {
86
+ design,
87
+ items,
88
+ item: (id) => items.find((c) => c.data.id === id),
89
+ where: (id) => ({ id, live: live.get(id) ?? [], terminal: [...(terminal.get(id) ?? [])] }),
90
+ known: (id) => live.has(id) || terminal.has(id),
91
+ ids: () => [...new Set([...live.keys(), ...terminal.keys()])].sort(),
92
+ vanished: () => vanished,
93
+ };
94
+ }
package/lib/linter.js CHANGED
@@ -1,9 +1,15 @@
1
1
  // П2 linter (GCF-PRD §13.2): explicit patterns over systems docs, no ML.
2
2
  // `<!-- gcf:allow <reason> -->` on the preceding line suppresses the next line's finding;
3
3
  // a pragma without a reason is itself a finding. cnlp check runs separately (form/lexicon).
4
-
5
- const UNIT_NUM = /\d+(?:[.,]\d+)?\s?(?:ms|мс|s|с|dmg|hp|px|сек)\b/;
6
- const SIGNATURE = /\w+\s*\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
4
+ import { UNITS, closingComparison } from './design-check.js';
5
+
6
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ // One unit list with the design check (M5), plus the units balance values come in. The end of
8
+ // a unit is any non-letter in any script: `\w` is ASCII-only, and "5 слотов" is not "5 с".
9
+ const UNIT_WORDS = [...UNITS, 'мс', 'с', 'сек', 'dmg', 'hp', 'px'].map(esc).join('|');
10
+ const UNIT_NUM = new RegExp(`\\d+(?:[.,]\\d+)?\\s?(?:${UNIT_WORDS})(?![\\p{L}\\p{N}_-])`, 'u');
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[^)]*\)/;
7
13
  const CAMEL = /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/;
8
14
  const ENVELOPE_FIELD = /^\s*(?:- )?(?:question|assumed|proposed):/;
9
15
 
@@ -26,8 +32,10 @@ function blockOf(lines, i) {
26
32
  }
27
33
 
28
34
  export function lint(content) {
35
+ // CRLF from a Windows checkout: a trailing `\r` would defeat every end-anchored pattern.
36
+ const lines = String(content).replace(/\r\n/g, '\n').split('\n');
29
37
  /** @type {LintState} */
30
- const st = { lines: String(content).split('\n'), issues: [], pragmas: [], fenceStart: -1 };
38
+ const st = { lines, issues: [], pragmas: [], fenceStart: -1 };
31
39
 
32
40
  for (let i = 0; i < st.lines.length; i++) {
33
41
  const allowed = readPragma(st, i);
@@ -85,7 +93,11 @@ function checkLine(st, i) {
85
93
 
86
94
  // Envelopes are legal where the spec puts them: the feel block, and the question/assumed/
87
95
  // proposed fields of an open-questions record, where import parks D2 envelopes for the human.
88
- if (blockOf(st.lines, i) !== 'feel' && !ENVELOPE_FIELD.test(line) && UNIT_NUM.test(line)) {
96
+ // An exact limit is legal as a closing comparison in any block (GCF-PRD §11.3), as in the
97
+ // card M5 accepted: a boundary collapses into `behavior:` and must not fail its own verdict.
98
+ const block = blockOf(st.lines, i);
99
+ const limit = closingComparison(line);
100
+ if (block !== 'feel' && !limit && !ENVELOPE_FIELD.test(line) && UNIT_NUM.test(line)) {
89
101
  flag('number-with-unit-outside-feel');
90
102
  }
91
103