gcf-harness-aif-ext 0.8.9 → 0.9.0

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
@@ -47,6 +47,14 @@ 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.0, to update together with core 0.7.6:
51
+ - The harness's own agent runs with `--permission-mode bypassPermissions`: it may do everything but the project's deny rules, which hold in every mode. The rules come from the deny groups of `gcf/config.yml` (`ai-factory gcf permissions`, or the board): git history and uncommitted work, recursive delete, publishing, the human's GCF moves and the guards are on by default; secrets, changes outside the project and killing processes are off. `git push` is allowed. Deny rules match the command as written: they catch accidents, not a rewording, and `/gcf-run` makes a refused command a stop with `needs permission: <command>`.
52
+ - A run loads the project's settings and MCP servers only (`--setting-sources project,local --strict-mcp-config --mcp-config .mcp.json`): the operator's plugins, hooks and claude.ai connectors stay out. So does `env` or `apiKeyHelper` in `~/.claude/settings.json`: an operator who logs in through them, not `claude login`, names the agent with `--agent`. The deny rules and the handoff guard come in `.gcf-session/runs/agent-settings.json` (`--settings`), so a worktree gets them without a commit.
53
+ - Context handoff: past `context_handoff_pct` (60 by default) of the window, or at a compaction, the run flags the session; the handoff guard then denies every call of the main agent but the plan's read and edit with the instruction to write `## Handoff` into the plan and end. The run starts a fresh session with `/gcf-run <id> --handoff`, which goes on from the plan: the ticked tasks stay done. A session that works on past its flag is stopped; after 3 handoffs in a row the item is blocked with `context handoff limit`. The window is `context_window`, else the one a result of the same model reported (`.gcf-session/runs/windows.json`); with neither, the first session of a model runs without a handoff and says so. The sessions of an item share one timeout, and their costs add up; `--json` gives `handoffs`.
54
+ - `auto_compact_pct` sets the agent's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`, at least 15 points above the handoff share.
55
+ - The readset-guard reads the manifest of the directory the session started in (`CLAUDE_PROJECT_DIR`): an agent's `cd` no longer lifts the readset.
56
+ - `/gcf-run` writes a plan task's code through aif-implement, so the plan ticks show the item's progress, and answers aif-implement's questions itself: no commit, stash or merge.
57
+
50
58
  0.8.0, to update together with core 0.7.0:
51
59
  - `run_mode: main` in `gcf/config.yml` (or `run --mode main`): items run one after another in the main checkout, for an agent that works through an editor bound to the project folder. The item carries the core's session mark (`gcf claim`), not a run lock, so the agent records its own move; one run works the checkout at a time (`.gcf-session/runs/.main-run.lock`).
52
60
  - `gcf-harness next [N] --json`: the ready items that can run together now, by priority; each is taken while the batch with it passes the gate.
@@ -38,7 +38,8 @@ const resultText = (content) =>
38
38
  Array.isArray(content) ? content.map((c) => c?.text ?? '').join('\n') : String(content ?? '');
39
39
 
40
40
  /** `1.2M`, `85k`, `950`: one decimal below ten of its unit. */
41
- const count = (n) => (n >= 1e6 ? scaled(n / 1e6, 'M') : n >= 1e3 ? scaled(n / 1e3, 'k') : `${n}`);
41
+ export const count = (n) =>
42
+ n >= 1e6 ? scaled(n / 1e6, 'M') : n >= 1e3 ? scaled(n / 1e3, 'k') : `${n}`;
42
43
  const scaled = (v, unit) => `${v < 10 ? v.toFixed(1) : Math.round(v)}${unit}`;
43
44
 
44
45
  /** `8s`, `34m`, `1h 12m`. */
@@ -106,7 +107,12 @@ function limitIn(text, whole = true) {
106
107
  * final text, a summary line and the cost. `session_id` comes with the init event, `limit` with
107
108
  * the usage-limit message, in whatever form it arrives: the session can be resumed after either.
108
109
  * @param {string} line
109
- * @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null}}}
110
+ * The main agent's turns also carry `context`, the tokens its window holds (a subagent's turns,
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`.
113
+ * @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null},
114
+ * context?: number, turn?: string, model?: string, windows?: Record<string, number>,
115
+ * compacted?: boolean}}
110
116
  */
111
117
  export function activity(line, now = new Date()) {
112
118
  let e;
@@ -120,8 +126,8 @@ export function activity(line, now = new Date()) {
120
126
 
121
127
  const at = `[${clock(now)}]`;
122
128
  const content = Array.isArray(e.message?.content) ? e.message.content : [];
123
- if (e.type === 'system') return system(e);
124
- if (e.type === 'assistant') return said(content, at);
129
+ if (e.type === 'system') return system(e, at);
130
+ if (e.type === 'assistant') return { ...said(content, at), ...contextOf(e) };
125
131
  if (e.type === 'user')
126
132
  return {
127
133
  lines: content
@@ -133,11 +139,33 @@ export function activity(line, now = new Date()) {
133
139
  return { lines: [] };
134
140
  }
135
141
 
136
- /** A system event: the init names the session; the rest (hooks, thinking) give nothing. */
137
- const system = (e) =>
138
- e.subtype === 'init' && typeof e.session_id === 'string'
139
- ? { lines: [], session_id: e.session_id }
140
- : { lines: [] };
142
+ /**
143
+ * A system event: the init names the session and its model; a compaction is a line, the
144
+ * agent's context summarized; the rest (hooks, thinking) give nothing.
145
+ */
146
+ function system(e, at) {
147
+ if (e.subtype === 'compact_boundary')
148
+ return { lines: [`${at} compacted: the agent's context was summarized`], compacted: true };
149
+ if (e.subtype !== 'init' || typeof e.session_id !== 'string') return { lines: [] };
150
+
151
+ return {
152
+ lines: [],
153
+ session_id: e.session_id,
154
+ ...(typeof e.model === 'string' ? { model: e.model } : {}),
155
+ };
156
+ }
157
+
158
+ /** The tokens a main-agent turn holds in its window: its input, read from cache or written. */
159
+ function contextOf(e) {
160
+ const u = e.message?.usage;
161
+ if (e.parent_tool_use_id != null || !u) return {};
162
+ const tokens =
163
+ Number(u.input_tokens ?? 0) +
164
+ Number(u.cache_read_input_tokens ?? 0) +
165
+ Number(u.cache_creation_input_tokens ?? 0);
166
+
167
+ return { context: tokens, ...(e.message.id ? { turn: String(e.message.id) } : {}) };
168
+ }
141
169
 
142
170
  /** An assistant turn: its tool calls and its text, which may be the usage-limit message. */
143
171
  function said(content, at) {
@@ -162,9 +190,15 @@ function ended(e, at) {
162
190
  // An error result counts the message anywhere in it; any other only as the whole text.
163
191
  const limit = limitIn(e.result, !e.is_error);
164
192
 
193
+ // The window of each model the session used: its subagents may run on another.
194
+ const windows = Object.entries(e.modelUsage ?? {})
195
+ .map(([m, u]) => [m, Number(u?.contextWindow)])
196
+ .filter(([, n]) => n > 0);
197
+
165
198
  return {
166
199
  lines: [...text, `${at} ${summary(e, cost, Boolean(limit.limit))}`],
167
200
  cost,
168
201
  ...limit,
202
+ ...(windows.length ? { windows: Object.fromEntries(windows) } : {}),
169
203
  };
170
204
  }
@@ -4,11 +4,11 @@
4
4
  // allows what a headless agent run needs (AGENT_ALLOW).
5
5
  // `ai-factory gcf-harness gate <ids…>` — the /gcf-run gate as code (Harness-PRD §4.3).
6
6
  import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
7
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { run, cliCore, DEFAULT_AGENT, DEFAULT_TIMEOUT_MIN, MAIN_RUN } from './run.js';
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
14
  const MARKER = 'gcf-readset-guard';
@@ -214,6 +214,9 @@ function registerGuard(settings, command) {
214
214
  // What a headless `claude -p` run needs at least: it cannot ask, so anything not allowed is
215
215
  // denied. The engine's own tools (an editor's MCP server) are the project's to add. On Windows
216
216
  // the agent runs commands through its PowerShell tool, which Bash rules do not cover.
217
+ // The harness's own agent runs with bypassPermissions and the project's deny rules (run.js
218
+ // runFlags), where allow rules do nothing: these serve an agent named with `--agent` and a
219
+ // manual session. shortcut: the list stays this short; grow the deny groups, not this.
217
220
  const AGENT_ALLOW = [
218
221
  'Edit',
219
222
  'Write',
@@ -379,8 +382,16 @@ export function stopRun(projectDir, id, core = cliCore(projectDir)) {
379
382
  const runs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
380
383
  const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
381
384
  const lock = lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
385
+ // Between two sessions of the item (a context handoff) the lock names none: the mark keeps
386
+ // the run from starting the next one.
387
+ if (lock?.item === id)
388
+ writeFileSync(
389
+ stopOf(runs, id),
390
+ `${Date.now()}
391
+ `,
392
+ );
382
393
  if (lock && lock.session && alive(lock.session)) killTree(lock.session, true);
383
- else if (lock) killTree(lock.run, false);
394
+ else if (lock && lock.item !== id) killTree(lock.run, false);
384
395
  core(['release', id]);
385
396
 
386
397
  return lock ? `stopped the run of ${id}; it stays ready` : `no run drives ${id}`;
package/commands/run.js CHANGED
@@ -3,14 +3,97 @@
3
3
  // verified branches a dead run left; an agent that needs the human leaves `outcome.json` in its
4
4
  // worktree, and the block is recorded on main — a worktree never writes gcf/ status.
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
- import { existsSync, utimesSync, writeFileSync, writeSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, utimesSync, writeFileSync, writeSync } from 'node:fs';
7
7
  import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
- import { activity, clock } from './activity.js';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { activity, clock, count } from './activity.js';
10
11
 
11
12
  // The event stream, not the final answer alone: the log shows the session at work as it goes.
12
13
  export const DEFAULT_AGENT = 'claude -p --output-format stream-json --verbose';
13
14
 
15
+ const HOOKS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'hooks');
16
+
17
+ /**
18
+ * What the harness's own agent runs with (Harness-PRD §7): everything allowed but the project's
19
+ * deny rules, which hold in every mode; the project's settings and MCP servers only, so the
20
+ * operator's plugins, hooks and connectors stay out of the run; and `settingsFile`, the deny
21
+ * rules and the handoff guard. An agent the operator names (`--agent`) gets none of it.
22
+ */
23
+ export function runFlags(dir, settingsFile) {
24
+ const mcp = existsSync(path.join(dir, '.mcp.json')) ? ' --mcp-config .mcp.json' : '';
25
+
26
+ return (
27
+ '--permission-mode bypassPermissions --setting-sources project,local ' +
28
+ `--strict-mcp-config${mcp} --settings "${settingsFile}"`
29
+ );
30
+ }
31
+
32
+ /** `.gcf-session/runs/agent-settings.json`: the deny rules of `gcf board`, the handoff guard. */
33
+ export async function writeAgentSettings(logs, agent) {
34
+ const file = path.join(logs, 'agent-settings.json');
35
+ const guard = path.join(HOOKS, 'handoff-guard.js').replaceAll('\\', '/');
36
+ const settings = {
37
+ permissions: { deny: agent?.deny ?? [], allow: agent?.allow ?? [] },
38
+ hooks: {
39
+ PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: `node "${guard}"` }] }],
40
+ },
41
+ };
42
+ await writeFile(file, `${JSON.stringify(settings, null, 2)}\n`);
43
+
44
+ return file;
45
+ }
46
+
47
+ // The window of a model a run met before: `<model>: tokens`, from the result of its sessions.
48
+ const windowsOf = (logs) => path.join(logs, 'windows.json');
49
+
50
+ /**
51
+ * The context window a session's share is taken of: the project's `context_window`, the one a
52
+ * result of the same model reported, then a `[1m]` model; null when none says it.
53
+ */
54
+ export function windowFor(model, configured, learned) {
55
+ if (configured > 0) return configured;
56
+ if (model && learned?.[model] > 0) return learned[model];
57
+
58
+ return /\[1m\]$/i.test(model ?? '') ? 1_000_000 : null;
59
+ }
60
+
61
+ /** Keep the window a session's result reported for its model. */
62
+ async function learnWindow(logs, learned, ran) {
63
+ if (!ran.model || !(ran.window > 0) || learned[ran.model] === ran.window) return;
64
+ learned[ran.model] = ran.window;
65
+ await writeFile(windowsOf(logs), `${JSON.stringify(learned, null, 2)}\n`).catch(() => {});
66
+ }
67
+
68
+ // The flag the handoff guard reads, in the session's own checkout.
69
+ const HANDOFF_FLAG = path.join('.gcf-session', 'handoff.json');
70
+ // Fresh sessions one item may take in a row; the next handoff blocks it.
71
+ export const MAX_HANDOFFS = 3;
72
+ const HANDOFF_LIMIT =
73
+ `context handoff limit: ${MAX_HANDOFFS} fresh sessions did not finish the item - ` +
74
+ 'split it, or raise context_handoff_pct';
75
+ // A session that keeps on working past its flag: main-agent turns, or points over the share.
76
+ const HARD_TURNS = 8;
77
+ const HARD_OVER = 15;
78
+
79
+ /** Two costs as one: what an item's sessions spent together. */
80
+ function addCost(a, b) {
81
+ if (!a || !b) return a ?? b;
82
+ const t = (k) => a.tokens[k] + b.tokens[k];
83
+
84
+ return {
85
+ cost_usd: a.cost_usd + b.cost_usd,
86
+ tokens: {
87
+ input: t('input'),
88
+ output: t('output'),
89
+ cache_read: t('cache_read'),
90
+ cache_creation: t('cache_creation'),
91
+ },
92
+ turns: a.turns + b.turns,
93
+ duration_ms: a.duration_ms + b.duration_ms,
94
+ };
95
+ }
96
+
14
97
  /** The core CLI: the same ai-factory that runs this command, in the project. */
15
98
  export function cliCore(projectDir) {
16
99
  return (args) => {
@@ -104,9 +187,11 @@ function byLine(stream, take) {
104
187
  rest = lines.pop() ?? '';
105
188
  for (const l of lines) take(l);
106
189
  });
190
+ // Cleared before the take: a take that ends the session flushes again, and must find nothing.
107
191
  const flush = () => {
108
- if (rest) take(rest);
192
+ const last = rest;
109
193
  rest = '';
194
+ if (last) take(last);
110
195
  };
111
196
 
112
197
  stream.on('end', flush);
@@ -123,10 +208,16 @@ const NO_SESSION = /No conversation found with session ID/;
123
208
  * good. Its stdout reaches the log through `activity`, its stderr as it is; `cost` is what a
124
209
  * Claude session reported at its end, `session_id` the id a rerun resumes it by, `limit` the
125
210
  * usage limit that ended it, `gone` that the session it was to resume does not exist.
211
+ *
212
+ * `handoff` ({pct, windowOf(model), plan}) watches the main agent's context: past `pct` of the
213
+ * window, or at a compaction, the session gets `.gcf-session/handoff.json`, and the handoff
214
+ * guard turns its tool calls into the instruction to hand over; one that keeps on working is
215
+ * stopped. `handoff: true` says the session was told to hand over.
126
216
  * @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
127
- * session_id?: string, limit?: {resets: string | null}, gone: boolean}>}
217
+ * session_id?: string, limit?: {resets: string | null}, gone: boolean, handoff: boolean,
218
+ * model?: string, window?: number}>}
128
219
  */
129
- function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
220
+ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {}, handoff }) {
130
221
  return new Promise((resolve) => {
131
222
  const command = `${agent} "${prompt}"`;
132
223
  // A line before the agent's first: the log's time says the session is alive from its start.
@@ -135,7 +226,8 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
135
226
  cwd: dir,
136
227
  shell: true,
137
228
  // The session's own gate must not refuse the item this run marked as running.
138
- env: { ...process.env, GCF_RUN_ITEM: id },
229
+ // The guards read the session's own checkout, not one an outer Claude session named.
230
+ env: { ...process.env, ...env, GCF_RUN_ITEM: id, CLAUDE_PROJECT_DIR: dir },
139
231
  detached: !WIN,
140
232
  windowsHide: true,
141
233
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -147,6 +239,13 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
147
239
  let sessionId;
148
240
  let limit;
149
241
  let gone = false;
242
+ let model;
243
+ let window;
244
+ let context = 0;
245
+ let flagged = false;
246
+ let unknown = false;
247
+ let turn;
248
+ let after = 0;
150
249
  const write = (lines) => {
151
250
  try {
152
251
  if (open && lines.length) writeSync(fd, lines.map((l) => `${l}\n`).join(''));
@@ -155,12 +254,50 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
155
254
  }
156
255
  };
157
256
 
257
+ /** The handoff watch of one event: flag the session past its share, stop it past more. */
258
+ const watch = (compacted) => {
259
+ if (!handoff?.pct || !open) return;
260
+ const win = handoff.windowOf(model);
261
+ const pct = win ? Math.round((100 * context) / win) : null;
262
+ if (pct === null && !compacted && !unknown) {
263
+ unknown = true;
264
+ write([`[${clock()}] handoff: off, the window of ${model ?? 'the model'} is unknown`]);
265
+ }
266
+ if (!flagged && (compacted || (pct !== null && pct >= handoff.pct))) {
267
+ flagged = true;
268
+ const flag = { pct, tokens: context, window: win, plan: handoff.plan ?? null };
269
+ try {
270
+ mkdirSync(path.join(dir, '.gcf-session'), { recursive: true });
271
+ writeFileSync(path.join(dir, HANDOFF_FLAG), `${JSON.stringify(flag)}\n`);
272
+ } catch {
273
+ // No flag: the turns past it stop the session all the same.
274
+ }
275
+ const at = compacted ? 'a compaction' : `context ${pct}%`;
276
+ const of = win && !compacted ? ` (${count(context)} of ${count(win)})` : '';
277
+ write([`[${clock()}] handoff: ${at}${of}, the session hands over`]);
278
+ }
279
+ if (flagged && (after > HARD_TURNS || (pct !== null && pct >= handoff.pct + HARD_OVER))) {
280
+ write([`[${clock()}] handoff: the session went on past its flag, stopped`]);
281
+ killTree(child);
282
+ end('handoff');
283
+ }
284
+ };
285
+
158
286
  const flushOut = byLine(child.stdout, (line) => {
159
287
  const a = activity(line);
160
288
  cost = a.cost ?? cost;
161
289
  sessionId = a.session_id ?? sessionId;
162
290
  limit = a.limit ?? limit;
291
+ model = a.model ?? model;
292
+ // The init model's window; the largest when the result does not name that model.
293
+ if (a.windows) window = a.windows[model] ?? Math.max(...Object.values(a.windows));
163
294
  write(a.lines);
295
+ if (a.context !== undefined) {
296
+ context = a.context;
297
+ if (flagged && a.turn !== turn) after++;
298
+ turn = a.turn;
299
+ }
300
+ if (a.context !== undefined || a.compacted) watch(Boolean(a.compacted));
164
301
  });
165
302
  const flushErr = byLine(child.stderr, (line) => {
166
303
  gone ||= NO_SESSION.test(line);
@@ -177,7 +314,20 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
177
314
  open = false;
178
315
  sessions.delete(child);
179
316
  clearTimeout(timer);
180
- resolve({ code, cost, session_id: sessionId, limit, gone });
317
+ // Told to hand over, and it went on past the flag: it read the guard's instruction, or the
318
+ // run stopped it. A flag on its last turn found a session already done.
319
+ const handedOver = flagged && (after > 0 || code === 'handoff');
320
+ resolve({
321
+ code,
322
+ cost,
323
+ session_id: sessionId,
324
+ limit,
325
+ gone,
326
+ handoff: flagged,
327
+ handedOver,
328
+ model,
329
+ window,
330
+ });
181
331
  }
182
332
 
183
333
  child.on('error', () => end(-1));
@@ -369,6 +519,9 @@ async function prepare({ claimed, failed }, core, agentOf) {
369
519
  return { ready, failed };
370
520
  }
371
521
 
522
+ /** `<id>.stop`: `gcf-harness stop` between two sessions of the item: no fresh one starts. */
523
+ export const stopOf = (logs, id) => path.join(logs, `${id}.stop`);
524
+
372
525
  /** `<id>.agent.json`: the agent session an item's last run left unfinished, for a rerun. */
373
526
  const recordOf = (logs, id) => path.join(logs, `${id}.agent.json`);
374
527
 
@@ -380,7 +533,10 @@ async function kept(logs, id) {
380
533
  try {
381
534
  const rec = JSON.parse(await readFile(recordOf(logs, id), 'utf8'));
382
535
 
383
- return SESSION_ID.test(String(rec?.session_id)) ? rec : null;
536
+ if (SESSION_ID.test(String(rec?.session_id))) return rec;
537
+
538
+ // A handoff needs no session: the rerun starts fresh. An id that is not one is dropped.
539
+ return rec?.ended === 'handoff' ? { ...rec, session_id: undefined } : null;
384
540
  } catch {
385
541
  return null;
386
542
  }
@@ -393,12 +549,26 @@ async function kept(logs, id) {
393
549
  async function remember(logs, r, item) {
394
550
  if (item.result === 'error') return;
395
551
  if (!STAYED.test(item.result) || r.lost) return rm(recordOf(logs, r.id), { force: true });
396
- if (!r.session_id) return;
397
- const ended = r.limit ? 'limit' : r.code === 'timeout' ? 'timeout' : `exit ${r.code}`;
552
+ if (!r.session_id && !r.handoffPending && !r.handoffs) return;
553
+ // A session that handed over is not resumed: the rerun starts fresh from the plan. So does
554
+ // one after a handoff that reported no id: the full session before it is never resumed.
555
+ const ended =
556
+ r.handoffPending || !r.session_id
557
+ ? 'handoff'
558
+ : r.limit
559
+ ? 'limit'
560
+ : r.code === 'timeout'
561
+ ? 'timeout'
562
+ : `exit ${r.code}`;
398
563
  const rec = {
399
- session_id: r.session_id,
564
+ ...(r.session_id ? { session_id: r.session_id } : {}),
400
565
  ended,
401
566
  ...(r.limit?.resets ? { resets: r.limit.resets } : {}),
567
+ // The handoffs so far: the cap holds across runs.
568
+ ...(r.handoffs ? { handoffs: r.handoffs } : {}),
569
+ // The fresh session a counted handoff started ended with no id: the next one is no new
570
+ // handoff, only the same fresh start again.
571
+ ...(!r.handoffPending && !r.session_id ? { counted: true } : {}),
402
572
  at: new Date().toISOString(),
403
573
  };
404
574
  await writeFile(recordOf(logs, r.id), `${JSON.stringify(rec, null, 2)}\n`);
@@ -416,6 +586,17 @@ function noResult(r, rerun) {
416
586
  `no result (its session could not be resumed, agent exit ${r.code}): ` +
417
587
  `${r.id} stays ready, a rerun starts fresh`,
418
588
  };
589
+ if (r.handoffPending) {
590
+ const limit = r.limit
591
+ ? `usage limit${r.limit.resets ? `, resets ${r.limit.resets}` : ''}; `
592
+ : '';
593
+
594
+ return {
595
+ id: r.id,
596
+ result: `paused (${limit}context handoff): ${r.id} stays ready, a rerun goes on from the plan`,
597
+ ...(r.limit ? { limit_resets: r.limit.resets } : {}),
598
+ };
599
+ }
419
600
  const again = r.session_id ? 'a rerun resumes its session' : rerun;
420
601
  if (r.limit) {
421
602
  const resets = r.limit.resets ? `, resets ${r.limit.resets}` : '';
@@ -434,16 +615,20 @@ function noResult(r, rerun) {
434
615
 
435
616
  const STAYED = / stays ready, a rerun /;
436
617
 
618
+ const taskOf = (r) => (r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`);
619
+
437
620
  /**
438
621
  * The agent command and prompt of an item's session: `/gcf-run`, or the resume of the session
439
622
  * its last run kept (`--resume`, in the same directory, so the agent finds it) unless `fresh`.
440
623
  */
441
624
  async function commandOf(r, logs, fresh) {
442
- const task = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
625
+ const task = taskOf(r);
443
626
  if (fresh) await rm(recordOf(logs, r.id), { force: true });
627
+ const rec = fresh ? null : await kept(logs, r.id);
628
+ // A session that handed over goes on in a fresh one, whatever the agent.
629
+ if (rec?.ended === 'handoff') return { agent: r.agent, prompt: `${task} --handoff`, rec };
444
630
  // `--resume` is a flag of `claude`: another agent gets the task, and the record stays.
445
- const rec = fresh || !/^claude(\s|$)/.test(r.agent) ? null : await kept(logs, r.id);
446
- if (!rec) return { agent: r.agent, prompt: task, rec };
631
+ if (!rec || !/^claude(\s|$)/.test(r.agent)) return { agent: r.agent, prompt: task, rec: null };
447
632
  // One plain line: the prompt goes into the shell command in double quotes.
448
633
  const ended = String(rec.ended).replace(/[^\w -]/g, '');
449
634
 
@@ -458,36 +643,113 @@ async function commandOf(r, logs, fresh) {
458
643
  * One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
459
644
  * its worktree mode; on main it records the item's move itself.
460
645
  */
461
- async function session(r, logs, timeoutMs, fresh) {
646
+ async function session(r, { logs, timeoutMs, fresh, launch }) {
462
647
  /** @type {import('node:fs/promises').FileHandle | null} */
463
648
  let log = null;
464
649
  // The lock names the item and its session: `stop <id>` ends that session alone, not the run.
465
650
  const lock = path.join(logs, `${r.main ? MAIN_RUN : r.id}.lock`);
466
- const onSpawn = (pid) => writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
651
+ const onSpawn = (pid) => {
652
+ writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
653
+ // A stop that came while the lock named no session left only its mark: it ends this one.
654
+ if (existsSync(stopOf(logs, r.id))) killTree({ pid });
655
+ };
467
656
  // On main the item carries a session mark; a stage with no gcf command must not expire it.
468
657
  const mark = path.join(logs, `${r.id}.session`);
469
658
  const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
659
+ const flag = path.join(r.dir, HANDOFF_FLAG);
660
+ await rm(stopOf(logs, r.id), { force: true });
470
661
  try {
471
- const { agent, prompt, rec } = await commandOf(r, logs, fresh);
662
+ let { agent, prompt, rec } = await commandOf(r, logs, fresh);
472
663
  log = await open(path.join(logs, `${r.id}.log`), 'a');
473
- const opts = { fd: log.fd, timeoutMs, id: r.id, onSpawn };
474
- const ran = await startAgent(agent, prompt, r.dir, opts);
664
+ const fd = log.fd;
665
+ const flags = launch?.flags(r.dir);
666
+ const handoff = launch?.handoff && { ...launch.handoff, plan: launch.planOf?.(r.id) };
667
+ // One deadline for the item, however many sessions it takes.
668
+ const deadline = Date.now() + timeoutMs;
669
+ let handoffs = Number(rec?.handoffs) || 0;
670
+ let cost;
671
+ let ran = null;
672
+ let lost = false;
673
+ let handoffLimit = false;
674
+ const stopped = () => existsSync(stopOf(logs, r.id));
675
+ // The cap counts each handoff once, the one a kept record still owes a session included.
676
+ const capped = async () => {
677
+ if (handoffs < MAX_HANDOFFS) {
678
+ handoffs++;
679
+
680
+ return false;
681
+ }
682
+ handoffLimit = true;
683
+ // A worktree records no move: the main checkout reads the block from its outcome.
684
+ if (!r.main) {
685
+ await mkdir(path.join(r.dir, '.gcf-session'), { recursive: true });
686
+ await writeFile(
687
+ path.join(r.dir, '.gcf-session', 'outcome.json'),
688
+ `${JSON.stringify({ block: HANDOFF_LIMIT })}
689
+ `,
690
+ );
691
+ }
475
692
 
476
- // A resume that claude answered with `No conversation found`: the session is gone, and the
477
- // next run starts fresh rather than fail on it again. Any other early end (a stop, a crash,
478
- // no network) keeps it for the next try.
479
- const lost = Boolean(rec) && ran.gone;
693
+ return true;
694
+ };
695
+ // A record whose handoff its own run already counted (`counted`) owes none.
696
+ const fromHandoff = rec?.ended === 'handoff';
697
+ const owed = fromHandoff && !rec.counted;
698
+ for (let first = true; !(first && owed && (await capped())); first = false) {
699
+ await rm(flag, { force: true });
700
+ const command = flags ? `${agent} ${flags}` : agent;
701
+ const left = Math.max(deadline - Date.now(), 1000);
702
+ const opts = { fd, timeoutMs: left, id: r.id, onSpawn, env: launch?.env, handoff };
703
+ ran = await startAgent(command, prompt, r.dir, opts);
704
+ await rm(flag, { force: true });
705
+ // Between two sessions the lock names none: a stop then marks the item, not the run.
706
+ writeFileSync(
707
+ lock,
708
+ `${process.pid}
709
+ ${r.id}
710
+ `,
711
+ );
712
+ cost = addCost(cost, ran.cost);
713
+ if (launch?.learned) await learnWindow(logs, launch.learned, ran);
714
+ // A resume that claude answered with `No conversation found`: the session is gone, and
715
+ // the next run starts fresh rather than fail on it again. Any other early end (a stop, a
716
+ // crash, no network) keeps it for the next try.
717
+ if (first) lost = Boolean(rec) && !fromHandoff && ran.gone;
718
+ // The resumed session's id stands in for one its own run did not report; a fresh session
719
+ // after a handoff has none to borrow: the full one is never resumed.
720
+ if (first && !fromHandoff && !lost) ran.session_id ??= rec?.session_id;
721
+
722
+ // Only a session that handed over as told goes on: one that saw its flag and ended its
723
+ // reply, or that the run stopped past it. A stop, a limit or the deadline leaves the
724
+ // handoff to the next run.
725
+ const over = ran.handedOver && (ran.code === 0 || ran.code === 'handoff') && !ran.limit;
726
+ if (!over || Date.now() >= deadline || stopped() || (await r.done?.())) break;
727
+ if (await capped()) break;
728
+ writeSync(
729
+ fd,
730
+ `[${clock()}] handoff ${handoffs}: a fresh session goes on from the plan
731
+ `,
732
+ );
733
+ agent = r.agent;
734
+ prompt = `${taskOf(r)} --handoff`;
735
+ }
480
736
 
481
737
  return {
482
738
  ...r,
483
- ...ran,
484
- session_id: ran.session_id ?? (lost ? undefined : rec?.session_id),
739
+ ...(ran ?? { code: 0 }),
740
+ cost,
741
+ handoffs,
742
+ handoffLimit,
743
+ // The session was told to hand over and no fresh one took it: the next run does.
744
+ // A limit there too: the rerun after the reset starts fresh, it does not resume the full one.
745
+ handoffPending: Boolean(ran?.handoff) && !handoffLimit,
485
746
  lost,
486
747
  };
487
748
  } catch (e) {
488
749
  return { ...r, code: null, error: e.message };
489
750
  } finally {
490
751
  if (keep) clearInterval(keep);
752
+ await rm(stopOf(logs, r.id), { force: true });
491
753
  await log?.close();
492
754
  await r.release();
493
755
  }
@@ -569,6 +831,34 @@ function resultOf(run, integrated) {
569
831
 
570
832
  export const DEFAULT_TIMEOUT_MIN = 120;
571
833
 
834
+ /**
835
+ * How the run starts its sessions (Harness-PRD §7): a `managed` agent, the harness's own, gets
836
+ * the run flags, the settings file and the handoff watch, which needs the guard those settings
837
+ * bring; every agent gets the compaction share. A core that reports no deny rules is refused:
838
+ * bypass with none would allow everything.
839
+ */
840
+ async function launchOf(logs, managed, board) {
841
+ if (managed && !Array.isArray(board.agent?.deny))
842
+ throw new Error('gcf board reports no agent deny rules: update gcf-aif-ext to 0.7.6 or later');
843
+ const settings = managed ? await writeAgentSettings(logs, board.agent) : null;
844
+ const ctx = board.context ?? {};
845
+ const learned = await readFile(windowsOf(logs), 'utf8')
846
+ .then((t) => JSON.parse(t) ?? {})
847
+ .catch(() => ({}));
848
+
849
+ return {
850
+ flags: (dir) => (settings ? runFlags(dir, settings) : ''),
851
+ env: ctx.compact_pct > 0 ? { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(ctx.compact_pct) } : {},
852
+ handoff: managed &&
853
+ ctx.handoff_pct > 0 && {
854
+ pct: ctx.handoff_pct,
855
+ windowOf: (model) => windowFor(model, ctx.window, learned),
856
+ },
857
+ learned,
858
+ planOf: () => null,
859
+ };
860
+ }
861
+
572
862
  // The move an item's status shows after a run on main: the agent recorded it itself.
573
863
  const MOVE_OF = {
574
864
  'awaiting-verdict': 'submit',
@@ -619,30 +909,47 @@ async function runMain(projectDir, ids, ctx) {
619
909
  }
620
910
 
621
911
  /** One item of a main run: marked, its session, its mark cleared, its result read. */
622
- async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs, fresh }) {
912
+ async function mainItem(projectDir, id, ctx) {
913
+ const { core, logs, agentOf } = ctx;
623
914
  let r;
624
915
  try {
625
916
  const claimed = await core(['claim', id]);
626
917
  if (!claimed.ok) throw new Error((claimed.err || claimed.out).trim());
627
918
  const none = async () => {};
919
+ // The agent recorded the item's move: no fresh session takes it over.
920
+ // A board that cannot be read is no move: the run goes on, and the result read says why.
921
+ const done = async () => {
922
+ try {
923
+ const items = json(await core(['board', '--json']), 'board').items;
628
924
 
629
- const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true };
630
- r = await session(s, logs, timeoutMs, fresh);
925
+ return items?.find((i) => i.id === id)?.status !== 'ready';
926
+ } catch {
927
+ return false;
928
+ }
929
+ };
930
+
931
+ const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true, done };
932
+ r = await session(s, ctx);
631
933
  } catch (e) {
632
934
  r = { id, error: e.message };
633
935
  }
634
936
 
635
937
  await core(['release', id]);
938
+ if (r.handoffLimit) await core(['block', id, '--reason', HANDOFF_LIMIT]);
636
939
  const item = await mainResult(core, r);
637
940
  await remember(logs, r, item);
638
941
 
639
- return { ...item, ...r.cost };
942
+ return { ...item, ...r.cost, ...(r.handoffs ? { handoffs: r.handoffs } : {}) };
640
943
  }
641
944
 
642
945
  /** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
643
- async function runWorktrees(claims, { core, logs, agentOf, timeoutMs, fresh }) {
946
+ async function runWorktrees(claims, ctx) {
947
+ const { core, logs, agentOf } = ctx;
644
948
  const { ready, failed } = await prepare(claims, core, agentOf);
645
- const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs, fresh)));
949
+ // A session that verified its branch, or left an outcome, is done: no fresh one follows.
950
+ const done = (r) => () =>
951
+ verified(r.dir, r.id) || existsSync(path.join(r.dir, '.gcf-session', 'outcome.json'));
952
+ const runs = await Promise.all(ready.map((r) => session({ ...r, done: done(r) }, ctx)));
646
953
  const items = [...failed];
647
954
  for (const r of runs) {
648
955
  const outcome = await outcomeOf(r.dir);
@@ -665,7 +972,11 @@ async function runWorktrees(claims, { core, logs, agentOf, timeoutMs, fresh }) {
665
972
  );
666
973
 
667
974
  // What each session spent rides on its item's line, when the agent reported it.
668
- return items.map((i) => ({ ...i, ...runs.find((r) => r.id === i.id)?.cost }));
975
+ return items.map((i) => {
976
+ const r = runs.find((x) => x.id === i.id);
977
+
978
+ return { ...i, ...r?.cost, ...(r?.handoffs ? { handoffs: r.handoffs } : {}) };
979
+ });
669
980
  }
670
981
 
671
982
  /**
@@ -683,6 +994,8 @@ export async function run(
683
994
  timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
684
995
  mode = undefined,
685
996
  fresh = false,
997
+ // The harness's flags, settings and handoff watch: its own agent's by default.
998
+ managed = agent === DEFAULT_AGENT,
686
999
  },
687
1000
  ) {
688
1001
  // An id names a lock file and goes into the agent's shell command: a slug, nothing else.
@@ -693,6 +1006,8 @@ export async function run(
693
1006
  await mkdir(logs, { recursive: true });
694
1007
  const board = json(await core(['board', '--json']), 'board');
695
1008
  const runMode = mode ?? board.run_mode ?? 'worktree';
1009
+ // Before any item is claimed: a refusal here leaves no lock behind.
1010
+ const launch = await launchOf(logs, managed, board);
696
1011
  // The gate counts the items other runs hold locks on, so this run's locks go in with its gate.
697
1012
  // shortcut: a main run marks each item only as its session starts, so the items it has yet
698
1013
  // to reach do not count for another run's gate; claim them all here if main and worktree
@@ -705,12 +1020,10 @@ export async function run(
705
1020
  });
706
1021
  if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
707
1022
 
708
- const agentOf = (id) =>
709
- agentFor(
710
- agent,
711
- (board.items ?? []).find((i) => i.id === id),
712
- );
713
- const ctx = { core, logs, agentOf, timeoutMs, fresh };
1023
+ const itemOf = (id) => (board.items ?? []).find((i) => i.id === id);
1024
+ const agentOf = (id) => agentFor(agent, itemOf(id));
1025
+ const ctx = { core, logs, agentOf, timeoutMs, fresh, launch };
1026
+ launch.planOf = (id) => itemOf(id)?.plan_file ?? null;
714
1027
  const unhook = stopWithSessions();
715
1028
  try {
716
1029
  const items =
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.8.9",
4
+ "version": "0.9.0",
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,85 @@
1
+ #!/usr/bin/env node
2
+ // Handoff guard (Harness-PRD §7): a PreToolUse hook the harness hands its agent runs through
3
+ // `--settings`. When the run sees the session's context pass its handoff share it writes
4
+ // .gcf-session/handoff.json; from then on every tool call of the main agent but the plan's
5
+ // read and edit is denied with the handoff instruction, which the agent reads as the reason.
6
+ // The session writes its `## Handoff` into the plan and ends; the harness starts a fresh one.
7
+ // No flag → silent allow. A subagent's call passes: the reviewer it runs ends cleanly first.
8
+ import { readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ export const FLAG = path.join('.gcf-session', 'handoff.json');
12
+
13
+ const PLAN_TOOLS = ['Read', 'Edit', 'Write', 'MultiEdit'];
14
+
15
+ /** Whether a path is the item's plan (`flag.plan`) or a plan under `.ai-factory/plans/`. */
16
+ function isPlan(file, projectDir, flag) {
17
+ if (typeof file !== 'string' || !file) return false;
18
+ const abs = path.resolve(projectDir, file);
19
+ if (typeof flag.plan === 'string' && abs === path.resolve(projectDir, flag.plan)) return true;
20
+ const rel = path.relative(path.join(projectDir, '.ai-factory', 'plans'), abs);
21
+
22
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
23
+ }
24
+
25
+ /** The instruction the denied call carries to the agent. */
26
+ export function handoffReason(flag) {
27
+ const at = flag?.pct ? `${flag.pct}%` : 'its limit';
28
+
29
+ return (
30
+ `context at ${at} of the window: hand over now. Start no new task. Write a "## Handoff" ` +
31
+ "section at the end of the item's plan: the tasks done, the one in progress and where it " +
32
+ 'stands, the next step, the open review findings and the DoD diagnosis. Put no "- [ ]" ' +
33
+ 'line in it. Leave the task in progress unticked. Then end your reply: a fresh session ' +
34
+ 'goes on from the plan.'
35
+ );
36
+ }
37
+
38
+ /**
39
+ * The verdict on one tool call: `{allow}` or `{allow: false, reason}`. `flag` is the parsed
40
+ * handoff.json, or null.
41
+ */
42
+ export function decide(input, projectDir, flag) {
43
+ if (!flag) return { allow: true };
44
+ if (input?.agent_id) return { allow: true };
45
+ const file = input?.tool_input?.file_path;
46
+ if (PLAN_TOOLS.includes(input?.tool_name) && isPlan(file, projectDir, flag))
47
+ return { allow: true };
48
+
49
+ return { allow: false, reason: handoffReason(flag) };
50
+ }
51
+
52
+ /** The flag of the session's checkout, or null; an unreadable one still hands over. */
53
+ export function readFlag(projectDir) {
54
+ let text;
55
+ try {
56
+ text = readFileSync(path.join(projectDir, FLAG), 'utf8');
57
+ } catch {
58
+ return null;
59
+ }
60
+ try {
61
+ return JSON.parse(text) ?? {};
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ // Hook entry: only runs when executed directly, so tests can import decide().
68
+ const entry = process.argv[1]?.replaceAll('\\', '/');
69
+ if (import.meta.url === `file://${entry}` || entry?.endsWith('handoff-guard.js')) {
70
+ let input = null;
71
+ try {
72
+ input = JSON.parse(readFileSync(0, 'utf8'));
73
+ } catch {
74
+ // No input: nothing to judge.
75
+ }
76
+ // The directory the session started in: `cwd` follows a `cd` of the agent.
77
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input?.cwd ?? process.cwd();
78
+ const verdict = decide(input, projectDir, readFlag(projectDir));
79
+ if (!verdict.allow) {
80
+ console.error(`gcf handoff-guard: ${verdict.reason}`);
81
+ process.exit(2);
82
+ }
83
+
84
+ process.exit(0);
85
+ }
@@ -141,7 +141,9 @@ export function keepSessionsAlive(projectDir, now = Date.now()) {
141
141
  const entry = process.argv[1]?.replaceAll('\\', '/');
142
142
  if (import.meta.url === `file://${entry}` || entry?.endsWith('readset-guard.js')) {
143
143
  const input = readStdin();
144
- const projectDir = input?.cwd ?? process.cwd();
144
+ // The directory the session started in: `cwd` follows a `cd` of the agent, and a manifest
145
+ // not found there would allow every read.
146
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input?.cwd ?? process.cwd();
145
147
  keepSessionsAlive(projectDir);
146
148
  const verdict = decide(input, projectDir, readManifest(projectDir));
147
149
  if (!verdict.allow) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.8.9",
3
+ "version": "0.9.0",
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.7.0"
31
+ "gcf-aif-ext": "^0.7.6"
32
32
  },
33
33
  "files": [
34
34
  "extension.json",
@@ -14,6 +14,7 @@ purpose:
14
14
  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
+ - `--handoff` when the harness started this session after the last one filled its context: the plan's `## Handoff` says where it stopped
17
18
 
18
19
  preconditions:
19
20
  - `gcf/` exists and `ai-factory gcf audit` runs (the gcf-aif-ext extension provides it)
@@ -37,6 +38,9 @@ forbidden_behaviors:
37
38
  - do not write a refused command into a DoD command or a config that `ai-factory gcf` runs
38
39
  - a dedicated tool doing the same job (Read, Grep, Glob, Edit) is no wrapper, nor is a refused pipe or `;` chain split into single allowed commands
39
40
  - a refusal none of those replaces is a stop: run `ai-factory gcf block <id> --reason "needs permission: <command>"` and stop the item; under `--worktree` step 22 records it instead
41
+ - a call the handoff guard denies with "context at" is no refusal: block nothing, write the `## Handoff` it asks for and end the reply
42
+ - a read the readset guard denies is no refusal either: drop that read, and widen the readset only through the item's `system:` or `depends:`
43
+ - delete one file with `rm -f <file>` or `Remove-Item -Force <file>`, never with `-r`: the deny rules refuse a recursive delete
40
44
 
41
45
  workflow:
42
46
  1. session ritual: delete a `.gcf-session/readset.json` left by a killed run; run `ai-factory gcf integrate` to merge the verified branches a dead run left; run `ai-factory gcf audit --json`; read `gcf/INDEX.md`; report the audit first line
@@ -63,6 +67,8 @@ workflow:
63
67
  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
64
68
  23. with `--worktree`, run no `ai-factory gcf block`, `submit` or `close`: the core refuses them on a `work/` branch, and the harness records the move on main
65
69
  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
+ 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
+ 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
66
72
 
67
73
  invocation:
68
74
  - run when the user names a work item or a unit to drive; batches share steps 1 and 19