gcf-aif-ext 0.2.0 → 0.2.4

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 CHANGED
@@ -14,14 +14,44 @@ Runtime-agnostic — everything here works on any AI Factory runtime. The Claude
14
14
 
15
15
  ## Install
16
16
 
17
+ Install into an initialized AI Factory project from npm or a local checkout:
18
+
19
+ ```bash
20
+ ai-factory init
21
+ ai-factory extension add gcf-aif-ext # npm
22
+ # ai-factory extension add ../GCF/packages/gcf-aif-ext # local checkout
23
+ ```
24
+
25
+ This installs the 8 skills into each configured runtime, registers the `gcf` command and appends the injections to `aif-plan`, `aif-review` and `aif-commit`. Then set up the project — two steps, run once:
26
+
17
27
  ```bash
18
- ai-factory extension add <path-to>/packages/gcf-aif-ext
19
- ai-factory gcf init # seeds gcf/ and cnlp.config.json
20
- ai-factory gcf hooks-install # installs the pre-commit gate
28
+ ai-factory gcf init # seeds gcf/ and cnlp.config.json
29
+ ai-factory gcf hooks-install # arms the pre-commit gate
21
30
  ```
22
31
 
32
+ `gcf init` is idempotent: it creates what is missing and overwrites nothing.
33
+
34
+ `hooks-install` is a separate step because an AI Factory manifest can declare skills, commands and injections but cannot install a git hook — and because arming a gate that can break your commits is your call, not the extension's. It needs a git repository, and it will not overwrite a `pre-commit` it did not write: if husky or lefthook already owns the hook, it prints the one line to chain from it instead. Without the hook GCF still works; nothing enforces it at commit time.
35
+
23
36
  Then `/gcf-vision` for a brand-new game, `/gcf-adopt` for a project that already has code, `/gcf-ingest <gdd.md>` for an existing design doc, or `/gcf-card` to start a feature by hand.
24
37
 
38
+ ## Upgrade
39
+
40
+ ```bash
41
+ ai-factory extension update gcf-aif-ext # pulls the new version
42
+ ai-factory extension update --force # refresh even if the version is unchanged
43
+ ```
44
+
45
+ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook.
46
+
47
+ To remove the extension:
48
+
49
+ ```bash
50
+ ai-factory extension remove gcf-aif-ext
51
+ ```
52
+
53
+ Removal leaves `gcf/` and `cnlp.config.json` in place. It also leaves the pre-commit hook, which calls into the removed extension and will now fail every commit — delete `.git/hooks/pre-commit` (or the chained line) by hand.
54
+
25
55
  ## What it adds
26
56
 
27
57
  **Skills** — `gcf-init`, `gcf-adopt`, `gcf-card`, `gcf-verdict`, `gcf-audit`, `gcf-vision`, `gcf-debate`, `gcf-ingest`.
package/commands/gcf.js CHANGED
@@ -141,7 +141,7 @@ export function register(program) {
141
141
  gcf.command('hooks-install')
142
142
  .description('Install the gcf pre-commit gate into the repository hooks dir (idempotent)')
143
143
  .action(async () => {
144
- const { writeFile, chmod } = await import('node:fs/promises');
144
+ const { writeFile } = await import('node:fs/promises');
145
145
  // --git-path resolves the hooks dir for a plain repo, a worktree and a submodule alike,
146
146
  // where a bare existsSync('.git') passes on a worktree and then fails with ENOTDIR.
147
147
  const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: process.cwd(), encoding: 'utf8' });
@@ -156,8 +156,9 @@ export function register(program) {
156
156
  + `node "${hookScript.replaceAll('\\', '/')}" || exit 1`);
157
157
  }
158
158
  await mkdir(path.dirname(target), { recursive: true });
159
- await writeFile(target, shim, 'utf8');
160
- await chmod(target, 0o755).catch(() => {});
159
+ // Mode on creation, not a chmod afterwards that can fail with the hook already in place.
160
+ // A file being replaced is our own earlier shim, already executable.
161
+ await writeFile(target, shim, { encoding: 'utf8', mode: 0o755 });
161
162
  console.log(`pre-commit hook installed → ${target}`);
162
163
  });
163
164
 
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.2.0",
4
+ "version": "0.2.4",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -87,5 +87,7 @@ for (const s of systemDocs) {
87
87
 
88
88
  // 3: regenerate INDEX and stage it (deterministic artifact, not a human error).
89
89
  await writeIndex(root);
90
- spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], { cwd: projectDir, stdio: 'ignore' });
90
+ // An unstaged INDEX (gitignored, locked index) would leave the commit without the row it validated.
91
+ const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], { cwd: projectDir, stdio: 'ignore' });
92
+ if (added.status !== 0) fail('could not stage gcf/INDEX.md — is it gitignored?');
91
93
  process.exit(0);
package/lib/audit-core.js CHANGED
@@ -22,7 +22,8 @@ export async function audit(projectDir) {
22
22
  const root = path.join(path.resolve(projectDir), 'gcf');
23
23
  const config = await readConfig(root);
24
24
  const cards = await listCards(root);
25
- const report = { first_line: '', stale_built: [], tune_limit: [], wip: null, pillars: null, until: [], linter: {}, pragmas: [], index_drift: false, illegal_status: [] };
25
+ const report = { first_line: '', stale_built: [], tune_limit: [], open_depends: [], wip: null, pillars: null, until: [], linter: {}, pragmas: [], index_drift: false, illegal_status: [] };
26
+ const liveIds = new Set(cards.map((c) => c.data.id));
26
27
 
27
28
  // Staleness is measured in commits, so outside a repository there is no answer to give.
28
29
  // Without this probe every built card would read as stale and stop the harness.
@@ -51,6 +52,10 @@ export async function audit(projectDir) {
51
52
  if ((c.data.tune_count ?? 0) >= config.tune_limit) {
52
53
  report.tune_limit.push({ id: c.data.id, tune_count: c.data.tune_count, limit: config.tune_limit });
53
54
  }
55
+ // A depends id that still has a card file is unplayed (GCF-PRD §7.2). Data for the
56
+ // harness gate, not an alert: a proposed card waiting on its dependency is normal.
57
+ const open = (c.data.depends ?? []).filter((d) => liveIds.has(d));
58
+ if (open.length) report.open_depends.push({ id: c.data.id, depends: open });
54
59
  }
55
60
 
56
61
  const wipCount = cards.filter((c) => c.data.status === 'built').length;
package/lib/gcf-paths.js CHANGED
@@ -25,7 +25,18 @@ export function gcfRoot(projectDir = process.cwd()) {
25
25
  export async function readConfig(root) {
26
26
  const file = path.join(root, 'config.yml');
27
27
  if (!existsSync(file)) return { ...DEFAULT_CONFIG };
28
- return { ...DEFAULT_CONFIG, ...(YAML.parse(await readFile(file, 'utf8')) ?? {}) };
28
+ const parsed = YAML.parse(await readFile(file, 'utf8')) ?? {};
29
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('gcf/config.yml must be a key: value mapping');
30
+ // A blank `key:` parses as null and means "not set", like an absent key.
31
+ const config = { ...DEFAULT_CONFIG, ...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)) };
32
+ // A limit that is not a number compares false against everything, which silently
33
+ // switches its gate off — the one failure a gate must not have.
34
+ for (const key of Object.keys(DEFAULT_CONFIG)) {
35
+ if (!Number.isFinite(config[key]) || config[key] < 0) {
36
+ throw new Error(`gcf/config.yml: ${key} must be a non-negative number, got ${JSON.stringify(config[key])}`);
37
+ }
38
+ }
39
+ return config;
29
40
  }
30
41
 
31
42
  /** Token estimate: ceil(chars/4). Stability over accuracy (GCF-PRD §9.1). */
package/lib/git.js CHANGED
@@ -2,18 +2,41 @@ import { spawnSync } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
 
4
4
  /**
5
- * Stage a lifecycle relocation so git reports it as a rename rather than a delete plus an
6
- * untracked file: git derives the rename from content once both sides sit in the index.
7
- * Silent no-op outside a work tree or when git is missing — the file operation has
8
- * already succeeded and must not be undone by a staging failure.
5
+ * The stage-0 index entry (mode + blob) of each file, or `entry: null` when the index
6
+ * holds none. Paths are taken relative to `cwd` (an existing directory inside the work
7
+ * tree), so a missing parent directory of a file never misreads as "untracked". A
8
+ * conflicted path is left out: writing a single entry back would mark it resolved.
9
+ * Returns null outside a work tree — there is no index to put back.
9
10
  */
10
- export function stageMove(...files) {
11
- const paths = files.filter(Boolean);
12
- let staged = false;
13
- // One `git add` per path: a single unmatched pathspec aborts the whole batch otherwise.
14
- for (const file of paths) {
15
- const res = spawnSync('git', ['add', '--', file], { cwd: path.dirname(file), stdio: 'ignore' });
16
- if (res.status === 0) staged = true;
11
+ export function snapshotIndex(cwd, files) {
12
+ const rels = files.map((f) => path.relative(cwd, f).replaceAll('\\', '/'));
13
+ // -z: no C-quoting of non-ASCII paths, so the output is the path itself.
14
+ const res = spawnSync('git', ['ls-files', '-s', '-z', '--', ...rels], { cwd, encoding: 'utf8' });
15
+ if (res.status !== 0) return null;
16
+ const prefix = spawnSync('git', ['rev-parse', '--show-prefix'], { cwd, encoding: 'utf8' }).stdout.trim();
17
+ const entries = new Map();
18
+ const conflicted = new Set();
19
+ for (const rec of res.stdout.split('\0').filter(Boolean)) {
20
+ const m = rec.match(/^(\d+) ([0-9a-f]+) (\d)\t(.+)$/s);
21
+ if (!m) continue;
22
+ if (m[3] === '0') entries.set(m[4], { mode: m[1], blob: m[2] });
23
+ else conflicted.add(m[4]);
17
24
  }
18
- return staged;
25
+ return { cwd, prefix, files: rels.filter((r) => !conflicted.has(r)).map((rel) => ({ rel, entry: entries.get(rel) ?? null })) };
26
+ }
27
+
28
+ /** Put back the entries `snapshotIndex` took. Returns one Error per path it could not restore. */
29
+ export function restoreIndex(snapshot) {
30
+ if (!snapshot) return [];
31
+ const errors = [];
32
+ for (const { rel, entry } of snapshot.files) {
33
+ // --cacheinfo takes a path from the repository root and ignores the cwd; --force-remove
34
+ // takes one relative to the cwd.
35
+ const args = entry
36
+ ? ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.blob},${snapshot.prefix}${rel}`]
37
+ : ['update-index', '--force-remove', '--', rel];
38
+ const res = spawnSync('git', args, { cwd: snapshot.cwd, encoding: 'utf8' });
39
+ if (res.status !== 0) errors.push(new Error(`could not restore the index entry of ${rel}: ${(res.stderr || '').trim() || res.error?.message}`));
40
+ }
41
+ return errors;
19
42
  }
package/lib/id.js CHANGED
@@ -18,17 +18,6 @@ export function isValidId(id) {
18
18
  return ID_RE.test(String(id ?? ''));
19
19
  }
20
20
 
21
- /** Next free number across BOTH kinds: a card and a candidate never share a number. */
22
- export function nextId(kind, existingIds) {
23
- if (kind !== 'card' && kind !== 'cand') throw new Error(`Unknown id kind: ${kind}`);
24
- let max = 0;
25
- for (const id of existingIds) {
26
- const m = String(id).match(/^(?:card|cand)-(\d{3})$/);
27
- if (m) max = Math.max(max, Number(m[1]));
28
- }
29
- return `${kind}-${String(max + 1).padStart(3, '0')}`;
30
- }
31
-
32
21
  /** `cand-012` → `card-012`: materialization keeps the number (GCF-PRD §5.2). */
33
22
  export function candToCard(candId) {
34
23
  const m = String(candId).match(/^cand-(\d{3})$/);
package/lib/safe-path.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { stageMove } from './git.js';
3
+ import { snapshotIndex, restoreIndex } from './git.js';
4
4
 
5
5
  /**
6
6
  * Resolve `target` and assert it stays inside `projectDir` (reject traversal / outside-project).
@@ -39,10 +39,15 @@ export async function atomicWrite(file, data) {
39
39
  }
40
40
  }
41
41
 
42
- /** Restore every listed file to its original bytes when `action` fails. */
43
- export async function withFileRollback(files, action) {
42
+ /**
43
+ * Restore every listed file to its original bytes when `action` fails. With `gitCwd` (an
44
+ * existing directory in the work tree) the files' git index entries are restored as well.
45
+ */
46
+ export async function withFileRollback(files, action, { gitCwd = null } = {}) {
47
+ const unique = [...new Set(files)];
44
48
  const snapshots = [];
45
- for (const file of new Set(files)) {
49
+ const index = gitCwd ? snapshotIndex(gitCwd, unique) : null;
50
+ for (const file of unique) {
46
51
  try {
47
52
  snapshots.push([file, await readFile(file, 'utf8')]);
48
53
  } catch (err) {
@@ -63,8 +68,9 @@ export async function withFileRollback(files, action) {
63
68
  rollbackErrors.push(rollbackError);
64
69
  }
65
70
  }
66
- // An inner step may already have staged its move; realign the index with the restored tree.
67
- stageMove(...snapshots.map(([file]) => file));
71
+ // 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.
73
+ rollbackErrors.push(...restoreIndex(index));
68
74
  if (rollbackErrors.length) throw new AggregateError([err, ...rollbackErrors], `Operation failed and rollback was incomplete: ${err.message}`);
69
75
  throw err;
70
76
  }
package/lib/verdict-fs.js CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { parse, serialize } from './frontmatter.js';
6
6
  import { listCards } from './gcf-paths.js';
7
- import { atomicWrite, withFileRollback } from './safe-path.js';
7
+ import { atomicWrite, resolveInside, withFileRollback } from './safe-path.js';
8
8
  import { writeIndex } from './index-gen.js';
9
9
  import { isLegal } from './transitions.js';
10
10
 
@@ -44,8 +44,6 @@ function operationInProgress(projectDir) {
44
44
  }
45
45
 
46
46
  function gitCommit(projectDir, message, addPaths) {
47
- const busy = operationInProgress(projectDir);
48
- if (busy) throw new Error(`cannot record a verdict while ${busy} is in progress — finish or abort it first`);
49
47
  // Stage and commit exactly the files this verdict touched: `git add -- gcf/` would stage a
50
48
  // user's unrelated edits under gcf/, and rollback only restores what it snapshotted.
51
49
  for (const p of addPaths) spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
@@ -72,6 +70,10 @@ export async function applyVerdict(projectDir, cardId, verdict, { note = '', tag
72
70
  if (!isLegal(card.data.status, target)) {
73
71
  throw new Error(`Illegal transition ${card.data.status} -> ${target} for ${cardId} (verdict needs status=built)`);
74
72
  }
73
+ // Before any write: refusing after the files changed would send a failed commit through the
74
+ // rollback, and restoring a conflicted index entry marks the conflict resolved.
75
+ 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`);
75
77
 
76
78
  const touched = [card.file, path.join(root, 'INDEX.md')];
77
79
  const systems = card.data.system ?? [];
@@ -95,7 +97,9 @@ export async function applyVerdict(projectDir, cardId, verdict, { note = '', tag
95
97
  await unlink(card.file);
96
98
  };
97
99
  } else {
98
- const sysFiles = systems.map((s) => path.join(root, 'systems', `${s}.md`));
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`));
99
103
  touched.push(...sysFiles);
100
104
  mutate = async () => {
101
105
  for (const [i, sysFile] of sysFiles.entries()) {
@@ -121,6 +125,6 @@ export async function applyVerdict(projectDir, cardId, verdict, { note = '', tag
121
125
  await mutate();
122
126
  await writeIndex(root);
123
127
  if (commit) hash = gitCommit(projectDir, message, touched);
124
- });
128
+ }, { gitCwd: commit ? path.resolve(projectDir) : null });
125
129
  return { verdict, card: cardId, systems, hash };
126
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",