gcf-harness-aif-ext 0.8.9 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,19 @@ 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.1 is a fix release:
51
+ - 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
+ - `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.
53
+ - A stop that comes while the session starts no longer misses it.
54
+
55
+ 0.9.0, to update together with core 0.7.6:
56
+ - 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>`.
57
+ - 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.
58
+ - 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`.
59
+ - `auto_compact_pct` sets the agent's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`, at least 15 points above the handoff share.
60
+ - 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.
61
+ - `/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.
62
+
50
63
  0.8.0, to update together with core 0.7.0:
51
64
  - `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
65
  - `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]) => /** @type {[string, number]} */ ([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',
@@ -326,7 +329,7 @@ export function register(program) {
326
329
  });
327
330
  harness
328
331
  .command('stop <id>')
329
- .description('Stop the run that drives an item, with every session it started; it stays ready')
332
+ .description('Stop the session that works an item; the run goes on, the item stays ready')
330
333
  .action((id) => console.log(stopRun(process.cwd(), id)));
331
334
  }
332
335
 
@@ -357,12 +360,12 @@ function lockOf(file) {
357
360
  }
358
361
  }
359
362
 
360
- /** Kill a process and everything under it: a tree on Windows, its own group elsewhere. */
361
- function killTree(pid, group) {
363
+ /** Kill a session and everything under it: a tree on Windows, its own group elsewhere. */
364
+ function killTree(pid) {
362
365
  if (WIN) spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
363
366
  else {
364
367
  try {
365
- process.kill(group ? -pid : pid, group ? 'SIGKILL' : 'SIGTERM');
368
+ process.kill(-pid, 'SIGKILL');
366
369
  } catch {
367
370
  // Gone already.
368
371
  }
@@ -372,15 +375,24 @@ function killTree(pid, group) {
372
375
  /**
373
376
  * Stop the session that works `id`: the one its own lock names (worktree mode), or the main
374
377
  * checkout's run when that run works this very item (main mode). Only that session goes: the
375
- * run records it as no result and goes on with the rest. A run that has not started a session
376
- * yet goes down whole. The item stays ready and its work in progress is kept.
378
+ * run records it as stopped and goes on with the rest. The item stays ready and its work in
379
+ * progress is kept.
377
380
  */
378
381
  export function stopRun(projectDir, id, core = cliCore(projectDir)) {
379
382
  const runs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
380
- const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
381
- const lock = lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
382
- if (lock && lock.session && alive(lock.session)) killTree(lock.session, true);
383
- else if (lock) killTree(lock.run, false);
383
+ const lockFor = () => {
384
+ const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
385
+
386
+ return lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
387
+ };
388
+
389
+ // The mark says the session ended by a stop, and stops the next one as it starts: the lock
390
+ // names none before the item's first session, or between two of a handoff. Read again after
391
+ // the mark: a session that started meanwhile did not see it.
392
+ const lock = lockFor();
393
+ if (lock) writeFileSync(stopOf(runs, id), `${Date.now()}\n`);
394
+ const session = lock && lockFor()?.session;
395
+ if (session && alive(session)) killTree(session);
384
396
  core(['release', id]);
385
397
 
386
398
  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) => {
@@ -73,12 +156,12 @@ function killTree(child) {
73
156
  }
74
157
  }
75
158
 
76
- // The sessions this run has going: a stop (`gcf-harness stop`) takes them down with the run.
159
+ // The sessions this run has going: a run that is killed (SIGTERM) takes them down with it.
77
160
  const sessions = new Set();
78
161
 
79
162
  /**
80
- * `gcf-harness stop` signals the run (SIGTERM); its sessions lead their own process groups, so
81
- * the run takes each down before it exits. On Windows the stop kills the whole tree itself.
163
+ * A run that gets SIGTERM (`gcf-harness stop <id>` stops one session, never the run): its
164
+ * sessions lead their own process groups, so the run takes each down before it exits.
82
165
  * Returns the unhook.
83
166
  */
84
167
  function stopWithSessions() {
@@ -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);
@@ -117,35 +202,117 @@ function byLine(stream, take) {
117
202
  // What `claude --resume` prints on stderr for a session it does not have.
118
203
  const NO_SESSION = /No conversation found with session ID/;
119
204
 
205
+ /** Keep what a session's activity told: the last of each, the model's window from the result. */
206
+ function absorb(seen, a) {
207
+ seen.cost = a.cost ?? seen.cost;
208
+ seen.session_id = a.session_id ?? seen.session_id;
209
+ seen.limit = a.limit ?? seen.limit;
210
+ seen.model = a.model ?? seen.model;
211
+ // The init model's window; the largest when the result does not name that model.
212
+ if (a.windows) seen.window = a.windows[seen.model] ?? Math.max(...Object.values(a.windows));
213
+ }
214
+
215
+ /** Write the flag the handoff guard reads into the session's checkout. */
216
+ function writeFlag(dir, flag) {
217
+ try {
218
+ mkdirSync(path.join(dir, '.gcf-session'), { recursive: true });
219
+ writeFileSync(path.join(dir, HANDOFF_FLAG), `${JSON.stringify(flag)}\n`);
220
+ } catch {
221
+ // No flag: the turns past it stop the session all the same.
222
+ }
223
+ }
224
+
225
+ /**
226
+ * An agent session's shell: its own process group off Windows, so a stop takes all it started.
227
+ * The guards read the session's own checkout, not one an outer Claude session named.
228
+ */
229
+ function spawnAgent(command, dir, env) {
230
+ return spawn(command, {
231
+ cwd: dir,
232
+ shell: true,
233
+ // GCF_RUN_ITEM: the session's own gate must not refuse the item this run marked as running.
234
+ env: { ...process.env, ...env, CLAUDE_PROJECT_DIR: dir },
235
+ detached: !WIN,
236
+ windowsHide: true,
237
+ stdio: ['ignore', 'pipe', 'pipe'],
238
+ });
239
+ }
240
+
241
+ /**
242
+ * The handoff watch of one session (see `startAgent`): `see(a, model)` takes each activity, flags
243
+ * the session past its share or at a compaction, and calls `stop` once it works on past more.
244
+ */
245
+ function handoffWatch(handoff, dir, write, stop) {
246
+ const w = { flagged: false, after: 0, context: 0, turn: undefined, unknown: false };
247
+ const unknownOnce = (model) => {
248
+ if (w.unknown) return;
249
+ w.unknown = true;
250
+ write([`[${clock()}] handoff: off, the window of ${model ?? 'the model'} is unknown`]);
251
+ };
252
+
253
+ const flag = (win, pct, compacted) => {
254
+ w.flagged = true;
255
+ writeFlag(dir, { pct, tokens: w.context, window: win, plan: handoff.plan ?? null });
256
+ const why = compacted
257
+ ? 'a compaction'
258
+ : `context ${pct}% (${count(w.context)} of ${count(win)})`;
259
+ write([`[${clock()}] handoff: ${why}, the session hands over`]);
260
+ };
261
+
262
+ const check = (model, compacted) => {
263
+ const win = handoff.windowOf(model);
264
+ const pct = win ? Math.round((100 * w.context) / win) : null;
265
+ // No share is under every threshold: a session of an unknown window hands over at a compaction.
266
+ const share = pct ?? 0;
267
+ if (pct === null && !compacted) unknownOnce(model);
268
+ if (!w.flagged && (compacted || share >= handoff.pct)) flag(win, pct, compacted);
269
+ if (w.flagged && (w.after > HARD_TURNS || share >= handoff.pct + HARD_OVER)) {
270
+ write([`[${clock()}] handoff: the session went on past its flag, stopped`]);
271
+ stop();
272
+ }
273
+ };
274
+
275
+ return {
276
+ state: w,
277
+ see(a, model) {
278
+ if (a.context !== undefined) {
279
+ w.context = a.context;
280
+ if (w.flagged && a.turn !== w.turn) w.after++;
281
+ w.turn = a.turn;
282
+ }
283
+
284
+ if (handoff?.pct && (a.context !== undefined || a.compacted))
285
+ check(model, Boolean(a.compacted));
286
+ },
287
+ };
288
+ }
289
+
120
290
  /**
121
291
  * One agent session on `prompt` in `dir`; resolves with its exit code, or `timeout` when it ran
122
292
  * past `timeoutMs`: a hung session would hold the item's run lock, and every move on it, for
123
293
  * good. Its stdout reaches the log through `activity`, its stderr as it is; `cost` is what a
124
294
  * Claude session reported at its end, `session_id` the id a rerun resumes it by, `limit` the
125
295
  * usage limit that ended it, `gone` that the session it was to resume does not exist.
296
+ *
297
+ * `handoff` ({pct, windowOf(model), plan}) watches the main agent's context: past `pct` of the
298
+ * window, or at a compaction, the session gets `.gcf-session/handoff.json`, and the handoff
299
+ * guard turns its tool calls into the instruction to hand over; one that keeps on working is
300
+ * stopped. `handoff: true` says the session was told to hand over, `handedOver` that it did.
126
301
  * @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
127
- * session_id?: string, limit?: {resets: string | null}, gone: boolean}>}
302
+ * session_id?: string, limit?: {resets: string | null}, gone: boolean, handoff: boolean,
303
+ * handedOver: boolean, model?: string, window?: number}>}
128
304
  */
129
- function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
305
+ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {}, handoff }) {
130
306
  return new Promise((resolve) => {
131
307
  const command = `${agent} "${prompt}"`;
132
308
  // A line before the agent's first: the log's time says the session is alive from its start.
133
309
  writeSync(fd, `[${clock()}] start: ${command}\n`);
134
- const child = spawn(command, {
135
- cwd: dir,
136
- shell: true,
137
- // The session's own gate must not refuse the item this run marked as running.
138
- env: { ...process.env, GCF_RUN_ITEM: id },
139
- detached: !WIN,
140
- windowsHide: true,
141
- stdio: ['ignore', 'pipe', 'pipe'],
142
- });
310
+ const child = spawnAgent(command, dir, { ...env, GCF_RUN_ITEM: id });
143
311
  sessions.add(child);
144
312
  if (child.pid) onSpawn?.(child.pid);
145
313
  let open = true;
146
- let cost;
147
- let sessionId;
148
- let limit;
314
+ /** @type {Record<string, any>} */
315
+ const seen = {};
149
316
  let gone = false;
150
317
  const write = (lines) => {
151
318
  try {
@@ -155,12 +322,15 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
155
322
  }
156
323
  };
157
324
 
325
+ const watch = handoffWatch(handoff, dir, write, () => {
326
+ killTree(child);
327
+ end('handoff');
328
+ });
158
329
  const flushOut = byLine(child.stdout, (line) => {
159
330
  const a = activity(line);
160
- cost = a.cost ?? cost;
161
- sessionId = a.session_id ?? sessionId;
162
- limit = a.limit ?? limit;
331
+ absorb(seen, a);
163
332
  write(a.lines);
333
+ if (open) watch.see(a, seen.model);
164
334
  });
165
335
  const flushErr = byLine(child.stderr, (line) => {
166
336
  gone ||= NO_SESSION.test(line);
@@ -177,7 +347,11 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
177
347
  open = false;
178
348
  sessions.delete(child);
179
349
  clearTimeout(timer);
180
- resolve({ code, cost, session_id: sessionId, limit, gone });
350
+ const { flagged, after } = watch.state;
351
+ // Told to hand over, and it went on past the flag: it read the guard's instruction, or the
352
+ // run stopped it. A flag on its last turn found a session already done.
353
+ const handedOver = flagged && (after > 0 || code === 'handoff');
354
+ resolve({ ...seen, code, gone, handoff: flagged, handedOver });
181
355
  }
182
356
 
183
357
  child.on('error', () => end(-1));
@@ -341,8 +515,14 @@ async function claimAll(ids, logs) {
341
515
  const failed = [];
342
516
  for (const id of ids) {
343
517
  const release = await claim(logs, id);
344
- if (release) claimed.push({ id, release });
345
- else failed.push({ id, result: 'error', error: `${id} is already running in another run` });
518
+ if (!release) {
519
+ failed.push({ id, result: 'error', error: `${id} is already running in another run` });
520
+ continue;
521
+ }
522
+
523
+ // A dead run's stop mark goes; one from now on stops the item's session as it starts.
524
+ await rm(stopOf(logs, id), { force: true });
525
+ claimed.push({ id, release });
346
526
  }
347
527
 
348
528
  return { claimed, failed };
@@ -369,6 +549,12 @@ async function prepare({ claimed, failed }, core, agentOf) {
369
549
  return { ready, failed };
370
550
  }
371
551
 
552
+ /**
553
+ * `<id>.stop`: `gcf-harness stop` while the item has no session, before its first or between two
554
+ * of a handoff: the next one is stopped as it starts.
555
+ */
556
+ export const stopOf = (logs, id) => path.join(logs, `${id}.stop`);
557
+
372
558
  /** `<id>.agent.json`: the agent session an item's last run left unfinished, for a rerun. */
373
559
  const recordOf = (logs, id) => path.join(logs, `${id}.agent.json`);
374
560
 
@@ -380,12 +566,28 @@ async function kept(logs, id) {
380
566
  try {
381
567
  const rec = JSON.parse(await readFile(recordOf(logs, id), 'utf8'));
382
568
 
383
- return SESSION_ID.test(String(rec?.session_id)) ? rec : null;
569
+ if (SESSION_ID.test(String(rec?.session_id))) return rec;
570
+
571
+ // A handoff needs no session: the rerun starts fresh. An id that is not one is dropped.
572
+ return rec?.ended === 'handoff' ? { ...rec, session_id: undefined } : null;
384
573
  } catch {
385
574
  return null;
386
575
  }
387
576
  }
388
577
 
578
+ /**
579
+ * Why a kept session ended. A session that handed over is not resumed: the rerun starts fresh
580
+ * from the plan. So does one after a handoff that reported no id: the full session before it is
581
+ * never resumed.
582
+ */
583
+ function endedOf(r) {
584
+ if (r.handoffPending || !r.session_id) return 'handoff';
585
+ if (r.limit) return 'limit';
586
+ if (r.stopped) return 'stopped';
587
+
588
+ return r.code === 'timeout' ? 'timeout' : `exit ${r.code}`;
589
+ }
590
+
389
591
  /**
390
592
  * The record of a session that ended with its item still ready: a rerun resumes it with all the
391
593
  * agent read and decided. The item's move ends the session for good; an error leaves it as is.
@@ -393,15 +595,23 @@ async function kept(logs, id) {
393
595
  async function remember(logs, r, item) {
394
596
  if (item.result === 'error') return;
395
597
  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}`;
398
- const rec = {
399
- session_id: r.session_id,
400
- ended,
598
+ if (!r.session_id && !r.handoffPending && !r.handoffs) return;
599
+ await writeFile(recordOf(logs, r.id), `${JSON.stringify(recordFor(r), null, 2)}\n`);
600
+ }
601
+
602
+ /** The `<id>.agent.json` of a session that ended with its item still ready. */
603
+ function recordFor(r) {
604
+ return {
605
+ ...(r.session_id ? { session_id: r.session_id } : {}),
606
+ ended: endedOf(r),
401
607
  ...(r.limit?.resets ? { resets: r.limit.resets } : {}),
608
+ // The handoffs so far: the cap holds across runs.
609
+ ...(r.handoffs ? { handoffs: r.handoffs } : {}),
610
+ // The fresh session a counted handoff started ended with no id: the next one is no new
611
+ // handoff, only the same fresh start again.
612
+ ...(!r.handoffPending && !r.session_id ? { counted: true } : {}),
402
613
  at: new Date().toISOString(),
403
614
  };
404
- await writeFile(recordOf(logs, r.id), `${JSON.stringify(rec, null, 2)}\n`);
405
615
  }
406
616
 
407
617
  /**
@@ -416,6 +626,19 @@ function noResult(r, rerun) {
416
626
  `no result (its session could not be resumed, agent exit ${r.code}): ` +
417
627
  `${r.id} stays ready, a rerun starts fresh`,
418
628
  };
629
+ if (r.handoffPending) {
630
+ const limit = r.limit
631
+ ? `usage limit${r.limit.resets ? `, resets ${r.limit.resets}` : ''}; `
632
+ : '';
633
+
634
+ return {
635
+ id: r.id,
636
+ result:
637
+ `paused (${limit}context handoff): ${r.id} stays ready, ` + 'a rerun goes on from the plan',
638
+ ...(r.limit ? { limit_resets: r.limit.resets } : {}),
639
+ };
640
+ }
641
+
419
642
  const again = r.session_id ? 'a rerun resumes its session' : rerun;
420
643
  if (r.limit) {
421
644
  const resets = r.limit.resets ? `, resets ${r.limit.resets}` : '';
@@ -427,23 +650,31 @@ function noResult(r, rerun) {
427
650
  };
428
651
  }
429
652
 
430
- const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
653
+ const why = r.stopped
654
+ ? 'stopped'
655
+ : r.code === 'timeout'
656
+ ? 'the session timed out'
657
+ : `agent exit ${r.code}`;
431
658
 
432
659
  return { id: r.id, result: `no result (${why}): ${r.id} stays ready, ${again}` };
433
660
  }
434
661
 
435
662
  const STAYED = / stays ready, a rerun /;
436
663
 
664
+ const taskOf = (r) => (r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`);
665
+
437
666
  /**
438
667
  * The agent command and prompt of an item's session: `/gcf-run`, or the resume of the session
439
668
  * its last run kept (`--resume`, in the same directory, so the agent finds it) unless `fresh`.
440
669
  */
441
670
  async function commandOf(r, logs, fresh) {
442
- const task = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
671
+ const task = taskOf(r);
443
672
  if (fresh) await rm(recordOf(logs, r.id), { force: true });
673
+ const rec = fresh ? null : await kept(logs, r.id);
674
+ // A session that handed over goes on in a fresh one, whatever the agent.
675
+ if (rec?.ended === 'handoff') return { agent: r.agent, prompt: `${task} --handoff`, rec };
444
676
  // `--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 };
677
+ if (!rec || !/^claude(\s|$)/.test(r.agent)) return { agent: r.agent, prompt: task, rec: null };
447
678
  // One plain line: the prompt goes into the shell command in double quotes.
448
679
  const ended = String(rec.ended).replace(/[^\w -]/g, '');
449
680
 
@@ -454,40 +685,143 @@ async function commandOf(r, logs, fresh) {
454
685
  };
455
686
  }
456
687
 
688
+ /**
689
+ * One agent session of the item `s.r`, its handoff flag cleared around it. `stopped` says a
690
+ * `gcf-harness stop` ended it.
691
+ */
692
+ async function agentSession(s, agent, prompt) {
693
+ const { r, fd, deadline, launch } = s;
694
+ const flag = path.join(r.dir, HANDOFF_FLAG);
695
+ await rm(flag, { force: true });
696
+ const flags = launch?.flags(r.dir);
697
+ const handoff = launch?.handoff && { ...launch.handoff, plan: launch.planOf?.(r.id) };
698
+ const left = Math.max(deadline - Date.now(), 1000);
699
+ const opts = { fd, timeoutMs: left, id: r.id, onSpawn: s.onSpawn, env: launch?.env, handoff };
700
+ const ran = await startAgent(flags ? `${agent} ${flags}` : agent, prompt, r.dir, opts);
701
+ await rm(flag, { force: true });
702
+ // Between two sessions the lock names none: a stop then marks the item, not the run.
703
+ writeFileSync(s.lock, `${process.pid}\n${r.id}\n`);
704
+ if (launch?.learned) await learnWindow(s.logs, launch.learned, ran);
705
+
706
+ return { ...ran, stopped: existsSync(stopOf(s.logs, r.id)) };
707
+ }
708
+
709
+ /**
710
+ * Count the item's next handoff, or say the cap is reached: the item is then blocked. The cap
711
+ * counts each handoff once, the one a kept record still owes a session included.
712
+ */
713
+ async function capped(count, r) {
714
+ if (count.handoffs < MAX_HANDOFFS) {
715
+ count.handoffs++;
716
+
717
+ return false;
718
+ }
719
+
720
+ count.limit = true;
721
+ // A worktree records no move: the main checkout reads the block from its outcome.
722
+ if (!r.main) {
723
+ await mkdir(path.join(r.dir, '.gcf-session'), { recursive: true });
724
+ const outcome = `${JSON.stringify({ block: HANDOFF_LIMIT })}\n`;
725
+ await writeFile(path.join(r.dir, '.gcf-session', 'outcome.json'), outcome);
726
+ }
727
+
728
+ return true;
729
+ }
730
+
731
+ /**
732
+ * Only a session that handed over as told goes on: one that saw its flag and ended its reply,
733
+ * or that the run stopped past it. A stop, a limit or the deadline leaves the handoff to the
734
+ * next run.
735
+ */
736
+ async function goesOn(s, ran) {
737
+ const over = ran.handedOver && (ran.code === 0 || ran.code === 'handoff') && !ran.limit;
738
+
739
+ return over && Date.now() < s.deadline && !ran.stopped && !(await s.r.done?.());
740
+ }
741
+
742
+ /**
743
+ * Whether the session `rec` kept is gone: a resume that claude answered with `No conversation
744
+ * found`, so the next run starts fresh rather than fail on it again. Any other early end (a stop,
745
+ * a crash, no network) keeps it for the next try, and the resumed session's id stands in for one
746
+ * its own run did not report. A fresh session after a handoff has none to borrow: the full one is
747
+ * never resumed.
748
+ */
749
+ function resumeLost(rec, ran) {
750
+ if (!rec || rec.ended === 'handoff') return false;
751
+ if (ran.gone) return true;
752
+ ran.session_id ??= rec.session_id;
753
+
754
+ return false;
755
+ }
756
+
757
+ /**
758
+ * 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.
760
+ */
761
+ async function handOver(s, { agent, prompt, rec }) {
762
+ const { r } = s;
763
+ const count = { handoffs: Number(rec?.handoffs) || 0, limit: false };
764
+ const fromHandoff = rec?.ended === 'handoff';
765
+ // A record whose handoff its own run already counted (`counted`) owes none.
766
+ if (fromHandoff && !rec.counted && (await capped(count, r)))
767
+ return { code: 0, handoffs: count.handoffs, handoffLimit: true, handoffPending: false };
768
+ let ran = await agentSession(s, agent, prompt);
769
+ let cost = ran.cost;
770
+ const lost = resumeLost(rec, ran);
771
+ while ((await goesOn(s, ran)) && !(await capped(count, r))) {
772
+ writeSync(
773
+ s.fd,
774
+ `[${clock()}] handoff ${count.handoffs}: a fresh session goes on from the plan\n`,
775
+ );
776
+ ran = await agentSession(s, r.agent, `${taskOf(r)} --handoff`);
777
+ cost = addCost(cost, ran.cost);
778
+ }
779
+
780
+ return {
781
+ ...ran,
782
+ cost,
783
+ handoffs: count.handoffs,
784
+ handoffLimit: count.limit,
785
+ // The session was told to hand over and no fresh one took it: the next run does.
786
+ // A limit there too: the rerun after the reset starts fresh, it does not resume the full one.
787
+ handoffPending: ran.handoff && !count.limit,
788
+ lost,
789
+ };
790
+ }
791
+
457
792
  /**
458
793
  * One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
459
794
  * its worktree mode; on main it records the item's move itself.
460
795
  */
461
- async function session(r, logs, timeoutMs, fresh) {
796
+ async function session(r, { logs, timeoutMs, fresh, launch }) {
462
797
  /** @type {import('node:fs/promises').FileHandle | null} */
463
798
  let log = null;
464
799
  // The lock names the item and its session: `stop <id>` ends that session alone, not the run.
465
800
  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`);
801
+ const onSpawn = (pid) => {
802
+ writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
803
+ // A stop that came while the lock named no session left only its mark: it ends this one.
804
+ if (existsSync(stopOf(logs, r.id))) killTree({ pid });
805
+ };
806
+
467
807
  // On main the item carries a session mark; a stage with no gcf command must not expire it.
468
808
  const mark = path.join(logs, `${r.id}.session`);
469
809
  const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
470
810
  try {
471
- const { agent, prompt, rec } = await commandOf(r, logs, fresh);
811
+ const cmd = await commandOf(r, logs, fresh);
472
812
  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);
475
-
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;
813
+ // One deadline for the item, however many sessions it takes.
814
+ const deadline = Date.now() + timeoutMs;
480
815
 
481
816
  return {
482
817
  ...r,
483
- ...ran,
484
- session_id: ran.session_id ?? (lost ? undefined : rec?.session_id),
485
- lost,
818
+ ...(await handOver({ r, logs, lock, fd: log.fd, deadline, launch, onSpawn }, cmd)),
486
819
  };
487
820
  } catch (e) {
488
821
  return { ...r, code: null, error: e.message };
489
822
  } finally {
490
823
  if (keep) clearInterval(keep);
824
+ await rm(stopOf(logs, r.id), { force: true });
491
825
  await log?.close();
492
826
  await r.release();
493
827
  }
@@ -569,6 +903,34 @@ function resultOf(run, integrated) {
569
903
 
570
904
  export const DEFAULT_TIMEOUT_MIN = 120;
571
905
 
906
+ /**
907
+ * How the run starts its sessions (Harness-PRD §7): a `managed` agent, the harness's own, gets
908
+ * the run flags, the settings file and the handoff watch, which needs the guard those settings
909
+ * bring; every agent gets the compaction share. A core that reports no deny rules is refused:
910
+ * bypass with none would allow everything.
911
+ */
912
+ async function launchOf(logs, managed, board) {
913
+ if (managed && !Array.isArray(board.agent?.deny))
914
+ throw new Error('gcf board reports no agent deny rules: update gcf-aif-ext to 0.7.6 or later');
915
+ const settings = managed ? await writeAgentSettings(logs, board.agent) : null;
916
+ const ctx = board.context ?? {};
917
+ const learned = await readFile(windowsOf(logs), 'utf8')
918
+ .then((t) => JSON.parse(t) ?? {})
919
+ .catch(() => ({}));
920
+
921
+ return {
922
+ flags: (dir) => (settings ? runFlags(dir, settings) : ''),
923
+ env: ctx.compact_pct > 0 ? { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(ctx.compact_pct) } : {},
924
+ handoff: managed &&
925
+ ctx.handoff_pct > 0 && {
926
+ pct: ctx.handoff_pct,
927
+ windowOf: (model) => windowFor(model, ctx.window, learned),
928
+ },
929
+ learned,
930
+ planOf: (id) => board.items?.find((i) => i.id === id)?.plan_file ?? null,
931
+ };
932
+ }
933
+
572
934
  // The move an item's status shows after a run on main: the agent recorded it itself.
573
935
  const MOVE_OF = {
574
936
  'awaiting-verdict': 'submit',
@@ -619,30 +981,51 @@ async function runMain(projectDir, ids, ctx) {
619
981
  }
620
982
 
621
983
  /** 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 }) {
984
+ async function mainItem(projectDir, id, ctx) {
985
+ const { core, logs, agentOf } = ctx;
623
986
  let r;
987
+ await rm(stopOf(logs, id), { force: true });
988
+ // The checkout's lock names the item from now on: a stop before its session marks it.
989
+ writeFileSync(path.join(logs, `${MAIN_RUN}.lock`), `${process.pid}\n${id}\n`);
624
990
  try {
625
991
  const claimed = await core(['claim', id]);
626
992
  if (!claimed.ok) throw new Error((claimed.err || claimed.out).trim());
627
993
  const none = async () => {};
628
994
 
629
- const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true };
630
- r = await session(s, logs, timeoutMs, fresh);
995
+ // The agent recorded the item's move: no fresh session takes it over.
996
+ // A board that cannot be read is no move: the run goes on, and the result read says why.
997
+ const done = async () => {
998
+ try {
999
+ const items = json(await core(['board', '--json']), 'board').items;
1000
+
1001
+ return items?.find((i) => i.id === id)?.status !== 'ready';
1002
+ } catch {
1003
+ return false;
1004
+ }
1005
+ };
1006
+
1007
+ const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true, done };
1008
+ r = await session(s, ctx);
631
1009
  } catch (e) {
632
1010
  r = { id, error: e.message };
633
1011
  }
634
1012
 
635
1013
  await core(['release', id]);
1014
+ if (r.handoffLimit) await core(['block', id, '--reason', HANDOFF_LIMIT]);
636
1015
  const item = await mainResult(core, r);
637
1016
  await remember(logs, r, item);
638
1017
 
639
- return { ...item, ...r.cost };
1018
+ return { ...item, ...r.cost, ...(r.handoffs ? { handoffs: r.handoffs } : {}) };
640
1019
  }
641
1020
 
642
1021
  /** 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 }) {
1022
+ async function runWorktrees(claims, ctx) {
1023
+ const { core, logs, agentOf } = ctx;
644
1024
  const { ready, failed } = await prepare(claims, core, agentOf);
645
- const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs, fresh)));
1025
+ // A session that verified its branch, or left an outcome, is done: no fresh one follows.
1026
+ const done = (r) => () =>
1027
+ verified(r.dir, r.id) || existsSync(path.join(r.dir, '.gcf-session', 'outcome.json'));
1028
+ const runs = await Promise.all(ready.map((r) => session({ ...r, done: done(r) }, ctx)));
646
1029
  const items = [...failed];
647
1030
  for (const r of runs) {
648
1031
  const outcome = await outcomeOf(r.dir);
@@ -665,7 +1048,11 @@ async function runWorktrees(claims, { core, logs, agentOf, timeoutMs, fresh }) {
665
1048
  );
666
1049
 
667
1050
  // 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 }));
1051
+ return items.map((i) => {
1052
+ const r = runs.find((x) => x.id === i.id);
1053
+
1054
+ return { ...i, ...r?.cost, ...(r?.handoffs ? { handoffs: r.handoffs } : {}) };
1055
+ });
669
1056
  }
670
1057
 
671
1058
  /**
@@ -683,6 +1070,8 @@ export async function run(
683
1070
  timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
684
1071
  mode = undefined,
685
1072
  fresh = false,
1073
+ // The harness's flags, settings and handoff watch: its own agent's by default.
1074
+ managed = agent === DEFAULT_AGENT,
686
1075
  },
687
1076
  ) {
688
1077
  // An id names a lock file and goes into the agent's shell command: a slug, nothing else.
@@ -693,6 +1082,8 @@ export async function run(
693
1082
  await mkdir(logs, { recursive: true });
694
1083
  const board = json(await core(['board', '--json']), 'board');
695
1084
  const runMode = mode ?? board.run_mode ?? 'worktree';
1085
+ // Before any item is claimed: a refusal here leaves no lock behind.
1086
+ const launch = await launchOf(logs, managed, board);
696
1087
  // The gate counts the items other runs hold locks on, so this run's locks go in with its gate.
697
1088
  // shortcut: a main run marks each item only as its session starts, so the items it has yet
698
1089
  // to reach do not count for another run's gate; claim them all here if main and worktree
@@ -705,12 +1096,9 @@ export async function run(
705
1096
  });
706
1097
  if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
707
1098
 
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 };
1099
+ const itemOf = (id) => (board.items ?? []).find((i) => i.id === id);
1100
+ const agentOf = (id) => agentFor(agent, itemOf(id));
1101
+ const ctx = { core, logs, agentOf, timeoutMs, fresh, launch };
714
1102
  const unhook = stopWithSessions();
715
1103
  try {
716
1104
  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.1",
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,88 @@
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
+
61
+ try {
62
+ return JSON.parse(text) ?? {};
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ // Hook entry: only runs when executed directly, so tests can import decide().
69
+ const entry = process.argv[1]?.replaceAll('\\', '/');
70
+ if (import.meta.url === `file://${entry}` || entry?.endsWith('handoff-guard.js')) {
71
+ /** @type {Record<string, any> | null} */
72
+ let input = null;
73
+ try {
74
+ input = JSON.parse(readFileSync(0, 'utf8'));
75
+ } catch {
76
+ // No input: nothing to judge.
77
+ }
78
+
79
+ // The directory the session started in: `cwd` follows a `cd` of the agent.
80
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input?.cwd ?? process.cwd();
81
+ const verdict = decide(input, projectDir, readFlag(projectDir));
82
+ if (!verdict.allow) {
83
+ console.error(`gcf handoff-guard: ${verdict.reason}`);
84
+ process.exit(2);
85
+ }
86
+
87
+ process.exit(0);
88
+ }
@@ -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.1",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "prepack": "node -e \"const p=require('./package.json'),e=require('./extension.json');if(p.version!==e.version)throw new Error('version mismatch: package.json '+p.version+' vs extension.json '+e.version)\""
29
29
  },
30
30
  "peerDependencies": {
31
- "gcf-aif-ext": "^0.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