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 +13 -0
- package/commands/activity.js +43 -9
- package/commands/harness.js +24 -12
- package/commands/run.js +451 -63
- package/extension.json +1 -1
- package/hooks/handoff-guard.js +88 -0
- package/hooks/readset-guard.js +3 -1
- package/package.json +2 -2
- package/skills/gcf-run/SKILL.md +6 -0
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.
|
package/commands/activity.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
}
|
package/commands/harness.js
CHANGED
|
@@ -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
|
|
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
|
|
361
|
-
function killTree(pid
|
|
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(
|
|
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
|
|
376
|
-
*
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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 {
|
|
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
|
|
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
|
|
81
|
-
* the run takes each down before it exits.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
811
|
+
const cmd = await commandOf(r, logs, fresh);
|
|
472
812
|
log = await open(path.join(logs, `${r.id}.log`), 'a');
|
|
473
|
-
|
|
474
|
-
const
|
|
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
|
-
...
|
|
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,
|
|
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
|
-
|
|
630
|
-
|
|
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,
|
|
1022
|
+
async function runWorktrees(claims, ctx) {
|
|
1023
|
+
const { core, logs, agentOf } = ctx;
|
|
644
1024
|
const { ready, failed } = await prepare(claims, core, agentOf);
|
|
645
|
-
|
|
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) =>
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
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.
|
|
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
|
+
}
|
package/hooks/readset-guard.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
31
|
+
"gcf-aif-ext": "^0.7.6"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"extension.json",
|
package/skills/gcf-run/SKILL.md
CHANGED
|
@@ -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
|