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.
- package/README.md +54 -10
- package/commands/gcf.js +452 -30
- package/extension.json +22 -5
- package/hooks/pre-commit.js +69 -41
- package/injections/commit-status.md +7 -4
- package/injections/plan-context.md +8 -4
- package/injections/review-pillars.md +4 -3
- package/lib/anchors.js +60 -35
- package/lib/audit-core.js +172 -55
- package/lib/bundle.js +278 -0
- package/lib/context.js +25 -23
- package/lib/design-check.js +631 -0
- package/lib/design.js +179 -0
- package/lib/gcf-paths.js +13 -2
- package/lib/git.js +63 -0
- package/lib/id.js +7 -15
- package/lib/inbox.js +54 -0
- package/lib/index-gen.js +62 -13
- package/lib/ledger.js +94 -0
- package/lib/linter.js +17 -5
- package/lib/live.js +179 -0
- package/lib/migrate.js +345 -0
- package/lib/plan-check.js +31 -0
- package/lib/playtest-list.js +9 -5
- package/lib/systems-doc.js +85 -0
- package/lib/transitions.js +36 -21
- package/lib/unit-ops.js +297 -0
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +112 -165
- package/lib/work-ops.js +727 -0
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +3 -2
- package/profiles/system.md +1 -1
- package/profiles/unit-constraint.md +52 -0
- package/profiles/unit-entity.md +48 -0
- package/profiles/unit-mechanic.md +111 -0
- package/profiles/unit-resource.md +91 -0
- package/profiles/unit-rule.md +52 -0
- package/schemas/item.schema.json +105 -0
- package/skills/gcf-adopt/SKILL.md +3 -3
- package/skills/gcf-audit/SKILL.md +7 -6
- package/skills/gcf-card/SKILL.md +31 -17
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +39 -23
- package/skills/gcf-init/SKILL.md +5 -5
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -15
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -5
- package/templates/cnlp.config.json +0 -1
- package/templates/config.yml +7 -0
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/item.md +58 -0
- package/templates/system.md +1 -1
- package/lib/validate-card.js +0 -82
- package/profiles/card.md +0 -65
- package/schemas/card.schema.json +0 -30
- package/templates/card.md +0 -34
package/lib/verdict-fs.js
CHANGED
|
@@ -1,193 +1,140 @@
|
|
|
1
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 [
|
|
123
|
-
body =
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
164
|
-
const
|
|
165
|
-
|
|
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
|
}
|