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 +33 -3
- package/commands/gcf.js +4 -3
- 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
|
@@ -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
|
|
19
|
-
ai-factory gcf
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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
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
|
}
|