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/verdict-fs.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
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
7
|
import { parseBlocks, itemsOf, readRecords } from 'cnlp-kit';
|
|
5
8
|
import { serialize } from './frontmatter.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { writeIndex } from './index-gen.js';
|
|
9
|
-
import { isLegal } from './transitions.js';
|
|
10
|
-
import { operationInProgress, commitPaths, dirtyPaths } from './git.js';
|
|
11
|
-
import { blockLines, upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
9
|
+
import { atomicWrite, resolveInside } from './safe-path.js';
|
|
10
|
+
import { blockLines, upsertBlock, removeLines, openSystemDoc } from './systems-doc.js';
|
|
12
11
|
|
|
13
12
|
const ENVELOPE = /^\S+\.\.\S+ /;
|
|
14
13
|
const real = (lines) => lines.filter((l) => !/^- none\b/.test(l));
|
|
@@ -37,9 +36,10 @@ function rateLines(body, out) {
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
|
-
*
|
|
39
|
+
* Item → systems-doc lines by the fixed map (GCF-PRD §8.2). Deterministic: a record becomes
|
|
41
40
|
* one line, a bullet stays itself, `- none:` placeholders carry nothing. A line keeps its
|
|
42
|
-
* `[until: …]` and stays in its own section, where the reader who needs it looks.
|
|
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
43
|
*/
|
|
44
44
|
export function collapseLines(body) {
|
|
45
45
|
/** @type {Record<string, string[]>} */
|
|
@@ -63,47 +63,76 @@ export function collapseLines(body) {
|
|
|
63
63
|
return out;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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`));
|
|
72
|
+
|
|
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);
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: item.data.id }, body));
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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);
|
|
94
|
+
let body = doc.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
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: item.data.id }, body));
|
|
89
104
|
}
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
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';
|
|
109
|
+
|
|
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 }) {
|
|
93
112
|
const gyFile = path.join(root, 'graveyard.md');
|
|
94
|
-
const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') :
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
// One record per line: a line break would split the reason from its tags, a pipe its fields.
|
|
118
|
+
const flat = (s) =>
|
|
119
|
+
String(s)
|
|
120
|
+
.replace(/\s*\|\s*/g, '; ')
|
|
121
|
+
.replace(/\s*\r?\n\s*/g, ' ')
|
|
122
|
+
.trim();
|
|
123
|
+
const why = flat(reason || 'no reason recorded');
|
|
124
|
+
const line = `- ${id} | ${type} | ${why} | tags: ${tags.map(flat).join(', ')}${eol}`;
|
|
125
|
+
await atomicWrite(gyFile, head + line);
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
export const HISTORY_HEAD =
|
|
100
|
-
'# History — `- <id> | done|collapsed | <date>`, one line per
|
|
101
|
-
'
|
|
129
|
+
'# History — `- <id> | done|collapsed|decided | <date>`, one line per item or unit that ' +
|
|
130
|
+
'ended accepted; the move writes it in the commit that ends the life\n';
|
|
102
131
|
|
|
103
132
|
/**
|
|
104
|
-
* The history line of
|
|
105
|
-
* not at the end: branches recording different ids touch different places of the file
|
|
106
|
-
* cleanly, and a revert of the move takes out exactly its line.
|
|
133
|
+
* The history line of an id that ended accepted (`done`, `collapsed`, `decided`). It goes in id
|
|
134
|
+
* order, not at the end: branches recording different ids touch different places of the file
|
|
135
|
+
* and merge cleanly, and a revert of the move takes out exactly its line.
|
|
107
136
|
*/
|
|
108
137
|
export async function recordLine(root, id, event, date = new Date().toISOString().slice(0, 10)) {
|
|
109
138
|
const file = path.join(root, 'history.md');
|
|
@@ -115,93 +144,3 @@ export async function recordLine(root, id, event, date = new Date().toISOString(
|
|
|
115
144
|
lines.splice(at < 0 ? lines.length : at, 0, `- ${id} | ${event} | ${date}`);
|
|
116
145
|
await atomicWrite(file, `${lines.join(eol)}${eol}`);
|
|
117
146
|
}
|
|
118
|
-
|
|
119
|
-
function planKill({ root, card, cardId, note, tags, systems, touched }) {
|
|
120
|
-
touched.push(path.join(root, 'graveyard.md'));
|
|
121
|
-
|
|
122
|
-
return async () => {
|
|
123
|
-
await buryLine(root, cardId, note, tags.length ? tags : systems);
|
|
124
|
-
await unlink(card.file);
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Merge the card into one systems doc; a new doc starts from design/'s purpose. */
|
|
129
|
-
async function mergeIntoSystem(root, system, card, cardId) {
|
|
130
|
-
const doc = await openSystemDoc(root, system);
|
|
131
|
-
let body = doc.body;
|
|
132
|
-
for (const [section, lines] of Object.entries(collapseLines(card.body))) {
|
|
133
|
-
body = upsertBlock(body, section, lines);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: cardId }, body));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function planKeep({ root, card, cardId, systems, touched }) {
|
|
140
|
-
// `system:` names files, and the pre-commit gate validates it only after they are written:
|
|
141
|
-
// `../../x` would already have overwritten a file outside gcf/.
|
|
142
|
-
const sysFiles = systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
|
|
143
|
-
touched.push(...sysFiles, path.join(root, 'history.md'));
|
|
144
|
-
|
|
145
|
-
return async () => {
|
|
146
|
-
for (const s of systems) await mergeIntoSystem(root, s, card, cardId);
|
|
147
|
-
await recordLine(root, cardId, 'collapsed');
|
|
148
|
-
await unlink(card.file);
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const PLANS = { tune: planTune, kill: planKill, keep: planKeep };
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* The one operation that collapses a card (GCF-PRD §8). Atomic: file changes roll back
|
|
156
|
-
* on failure; the verdict commit has the fixed greppable format. `keep` merges into every
|
|
157
|
-
* touched systems doc and writes the history line; `kill` writes the graveyard line; `tune`
|
|
158
|
-
* bumps the counter and
|
|
159
|
-
* appends the human note to the `tuning` block.
|
|
160
|
-
*
|
|
161
|
-
* The commit runs INSIDE the rollback: it fires the pre-commit gate, which can reject the
|
|
162
|
-
* merged systems doc, and a card unlinked by a commit that never landed is a lost card.
|
|
163
|
-
*/
|
|
164
|
-
export async function applyVerdict(
|
|
165
|
-
projectDir,
|
|
166
|
-
cardId,
|
|
167
|
-
verdict,
|
|
168
|
-
{ note = '', tags = [], commit = true } = {},
|
|
169
|
-
) {
|
|
170
|
-
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
171
|
-
const card = await findVerdictCard(root, cardId, verdict);
|
|
172
|
-
|
|
173
|
-
// Before any write: refusing after the files changed would send a failed commit through the
|
|
174
|
-
// rollback, and restoring a conflicted index entry marks the conflict resolved.
|
|
175
|
-
const busy = commit ? operationInProgress(projectDir) : null;
|
|
176
|
-
if (busy)
|
|
177
|
-
throw new Error(
|
|
178
|
-
`cannot record a verdict while ${busy} is in progress — finish or abort it first`,
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
const touched = [card.file, path.join(root, 'INDEX.md')];
|
|
182
|
-
const systems = card.data.system ?? [];
|
|
183
|
-
const mutate = PLANS[verdict]({ root, card, cardId, note, tags, systems, touched });
|
|
184
|
-
// The commit takes whole files: a user's own uncommitted edit in a systems doc or the
|
|
185
|
-
// graveyard would ride along unseen. The card is the verdict's input and may be dirty.
|
|
186
|
-
const others = touched.filter((f) => f !== card.file && path.basename(f) !== 'INDEX.md');
|
|
187
|
-
const dirty = commit ? dirtyPaths(projectDir, others) : [];
|
|
188
|
-
if (dirty.length)
|
|
189
|
-
throw new Error(
|
|
190
|
-
`commit or stash these first, the verdict would carry them: ${dirty.join('; ')}`,
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
const message = `gcf(verdict): ${verdict} ${cardId} → ${systems.join(',')}`;
|
|
194
|
-
/** @type {string | null} */
|
|
195
|
-
let hash = null;
|
|
196
|
-
await withFileRollback(
|
|
197
|
-
touched,
|
|
198
|
-
async () => {
|
|
199
|
-
await mutate();
|
|
200
|
-
await writeIndex(root);
|
|
201
|
-
if (commit) hash = commitPaths(projectDir, message, touched);
|
|
202
|
-
},
|
|
203
|
-
{ gitCwd: commit ? path.resolve(projectDir) : null },
|
|
204
|
-
);
|
|
205
|
-
|
|
206
|
-
return { verdict, card: cardId, systems, hash };
|
|
207
|
-
}
|