gcf-aif-ext 0.2.4 → 0.2.7

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/lib/id.js CHANGED
@@ -11,6 +11,7 @@ export function slugify(name) {
11
11
  .replace(/[^a-z0-9]+/g, '-')
12
12
  .replace(/^-+|-+$/g, '');
13
13
  if (!slug) throw new Error(`Cannot derive a slug from: ${JSON.stringify(name)}`);
14
+
14
15
  return slug;
15
16
  }
16
17
 
@@ -22,6 +23,7 @@ export function isValidId(id) {
22
23
  export function candToCard(candId) {
23
24
  const m = String(candId).match(/^cand-(\d{3})$/);
24
25
  if (!m) throw new Error(`Not a candidate id: ${candId}`);
26
+
25
27
  return `card-${m[1]}`;
26
28
  }
27
29
 
package/lib/index-gen.js CHANGED
@@ -10,36 +10,53 @@ import { atomicWrite } from './safe-path.js';
10
10
  */
11
11
  export async function generate(root) {
12
12
  const lines = ['# INDEX — generated, do not edit', ''];
13
- const row = (type, file, status, system, descr) =>
13
+ const row = ([type, file, status, system, descr]) =>
14
14
  lines.push(`${type} | gcf/${file} | ${status} | ${system} | ${descr}`);
15
15
 
16
- if (existsSync(path.join(root, 'PILLARS.md'))) row('pillars', 'PILLARS.md', '-', '-', 'pillars and negative invariants');
17
- if (existsSync(path.join(root, 'backlog.md'))) {
18
- const count = ((await readFile(path.join(root, 'backlog.md'), 'utf8')).match(/^- cand-/gm) ?? []).length;
19
- row('backlog', 'backlog.md', '-', '-', `${count} candidates`);
16
+ if (existsSync(path.join(root, 'PILLARS.md')))
17
+ row(['pillars', 'PILLARS.md', '-', '-', 'pillars and negative invariants']);
18
+ const backlog = await readIfExists(root, 'backlog.md');
19
+ if (backlog !== null) {
20
+ row(['backlog', 'backlog.md', '-', '-', `${count(backlog, /^- cand-/gm)} candidates`]);
20
21
  }
22
+
21
23
  for (const s of await listSystems(root)) {
22
- row('system', `systems/${s.name}`, '-', s.data.system ?? '-', firstBullet(s.body, 'purpose'));
24
+ const purpose = firstBullet(s.body, 'purpose');
25
+ row(['system', `systems/${s.name}`, '-', s.data.system ?? '-', purpose]);
23
26
  }
27
+
24
28
  for (const c of await listCards(root)) {
25
- row('card', `cards/${c.name}`, c.data.status ?? '?', (c.data.system ?? []).join(','), firstBullet(c.body, 'behavior'));
29
+ const system = (c.data.system ?? []).join(',');
30
+ row(['card', `cards/${c.name}`, c.data.status ?? '?', system, firstBullet(c.body, 'behavior')]);
26
31
  }
27
- if (existsSync(path.join(root, 'graveyard.md'))) {
28
- const count = ((await readFile(path.join(root, 'graveyard.md'), 'utf8')).match(/^- card-/gm) ?? []).length;
29
- row('graveyard', 'graveyard.md', '-', '-', `${count} killed ideas`);
32
+
33
+ const graveyard = await readIfExists(root, 'graveyard.md');
34
+ if (graveyard !== null) {
35
+ row(['graveyard', 'graveyard.md', '-', '-', `${count(graveyard, /^- card-/gm)} killed ideas`]);
30
36
  }
31
- if (existsSync(path.join(root, 'open-questions.md'))) {
32
- const txt = await readFile(path.join(root, 'open-questions.md'), 'utf8');
33
- const d1 = (txt.match(/^\s*class:\s*D1/gm) ?? []).length;
34
- const d2 = (txt.match(/^\s*class:\s*D2/gm) ?? []).length;
35
- row('questions', 'open-questions.md', '-', '-', `${d1} D1, ${d2} D2`);
37
+
38
+ const txt = await readIfExists(root, 'open-questions.md');
39
+ if (txt !== null) {
40
+ const d1 = count(txt, /^\s*class:\s*D1/gm);
41
+ const d2 = count(txt, /^\s*class:\s*D2/gm);
42
+ row(['questions', 'open-questions.md', '-', '-', `${d1} D1, ${d2} D2`]);
36
43
  }
44
+
37
45
  return `${lines.join('\n')}\n`;
38
46
  }
39
47
 
48
+ async function readIfExists(root, rel) {
49
+ const file = path.join(root, rel);
50
+
51
+ return existsSync(file) ? readFile(file, 'utf8') : null;
52
+ }
53
+
54
+ const count = (text, re) => (text.match(re) ?? []).length;
55
+
40
56
  export async function writeIndex(root) {
41
57
  const content = await generate(root);
42
58
  await atomicWrite(path.join(root, 'INDEX.md'), content);
59
+
43
60
  return content;
44
61
  }
45
62
 
@@ -47,5 +64,6 @@ export async function writeIndex(root) {
47
64
  export async function drifted(root) {
48
65
  const file = path.join(root, 'INDEX.md');
49
66
  if (!existsSync(file)) return true;
67
+
50
68
  return (await readFile(file, 'utf8')) !== (await generate(root));
51
69
  }
package/lib/linter.js CHANGED
@@ -5,6 +5,15 @@
5
5
  const UNIT_NUM = /\d+(?:[.,]\d+)?\s?(?:ms|мс|s|с|dmg|hp|px|сек)\b/;
6
6
  const SIGNATURE = /\w+\s*\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
7
7
  const CAMEL = /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/;
8
+ const ENVELOPE_FIELD = /^\s*(?:- )?(?:question|assumed|proposed):/;
9
+
10
+ /**
11
+ * @typedef {object} LintState
12
+ * @property {string[]} lines
13
+ * @property {Array<{line: number, rule: string, text: string}>} issues
14
+ * @property {Array<{line: number, reason: string | null}>} pragmas
15
+ * @property {number} fenceStart line index of the open fence, -1 when outside one
16
+ */
8
17
 
9
18
  /** Which `key:` block a line sits in (flat CNL-P body). */
10
19
  function blockOf(lines, i) {
@@ -12,50 +21,79 @@ function blockOf(lines, i) {
12
21
  const m = lines[j].match(/^([a-z_]+):\s*$/) ?? lines[j].match(/^([a-z_]+):\s+\S/);
13
22
  if (m) return m[1];
14
23
  }
24
+
15
25
  return null;
16
26
  }
17
27
 
18
28
  export function lint(content) {
19
- const lines = String(content).split('\n');
20
- const issues = [];
21
- const pragmas = [];
22
- let fenceStart = -1;
23
-
24
- for (let i = 0; i < lines.length; i++) {
25
- const line = lines[i];
26
- const prev = lines[i - 1] ?? '';
27
-
28
- const pragma = prev.match(/<!--\s*gcf:allow\s*(.*?)\s*-->/);
29
- if (pragma && !pragma[1]) issues.push({ line: i, rule: 'pragma-without-reason', text: prev.trim() });
30
- const allowed = Boolean(pragma && pragma[1]);
31
- if (pragma) pragmas.push({ line: i, reason: pragma[1] || null });
32
-
33
- if (line.startsWith('```')) {
34
- if (fenceStart < 0) fenceStart = i;
35
- else {
36
- if (i - fenceStart - 1 > 3) issues.push({ line: fenceStart + 1, rule: 'fenced-code-over-3-lines', text: `${i - fenceStart - 1} lines` });
37
- fenceStart = -1;
38
- }
39
- continue;
40
- }
41
- if (fenceStart >= 0 || allowed) continue;
42
-
43
- const block = blockOf(lines, i);
44
- if (block !== 'feel' && UNIT_NUM.test(line)) {
45
- issues.push({ line: i + 1, rule: 'number-with-unit-outside-feel', text: line.trim() });
46
- }
47
- if (SIGNATURE.test(line)) {
48
- issues.push({ line: i + 1, rule: 'signature', text: line.trim() });
49
- }
50
- // Three or more consecutive bullets each naming CamelCase identifiers = a member list.
51
- if (/^- /.test(line) && CAMEL.test(line)
52
- && /^- /.test(prev) && CAMEL.test(prev)
53
- && /^- /.test(lines[i - 2] ?? '') && CAMEL.test(lines[i - 2] ?? '')) {
54
- issues.push({ line: i + 1, rule: 'camelcase-member-list', text: line.trim() });
55
- }
29
+ /** @type {LintState} */
30
+ const st = { lines: String(content).split('\n'), issues: [], pragmas: [], fenceStart: -1 };
31
+
32
+ for (let i = 0; i < st.lines.length; i++) {
33
+ const allowed = readPragma(st, i);
34
+ if (st.lines[i].startsWith('```')) toggleFence(st, i);
35
+ else if (st.fenceStart < 0 && !allowed) checkLine(st, i);
36
+ }
37
+
38
+ // An unclosed fence leaves fenceStart set, so the loop above skipped every remaining
39
+ // line: one stray fence would otherwise disable П2 for the rest of the file.
40
+ if (st.fenceStart >= 0)
41
+ st.issues.push({
42
+ line: st.fenceStart + 1,
43
+ rule: 'unterminated-fence',
44
+ text: 'code fence never closed',
45
+ });
46
+
47
+ return { issues: st.issues, pragmas: st.pragmas };
48
+ }
49
+
50
+ /** Records a pragma on the previous line; true when it carries a reason and so allows line i. */
51
+ function readPragma(st, i) {
52
+ const prev = st.lines[i - 1] ?? '';
53
+ const pragma = prev.match(/<!--\s*gcf:allow\s*(.*?)\s*-->/);
54
+ if (!pragma) return false;
55
+ if (!pragma[1]) st.issues.push({ line: i, rule: 'pragma-without-reason', text: prev.trim() });
56
+ st.pragmas.push({ line: i, reason: pragma[1] || null });
57
+
58
+ return Boolean(pragma[1]);
59
+ }
60
+
61
+ /** Opens a fence, or closes it and flags a body over 3 lines. */
62
+ function toggleFence(st, i) {
63
+ if (st.fenceStart < 0) {
64
+ st.fenceStart = i;
65
+
66
+ return;
67
+ }
68
+
69
+ const size = i - st.fenceStart - 1;
70
+ if (size > 3)
71
+ st.issues.push({
72
+ line: st.fenceStart + 1,
73
+ rule: 'fenced-code-over-3-lines',
74
+ text: `${size} lines`,
75
+ });
76
+ st.fenceStart = -1;
77
+ }
78
+
79
+ const memberBullet = (line) => /^- /.test(line) && CAMEL.test(line);
80
+
81
+ /** Pattern rules for one prose line (outside fences, not pragma-allowed). */
82
+ function checkLine(st, i) {
83
+ const line = st.lines[i];
84
+ const flag = (rule) => st.issues.push({ line: i + 1, rule, text: line.trim() });
85
+
86
+ // Envelopes are legal where the spec puts them: the feel block, and the question/assumed/
87
+ // proposed fields of an open-questions record, where import parks D2 envelopes for the human.
88
+ if (blockOf(st.lines, i) !== 'feel' && !ENVELOPE_FIELD.test(line) && UNIT_NUM.test(line)) {
89
+ flag('number-with-unit-outside-feel');
90
+ }
91
+
92
+ if (SIGNATURE.test(line)) flag('signature');
93
+
94
+ // Three or more consecutive bullets each naming CamelCase identifiers = a member list.
95
+ const prev = st.lines[i - 1] ?? '';
96
+ if (memberBullet(line) && memberBullet(prev) && memberBullet(st.lines[i - 2] ?? '')) {
97
+ flag('camelcase-member-list');
56
98
  }
57
- // An unclosed fence leaves fenceStart set, so the loop above `continue`d past every
58
- // remaining line: one stray fence would otherwise disable П2 for the rest of the file.
59
- if (fenceStart >= 0) issues.push({ line: fenceStart + 1, rule: 'unterminated-fence', text: 'code fence never closed' });
60
- return { issues, pragmas };
61
99
  }
package/lib/safe-path.js CHANGED
@@ -21,6 +21,7 @@ export function resolveInside(projectDir, target) {
21
21
  ' next: pass a path within the project root',
22
22
  );
23
23
  }
24
+
24
25
  return resolved;
25
26
  }
26
27
 
@@ -39,9 +40,21 @@ export async function atomicWrite(file, data) {
39
40
  }
40
41
  }
41
42
 
43
+ /** Put one snapshotted file back: `null` content means it did not exist before. */
44
+ async function restoreSnapshot(file, content) {
45
+ if (content === null)
46
+ await unlink(file).catch((e) => {
47
+ if (e.code !== 'ENOENT') throw e;
48
+ });
49
+ else await atomicWrite(file, content);
50
+ }
51
+
42
52
  /**
43
53
  * Restore every listed file to its original bytes when `action` fails. With `gitCwd` (an
44
54
  * existing directory in the work tree) the files' git index entries are restored as well.
55
+ * @param {string[]} files
56
+ * @param {() => Promise<any>} action
57
+ * @param {{ gitCwd?: string | null }} [options]
45
58
  */
46
59
  export async function withFileRollback(files, action, { gitCwd = null } = {}) {
47
60
  const unique = [...new Set(files)];
@@ -62,16 +75,22 @@ export async function withFileRollback(files, action, { gitCwd = null } = {}) {
62
75
  const rollbackErrors = [];
63
76
  for (const [file, content] of snapshots) {
64
77
  try {
65
- if (content === null) await unlink(file).catch((e) => { if (e.code !== 'ENOENT') throw e; });
66
- else await atomicWrite(file, content);
78
+ await restoreSnapshot(file, content);
67
79
  } catch (rollbackError) {
68
80
  rollbackErrors.push(rollbackError);
69
81
  }
70
82
  }
83
+
71
84
  // An inner step may already have staged its writes. Re-adding the restored files would
72
- // fold the user's unstaged edits into the index; putting back the old entries keeps them apart.
85
+ // fold the user's unstaged edits into the index; putting back the old entries keeps
86
+ // them apart.
73
87
  rollbackErrors.push(...restoreIndex(index));
74
- if (rollbackErrors.length) throw new AggregateError([err, ...rollbackErrors], `Operation failed and rollback was incomplete: ${err.message}`);
88
+ if (rollbackErrors.length)
89
+ throw new AggregateError(
90
+ [err, ...rollbackErrors],
91
+ `Operation failed and rollback was incomplete: ${err.message}`,
92
+ { cause: err },
93
+ );
75
94
  throw err;
76
95
  }
77
96
  }
@@ -19,9 +19,7 @@ export function isLegal(from, to) {
19
19
 
20
20
  /** Statuses reachable from `from`, for error messages. */
21
21
  export function legalTargets(from) {
22
- return [...LEGAL]
23
- .filter((t) => t.startsWith(`${from}>`))
24
- .map((t) => t.split('>')[1]);
22
+ return [...LEGAL].filter((t) => t.startsWith(`${from}>`)).map((t) => t.split('>')[1]);
25
23
  }
26
24
 
27
25
  /** A status a card FILE may carry. Terminal statuses exist only in the transition itself. */
@@ -1,19 +1,43 @@
1
1
  // Runtime mirror of schemas/card.schema.json for the pre-commit hook (no ajv in
2
2
  // production deps). The parity test keeps the two in agreement.
3
- const FIELDS = ['schema', 'id', 'slug', 'status', 'route', 'system', 'depends', 'source', 'tune_count', 'built_at', 'created'];
3
+ const FIELDS = [
4
+ 'schema',
5
+ 'id',
6
+ 'slug',
7
+ 'status',
8
+ 'route',
9
+ 'system',
10
+ 'depends',
11
+ 'source',
12
+ 'tune_count',
13
+ 'built_at',
14
+ 'created',
15
+ ];
4
16
  const REQUIRED = FIELDS.filter((f) => f !== 'built_at');
5
17
 
6
18
  const RULES = {
7
19
  schema: (v) => v === 1 || 'schema must be 1',
8
20
  id: (v) => /^card-\d{3}$/.test(v) || 'id must match card-NNN',
9
21
  slug: (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) || 'slug must be a lowercase hyphen slug',
10
- status: (v) => ['proposed', 'built', 'tuning'].includes(v) || `status "${v}" is not storable (П1: terminal statuses never live in a file)`,
22
+ status: (v) =>
23
+ ['proposed', 'built', 'tuning'].includes(v) ||
24
+ `status "${v}" is not storable (П1: terminal statuses never live in a file)`,
11
25
  route: (v) => ['soft', 'hard'].includes(v) || 'route must be soft or hard',
12
- system: (v) => (Array.isArray(v) && v.length >= 1 && v.every((s) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(s))) || 'system must list >=1 subsystem slugs',
13
- depends: (v) => (Array.isArray(v) && v.every((d) => /^card-\d{3}$/.test(d))) || 'depends must list card-NNN ids',
14
- source: (v) => /^(GDD#[A-Za-z0-9_-]+|manual|inferred)$/.test(v) || 'source must be GDD#<id>, manual or inferred',
26
+ system: (v) =>
27
+ (Array.isArray(v) && v.length >= 1 && v.every((s) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(s))) ||
28
+ 'system must list >=1 subsystem slugs',
29
+ depends: (v) =>
30
+ (Array.isArray(v) && v.every((d) => /^card-\d{3}$/.test(d))) ||
31
+ 'depends must list card-NNN ids',
32
+ source: (v) =>
33
+ /^(GDD#[A-Za-z0-9_-]+|manual|inferred)$/.test(v) ||
34
+ 'source must be GDD#<id>, manual or inferred',
15
35
  tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
16
- built_at: (v) => v === null || v === undefined || /^[0-9a-f]{7,40}$/.test(v) || 'built_at must be null or a commit hash',
36
+ built_at: (v) =>
37
+ v === null ||
38
+ v === undefined ||
39
+ /^[0-9a-f]{7,40}$/.test(v) ||
40
+ 'built_at must be null or a commit hash',
17
41
  created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
18
42
  };
19
43
 
@@ -26,12 +50,17 @@ export function validateCard(data) {
26
50
  const errors = [];
27
51
  for (const key of REQUIRED) if (!(key in data)) errors.push(`missing field: ${key}`);
28
52
  for (const key of Object.keys(data)) {
29
- if (!FIELDS.includes(key)) errors.push(`unknown field: ${key}${key === 'until' ? ' (until: is a line annotation, not frontmatter)' : ''}`);
53
+ if (!FIELDS.includes(key))
54
+ errors.push(
55
+ `unknown field: ${key}` +
56
+ (key === 'until' ? ' (until: is a line annotation, not frontmatter)' : ''),
57
+ );
30
58
  else if (key in RULES) {
31
59
  const res = RULES[key](data[key]);
32
60
  if (res !== true) errors.push(res);
33
61
  }
34
62
  }
63
+
35
64
  return errors;
36
65
  }
37
66
 
@@ -41,8 +70,13 @@ export function validateUntil(body) {
41
70
  const lines = String(body).split('\n');
42
71
  lines.forEach((line, i) => {
43
72
  for (const m of line.matchAll(/\[until:[^\]]*\]/g)) {
44
- if (!UNTIL_OK.test(m[0])) errors.push(`line ${i + 1}: bad until annotation ${m[0]} (use verdict(card-NNN), retune(system) or manual(text))`);
73
+ if (!UNTIL_OK.test(m[0]))
74
+ errors.push(
75
+ `line ${i + 1}: bad until annotation ${m[0]} ` +
76
+ '(use verdict(card-NNN), retune(system) or manual(text))',
77
+ );
45
78
  }
46
79
  });
80
+
47
81
  return errors;
48
82
  }
package/lib/verdict-fs.js CHANGED
@@ -20,6 +20,7 @@ const MERGE_MAP = [
20
20
 
21
21
  function blockLines(body, key) {
22
22
  const m = String(body).match(new RegExp(`^${key}:\\s*\\n((?:- .*\\n?)*)`, 'm'));
23
+
23
24
  return m ? m[1].split('\n').filter((l) => l.startsWith('- ')) : [];
24
25
  }
25
26
 
@@ -29,29 +30,122 @@ function upsertBlock(body, key, addLines) {
29
30
  const fresh = addLines.filter((l) => !existing.has(l));
30
31
  if (!fresh.length) return body;
31
32
  const re = new RegExp(`(^${key}:\\s*\\n(?:- .*\\n?)*)`, 'm');
32
- if (re.test(body)) return body.replace(re, (blk) => `${blk.endsWith('\n') ? blk : `${blk}\n`}${fresh.join('\n')}\n`);
33
+ if (re.test(body))
34
+ return body.replace(
35
+ re,
36
+ (blk) => `${blk.endsWith('\n') ? blk : `${blk}\n`}${fresh.join('\n')}\n`,
37
+ );
38
+
33
39
  return `${body.trimEnd()}\n\n${key}:\n${fresh.join('\n')}\n`;
34
40
  }
35
41
 
36
42
  // `git commit --only <paths>` is a partial commit, which git refuses outright while a merge
37
43
  // or a sequencer operation is in progress. Detect it up front so the failure has a reason.
38
- const IN_PROGRESS = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply'];
44
+ const IN_PROGRESS = [
45
+ 'MERGE_HEAD',
46
+ 'CHERRY_PICK_HEAD',
47
+ 'REVERT_HEAD',
48
+ 'rebase-merge',
49
+ 'rebase-apply',
50
+ ];
39
51
  function operationInProgress(projectDir) {
40
- const gitDir = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: projectDir, encoding: 'utf8' });
52
+ const gitDir = spawnSync('git', ['rev-parse', '--git-dir'], {
53
+ cwd: projectDir,
54
+ encoding: 'utf8',
55
+ });
41
56
  if (gitDir.status !== 0) return null;
42
57
  const dir = path.resolve(projectDir, gitDir.stdout.trim());
58
+
43
59
  return IN_PROGRESS.find((name) => existsSync(path.join(dir, name))) ?? null;
44
60
  }
45
61
 
46
62
  function gitCommit(projectDir, message, addPaths) {
47
63
  // Stage and commit exactly the files this verdict touched: `git add -- gcf/` would stage a
48
64
  // user's unrelated edits under gcf/, and rollback only restores what it snapshotted.
49
- for (const p of addPaths) spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
50
- const res = spawnSync('git', ['commit', '-m', message, '--only', '--', ...addPaths], { cwd: projectDir, encoding: 'utf8' });
65
+ for (const p of addPaths)
66
+ spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
67
+ const res = spawnSync('git', ['commit', '-m', message, '--only', '--', ...addPaths], {
68
+ cwd: projectDir,
69
+ encoding: 'utf8',
70
+ });
51
71
  if (res.status !== 0) throw new Error(`verdict commit failed: ${res.stderr || res.stdout}`);
52
- return spawnSync('git', ['rev-parse', 'HEAD'], { cwd: projectDir, encoding: 'utf8' }).stdout.trim();
72
+
73
+ return spawnSync('git', ['rev-parse', 'HEAD'], {
74
+ cwd: projectDir,
75
+ encoding: 'utf8',
76
+ }).stdout.trim();
77
+ }
78
+
79
+ /** Find the card and refuse an unknown verdict or a transition the lifecycle forbids. */
80
+ async function findVerdictCard(root, cardId, verdict) {
81
+ const card = (await listCards(root)).find((c) => c.data.id === cardId);
82
+ if (!card) throw new Error(`Card not found: ${cardId}`);
83
+ const target = { keep: 'validated', tune: 'tuning', kill: 'killed' }[verdict];
84
+ if (!target) throw new Error(`Unknown verdict: ${verdict}`);
85
+ if (!isLegal(card.data.status, target)) {
86
+ throw new Error(
87
+ `Illegal transition ${card.data.status} -> ${target} for ${cardId} ` +
88
+ '(verdict needs status=built)',
89
+ );
90
+ }
91
+
92
+ return card;
93
+ }
94
+
95
+ function planTune({ card, note }) {
96
+ return async () => {
97
+ const data = { ...card.data, status: 'tuning', tune_count: (card.data.tune_count ?? 0) + 1 };
98
+ const body = note ? upsertBlock(card.body, 'feel', [`- retune: ${note}`]) : card.body;
99
+ await atomicWrite(card.file, serialize(data, body));
100
+ };
101
+ }
102
+
103
+ function planKill({ root, card, cardId, note, tags, systems, touched }) {
104
+ const gyFile = path.join(root, 'graveyard.md');
105
+ touched.push(gyFile);
106
+
107
+ return async () => {
108
+ const reason = note || 'no reason recorded';
109
+ const tagStr = (tags.length ? tags : systems).join(', ');
110
+ const line = `- ${cardId} | ${card.data.slug} | ${reason} | tags: ${tagStr}\n`;
111
+ const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
112
+ await atomicWrite(gyFile, existing + line);
113
+ await unlink(card.file);
114
+ };
53
115
  }
54
116
 
117
+ async function mergeIntoSystem(sysFile, system, card, cardId) {
118
+ let doc = existsSync(sysFile)
119
+ ? parse(await readFile(sysFile, 'utf8'))
120
+ : { data: { system }, body: 'purpose:\n- (fill in)\n' };
121
+ let body = doc.body;
122
+ for (const [slot, section] of MERGE_MAP) {
123
+ body = upsertBlock(body, section, blockLines(card.body, slot));
124
+ }
125
+
126
+ // Lines with a live until: move to `open` (GCF-PRD §8.2.5).
127
+ const openLines = blockLines(card.body, 'interactions').filter((l) => l.includes('[until:'));
128
+ body = upsertBlock(body, 'open', openLines);
129
+ await atomicWrite(sysFile, serialize({ ...doc.data, updated_by: cardId }, body));
130
+ }
131
+
132
+ function planKeep({ root, card, cardId, systems, touched }) {
133
+ // `system:` names files, and the pre-commit gate validates it only after they are written:
134
+ // `../../x` would already have overwritten a file outside gcf/.
135
+ const sysFiles = systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
136
+ touched.push(...sysFiles);
137
+
138
+ return async () => {
139
+ for (const [i, sysFile] of sysFiles.entries()) {
140
+ await mergeIntoSystem(sysFile, systems[i], card, cardId);
141
+ }
142
+
143
+ await unlink(card.file);
144
+ };
145
+ }
146
+
147
+ const PLANS = { tune: planTune, kill: planKill, keep: planKeep };
148
+
55
149
  /**
56
150
  * The one operation that collapses a card (GCF-PRD §8). Atomic: file changes roll back
57
151
  * on failure; the verdict commit has the fixed greppable format. `keep` merges into every
@@ -61,70 +155,39 @@ function gitCommit(projectDir, message, addPaths) {
61
155
  * The commit runs INSIDE the rollback: it fires the pre-commit gate, which can reject the
62
156
  * merged systems doc, and a card unlinked by a commit that never landed is a lost card.
63
157
  */
64
- export async function applyVerdict(projectDir, cardId, verdict, { note = '', tags = [], commit = true } = {}) {
158
+ export async function applyVerdict(
159
+ projectDir,
160
+ cardId,
161
+ verdict,
162
+ { note = '', tags = [], commit = true } = {},
163
+ ) {
65
164
  const root = path.join(path.resolve(projectDir), 'gcf');
66
- const card = (await listCards(root)).find((c) => c.data.id === cardId);
67
- if (!card) throw new Error(`Card not found: ${cardId}`);
68
- const target = { keep: 'validated', tune: 'tuning', kill: 'killed' }[verdict];
69
- if (!target) throw new Error(`Unknown verdict: ${verdict}`);
70
- if (!isLegal(card.data.status, target)) {
71
- throw new Error(`Illegal transition ${card.data.status} -> ${target} for ${cardId} (verdict needs status=built)`);
72
- }
165
+ const card = await findVerdictCard(root, cardId, verdict);
166
+
73
167
  // Before any write: refusing after the files changed would send a failed commit through the
74
168
  // rollback, and restoring a conflicted index entry marks the conflict resolved.
75
169
  const busy = commit ? operationInProgress(projectDir) : null;
76
- if (busy) throw new Error(`cannot record a verdict while ${busy} is in progress — finish or abort it first`);
170
+ if (busy)
171
+ throw new Error(
172
+ `cannot record a verdict while ${busy} is in progress — finish or abort it first`,
173
+ );
77
174
 
78
175
  const touched = [card.file, path.join(root, 'INDEX.md')];
79
176
  const systems = card.data.system ?? [];
80
- let mutate;
81
-
82
- if (verdict === 'tune') {
83
- mutate = async () => {
84
- const data = { ...card.data, status: 'tuning', tune_count: (card.data.tune_count ?? 0) + 1 };
85
- const body = note ? upsertBlock(card.body, 'feel', [`- retune: ${note}`]) : card.body;
86
- await atomicWrite(card.file, serialize(data, body));
87
- };
88
- } else if (verdict === 'kill') {
89
- const gyFile = path.join(root, 'graveyard.md');
90
- touched.push(gyFile);
91
- mutate = async () => {
92
- const reason = note || 'no reason recorded';
93
- const tagStr = (tags.length ? tags : systems).join(', ');
94
- const line = `- ${cardId} | ${card.data.slug} | ${reason} | tags: ${tagStr}\n`;
95
- const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
96
- await atomicWrite(gyFile, existing + line);
97
- await unlink(card.file);
98
- };
99
- } else {
100
- // `system:` names files, and the pre-commit gate validates it only after they are written:
101
- // `../../x` would already have overwritten a file outside gcf/.
102
- const sysFiles = systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
103
- touched.push(...sysFiles);
104
- mutate = async () => {
105
- for (const [i, sysFile] of sysFiles.entries()) {
106
- let doc = existsSync(sysFile)
107
- ? parse(await readFile(sysFile, 'utf8'))
108
- : { data: { system: systems[i] }, body: 'purpose:\n- (fill in)\n' };
109
- let body = doc.body;
110
- for (const [slot, section] of MERGE_MAP) {
111
- body = upsertBlock(body, section, blockLines(card.body, slot));
112
- }
113
- // Lines with a live until: move to `open` (GCF-PRD §8.2.5).
114
- const openLines = blockLines(card.body, 'interactions').filter((l) => l.includes('[until:'));
115
- body = upsertBlock(body, 'open', openLines);
116
- await atomicWrite(sysFile, serialize({ ...doc.data, updated_by: cardId }, body));
117
- }
118
- await unlink(card.file);
119
- };
120
- }
177
+ const mutate = PLANS[verdict]({ root, card, cardId, note, tags, systems, touched });
121
178
 
122
179
  const message = `gcf(verdict): ${verdict} ${cardId} ${card.data.slug} → ${systems.join(',')}`;
180
+ /** @type {string | null} */
123
181
  let hash = null;
124
- await withFileRollback(touched, async () => {
125
- await mutate();
126
- await writeIndex(root);
127
- if (commit) hash = gitCommit(projectDir, message, touched);
128
- }, { gitCwd: commit ? path.resolve(projectDir) : null });
182
+ await withFileRollback(
183
+ touched,
184
+ async () => {
185
+ await mutate();
186
+ await writeIndex(root);
187
+ if (commit) hash = gitCommit(projectDir, message, touched);
188
+ },
189
+ { gitCwd: commit ? path.resolve(projectDir) : null },
190
+ );
191
+
129
192
  return { verdict, card: cardId, systems, hash };
130
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.2.4",
3
+ "version": "0.2.7",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@ forbidden_behaviors:
24
24
  - do not read code files whole: names, paths and entry points are the whole input
25
25
 
26
26
  workflow:
27
- 1. run `ai-factory gcf init` and report what it created; when `backlog.md` and `PILLARS.md` still hold the template examples, clear those example lines so INDEX does not count them
27
+ 1. run `ai-factory gcf init` and report what it created; when `.ai-factory/DESCRIPTION.md` is absent, tell the human to run `/aif` before the first `/gcf-card`
28
28
  2. scan the code tree by names only - directories, `*.asmdef`, feature classes, entry points - and propose a subsystem list with a path per entry
29
29
  3. ask the human to edit and confirm the names; a name they reject or doubt becomes a D1 record in `gcf/open-questions.md`, not a file
30
30
  4. write `gcf/systems/<name>.md` per confirmed subsystem from the package template, frontmatter `system: <name>` and `updated_by: adopt`
@@ -32,7 +32,7 @@ workflow:
32
32
  6. fill `purpose:` with one line of intent the human confirmed: the INDEX row of a subsystem is its first `purpose` bullet
33
33
  7. fill `behavior:` and `constraints:` with 1 placeholder each, annotated `[until: manual(first verdict touching <name>)]`: the profile requires both blocks, and a `manual(...)` trigger is not an audit finding
34
34
  8. write a constraint the human states out loud into `constraints:`, and do not interview subsystem by subsystem for more: unanswered questions on adoption day are the staleness this framework exists to remove
35
- 9. draft `PILLARS.md` by the interview in the `gcf-init` skill, steps 3..5, and do not restate that interview here
35
+ 9. draft `PILLARS.md` by the interview in the `gcf-init` skill, steps 4..6, and do not restate that interview here
36
36
  10. run `npx cnlp check`, then `ai-factory gcf lint` over each new systems doc, then `ai-factory gcf index`
37
37
  11. ask the human to run `ai-factory gcf hooks-install`; when it refuses over a foreign pre-commit, hand them the chain line it prints
38
38
  12. state the next step: an old design document goes through `/gcf-ingest`, new work starts at `/gcf-card`