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.
- package/README.md +63 -11
- package/commands/gcf.js +321 -43
- package/extension.json +22 -5
- package/hooks/pre-commit.js +60 -31
- package/injections/commit-status.md +7 -5
- package/injections/plan-context.md +8 -5
- package/injections/review-pillars.md +4 -3
- package/lib/agents-block.js +66 -0
- package/lib/anchors.js +27 -4
- package/lib/audit-core.js +135 -48
- package/lib/bundle.js +7 -4
- package/lib/context.js +33 -25
- package/lib/design-check.js +149 -40
- package/lib/design.js +2 -2
- package/lib/gcf-paths.js +13 -2
- package/lib/id.js +2 -2
- package/lib/inbox.js +54 -0
- package/lib/index-gen.js +23 -8
- package/lib/ledger.js +19 -17
- package/lib/linter.js +2 -1
- package/lib/live.js +191 -0
- package/lib/pillars.js +113 -0
- package/lib/plan-check.js +31 -0
- package/lib/playtest-list.js +8 -4
- package/lib/systems-doc.js +16 -1
- package/lib/transitions.js +36 -21
- package/lib/unit-ops.js +125 -97
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +67 -128
- package/lib/work-ops.js +759 -0
- package/package.json +4 -3
- package/profiles/pillars.md +1 -1
- package/profiles/system.md +1 -1
- package/profiles/unit-constraint.md +10 -0
- package/profiles/unit-entity.md +10 -0
- package/profiles/unit-mechanic.md +6 -1
- package/profiles/unit-resource.md +6 -1
- package/profiles/unit-rule.md +10 -0
- package/schemas/item.schema.json +105 -0
- package/skills/gcf-adopt/SKILL.md +2 -2
- package/skills/gcf-audit/SKILL.md +3 -3
- package/skills/gcf-card/SKILL.md +27 -20
- package/skills/gcf-ingest/SKILL.md +3 -3
- package/skills/gcf-init/SKILL.md +8 -5
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -16
- package/templates/agents-block.md +28 -0
- package/templates/config.yml +7 -0
- package/templates/{card.md → item.md} +10 -3
- package/lib/validate-card.js +0 -93
- 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
|
+
}
|
package/lib/playtest-list.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { listItems, scalarOf } from './gcf-paths.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
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
|
|
6
|
-
.filter((c) => c.data.status
|
|
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
|
}));
|
package/lib/systems-doc.js
CHANGED
|
@@ -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
|
|
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. */
|
package/lib/transitions.js
CHANGED
|
@@ -1,28 +1,43 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
const STORED =
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'
|
|
13
|
-
'
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
21
|
-
export
|
|
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
|
|
40
|
+
/** A status an item FILE may carry. */
|
|
26
41
|
export function isStorable(status) {
|
|
27
|
-
return STORED.
|
|
42
|
+
return STORED.includes(status);
|
|
28
43
|
}
|