gcf-aif-ext 0.3.0 → 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 +53 -11
- package/commands/gcf.js +287 -42
- package/extension.json +22 -5
- package/hooks/pre-commit.js +55 -31
- package/injections/commit-status.md +7 -5
- package/injections/plan-context.md +8 -5
- package/injections/review-pillars.md +4 -3
- package/lib/anchors.js +27 -4
- package/lib/audit-core.js +135 -48
- package/lib/bundle.js +7 -4
- package/lib/context.js +24 -23
- package/lib/design-check.js +78 -27
- 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 +179 -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 +124 -97
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +61 -128
- package/lib/work-ops.js +727 -0
- package/package.json +1 -1
- 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 +25 -20
- package/skills/gcf-ingest/SKILL.md +3 -3
- package/skills/gcf-init/SKILL.md +2 -2
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -16
- 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,179 @@
|
|
|
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.
|
|
121
|
+
*/
|
|
122
|
+
export function revertDelta(capabilities, delta) {
|
|
123
|
+
if (!delta || 'none' in delta) return capabilities;
|
|
124
|
+
const added = delta.changes.filter((c) => c.change === 'added').map((c) => c.id);
|
|
125
|
+
|
|
126
|
+
return capabilities.filter((r) => !added.includes(r.id));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The known-issue record of a parked bug: its title or first intent bullet, and its file. */
|
|
130
|
+
export function knownIssueOf(item) {
|
|
131
|
+
const intent = String(item.body ?? '').match(/^intent:\s*\n- (.+)$/m)?.[1];
|
|
132
|
+
const summary = String(item.data.title ?? intent ?? item.data.id).trim();
|
|
133
|
+
|
|
134
|
+
return { id: String(item.data.id), summary, refs: `gcf/work/${item.data.id}.md` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The refs of a record that name no file of the project. */
|
|
138
|
+
const brokenRefs = (projectDir, r) =>
|
|
139
|
+
String(r.refs ?? '')
|
|
140
|
+
.split(',')
|
|
141
|
+
.map((s) => s.trim())
|
|
142
|
+
.filter((ref) => ref && !existsSync(path.join(projectDir, ref.split('#')[0])));
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `live check`: record form, duplicate ids, refs that name no file, a known issue without its
|
|
146
|
+
* parked bug and a parked bug without its known issue.
|
|
147
|
+
*/
|
|
148
|
+
export async function checkLive(projectDir) {
|
|
149
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
150
|
+
const live = await readLive(root);
|
|
151
|
+
const issues = [...live.problems];
|
|
152
|
+
/** @type {Array<[string, LiveRecord[]]>} */
|
|
153
|
+
const kinds = [
|
|
154
|
+
['capability', live.capabilities],
|
|
155
|
+
['known issue', live.knownIssues],
|
|
156
|
+
];
|
|
157
|
+
for (const [name, list] of kinds) {
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
for (const r of list) {
|
|
160
|
+
if (seen.has(r.id)) issues.push(`${name} ${r.id} is listed twice`);
|
|
161
|
+
seen.add(r.id);
|
|
162
|
+
const bad = summaryProblem(r.summary);
|
|
163
|
+
if (bad) issues.push(`${name} ${r.id}: ${bad}`);
|
|
164
|
+
issues.push(
|
|
165
|
+
...brokenRefs(projectDir, r).map((ref) => `${name} ${r.id}: refs ${ref} names no file`),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const parked = (await listItems(root)).filter((i) => i.data.status === 'parked');
|
|
171
|
+
const known = new Set(live.knownIssues.map((r) => r.id));
|
|
172
|
+
for (const p of parked)
|
|
173
|
+
if (!known.has(p.data.id)) issues.push(`parked bug ${p.data.id} has no known-issue record`);
|
|
174
|
+
for (const id of known)
|
|
175
|
+
if (!parked.some((p) => p.data.id === id))
|
|
176
|
+
issues.push(`known issue ${id} has no parked bug in gcf/work/`);
|
|
177
|
+
|
|
178
|
+
return { ...live, issues };
|
|
179
|
+
}
|
|
@@ -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
|
}
|