gcf-aif-ext 0.2.1 → 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/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.1",
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.1",
3
+ "version": "0.2.4",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",