gcf-harness-aif-ext 0.2.0 → 0.3.2
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 +31 -4
- package/commands/harness.js +19 -8
- package/extension.json +1 -1
- package/hooks/readset-guard.js +23 -14
- package/package.json +2 -2
- package/skills/gcf-run/SKILL.md +4 -3
- package/skills/gcf-run-ingest/SKILL.md +8 -6
- package/workflows/ingest.js +87 -51
package/README.md
CHANGED
|
@@ -6,24 +6,51 @@ The harness turns policy-as-text into an executable pipeline. It has **no memory
|
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
|
-
- [`gcf-aif-ext`](../gcf-aif-ext) `^0.2.
|
|
9
|
+
- [`gcf-aif-ext`](../gcf-aif-ext) `^0.2.2` 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
|
|
10
10
|
- Claude Code 2.1.270+ (hooks, dynamic workflows)
|
|
11
11
|
- AI Factory 2.x, Node.js ≥ 22.14
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
|
+
Install into a project that already has the core, from npm or a local checkout:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
ai-factory extension add gcf-aif-ext # the core, if not yet installed
|
|
19
|
+
ai-factory extension add gcf-harness-aif-ext # npm
|
|
20
|
+
# ai-factory extension add ../GCF/packages/gcf-harness-aif-ext # local checkout
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This installs the `gcf-run` and `gcf-run-ingest` skills and registers the `gcf-harness` command. Then initialize the harness in the project — like `gcf init`, once per project:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ai-factory gcf-harness init # per project: .claude/settings.json + .gitignore
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It is **per project**, not global — run it again in every game repository: it adds the readset-guard `PreToolUse` entry to the project's `.claude/settings.json` (never `~/.claude`) and `.gcf-session/` to `.gitignore`. It is idempotent — a re-run after moving or cloning the project repoints the hook at the new path — refuses when the `gcf` command is not registered, and refuses to rewrite a `settings.json` it cannot parse. It is a separate step for the same reason as `gcf hooks-install`: an AI Factory manifest cannot install Claude Code hooks.
|
|
30
|
+
|
|
31
|
+
## Upgrade
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
ai-factory extension update gcf-harness-aif-ext
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Updating keeps the hook entry valid — it points into the extension's install directory, which an update refreshes in place.
|
|
38
|
+
|
|
39
|
+
To remove the extension:
|
|
40
|
+
|
|
15
41
|
```bash
|
|
16
|
-
ai-factory extension
|
|
17
|
-
ai-factory gcf-harness install # registers the readset-guard hook
|
|
42
|
+
ai-factory extension remove gcf-harness-aif-ext
|
|
18
43
|
```
|
|
19
44
|
|
|
45
|
+
Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry whose command ends in `# gcf-readset-guard` by hand, or every Read, Grep and Glob will report a hook error.
|
|
46
|
+
|
|
20
47
|
## What it adds
|
|
21
48
|
|
|
22
49
|
**`/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.
|
|
23
50
|
|
|
24
51
|
**`/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.
|
|
25
52
|
|
|
26
|
-
**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
|
|
53
|
+
**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.
|
|
27
54
|
|
|
28
55
|
## Docs
|
|
29
56
|
|
package/commands/harness.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `ai-factory gcf-harness
|
|
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
4
|
import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
@@ -23,8 +23,8 @@ function coreMissing(program) {
|
|
|
23
23
|
export function register(program) {
|
|
24
24
|
const harness = program.command('gcf-harness').description('GCF harness setup');
|
|
25
25
|
|
|
26
|
-
harness.command('
|
|
27
|
-
.description('
|
|
26
|
+
harness.command('init')
|
|
27
|
+
.description('Initialize the harness in this project: readset-guard hook in .claude/settings.json (idempotent)')
|
|
28
28
|
.action(async () => {
|
|
29
29
|
if (coreMissing(program)) {
|
|
30
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');
|
|
@@ -46,22 +46,33 @@ export function register(program) {
|
|
|
46
46
|
}
|
|
47
47
|
settings.hooks ??= {};
|
|
48
48
|
settings.hooks.PreToolUse ??= [];
|
|
49
|
-
const
|
|
50
|
-
|
|
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) {
|
|
51
56
|
settings.hooks.PreToolUse.push({
|
|
52
57
|
matcher: 'Read|Grep|Glob',
|
|
53
|
-
hooks: [{ type: 'command', command
|
|
58
|
+
hooks: [{ type: 'command', command }],
|
|
54
59
|
});
|
|
60
|
+
}
|
|
61
|
+
for (const h of stale) h.command = command;
|
|
62
|
+
if (!ours.length || stale.length) {
|
|
55
63
|
await mkdir(path.dirname(settingsFile), { recursive: true });
|
|
56
64
|
await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
57
|
-
console.log(`readset-guard registered → ${settingsFile}`);
|
|
65
|
+
console.log(`readset-guard ${ours.length ? 'repointed' : 'registered'} → ${settingsFile}`);
|
|
58
66
|
} else {
|
|
59
67
|
console.log('readset-guard already registered');
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
// Only an active rule ignoring the root .gcf-session/ counts — not a comment or a
|
|
71
|
+
// pattern for some other directory that happens to contain the name.
|
|
62
72
|
const gitignore = path.join(projectDir, '.gitignore');
|
|
63
73
|
const line = '.gcf-session/';
|
|
64
|
-
const has = existsSync(gitignore) && (await readFile(gitignore, 'utf8')).
|
|
74
|
+
const has = existsSync(gitignore) && (await readFile(gitignore, 'utf8')).split('\n')
|
|
75
|
+
.some((l) => /^(\/|\*\*\/)?\.gcf-session\/?$/.test(l.trim()));
|
|
65
76
|
if (!has) await appendFile(gitignore, `\n${line}\n`, 'utf8');
|
|
66
77
|
});
|
|
67
78
|
}
|
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.2
|
|
4
|
+
"version": "0.3.2",
|
|
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
|
@@ -17,30 +17,39 @@ function readStdin() {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether a Grep `glob:` keeps a search away from `gcfDir`, the first path segment leading
|
|
22
|
+
* from the search root to gcf/. Globs are gitignore-style: one without a slash matches at
|
|
23
|
+
* any depth, and a wildcard, brace or negation in the first segment can still match gcf/.
|
|
24
|
+
* Only a literal first segment that is not gcfDir proves the search cannot get there.
|
|
25
|
+
*/
|
|
26
|
+
function globAvoids(glob, gcfDir) {
|
|
27
|
+
const parts = (glob ?? '').replaceAll('\\', '/').replace(/^\.?\//, '').split('/');
|
|
28
|
+
return parts.length > 1 && !/[*?[\]{}!]/.test(parts[0]) && parts[0] !== gcfDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export function decide(input, projectDir, manifest) {
|
|
21
32
|
// No manifest, or no input to judge at all: never restrict. A hook that hard-blocks every
|
|
22
33
|
// read because stdin hiccuped is worse than the leak it guards.
|
|
23
34
|
if (!manifest || !input) return { allow: true };
|
|
24
35
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
// Without `path`, a search runs from the project root.
|
|
37
|
+
const candidate = input.tool_input?.file_path ?? input.tool_input?.path ?? '.';
|
|
38
|
+
const abs = path.resolve(projectDir, candidate);
|
|
39
|
+
const gcfRoot = path.join(path.resolve(projectDir), 'gcf');
|
|
40
|
+
const relFromGcf = path.relative(gcfRoot, abs);
|
|
41
|
+
if (relFromGcf.startsWith('..') || path.isAbsolute(relFromGcf)) {
|
|
42
|
+
// Outside gcf/. Only a CONTENT search from a directory above gcf/ still reaches it: Glob
|
|
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 };
|
|
33
47
|
return {
|
|
34
48
|
allow: false,
|
|
35
|
-
reason: 'an unscoped Grep reads the contents of all of gcf/ — pass `path:` or a `glob:`
|
|
49
|
+
reason: 'an unscoped Grep reads the contents of all of gcf/ — pass `path:` or a `glob:` anchored outside gcf/ (e.g. `src/**`).',
|
|
36
50
|
};
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
const abs = path.resolve(projectDir, candidate);
|
|
40
|
-
const gcfRoot = path.join(path.resolve(projectDir), 'gcf');
|
|
41
|
-
const relFromGcf = path.relative(gcfRoot, abs);
|
|
42
|
-
if (relFromGcf.startsWith('..') || path.isAbsolute(relFromGcf)) return { allow: true }; // outside gcf/
|
|
43
|
-
|
|
44
53
|
// The gcf root itself relativizes to '' — unguarded it becomes 'gcf/' and matches nothing.
|
|
45
54
|
const relPosix = relFromGcf.replaceAll('\\', '/');
|
|
46
55
|
const rel = relPosix ? `gcf/${relPosix}` : 'gcf';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gcf-harness-aif-ext",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
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.2"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"extension.json",
|
package/skills/gcf-run/SKILL.md
CHANGED
|
@@ -13,17 +13,18 @@ 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
|
|
16
17
|
|
|
17
18
|
forbidden_behaviors:
|
|
18
19
|
- do not go past `built`: the verdict belongs to the human, this pipeline has no stage after the build
|
|
19
20
|
- do not read a `gcf/` file outside the readset from `ai-factory gcf context`
|
|
20
|
-
- do not continue past a failed gate: report the one-line reason and stop - a stop is a normal outcome
|
|
21
|
+
- 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
|
|
21
22
|
- do not batch cards whose `system:` lists intersect, unless `depends:` orders them
|
|
22
23
|
|
|
23
24
|
workflow:
|
|
24
|
-
1. session ritual: 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
|
|
25
|
+
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
|
|
25
26
|
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
|
|
26
|
-
3. gate, per card
|
|
27
|
+
3. gate, per card, on a fresh `ai-factory gcf audit --json` - step 2 and a tune verdict move the counters: absent from `open_depends` and `tune_limit`; `wip.count` plus the number of cards in the batch is at most `wip.limit`
|
|
27
28
|
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
|
|
28
29
|
5. plan with aif-plan, shaped by the card slots, reading only the readset files
|
|
29
30
|
6. implement with aif-implement, then verify: autotests plus the card's negative constraints; retries <= 2 with a diagnosis each, then stop
|
|
@@ -18,15 +18,17 @@ forbidden_behaviors:
|
|
|
18
18
|
- do not create card files: the package holds backlog lines, a card materializes only at pull
|
|
19
19
|
- do not let an agent write gcf files from inside the workflow: the script returns data, this skill writes
|
|
20
20
|
- do not paste the package into PILLARS.md: pillar candidates go to the human as D1
|
|
21
|
+
- do not write anything from a failed workflow: a dead agent fails it on purpose, so report its label and stop
|
|
21
22
|
|
|
22
23
|
workflow:
|
|
23
24
|
1. run `ai-factory gcf anchor <file> --json` and keep the sections list
|
|
24
|
-
2.
|
|
25
|
-
3. read
|
|
26
|
-
4.
|
|
27
|
-
5.
|
|
28
|
-
6.
|
|
29
|
-
7.
|
|
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 9 of the gcf-ingest skill does
|
|
26
|
+
3. read `envelope_default_spread` from `gcf/config.yml`
|
|
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 the workflow minted; D1 and D2 records appended to `gcf/open-questions.md`; then run `ai-factory gcf index`
|
|
29
|
+
6. mark a candidate whose nouns match the `purpose` or the `code` paths of an existing systems doc with `impl?` in its line, the same rule the sequential `/gcf-ingest` applies
|
|
30
|
+
7. report the package in the GCF order: D1 questions first, pillar candidates, backlog summary with the top crossing candidates, D2 envelope assumptions
|
|
31
|
+
8. verify equivalence on the first real GDD: compare this package against a sequential `/gcf-ingest` pass and report any divergence as a harness bug (Harness-PRD H3)
|
|
30
32
|
|
|
31
33
|
invocation:
|
|
32
34
|
- run when the user asks for the workflow import on a GDD
|
package/workflows/ingest.js
CHANGED
|
@@ -2,12 +2,12 @@ export const meta = {
|
|
|
2
2
|
name: 'gcf-ingest',
|
|
3
3
|
description: 'Fan-out GDD import: parallel aspect extraction, code-verified provenance, lenses, one package',
|
|
4
4
|
phases: [
|
|
5
|
-
{ title: 'Extract', detail: 'one agent per aspect
|
|
5
|
+
{ title: 'Extract', detail: 'one agent per aspect per segment of the anchored sections' },
|
|
6
6
|
{ title: 'Lenses', detail: 'completeness per candidate, coherence over the whole set' },
|
|
7
7
|
{ title: 'Conflicts', detail: 'pairwise checks where the interaction matrix crosses' },
|
|
8
8
|
],
|
|
9
9
|
}
|
|
10
|
-
// args: { sections: [{anchor, heading, body}], spread: number, start_num: number }
|
|
10
|
+
// args: { sections: [{anchor, heading, body}], spread: number, start_num: number, segment_chars?: number }
|
|
11
11
|
// The main thread runs `ai-factory gcf anchor --json` BEFORE this workflow and writes
|
|
12
12
|
// the outputs AFTER it — the script has no filesystem, by design (Harness-PRD §6.1).
|
|
13
13
|
|
|
@@ -16,7 +16,27 @@ const spread = args.spread ?? 0.3
|
|
|
16
16
|
// Candidate ids continue the existing backlog numbering: minting from the array index
|
|
17
17
|
// makes every re-ingest emit cand-001 again, colliding with what is already filed.
|
|
18
18
|
const startNum = Number(args.start_num) || 1 // args come from a model-composed call: "5" would concatenate
|
|
19
|
-
|
|
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
|
+
}
|
|
32
|
+
|
|
33
|
+
// A dead agent must stop the import, not shrink it: an empty extraction reads as "the GDD
|
|
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
|
+
}
|
|
20
40
|
|
|
21
41
|
const UNITS = {
|
|
22
42
|
type: 'object',
|
|
@@ -47,15 +67,15 @@ const ASPECTS = [
|
|
|
47
67
|
]
|
|
48
68
|
|
|
49
69
|
phase('Extract')
|
|
70
|
+
const byAnchor = new Map(sections.map((s) => [s.anchor, s.body]))
|
|
50
71
|
const extracted = await pipeline(
|
|
51
|
-
ASPECTS,
|
|
52
|
-
(a) =>
|
|
53
|
-
`Extract ${a.prompt} from this GDD. Every unit MUST carry a verbatim quote copied exactly from its section and the section anchor.\n\n${
|
|
54
|
-
{ label: `extract:${a.key}`, phase: 'Extract', schema: UNITS },
|
|
55
|
-
).then((r) => ({ aspect: a.key, units: r
|
|
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 ?? [] })),
|
|
56
77
|
// Provenance is CODE, not an agent: a quote either is a substring of its section or it is not.
|
|
57
78
|
(r) => {
|
|
58
|
-
const byAnchor = new Map(sections.map((s) => [s.anchor, s.body]))
|
|
59
79
|
const cited = []
|
|
60
80
|
const inferred = []
|
|
61
81
|
for (const u of r.units) {
|
|
@@ -67,20 +87,22 @@ const extracted = await pipeline(
|
|
|
67
87
|
},
|
|
68
88
|
)
|
|
69
89
|
|
|
70
|
-
const byAspect =
|
|
71
|
-
const
|
|
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)
|
|
72
93
|
const candidates = byAspect.features ?? []
|
|
73
94
|
const subsystems = byAspect.subsystems ?? []
|
|
74
|
-
log(`${candidates.length} candidates, ${inferred.length} inferred units diverted to questions`)
|
|
95
|
+
log(`${candidates.length} candidates over ${segments.length} segment(s), ${inferred.length} inferred units diverted to questions`)
|
|
75
96
|
|
|
76
|
-
// Numbers → envelopes: value ± spread, assumed. Deterministic transformation.
|
|
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.
|
|
77
99
|
const envelopes = (byAspect.numbers ?? []).map((u) => {
|
|
78
|
-
const m = (u.detail ?? u.quote).match(/(\d+(?:[.,]\d+)?)\s*([
|
|
100
|
+
const m = (u.detail ?? u.quote).match(/((?<![\p{L}\p{N}])[-−])?(\d+(?:[.,]\d+)?)\s*([\p{L}%°][\p{L}\p{N}%°/^²³·]*)?/u)
|
|
79
101
|
if (!m) return { ...u, envelope: null }
|
|
80
|
-
const v =
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
return { ...u, envelope: `${
|
|
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 }
|
|
84
106
|
})
|
|
85
107
|
|
|
86
108
|
phase('Lenses')
|
|
@@ -93,44 +115,40 @@ const CAND_LENS = {
|
|
|
93
115
|
},
|
|
94
116
|
required: ['gaps', 'systems', 'state_written'],
|
|
95
117
|
}
|
|
96
|
-
const
|
|
118
|
+
const enriched = await pipeline(
|
|
97
119
|
candidates,
|
|
98
|
-
(c) =>
|
|
99
|
-
`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${
|
|
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) ?? ''}`,
|
|
100
122
|
{ label: `lens:${c.name}`, phase: 'Lenses', schema: CAND_LENS },
|
|
101
|
-
).then((r) =>
|
|
123
|
+
).then((r) => ({ ...c, ...r })),
|
|
102
124
|
)
|
|
125
|
+
const idOf = new Map(enriched.map((c, i) => [c, `cand-${String(startNum + i).padStart(3, '0')}`]))
|
|
103
126
|
|
|
104
|
-
const
|
|
105
|
-
const FINDINGS = {
|
|
127
|
+
const FINDING = {
|
|
106
128
|
type: 'object',
|
|
107
129
|
properties: {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
lens: { type: 'string' },
|
|
114
|
-
anchors: { type: 'array', items: { type: 'string' } },
|
|
115
|
-
class: { enum: ['D1', 'D2'] },
|
|
116
|
-
question: { type: 'string' },
|
|
117
|
-
proposed: { type: 'string' },
|
|
118
|
-
},
|
|
119
|
-
required: ['lens', 'anchors', 'class', 'question', 'proposed'],
|
|
120
|
-
},
|
|
121
|
-
},
|
|
130
|
+
lens: { type: 'string' },
|
|
131
|
+
anchors: { type: 'array', items: { type: 'string' } },
|
|
132
|
+
class: { enum: ['D1', 'D2'] },
|
|
133
|
+
question: { type: 'string' },
|
|
134
|
+
proposed: { type: 'string' },
|
|
122
135
|
},
|
|
123
|
-
required: ['
|
|
136
|
+
required: ['lens', 'anchors', 'class', 'question', 'proposed'],
|
|
124
137
|
}
|
|
138
|
+
const FINDINGS = { type: 'object', properties: { findings: { type: 'array', items: FINDING } }, required: ['findings'] }
|
|
125
139
|
const COHERENCE = ['loop closure: every produced resource has a sink and every sink a source',
|
|
126
140
|
'progression pressure: what moves the player forward and brings them back',
|
|
127
141
|
'economy balance: do the numbers converge over time',
|
|
128
142
|
'dictionary: exactly one word per concept across the document']
|
|
143
|
+
// A segmented GDD does not fit one agent: the whole-set lenses read the cited units instead.
|
|
144
|
+
const wholeDoc = segments.length === 1
|
|
145
|
+
? segments[0].texts.join('\n\n')
|
|
146
|
+
: `Cited units (the GDD is too large to attach):\n${JSON.stringify(Object.fromEntries(Object.entries(byAspect).map(([k, us]) => [k, us.map((u) => ({ name: u.name, anchor: u.anchor, quote: u.quote, detail: u.detail }))])))}`
|
|
129
147
|
// Barrier is correct here: coherence lenses need the WHOLE candidate set at once.
|
|
130
|
-
const coherence = (await parallel(COHERENCE.map((lens) => () =>
|
|
131
|
-
`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${
|
|
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}`,
|
|
132
150
|
{ label: `lens:${lens.split(':')[0]}`, phase: 'Lenses', schema: FINDINGS },
|
|
133
|
-
)))).
|
|
151
|
+
)))).flatMap((r) => r.findings)
|
|
134
152
|
|
|
135
153
|
phase('Conflicts')
|
|
136
154
|
// Interaction matrix from code: pairs writing the same named state.
|
|
@@ -142,22 +160,40 @@ for (let i = 0; i < enriched.length; i++) {
|
|
|
142
160
|
}
|
|
143
161
|
}
|
|
144
162
|
log(`${pairs.length} crossing pair(s) from the interaction matrix`)
|
|
145
|
-
|
|
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.
|
|
165
|
+
const CONFLICT = {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties: {
|
|
168
|
+
findings: FINDINGS.properties.findings,
|
|
169
|
+
order: { enum: ['a_first', 'b_first', 'none'], description: 'a_first: B cannot be built or played without A already in the game' },
|
|
170
|
+
},
|
|
171
|
+
required: ['findings', 'order'],
|
|
172
|
+
}
|
|
173
|
+
const conflictResults = await pipeline(
|
|
146
174
|
pairs,
|
|
147
|
-
(p) =>
|
|
148
|
-
`Two mechanics write the same state (${p.shared.join(', ')}): "${p.a.name}" (${p.a.anchor}) and "${p.b.name}" (${p.b.anchor}). Does the document define their interaction? If not, report the gap as a D1 finding
|
|
149
|
-
{ label: `conflict:${p.a.name}×${p.b.name}`, phase: 'Conflicts', schema:
|
|
150
|
-
),
|
|
151
|
-
)
|
|
175
|
+
(p) => ask(
|
|
176
|
+
`Two mechanics write the same state (${p.shared.join(', ')}): A "${p.a.name}" (${p.a.anchor}) and B "${p.b.name}" (${p.b.anchor}). Does the document define their interaction? If not, report the gap as a D1 finding. Also say whether one has to exist before the other.\n\n${[...new Set([p.a.anchor, p.b.anchor])].map((anchor) => sectionText(sections.find((s) => s.anchor === anchor) ?? { anchor, body: '' })).join('\n\n')}`,
|
|
177
|
+
{ label: `conflict:${p.a.name}×${p.b.name}`, phase: 'Conflicts', schema: CONFLICT },
|
|
178
|
+
).then((r) => ({ ...r, pair: p })),
|
|
179
|
+
)
|
|
180
|
+
const conflicts = conflictResults.flatMap((r) => r.findings)
|
|
181
|
+
const deps = new Map(enriched.map((c) => [c, []]))
|
|
182
|
+
for (const { order, pair } of conflictResults) {
|
|
183
|
+
if (order === 'a_first') deps.get(pair.b).push(idOf.get(pair.a))
|
|
184
|
+
if (order === 'b_first') deps.get(pair.a).push(idOf.get(pair.b))
|
|
185
|
+
}
|
|
152
186
|
|
|
153
187
|
// Package assembly is deterministic code — an agent never writes the final report.
|
|
154
188
|
const questions = [...coherence, ...conflicts,
|
|
155
|
-
...inferred.map((u) => ({ lens: 'provenance', anchors: [u.anchor], class: 'D1', question: `"${u.name}" (${u.aspect}) has no verbatim quote — said or invented?`, proposed: 'drop it' }))
|
|
189
|
+
...inferred.map((u) => ({ lens: 'provenance', anchors: [u.anchor], class: 'D1', question: `"${u.name}" (${u.aspect}) has no verbatim quote — said or invented?`, proposed: 'drop it' })),
|
|
190
|
+
// GCF-PRD §11.3: an envelope is a D2 assumption — the number is cited, the spread is not.
|
|
191
|
+
...envelopes.filter((e) => e.envelope).map((e) => ({ lens: 'envelope', anchors: [e.anchor], class: 'D2', question: `"${e.name}": "${e.quote}" taken as the envelope ${e.envelope} — assumed: ±${Math.round(spread * 100)}%`, proposed: e.envelope }))]
|
|
156
192
|
return {
|
|
157
193
|
pillar_candidates: byAspect.pillars ?? [],
|
|
158
194
|
subsystems: subsystems.map((s) => s.name),
|
|
159
|
-
candidates: enriched.map((c
|
|
160
|
-
line: `-
|
|
195
|
+
candidates: enriched.map((c) => ({
|
|
196
|
+
line: `- ${idOf.get(c)} | ${c.name} | route: soft | sys: ${c.systems[0] ?? 'unassigned'} | deps: ${deps.get(c).join(', ') || '-'} | src: ${c.anchor}${c.gaps.length ? ` | gaps: ${c.gaps.join(', ')}` : ''}`,
|
|
161
197
|
crossings: pairs.filter((p) => p.a === c || p.b === c).length,
|
|
162
198
|
})),
|
|
163
199
|
envelopes,
|