gcf-harness-aif-ext 0.3.2 → 0.3.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 +12 -3
- package/commands/harness.js +196 -52
- package/extension.json +1 -1
- package/hooks/readset-guard.js +64 -37
- package/package.json +2 -2
- package/skills/gcf-run/SKILL.md +2 -3
- package/skills/gcf-run-ingest/SKILL.md +7 -5
- package/workflows/ingest.js +370 -141
package/README.md
CHANGED
|
@@ -2,17 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Claude-Code-specific orchestration for the [Game Context Framework](../gcf-aif-ext). Optional: GCF works without it, by hand.
|
|
4
4
|
|
|
5
|
+
> **Claude Code only.** Codex and the other AI Factory runtimes are not supported: the harness is built on Claude Code
|
|
6
|
+
> dynamic workflows and `PreToolUse` hooks, which they do not have. AI Factory still copies the `gcf-run` skills into
|
|
7
|
+
> every runtime the project is configured for, where they do not work — on a project without Claude Code, do not
|
|
8
|
+
> install the harness. GCF itself keeps working on any runtime.
|
|
9
|
+
|
|
5
10
|
The harness turns policy-as-text into an executable pipeline. It has **no memory of its own** — everything it knows between two agent calls is on disk in `gcf/`, so a run killed at any point restarts and reaches the same state.
|
|
6
11
|
|
|
7
12
|
## Requirements
|
|
8
13
|
|
|
9
|
-
- [`gcf-aif-ext`](../gcf-aif-ext) `^0.2.
|
|
10
|
-
- Claude Code 2.1.270+ (hooks, dynamic workflows)
|
|
14
|
+
- [`gcf-aif-ext`](../gcf-aif-ext) `^0.2.7` installed, with a `gcf/` tree in the project — declared as a peer dependency, and `gcf-harness init` refuses when the `gcf` command is not registered
|
|
15
|
+
- Claude Code 2.1.270+ (hooks, dynamic workflows) — the only supported runtime
|
|
11
16
|
- AI Factory 2.x, Node.js ≥ 22.14
|
|
12
17
|
|
|
13
18
|
## Install
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
The shortest way is from the core: `ai-factory gcf init` asks whether to install the harness too, and on yes runs both steps below. It asks only in a terminal and only when the project has the Claude Code agent; `gcf init --harness` installs it without asking, `--no-harness` skips the question.
|
|
21
|
+
|
|
22
|
+
By hand, into a project that already has the core, from npm or a local checkout:
|
|
16
23
|
|
|
17
24
|
```bash
|
|
18
25
|
ai-factory extension add gcf-aif-ext # the core, if not yet installed
|
|
@@ -48,6 +55,8 @@ Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry wh
|
|
|
48
55
|
|
|
49
56
|
**`/gcf-run <card-id…>`** — drives a card from the backlog to `built`, with every gate checked *before* the tokens are spent: the audit ritual, collapsed dependencies, the tune limit, the WIP cap, the context budget. It stops at `built` and prints the playtest list — the verdict belongs to the human, and there is no stage past the build.
|
|
50
57
|
|
|
58
|
+
**`ai-factory gcf-harness gate <card-id…>`** — the `/gcf-run` gate as code. It reads one `gcf audit --json` report and exits 1 with a one-line reason when the cards may not go: a `schema:` other than 1, a card that is not `proposed` or `tuning`, an unplayed dependency, the tune limit, no WIP room for the whole batch (`built` count + batch size over `wip_built_limit`), or two cards of the batch touching the same system. The skill runs it and stops on a non-zero exit — no model compares the numbers.
|
|
59
|
+
|
|
51
60
|
**`/gcf-run-ingest <gdd.md>`** — imports a design document as a fan-out workflow: parallel aspect extraction, provenance verified by code rather than by an agent (a quote either is a verbatim substring of its section or it is not), completeness lenses per candidate, and an interaction matrix computed from the candidates that write the same state.
|
|
52
61
|
|
|
53
62
|
**readset-guard** — a `PreToolUse` hook on Read/Grep/Glob. While a run manifest exists at `.gcf-session/readset.json`, a path inside `gcf/` that is not in the readset is denied, and so is a Grep from the project root (no `path`, or a `path` above `gcf/`) unless its `glob` starts with a literal directory other than `gcf` (`src/**`, not `**/*.md`), because such a content search would read the whole tree past the gate. Glob and Greps scoped to the game code are never restricted. No manifest means no restriction: manual work is never gated. An honest limit — `cat` through Bash still bypasses it. This is a discipline tool, not a security perimeter.
|
package/commands/harness.js
CHANGED
|
@@ -1,13 +1,108 @@
|
|
|
1
1
|
// `ai-factory gcf-harness init` — registers the readset-guard PreToolUse hook in the
|
|
2
2
|
// project's .claude/settings.json (the AIF manifest cannot install Claude Code hooks —
|
|
3
3
|
// same decision as gcf hooks-install for git hooks). Idempotent by marker comment.
|
|
4
|
+
// `ai-factory gcf-harness gate <ids…>` — the /gcf-run gate as code (Harness-PRD §4.3).
|
|
4
5
|
import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
5
6
|
import { existsSync } from 'node:fs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
|
|
9
11
|
const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
12
|
const MARKER = 'gcf-readset-guard';
|
|
13
|
+
const SCHEMA = 1; // the only gcf/config.yml schema this harness drives
|
|
14
|
+
const SESSION_RULE = /^(\/|\*\*\/)?\.gcf-session\/?$/;
|
|
15
|
+
|
|
16
|
+
// ─── gate ────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Why one card may not go, or null. */
|
|
19
|
+
function cardProblem(id, card, report) {
|
|
20
|
+
if (!card) return `${id} has no card file — materialize the candidate first`;
|
|
21
|
+
if (!['proposed', 'tuning'].includes(card.status)) {
|
|
22
|
+
return `${id} is ${card.status} — only proposed and tuning cards are driven`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A dependency inside the same batch lands here too: the dependent waits for its verdict.
|
|
26
|
+
const open = report.open_depends.find((o) => o.id === id);
|
|
27
|
+
if (open) return `${id} depends on unplayed ${open.depends.join(', ')} — playtest them first`;
|
|
28
|
+
if (report.tune_limit.some((t) => t.id === id)) {
|
|
29
|
+
return `${id} is at the tune limit — kill or redesign it`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Why the batch as a whole may not go, or null. */
|
|
36
|
+
function batchProblem(cards, wip) {
|
|
37
|
+
// `wip.over` is false at count === limit, and this run adds every card of the batch.
|
|
38
|
+
if (wip.count + cards.length > wip.limit) {
|
|
39
|
+
return (
|
|
40
|
+
`WIP: ${wip.count} built + ${cards.length} in this run exceeds the limit of ${wip.limit}` +
|
|
41
|
+
' — playtest first'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < cards.length; i++) {
|
|
46
|
+
for (const other of cards.slice(i + 1)) {
|
|
47
|
+
const shared = cards[i].system.filter((s) => other.system.includes(s));
|
|
48
|
+
if (shared.length) {
|
|
49
|
+
const pair = `${cards[i].id} and ${other.id}`;
|
|
50
|
+
|
|
51
|
+
return `${pair} both touch ${shared.join(', ')} — run them separately`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const verdict = (reason) => ({ pass: !reason, reason: reason ?? '' });
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decides whether the cards may enter the paid stages, from one `gcf audit --json` report.
|
|
63
|
+
* Pure: the report is the whole input, so every rule has a test and no model compares numbers.
|
|
64
|
+
*/
|
|
65
|
+
export function gate(report, ids) {
|
|
66
|
+
if (!Array.isArray(report?.cards)) {
|
|
67
|
+
return verdict('the audit report has no card summary — gcf-aif-ext >= 0.2.6 is required');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (report.schema !== SCHEMA) {
|
|
71
|
+
const drives = `this harness drives schema ${SCHEMA} only`;
|
|
72
|
+
|
|
73
|
+
return verdict(`gcf/config.yml has schema: ${report.schema}; ${drives}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const byId = new Map(report.cards.map((c) => [c.id, c]));
|
|
77
|
+
for (const id of ids) {
|
|
78
|
+
const problem = cardProblem(id, byId.get(id), report);
|
|
79
|
+
if (problem) return verdict(problem);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return verdict(
|
|
83
|
+
batchProblem(
|
|
84
|
+
ids.map((id) => byId.get(id)),
|
|
85
|
+
report.wip,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `gcf audit` exits 1 on any finding, so the report is read from stdout whatever the code. */
|
|
91
|
+
function auditReport() {
|
|
92
|
+
// Re-enter the same ai-factory CLI that is running this command: no PATH or .cmd lookup.
|
|
93
|
+
const args = [process.argv[1], 'gcf', 'audit', '--json'];
|
|
94
|
+
const res = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(res.stdout);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const output = (res.stderr || res.stdout || '').trim();
|
|
99
|
+
throw new Error(`\`ai-factory gcf audit --json\` produced no report: ${output}`, {
|
|
100
|
+
cause: err,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── init ────────────────────────────────────────────────────────────────────────────
|
|
11
106
|
|
|
12
107
|
/**
|
|
13
108
|
* The harness holds no code of the core: it calls `ai-factory gcf …` and writes gcf/.
|
|
@@ -17,62 +112,111 @@ const MARKER = 'gcf-readset-guard';
|
|
|
17
112
|
*/
|
|
18
113
|
function coreMissing(program) {
|
|
19
114
|
if (!Array.isArray(program?.commands)) return false;
|
|
20
|
-
|
|
115
|
+
const nameOf = (c) => (typeof c?.name === 'function' ? c.name() : c?.name);
|
|
116
|
+
|
|
117
|
+
return !program.commands.some((c) => nameOf(c) === 'gcf');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Refuse on malformed settings rather than rewriting the file from {} — the user's
|
|
122
|
+
* existing hooks are worth more than this one registration.
|
|
123
|
+
*/
|
|
124
|
+
async function readSettings(settingsFile) {
|
|
125
|
+
if (!existsSync(settingsFile)) return {};
|
|
126
|
+
// Outside the try: an unreadable file is its own error, not "invalid JSON".
|
|
127
|
+
const raw = await readFile(settingsFile, 'utf8');
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(raw);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
throw new Error(`${settingsFile} is not valid JSON (${err.message}) — fix it, then re-run`, {
|
|
132
|
+
cause: err,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Adds the guard entry, or repoints an existing one: a moved or cloned project keeps the old
|
|
139
|
+
* absolute path under the marker, and the registered hook would keep launching a missing file.
|
|
140
|
+
* Returns what happened, or null when the settings already hold the right command.
|
|
141
|
+
*/
|
|
142
|
+
function registerGuard(settings, command) {
|
|
143
|
+
settings.hooks ??= {};
|
|
144
|
+
settings.hooks.PreToolUse ??= [];
|
|
145
|
+
const ours = settings.hooks.PreToolUse.flatMap((e) => e?.hooks ?? []).filter(
|
|
146
|
+
(h) => typeof h?.command === 'string' && h.command.includes(MARKER),
|
|
147
|
+
);
|
|
148
|
+
if (!ours.length) {
|
|
149
|
+
settings.hooks.PreToolUse.push({
|
|
150
|
+
matcher: 'Read|Grep|Glob',
|
|
151
|
+
hooks: [{ type: 'command', command }],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return 'registered';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const stale = ours.filter((h) => h.command !== command);
|
|
158
|
+
for (const h of stale) h.command = command;
|
|
159
|
+
|
|
160
|
+
return stale.length ? 'repointed' : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Only an active rule ignoring the root .gcf-session/ counts — not a comment or a pattern
|
|
165
|
+
* for some other directory that happens to contain the name.
|
|
166
|
+
*/
|
|
167
|
+
async function ignoreSession(projectDir) {
|
|
168
|
+
const gitignore = path.join(projectDir, '.gitignore');
|
|
169
|
+
const lines = existsSync(gitignore) ? (await readFile(gitignore, 'utf8')).split('\n') : [];
|
|
170
|
+
if (!lines.some((l) => SESSION_RULE.test(l.trim()))) {
|
|
171
|
+
await appendFile(gitignore, '\n.gcf-session/\n', 'utf8');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function init(program) {
|
|
176
|
+
if (coreMissing(program)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'gcf-aif-ext is not installed: the harness drives `ai-factory gcf …` — ' +
|
|
179
|
+
'run `ai-factory extension add gcf-aif-ext` first',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const projectDir = process.cwd();
|
|
184
|
+
const settingsFile = path.join(projectDir, '.claude', 'settings.json');
|
|
185
|
+
const guard = path.join(EXT_ROOT, 'hooks', 'readset-guard.js').replaceAll('\\', '/');
|
|
186
|
+
const settings = await readSettings(settingsFile);
|
|
187
|
+
const change = registerGuard(settings, `node "${guard}" # ${MARKER}`);
|
|
188
|
+
if (change) {
|
|
189
|
+
await mkdir(path.dirname(settingsFile), { recursive: true });
|
|
190
|
+
await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
191
|
+
console.log(`readset-guard ${change} → ${settingsFile}`);
|
|
192
|
+
} else {
|
|
193
|
+
console.log('readset-guard already registered');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await ignoreSession(projectDir);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function runGate(ids, opts) {
|
|
200
|
+
const result = gate(auditReport(), [...new Set(ids)]);
|
|
201
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
202
|
+
else if (result.pass) console.log('gate: pass');
|
|
203
|
+
if (!result.pass) {
|
|
204
|
+
console.error(`gcf gate: ${result.reason}`);
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
}
|
|
21
207
|
}
|
|
22
208
|
|
|
23
209
|
export function register(program) {
|
|
24
210
|
const harness = program.command('gcf-harness').description('GCF harness setup');
|
|
25
211
|
|
|
26
|
-
harness
|
|
27
|
-
.
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
throw new Error('gcf-aif-ext is not installed: the harness drives `ai-factory gcf …` — run `ai-factory extension add gcf-aif-ext` first');
|
|
31
|
-
}
|
|
32
|
-
const projectDir = process.cwd();
|
|
33
|
-
const settingsFile = path.join(projectDir, '.claude', 'settings.json');
|
|
34
|
-
const guard = path.join(EXT_ROOT, 'hooks', 'readset-guard.js').replaceAll('\\', '/');
|
|
35
|
-
|
|
36
|
-
// Refuse on malformed settings rather than rewriting the file from {} — the user's
|
|
37
|
-
// existing hooks are worth more than this one registration.
|
|
38
|
-
let settings = {};
|
|
39
|
-
if (existsSync(settingsFile)) {
|
|
40
|
-
const raw = await readFile(settingsFile, 'utf8');
|
|
41
|
-
try {
|
|
42
|
-
settings = JSON.parse(raw);
|
|
43
|
-
} catch (err) {
|
|
44
|
-
throw new Error(`${settingsFile} is not valid JSON (${err.message}) — fix it, then re-run`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
settings.hooks ??= {};
|
|
48
|
-
settings.hooks.PreToolUse ??= [];
|
|
49
|
-
const command = `node "${guard}" # ${MARKER}`;
|
|
50
|
-
// A moved or cloned project keeps the old absolute path under the marker: repoint it,
|
|
51
|
-
// or the registered hook keeps launching a file that is gone.
|
|
52
|
-
const ours = settings.hooks.PreToolUse.flatMap((e) => e?.hooks ?? [])
|
|
53
|
-
.filter((h) => typeof h?.command === 'string' && h.command.includes(MARKER));
|
|
54
|
-
const stale = ours.filter((h) => h.command !== command);
|
|
55
|
-
if (!ours.length) {
|
|
56
|
-
settings.hooks.PreToolUse.push({
|
|
57
|
-
matcher: 'Read|Grep|Glob',
|
|
58
|
-
hooks: [{ type: 'command', command }],
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
for (const h of stale) h.command = command;
|
|
62
|
-
if (!ours.length || stale.length) {
|
|
63
|
-
await mkdir(path.dirname(settingsFile), { recursive: true });
|
|
64
|
-
await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
65
|
-
console.log(`readset-guard ${ours.length ? 'repointed' : 'registered'} → ${settingsFile}`);
|
|
66
|
-
} else {
|
|
67
|
-
console.log('readset-guard already registered');
|
|
68
|
-
}
|
|
212
|
+
harness
|
|
213
|
+
.command('init')
|
|
214
|
+
.description('Initialize the harness in this project: readset-guard hook (idempotent)')
|
|
215
|
+
.action(() => init(program));
|
|
69
216
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
.some((l) => /^(\/|\*\*\/)?\.gcf-session\/?$/.test(l.trim()));
|
|
76
|
-
if (!has) await appendFile(gitignore, `\n${line}\n`, 'utf8');
|
|
77
|
-
});
|
|
217
|
+
harness
|
|
218
|
+
.command('gate <ids...>')
|
|
219
|
+
.description('Gate cards before the paid stages of /gcf-run: exit 1 with the reason')
|
|
220
|
+
.option('--json', 'machine output: {pass, reason}')
|
|
221
|
+
.action(runGate);
|
|
78
222
|
}
|
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-harness-aif-ext",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.4",
|
|
5
5
|
"description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills/gcf-run",
|
package/hooks/readset-guard.js
CHANGED
|
@@ -24,10 +24,59 @@ function readStdin() {
|
|
|
24
24
|
* Only a literal first segment that is not gcfDir proves the search cannot get there.
|
|
25
25
|
*/
|
|
26
26
|
function globAvoids(glob, gcfDir) {
|
|
27
|
-
const parts = (glob ?? '')
|
|
27
|
+
const parts = (glob ?? '')
|
|
28
|
+
.replaceAll('\\', '/')
|
|
29
|
+
.replace(/^\.?\//, '')
|
|
30
|
+
.split('/');
|
|
31
|
+
|
|
28
32
|
return parts.length > 1 && !/[*?[\]{}!]/.test(parts[0]) && parts[0] !== gcfDir;
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
const UNSCOPED_GREP =
|
|
36
|
+
'an unscoped Grep reads the contents of all of gcf/ — ' +
|
|
37
|
+
'pass `path:` or a `glob:` anchored outside gcf/ (e.g. `src/**`).';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A path outside gcf/: only a CONTENT search from a directory above gcf/ still reaches it.
|
|
41
|
+
* Glob returns paths, and gcf/ paths are already public in INDEX.md.
|
|
42
|
+
*/
|
|
43
|
+
function outsideVerdict(input, abs, gcfRoot) {
|
|
44
|
+
const toGcf = path.relative(abs, gcfRoot).replaceAll('\\', '/');
|
|
45
|
+
const above = !toGcf.startsWith('..') && !path.isAbsolute(toGcf);
|
|
46
|
+
if (input.tool_name !== 'Grep' || !above) return { allow: true };
|
|
47
|
+
if (globAvoids(input.tool_input?.glob, toGcf.split('/')[0])) return { allow: true };
|
|
48
|
+
|
|
49
|
+
return { allow: false, reason: UNSCOPED_GREP };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The paths a manifest allows, plus the two every stage may always read. */
|
|
53
|
+
function allowedPaths(manifest) {
|
|
54
|
+
// `gcf context --json` emits files as {path, tokens_est}; accept that shape as well as
|
|
55
|
+
// the projected string list, so a manifest written either way still gates correctly.
|
|
56
|
+
const files = (manifest.files ?? []).map((f) => (typeof f === 'string' ? f : f?.path));
|
|
57
|
+
|
|
58
|
+
return new Set([...files.filter(Boolean), 'gcf/INDEX.md', 'gcf']);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A path inside gcf/ passes only through the readset. */
|
|
62
|
+
function insideVerdict(rel, manifest) {
|
|
63
|
+
const allowed = allowedPaths(manifest);
|
|
64
|
+
if (allowed.has(rel)) return { allow: true };
|
|
65
|
+
// ponytail: a directory path passes when any allowed file lives under it, so Grep over
|
|
66
|
+
// gcf/systems can still see sibling files' content; per-file grep scoping if it leaks.
|
|
67
|
+
if ([...allowed].some((f) => f.startsWith(`${rel}/`))) return { allow: true };
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
allow: false,
|
|
71
|
+
// No "delete the manifest" hint: that is the human's lever for lifting the guard, and an
|
|
72
|
+
// agent told about it on every denial will reach for it and turn a leak into a bypass.
|
|
73
|
+
reason:
|
|
74
|
+
`${rel} is outside the current readset (.gcf-session/readset.json). ` +
|
|
75
|
+
'Extend it via `ai-factory gcf context <card>`, ' +
|
|
76
|
+
'or work from the files already in the readset.',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
31
80
|
export function decide(input, projectDir, manifest) {
|
|
32
81
|
// No manifest, or no input to judge at all: never restrict. A hook that hard-blocks every
|
|
33
82
|
// read because stdin hiccuped is worse than the leak it guards.
|
|
@@ -39,55 +88,33 @@ export function decide(input, projectDir, manifest) {
|
|
|
39
88
|
const gcfRoot = path.join(path.resolve(projectDir), 'gcf');
|
|
40
89
|
const relFromGcf = path.relative(gcfRoot, abs);
|
|
41
90
|
if (relFromGcf.startsWith('..') || path.isAbsolute(relFromGcf)) {
|
|
42
|
-
|
|
43
|
-
// returns paths, and gcf/ paths are already public in INDEX.md.
|
|
44
|
-
const toGcf = path.relative(abs, gcfRoot).replaceAll('\\', '/');
|
|
45
|
-
const above = !toGcf.startsWith('..') && !path.isAbsolute(toGcf);
|
|
46
|
-
if (input.tool_name !== 'Grep' || !above || globAvoids(input.tool_input?.glob, toGcf.split('/')[0])) return { allow: true };
|
|
47
|
-
return {
|
|
48
|
-
allow: false,
|
|
49
|
-
reason: 'an unscoped Grep reads the contents of all of gcf/ — pass `path:` or a `glob:` anchored outside gcf/ (e.g. `src/**`).',
|
|
50
|
-
};
|
|
91
|
+
return outsideVerdict(input, abs, gcfRoot);
|
|
51
92
|
}
|
|
52
93
|
|
|
53
94
|
// The gcf root itself relativizes to '' — unguarded it becomes 'gcf/' and matches nothing.
|
|
54
95
|
const relPosix = relFromGcf.replaceAll('\\', '/');
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
'gcf',
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// ponytail: a directory path passes when any allowed file lives under it, so Grep over
|
|
65
|
-
// gcf/systems can still see sibling files' content; per-file grep scoping if it leaks.
|
|
66
|
-
for (const f of allowed) {
|
|
67
|
-
if (f.startsWith(`${rel}/`)) return { allow: true };
|
|
96
|
+
|
|
97
|
+
return insideVerdict(relPosix ? `gcf/${relPosix}` : 'gcf', manifest);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readManifest(projectDir) {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(readFileSync(path.join(projectDir, '.gcf-session', 'readset.json'), 'utf8'));
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
68
105
|
}
|
|
69
|
-
return {
|
|
70
|
-
allow: false,
|
|
71
|
-
// No "delete the manifest" hint: that is the human's lever for lifting the guard, and an
|
|
72
|
-
// agent told about it on every denial will reach for it and turn a leak into a bypass.
|
|
73
|
-
reason: `${rel} is outside the current readset (.gcf-session/readset.json). Extend it via \`ai-factory gcf context <card>\`, or work from the files already in the readset.`,
|
|
74
|
-
};
|
|
75
106
|
}
|
|
76
107
|
|
|
77
108
|
// Hook entry: only runs when executed directly, so tests can import decide().
|
|
78
|
-
|
|
109
|
+
const entry = process.argv[1]?.replaceAll('\\', '/');
|
|
110
|
+
if (import.meta.url === `file://${entry}` || entry?.endsWith('readset-guard.js')) {
|
|
79
111
|
const input = readStdin();
|
|
80
112
|
const projectDir = input?.cwd ?? process.cwd();
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
manifest = JSON.parse(readFileSync(path.join(projectDir, '.gcf-session', 'readset.json'), 'utf8'));
|
|
84
|
-
} catch {
|
|
85
|
-
manifest = null;
|
|
86
|
-
}
|
|
87
|
-
const verdict = decide(input, projectDir, manifest);
|
|
113
|
+
const verdict = decide(input, projectDir, readManifest(projectDir));
|
|
88
114
|
if (!verdict.allow) {
|
|
89
115
|
console.error(`gcf readset-guard: ${verdict.reason}`);
|
|
90
116
|
process.exit(2);
|
|
91
117
|
}
|
|
118
|
+
|
|
92
119
|
process.exit(0);
|
|
93
120
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gcf-harness-aif-ext",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"prepack": "node -e \"const p=require('./package.json'),e=require('./extension.json');if(p.version!==e.version)throw new Error('version mismatch: package.json '+p.version+' vs extension.json '+e.version)\""
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"gcf-aif-ext": "^0.2.
|
|
31
|
+
"gcf-aif-ext": "^0.2.7"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"extension.json",
|
package/skills/gcf-run/SKILL.md
CHANGED
|
@@ -13,18 +13,17 @@ inputs:
|
|
|
13
13
|
|
|
14
14
|
preconditions:
|
|
15
15
|
- `gcf/` exists and `ai-factory gcf audit` runs (the gcf-aif-ext extension provides it)
|
|
16
|
-
- `schema:` in `gcf/config.yml` is 1, the only schema this harness drives: any other value refuses the run at start
|
|
17
16
|
|
|
18
17
|
forbidden_behaviors:
|
|
19
18
|
- do not go past `built`: the verdict belongs to the human, this pipeline has no stage after the build
|
|
20
19
|
- do not read a `gcf/` file outside the readset from `ai-factory gcf context`
|
|
21
20
|
- do not continue past a failed gate: report the one-line reason, delete `.gcf-session/readset.json`, and stop - a stop is a normal outcome
|
|
22
|
-
- do not
|
|
21
|
+
- do not compare the audit numbers yourself: the gate command is the only judge of whether a card may go
|
|
23
22
|
|
|
24
23
|
workflow:
|
|
25
24
|
1. session ritual: delete a `.gcf-session/readset.json` left by a killed run; run `ai-factory gcf audit --json`; read `gcf/INDEX.md`; stale built cards with live dependents mean stop - print the playtest list instead of building
|
|
26
25
|
2. for a cand-NNN input: materialize it with the gcf-card skill; a hard route leaves the pipeline for the normal AIF cycle here
|
|
27
|
-
3. gate
|
|
26
|
+
3. gate: run `ai-factory gcf-harness gate <card-ids...>` over the whole batch; a non-zero exit is a failed gate, its stderr line is the reason
|
|
28
27
|
4. readset: run `ai-factory gcf context <card-id> --json`; write `{"card":"<id>","files": ctx.files.map(f => f.path)}` to `.gcf-session/readset.json` - paths, not objects; `over_budget: true` stops the run with a D1 question naming the subsystem
|
|
29
28
|
5. plan with aif-plan, shaped by the card slots, reading only the readset files
|
|
30
29
|
6. implement with aif-implement, then verify: autotests plus the card's negative constraints; retries <= 2 with a diagnosis each, then stop
|
|
@@ -22,13 +22,15 @@ forbidden_behaviors:
|
|
|
22
22
|
|
|
23
23
|
workflow:
|
|
24
24
|
1. run `ai-factory gcf anchor <file> --json` and keep the sections list
|
|
25
|
-
2. on a re-import the output carries `delta`: keep only the sections whose anchor is in `delta.added`, skip the workflow when none are left, and apply the other delta classes as step
|
|
25
|
+
2. on a re-import the output carries `delta`: keep only the sections whose anchor is in `delta.added`, skip the workflow when none are left, and apply the other delta classes as step 11 of the gcf-ingest skill does
|
|
26
26
|
3. read `envelope_default_spread` from `gcf/config.yml`
|
|
27
27
|
4. read the highest NNN across `gcf/backlog.md` and `gcf/cards/`; invoke the Workflow tool with scriptPath `.ai-factory/extensions/gcf-harness-aif-ext/workflows/ingest.js` and args `{sections, spread, start_num}`, start_num = NNN + 1
|
|
28
|
-
5. write the results: candidate lines appended to `gcf/backlog.md` keeping the ids
|
|
29
|
-
6.
|
|
30
|
-
7.
|
|
31
|
-
8.
|
|
28
|
+
5. write the results: candidate lines appended to `gcf/backlog.md` keeping the minted ids; fold each `exact` number into the description of the line citing its anchor
|
|
29
|
+
6. append the D1 and D2 records to `gcf/open-questions.md` in the record form the gcf-ingest skill states under outputs, a D2 with its `assumed:` and `until:`
|
|
30
|
+
7. run `ai-factory gcf quotes` and move each flagged line to a D1 with lens `provenance`, then run `ai-factory gcf index`
|
|
31
|
+
8. mark a candidate whose nouns match the `purpose` or the `code` paths of an existing systems doc with `impl?`; with `gcf/systems/` empty on a codebase, report the marks as blind
|
|
32
|
+
9. report the package in the GCF order: D1 questions first, pillar candidates, backlog summary with the top crossing candidates, D2 envelope assumptions
|
|
33
|
+
10. verify equivalence on the first real GDD: compare this package against a sequential `/gcf-ingest` pass and report any divergence as a harness bug
|
|
32
34
|
|
|
33
35
|
invocation:
|
|
34
36
|
- run when the user asks for the workflow import on a GDD
|
package/workflows/ingest.js
CHANGED
|
@@ -1,42 +1,22 @@
|
|
|
1
1
|
export const meta = {
|
|
2
2
|
name: 'gcf-ingest',
|
|
3
|
-
description:
|
|
3
|
+
description:
|
|
4
|
+
'Fan-out GDD import: parallel aspect extraction, code-verified provenance, lenses, one package',
|
|
4
5
|
phases: [
|
|
5
6
|
{ title: 'Extract', detail: 'one agent per aspect per segment of the anchored sections' },
|
|
6
7
|
{ title: 'Lenses', detail: 'completeness per candidate, coherence over the whole set' },
|
|
7
8
|
{ title: 'Conflicts', detail: 'pairwise checks where the interaction matrix crosses' },
|
|
8
9
|
],
|
|
9
|
-
}
|
|
10
|
-
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// args: { sections: [{anchor, heading, body}], spread, start_num, segment_chars? }
|
|
11
13
|
// The main thread runs `ai-factory gcf anchor --json` BEFORE this workflow and writes
|
|
12
14
|
// the outputs AFTER it — the script has no filesystem, by design (Harness-PRD §6.1).
|
|
15
|
+
// Layout: schemas, then pure helpers, then the three phases, then package assembly.
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
const spread = args.spread ?? 0.3
|
|
16
|
-
// Candidate ids continue the existing backlog numbering: minting from the array index
|
|
17
|
-
// makes every re-ingest emit cand-001 again, colliding with what is already filed.
|
|
18
|
-
const startNum = Number(args.start_num) || 1 // args come from a model-composed call: "5" would concatenate
|
|
19
|
-
// GCF-PRD §11.7: a large GDD is cut into segments, each agent sees one. A section is never
|
|
20
|
-
// split — quotes are verified against whole sections. ponytail: chars, not tokens.
|
|
21
|
-
const segmentChars = Number(args.segment_chars) || 60000
|
|
22
|
-
const sectionText = (s) => `[${s.anchor}] ${s.heading ?? '(preamble)'}\n${s.body}`
|
|
23
|
-
const segments = []
|
|
24
|
-
for (const s of sections) {
|
|
25
|
-
const last = segments[segments.length - 1]
|
|
26
|
-
const text = sectionText(s)
|
|
27
|
-
if (last && last.size + text.length <= segmentChars) {
|
|
28
|
-
last.texts.push(text)
|
|
29
|
-
last.size += text.length
|
|
30
|
-
} else segments.push({ texts: [text], size: text.length })
|
|
31
|
-
}
|
|
17
|
+
// ─── Schemas: what each agent must return ────────────────────────────────────────────
|
|
32
18
|
|
|
33
|
-
|
|
34
|
-
// has nothing here", and a re-import skips unchanged sections, so the loss would stick.
|
|
35
|
-
const ask = async (prompt, opts) => {
|
|
36
|
-
const r = await agent(prompt, opts)
|
|
37
|
-
if (!r) throw new Error(`${opts.label} returned no result — import stopped, nothing to write; re-run the workflow`)
|
|
38
|
-
return r
|
|
39
|
-
}
|
|
19
|
+
const strings = (description) => ({ type: 'array', items: { type: 'string' }, description });
|
|
40
20
|
|
|
41
21
|
const UNITS = {
|
|
42
22
|
type: 'object',
|
|
@@ -50,79 +30,37 @@ const UNITS = {
|
|
|
50
30
|
anchor: { type: 'string' },
|
|
51
31
|
quote: { type: 'string', description: 'verbatim substring of the source section' },
|
|
52
32
|
detail: { type: 'string' },
|
|
33
|
+
kind: {
|
|
34
|
+
enum: ['tunable', 'count', 'limit', 'threshold'],
|
|
35
|
+
description:
|
|
36
|
+
'numbers only: tunable = a feel value to balance; count = how many of a thing; ' +
|
|
37
|
+
'limit = a hard bound; threshold = a performance target',
|
|
38
|
+
},
|
|
53
39
|
},
|
|
54
40
|
required: ['name', 'anchor', 'quote'],
|
|
55
41
|
},
|
|
56
42
|
},
|
|
57
43
|
},
|
|
58
44
|
required: ['units'],
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const ASPECTS = [
|
|
62
|
-
{ key: 'pillars', prompt: 'pillar candidates: statements about what the game IS and is NOT' },
|
|
63
|
-
{ key: 'subsystems', prompt: 'subsystems: the nouns — movement, combat, economy; lowercase slug in name' },
|
|
64
|
-
{ key: 'features', prompt: 'feature candidates: the player verbs — things the player does; one unit per mechanic' },
|
|
65
|
-
{ key: 'numbers', prompt: 'exact numbers and progressions: durations, costs, curves; detail carries the value and unit' },
|
|
66
|
-
{ key: 'constraints', prompt: 'platform, audience, monetization and scope constraints' },
|
|
67
|
-
]
|
|
45
|
+
};
|
|
68
46
|
|
|
69
|
-
phase('Extract')
|
|
70
|
-
const byAnchor = new Map(sections.map((s) => [s.anchor, s.body]))
|
|
71
|
-
const extracted = await pipeline(
|
|
72
|
-
ASPECTS.flatMap((a) => segments.map((seg, i) => ({ a, seg, i }))),
|
|
73
|
-
({ a, seg, i }) => ask(
|
|
74
|
-
`Extract ${a.prompt} from this GDD${segments.length > 1 ? ` (part ${i + 1} of ${segments.length})` : ''}. Every unit MUST carry a verbatim quote copied exactly from its section and the section anchor.\n\n${seg.texts.join('\n\n')}`,
|
|
75
|
-
{ label: `extract:${a.key}${segments.length > 1 ? `:${i + 1}` : ''}`, phase: 'Extract', schema: UNITS },
|
|
76
|
-
).then((r) => ({ aspect: a.key, units: r.units ?? [] })),
|
|
77
|
-
// Provenance is CODE, not an agent: a quote either is a substring of its section or it is not.
|
|
78
|
-
(r) => {
|
|
79
|
-
const cited = []
|
|
80
|
-
const inferred = []
|
|
81
|
-
for (const u of r.units) {
|
|
82
|
-
const body = byAnchor.get(u.anchor) ?? ''
|
|
83
|
-
if (u.quote && body.includes(u.quote)) cited.push(u)
|
|
84
|
-
else inferred.push({ ...u, aspect: r.aspect })
|
|
85
|
-
}
|
|
86
|
-
return { aspect: r.aspect, cited, inferred }
|
|
87
|
-
},
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
const byAspect = {}
|
|
91
|
-
for (const r of extracted) byAspect[r.aspect] = [...(byAspect[r.aspect] ?? []), ...r.cited]
|
|
92
|
-
const inferred = extracted.flatMap((r) => r.inferred)
|
|
93
|
-
const candidates = byAspect.features ?? []
|
|
94
|
-
const subsystems = byAspect.subsystems ?? []
|
|
95
|
-
log(`${candidates.length} candidates over ${segments.length} segment(s), ${inferred.length} inferred units diverted to questions`)
|
|
96
|
-
|
|
97
|
-
// Numbers → envelopes: value ± spread, assumed. Deterministic transformation. The sign and a
|
|
98
|
-
// compound unit (m/s2) are part of the value; a hyphen glued to a word (level-5) is not a minus.
|
|
99
|
-
const envelopes = (byAspect.numbers ?? []).map((u) => {
|
|
100
|
-
const m = (u.detail ?? u.quote).match(/((?<![\p{L}\p{N}])[-−])?(\d+(?:[.,]\d+)?)\s*([\p{L}%°][\p{L}\p{N}%°/^²³·]*)?/u)
|
|
101
|
-
if (!m) return { ...u, envelope: null }
|
|
102
|
-
const v = (m[1] ? -1 : 1) * parseFloat(m[2].replace(',', '.'))
|
|
103
|
-
const a = +(v * (1 - spread)).toFixed(3)
|
|
104
|
-
const b = +(v * (1 + spread)).toFixed(3)
|
|
105
|
-
return { ...u, envelope: `${Math.min(a, b)}..${Math.max(a, b)} ${m[3] ?? ''}`.trim(), assumed: true }
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
phase('Lenses')
|
|
109
47
|
const CAND_LENS = {
|
|
110
48
|
type: 'object',
|
|
111
49
|
properties: {
|
|
112
|
-
gaps:
|
|
113
|
-
systems:
|
|
114
|
-
state_written:
|
|
50
|
+
gaps: strings('missing answers: state, input, feedback, refusal, boundaries, persistence'),
|
|
51
|
+
systems: strings('subsystem slugs this candidate touches'),
|
|
52
|
+
state_written: strings('named state this mechanic writes'),
|
|
53
|
+
route: {
|
|
54
|
+
enum: ['soft', 'hard'],
|
|
55
|
+
description:
|
|
56
|
+
'hard when an agent can verify acceptance alone (data format, protocol, ' +
|
|
57
|
+
'structural invariant, numeric performance threshold); ' +
|
|
58
|
+
'soft when acceptance is a playtest; doubt → soft',
|
|
59
|
+
},
|
|
60
|
+
route_why: { type: 'string', description: 'the reason for the route, one short clause' },
|
|
115
61
|
},
|
|
116
|
-
required: ['gaps', 'systems', 'state_written'],
|
|
117
|
-
}
|
|
118
|
-
const enriched = await pipeline(
|
|
119
|
-
candidates,
|
|
120
|
-
(c) => ask(
|
|
121
|
-
`Completeness lenses over one feature candidate. Candidate: ${c.name} (${c.anchor}: "${c.quote}").\nKnown subsystems: ${subsystems.map((s) => s.name).join(', ') || 'none yet'}.\nSection:\n${byAnchor.get(c.anchor) ?? ''}`,
|
|
122
|
-
{ label: `lens:${c.name}`, phase: 'Lenses', schema: CAND_LENS },
|
|
123
|
-
).then((r) => ({ ...c, ...r })),
|
|
124
|
-
)
|
|
125
|
-
const idOf = new Map(enriched.map((c, i) => [c, `cand-${String(startNum + i).padStart(3, '0')}`]))
|
|
62
|
+
required: ['gaps', 'systems', 'state_written', 'route', 'route_why'],
|
|
63
|
+
};
|
|
126
64
|
|
|
127
65
|
const FINDING = {
|
|
128
66
|
type: 'object',
|
|
@@ -134,70 +72,361 @@ const FINDING = {
|
|
|
134
72
|
proposed: { type: 'string' },
|
|
135
73
|
},
|
|
136
74
|
required: ['lens', 'anchors', 'class', 'question', 'proposed'],
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// Barrier is correct here: coherence lenses need the WHOLE candidate set at once.
|
|
148
|
-
const coherence = (await parallel(COHERENCE.map((lens) => () => ask(
|
|
149
|
-
`Coherence lens over the whole GDD: ${lens}.\nCandidates: ${JSON.stringify(enriched.map((c) => ({ name: c.name, anchor: c.anchor, state_written: c.state_written })))}\n\n${wholeDoc}`,
|
|
150
|
-
{ label: `lens:${lens.split(':')[0]}`, phase: 'Lenses', schema: FINDINGS },
|
|
151
|
-
)))).flatMap((r) => r.findings)
|
|
152
|
-
|
|
153
|
-
phase('Conflicts')
|
|
154
|
-
// Interaction matrix from code: pairs writing the same named state.
|
|
155
|
-
const pairs = []
|
|
156
|
-
for (let i = 0; i < enriched.length; i++) {
|
|
157
|
-
for (let j = i + 1; j < enriched.length; j++) {
|
|
158
|
-
const shared = enriched[i].state_written.filter((s) => enriched[j].state_written.includes(s))
|
|
159
|
-
if (shared.length) pairs.push({ a: enriched[i], b: enriched[j], shared })
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
log(`${pairs.length} crossing pair(s) from the interaction matrix`)
|
|
163
|
-
// `deps` is the matrix's by-product (GCF-PRD §11.4): the pair check also says which mechanic
|
|
164
|
-
// has to be played first.
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const FINDINGS = {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: { findings: { type: 'array', items: FINDING } },
|
|
80
|
+
required: ['findings'],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// `deps` is the matrix's by-product (GCF-PRD §11.4): the pair check also says which
|
|
84
|
+
// mechanic has to be played first.
|
|
165
85
|
const CONFLICT = {
|
|
166
86
|
type: 'object',
|
|
167
87
|
properties: {
|
|
168
88
|
findings: FINDINGS.properties.findings,
|
|
169
|
-
order: {
|
|
89
|
+
order: {
|
|
90
|
+
enum: ['a_first', 'b_first', 'none'],
|
|
91
|
+
description: 'a_first: B cannot be built or played without A already in the game',
|
|
92
|
+
},
|
|
170
93
|
},
|
|
171
94
|
required: ['findings', 'order'],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const ASPECTS = [
|
|
98
|
+
{ key: 'pillars', prompt: 'pillar candidates: statements about what the game IS and is NOT' },
|
|
99
|
+
{
|
|
100
|
+
key: 'subsystems',
|
|
101
|
+
prompt: 'subsystems: the nouns — movement, combat, economy; lowercase slug in name',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: 'features',
|
|
105
|
+
prompt: 'feature candidates: the player verbs — things the player does; one unit per mechanic',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'numbers',
|
|
109
|
+
prompt:
|
|
110
|
+
'exact numbers and progressions: durations, costs, curves; detail carries the value and ' +
|
|
111
|
+
'unit, kind says whether it is tunable, a count, a hard limit or a performance threshold',
|
|
112
|
+
},
|
|
113
|
+
{ key: 'constraints', prompt: 'platform, audience, monetization and scope constraints' },
|
|
114
|
+
{
|
|
115
|
+
key: 'questions',
|
|
116
|
+
prompt:
|
|
117
|
+
'questions the document itself leaves open: its open-questions section, TBD, TODO; ' +
|
|
118
|
+
'name carries the question, detail the answer the text leans to, if any',
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const COHERENCE = [
|
|
123
|
+
'loop closure: every produced resource has a sink and every sink a source',
|
|
124
|
+
'progression pressure: what moves the player forward and brings them back',
|
|
125
|
+
'economy balance: do the numbers converge over time',
|
|
126
|
+
'dictionary: exactly one word per concept across the document',
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
// ─── Helpers: pure functions, no agents ──────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const sectionText = (s) => `[${s.anchor}] ${s.heading ?? '(preamble)'}\n${s.body}`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* GCF-PRD §11.7: a large GDD is cut into segments, each agent sees one. A section is never
|
|
135
|
+
* split — quotes are verified against whole sections. ponytail: chars, not tokens.
|
|
136
|
+
*/
|
|
137
|
+
function packSegments(sections, limit) {
|
|
138
|
+
const segments = [];
|
|
139
|
+
for (const s of sections) {
|
|
140
|
+
const last = segments[segments.length - 1];
|
|
141
|
+
const text = sectionText(s);
|
|
142
|
+
if (last && last.size + text.length <= limit) {
|
|
143
|
+
last.texts.push(text);
|
|
144
|
+
last.size += text.length;
|
|
145
|
+
} else {
|
|
146
|
+
segments.push({ texts: [text], size: text.length });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return segments;
|
|
172
151
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
const
|
|
152
|
+
|
|
153
|
+
// A line wrap in the GDD is not an edit: whitespace runs compare equal, as in `gcf quotes`.
|
|
154
|
+
const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
|
|
155
|
+
|
|
156
|
+
/** Provenance is CODE, not an agent: a quote either is a substring of its section or not. */
|
|
157
|
+
function splitByProvenance({ aspect, units }, bodyOf) {
|
|
158
|
+
const cited = [];
|
|
159
|
+
const inferred = [];
|
|
160
|
+
for (const u of units) {
|
|
161
|
+
const body = ws(bodyOf.get(u.anchor) ?? '');
|
|
162
|
+
if (u.quote && body.includes(ws(u.quote))) cited.push(u);
|
|
163
|
+
else inferred.push({ ...u, aspect });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { aspect, cited, inferred };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The sign and a compound unit (m/s2) are part of the value; a hyphen glued to a word
|
|
170
|
+
// (level-5) is not a minus.
|
|
171
|
+
const NUMBER = /((?<![\p{L}\p{N}])[-−])?(\d+(?:[.,]\d+)?)\s*([\p{L}%°][\p{L}\p{N}%°/^²³·]*)?/u;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Number → envelope: value ± spread, assumed. Deterministic transformation. Only a tunable
|
|
175
|
+
* value gets one: a count (50 levels), a hard limit or a performance threshold stays exact.
|
|
176
|
+
* An integer keeps integer bounds, widened outward — 2 hp is 1..3, never 1.4..2.6.
|
|
177
|
+
*/
|
|
178
|
+
function toEnvelope(unit, spread) {
|
|
179
|
+
const m = (unit.detail ?? unit.quote).match(NUMBER);
|
|
180
|
+
if (!m || (unit.kind && unit.kind !== 'tunable')) return { ...unit, envelope: null };
|
|
181
|
+
const value = (m[1] ? -1 : 1) * parseFloat(m[2].replace(',', '.'));
|
|
182
|
+
const scaled = [value * (1 - spread), value * (1 + spread)].map((x) => +x.toFixed(3));
|
|
183
|
+
let lo = Math.min(...scaled);
|
|
184
|
+
let hi = Math.max(...scaled);
|
|
185
|
+
if (/^\d+$/.test(m[2])) {
|
|
186
|
+
lo = Math.min(Math.floor(lo), value - 1);
|
|
187
|
+
hi = Math.max(Math.ceil(hi), value + 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ...unit, envelope: `${lo}..${hi} ${m[3] ?? ''}`.trim(), assumed: true };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Interaction matrix from code: pairs of candidates writing the same named state. */
|
|
194
|
+
function crossingPairs(cands) {
|
|
195
|
+
const pairs = [];
|
|
196
|
+
for (let i = 0; i < cands.length; i++) {
|
|
197
|
+
for (let j = i + 1; j < cands.length; j++) {
|
|
198
|
+
const shared = cands[i].state_written.filter((s) => cands[j].state_written.includes(s));
|
|
199
|
+
if (shared.length) pairs.push({ a: cands[i], b: cands[j], shared });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return pairs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The backlog line grammar `gcf index` parses (GCF-PRD §5.2). */
|
|
207
|
+
function candidateLine(c, deps) {
|
|
208
|
+
const fields = [
|
|
209
|
+
`- ${c.id}`,
|
|
210
|
+
c.name,
|
|
211
|
+
`route: ${c.route === 'hard' ? 'hard' : 'soft'} (${c.route_why || 'acceptance is a playtest'})`,
|
|
212
|
+
`sys: ${c.systems[0] ?? 'unassigned'}`,
|
|
213
|
+
`deps: ${deps.join(', ') || '-'}`,
|
|
214
|
+
`src: ${c.anchor}`,
|
|
215
|
+
`q: "${c.quote}"`,
|
|
216
|
+
];
|
|
217
|
+
if (c.gaps.length) fields.push(`gaps: ${c.gaps.join(', ')}`);
|
|
218
|
+
fields.push(c.detail || c.name);
|
|
219
|
+
|
|
220
|
+
return fields.join(' | ');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Prompts ─────────────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
function extractPrompt(aspect, segment, part, parts) {
|
|
226
|
+
return [
|
|
227
|
+
`Extract ${aspect.prompt} from this GDD${parts > 1 ? ` (part ${part} of ${parts})` : ''}.`,
|
|
228
|
+
'Every unit MUST carry a verbatim quote copied exactly from its section',
|
|
229
|
+
'and the section anchor.',
|
|
230
|
+
'',
|
|
231
|
+
segment.texts.join('\n\n'),
|
|
232
|
+
].join('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function lensPrompt(c, subsystemNames, sectionBody) {
|
|
236
|
+
return [
|
|
237
|
+
'Completeness lenses over one feature candidate, and its route with the reason.',
|
|
238
|
+
`Candidate: ${c.name} (${c.anchor}: "${c.quote}").`,
|
|
239
|
+
`Known subsystems: ${subsystemNames.join(', ') || 'none yet'}.`,
|
|
240
|
+
'Section:',
|
|
241
|
+
sectionBody,
|
|
242
|
+
].join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function coherencePrompt(lens, cands, document) {
|
|
246
|
+
const summary = cands.map((c) => ({
|
|
247
|
+
name: c.name,
|
|
248
|
+
anchor: c.anchor,
|
|
249
|
+
state_written: c.state_written,
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
return [
|
|
253
|
+
`Coherence lens over the whole GDD: ${lens}.`,
|
|
254
|
+
`Candidates: ${JSON.stringify(summary)}`,
|
|
255
|
+
'',
|
|
256
|
+
document,
|
|
257
|
+
].join('\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function conflictPrompt({ a, b, shared }, sectionTexts) {
|
|
261
|
+
return [
|
|
262
|
+
`Two mechanics write the same state (${shared.join(', ')}):`,
|
|
263
|
+
`A "${a.name}" (${a.anchor}) and B "${b.name}" (${b.anchor}).`,
|
|
264
|
+
'Does the document define their interaction? If not, report the gap as a D1 finding.',
|
|
265
|
+
'Also say whether one has to exist before the other.',
|
|
266
|
+
'',
|
|
267
|
+
sectionTexts.join('\n\n'),
|
|
268
|
+
].join('\n');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A segmented GDD does not fit one agent: the whole-set lenses read the cited units instead. */
|
|
272
|
+
function citedDigest(byAspect) {
|
|
273
|
+
const digest = Object.fromEntries(
|
|
274
|
+
Object.entries(byAspect).map(([aspect, units]) => [
|
|
275
|
+
aspect,
|
|
276
|
+
units.map(({ name, anchor, quote, detail }) => ({ name, anchor, quote, detail })),
|
|
277
|
+
]),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
return `Cited units (the GDD is too large to attach):\n${JSON.stringify(digest)}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A dead agent must stop the import, not shrink it: an empty extraction reads as "the GDD
|
|
284
|
+
// has nothing here", and a re-import skips unchanged sections, so the loss would stick.
|
|
285
|
+
async function ask(prompt, opts) {
|
|
286
|
+
const result = await agent(prompt, opts);
|
|
287
|
+
if (!result) {
|
|
288
|
+
throw new Error(`${opts.label} returned no result — import stopped, nothing to write`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Run ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
const sections = args.sections;
|
|
297
|
+
const spread = args.spread ?? 0.3;
|
|
298
|
+
// Candidate ids continue the existing backlog numbering: minting from the array index
|
|
299
|
+
// makes every re-ingest emit cand-001 again. Number(): args come from a model-composed
|
|
300
|
+
// call, and "5" would concatenate.
|
|
301
|
+
const startNum = Number(args.start_num) || 1;
|
|
302
|
+
const segments = packSegments(sections, Number(args.segment_chars) || 60000);
|
|
303
|
+
const bodyOf = new Map(sections.map((s) => [s.anchor, s.body]));
|
|
304
|
+
const multi = segments.length > 1;
|
|
305
|
+
|
|
306
|
+
phase('Extract');
|
|
307
|
+
const jobs = ASPECTS.flatMap((aspect) => segments.map((segment, i) => ({ aspect, segment, i })));
|
|
308
|
+
const extracted = await pipeline(
|
|
309
|
+
jobs,
|
|
310
|
+
({ aspect, segment, i }) =>
|
|
311
|
+
ask(extractPrompt(aspect, segment, i + 1, segments.length), {
|
|
312
|
+
label: `extract:${aspect.key}${multi ? `:${i + 1}` : ''}`,
|
|
313
|
+
phase: 'Extract',
|
|
314
|
+
schema: UNITS,
|
|
315
|
+
}).then((r) => ({ aspect: aspect.key, units: r.units ?? [] })),
|
|
316
|
+
(r) => splitByProvenance(r, bodyOf),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const byAspect = {};
|
|
320
|
+
for (const r of extracted) byAspect[r.aspect] = [...(byAspect[r.aspect] ?? []), ...r.cited];
|
|
321
|
+
const inferred = extracted.flatMap((r) => r.inferred);
|
|
322
|
+
const candidates = byAspect.features ?? [];
|
|
323
|
+
const subsystemNames = (byAspect.subsystems ?? []).map((s) => s.name);
|
|
324
|
+
const envelopes = (byAspect.numbers ?? []).map((u) => toEnvelope(u, spread));
|
|
325
|
+
log(`${candidates.length} candidates over ${segments.length} segment(s)`);
|
|
326
|
+
log(`${inferred.length} inferred units diverted to questions`);
|
|
327
|
+
|
|
328
|
+
phase('Lenses');
|
|
329
|
+
const enriched = await pipeline(candidates, (c) =>
|
|
330
|
+
ask(lensPrompt(c, subsystemNames, bodyOf.get(c.anchor) ?? ''), {
|
|
331
|
+
label: `lens:${c.name}`,
|
|
332
|
+
phase: 'Lenses',
|
|
333
|
+
schema: CAND_LENS,
|
|
334
|
+
}).then((r) => ({ ...c, ...r })),
|
|
335
|
+
);
|
|
336
|
+
const cands = enriched.map((c, i) => ({
|
|
337
|
+
...c,
|
|
338
|
+
id: `cand-${String(startNum + i).padStart(3, '0')}`,
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
// No sections at all also takes the digest path: there is no segment to attach.
|
|
342
|
+
const wholeDoc = segments.length === 1 ? segments[0].texts.join('\n\n') : citedDigest(byAspect);
|
|
343
|
+
// Barrier is correct here: coherence lenses need the WHOLE candidate set at once.
|
|
344
|
+
const coherenceResults = await parallel(
|
|
345
|
+
COHERENCE.map(
|
|
346
|
+
(lens) => () =>
|
|
347
|
+
ask(coherencePrompt(lens, cands, wholeDoc), {
|
|
348
|
+
label: `lens:${lens.split(':')[0]}`,
|
|
349
|
+
phase: 'Lenses',
|
|
350
|
+
schema: FINDINGS,
|
|
351
|
+
}),
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
const coherence = coherenceResults.flatMap((r) => r.findings);
|
|
355
|
+
|
|
356
|
+
phase('Conflicts');
|
|
357
|
+
const pairs = crossingPairs(cands);
|
|
358
|
+
log(`${pairs.length} crossing pair(s) from the interaction matrix`);
|
|
359
|
+
const pairSections = ({ a, b }) =>
|
|
360
|
+
[...new Set([a.anchor, b.anchor])].map((anchor) =>
|
|
361
|
+
sectionText(sections.find((s) => s.anchor === anchor) ?? { anchor, body: '' }),
|
|
362
|
+
);
|
|
363
|
+
const conflictResults = await pipeline(pairs, (p) =>
|
|
364
|
+
ask(conflictPrompt(p, pairSections(p)), {
|
|
365
|
+
label: `conflict:${p.a.name}×${p.b.name}`,
|
|
366
|
+
phase: 'Conflicts',
|
|
367
|
+
schema: CONFLICT,
|
|
368
|
+
}).then((r) => ({ ...r, pair: p })),
|
|
369
|
+
);
|
|
370
|
+
const conflicts = conflictResults.flatMap((r) => r.findings);
|
|
371
|
+
/** @type {Record<string, string[]>} */
|
|
372
|
+
const deps = Object.fromEntries(cands.map((c) => [c.id, []]));
|
|
182
373
|
for (const { order, pair } of conflictResults) {
|
|
183
|
-
if (order === 'a_first') deps
|
|
184
|
-
if (order === 'b_first') deps
|
|
374
|
+
if (order === 'a_first') deps[pair.b.id].push(pair.a.id);
|
|
375
|
+
if (order === 'b_first') deps[pair.a.id].push(pair.b.id);
|
|
185
376
|
}
|
|
186
377
|
|
|
187
|
-
// Package
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
378
|
+
// ─── Package: deterministic code — an agent never writes the final report ────────────
|
|
379
|
+
|
|
380
|
+
const provenanceQuestions = inferred.map((u) => ({
|
|
381
|
+
lens: 'provenance',
|
|
382
|
+
anchors: [u.anchor],
|
|
383
|
+
class: 'D1',
|
|
384
|
+
question: `"${u.name}" (${u.aspect}) has no verbatim quote — said or invented?`,
|
|
385
|
+
proposed: 'drop it',
|
|
386
|
+
}));
|
|
387
|
+
// GCF-PRD §11.3: an envelope is a D2 assumption — the number is cited, the spread is not.
|
|
388
|
+
const envelopeQuestions = envelopes
|
|
389
|
+
.filter((e) => e.envelope)
|
|
390
|
+
.map((e) => ({
|
|
391
|
+
lens: 'envelope',
|
|
392
|
+
anchors: [e.anchor],
|
|
393
|
+
class: 'D2',
|
|
394
|
+
question: `"${e.name}": "${e.quote}" taken as the envelope ${e.envelope}`,
|
|
395
|
+
proposed: e.envelope,
|
|
396
|
+
assumed: `±${Math.round(spread * 100)}% around the cited number`,
|
|
397
|
+
until: `manual(materialization of the candidate citing ${e.anchor})`,
|
|
398
|
+
}));
|
|
399
|
+
// GCF-PRD §11.2: the GDD's own open questions are the designer's gaps, said out loud.
|
|
400
|
+
const gddQuestions = (byAspect.questions ?? []).map((u) => ({
|
|
401
|
+
lens: 'gdd-open',
|
|
402
|
+
anchors: [u.anchor],
|
|
403
|
+
class: 'D1',
|
|
404
|
+
question: u.name,
|
|
405
|
+
proposed: u.detail || 'ask the designer',
|
|
406
|
+
}));
|
|
407
|
+
const questions = [
|
|
408
|
+
...gddQuestions,
|
|
409
|
+
...coherence,
|
|
410
|
+
...conflicts,
|
|
411
|
+
...provenanceQuestions,
|
|
412
|
+
...envelopeQuestions,
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
const result = {
|
|
193
416
|
pillar_candidates: byAspect.pillars ?? [],
|
|
194
|
-
subsystems:
|
|
195
|
-
candidates:
|
|
196
|
-
line:
|
|
417
|
+
subsystems: subsystemNames,
|
|
418
|
+
candidates: cands.map((c) => ({
|
|
419
|
+
line: candidateLine(c, deps[c.id]),
|
|
197
420
|
crossings: pairs.filter((p) => p.a === c || p.b === c).length,
|
|
198
421
|
})),
|
|
199
|
-
envelopes,
|
|
422
|
+
envelopes: envelopes.filter((e) => e.envelope),
|
|
423
|
+
// Counts, hard limits and thresholds stay exact: the write step folds each into the
|
|
424
|
+
// description of the candidate citing the same anchor.
|
|
425
|
+
exact: envelopes.filter((e) => !e.envelope),
|
|
200
426
|
d1: questions.filter((q) => q.class === 'D1'),
|
|
201
427
|
d2: questions.filter((q) => q.class === 'D2'),
|
|
202
428
|
constraints: byAspect.constraints ?? [],
|
|
203
|
-
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// @ts-expect-error -- the Workflow runtime runs this file as a function body: return is its API
|
|
432
|
+
return result;
|