gcf-aif-ext 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +63 -11
  2. package/commands/gcf.js +321 -43
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +60 -31
  5. package/injections/commit-status.md +7 -5
  6. package/injections/plan-context.md +8 -5
  7. package/injections/review-pillars.md +4 -3
  8. package/lib/agents-block.js +66 -0
  9. package/lib/anchors.js +27 -4
  10. package/lib/audit-core.js +135 -48
  11. package/lib/bundle.js +7 -4
  12. package/lib/context.js +33 -25
  13. package/lib/design-check.js +149 -40
  14. package/lib/design.js +2 -2
  15. package/lib/gcf-paths.js +13 -2
  16. package/lib/id.js +2 -2
  17. package/lib/inbox.js +54 -0
  18. package/lib/index-gen.js +23 -8
  19. package/lib/ledger.js +19 -17
  20. package/lib/linter.js +2 -1
  21. package/lib/live.js +191 -0
  22. package/lib/pillars.js +113 -0
  23. package/lib/plan-check.js +31 -0
  24. package/lib/playtest-list.js +8 -4
  25. package/lib/systems-doc.js +16 -1
  26. package/lib/transitions.js +36 -21
  27. package/lib/unit-ops.js +125 -97
  28. package/lib/validate-item.js +244 -0
  29. package/lib/verdict-fs.js +67 -128
  30. package/lib/work-ops.js +759 -0
  31. package/package.json +4 -3
  32. package/profiles/pillars.md +1 -1
  33. package/profiles/system.md +1 -1
  34. package/profiles/unit-constraint.md +10 -0
  35. package/profiles/unit-entity.md +10 -0
  36. package/profiles/unit-mechanic.md +6 -1
  37. package/profiles/unit-resource.md +6 -1
  38. package/profiles/unit-rule.md +10 -0
  39. package/schemas/item.schema.json +105 -0
  40. package/skills/gcf-adopt/SKILL.md +2 -2
  41. package/skills/gcf-audit/SKILL.md +3 -3
  42. package/skills/gcf-card/SKILL.md +27 -20
  43. package/skills/gcf-ingest/SKILL.md +3 -3
  44. package/skills/gcf-init/SKILL.md +8 -5
  45. package/skills/gcf-triage/SKILL.md +35 -0
  46. package/skills/gcf-verdict/SKILL.md +19 -16
  47. package/templates/agents-block.md +28 -0
  48. package/templates/config.yml +7 -0
  49. package/templates/{card.md → item.md} +10 -3
  50. package/lib/validate-card.js +0 -93
  51. package/schemas/card.schema.json +0 -46
package/lib/live.js ADDED
@@ -0,0 +1,191 @@
1
+ // LIVE.md (Methodology §8): what the game is now, one short record per capability and per known
2
+ // issue, no history. Capabilities change only through the live: delta of an item, applied in the
3
+ // commit of the move that ends it; known issues are the parked bugs, written by `decide known`
4
+ // and taken out by any end of that bug.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { atomicWrite } from './safe-path.js';
9
+ import { listItems } from './gcf-paths.js';
10
+ import { summaryProblem } from './validate-item.js';
11
+
12
+ const SECTIONS = { capabilities: 'capabilities', 'known-issues': 'knownIssues' };
13
+ const NONE = {
14
+ capabilities: '- none: no capability recorded yet',
15
+ 'known-issues': '- none: no known issue',
16
+ };
17
+
18
+ /** @typedef {{id: string, summary: string, refs?: string}} LiveRecord */
19
+
20
+ /** Parse LIVE.md text: `{capabilities, knownIssues, problems}`. */
21
+ export function parseLive(text) {
22
+ /** @type {{capabilities: LiveRecord[], knownIssues: LiveRecord[], problems: string[]}} */
23
+ const out = { capabilities: [], knownIssues: [], problems: [] };
24
+ const body = String(text)
25
+ .replace(/\r\n/g, '\n')
26
+ .replace(/^---\n[\s\S]*?\n---\n?/, '');
27
+ /** @type {'capabilities' | 'knownIssues' | null} */
28
+ let section = null;
29
+ /** @type {LiveRecord | null} */
30
+ let rec = null;
31
+ body.split('\n').forEach((line, i) => {
32
+ const head = line.match(/^([a-z-]+):\s*$/);
33
+ if (head) {
34
+ section = SECTIONS[head[1]] ?? null;
35
+ if (!section) out.problems.push(`line ${i + 1}: unknown section ${head[1]}:`);
36
+ rec = null;
37
+
38
+ return;
39
+ }
40
+
41
+ const start = line.match(/^- id:\s*(\S+)\s*$/);
42
+ if (start && section) {
43
+ rec = { id: start[1], summary: '' };
44
+ out[section].push(rec);
45
+
46
+ return;
47
+ }
48
+
49
+ const field = line.match(/^ {2}(summary|refs):\s*(.+?)\s*$/);
50
+ if (field && rec) {
51
+ rec[field[1]] = field[2];
52
+
53
+ return;
54
+ }
55
+
56
+ if (line.trim() && !/^- none\b/.test(line) && !line.startsWith('#'))
57
+ out.problems.push(`line ${i + 1}: not a record line: ${line.trim()}`);
58
+ });
59
+
60
+ return out;
61
+ }
62
+
63
+ const recordText = (r) =>
64
+ [`- id: ${r.id}`, ` summary: ${r.summary}`, ...(r.refs ? [` refs: ${r.refs}`] : [])].join('\n');
65
+
66
+ const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
67
+
68
+ /** LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly. */
69
+ export function serializeLive({ capabilities, knownIssues }) {
70
+ const block = (key, list) =>
71
+ `${key}:\n${list.length ? [...list].sort(byId).map(recordText).join('\n') : NONE[key]}\n`;
72
+
73
+ return [
74
+ '---\ntype: live\n---\n',
75
+ block('capabilities', capabilities),
76
+ block('known-issues', knownIssues),
77
+ ].join('\n');
78
+ }
79
+
80
+ export const liveFile = (root) => path.join(root, 'LIVE.md');
81
+
82
+ export async function readLive(root) {
83
+ const file = liveFile(root);
84
+
85
+ return parseLive(existsSync(file) ? await readFile(file, 'utf8') : '');
86
+ }
87
+
88
+ export async function writeLive(root, live) {
89
+ await atomicWrite(liveFile(root), serializeLive(live));
90
+ }
91
+
92
+ /**
93
+ * The capabilities after `delta` (an item's `live:`), or a thrown reason. `none` changes nothing;
94
+ * `added` needs a new id, `modified` and `removed` an existing one.
95
+ */
96
+ export function applyDelta(capabilities, delta, itemId) {
97
+ if (!delta) throw new Error(`${itemId} has no live: delta — say what it changes, or none: <why>`);
98
+ if ('none' in delta) return capabilities;
99
+ let next = [...capabilities];
100
+ for (const c of delta.changes) {
101
+ const at = next.findIndex((r) => r.id === c.id);
102
+ if (c.change === 'added' && at >= 0)
103
+ throw new Error(`${itemId}: capability ${c.id} already exists — use change: modified`);
104
+ if (c.change !== 'added' && at < 0)
105
+ throw new Error(`${itemId}: capability ${c.id} is not in LIVE.md — nothing to ${c.change}`);
106
+ if (c.change === 'removed') next = next.filter((r) => r.id !== c.id);
107
+ else {
108
+ const rec = { id: c.id, summary: c.summary, ...(c.refs ? { refs: c.refs } : {}) };
109
+ if (at >= 0) next[at] = { ...next[at], ...rec };
110
+ else next.push(rec);
111
+ }
112
+ }
113
+
114
+ return next;
115
+ }
116
+
117
+ /**
118
+ * The capabilities without what `delta` added: an unplayed mechanic killed, dropped or sent back
119
+ * to tuning leaves the game, so its record leaves LIVE.md with it. `modified` and `removed`
120
+ * cannot be undone from the delta alone and stay as they are. A record another item modified
121
+ * since is refused: taking it out would erase that item's accepted change.
122
+ */
123
+ export function revertDelta(capabilities, delta, itemId) {
124
+ if (!delta || 'none' in delta) return capabilities;
125
+ const added = delta.changes.filter((c) => c.change === 'added');
126
+ for (const c of added) {
127
+ const now = capabilities.find((r) => r.id === c.id);
128
+ // LIVE.md keeps fields trimmed: compare in that form, or a trailing space reads as an edit.
129
+ const same = (a, b) => String(a ?? '').trim() === String(b ?? '').trim();
130
+ if (now && !(same(now.summary, c.summary) && same(now.refs, c.refs)))
131
+ throw new Error(
132
+ `${itemId}: capability ${c.id} was modified after ${itemId} added it — taking it out ` +
133
+ 'would erase that change: remove the record from LIVE.md by hand in its own commit ' +
134
+ 'and repeat, or keep the item',
135
+ );
136
+ }
137
+
138
+ return capabilities.filter((r) => !added.some((c) => c.id === r.id));
139
+ }
140
+
141
+ /** The known-issue record of a parked bug: its title or first intent bullet, and its file. */
142
+ export function knownIssueOf(item) {
143
+ const intent = String(item.body ?? '').match(/^intent:\s*\n- (.+)$/m)?.[1];
144
+ const summary = String(item.data.title ?? intent ?? item.data.id).trim();
145
+
146
+ return { id: String(item.data.id), summary, refs: `gcf/work/${item.data.id}.md` };
147
+ }
148
+
149
+ /** The refs of a record that name no file of the project. */
150
+ const brokenRefs = (projectDir, r) =>
151
+ String(r.refs ?? '')
152
+ .split(',')
153
+ .map((s) => s.trim())
154
+ .filter((ref) => ref && !existsSync(path.join(projectDir, ref.split('#')[0])));
155
+
156
+ /**
157
+ * `live check`: record form, duplicate ids, refs that name no file, a known issue without its
158
+ * parked bug and a parked bug without its known issue.
159
+ */
160
+ export async function checkLive(projectDir) {
161
+ const root = path.join(path.resolve(projectDir), 'gcf');
162
+ const live = await readLive(root);
163
+ const issues = [...live.problems];
164
+ /** @type {Array<[string, LiveRecord[]]>} */
165
+ const kinds = [
166
+ ['capability', live.capabilities],
167
+ ['known issue', live.knownIssues],
168
+ ];
169
+ for (const [name, list] of kinds) {
170
+ const seen = new Set();
171
+ for (const r of list) {
172
+ if (seen.has(r.id)) issues.push(`${name} ${r.id} is listed twice`);
173
+ seen.add(r.id);
174
+ const bad = summaryProblem(r.summary);
175
+ if (bad) issues.push(`${name} ${r.id}: ${bad}`);
176
+ issues.push(
177
+ ...brokenRefs(projectDir, r).map((ref) => `${name} ${r.id}: refs ${ref} names no file`),
178
+ );
179
+ }
180
+ }
181
+
182
+ const parked = (await listItems(root)).filter((i) => i.data.status === 'parked');
183
+ const known = new Set(live.knownIssues.map((r) => r.id));
184
+ for (const p of parked)
185
+ if (!known.has(p.data.id)) issues.push(`parked bug ${p.data.id} has no known-issue record`);
186
+ for (const id of known)
187
+ if (!parked.some((p) => p.data.id === id))
188
+ issues.push(`known issue ${id} has no parked bug in gcf/work/`);
189
+
190
+ return { ...live, issues };
191
+ }
package/lib/pillars.js ADDED
@@ -0,0 +1,113 @@
1
+ // Revising PILLARS.md after units and items cite it: a rename follows the id everywhere it is
2
+ // cited, a retire takes it out and sends every unit left without a pillar back to the human.
3
+ // Pillars are human authorship (gcf-init): these moves only carry out the human's decision.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { serialize } from './frontmatter.js';
8
+ import { SLUG_RE, listOf, parseDesign } from './design.js';
9
+ import { recordsIn } from './design-check.js';
10
+ import { buildLedger } from './ledger.js';
11
+ import { atomicWrite } from './safe-path.js';
12
+ import { commitMove } from './unit-ops.js';
13
+
14
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+
16
+ /** The `id: <id>` line of a PILLARS.md record; `\r` and the line break stay outside it. */
17
+ const idLine = (id) =>
18
+ new RegExp(`^([ \\t]*(?:- )?id:[ \\t]*)${escapeRe(id)}[ \\t]*(?=\\r?$)`, 'm');
19
+
20
+ /**
21
+ * The design file with `edit` applied to each unit's header `pillars:` line — the header only:
22
+ * a `pillars:` line in a body or the preamble is not a citation. TBD stays TBD; a list left
23
+ * empty reads TBD, so the unit is not ready until the human gives it a pillar again.
24
+ */
25
+ function rewriteHeaders(text, edit) {
26
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
27
+ const lines = text.split(/\r?\n/);
28
+ for (const u of parseDesign(text, '').units) {
29
+ const at = u.headerLines.pillars;
30
+ const list = listOf(u.header.pillars ?? '');
31
+ if (at === undefined || list === 'TBD') continue;
32
+ const next = edit(list);
33
+ if (next.join() !== list.join())
34
+ lines[at - 1] = `pillars: ${next.length ? `[${next.join(', ')}]` : 'TBD'}`;
35
+ }
36
+
37
+ return lines.join(eol);
38
+ }
39
+
40
+ /**
41
+ * Apply `edit` to every citing list — unit headers in design/, item frontmatter in work/ — and
42
+ * `editPillars` to PILLARS.md, in one commit `gcf(pillar): <tail>`. Returns the items left
43
+ * citing no pillar: an item in work has no TBD to fall back to, so the human hears about it.
44
+ */
45
+ async function revise(projectDir, id, { edit, editPillars, tail, commit }) {
46
+ const root = path.join(path.resolve(projectDir), 'gcf');
47
+ const pillarsFile = path.join(root, 'PILLARS.md');
48
+ const text = existsSync(pillarsFile) ? await readFile(pillarsFile, 'utf8') : '';
49
+ const ids = recordsIn(text, 'pillars').map((p) => p.id);
50
+ if (!ids.includes(id)) throw new Error(`${id} is not a pillar of gcf/PILLARS.md`);
51
+ const ledger = await buildLedger(projectDir);
52
+ const designFiles = ledger.design.map((d) => path.join(root, d.file));
53
+ const items = ledger.items.filter((c) => (c.data.pillars ?? []).includes(id));
54
+ const files = [pillarsFile, ...designFiles, ...items.map((c) => c.file)];
55
+ const mutate = async () => {
56
+ await atomicWrite(pillarsFile, editPillars(text));
57
+ for (const f of designFiles) {
58
+ const old = await readFile(f, 'utf8');
59
+ const next = rewriteHeaders(old, edit);
60
+ if (next !== old) await atomicWrite(f, next);
61
+ }
62
+
63
+ for (const c of items) {
64
+ await atomicWrite(c.file, serialize({ ...c.data, pillars: edit(c.data.pillars) }, c.body));
65
+ }
66
+ };
67
+
68
+ return {
69
+ id,
70
+ hash: await commitMove(projectDir, `gcf(pillar): ${tail}`, { files, mutate, commit }),
71
+ bare: items.filter((c) => !edit(c.data.pillars).length).map((c) => String(c.data.id)),
72
+ };
73
+ }
74
+
75
+ /** `pillar rename <from> <to>`: the id changes in PILLARS.md and every unit and item citing it. */
76
+ export async function renamePillar(projectDir, from, to, { commit = true } = {}) {
77
+ if (!SLUG_RE.test(to)) throw new Error(`${to}: a pillar id is a lowercase hyphen slug`);
78
+ const text = await readFile(path.join(projectDir, 'gcf', 'PILLARS.md'), 'utf8').catch(() => '');
79
+ if (recordsIn(text, 'pillars').some((p) => p.id === to))
80
+ throw new Error(`${to} is a pillar already`);
81
+
82
+ return revise(projectDir, from, {
83
+ edit: (list) => list.map((p) => (p === from ? to : p)),
84
+ editPillars: (t) => t.replace(idLine(from), (_, head) => `${head}${to}`),
85
+ tail: `rename ${from} → ${to}`,
86
+ commit,
87
+ });
88
+ }
89
+
90
+ /**
91
+ * `pillar retire <id> --reason`: the record leaves PILLARS.md and the id leaves every list. A
92
+ * unit left citing no pillar reads `pillars: TBD` again — it is not ready until the human gives
93
+ * it one, or decides it is out of scope. The reason is kept in the commit.
94
+ */
95
+ export async function retirePillar(projectDir, id, { reason, commit = true }) {
96
+ const why = String(reason ?? '')
97
+ .replace(/\s+/g, ' ')
98
+ .trim();
99
+ if (!why) throw new Error('a retire needs a reason: the commit keeps it');
100
+ const own = idLine(id);
101
+
102
+ return revise(projectDir, id, {
103
+ edit: (list) => list.filter((p) => p !== id),
104
+ // A record starts at a `- ` line; the one holding `id: <id>` goes, blank lines and all.
105
+ editPillars: (t) =>
106
+ t
107
+ .split(/\n(?=- )/)
108
+ .filter((rec) => !own.test(rec))
109
+ .join('\n'),
110
+ tail: `retire ${id}: ${why}`,
111
+ commit,
112
+ });
113
+ }
@@ -0,0 +1,31 @@
1
+ // `gcf plan check <id>` (Methodology §5.2.1): the deterministic check after each improve pass —
2
+ // the plan exists, it holds task checkboxes, and the item's readset fits the context budget.
3
+ // Whether the plan is any good is the improve passes' job; this only says it is usable.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { buildLedger } from './ledger.js';
8
+ import { buildContext } from './context.js';
9
+
10
+ export async function planCheck(projectDir, id) {
11
+ const item = (await buildLedger(projectDir)).item(id);
12
+ if (!item) throw new Error(`${id} is not a work item of gcf/work/`);
13
+ const plan = item.data.plan ?? `.ai-factory/plans/${id}.md`;
14
+ const file = path.resolve(projectDir, plan);
15
+ const problems = [];
16
+ let tasks = 0;
17
+ let done = 0;
18
+ if (!existsSync(file)) problems.push(`no plan at ${plan}`);
19
+ else {
20
+ const text = await readFile(file, 'utf8');
21
+ tasks = (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length;
22
+ done = (text.match(/^\s*- \[[xX]\] /gm) ?? []).length;
23
+ if (!tasks) problems.push(`${plan} has no task checkboxes (- [ ] …)`);
24
+ }
25
+
26
+ const ctx = await buildContext(projectDir, id);
27
+ if (ctx.over_budget)
28
+ problems.push(`readset ~${ctx.total_tokens_est} tokens is over the budget ${ctx.budget}`);
29
+
30
+ return { id, plan, tasks, done, problems, ok: problems.length === 0 };
31
+ }
@@ -1,12 +1,16 @@
1
- import { listCards, scalarOf } from './gcf-paths.js';
1
+ import { listItems, scalarOf } from './gcf-paths.js';
2
2
 
3
- /** Playtest list (GCF-PRD §10): every built card with its miss-criterion question. */
3
+ /**
4
+ * Playtest list (Methodology §5.3): every item waiting for its verdict, and every unplayed one,
5
+ * with the question the play must answer.
6
+ */
4
7
  export async function playtestList(root) {
5
- return (await listCards(root))
6
- .filter((c) => c.data.status === 'built')
8
+ return (await listItems(root))
9
+ .filter((c) => ['awaiting-verdict', 'unplayed'].includes(c.data.status))
7
10
  .map((c) => ({
8
11
  id: c.data.id,
9
12
  title: c.data.title ?? null,
13
+ status: c.data.status,
10
14
  system: c.data.system ?? [],
11
15
  question: scalarOf(c.body, 'miss_criterion') || '(miss_criterion is empty)',
12
16
  }));
@@ -7,7 +7,7 @@ import { parseBlocks, itemsOf } from 'cnlp-kit';
7
7
  import { parse } from './frontmatter.js';
8
8
  import { scalarIn } from './design.js';
9
9
 
10
- const NONE_LINE = '- none: no card of this subsystem has collapsed yet';
10
+ const NONE_LINE = '- none: no item of this subsystem has collapsed yet';
11
11
 
12
12
  export function blockLines(body, key) {
13
13
  const { lines, sections } = parseBlocks(body);
@@ -36,6 +36,21 @@ export function upsertBlock(rawBody, key, addLines) {
36
36
  return `${body.trimEnd()}\n\n${block}`;
37
37
  }
38
38
 
39
+ /**
40
+ * The `key:` bullet block without `dropLines` — the inverse of upsertBlock. A block left empty
41
+ * gets the `- none:` placeholder back, as a new systems doc starts.
42
+ */
43
+ export function removeLines(rawBody, key, dropLines) {
44
+ const body = lf(rawBody);
45
+ const existing = blockLines(body, key);
46
+ const kept = existing.filter((l) => !dropLines.includes(l));
47
+ if (kept.length === existing.length) return body;
48
+ const block = `${key}:\n${(kept.length ? kept : [NONE_LINE]).join('\n')}\n`;
49
+ const re = new RegExp(`^${key}:[^\\n]*\\n(?:(?!\\S+:).*\\n?)*`, 'm');
50
+
51
+ return body.replace(re, () => `${block}\n`).replace(/\n{3,}/g, '\n\n');
52
+ }
53
+
39
54
  const lf = (text) => String(text).replace(/\r\n/g, '\n');
40
55
 
41
56
  /** Purpose lines a new systems doc starts with: design/<sys>.md's `purpose:`, or a marker. */
@@ -1,28 +1,43 @@
1
- // GCF-PRD §7.1. `none` = materialization (no card file yet).
2
- // `validated` and `killed` are terminal and NEVER stored: the verdict that produces them
3
- // also deletes the card file, so a card file carrying one is a П1 violation (pre-commit check).
4
- const STORED = new Set(['proposed', 'built', 'tuning']);
5
- export const TERMINAL = new Set(['validated', 'killed']);
1
+ // Methodology §4. A work item FILE carries one of the stored statuses; `running` and `verify` are
2
+ // read from the run (worktree + session), never written. A terminal move deletes the file and
3
+ // writes a history or graveyard line in the same commit, so no terminal status is ever stored.
4
+ export const STORED = [
5
+ 'ready',
6
+ 'awaiting-verdict',
7
+ 'awaiting-decision',
8
+ 'blocked',
9
+ 'parked',
10
+ 'unplayed',
11
+ ];
6
12
 
7
- const LEGAL = new Set([
8
- 'none>proposed',
9
- 'proposed>built', // agent flips frontmatter itself on implementation commit (GCF-PRD §5.1)
10
- 'built>tuning', // /gcf-verdict tune
11
- 'tuning>built', // agent re-built after tuning
12
- 'built>validated', // /gcf-verdict keep — file is deleted in the same atomic commit
13
- 'built>killed', // /gcf-verdict kill — file is deleted in the same atomic commit
14
- ]);
13
+ // The status each command takes an item from (§4.1). `drop` leaves from any stored status.
14
+ const FROM = {
15
+ submit: ['ready'],
16
+ close: ['ready'],
17
+ 'verdict:keep': ['awaiting-verdict', 'unplayed'],
18
+ 'verdict:tune': ['awaiting-verdict', 'unplayed'],
19
+ 'verdict:kill': ['awaiting-verdict', 'unplayed'],
20
+ 'decide:go': ['awaiting-decision'],
21
+ 'decide:no': ['awaiting-decision', 'ready'],
22
+ 'decide:wontfix': ['ready', 'blocked', 'parked'],
23
+ 'decide:known': ['ready', 'blocked'],
24
+ 'decide:close': ['awaiting-decision'],
25
+ 'decide:continue': ['awaiting-decision'],
26
+ 'decide:kill': ['awaiting-decision', 'ready'],
27
+ block: ['ready'],
28
+ unblock: ['blocked', 'parked'],
29
+ drop: STORED,
30
+ };
15
31
 
16
- export function isLegal(from, to) {
17
- return LEGAL.has(`${from}>${to}`);
32
+ /** True when `move` (`close`, `verdict:keep`, …) may start from `status`. */
33
+ export function allowedFrom(move, status) {
34
+ return (FROM[move] ?? []).includes(status);
18
35
  }
19
36
 
20
- /** Statuses reachable from `from`, for error messages. */
21
- export function legalTargets(from) {
22
- return [...LEGAL].filter((t) => t.startsWith(`${from}>`)).map((t) => t.split('>')[1]);
23
- }
37
+ /** Statuses `move` starts from, for error messages. */
38
+ export const fromOf = (move) => FROM[move] ?? [];
24
39
 
25
- /** A status a card FILE may carry. Terminal statuses exist only in the transition itself. */
40
+ /** A status an item FILE may carry. */
26
41
  export function isStorable(status) {
27
- return STORED.has(status);
42
+ return STORED.includes(status);
28
43
  }