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/verdict-fs.js CHANGED
@@ -1,193 +1,140 @@
1
- import { readFile, unlink } from 'node:fs/promises';
1
+ // The writes that end or collapse a work item (Methodology §4.1, §9): an item's slots into its
2
+ // systems docs and back out, the history line, the graveyard line. The moves themselves live in
3
+ // work-ops.js; these are the deterministic pieces they share.
4
+ import { readFile } from 'node:fs/promises';
2
5
  import { existsSync } from 'node:fs';
3
6
  import path from 'node:path';
4
- import { spawnSync } from 'node:child_process';
5
- import { parse, serialize } from './frontmatter.js';
6
- import { listCards } from './gcf-paths.js';
7
- import { atomicWrite, resolveInside, withFileRollback } from './safe-path.js';
8
- import { writeIndex } from './index-gen.js';
9
- import { isLegal } from './transitions.js';
10
-
11
- // Slot → systems-doc section map (GCF-PRD §8.2). Deterministic merge: lines are
12
- // carried verbatim and deduplicated; the LLM compression of `behavior` happens in the
13
- // card BEFORE the verdict (the skill edits the card), not here.
14
- const MERGE_MAP = [
15
- ['behavior', 'behavior'],
16
- ['contract', 'code'],
17
- ['feel', 'feel'],
18
- ['constraints', 'constraints'],
19
- ];
20
-
21
- function blockLines(body, key) {
22
- const m = String(body).match(new RegExp(`^${key}:\\s*\\n((?:- .*\\n?)*)`, 'm'));
23
-
24
- return m ? m[1].split('\n').filter((l) => l.startsWith('- ')) : [];
25
- }
7
+ import { parseBlocks, itemsOf, readRecords } from 'cnlp-kit';
8
+ import { serialize } from './frontmatter.js';
9
+ import { atomicWrite, resolveInside } from './safe-path.js';
10
+ import { blockLines, upsertBlock, removeLines, openSystemDoc } from './systems-doc.js';
26
11
 
27
- function upsertBlock(body, key, addLines) {
28
- if (!addLines.length) return body;
29
- const existing = new Set(blockLines(body, key));
30
- const fresh = addLines.filter((l) => !existing.has(l));
31
- if (!fresh.length) return body;
32
- const re = new RegExp(`(^${key}:\\s*\\n(?:- .*\\n?)*)`, 'm');
33
- if (re.test(body))
34
- return body.replace(
35
- re,
36
- (blk) => `${blk.endsWith('\n') ? blk : `${blk}\n`}${fresh.join('\n')}\n`,
37
- );
12
+ const ENVELOPE = /^\S+\.\.\S+ /;
13
+ const real = (lines) => lines.filter((l) => !/^- none\b/.test(l));
38
14
 
39
- return `${body.trimEnd()}\n\n${key}:\n${fresh.join('\n')}\n`;
40
- }
15
+ function recordsOf(body, key) {
16
+ const { lines, sections } = parseBlocks(body);
17
+ const at = sections.find((s) => s.key === key && !s.value);
41
18
 
42
- // `git commit --only <paths>` is a partial commit, which git refuses outright while a merge
43
- // or a sequencer operation is in progress. Detect it up front so the failure has a reason.
44
- const IN_PROGRESS = [
45
- 'MERGE_HEAD',
46
- 'CHERRY_PICK_HEAD',
47
- 'REVERT_HEAD',
48
- 'rebase-merge',
49
- 'rebase-apply',
50
- ];
51
- function operationInProgress(projectDir) {
52
- const gitDir = spawnSync('git', ['rev-parse', '--git-dir'], {
53
- cwd: projectDir,
54
- encoding: 'utf8',
55
- });
56
- if (gitDir.status !== 0) return null;
57
- const dir = path.resolve(projectDir, gitDir.stdout.trim());
58
-
59
- return IN_PROGRESS.find((name) => existsSync(path.join(dir, name))) ?? null;
19
+ return at ? readRecords(itemsOf(lines, at.line)).records.map((r) => r.values) : [];
60
20
  }
61
21
 
62
- function gitCommit(projectDir, message, addPaths) {
63
- // Stage and commit exactly the files this verdict touched: `git add -- gcf/` would stage a
64
- // user's unrelated edits under gcf/, and rollback only restores what it snapshotted.
65
- for (const p of addPaths)
66
- spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
67
- const res = spawnSync('git', ['commit', '-m', message, '--only', '--', ...addPaths], {
68
- cwd: projectDir,
69
- encoding: 'utf8',
70
- });
71
- if (res.status !== 0) throw new Error(`verdict commit failed: ${res.stderr || res.stdout}`);
72
-
73
- return spawnSync('git', ['rev-parse', 'HEAD'], {
74
- cwd: projectDir,
75
- encoding: 'utf8',
76
- }).stdout.trim();
22
+ const scalar = (body, key) => parseBlocks(body).sections.find((s) => s.key === key)?.value ?? '';
23
+
24
+ /** A resource rate: an envelope is a range of intent (feel), a comparison is exact (constraint). */
25
+ function rateLines(body, out) {
26
+ for (const [key, word] of [
27
+ ['sources', 'source'],
28
+ ['sinks', 'sink'],
29
+ ]) {
30
+ for (const r of recordsOf(body, key)) {
31
+ out.behavior.push(`- ${word} ${r.name}`);
32
+ if (ENVELOPE.test(`${r.rate} `)) out.feel.push(`- ${r.name} rate ${r.rate}`);
33
+ else out.constraints.push(`- ${r.rate}`);
34
+ }
35
+ }
77
36
  }
78
37
 
79
- /** Find the card and refuse an unknown verdict or a transition the lifecycle forbids. */
80
- async function findVerdictCard(root, cardId, verdict) {
81
- const card = (await listCards(root)).find((c) => c.data.id === cardId);
82
- if (!card) throw new Error(`Card not found: ${cardId}`);
83
- const target = { keep: 'validated', tune: 'tuning', kill: 'killed' }[verdict];
84
- if (!target) throw new Error(`Unknown verdict: ${verdict}`);
85
- if (!isLegal(card.data.status, target)) {
86
- throw new Error(
87
- `Illegal transition ${card.data.status} -> ${target} for ${cardId} ` +
88
- '(verdict needs status=built)',
89
- );
38
+ /**
39
+ * Item → systems-doc lines by the fixed map (GCF-PRD §8.2). Deterministic: a record becomes
40
+ * one line, a bullet stays itself, `- none:` placeholders carry nothing. A line keeps its
41
+ * `[until: …]` and stays in its own section, where the reader who needs it looks. Being a pure
42
+ * function of the body is what lets an unplayed item's lines be taken back out (§5.4).
43
+ */
44
+ export function collapseLines(body) {
45
+ /** @type {Record<string, string[]>} */
46
+ const out = { purpose: [], code: [], behavior: [], feel: [], constraints: [] };
47
+ out.purpose.push(...real(blockLines(body, 'intent')));
48
+ for (const r of recordsOf(body, 'behavior')) {
49
+ out.behavior.push(`- ${r.input} -> ${r.state} -> ${r.feedback}`);
90
50
  }
91
51
 
92
- return card;
52
+ out.behavior.push(...real(blockLines(body, 'refusal')), ...real(blockLines(body, 'boundaries')));
53
+ const persistence = scalar(body, 'persistence');
54
+ if (persistence && !/^none\b/.test(persistence)) out.behavior.push(`- persists: ${persistence}`);
55
+ rateLines(body, out);
56
+ const like = scalar(body, 'feel_like');
57
+ if (like) out.feel.push(`- ${like}`);
58
+ for (const r of recordsOf(body, 'feel')) out.feel.push(`- ${r.name} ${r.envelope}`);
59
+ out.code.push(...real(blockLines(body, 'contract')));
60
+ out.constraints.push(...real(blockLines(body, 'constraints')));
61
+ out.constraints.push(...real(blockLines(body, 'interactions')));
62
+
63
+ return out;
93
64
  }
94
65
 
95
- function planTune({ card, note }) {
96
- return async () => {
97
- const data = { ...card.data, status: 'tuning', tune_count: (card.data.tune_count ?? 0) + 1 };
98
- const body = note ? upsertBlock(card.body, 'feel', [`- retune: ${note}`]) : card.body;
99
- await atomicWrite(card.file, serialize(data, body));
100
- };
101
- }
66
+ /**
67
+ * The systems doc files an item writes. `system:` names files, and the pre-commit gate validates
68
+ * it only after they are written: `../../x` would already have overwritten a file outside gcf/.
69
+ */
70
+ export const systemFiles = (root, systems) =>
71
+ systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
102
72
 
103
- function planKill({ root, card, cardId, note, tags, systems, touched }) {
104
- const gyFile = path.join(root, 'graveyard.md');
105
- touched.push(gyFile);
106
-
107
- return async () => {
108
- const reason = note || 'no reason recorded';
109
- const tagStr = (tags.length ? tags : systems).join(', ');
110
- const line = `- ${cardId} | ${card.data.slug} | ${reason} | tags: ${tagStr}\n`;
111
- const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
112
- await atomicWrite(gyFile, existing + line);
113
- await unlink(card.file);
114
- };
73
+ /** Merge the item's lines into one systems doc; a new doc starts from design/'s purpose. */
74
+ export async function mergeIntoSystem(root, system, item) {
75
+ const doc = await openSystemDoc(root, system);
76
+ let body = doc.body;
77
+ for (const [section, lines] of Object.entries(collapseLines(item.body))) {
78
+ body = upsertBlock(body, section, lines);
79
+ }
80
+
81
+ await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: item.data.id }, body));
115
82
  }
116
83
 
117
- async function mergeIntoSystem(sysFile, system, card, cardId) {
118
- let doc = existsSync(sysFile)
119
- ? parse(await readFile(sysFile, 'utf8'))
120
- : { data: { system }, body: 'purpose:\n- (fill in)\n' };
84
+ /**
85
+ * Take an unplayed item's lines back out of a systems doc (tune, kill or drop of an unplayed
86
+ * item). `keep` are lines other unplayed items still owe the doc. shortcut: a line shared word
87
+ * for word with an already kept item leaves too — that body is gone; upgrade to per-line origin
88
+ * marks if that ever bites.
89
+ */
90
+ export async function unmergeFromSystem(root, system, item, keep = []) {
91
+ const file = path.join(root, 'systems', `${system}.md`);
92
+ if (!existsSync(file)) return;
93
+ const doc = await openSystemDoc(root, system);
121
94
  let body = doc.body;
122
- for (const [slot, section] of MERGE_MAP) {
123
- body = upsertBlock(body, section, blockLines(card.body, slot));
95
+ for (const [section, lines] of Object.entries(collapseLines(item.body))) {
96
+ body = removeLines(
97
+ body,
98
+ section,
99
+ lines.filter((l) => !keep.includes(l)),
100
+ );
124
101
  }
125
102
 
126
- // Lines with a live until: move to `open` (GCF-PRD §8.2.5).
127
- const openLines = blockLines(card.body, 'interactions').filter((l) => l.includes('[until:'));
128
- body = upsertBlock(body, 'open', openLines);
129
- await atomicWrite(sysFile, serialize({ ...doc.data, updated_by: cardId }, body));
103
+ await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: item.data.id }, body));
130
104
  }
131
105
 
132
- function planKeep({ root, card, cardId, systems, touched }) {
133
- // `system:` names files, and the pre-commit gate validates it only after they are written:
134
- // `../../x` would already have overwritten a file outside gcf/.
135
- const sysFiles = systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
136
- touched.push(...sysFiles);
106
+ export const GRAVEYARD_HEAD =
107
+ '# Graveyard — `- <id> | <type> | <reason> | tags: <tags>`, one line per idea or piece of ' +
108
+ 'work killed, dropped or refused\n';
137
109
 
138
- return async () => {
139
- for (const [i, sysFile] of sysFiles.entries()) {
140
- await mergeIntoSystem(sysFile, systems[i], card, cardId);
141
- }
142
-
143
- await unlink(card.file);
144
- };
110
+ /** The graveyard line of an id that ended without a keep: `- <id> | <type> | <reason> | tags:`. */
111
+ export async function buryLine(root, id, { type, reason, tags }) {
112
+ const gyFile = path.join(root, 'graveyard.md');
113
+ const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : GRAVEYARD_HEAD;
114
+ // A hand-edited file may end without a newline: the line would glue onto the last one.
115
+ const eol = existing.match(/\r?\n/)?.[0] ?? '\n';
116
+ const head = existing && !existing.endsWith('\n') ? existing + eol : existing;
117
+ const why = String(reason || 'no reason recorded').replace(/\s*\|\s*/g, '; ');
118
+ const line = `- ${id} | ${type} | ${why} | tags: ${tags.join(', ')}${eol}`;
119
+ await atomicWrite(gyFile, head + line);
145
120
  }
146
121
 
147
- const PLANS = { tune: planTune, kill: planKill, keep: planKeep };
122
+ export const HISTORY_HEAD =
123
+ '# History — `- <id> | done|collapsed|decided | <date>`, one line per item or unit that ' +
124
+ 'ended accepted; the move writes it in the commit that ends the life\n';
148
125
 
149
126
  /**
150
- * The one operation that collapses a card (GCF-PRD §8). Atomic: file changes roll back
151
- * on failure; the verdict commit has the fixed greppable format. `keep` merges into every
152
- * touched systems doc; `kill` writes the graveyard line; `tune` bumps the counter and
153
- * appends the human note to the feel slot.
154
- *
155
- * The commit runs INSIDE the rollback: it fires the pre-commit gate, which can reject the
156
- * merged systems doc, and a card unlinked by a commit that never landed is a lost card.
127
+ * The history line of an id that ended accepted (`done`, `collapsed`, `decided`). It goes in id
128
+ * order, not at the end: branches recording different ids touch different places of the file
129
+ * and merge cleanly, and a revert of the move takes out exactly its line.
157
130
  */
158
- export async function applyVerdict(
159
- projectDir,
160
- cardId,
161
- verdict,
162
- { note = '', tags = [], commit = true } = {},
163
- ) {
164
- const root = path.join(path.resolve(projectDir), 'gcf');
165
- const card = await findVerdictCard(root, cardId, verdict);
166
-
167
- // Before any write: refusing after the files changed would send a failed commit through the
168
- // rollback, and restoring a conflicted index entry marks the conflict resolved.
169
- const busy = commit ? operationInProgress(projectDir) : null;
170
- if (busy)
171
- throw new Error(
172
- `cannot record a verdict while ${busy} is in progress — finish or abort it first`,
173
- );
174
-
175
- const touched = [card.file, path.join(root, 'INDEX.md')];
176
- const systems = card.data.system ?? [];
177
- const mutate = PLANS[verdict]({ root, card, cardId, note, tags, systems, touched });
178
-
179
- const message = `gcf(verdict): ${verdict} ${cardId} ${card.data.slug} → ${systems.join(',')}`;
180
- /** @type {string | null} */
181
- let hash = null;
182
- await withFileRollback(
183
- touched,
184
- async () => {
185
- await mutate();
186
- await writeIndex(root);
187
- if (commit) hash = gitCommit(projectDir, message, touched);
188
- },
189
- { gitCwd: commit ? path.resolve(projectDir) : null },
190
- );
191
-
192
- return { verdict, card: cardId, systems, hash };
131
+ export async function recordLine(root, id, event, date = new Date().toISOString().slice(0, 10)) {
132
+ const file = path.join(root, 'history.md');
133
+ const old = existsSync(file) ? await readFile(file, 'utf8') : '';
134
+ // A file kept with CRLF stays CRLF; an empty one starts from the header.
135
+ const eol = old.includes('\r\n') ? '\r\n' : '\n';
136
+ const lines = (old.trim() ? old : HISTORY_HEAD).replace(/\s+$/, '').split(/\r?\n/);
137
+ const at = lines.findIndex((l) => (l.match(/^- (\S+) \|/)?.[1] ?? '') > id);
138
+ lines.splice(at < 0 ? lines.length : at, 0, `- ${id} | ${event} | ${date}`);
139
+ await atomicWrite(file, `${lines.join(eol)}${eol}`);
193
140
  }