gcf-aif-ext 0.2.1 → 0.2.5
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 +2 -0
- package/commands/gcf.js +57 -5
- package/extension.json +1 -1
- package/hooks/pre-commit.js +3 -1
- package/lib/audit-core.js +6 -1
- package/lib/gcf-paths.js +12 -1
- package/lib/git.js +35 -12
- package/lib/id.js +0 -11
- package/lib/safe-path.js +12 -6
- package/lib/verdict-fs.js +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ ai-factory gcf hooks-install # arms the pre-commit gate
|
|
|
31
31
|
|
|
32
32
|
`gcf init` is idempotent: it creates what is missing and overwrites nothing.
|
|
33
33
|
|
|
34
|
+
It then offers the optional [harness](../gcf-harness-aif-ext) — orchestration and a readset guard for **Claude Code only** — and on yes runs `ai-factory extension add gcf-harness-aif-ext` and `ai-factory gcf-harness init`. It asks only in a terminal and only when `.ai-factory.json` lists the Claude Code agent; an agent or CI run is never asked and installs nothing. `--harness` installs it without asking, `--no-harness` skips the question.
|
|
35
|
+
|
|
34
36
|
`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
37
|
|
|
36
38
|
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.
|
package/commands/gcf.js
CHANGED
|
@@ -15,12 +15,53 @@ const MARKER = 'gcf-aif-ext pre-commit gate';
|
|
|
15
15
|
|
|
16
16
|
const out = (json, value, human) => console.log(json ? JSON.stringify(value, null, 2) : human(value));
|
|
17
17
|
|
|
18
|
+
const HARNESS = 'gcf-harness-aif-ext';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `--harness` / `--no-harness` decide. Without either, ask only when someone can answer
|
|
22
|
+
* and the answer matters: an agent, CI or MinTTY run has no terminal, and a project
|
|
23
|
+
* without the Claude Code agent has no use for a Claude-Code-only extension.
|
|
24
|
+
*/
|
|
25
|
+
export async function harnessWanted(flag, ask, canAsk) {
|
|
26
|
+
if (flag !== undefined) return flag;
|
|
27
|
+
return canAsk ? ask() : false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function claudeConfigured() {
|
|
31
|
+
try {
|
|
32
|
+
const { agents } = JSON.parse(await readFile(path.join(process.cwd(), '.ai-factory.json'), 'utf8'));
|
|
33
|
+
return agents.some((a) => a?.id === 'claude');
|
|
34
|
+
} catch {
|
|
35
|
+
return true; // unknown config shape: let the human decide
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function askHarness() {
|
|
40
|
+
const { createInterface } = await import('node:readline/promises');
|
|
41
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
try {
|
|
43
|
+
const answer = await rl.question(`Also install ${HARNESS} (orchestration + readset guard, Claude Code only)? [y/N] `);
|
|
44
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
45
|
+
} finally {
|
|
46
|
+
rl.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Re-enter the AIF CLI that is running this command, not whatever `ai-factory` PATH holds
|
|
51
|
+
// (an npx run has none on PATH).
|
|
52
|
+
function aif(args) {
|
|
53
|
+
const res = spawnSync(process.execPath, [process.argv[1], ...args], { cwd: process.cwd(), stdio: 'inherit' });
|
|
54
|
+
if (res.status !== 0) throw new Error(`ai-factory ${args.join(' ')} failed — gcf/ is ready; retry that command by hand`);
|
|
55
|
+
}
|
|
56
|
+
|
|
18
57
|
export function register(program) {
|
|
19
58
|
const gcf = program.command('gcf').description('Game Context Framework core');
|
|
20
59
|
|
|
21
60
|
gcf.command('init')
|
|
22
|
-
.description('Create the gcf/ skeleton from templates (idempotent, never overwrites)')
|
|
23
|
-
.
|
|
61
|
+
.description('Create the gcf/ skeleton from templates (idempotent, never overwrites); offers the harness')
|
|
62
|
+
.option('--harness', `also install ${HARNESS} and run gcf-harness init (Claude Code only)`)
|
|
63
|
+
.option('--no-harness', 'do not offer the harness')
|
|
64
|
+
.action(async (opts) => {
|
|
24
65
|
const root = path.join(process.cwd(), 'gcf');
|
|
25
66
|
for (const dir of ['cards', 'systems', 'source', '.metrics']) {
|
|
26
67
|
await mkdir(path.join(root, dir), { recursive: true });
|
|
@@ -44,6 +85,16 @@ export function register(program) {
|
|
|
44
85
|
const { writeIndex } = await lib('index-gen');
|
|
45
86
|
await writeIndex(root);
|
|
46
87
|
console.log('gcf/ ready. Edit PILLARS.md, then add candidates to backlog.md.');
|
|
88
|
+
|
|
89
|
+
const installed = program.commands?.some((c) => (typeof c?.name === 'function' ? c.name() : c?.name) === 'gcf-harness');
|
|
90
|
+
if (installed) {
|
|
91
|
+
if (opts.harness) aif(['gcf-harness', 'init']);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY) && (await claudeConfigured());
|
|
95
|
+
if (!(await harnessWanted(opts.harness, askHarness, canAsk))) return;
|
|
96
|
+
aif(['extension', 'add', HARNESS]);
|
|
97
|
+
aif(['gcf-harness', 'init']);
|
|
47
98
|
});
|
|
48
99
|
|
|
49
100
|
gcf.command('index')
|
|
@@ -141,7 +192,7 @@ export function register(program) {
|
|
|
141
192
|
gcf.command('hooks-install')
|
|
142
193
|
.description('Install the gcf pre-commit gate into the repository hooks dir (idempotent)')
|
|
143
194
|
.action(async () => {
|
|
144
|
-
const { writeFile
|
|
195
|
+
const { writeFile } = await import('node:fs/promises');
|
|
145
196
|
// --git-path resolves the hooks dir for a plain repo, a worktree and a submodule alike,
|
|
146
197
|
// where a bare existsSync('.git') passes on a worktree and then fails with ENOTDIR.
|
|
147
198
|
const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: process.cwd(), encoding: 'utf8' });
|
|
@@ -156,8 +207,9 @@ export function register(program) {
|
|
|
156
207
|
+ `node "${hookScript.replaceAll('\\', '/')}" || exit 1`);
|
|
157
208
|
}
|
|
158
209
|
await mkdir(path.dirname(target), { recursive: true });
|
|
159
|
-
|
|
160
|
-
|
|
210
|
+
// Mode on creation, not a chmod afterwards that can fail with the hook already in place.
|
|
211
|
+
// A file being replaced is our own earlier shim, already executable.
|
|
212
|
+
await writeFile(target, shim, { encoding: 'utf8', mode: 0o755 });
|
|
161
213
|
console.log(`pre-commit hook installed → ${target}`);
|
|
162
214
|
});
|
|
163
215
|
|
package/extension.json
CHANGED
package/hooks/pre-commit.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
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
|
}
|