gcf-aif-ext 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/commands/gcf.js +408 -73
- package/extension.json +1 -1
- package/hooks/pre-commit.js +47 -29
- package/injections/commit-status.md +1 -0
- package/injections/plan-context.md +3 -2
- package/injections/review-pillars.md +1 -1
- package/lib/anchors.js +110 -10
- package/lib/audit-core.js +175 -50
- package/lib/bundle.js +275 -0
- package/lib/context.js +55 -36
- package/lib/design-check.js +580 -0
- package/lib/design.js +179 -0
- package/lib/frontmatter.js +2 -1
- package/lib/gcf-paths.js +14 -3
- package/lib/git.js +85 -4
- package/lib/id.js +8 -14
- package/lib/index-gen.js +68 -16
- package/lib/ledger.js +92 -0
- package/lib/linter.js +91 -42
- package/lib/migrate.js +345 -0
- package/lib/playtest-list.js +1 -1
- package/lib/safe-path.js +23 -4
- package/lib/systems-doc.js +70 -0
- package/lib/transitions.js +1 -3
- package/lib/unit-ops.js +270 -0
- package/lib/validate-card.js +59 -14
- package/lib/verdict-fs.js +173 -96
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +2 -1
- package/profiles/unit-constraint.md +42 -0
- package/profiles/unit-entity.md +38 -0
- package/profiles/unit-mechanic.md +106 -0
- package/profiles/unit-resource.md +86 -0
- package/profiles/unit-rule.md +42 -0
- package/schemas/card.schema.json +23 -7
- package/skills/gcf-adopt/SKILL.md +3 -3
- package/skills/gcf-audit/SKILL.md +4 -3
- package/skills/gcf-card/SKILL.md +26 -16
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +43 -16
- package/skills/gcf-init/SKILL.md +9 -8
- package/skills/gcf-verdict/SKILL.md +7 -6
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -4
- package/templates/card.md +27 -10
- package/templates/cnlp.config.json +0 -1
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/system.md +1 -1
- package/profiles/card.md +0 -65
package/lib/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 card 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
|
+
// Only a soft unit becomes a card; a hard one leaves design/ through `gcf done`.
|
|
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/frontmatter.js
CHANGED
|
@@ -15,8 +15,9 @@ export function parse(content) {
|
|
|
15
15
|
try {
|
|
16
16
|
data = YAML.parse(m[1]) ?? {};
|
|
17
17
|
} catch (err) {
|
|
18
|
-
throw new Error(`Frontmatter YAML could not be parsed: ${err.message}
|
|
18
|
+
throw new Error(`Frontmatter YAML could not be parsed: ${err.message}`, { cause: err });
|
|
19
19
|
}
|
|
20
|
+
|
|
20
21
|
return { data, body: m[2], hasFrontmatter: true };
|
|
21
22
|
}
|
|
22
23
|
|
package/lib/gcf-paths.js
CHANGED
|
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
|
|
|
19
19
|
export function gcfRoot(projectDir = process.cwd()) {
|
|
20
20
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
21
21
|
if (!existsSync(root)) throw new Error(`No gcf/ directory in ${projectDir}. Run gcf init first.`);
|
|
22
|
+
|
|
22
23
|
return root;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -26,16 +27,23 @@ export async function readConfig(root) {
|
|
|
26
27
|
const file = path.join(root, 'config.yml');
|
|
27
28
|
if (!existsSync(file)) return { ...DEFAULT_CONFIG };
|
|
28
29
|
const parsed = YAML.parse(await readFile(file, 'utf8')) ?? {};
|
|
29
|
-
if (typeof parsed !== 'object' || Array.isArray(parsed))
|
|
30
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed))
|
|
31
|
+
throw new Error('gcf/config.yml must be a key: value mapping');
|
|
30
32
|
// A blank `key:` parses as null and means "not set", like an absent key.
|
|
31
|
-
const config = {
|
|
33
|
+
const config = {
|
|
34
|
+
...DEFAULT_CONFIG,
|
|
35
|
+
...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)),
|
|
36
|
+
};
|
|
32
37
|
// A limit that is not a number compares false against everything, which silently
|
|
33
38
|
// switches its gate off — the one failure a gate must not have.
|
|
34
39
|
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
|
35
40
|
if (!Number.isFinite(config[key]) || config[key] < 0) {
|
|
36
|
-
throw new Error(
|
|
41
|
+
throw new Error(
|
|
42
|
+
`gcf/config.yml: ${key} must be a non-negative number, got ${JSON.stringify(config[key])}`,
|
|
43
|
+
);
|
|
37
44
|
}
|
|
38
45
|
}
|
|
46
|
+
|
|
39
47
|
return config;
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -52,6 +60,7 @@ async function listDocs(dir) {
|
|
|
52
60
|
const { data, body } = parse(await readFile(file, 'utf8'));
|
|
53
61
|
out.push({ file, name, data, body });
|
|
54
62
|
}
|
|
63
|
+
|
|
55
64
|
return out;
|
|
56
65
|
}
|
|
57
66
|
|
|
@@ -61,11 +70,13 @@ export const listSystems = (root) => listDocs(path.join(root, 'systems'));
|
|
|
61
70
|
/** First bullet of a `key:` block in a CNL-P body, for one-line descriptions. */
|
|
62
71
|
export function firstBullet(body, key) {
|
|
63
72
|
const m = String(body).match(new RegExp(`^${key}:\\s*\\n- (.+)$`, 'm'));
|
|
73
|
+
|
|
64
74
|
return m ? m[1].trim() : '';
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/** Scalar value of a `key:` line. */
|
|
68
78
|
export function scalarOf(body, key) {
|
|
69
79
|
const m = String(body).match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
|
|
80
|
+
|
|
70
81
|
return m ? m[1].trim() : '';
|
|
71
82
|
}
|
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
|
|
@@ -13,7 +76,10 @@ export function snapshotIndex(cwd, files) {
|
|
|
13
76
|
// -z: no C-quoting of non-ASCII paths, so the output is the path itself.
|
|
14
77
|
const res = spawnSync('git', ['ls-files', '-s', '-z', '--', ...rels], { cwd, encoding: 'utf8' });
|
|
15
78
|
if (res.status !== 0) return null;
|
|
16
|
-
const prefix = spawnSync('git', ['rev-parse', '--show-prefix'], {
|
|
79
|
+
const prefix = spawnSync('git', ['rev-parse', '--show-prefix'], {
|
|
80
|
+
cwd,
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
}).stdout.trim();
|
|
17
83
|
const entries = new Map();
|
|
18
84
|
const conflicted = new Set();
|
|
19
85
|
for (const rec of res.stdout.split('\0').filter(Boolean)) {
|
|
@@ -22,7 +88,14 @@ export function snapshotIndex(cwd, files) {
|
|
|
22
88
|
if (m[3] === '0') entries.set(m[4], { mode: m[1], blob: m[2] });
|
|
23
89
|
else conflicted.add(m[4]);
|
|
24
90
|
}
|
|
25
|
-
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
cwd,
|
|
94
|
+
prefix,
|
|
95
|
+
files: rels
|
|
96
|
+
.filter((r) => !conflicted.has(r))
|
|
97
|
+
.map((rel) => ({ rel, entry: entries.get(rel) ?? null })),
|
|
98
|
+
};
|
|
26
99
|
}
|
|
27
100
|
|
|
28
101
|
/** Put back the entries `snapshotIndex` took. Returns one Error per path it could not restore. */
|
|
@@ -33,10 +106,18 @@ export function restoreIndex(snapshot) {
|
|
|
33
106
|
// --cacheinfo takes a path from the repository root and ignores the cwd; --force-remove
|
|
34
107
|
// takes one relative to the cwd.
|
|
35
108
|
const args = entry
|
|
36
|
-
? [
|
|
109
|
+
? [
|
|
110
|
+
'update-index',
|
|
111
|
+
'--add',
|
|
112
|
+
'--cacheinfo',
|
|
113
|
+
`${entry.mode},${entry.blob},${snapshot.prefix}${rel}`,
|
|
114
|
+
]
|
|
37
115
|
: ['update-index', '--force-remove', '--', rel];
|
|
38
116
|
const res = spawnSync('git', args, { cwd: snapshot.cwd, encoding: 'utf8' });
|
|
39
|
-
|
|
117
|
+
const reason = (res.stderr || '').trim() || res.error?.message;
|
|
118
|
+
if (res.status !== 0)
|
|
119
|
+
errors.push(new Error(`could not restore the index entry of ${rel}: ${reason}`));
|
|
40
120
|
}
|
|
121
|
+
|
|
41
122
|
return errors;
|
|
42
123
|
}
|
package/lib/id.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
|
-
// GCF-PRD §5.
|
|
4
|
-
//
|
|
5
|
-
const ID_RE = /^(
|
|
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.
|
|
5
|
+
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
6
6
|
|
|
7
|
-
/** Derive a
|
|
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()
|
|
11
11
|
.replace(/[^a-z0-9]+/g, '-')
|
|
12
12
|
.replace(/^-+|-+$/g, '');
|
|
13
13
|
if (!slug) throw new Error(`Cannot derive a slug from: ${JSON.stringify(name)}`);
|
|
14
|
+
|
|
14
15
|
return slug;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -18,14 +19,7 @@ export function isValidId(id) {
|
|
|
18
19
|
return ID_RE.test(String(id ?? ''));
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
export function
|
|
23
|
-
|
|
24
|
-
if (!m) throw new Error(`Not a candidate id: ${candId}`);
|
|
25
|
-
return `card-${m[1]}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Card filename invariant: `cards/<id>-<slug>.md`. */
|
|
29
|
-
export function stemMatches(file, id, slug) {
|
|
30
|
-
return path.basename(file, '.md') === `${id}-${slug}`;
|
|
22
|
+
/** Card filename invariant: `cards/<id>.md`. */
|
|
23
|
+
export function stemMatches(file, id) {
|
|
24
|
+
return path.basename(file, '.md') === id;
|
|
31
25
|
}
|
package/lib/index-gen.js
CHANGED
|
@@ -3,43 +3,94 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { listCards, listSystems, firstBullet } from './gcf-paths.js';
|
|
5
5
|
import { atomicWrite } from './safe-path.js';
|
|
6
|
+
import { readDesign, slotValues, loadDocProfile, isTbd, UNIT_TYPES } from './design.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* INDEX.md is a deterministic function of gcf/ contents (GCF-PRD §5.1): generated,
|
|
9
10
|
* never hand-written. The pre-commit hook regenerates it and stages the result.
|
|
10
11
|
*/
|
|
12
|
+
const SINGLETONS = [
|
|
13
|
+
['pillars', 'PILLARS.md', 'pillars and negative invariants'],
|
|
14
|
+
['game', 'game.md', 'vision, scope and milestones'],
|
|
15
|
+
['glossary', 'glossary.md', 'terms and forbidden synonyms'],
|
|
16
|
+
];
|
|
17
|
+
|
|
11
18
|
export async function generate(root) {
|
|
12
19
|
const lines = ['# INDEX — generated, do not edit', ''];
|
|
13
|
-
const row = (type, file, status, system, descr) =>
|
|
20
|
+
const row = ([type, file, status, system, descr]) =>
|
|
14
21
|
lines.push(`${type} | gcf/${file} | ${status} | ${system} | ${descr}`);
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
for (const [type, file, descr] of SINGLETONS) {
|
|
24
|
+
if (existsSync(path.join(root, file))) row([type, file, '-', '-', descr]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const backlog = await readIfExists(root, 'backlog.md');
|
|
28
|
+
if (backlog !== null) {
|
|
29
|
+
row(['backlog', 'backlog.md', '-', '-', `${count(backlog, /^- \S+/gm)} units in order`]);
|
|
20
30
|
}
|
|
31
|
+
|
|
32
|
+
// One row per subsystem, not per unit: INDEX is the one file read whole.
|
|
33
|
+
for (const d of await readDesign(root)) {
|
|
34
|
+
const tbd = await tbdCount(d.units);
|
|
35
|
+
row(['design', d.file, '-', d.system, `${d.units.length} units, ${tbd} TBD`]);
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
for (const s of await listSystems(root)) {
|
|
22
|
-
|
|
39
|
+
const purpose = firstBullet(s.body, 'purpose');
|
|
40
|
+
row(['system', `systems/${s.name}`, '-', s.data.system ?? '-', purpose]);
|
|
23
41
|
}
|
|
42
|
+
|
|
24
43
|
for (const c of await listCards(root)) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (existsSync(path.join(root, 'graveyard.md'))) {
|
|
28
|
-
const count = ((await readFile(path.join(root, 'graveyard.md'), 'utf8')).match(/^- card-/gm) ?? []).length;
|
|
29
|
-
row('graveyard', 'graveyard.md', '-', '-', `${count} killed ideas`);
|
|
44
|
+
const system = (c.data.system ?? []).join(',');
|
|
45
|
+
row(['card', `cards/${c.name}`, c.data.status ?? '?', system, firstBullet(c.body, 'intent')]);
|
|
30
46
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
47
|
+
|
|
48
|
+
await endRows(root, row);
|
|
49
|
+
|
|
50
|
+
const txt = await readIfExists(root, 'open-questions.md');
|
|
51
|
+
if (txt !== null) {
|
|
52
|
+
const d1 = count(txt, /^\s*class:\s*D1/gm);
|
|
53
|
+
const d2 = count(txt, /^\s*class:\s*D2/gm);
|
|
54
|
+
row(['questions', 'open-questions.md', '-', '-', `${d1} D1, ${d2} D2`]);
|
|
36
55
|
}
|
|
56
|
+
|
|
37
57
|
return `${lines.join('\n')}\n`;
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
/** The rows of the files that record how lives ended: one count per file. */
|
|
61
|
+
async function endRows(root, row) {
|
|
62
|
+
for (const [type, what] of [
|
|
63
|
+
['history', 'built or kept'],
|
|
64
|
+
['graveyard', 'killed ideas'],
|
|
65
|
+
]) {
|
|
66
|
+
const text = await readIfExists(root, `${type}.md`);
|
|
67
|
+
if (text !== null) row([type, `${type}.md`, '-', '-', `${count(text, /^- \S+ \|/gm)} ${what}`]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readIfExists(root, rel) {
|
|
72
|
+
const file = path.join(root, rel);
|
|
73
|
+
|
|
74
|
+
return existsSync(file) ? readFile(file, 'utf8') : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const count = (text, re) => (text.match(re) ?? []).length;
|
|
78
|
+
|
|
79
|
+
/** TBD values across units, read with each unit's own profile; an unknown type counts none. */
|
|
80
|
+
async function tbdCount(units) {
|
|
81
|
+
let n = 0;
|
|
82
|
+
for (const u of units.filter((x) => UNIT_TYPES.includes(x.header.type))) {
|
|
83
|
+
const values = slotValues(u.body, await loadDocProfile(`unit-${u.header.type}`));
|
|
84
|
+
n += values.filter((v) => isTbd(v.value)).length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return n;
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
export async function writeIndex(root) {
|
|
41
91
|
const content = await generate(root);
|
|
42
92
|
await atomicWrite(path.join(root, 'INDEX.md'), content);
|
|
93
|
+
|
|
43
94
|
return content;
|
|
44
95
|
}
|
|
45
96
|
|
|
@@ -47,5 +98,6 @@ export async function writeIndex(root) {
|
|
|
47
98
|
export async function drifted(root) {
|
|
48
99
|
const file = path.join(root, 'INDEX.md');
|
|
49
100
|
if (!existsSync(file)) return true;
|
|
101
|
+
|
|
50
102
|
return (await readFile(file, 'utf8')) !== (await generate(root));
|
|
51
103
|
}
|
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { readDesign } from './design.js';
|
|
10
|
+
import { listCards } from './gcf-paths.js';
|
|
11
|
+
|
|
12
|
+
const HISTORY_EVENTS = ['done', 'collapsed'];
|
|
13
|
+
|
|
14
|
+
/** `[id, second field]` of each line `- <id> | <field> | …` of a gcf file. */
|
|
15
|
+
async function recordLines(root, name) {
|
|
16
|
+
const file = path.join(root, name);
|
|
17
|
+
if (!existsSync(file)) return [];
|
|
18
|
+
|
|
19
|
+
return (await readFile(file, 'utf8')).split('\n').flatMap((l) => {
|
|
20
|
+
const m = l.match(/^- (\S+) \|\s*([^|]*?)\s*(?:\||$)/);
|
|
21
|
+
|
|
22
|
+
return m ? [[m[1], m[2]]] : [];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const gitOut = (args, cwd) => {
|
|
27
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
28
|
+
|
|
29
|
+
return res.status === 0 ? res.stdout : null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Ids HEAD holds as a card file or a design section. */
|
|
33
|
+
function headIds(projectDir) {
|
|
34
|
+
const files = gitOut(
|
|
35
|
+
['ls-tree', '-r', '--name-only', 'HEAD', '--', 'gcf/cards', 'gcf/design'],
|
|
36
|
+
projectDir,
|
|
37
|
+
);
|
|
38
|
+
const ids = new Set();
|
|
39
|
+
// Top level only, as readDesign and listCards read: a nested archive file is not a unit.
|
|
40
|
+
for (const f of (files ?? '')
|
|
41
|
+
.split('\n')
|
|
42
|
+
.filter((x) => /^gcf\/(cards|design)\/[^/]+\.md$/.test(x))) {
|
|
43
|
+
if (f.startsWith('gcf/cards/')) ids.add(path.basename(f, '.md'));
|
|
44
|
+
// `./` makes the path relative to the cwd, as ls-tree printed it.
|
|
45
|
+
else
|
|
46
|
+
for (const m of (gitOut(['show', `HEAD:./${f}`], projectDir) ?? '').matchAll(/^## (\S+)/gm))
|
|
47
|
+
ids.add(m[1]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return ids;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {object} Place
|
|
55
|
+
* @property {'design' | 'card'} state
|
|
56
|
+
* @property {string} file gcf-relative
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 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
|
+
*/
|
|
63
|
+
export async function buildLedger(projectDir) {
|
|
64
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
65
|
+
const design = await readDesign(root);
|
|
66
|
+
const cards = await listCards(root);
|
|
67
|
+
/** @type {Map<string, Place[]>} */
|
|
68
|
+
const live = new Map();
|
|
69
|
+
const add = (id, place) => live.set(id, [...(live.get(id) ?? []), place]);
|
|
70
|
+
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
|
+
|
|
73
|
+
/** @type {Map<string, Set<string>>} */
|
|
74
|
+
const terminal = new Map();
|
|
75
|
+
const mark = (id, event) => terminal.set(id, (terminal.get(id) ?? new Set()).add(event));
|
|
76
|
+
for (const [id, event] of await recordLines(root, 'history.md'))
|
|
77
|
+
if (HISTORY_EVENTS.includes(event)) mark(id, event);
|
|
78
|
+
for (const [id] of await recordLines(root, 'graveyard.md')) mark(id, 'killed');
|
|
79
|
+
// 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
|
+
// `vanished()` lets the gate warn that its reason was lost.
|
|
82
|
+
const vanished = [...headIds(projectDir)].filter((id) => !live.has(id) && !terminal.has(id));
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
design,
|
|
86
|
+
cards,
|
|
87
|
+
where: (id) => ({ id, live: live.get(id) ?? [], terminal: [...(terminal.get(id) ?? [])] }),
|
|
88
|
+
known: (id) => live.has(id) || terminal.has(id),
|
|
89
|
+
ids: () => [...new Set([...live.keys(), ...terminal.keys()])].sort(),
|
|
90
|
+
vanished: () => vanished,
|
|
91
|
+
};
|
|
92
|
+
}
|