gcf-harness-aif-ext 0.5.0 → 0.5.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/README.md CHANGED
@@ -11,7 +11,7 @@ The harness turns policy-as-text into an executable pipeline. It has **no memory
11
11
 
12
12
  ## Requirements
13
13
 
14
- - [`gcf-aif-ext`](../gcf-aif-ext) `^0.4.0` 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
14
+ - [`gcf-aif-ext`](../gcf-aif-ext) `^0.4.1` 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
15
  - Claude Code 2.1.270+ (hooks, dynamic workflows) — the only supported runtime
16
16
  - AI Factory 2.x, Node.js ≥ 22.14
17
17
 
@@ -45,6 +45,8 @@ Updating keeps the hook entry valid — it points into the extension's install d
45
45
 
46
46
  0.5.0 drives work items (`gcf/work/<id>.md`, schema 3) instead of cards and requires `gcf-aif-ext` 0.4.0 — update both together; against an older core the gate refuses with `gcf-aif-ext >= 0.4.0 is required`. A project still holding schema 2 cards in `gcf/cards/` re-creates them first (see the core's "Upgrading from 0.3.x"). What changes for you: `/gcf-run` takes ids of items in `ready`; it no longer stops at `built` but ends each item with `submit`, `close` or `block`; the plan is improved in passes until it converges; the WIP cap counts only items that end waiting for a verdict.
47
47
 
48
+ 0.5.1 is a fix release. During a run, `gcf/config.yml` is always readable, and a Grep inside `gcf/` reads only readset files. An import breaks a `depends:` cycle the pair checks closed and asks a D1 about it. It cuts a section longer than a segment at paragraphs, and lens and pair prompts carry a window around the quote. `/gcf-run` checks the plan against each pillar's invariant and asks each pillar's review question over the diff. Use it with core 0.4.1, which brings the parent feature into the readset.
49
+
48
50
  0.4.1 is a patch release. `/gcf-run` now commits the implementation first, then flips the card to `built` in a second commit, with `built_at` set to the implementation commit's hash. Update the core to 0.3.1 together with the harness: without it, an audit run before a verdict makes the pre-commit gate refuse that verdict.
49
51
 
50
52
  To remove the extension:
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.5.0",
4
+ "version": "0.5.1",
5
5
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
6
  "skills": [
7
7
  "skills/gcf-run",
@@ -49,22 +49,32 @@ function outsideVerdict(input, abs, gcfRoot) {
49
49
  return { allow: false, reason: UNSCOPED_GREP };
50
50
  }
51
51
 
52
- /** The paths a manifest allows, plus the two every stage may always read. */
53
- function allowedPaths(manifest) {
52
+ /**
53
+ * The files a manifest allows, plus the two every stage may always read: the index, and the
54
+ * config whose DoD commands and limits the run reads at its plan and DoD steps.
55
+ */
56
+ function allowedFiles(manifest) {
54
57
  // `gcf context --json` emits files as {path, tokens_est}; accept that shape as well as
55
58
  // the projected string list, so a manifest written either way still gates correctly.
56
59
  const files = (manifest.files ?? []).map((f) => (typeof f === 'string' ? f : f?.path));
57
60
 
58
- return new Set([...files.filter(Boolean), 'gcf/INDEX.md', 'gcf']);
61
+ return new Set([...files.filter(Boolean), 'gcf/INDEX.md', 'gcf/config.yml']);
59
62
  }
60
63
 
61
- /** A path inside gcf/ passes only through the readset. */
62
- function insideVerdict(rel, manifest) {
63
- const allowed = allowedPaths(manifest);
64
+ /**
65
+ * A path inside gcf/ passes only through the readset. A Grep reads every file under a
66
+ * directory, so it gets exact files only; Glob and Read may name a directory holding one.
67
+ */
68
+ function insideVerdict(rel, manifest, tool) {
69
+ const allowed = allowedFiles(manifest);
64
70
  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 };
71
+ const holds = [...allowed].some((f) => f.startsWith(`${rel}/`));
72
+ if (holds && tool !== 'Grep') return { allow: true };
73
+ if (holds)
74
+ return {
75
+ allow: false,
76
+ reason: `a Grep over ${rel} reads files outside the readset — Grep a readset file by path.`,
77
+ };
68
78
 
69
79
  return {
70
80
  allow: false,
@@ -94,7 +104,7 @@ export function decide(input, projectDir, manifest) {
94
104
  // The gcf root itself relativizes to '' — unguarded it becomes 'gcf/' and matches nothing.
95
105
  const relPosix = relFromGcf.replaceAll('\\', '/');
96
106
 
97
- return insideVerdict(relPosix ? `gcf/${relPosix}` : 'gcf', manifest);
107
+ return insideVerdict(relPosix ? `gcf/${relPosix}` : 'gcf', manifest, input.tool_name);
98
108
  }
99
109
 
100
110
  function readManifest(projectDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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.4.0"
31
+ "gcf-aif-ext": "^0.4.1"
32
32
  },
33
33
  "files": [
34
34
  "extension.json",
@@ -32,16 +32,18 @@ workflow:
32
32
  6. readset: run `ai-factory gcf context <item-id> --json`; write `{"item":"<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
33
33
  7. plan: run aif-plan full for the item into its `plan:` path, by default `.ai-factory/plans/<id>.md`, reading only the readset files; an existing plan (the item came back after a tune, an unblock or a continue) is kept, not rewritten
34
34
  8. improve the plan in passes, each pass a fresh subagent that reads only the plan and the readset and applies aif-improve without questions, writing each default it picks into the plan
35
- 9. after each pass run `ai-factory gcf plan check <id>`; a failure blocks the item with its reason; a pass that changes no task ends the loop - the plan converged
36
- 10. passes <= `plan_passes` from `gcf/config.yml` (5 by default); a plan still changing after the last pass: run `ai-factory gcf block <id> --reason "plan did not converge"` and stop
37
- 11. a question only the human can answer - a save format, a public API, a new dependency, a pillar conflict: run `ai-factory gcf block <id> --reason "<question>"` and stop
38
- 12. implement with aif-implement, then verify: the project's `dod.test` and `dod.build` commands from `gcf/config.yml`, plus the item's negative constraints; attempts <= `dod_attempts`, each with a diagnosis
39
- 13. review the diff with a fresh subagent; fix what it finds before the commit; DoD still red after the attempts: commit nothing, run `ai-factory gcf block <id> --reason "<diagnosis>"`
40
- 14. commit the implementation via aif-commit; write the item's `live:` delta
41
- 15. gate `verdict` or `decision`: run `ai-factory gcf submit <id>`; gate `auto`: run `ai-factory gcf close <id>` - a soft unit closes unplayed
42
- 16. a dependency still waiting for its verdict refuses the close: run `ai-factory gcf block <id> --reason "waits for <dep>"`
43
- 17. close the run: delete `.gcf-session/readset.json`, run `ai-factory gcf playtest` and hand the list to the human, with one report row per plan pass: pass, tasks changed, result
44
- 18. after a tune verdict or an unblock, re-enter this pipeline at step 5: the gates re-run because the counters moved
35
+ 9. each pass also checks every task against the `invariant` of each pillar in `gcf/PILLARS.md`: the pass reworks a task that breaks one, and a conflict no rework removes is a pillar conflict of step 12
36
+ 10. after each pass run `ai-factory gcf plan check <id>`; a failure blocks the item with its reason; a pass that changes no task ends the loop - the plan converged
37
+ 11. passes <= `plan_passes` from `gcf/config.yml` (5 by default); a plan still changing after the last pass: run `ai-factory gcf block <id> --reason "plan did not converge"` and stop
38
+ 12. a question only the human can answer - a save format, a public API, a new dependency, a pillar conflict: run `ai-factory gcf block <id> --reason "<question>"` and stop
39
+ 13. implement with aif-implement, then verify: the project's `dod.test` and `dod.build` commands from `gcf/config.yml`, plus the item's negative constraints; attempts <= `dod_attempts`, each with a diagnosis
40
+ 14. review the diff with a fresh subagent; fix what it finds before the commit; DoD still red after the attempts: commit nothing, run `ai-factory gcf block <id> --reason "<diagnosis>"`
41
+ 15. the reviewer also answers the `review_question` of each pillar in `gcf/PILLARS.md` over the diff: an answer that breaks the pillar is a finding
42
+ 16. commit the implementation via aif-commit; write the item's `live:` delta
43
+ 17. gate `verdict` or `decision`: run `ai-factory gcf submit <id>`; gate `auto`: run `ai-factory gcf close <id>` - a soft unit closes unplayed
44
+ 18. a dependency still waiting for its verdict refuses the close: run `ai-factory gcf block <id> --reason "waits for <dep>"`
45
+ 19. close the run: delete `.gcf-session/readset.json`, run `ai-factory gcf playtest` and hand the list to the human, with one report row per plan pass: pass, tasks changed, result
46
+ 20. after a tune verdict or an unblock, re-enter this pipeline at step 5: the gates re-run because the counters moved
45
47
 
46
48
  invocation:
47
- - run when the user names a work item or a unit to drive; batches share steps 1 and 17
49
+ - run when the user names a work item or a unit to drive; batches share steps 1 and 19
@@ -140,12 +140,13 @@ const COHERENCE = [
140
140
  const sectionText = (s) => `[${s.anchor}] ${s.heading ?? '(preamble)'}\n${s.body}`;
141
141
 
142
142
  /**
143
- * GCF-PRD §11.7: a large GDD is cut into segments, each agent sees one. A section is never
144
- * split — quotes are verified against whole sections. ponytail: chars, not tokens.
143
+ * GCF-PRD §11.7: a large GDD is cut into segments, each agent sees one. A section longer than
144
+ * a segment is cut at paragraphs, each part keeps the section's anchor: quotes are verified
145
+ * against the whole section (bodyOf). ponytail: chars, not tokens.
145
146
  */
146
147
  function packSegments(sections, limit) {
147
148
  const segments = [];
148
- for (const s of sections) {
149
+ for (const s of sections.flatMap((x) => sectionParts(x, limit))) {
149
150
  const last = segments[segments.length - 1];
150
151
  const text = sectionText(s);
151
152
  if (last && last.size + text.length <= limit) {
@@ -159,6 +160,62 @@ function packSegments(sections, limit) {
159
160
  return segments;
160
161
  }
161
162
 
163
+ /** A section as parts that fit `limit`: whole paragraphs, a paragraph too long cut by length. */
164
+ function sectionParts(s, limit) {
165
+ if (sectionText(s).length <= limit) return [s];
166
+ // 24: room for the ` (part N of M)` the heading gains. A floor of 500: a segment_chars at or
167
+ // below the heading would otherwise cut the body into one part per character.
168
+ const room = Math.max(500, limit - sectionText({ ...s, body: '' }).length - 24);
169
+ const paras = s.body
170
+ .split(/\n\s*\n/)
171
+ .flatMap((p) =>
172
+ Array.from({ length: Math.ceil(p.length / room) || 1 }, (_, i) =>
173
+ p.slice(i * room, (i + 1) * room),
174
+ ),
175
+ );
176
+ const bodies = [];
177
+ for (const p of paras) {
178
+ const last = bodies.length - 1;
179
+ if (last >= 0 && bodies[last].length + 2 + p.length <= room) bodies[last] += `\n\n${p}`;
180
+ else bodies.push(p);
181
+ }
182
+
183
+ return bodies.map((body, i) => ({
184
+ ...s,
185
+ heading: `${s.heading ?? '(preamble)'} (part ${i + 1} of ${bodies.length})`,
186
+ body,
187
+ }));
188
+ }
189
+
190
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
191
+
192
+ /**
193
+ * The part of a section an agent judging one unit needs: the whole body when it fits `limit`,
194
+ * else a window of `limit` chars centred on the quote, its edges pulled in to paragraph breaks
195
+ * that keep the quote. The quote matches across any whitespace, as provenance does, so one
196
+ * spanning two paragraphs is found too; a quote absent from the body yields its head.
197
+ */
198
+ function around(body, quote, rawLimit) {
199
+ const limit = Math.max(500, rawLimit);
200
+ if (body.length <= limit) return body;
201
+ const words = String(quote ?? '')
202
+ .trim()
203
+ .split(/\s+/)
204
+ .filter(Boolean);
205
+ const m = words.length ? new RegExp(words.map(escapeRe).join('\\s+')).exec(body) : null;
206
+ if (!m) return body.slice(0, limit);
207
+ const qEnd = m.index + m[0].length;
208
+ let start = Math.max(0, m.index - Math.max(0, Math.floor((limit - m[0].length) / 2)));
209
+ let end = Math.min(body.length, start + limit);
210
+ start = Math.max(0, Math.min(start, end - limit));
211
+ const from = body.indexOf('\n\n', start);
212
+ if (start > 0 && from >= 0 && from + 2 <= m.index) start = from + 2;
213
+ const to = body.lastIndexOf('\n\n', end);
214
+ if (end < body.length && to >= qEnd) end = to;
215
+
216
+ return body.slice(start, end);
217
+ }
218
+
162
219
  // A line wrap in the GDD is not an edit: whitespace runs compare equal, as in `gcf quotes`.
163
220
  const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
164
221
 
@@ -212,6 +269,30 @@ function crossingPairs(cands) {
212
269
  return pairs;
213
270
  }
214
271
 
272
+ /**
273
+ * Drop the edge that closes each cycle of `deps` (id → ids it depends on), in place, and return
274
+ * each dropped edge with its cycle: which of the two really comes first is the designer's call.
275
+ */
276
+ function breakCycles(deps) {
277
+ const broken = [];
278
+ const state = new Map();
279
+ const visit = (id, trail) => {
280
+ state.set(id, 'open');
281
+ for (const d of [...deps[id]]) {
282
+ if (state.get(d) === 'open') {
283
+ deps[id] = deps[id].filter((x) => x !== d);
284
+ broken.push({ id, dep: d, cycle: [...trail.slice(trail.indexOf(d)), id, d] });
285
+ } else if (!state.has(d)) visit(d, [...trail, id]);
286
+ }
287
+
288
+ state.set(id, 'done');
289
+ };
290
+
291
+ for (const id of Object.keys(deps)) if (!state.has(id)) visit(id, []);
292
+
293
+ return broken;
294
+ }
295
+
215
296
  /** An id no unit has used yet: the agent's slug, suffixed -2, -3… on a collision. */
216
297
  function freeId(wanted, taken) {
217
298
  const base = /^[a-z0-9]+(-[a-z0-9]+)*$/.test(wanted ?? '') ? wanted : 'unit';
@@ -342,7 +423,8 @@ const spread = args.spread ?? 0.3;
342
423
  // Every id the project has used (`ai-factory gcf where --json`): a dropped or done unit's id
343
424
  // is never minted again, or the new unit would collide with the old one's history.
344
425
  const taken = new Set(Array.isArray(args.taken) ? args.taken : []);
345
- const segments = packSegments(sections, Number(args.segment_chars) || 60000);
426
+ const segmentChars = Number(args.segment_chars) || 60000;
427
+ const segments = packSegments(sections, segmentChars);
346
428
  const bodyOf = new Map(sections.map((s) => [s.anchor, s.body]));
347
429
  const multi = segments.length > 1;
348
430
 
@@ -370,7 +452,7 @@ log(`${inferred.length} inferred units diverted to questions`);
370
452
 
371
453
  phase('Lenses');
372
454
  const enriched = await pipeline(candidates, (c) =>
373
- ask(lensPrompt(c, subsystemNames, bodyOf.get(c.anchor) ?? ''), {
455
+ ask(lensPrompt(c, subsystemNames, around(bodyOf.get(c.anchor) ?? '', c.quote, segmentChars)), {
374
456
  label: `lens:${c.name}`,
375
457
  phase: 'Lenses',
376
458
  schema: CAND_LENS,
@@ -396,10 +478,19 @@ const coherence = coherenceResults.flatMap((r) => r.findings);
396
478
  phase('Conflicts');
397
479
  const pairs = crossingPairs(cands);
398
480
  log(`${pairs.length} crossing pair(s) from the interaction matrix`);
399
- const pairSections = ({ a, b }) =>
400
- [...new Set([a.anchor, b.anchor])].map((anchor) =>
401
- sectionText(sections.find((s) => s.anchor === anchor) ?? { anchor, body: '' }),
402
- );
481
+ // Two units share the prompt, so each section gets half of a segment around its own quote.
482
+ const pairSections = ({ a, b }) => {
483
+ const units = a.anchor === b.anchor ? [a] : [a, b];
484
+
485
+ return units.map((u) => {
486
+ const s = sections.find((x) => x.anchor === u.anchor) ?? { anchor: u.anchor, body: '' };
487
+ const quotes = a.anchor === b.anchor ? [a.quote, b.quote] : [u.quote];
488
+ const body = quotes.map((q) => around(s.body, q, segmentChars / units.length / quotes.length));
489
+
490
+ return sectionText({ ...s, body: [...new Set(body)].join('\n\n[…]\n\n') });
491
+ });
492
+ };
493
+
403
494
  const conflictResults = await pipeline(pairs, (p) =>
404
495
  ask(conflictPrompt(p, pairSections(p)), {
405
496
  label: `conflict:${p.a.name}×${p.b.name}`,
@@ -415,6 +506,16 @@ for (const { order, pair } of conflictResults) {
415
506
  if (order === 'b_first') deps[pair.a.id].push(pair.b.id);
416
507
  }
417
508
 
509
+ // Each pair agent decided alone: their orders may close a cycle the gate would never open.
510
+ const anchorOf = new Map(cands.map((c) => [c.id, c.anchor]));
511
+ const cycleQuestions = breakCycles(deps).map(({ id, dep, cycle }) => ({
512
+ lens: 'depends',
513
+ anchors: [...new Set(cycle.map((x) => anchorOf.get(x)))],
514
+ class: 'D1',
515
+ question: `the pair orders close a cycle ${cycle.join(' -> ')}: which comes first?`,
516
+ proposed: `${id} does not depend on ${dep}`,
517
+ }));
518
+
418
519
  // ─── Package: deterministic code — an agent never writes the final report ────────────
419
520
 
420
521
  const provenanceQuestions = inferred.map((u) => ({
@@ -448,6 +549,7 @@ const questions = [
448
549
  ...gddQuestions,
449
550
  ...coherence,
450
551
  ...conflicts,
552
+ ...cycleQuestions,
451
553
  ...provenanceQuestions,
452
554
  ...envelopeQuestions,
453
555
  ];