gcf-harness-aif-ext 0.9.1 → 0.9.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
@@ -33,7 +33,7 @@ This installs the `gcf-run` and `gcf-run-ingest` skills and registers the `gcf-h
33
33
  ai-factory gcf-harness init # per project: .claude/settings.json + .gitignore
34
34
  ```
35
35
 
36
- 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 also allows what an unattended `claude -p` run needs at least — `Edit`, `Write`, `Bash(git *)`, `Bash(ai-factory *)`, and the same two as `PowerShell(…)` rules, the tool the agent runs commands with on Windows — because print mode cannot ask and denies the rest; add your engine's tools yourself (for example `mcp__<editor-server>` for an editor driven over MCP). Commit `.claude/settings.json`: the rules apply to every Claude session in the project. 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.
36
+ It is **per project**, not global — run it again in every game repository: it adds the readset-guard and implement-guard entries to the project's `.claude/settings.json` (never `~/.claude`) and `.gcf-session/` to `.gitignore`. It also allows what an unattended `claude -p` run needs at least — `Edit`, `Write`, `Bash(git *)`, `Bash(ai-factory *)`, and the same two as `PowerShell(…)` rules, the tool the agent runs commands with on Windows — because print mode cannot ask and denies the rest; add your engine's tools yourself (for example `mcp__<editor-server>` for an editor driven over MCP). Commit `.claude/settings.json`: the rules apply to every Claude session in the project. 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.
37
37
 
38
38
  ## Upgrade
39
39
 
@@ -47,6 +47,13 @@ Updating keeps the hook entry valid — it points into the extension's install d
47
47
 
48
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
49
 
50
+ 0.9.2 holds `/gcf-run` to aif-implement. Run `ai-factory gcf-harness init` again after the update: it registers the new hook.
51
+ - The implement guard: in a session that runs `/gcf-run`, a write to a code file is denied until the session invokes the aif-implement skill (gcf-run step 13), and so is a plan tick, which only aif-implement sets. Planning stays free: `gcf/`, `.ai-factory/`, `.gcf-session/` and any markdown file. After aif-implement every write is free, the fixes of a red DoD and of the review included. It works in the harness's runs and in an interactive `/gcf-run` alike, and touches no other session.
52
+ - `/gcf-run` converges a plan when an improve pass finds nothing blocking: a wrong or missing behavior or task, a broken pillar invariant, a rule conflict, a task no check can prove. Polish (wording, splitting tasks, order) is applied, but no longer keeps the loop going. Before, a pass that changed any task asked for one more, and a fresh reviewer almost always changes something. The report row per pass gives the blocking and polish counts.
53
+ - `/gcf-run` answers aif-plan's own questions (tests, logging, docs) itself, from the item, its DoD and the project's rules.
54
+ - The run report marks an item whose session wrote code without aif-implement: `— code written outside aif-implement` on its line, `process` in `--json` and in its kept record. It reads the session's own events, its subagents' included, so it also covers a project without the guard registered. A write through Bash is seen by neither: the review of the diff is the check there.
55
+ - Lessons (with core 0.7.10): after each agent session the run asks the same session, resumed, what got in its way - tools, rules, the framework, the project template - with the questions of `ai-factory gcf lessons prompt`. Its answers go to the inbox on main as lessons (`gcf lessons add`), and the board shows them on the card, in the drawer and in the inbox. The run passes `--no-lessons` to `/gcf-run`, so the agent carries no lessons instruction while it works. The turn follows a result, a handoff (before the fresh session), a timeout or an error; not a usage limit or a stop, and only for a `claude` agent with a session id. Another agent asks its lessons itself at the end of a run on main; in a worktree it records none. It takes up to 10 minutes; its cost goes on the item, and the report line says `, N lessons`. A failed lessons turn is logged and changes nothing else. A project that turns lessons off, or a core before 0.7.10, gets no turn and no flag.
56
+
50
57
  0.9.1 is a fix release:
51
58
  - A stopped session reads as `no result (stopped)`, and its kept record as `ended: stopped`: before, the kill's exit code (`agent exit 1` on Windows).
52
59
  - `gcf-harness stop <id>` never kills the run: a stop before the item's session starts (or between two sessions of a handoff) leaves `.gcf-session/runs/<id>.stop`, and that session is stopped as it starts. Before, a stop then killed the whole run with the other items of its batch. A run hung before its first session (in its worktree setup) is not stopped. On main, a stop marks the item too once the run has reached it. Known limit: a stop in the moment between a worktree run's claim of the item and its clearing of an old mark is lost.
@@ -82,11 +89,11 @@ To remove the extension:
82
89
  ai-factory extension remove gcf-harness-aif-ext
83
90
  ```
84
91
 
85
- 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.
92
+ Removal does not touch `.claude/settings.json`: delete the entries whose command ends in `# gcf-readset-guard` or `# gcf-implement-guard` (under `PreToolUse` and `UserPromptSubmit`) by hand, or the tools they match will report a hook error.
86
93
 
87
94
  ## What it adds
88
95
 
89
- **`/gcf-run <item-id…>`** — drives work items through plan, implementation and DoD, with every gate checked *before* the tokens are spent: the audit ritual, the gate below, the context budget. The plan is written by `aif-plan`, then improved in passes, each by a fresh subagent that reads only the plan and the readset and records every default it picks; `ai-factory gcf plan check <id>` runs after each pass, and a pass that changes no task ends the loop. A plan still changing after `plan_passes` passes, or a question only the human can answer (a save format, a public API, a new dependency, a pillar conflict), blocks the item before any code is written. The DoD is `ai-factory gcf verify <id>` — the gate's checks, the item's `live:` delta, and the project's `dod.build` and `dod.test` commands from `gcf/config.yml` — plus a review by a fresh subagent, up to `dod_attempts` attempts. Every item ends in a state the core records: `gcf submit` (gate `verdict` or `decision` — waits for the human), `gcf close` (gate `auto` — done, or a soft unit closed as `unplayed`) or `gcf block` with the reason. The verdict and the decision belong to the human: the pipeline stops at `submit` and prints the playtest list.
96
+ **`/gcf-run <item-id…>`** — drives work items through plan, implementation and DoD, with every gate checked *before* the tokens are spent: the audit ritual, the gate below, the context budget. The plan is written by `aif-plan`, then improved in passes, each by a fresh subagent that reads only the plan and the readset and records every default it picks; `ai-factory gcf plan check <id>` runs after each pass, and a pass with no blocking finding (a wrong or missing behavior or task, a broken pillar invariant, a rule conflict, an unprovable task) ends the loop. A blocking finding left after `plan_passes` passes (3 by default), or a question only the human can answer (a save format, a public API, a new dependency, a pillar conflict), blocks the item before any code is written. The DoD is `ai-factory gcf verify <id>` — the gate's checks, the item's `live:` delta, and the project's `dod.build` and `dod.test` commands from `gcf/config.yml` — plus a review by a fresh subagent, up to `dod_attempts` attempts. Every item ends in a state the core records: `gcf submit` (gate `verdict` or `decision` — waits for the human), `gcf close` (gate `auto` — done, or a soft unit closed as `unplayed`) or `gcf block` with the reason. The verdict and the decision belong to the human: the pipeline stops at `submit` and prints the playtest list.
90
97
 
91
98
  **`ai-factory gcf-harness gate <item-id…>`** — the `/gcf-run` gate as code. It reads one `gcf audit --json` report and exits 1 with a one-line reason when the items may not go: a core older than 0.4.0, a `schema:` other than 1 in `gcf/config.yml`, an id with no work item, a feature (run its children; the feature closes when they are finished), an item that is not `ready`, an unplayed dependency, no WIP room for the batch (items awaiting a verdict + the batch's `gate: verdict` items over `wip_built_limit`), or two items of the batch touching the same system unless both say `parallel: ok`. The tune limit is not a gate rule: `verdict tune` itself refuses at the limit, and an item tuned up to it still gets its last build. The skill runs it and stops on a non-zero exit — no model compares the numbers.
92
99
 
@@ -96,6 +103,8 @@ Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry wh
96
103
 
97
104
  **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.
98
105
 
106
+ **implement-guard** — a `UserPromptSubmit` and `PreToolUse` hook (Skill, the write tools, Bash and PowerShell). A session that starts `/gcf-run` (typed, passed to `claude -p`, or invoked as a skill) gets a state in `.gcf-session/guard/<session_id>.json`; until it invokes the aif-implement skill, Edit, Write, MultiEdit and NotebookEdit are denied outside `gcf/`, `.ai-factory/`, `.gcf-session/` and markdown, and a markdown write that adds a `- [x]` tick is denied too. Any other session is never touched, and an error lets the call through. The move that ends the item (`gcf block`, `submit` or `close`, through Bash or PowerShell) lets the session go, so the operator's own edits after a blocked run are free again. A plugin's skill (`<plugin>:aif-implement`) counts too. Limits: the flag is per session, so in a batch the first item's aif-implement frees the rest; a write through Bash is not seen, by the run report either.
107
+
99
108
  ## Docs
100
109
 
101
110
  [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.
@@ -109,10 +109,13 @@ function limitIn(text, whole = true) {
109
109
  * @param {string} line
110
110
  * The main agent's turns also carry `context`, the tokens its window holds (a subagent's turns,
111
111
  * `parent_tool_use_id` set, fill a window of their own); the init names the `model`, the result
112
- * the `windows` of its models, and a compaction says `compacted`.
112
+ * the `windows` of its models, and a compaction says `compacted`. A turn's tool calls give
113
+ * `implement` (the aif-implement skill) and `writes` (the files written, by call id); a tool
114
+ * result gives `failed`, the calls that wrote nothing.
113
115
  * @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null},
114
116
  * context?: number, turn?: string, model?: string, windows?: Record<string, number>,
115
- * compacted?: boolean}}
117
+ * compacted?: boolean, implement?: boolean, writes?: {id: string, file: string}[],
118
+ * failed?: string[]}}
116
119
  */
117
120
  export function activity(line, now = new Date()) {
118
121
  let e;
@@ -128,12 +131,18 @@ export function activity(line, now = new Date()) {
128
131
  const content = Array.isArray(e.message?.content) ? e.message.content : [];
129
132
  if (e.type === 'system') return system(e, at);
130
133
  if (e.type === 'assistant') return { ...said(content, at), ...contextOf(e) };
131
- if (e.type === 'user')
134
+ if (e.type === 'user') {
135
+ const errors = content.filter((b) => b.type === 'tool_result' && b.is_error);
136
+
132
137
  return {
133
- lines: content
134
- .filter((b) => b.type === 'tool_result' && b.is_error)
135
- .map((b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`),
138
+ lines: errors.map(
139
+ (b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`,
140
+ ),
141
+ // A refused or failed call wrote nothing: its write does not count.
142
+ ...(errors.length ? { failed: errors.map((b) => String(b.tool_use_id)) } : {}),
136
143
  };
144
+ }
145
+
137
146
  if (e.type === 'result') return ended(e, at);
138
147
 
139
148
  return { lines: [] };
@@ -167,12 +176,32 @@ function contextOf(e) {
167
176
  return { context: tokens, ...(e.message.id ? { turn: String(e.message.id) } : {}) };
168
177
  }
169
178
 
179
+ const WRITES = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
180
+
181
+ /**
182
+ * What a turn's tool calls tell of gcf-run step 13: `implement` when one invokes the aif-implement
183
+ * skill, `writes` the files its write tools name. A subagent's turns count too.
184
+ */
185
+ function steps(uses) {
186
+ const writes = uses
187
+ .filter((u) => WRITES.includes(u.name))
188
+ .map((u) => ({ id: String(u.id), file: u.input?.file_path ?? u.input?.notebook_path }))
189
+ .filter((w) => typeof w.file === 'string');
190
+ // A plugin's skill comes as <plugin>:aif-implement.
191
+ const implement = uses.some(
192
+ (u) => u.name === 'Skill' && /(^|:)aif-implement$/.test(String(u.input?.skill ?? '')),
193
+ );
194
+
195
+ return { ...(implement ? { implement } : {}), ...(writes.length ? { writes } : {}) };
196
+ }
197
+
170
198
  /** An assistant turn: its tool calls and its text, which may be the usage-limit message. */
171
199
  function said(content, at) {
172
200
  const texts = content.filter((b) => b.type === 'text').map((b) => b.text);
173
201
 
174
202
  return {
175
203
  ...(texts.map((t) => limitIn(t)).find((l) => l.limit) ?? {}),
204
+ ...steps(content.filter((b) => b.type === 'tool_use')),
176
205
  lines: content.flatMap((b) => {
177
206
  if (b.type === 'tool_use') return [`${at} ${toolLine(b)}`];
178
207
  const text = b.type === 'text' ? cut(firstLines(b.text, 2), 300) : '';
@@ -197,6 +226,8 @@ function ended(e, at) {
197
226
 
198
227
  return {
199
228
  lines: [...text, `${at} ${summary(e, cost, Boolean(limit.limit))}`],
229
+ // The reply itself: a lessons turn answers with its JSON here.
230
+ ...(typeof e.result === 'string' ? { result: e.result } : {}),
200
231
  cost,
201
232
  ...limit,
202
233
  ...(windows.length ? { windows: Object.fromEntries(windows) } : {}),
@@ -1,4 +1,4 @@
1
- // `ai-factory gcf-harness init` — registers the readset-guard PreToolUse hook in the
1
+ // `ai-factory gcf-harness init` — registers the readset-guard and implement-guard hooks 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. It also
4
4
  // allows what a headless agent run needs (AGENT_ALLOW).
@@ -11,7 +11,6 @@ import { fileURLToPath } from 'node:url';
11
11
  import { run, cliCore, DEFAULT_AGENT, DEFAULT_TIMEOUT_MIN, MAIN_RUN, stopOf } from './run.js';
12
12
 
13
13
  const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
14
- const MARKER = 'gcf-readset-guard';
15
14
  const SCHEMA = 1; // the only gcf/config.yml schema this harness drives
16
15
  const SESSION_RULE = /^(\/|\*\*\/)?\.gcf-session\/?$/;
17
16
 
@@ -185,20 +184,62 @@ async function readSettings(settingsFile) {
185
184
  }
186
185
  }
187
186
 
187
+ // The hooks `init` registers: the file, the event, its matcher (none for a prompt event).
188
+ /** @type {Array<{name: string, event: string, matcher?: string}>} */
189
+ const GUARDS = [
190
+ { name: 'readset-guard', event: 'PreToolUse', matcher: 'Read|Grep|Glob' },
191
+ {
192
+ name: 'implement-guard',
193
+ event: 'PreToolUse',
194
+ // Bash and PowerShell: a `gcf block|submit|close` ends the run, and the guard lets go.
195
+ matcher: 'Skill|Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell',
196
+ },
197
+ { name: 'implement-guard', event: 'UserPromptSubmit' },
198
+ ];
199
+
200
+ const hooksOf = (e) => (Array.isArray(e?.hooks) ? e.hooks : []);
201
+
202
+ /**
203
+ * A newer version may watch more tools. An entry of ours alone takes the new matcher; one we
204
+ * share with the operator's own hooks keeps theirs, and ours moves to an entry of its own, at
205
+ * most one: a hook copied into several entries, or one already moved, makes no second. Returns
206
+ * how many entries changed.
207
+ */
208
+ function widen(list, matcher, ours, command) {
209
+ const before = JSON.stringify(list);
210
+ const holding = list.filter((x) => hooksOf(x).some((h) => ours.includes(h)));
211
+ const alone = (e) => e.hooks.every((h) => ours.includes(h));
212
+ // Ours alone, the right matcher first; else a new one. It keeps one hook of ours, with the
213
+ // operator's own settings on it (a timeout).
214
+ let own = holding.find((e) => e.matcher === matcher && alone(e)) ?? holding.find(alone);
215
+ if (own) own.hooks = own.hooks.slice(0, 1);
216
+ else list.push((own = { hooks: [{ ...ours[0], command }] }));
217
+ own.matcher = matcher;
218
+ for (const e of holding) if (e !== own) e.hooks = e.hooks.filter((h) => !ours.includes(h));
219
+ // An entry that held only ours is empty now: it goes.
220
+ for (const e of holding) if (e !== own && !e.hooks.length) list.splice(list.indexOf(e), 1);
221
+
222
+ return JSON.stringify(list) === before ? 0 : 1;
223
+ }
224
+
188
225
  /**
189
- * Adds the guard entry, or repoints an existing one: a moved or cloned project keeps the old
190
- * absolute path under the marker, and the registered hook would keep launching a missing file.
191
- * Returns what happened, or null when the settings already hold the right command.
226
+ * Adds the guard entry of `event`, or repoints an existing one: a moved or cloned project keeps
227
+ * the old absolute path under the marker, and the registered hook would keep launching a missing
228
+ * file. Returns what happened, or null when the settings already hold the right command.
229
+ * @param {Record<string, any>} settings
230
+ * @param {{event: string, matcher?: string}} guard
231
+ * @param {string} marker
232
+ * @param {string} command
192
233
  */
193
- function registerGuard(settings, command) {
234
+ function registerGuard(settings, { event, matcher }, marker, command) {
194
235
  settings.hooks ??= {};
195
- settings.hooks.PreToolUse ??= [];
196
- const ours = settings.hooks.PreToolUse.flatMap((e) => e?.hooks ?? []).filter(
197
- (h) => typeof h?.command === 'string' && h.command.includes(MARKER),
198
- );
236
+ settings.hooks[event] ??= [];
237
+ const ours = settings.hooks[event]
238
+ .flatMap(hooksOf)
239
+ .filter((h) => typeof h?.command === 'string' && h.command.includes(marker));
199
240
  if (!ours.length) {
200
- settings.hooks.PreToolUse.push({
201
- matcher: 'Read|Grep|Glob',
241
+ settings.hooks[event].push({
242
+ ...(matcher ? { matcher } : {}),
202
243
  hooks: [{ type: 'command', command }],
203
244
  });
204
245
 
@@ -207,8 +248,9 @@ function registerGuard(settings, command) {
207
248
 
208
249
  const stale = ours.filter((h) => h.command !== command);
209
250
  for (const h of stale) h.command = command;
251
+ const widened = matcher ? widen(settings.hooks[event], matcher, ours, command) : 0;
210
252
 
211
- return stale.length ? 'repointed' : null;
253
+ return stale.length || widened ? 'repointed' : null;
212
254
  }
213
255
 
214
256
  // What a headless `claude -p` run needs at least: it cannot ask, so anything not allowed is
@@ -258,18 +300,22 @@ async function init(program) {
258
300
 
259
301
  const projectDir = process.cwd();
260
302
  const settingsFile = path.join(projectDir, '.claude', 'settings.json');
261
- const guard = path.join(EXT_ROOT, 'hooks', 'readset-guard.js').replaceAll('\\', '/');
262
303
  const settings = await readSettings(settingsFile);
263
- const change = registerGuard(settings, `node "${guard}" # ${MARKER}`);
304
+ const changes = GUARDS.map((g) => {
305
+ const file = path.join(EXT_ROOT, 'hooks', `${g.name}.js`).replaceAll('\\', '/');
306
+ const marker = `gcf-${g.name}`;
307
+ const change = registerGuard(settings, g, marker, `node "${file}" # ${marker}`);
308
+
309
+ return change && `${g.name} (${g.event}) ${change}`;
310
+ }).filter(Boolean);
264
311
  const allowed = allowAgent(settings);
265
- if (change || allowed.length) {
312
+ if (changes.length || allowed.length) {
266
313
  await mkdir(path.dirname(settingsFile), { recursive: true });
267
314
  await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
268
315
  }
269
316
 
270
- console.log(
271
- change ? `readset-guard ${change} → ${settingsFile}` : 'readset-guard already registered',
272
- );
317
+ for (const c of changes) console.log(`${c} → ${settingsFile}`);
318
+ if (!changes.length) console.log('readset-guard and implement-guard already registered');
273
319
  if (allowed.length) console.log(`agent runs may now: ${allowed.join(', ')}`);
274
320
 
275
321
  await ignoreSession(projectDir);
@@ -291,7 +337,8 @@ export function register(program) {
291
337
  harness
292
338
  .command('init')
293
339
  .description(
294
- 'Initialize the harness in this project: readset-guard hook, agent permissions (idempotent)',
340
+ 'Initialize the harness in this project: readset and implement guards, agent permissions ' +
341
+ '(idempotent)',
295
342
  )
296
343
  .action(() => init(program));
297
344
 
@@ -398,15 +445,21 @@ export function stopRun(projectDir, id, core = cliCore(projectDir)) {
398
445
  return lock ? `stopped the run of ${id}; it stays ready` : `no run drives ${id}`;
399
446
  }
400
447
 
448
+ /**
449
+ * An item's line of the run report: its result, why, a skipped step of the process, and the
450
+ * lessons its sessions left in the inbox.
451
+ */
452
+ export const itemLine = (i) =>
453
+ [`${i.id}: ${i.result}`, i.reason ?? i.conflict ?? i.error ?? i.skipped, i.process]
454
+ .filter(Boolean)
455
+ .join(' — ') + (i.lessons ? `, ${i.lessons} lesson${i.lessons > 1 ? 's' : ''}` : '');
456
+
401
457
  /** The run report a human reads, then the playtest sheet of what now waits for play. */
402
458
  function printRun(r) {
403
459
  for (const o of r.orphans)
404
460
  console.log(`orphan ${o.id}: ${o.move ?? o.conflict ?? o.error ?? o.skipped}`);
405
461
  if (r.gate) console.error(`gcf gate: ${r.gate}`);
406
- for (const i of r.items) {
407
- const why = i.reason ?? i.conflict ?? i.error ?? i.skipped;
408
- console.log(`${i.id}: ${i.result}${why ? ` — ${why}` : ''}`);
409
- }
462
+ for (const i of r.items) console.log(itemLine(i));
410
463
 
411
464
  if (!r.gate) console.log(`\n${cliCore(process.cwd())(['playtest', 'session']).out.trim()}`);
412
465
  }
package/commands/run.js CHANGED
@@ -8,6 +8,7 @@ import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { activity, clock, count } from './activity.js';
11
+ import { planning, readState } from '../hooks/implement-guard.js';
11
12
 
12
13
  // The event stream, not the final answer alone: the log shows the session at work as it goes.
13
14
  export const DEFAULT_AGENT = 'claude -p --output-format stream-json --verbose';
@@ -202,9 +203,15 @@ function byLine(stream, take) {
202
203
  // What `claude --resume` prints on stderr for a session it does not have.
203
204
  const NO_SESSION = /No conversation found with session ID/;
204
205
 
205
- /** Keep what a session's activity told: the last of each, the model's window from the result. */
206
- function absorb(seen, a) {
206
+ /**
207
+ * Keep what a session's activity told: the last of each, the model's window from the result,
208
+ * and for gcf-run step 13 whether it invoked aif-implement and wrote code (`dir`'s planning
209
+ * files are no code).
210
+ */
211
+ function absorb(seen, a, dir) {
212
+ trackWrites(seen, a, dir);
207
213
  seen.cost = a.cost ?? seen.cost;
214
+ seen.result = a.result ?? seen.result;
208
215
  seen.session_id = a.session_id ?? seen.session_id;
209
216
  seen.limit = a.limit ?? seen.limit;
210
217
  seen.model = a.model ?? seen.model;
@@ -212,6 +219,15 @@ function absorb(seen, a) {
212
219
  if (a.windows) seen.window = a.windows[seen.model] ?? Math.max(...Object.values(a.windows));
213
220
  }
214
221
 
222
+ /** Step 13: aif-implement, and the code writes by call id until a failed result drops one. */
223
+ function trackWrites(seen, a, dir) {
224
+ seen.implement ||= Boolean(a.implement);
225
+ seen.writes ??= new Map();
226
+ for (const w of a.writes ?? []) if (!planning(dir, w.file)) seen.writes.set(w.id, w.file);
227
+ for (const id of a.failed ?? []) seen.writes.delete(id);
228
+ seen.wrote = seen.writes.size > 0;
229
+ }
230
+
215
231
  /** Write the flag the handoff guard reads into the session's checkout. */
216
232
  function writeFlag(dir, flag) {
217
233
  try {
@@ -300,7 +316,7 @@ function handoffWatch(handoff, dir, write, stop) {
300
316
  * stopped. `handoff: true` says the session was told to hand over, `handedOver` that it did.
301
317
  * @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
302
318
  * session_id?: string, limit?: {resets: string | null}, gone: boolean, handoff: boolean,
303
- * handedOver: boolean, model?: string, window?: number}>}
319
+ * handedOver: boolean, model?: string, window?: number, result?: string}>}
304
320
  */
305
321
  function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {}, handoff }) {
306
322
  return new Promise((resolve) => {
@@ -328,7 +344,7 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {},
328
344
  });
329
345
  const flushOut = byLine(child.stdout, (line) => {
330
346
  const a = activity(line);
331
- absorb(seen, a);
347
+ absorb(seen, a, dir);
332
348
  write(a.lines);
333
349
  if (open) watch.see(a, seen.model);
334
350
  });
@@ -539,7 +555,7 @@ async function prepare({ claimed, failed }, core, agentOf) {
539
555
  const agent = agentOf(id);
540
556
  const wt = json(await core(['worktree', id, '--json']), `worktree ${id}`);
541
557
  if (wt.sync) throw new Error(wt.sync);
542
- ready.push({ id, dir: wt.path, agent, release });
558
+ ready.push({ id, dir: wt.path, agent, release, lessonsPrompt: await lessonsFor(core, id) });
543
559
  } catch (e) {
544
560
  await release();
545
561
  failed.push({ id, result: 'error', error: e.message });
@@ -610,6 +626,7 @@ function recordFor(r) {
610
626
  // The fresh session a counted handoff started ended with no id: the next one is no new
611
627
  // handoff, only the same fresh start again.
612
628
  ...(!r.handoffPending && !r.session_id ? { counted: true } : {}),
629
+ ...(r.skipped ? { process: OUTSIDE_IMPLEMENT } : {}),
613
630
  at: new Date().toISOString(),
614
631
  };
615
632
  }
@@ -661,7 +678,12 @@ function noResult(r, rerun) {
661
678
 
662
679
  const STAYED = / stays ready, a rerun /;
663
680
 
664
- const taskOf = (r) => (r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`);
681
+ // `--no-lessons`: the run asks the lessons itself, after the session (see `askLessons`).
682
+ // Only a claude agent gets the turn. Another asks its lessons itself at the run's end on main;
683
+ // in a worktree it records none (shortcut: ask them on main after the merge if it matters).
684
+ const taskOf = (r) =>
685
+ `/gcf-run ${r.id}${r.main ? '' : ' --worktree'}` +
686
+ (r.lessonsPrompt && /^claude(\s|$)/.test(r.agent) ? ' --no-lessons' : '');
665
687
 
666
688
  /**
667
689
  * The agent command and prompt of an item's session: `/gcf-run`, or the resume of the session
@@ -754,9 +776,147 @@ function resumeLost(rec, ran) {
754
776
  return false;
755
777
  }
756
778
 
779
+ /** The report note of an item whose session skipped gcf-run step 13. */
780
+ export const OUTSIDE_IMPLEMENT = 'code written outside aif-implement';
781
+
782
+ /**
783
+ * Whether a session wrote code with no aif-implement before it: its stream shows none, and
784
+ * neither does the implement guard's state of its session, which a resumed session inherits.
785
+ * shortcut: without the guard registered, a resumed session whose skill ran before the resume
786
+ * reads as skipped; `gcf-harness init` registers it.
787
+ */
788
+ const skippedImplement = (r, ran) =>
789
+ Boolean(ran.wrote && !ran.implement && !readState(r.dir, ran.session_id)?.implement);
790
+
791
+ /**
792
+ * The project's lessons prompt for item `id` (`gcf lessons prompt`), or null when the project
793
+ * turned lessons off or the core has no lessons command (before 0.7.10).
794
+ */
795
+ async function lessonsFor(core, id) {
796
+ const res = await core(['lessons', 'prompt', id, '--json']);
797
+ if (!res.ok) return null;
798
+ try {
799
+ const l = JSON.parse(res.out);
800
+
801
+ return l?.off || typeof l?.prompt !== 'string' ? null : l.prompt;
802
+ } catch {
803
+ return null;
804
+ }
805
+ }
806
+
807
+ const KINDS = ['tool', 'rules', 'framework', 'template', 'other'];
808
+
809
+ /**
810
+ * The lessons in a lessons turn's reply: its JSON array, bare or in a ```json fence, each entry
811
+ * held to `{kind, text, where}`. A reply that is no array is one lesson of kind `other`, so what
812
+ * the agent said is not lost; no reply is none.
813
+ */
814
+ export function lessonsOf(reply) {
815
+ if (typeof reply !== 'string' || !reply.trim()) return [];
816
+ const fenced = reply.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1];
817
+ // A bare array with words around it: from its first [ to its last ].
818
+ const bare = reply.slice(reply.indexOf('['), reply.lastIndexOf(']') + 1);
819
+ /** @type {any} */
820
+ let list = null;
821
+ for (const body of [fenced, reply, bare].filter(Boolean)) {
822
+ try {
823
+ list = JSON.parse(String(body).trim());
824
+ if (Array.isArray(list)) break;
825
+ } catch {
826
+ list = null;
827
+ }
828
+ }
829
+
830
+ if (!Array.isArray(list))
831
+ return [{ kind: 'other', text: reply.trim().slice(0, 500), where: null }];
832
+
833
+ return list
834
+ .filter((l) => typeof l?.text === 'string' && l.text.trim())
835
+ .map((l) => ({
836
+ kind: KINDS.includes(l.kind) ? l.kind : 'other',
837
+ text: l.text.trim(),
838
+ where: typeof l.where === 'string' && l.where.trim() ? l.where.trim() : null,
839
+ }));
840
+ }
841
+
842
+ // A lessons turn is one reply: past this it is stopped, and the item's result stands.
843
+ const LESSONS_MS = 10 * 60_000;
844
+
845
+ /** Whether the session `ran` gets a lessons turn: one to resume, not cut by a limit or a stop. */
846
+ const asksLessons = (r, ran) =>
847
+ Boolean(r.lessonsPrompt && SESSION_ID.test(String(ran.session_id))) &&
848
+ !ran.limit &&
849
+ !ran.stopped &&
850
+ !ran.gone &&
851
+ /^claude(\s|$)/.test(r.agent);
852
+
853
+ /** Record `list` in the inbox on main and log it; returns how many went in. */
854
+ async function recordLessons(s, list) {
855
+ const file = path.join(s.logs, `${s.r.id}.lessons.json`);
856
+ await writeFile(file, `${JSON.stringify(list, null, 2)}\n`);
857
+ // An empty list records nothing: the core makes no commit for it.
858
+ const added = list.length
859
+ ? await s.core(['lessons', 'add', s.r.id, '--from', file, '--commit'])
860
+ : { ok: true };
861
+ const note = added.ok ? `${list.length} recorded` : (added.err || added.out).trim();
862
+ writeSync(s.fd, `[${clock()}] lessons: ${note}\n`);
863
+
864
+ return added.ok ? list.length : 0;
865
+ }
866
+
867
+ /**
868
+ * The lessons turn after a session: the same agent, resumed, answers the project's lessons
869
+ * prompt, and its answers go to the inbox on main (`gcf lessons add`). Not after a usage limit
870
+ * or a stop, not without a session to resume or a `claude` agent. The prompt goes in a file:
871
+ * its text is the project's, and a shell must not read it. Returns `{count, cost}`; a failure
872
+ * is logged and changes nothing else.
873
+ */
874
+ async function askLessons(s, ran) {
875
+ const { r, fd, launch } = s;
876
+ if (!asksLessons(r, ran)) return { count: 0 };
877
+ const promptFile = path.join(r.dir, '.gcf-session', 'lessons.md');
878
+ try {
879
+ await mkdir(path.dirname(promptFile), { recursive: true });
880
+ await writeFile(promptFile, r.lessonsPrompt);
881
+ const flags = launch?.flags(r.dir);
882
+ const agent = `${r.agent} --resume ${ran.session_id}${flags ? ` ${flags}` : ''}`;
883
+ const ask =
884
+ 'Answer the questions in .gcf-session/lessons.md: your reply is the JSON array it asks for';
885
+ // No handoff watch: one reply, and the session is past its work.
886
+ const turn = await startAgent(agent, ask, r.dir, {
887
+ fd,
888
+ timeoutMs: LESSONS_MS,
889
+ id: r.id,
890
+ onSpawn: s.onSpawn,
891
+ env: launch?.env,
892
+ handoff: null,
893
+ });
894
+ writeFileSync(s.lock, `${process.pid}\n${r.id}\n`);
895
+ // A turn cut by the limit, a timeout, a stop or an error has no answer: its text is not one.
896
+ if (turn.limit || turn.gone || turn.code !== 0) {
897
+ writeSync(fd, `[${clock()}] lessons: the turn ended without an answer, none recorded\n`);
898
+
899
+ return { count: 0, cost: turn.cost };
900
+ }
901
+
902
+ const count = await recordLessons(s, lessonsOf(turn.result));
903
+
904
+ return { count, cost: turn.cost };
905
+ } catch (e) {
906
+ writeSync(fd, `[${clock()}] lessons: ${e.message}\n`);
907
+
908
+ return { count: 0 };
909
+ } finally {
910
+ await rm(promptFile, { force: true });
911
+ // A stop that came during the turn holds for the session after it too.
912
+ ran.stopped ||= existsSync(stopOf(s.logs, r.id));
913
+ }
914
+ }
915
+
757
916
  /**
758
917
  * The sessions of one item: the first runs its command; while one hands over as told, a fresh
759
- * one goes on from the plan, up to MAX_HANDOFFS.
918
+ * one goes on from the plan, up to MAX_HANDOFFS. `skipped` says one of them wrote code outside
919
+ * aif-implement. After each session its lessons turn, before the fresh one that follows it.
760
920
  */
761
921
  async function handOver(s, { agent, prompt, rec }) {
762
922
  const { r } = s;
@@ -766,20 +926,28 @@ async function handOver(s, { agent, prompt, rec }) {
766
926
  if (fromHandoff && !rec.counted && (await capped(count, r)))
767
927
  return { code: 0, handoffs: count.handoffs, handoffLimit: true, handoffPending: false };
768
928
  let ran = await agentSession(s, agent, prompt);
769
- let cost = ran.cost;
770
929
  const lost = resumeLost(rec, ran);
930
+ let skipped = skippedImplement(r, ran);
931
+ let learned = await askLessons(s, ran);
932
+ let cost = addCost(ran.cost, learned.cost);
933
+ let lessons = learned.count;
771
934
  while ((await goesOn(s, ran)) && !(await capped(count, r))) {
772
935
  writeSync(
773
936
  s.fd,
774
937
  `[${clock()}] handoff ${count.handoffs}: a fresh session goes on from the plan\n`,
775
938
  );
776
939
  ran = await agentSession(s, r.agent, `${taskOf(r)} --handoff`);
777
- cost = addCost(cost, ran.cost);
940
+ skipped ||= skippedImplement(r, ran);
941
+ learned = await askLessons(s, ran);
942
+ cost = addCost(addCost(cost, ran.cost), learned.cost);
943
+ lessons += learned.count;
778
944
  }
779
945
 
780
946
  return {
781
947
  ...ran,
782
948
  cost,
949
+ skipped,
950
+ lessons,
783
951
  handoffs: count.handoffs,
784
952
  handoffLimit: count.limit,
785
953
  // The session was told to hand over and no fresh one took it: the next run does.
@@ -793,7 +961,7 @@ async function handOver(s, { agent, prompt, rec }) {
793
961
  * One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
794
962
  * its worktree mode; on main it records the item's move itself.
795
963
  */
796
- async function session(r, { logs, timeoutMs, fresh, launch }) {
964
+ async function session(r, { core, logs, timeoutMs, fresh, launch }) {
797
965
  /** @type {import('node:fs/promises').FileHandle | null} */
798
966
  let log = null;
799
967
  // The lock names the item and its session: `stop <id>` ends that session alone, not the run.
@@ -815,7 +983,7 @@ async function session(r, { logs, timeoutMs, fresh, launch }) {
815
983
 
816
984
  return {
817
985
  ...r,
818
- ...(await handOver({ r, logs, lock, fd: log.fd, deadline, launch, onSpawn }, cmd)),
986
+ ...(await handOver({ r, core, logs, lock, fd: log.fd, deadline, launch, onSpawn }, cmd)),
819
987
  };
820
988
  } catch (e) {
821
989
  return { ...r, code: null, error: e.message };
@@ -1004,7 +1172,16 @@ async function mainItem(projectDir, id, ctx) {
1004
1172
  }
1005
1173
  };
1006
1174
 
1007
- const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true, done };
1175
+ const lessonsPrompt = await lessonsFor(core, id);
1176
+ const s = {
1177
+ id,
1178
+ dir: projectDir,
1179
+ agent: agentOf(id),
1180
+ release: none,
1181
+ main: true,
1182
+ done,
1183
+ lessonsPrompt,
1184
+ };
1008
1185
  r = await session(s, ctx);
1009
1186
  } catch (e) {
1010
1187
  r = { id, error: e.message };
@@ -1015,9 +1192,16 @@ async function mainItem(projectDir, id, ctx) {
1015
1192
  const item = await mainResult(core, r);
1016
1193
  await remember(logs, r, item);
1017
1194
 
1018
- return { ...item, ...r.cost, ...(r.handoffs ? { handoffs: r.handoffs } : {}) };
1195
+ return { ...item, ...r.cost, ...extras(r) };
1019
1196
  }
1020
1197
 
1198
+ /** What rides on an item's line beside its result: its handoffs, and a skipped step 13. */
1199
+ const extras = (r) => ({
1200
+ ...(r?.handoffs ? { handoffs: r.handoffs } : {}),
1201
+ ...(r?.skipped ? { process: OUTSIDE_IMPLEMENT } : {}),
1202
+ ...(r?.lessons ? { lessons: r.lessons } : {}),
1203
+ });
1204
+
1021
1205
  /** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
1022
1206
  async function runWorktrees(claims, ctx) {
1023
1207
  const { core, logs, agentOf } = ctx;
@@ -1051,7 +1235,7 @@ async function runWorktrees(claims, ctx) {
1051
1235
  return items.map((i) => {
1052
1236
  const r = runs.find((x) => x.id === i.id);
1053
1237
 
1054
- return { ...i, ...r?.cost, ...(r?.handoffs ? { handoffs: r.handoffs } : {}) };
1238
+ return { ...i, ...r?.cost, ...extras(r) };
1055
1239
  });
1056
1240
  }
1057
1241
 
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.9.1",
4
+ "version": "0.9.2",
5
5
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
6
  "skills": [
7
7
  "skills/gcf-run",
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // Implement guard (gcf-run step 13): a UserPromptSubmit and PreToolUse hook. In a session that
3
+ // runs /gcf-run, the first implementation of a plan task goes through the aif-implement skill:
4
+ // until the session invokes it, a write to a code file is denied, and so is a plan tick, which
5
+ // only aif-implement sets. Planning stays free: gcf/, .ai-factory/, .gcf-session/ and any
6
+ // markdown file. After aif-implement every write is free, the fixes of a red DoD and of the
7
+ // review included. A session that never ran /gcf-run is never touched.
8
+ // shortcut: the flag is per session, not per item: in a batch, the first item's aif-implement,
9
+ // or its end move, frees the rest; key it by item when batches share one session.
10
+ // shortcut: a write through Bash is not seen here, nor by the run's check: the diff review is.
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ const WRITES = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
15
+ const PLANNING_DIRS = ['gcf', '.ai-factory', '.gcf-session'];
16
+ const TICK = /^\s*- \[[xX]\] /gm;
17
+
18
+ const DENY =
19
+ 'the first implementation of a plan task goes through the aif-implement skill ' +
20
+ '(gcf-run step 13): run it on the plan. Planning may write gcf/, .ai-factory/ and markdown; ' +
21
+ 'the fixes of a red DoD or a review finding are free after aif-implement.';
22
+
23
+ function readStdin() {
24
+ try {
25
+ return JSON.parse(readFileSync(0, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Whether a write to `file` is planning, not code: a path under gcf/, .ai-factory/ or
33
+ * .gcf-session/, any markdown file, or a file outside the project.
34
+ */
35
+ export function planning(projectDir, file) {
36
+ const rel = path.relative(path.resolve(projectDir), path.resolve(projectDir, file));
37
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return true;
38
+ const first = rel.split(/[\\/]/)[0];
39
+
40
+ return PLANNING_DIRS.includes(first) || /\.md$/i.test(rel);
41
+ }
42
+
43
+ const ticks = (text) => (String(text ?? '').match(TICK) ?? []).length;
44
+
45
+ /** The ticks a markdown write adds: its new text against the text it replaces. */
46
+ function addedTicks(input, abs) {
47
+ const t = input.tool_input ?? {};
48
+ if (input.tool_name === 'Write') {
49
+ const old = existsSync(abs) ? readFileSync(abs, 'utf8') : '';
50
+
51
+ return ticks(t.content) - ticks(old);
52
+ }
53
+
54
+ const edits = input.tool_name === 'MultiEdit' ? (t.edits ?? []) : [t];
55
+
56
+ return edits.reduce((n, e) => n + ticks(e?.new_string) - ticks(e?.old_string), 0);
57
+ }
58
+
59
+ // A plugin's skill comes as <plugin>:<skill>.
60
+ const skillIs = (input, name) =>
61
+ input.tool_name === 'Skill' &&
62
+ new RegExp(`(^|:)${name}$`).test(String(input.tool_input?.skill ?? ''));
63
+ const promptIs = (input, command) =>
64
+ new RegExp(`^\\s*/${command}(\\s|$)`).test(String(input.prompt ?? ''));
65
+ const started = (input, name) => skillIs(input, name) || promptIs(input, name);
66
+ // The move that ends a run's item, run as a command (not echoed or searched for): the session's
67
+ // work on it is over. A move the core then refuses frees the session too: fail open.
68
+ const CLI = String.raw`((npx\s+|pnpm\s+exec\s+|\S*[\\/])?ai-factory(\.cmd)?\s+)?`;
69
+ const ENDS = new RegExp(String.raw`(^|[;&|(])\s*${CLI}gcf\s+(block|submit|close)\s+[a-z0-9]`, 'm');
70
+ const endsRun = (input) =>
71
+ ['Bash', 'PowerShell'].includes(input.tool_name) &&
72
+ ENDS.test(String(input.tool_input?.command ?? ''));
73
+
74
+ /**
75
+ * The session's state after `input`, and the verdict on it: `{state, allow, reason?}`. `state`
76
+ * is `{run, implement}`; /gcf-run and aif-implement set it, the move that ends the item clears
77
+ * `run`, so the operator's own edits after a blocked run are free again. `implement` stays: the
78
+ * run's check reads it for a session resumed after a limit, whose stream has no Skill call.
79
+ */
80
+ export function decide(input, projectDir, state) {
81
+ if (input && endsRun(input))
82
+ return { state: { run: false, implement: Boolean(state?.implement) }, allow: true };
83
+ const next = { run: Boolean(state?.run), implement: Boolean(state?.implement) };
84
+ if (!input) return { state: next, allow: true };
85
+ next.run ||= started(input, 'gcf-run');
86
+ next.implement ||= started(input, 'aif-implement');
87
+ const guarded = next.run && !next.implement && WRITES.includes(input.tool_name);
88
+
89
+ return { state: next, ...(guarded ? writeVerdict(input, projectDir) : { allow: true }) };
90
+ }
91
+
92
+ /** A write before aif-implement: code is denied, and so is a tick in markdown. */
93
+ function writeVerdict(input, projectDir) {
94
+ const file = input.tool_input?.file_path ?? input.tool_input?.notebook_path;
95
+ if (typeof file !== 'string') return { allow: true };
96
+ const abs = path.resolve(projectDir, file);
97
+ if (!planning(projectDir, abs)) return { allow: false, reason: DENY };
98
+ if (/\.md$/i.test(abs) && addedTicks(input, abs) > 0)
99
+ return { allow: false, reason: `a plan tick is aif-implement's: ${DENY}` };
100
+
101
+ return { allow: true };
102
+ }
103
+
104
+ /** The session's state file; a session id is a UUID, anything else gets no file. */
105
+ export const stateFile = (projectDir, sessionId) =>
106
+ /^[\w-]+$/.test(String(sessionId ?? ''))
107
+ ? path.join(projectDir, '.gcf-session', 'guard', `${sessionId}.json`)
108
+ : null;
109
+
110
+ /** A session's state: `{run, implement}`, or null without one. */
111
+ export function readState(projectDir, sessionId) {
112
+ const file = stateFile(projectDir, sessionId);
113
+ try {
114
+ return file ? JSON.parse(readFileSync(file, 'utf8')) : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // Hook entry: only runs when executed directly, so tests and run.js can import decide().
121
+ const entry = process.argv[1]?.replaceAll('\\', '/');
122
+ if (import.meta.url === `file://${entry}` || entry?.endsWith('implement-guard.js')) {
123
+ // Fail open: a hook that breaks on its own error must not stop the session's work.
124
+ try {
125
+ const input = readStdin();
126
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input?.cwd ?? process.cwd();
127
+ const old = readState(projectDir, input?.session_id);
128
+ const verdict = decide(input, projectDir, old);
129
+ const file = stateFile(projectDir, input?.session_id);
130
+ const changed =
131
+ verdict.state.run !== Boolean(old?.run) ||
132
+ verdict.state.implement !== Boolean(old?.implement);
133
+ if (file && changed) {
134
+ mkdirSync(path.dirname(file), { recursive: true });
135
+ writeFileSync(file, `${JSON.stringify(verdict.state)}\n`);
136
+ }
137
+
138
+ // A prompt is never refused: exit 2 there would drop the operator's message.
139
+ if (!verdict.allow && input?.hook_event_name !== 'UserPromptSubmit') {
140
+ console.error(`gcf implement-guard: ${verdict.reason}`);
141
+ process.exit(2);
142
+ }
143
+ } catch {
144
+ // Allow.
145
+ }
146
+
147
+ process.exit(0);
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@ inputs:
15
15
  - 1 or more work item or design unit ids; a batch shares one build and one playtest list
16
16
  - `--worktree` when `ai-factory gcf-harness run` started this session inside the item's worktree
17
17
  - `--handoff` when the harness started this session after the last one filled its context: the plan's `## Handoff` says where it stopped
18
+ - `--no-lessons` when the harness asks this session's lessons itself, in a turn after it ends
18
19
 
19
20
  preconditions:
20
21
  - `gcf/` exists and `ai-factory gcf audit` runs (the gcf-aif-ext extension provides it)
@@ -25,6 +26,10 @@ forbidden_behaviors:
25
26
  - 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
26
27
  - do not compare the audit numbers yourself: the gate command is the only judge of whether an item may go
27
28
  - do not ask the human about a reversible choice during planning: pick the safe default and write it into the plan
29
+ - answer aif-plan's own questions (tests, logging, docs, other preferences) yourself from the item, its DoD and the project's rules, and write each answer into the plan
30
+ - a plan pass sorts each finding as blocking or polish, and reports how many of each it found
31
+ - blocking: a wrong or missing behavior, a missing or wrong task, a broken pillar invariant, a conflict with a project rule, a task no check can prove
32
+ - polish: wording, splitting or merging tasks, their order, more detail - the pass applies it, and it does not keep the plan from converging
28
33
  - do not edit `status:` or `gcf/LIVE.md` by hand: `submit`, `close` and `block` own them
29
34
  - do not write a plan task's first implementation outside the aif-implement skill: it ticks the task done in the plan, and the board shows the item's progress from those ticks
30
35
  - a fix for a red DoD or a review finding goes in directly, without aif-implement
@@ -52,8 +57,8 @@ workflow:
52
57
  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
53
58
  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
54
59
  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
55
- 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
56
- 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
60
+ 10. after each pass run `ai-factory gcf plan check <id>`; a failure blocks the item with its reason; a pass with no blocking finding ends the loop - the plan converged, its polish applied
61
+ 11. passes <= `plan_passes` from `gcf/config.yml` (3 by default); a blocking finding left after the last pass: run `ai-factory gcf block <id> --reason "plan did not converge: <the finding>"` and stop
57
62
  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
58
63
  13. run the aif-implement skill on `@<plan path>`, write the item's `live:` delta, then run `ai-factory gcf verify <id>` and check the item's negative constraints; a non-zero exit is a red DoD; attempts <= `dod_attempts`, each with a diagnosis
59
64
  14. review the diff with a fresh subagent; fix what it finds before the commit; DoD still red after the attempts: commit no code, run `ai-factory gcf block <id> --reason "<diagnosis>"` - its commit carries the item file with its delta
@@ -61,7 +66,7 @@ workflow:
61
66
  16. commit the implementation, its plan and the `live:` delta via aif-commit
62
67
  17. gate `verdict` or `decision`: run `ai-factory gcf submit <id>`; gate `auto`: run `ai-factory gcf close <id>` - a soft unit closes unplayed
63
68
  18. a dependency still waiting for its verdict refuses the close: run `ai-factory gcf block <id> --reason "waits for <dep>"`
64
- 19. close the run: delete `.gcf-session/readset.json`, run `ai-factory gcf playtest session` and hand its checklist to the human, with one report row per plan pass: pass, tasks changed, result
69
+ 19. close the run: delete `.gcf-session/readset.json`, run `ai-factory gcf playtest session` and hand its checklist to the human, with one report row per plan pass: pass, blocking, polish, result
65
70
  20. after a tune verdict or an unblock, re-enter this pipeline at step 5: the gates re-run because the counters moved
66
71
  21. with `--worktree`: skip the integrate of step 1, steps 2-4 and the playtest of step 19, and change no `gcf/` file but the item's `live:`
67
72
  22. with `--worktree`, every stop - a failed gate, an over budget readset, a failed plan check, a refused command, steps 11, 12, 14, 18 - writes `{"block": "<reason>"}` to `.gcf-session/outcome.json` and ends the session
@@ -69,6 +74,7 @@ workflow:
69
74
  24. with `--worktree`, step 17 is: commit everything but `gcf/.metrics`, then run `ai-factory gcf verify <id> --mark`; the harness merges the branch
70
75
  25. with `--handoff`: run steps 1, 5 and 6, read the plan's `## Handoff`, skip steps 7-11 unless it says the plan passes were not done, then go on at step 13; a plan with no `## Handoff` goes on at step 13 from its ticks
71
76
  26. with `--handoff`, take the review findings and the DoD diagnosis of `## Handoff` into steps 13-15, and delete the section before the commit of step 16
77
+ 27. without `--no-lessons` and without `--worktree`, end the run with the lessons: run `ai-factory gcf lessons prompt <id>` for each item of the run and do what it says
72
78
 
73
79
  invocation:
74
80
  - run when the user names a work item or a unit to drive; batches share steps 1 and 19