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