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 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.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
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 add <path-to>/packages/gcf-harness-aif-ext
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 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.
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
 
@@ -1,4 +1,4 @@
1
- // `ai-factory gcf-harness install` — registers the readset-guard PreToolUse hook in the
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('install')
27
- .description('Register the readset-guard hook in .claude/settings.json (idempotent)')
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 already = JSON.stringify(settings.hooks.PreToolUse).includes(MARKER);
50
- if (!already) {
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: `node "${guard}" # ${MARKER}` }],
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')).includes(line);
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.0",
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",
@@ -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
- 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 };
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:` that scopes the search.',
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.0",
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.0"
31
+ "gcf-aif-ext": "^0.2.2"
32
32
  },
33
33
  "files": [
34
34
  "extension.json",
@@ -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: depends all collapsed, tune_count < limit, WIP under `wip_built_limit` - the audit report from step 1 carries each answer
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. 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)
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
@@ -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 over the anchored sections' },
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
- const gddText = sections.map((s) => `[${s.anchor}] ${s.heading ?? '(preamble)'}\n${s.body}`).join('\n\n')
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) => 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 ?? [] })),
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 = Object.fromEntries(extracted.filter(Boolean).map((r) => [r.aspect, r.cited]))
71
- const inferred = extracted.filter(Boolean).flatMap((r) => r.inferred)
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*([a-zA-Zа-яё%]*)/)
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 = 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 }
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 perCandidate = await pipeline(
118
+ const enriched = await pipeline(
97
119
  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 ?? ''}`,
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) => r && { ...c, ...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 enriched = perCandidate.filter(Boolean)
105
- const FINDINGS = {
127
+ const FINDING = {
106
128
  type: 'object',
107
129
  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
- },
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: ['findings'],
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) => () => 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}`,
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
- )))).filter(Boolean).flatMap((r) => r.findings)
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
- const conflicts = (await pipeline(
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) => 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)
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, 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(', ')}` : ''}`,
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,