gcf-harness-aif-ext 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleg (VirtualMaestro)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # gcf-harness-aif-ext
2
+
3
+ Claude-Code-specific orchestration for the [Game Context Framework](../gcf-aif-ext). Optional: GCF works without it, by hand.
4
+
5
+ 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
+
7
+ ## Requirements
8
+
9
+ - [`gcf-aif-ext`](../gcf-aif-ext) `^0.2.0` installed, with a `gcf/` tree in the project — declared as a peer dependency, and `gcf-harness install` refuses when the `gcf` command is not registered
10
+ - Claude Code 2.1.270+ (hooks, dynamic workflows)
11
+ - AI Factory 2.x, Node.js ≥ 22.14
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ ai-factory extension add <path-to>/packages/gcf-harness-aif-ext
17
+ ai-factory gcf-harness install # registers the readset-guard hook
18
+ ```
19
+
20
+ ## What it adds
21
+
22
+ **`/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
+
24
+ **`/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
+
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 an unscoped Grep or Glob is denied too, because a search from the project root would read the whole tree past the gate. 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
+
28
+ ## Docs
29
+
30
+ [Harness-PRD](https://github.com/VirtualMaestro/GCF/blob/main/docs/Harness-PRD.md) is the spec: pipeline stages, gates, execution surfaces, the guard's contract.
31
+
32
+ ## License
33
+
34
+ MIT
@@ -0,0 +1,67 @@
1
+ // `ai-factory gcf-harness install` — registers the readset-guard PreToolUse hook in the
2
+ // project's .claude/settings.json (the AIF manifest cannot install Claude Code hooks —
3
+ // same decision as gcf hooks-install for git hooks). Idempotent by marker comment.
4
+ import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const MARKER = 'gcf-readset-guard';
11
+
12
+ /**
13
+ * The harness holds no code of the core: it calls `ai-factory gcf …` and writes gcf/.
14
+ * That contract is invisible to npm, so the check happens here, at install time — and
15
+ * inside the action, because extensions register in an order nobody controls.
16
+ * An unreadable command list means the CLI shape is unknown, not that the core is absent.
17
+ */
18
+ function coreMissing(program) {
19
+ if (!Array.isArray(program?.commands)) return false;
20
+ return !program.commands.some((c) => (typeof c?.name === 'function' ? c.name() : c?.name) === 'gcf');
21
+ }
22
+
23
+ export function register(program) {
24
+ const harness = program.command('gcf-harness').description('GCF harness setup');
25
+
26
+ harness.command('install')
27
+ .description('Register the readset-guard hook in .claude/settings.json (idempotent)')
28
+ .action(async () => {
29
+ if (coreMissing(program)) {
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 already = JSON.stringify(settings.hooks.PreToolUse).includes(MARKER);
50
+ if (!already) {
51
+ settings.hooks.PreToolUse.push({
52
+ matcher: 'Read|Grep|Glob',
53
+ hooks: [{ type: 'command', command: `node "${guard}" # ${MARKER}` }],
54
+ });
55
+ await mkdir(path.dirname(settingsFile), { recursive: true });
56
+ await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
57
+ console.log(`readset-guard registered → ${settingsFile}`);
58
+ } else {
59
+ console.log('readset-guard already registered');
60
+ }
61
+
62
+ const gitignore = path.join(projectDir, '.gitignore');
63
+ const line = '.gcf-session/';
64
+ const has = existsSync(gitignore) && (await readFile(gitignore, 'utf8')).includes(line);
65
+ if (!has) await appendFile(gitignore, `\n${line}\n`, 'utf8');
66
+ });
67
+ }
package/extension.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
+ "name": "gcf-harness-aif-ext",
4
+ "version": "0.1.1",
5
+ "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
+ "skills": [
7
+ "skills/gcf-run",
8
+ "skills/gcf-run-ingest"
9
+ ],
10
+ "commands": [
11
+ { "name": "gcf-harness", "description": "GCF harness setup", "module": "commands/harness.js" }
12
+ ]
13
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // Readset guard (Harness-PRD §7): a PreToolUse hook for Read/Grep/Glob. While a run
3
+ // manifest exists at .gcf-session/readset.json, paths inside gcf/ that are not in the
4
+ // readset are denied (exit 2, reason on stderr — Claude Code shows it to the agent).
5
+ // No manifest → silent allow (manual work is never restricted). Paths outside gcf/
6
+ // are never touched, and neither is a search that cannot reach gcf/ — the project's own
7
+ // code stays freely greppable. Honest limit: Bash `cat` bypasses this; it is a discipline
8
+ // tool for a solo developer, not a security perimeter.
9
+ import { readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ function readStdin() {
13
+ try {
14
+ return JSON.parse(readFileSync(0, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function decide(input, projectDir, manifest) {
21
+ // No manifest, or no input to judge at all: never restrict. A hook that hard-blocks every
22
+ // read because stdin hiccuped is worse than the leak it guards.
23
+ if (!manifest || !input) return { allow: true };
24
+
25
+ const candidate = input.tool_input?.file_path ?? input.tool_input?.path ?? null;
26
+ if (!candidate) {
27
+ // Without `path`, a search runs from the project root. Only a CONTENT search is a leak:
28
+ // Glob returns paths, and gcf/ paths are already public in INDEX.md. A Grep whose `glob:`
29
+ // points away from gcf/ cannot reach it either. What is left is the real hole.
30
+ if (input.tool_name !== 'Grep') return { allow: true };
31
+ const scope = (input.tool_input?.glob ?? '').replaceAll('\\', '/');
32
+ if (scope && !scope.startsWith('gcf')) return { allow: true };
33
+ return {
34
+ allow: false,
35
+ reason: 'an unscoped Grep reads the contents of all of gcf/ — pass `path:` or a `glob:` that scopes the search.',
36
+ };
37
+ }
38
+
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
+ // The gcf root itself relativizes to '' — unguarded it becomes 'gcf/' and matches nothing.
45
+ const relPosix = relFromGcf.replaceAll('\\', '/');
46
+ const rel = relPosix ? `gcf/${relPosix}` : 'gcf';
47
+ // `gcf context --json` emits files as {path, tokens_est}; accept that shape as well as
48
+ // the projected string list, so a manifest written either way still gates correctly.
49
+ const allowed = new Set([
50
+ ...(manifest.files ?? []).map((f) => (typeof f === 'string' ? f : f?.path)).filter(Boolean),
51
+ 'gcf/INDEX.md',
52
+ 'gcf',
53
+ ]);
54
+ if (allowed.has(rel)) return { allow: true };
55
+ // ponytail: a directory path passes when any allowed file lives under it, so Grep over
56
+ // gcf/systems can still see sibling files' content; per-file grep scoping if it leaks.
57
+ for (const f of allowed) {
58
+ if (f.startsWith(`${rel}/`)) return { allow: true };
59
+ }
60
+ return {
61
+ allow: false,
62
+ // No "delete the manifest" hint: that is the human's lever for lifting the guard, and an
63
+ // agent told about it on every denial will reach for it and turn a leak into a bypass.
64
+ 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.`,
65
+ };
66
+ }
67
+
68
+ // Hook entry: only runs when executed directly, so tests can import decide().
69
+ if (import.meta.url === `file://${process.argv[1]?.replaceAll('\\', '/')}` || process.argv[1]?.endsWith('readset-guard.js')) {
70
+ const input = readStdin();
71
+ const projectDir = input?.cwd ?? process.cwd();
72
+ let manifest = null;
73
+ try {
74
+ manifest = JSON.parse(readFileSync(path.join(projectDir, '.gcf-session', 'readset.json'), 'utf8'));
75
+ } catch {
76
+ manifest = null;
77
+ }
78
+ const verdict = decide(input, projectDir, manifest);
79
+ if (!verdict.allow) {
80
+ console.error(`gcf readset-guard: ${verdict.reason}`);
81
+ process.exit(2);
82
+ }
83
+ process.exit(0);
84
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "gcf-harness-aif-ext",
3
+ "version": "0.1.1",
4
+ "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Oleg (VirtualMaestro)",
8
+ "aiFactoryCompatibility": ">=2.0.0 <3.0.0",
9
+ "keywords": [
10
+ "ai-factory",
11
+ "gcf",
12
+ "harness",
13
+ "extension"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/VirtualMaestro/GCF.git"
18
+ },
19
+ "homepage": "https://github.com/VirtualMaestro/GCF#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/VirtualMaestro/GCF/issues"
22
+ },
23
+ "engines": {
24
+ "node": ">=22.14"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test",
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
+ },
30
+ "peerDependencies": {
31
+ "gcf-aif-ext": "^0.2.0"
32
+ },
33
+ "files": [
34
+ "extension.json",
35
+ "skills",
36
+ "commands",
37
+ "hooks",
38
+ "workflows",
39
+ "LICENSE",
40
+ "README.md"
41
+ ]
42
+ }
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: gcf-run
3
+ description: Drive one or more GCF cards from backlog to built in a single run - gates before tokens, readset discipline, playtest list at the end. Use when the user says "прогони карточку" / "run the card".
4
+ ---
5
+
6
+ mode: pipeline
7
+
8
+ purpose:
9
+ - drive a card through gate, readset, plan, implement and build with the stop rules checked before each paid stage
10
+
11
+ inputs:
12
+ - 1 or more cand-NNN or card-NNN ids; a batch shares one build and one playtest list
13
+
14
+ preconditions:
15
+ - `gcf/` exists and `ai-factory gcf audit` runs (the gcf-aif-ext extension provides it)
16
+
17
+ forbidden_behaviors:
18
+ - do not go past `built`: the verdict belongs to the human, this pipeline has no stage after the build
19
+ - 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 batch cards whose `system:` lists intersect, unless `depends:` orders them
22
+
23
+ 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
+ 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: depends all collapsed, tune_count < limit, WIP under `wip_built_limit` - the audit report from step 1 carries each answer
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
28
+ 5. plan with aif-plan, shaped by the card slots, reading only the readset files
29
+ 6. implement with aif-implement, then verify: autotests plus the card's negative constraints; retries <= 2 with a diagnosis each, then stop
30
+ 7. build the project; flip the card frontmatter to `status: built` with `built_at:` set to the implementation commit hash, commit via aif-commit
31
+ 8. close the run: delete `.gcf-session/readset.json`, run `ai-factory gcf playtest` and hand the list to the human
32
+ 9. after a tune verdict, re-enter this pipeline at step 3: the gates re-run because the counters moved
33
+
34
+ invocation:
35
+ - run when the user names a card or candidate to drive; batches share steps 1 and 8
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: gcf-run-ingest
3
+ description: Fan-out GDD import on Claude Code dynamic workflows - parallel extraction and lenses, one output package. Use for a large GDD or when the user says "запусти импорт воркфлоу".
4
+ ---
5
+
6
+ mode: orchestrated import
7
+
8
+ purpose:
9
+ - run the gcf-ingest pipeline as a dynamic workflow: minutes of fan-out instead of hours of one agent
10
+
11
+ inputs:
12
+ - a path to the GDD (markdown); the sequential `/gcf-ingest` skill stays the correctness reference
13
+
14
+ preconditions:
15
+ - `gcf/` exists; the user has opted into multi-agent orchestration (the Workflow tool requires it)
16
+
17
+ forbidden_behaviors:
18
+ - do not create card files: the package holds backlog lines, a card materializes only at pull
19
+ - do not let an agent write gcf files from inside the workflow: the script returns data, this skill writes
20
+ - do not paste the package into PILLARS.md: pillar candidates go to the human as D1
21
+
22
+ workflow:
23
+ 1. run `ai-factory gcf anchor <file> --json` and keep the sections list
24
+ 2. read `envelope_default_spread` from `gcf/config.yml`
25
+ 3. 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
26
+ 4. 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`
27
+ 5. 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
28
+ 6. report the package in the GCF order: D1 questions first, pillar candidates, backlog summary with the top crossing candidates, D2 envelope assumptions
29
+ 7. 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
+
31
+ invocation:
32
+ - run when the user asks for the workflow import on a GDD
@@ -0,0 +1,167 @@
1
+ export const meta = {
2
+ name: 'gcf-ingest',
3
+ description: 'Fan-out GDD import: parallel aspect extraction, code-verified provenance, lenses, one package',
4
+ phases: [
5
+ { title: 'Extract', detail: 'one agent per aspect over the anchored sections' },
6
+ { title: 'Lenses', detail: 'completeness per candidate, coherence over the whole set' },
7
+ { title: 'Conflicts', detail: 'pairwise checks where the interaction matrix crosses' },
8
+ ],
9
+ }
10
+ // args: { sections: [{anchor, heading, body}], spread: number, start_num: number }
11
+ // The main thread runs `ai-factory gcf anchor --json` BEFORE this workflow and writes
12
+ // the outputs AFTER it — the script has no filesystem, by design (Harness-PRD §6.1).
13
+
14
+ const sections = args.sections
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
+ const gddText = sections.map((s) => `[${s.anchor}] ${s.heading ?? '(preamble)'}\n${s.body}`).join('\n\n')
20
+
21
+ const UNITS = {
22
+ type: 'object',
23
+ properties: {
24
+ units: {
25
+ type: 'array',
26
+ items: {
27
+ type: 'object',
28
+ properties: {
29
+ name: { type: 'string' },
30
+ anchor: { type: 'string' },
31
+ quote: { type: 'string', description: 'verbatim substring of the source section' },
32
+ detail: { type: 'string' },
33
+ },
34
+ required: ['name', 'anchor', 'quote'],
35
+ },
36
+ },
37
+ },
38
+ required: ['units'],
39
+ }
40
+
41
+ const ASPECTS = [
42
+ { key: 'pillars', prompt: 'pillar candidates: statements about what the game IS and is NOT' },
43
+ { key: 'subsystems', prompt: 'subsystems: the nouns — movement, combat, economy; lowercase slug in name' },
44
+ { key: 'features', prompt: 'feature candidates: the player verbs — things the player does; one unit per mechanic' },
45
+ { key: 'numbers', prompt: 'exact numbers and progressions: durations, costs, curves; detail carries the value and unit' },
46
+ { key: 'constraints', prompt: 'platform, audience, monetization and scope constraints' },
47
+ ]
48
+
49
+ phase('Extract')
50
+ const extracted = await pipeline(
51
+ ASPECTS,
52
+ (a) => agent(
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${gddText}`,
54
+ { label: `extract:${a.key}`, phase: 'Extract', schema: UNITS },
55
+ ).then((r) => ({ aspect: a.key, units: r?.units ?? [] })),
56
+ // Provenance is CODE, not an agent: a quote either is a substring of its section or it is not.
57
+ (r) => {
58
+ const byAnchor = new Map(sections.map((s) => [s.anchor, s.body]))
59
+ const cited = []
60
+ const inferred = []
61
+ for (const u of r.units) {
62
+ const body = byAnchor.get(u.anchor) ?? ''
63
+ if (u.quote && body.includes(u.quote)) cited.push(u)
64
+ else inferred.push({ ...u, aspect: r.aspect })
65
+ }
66
+ return { aspect: r.aspect, cited, inferred }
67
+ },
68
+ )
69
+
70
+ const byAspect = Object.fromEntries(extracted.filter(Boolean).map((r) => [r.aspect, r.cited]))
71
+ const inferred = extracted.filter(Boolean).flatMap((r) => r.inferred)
72
+ const candidates = byAspect.features ?? []
73
+ const subsystems = byAspect.subsystems ?? []
74
+ log(`${candidates.length} candidates, ${inferred.length} inferred units diverted to questions`)
75
+
76
+ // Numbers → envelopes: value ± spread, assumed. Deterministic transformation.
77
+ const envelopes = (byAspect.numbers ?? []).map((u) => {
78
+ const m = (u.detail ?? u.quote).match(/(\d+(?:[.,]\d+)?)\s*([a-zA-Zа-яё%]*)/)
79
+ if (!m) return { ...u, envelope: null }
80
+ const v = parseFloat(m[1].replace(',', '.'))
81
+ const lo = +(v * (1 - spread)).toFixed(3)
82
+ const hi = +(v * (1 + spread)).toFixed(3)
83
+ return { ...u, envelope: `${lo}..${hi} ${m[2]}`.trim(), assumed: true }
84
+ })
85
+
86
+ phase('Lenses')
87
+ const CAND_LENS = {
88
+ type: 'object',
89
+ properties: {
90
+ gaps: { type: 'array', items: { type: 'string' }, description: 'missing answers: state, input, feedback, refusal, boundaries, persistence' },
91
+ systems: { type: 'array', items: { type: 'string' }, description: 'subsystem slugs this candidate touches' },
92
+ state_written: { type: 'array', items: { type: 'string' }, description: 'named state this mechanic writes' },
93
+ },
94
+ required: ['gaps', 'systems', 'state_written'],
95
+ }
96
+ const perCandidate = await pipeline(
97
+ candidates,
98
+ (c) => agent(
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${sections.find((s) => s.anchor === c.anchor)?.body ?? ''}`,
100
+ { label: `lens:${c.name}`, phase: 'Lenses', schema: CAND_LENS },
101
+ ).then((r) => r && { ...c, ...r }),
102
+ )
103
+
104
+ const enriched = perCandidate.filter(Boolean)
105
+ const FINDINGS = {
106
+ type: 'object',
107
+ properties: {
108
+ findings: {
109
+ type: 'array',
110
+ items: {
111
+ type: 'object',
112
+ properties: {
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
+ },
122
+ },
123
+ required: ['findings'],
124
+ }
125
+ const COHERENCE = ['loop closure: every produced resource has a sink and every sink a source',
126
+ 'progression pressure: what moves the player forward and brings them back',
127
+ 'economy balance: do the numbers converge over time',
128
+ 'dictionary: exactly one word per concept across the document']
129
+ // Barrier is correct here: coherence lenses need the WHOLE candidate set at once.
130
+ const coherence = (await parallel(COHERENCE.map((lens) => () => agent(
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${gddText}`,
132
+ { label: `lens:${lens.split(':')[0]}`, phase: 'Lenses', schema: FINDINGS },
133
+ )))).filter(Boolean).flatMap((r) => r.findings)
134
+
135
+ phase('Conflicts')
136
+ // Interaction matrix from code: pairs writing the same named state.
137
+ const pairs = []
138
+ for (let i = 0; i < enriched.length; i++) {
139
+ for (let j = i + 1; j < enriched.length; j++) {
140
+ const shared = enriched[i].state_written.filter((s) => enriched[j].state_written.includes(s))
141
+ if (shared.length) pairs.push({ a: enriched[i], b: enriched[j], shared })
142
+ }
143
+ }
144
+ log(`${pairs.length} crossing pair(s) from the interaction matrix`)
145
+ const conflicts = (await pipeline(
146
+ pairs,
147
+ (p) => agent(
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: FINDINGS },
150
+ ),
151
+ )).filter(Boolean).flatMap((r) => r.findings)
152
+
153
+ // Package assembly is deterministic code — an agent never writes the final report.
154
+ 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' }))]
156
+ return {
157
+ pillar_candidates: byAspect.pillars ?? [],
158
+ subsystems: subsystems.map((s) => s.name),
159
+ candidates: enriched.map((c, i) => ({
160
+ line: `- cand-${String(startNum + i).padStart(3, '0')} | ${c.name} | route: soft | sys: ${c.systems[0] ?? 'unassigned'} | deps: - | src: ${c.anchor}${c.gaps.length ? ` | gaps: ${c.gaps.join(', ')}` : ''}`,
161
+ crossings: pairs.filter((p) => p.a === c || p.b === c).length,
162
+ })),
163
+ envelopes,
164
+ d1: questions.filter((q) => q.class === 'D1'),
165
+ d2: questions.filter((q) => q.class === 'D2'),
166
+ constraints: byAspect.constraints ?? [],
167
+ }