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.
Files changed (48) hide show
  1. package/README.md +53 -11
  2. package/commands/gcf.js +287 -42
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +55 -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/anchors.js +27 -4
  9. package/lib/audit-core.js +135 -48
  10. package/lib/bundle.js +7 -4
  11. package/lib/context.js +24 -23
  12. package/lib/design-check.js +78 -27
  13. package/lib/design.js +2 -2
  14. package/lib/gcf-paths.js +13 -2
  15. package/lib/id.js +2 -2
  16. package/lib/inbox.js +54 -0
  17. package/lib/index-gen.js +23 -8
  18. package/lib/ledger.js +19 -17
  19. package/lib/linter.js +2 -1
  20. package/lib/live.js +179 -0
  21. package/lib/plan-check.js +31 -0
  22. package/lib/playtest-list.js +8 -4
  23. package/lib/systems-doc.js +16 -1
  24. package/lib/transitions.js +36 -21
  25. package/lib/unit-ops.js +124 -97
  26. package/lib/validate-item.js +244 -0
  27. package/lib/verdict-fs.js +61 -128
  28. package/lib/work-ops.js +727 -0
  29. package/package.json +1 -1
  30. package/profiles/pillars.md +1 -1
  31. package/profiles/system.md +1 -1
  32. package/profiles/unit-constraint.md +10 -0
  33. package/profiles/unit-entity.md +10 -0
  34. package/profiles/unit-mechanic.md +6 -1
  35. package/profiles/unit-resource.md +6 -1
  36. package/profiles/unit-rule.md +10 -0
  37. package/schemas/item.schema.json +105 -0
  38. package/skills/gcf-adopt/SKILL.md +2 -2
  39. package/skills/gcf-audit/SKILL.md +3 -3
  40. package/skills/gcf-card/SKILL.md +25 -20
  41. package/skills/gcf-ingest/SKILL.md +3 -3
  42. package/skills/gcf-init/SKILL.md +2 -2
  43. package/skills/gcf-triage/SKILL.md +35 -0
  44. package/skills/gcf-verdict/SKILL.md +19 -16
  45. package/templates/config.yml +7 -0
  46. package/templates/{card.md → item.md} +10 -3
  47. package/lib/validate-card.js +0 -93
  48. package/schemas/card.schema.json +0 -46
package/lib/verdict-fs.js CHANGED
@@ -1,14 +1,13 @@
1
- import { readFile, unlink } from 'node:fs/promises';
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 { listCards } from './gcf-paths.js';
7
- import { atomicWrite, resolveInside, withFileRollback } from './safe-path.js';
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
- * Card → systems-doc lines by the fixed map (GCF-PRD §8.2). Deterministic: a record becomes
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,70 @@ export function collapseLines(body) {
63
63
  return out;
64
64
  }
65
65
 
66
- /** Find the card and refuse an unknown verdict or a transition the lifecycle forbids. */
67
- async function findVerdictCard(root, cardId, verdict) {
68
- const card = (await listCards(root)).find((c) => c.data.id === cardId);
69
- if (!card) throw new Error(`Card not found: ${cardId}`);
70
- const target = { keep: 'validated', tune: 'tuning', kill: 'killed' }[verdict];
71
- if (!target) throw new Error(`Unknown verdict: ${verdict}`);
72
- if (!isLegal(card.data.status, target)) {
73
- throw new Error(
74
- `Illegal transition ${card.data.status} -> ${target} for ${cardId} ` +
75
- '(verdict needs status=built)',
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
- return card;
81
+ await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: item.data.id }, body));
80
82
  }
81
83
 
82
- function planTune({ card, note }) {
83
- return async () => {
84
- const data = { ...card.data, status: 'tuning', tune_count: (card.data.tune_count ?? 0) + 1 };
85
- // The remark goes to `tuning:`, the block the agent reads before the rebuild.
86
- const body = note ? upsertBlock(card.body, 'tuning', [`- ${note}`]) : card.body;
87
- await atomicWrite(card.file, serialize(data, body));
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
- /** The graveyard line of a killed or dropped unit: `- <id> | <reason> | tags: <tags>`. */
92
- export async function buryLine(root, id, reason, tags) {
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
- const line = `- ${id} | ${reason || 'no reason recorded'} | tags: ${tags.join(', ')}\n`;
96
- await atomicWrite(gyFile, existing + line);
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);
97
120
  }
98
121
 
99
122
  export const HISTORY_HEAD =
100
- '# History — `- <id> | done|collapsed | <date>`, one line per unit built (gcf done) or ' +
101
- 'card kept by a verdict; the move writes it in the commit that ends the life\n';
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';
102
125
 
103
126
  /**
104
- * The history line of a unit built (`done`) or a card kept (`collapsed`). It goes in id order,
105
- * not at the end: branches recording different ids touch different places of the file and merge
106
- * cleanly, and a revert of the move takes out exactly its line.
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.
107
130
  */
108
131
  export async function recordLine(root, id, event, date = new Date().toISOString().slice(0, 10)) {
109
132
  const file = path.join(root, 'history.md');
@@ -115,93 +138,3 @@ export async function recordLine(root, id, event, date = new Date().toISOString(
115
138
  lines.splice(at < 0 ? lines.length : at, 0, `- ${id} | ${event} | ${date}`);
116
139
  await atomicWrite(file, `${lines.join(eol)}${eol}`);
117
140
  }
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
- }