gcf-harness-aif-ext 0.8.4 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,7 +77,7 @@ Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry wh
77
77
 
78
78
  **`ai-factory gcf-harness gate <item-id…>`** — the `/gcf-run` gate as code. It reads one `gcf audit --json` report and exits 1 with a one-line reason when the items may not go: a core older than 0.4.0, a `schema:` other than 1 in `gcf/config.yml`, an id with no work item, a feature (run its children; the feature closes when they are finished), an item that is not `ready`, an unplayed dependency, no WIP room for the batch (items awaiting a verdict + the batch's `gate: verdict` items over `wip_built_limit`), or two items of the batch touching the same system unless both say `parallel: ok`. The tune limit is not a gate rule: `verdict tune` itself refuses at the limit, and an item tuned up to it still gets its last build. The skill runs it and stops on a non-zero exit — no model compares the numbers.
79
79
 
80
- **`ai-factory gcf-harness run <item-id…> [--agent "<cmd>"]`** — runs a batch at once, each item in its own worktree (`gcf worktree <id>`) with its own agent session: `claude -p "/gcf-run <id> --worktree"` by default, or the command in `--agent` / `GCF_RUN_AGENT`. The sessions run unattended, so set the agent's permissions for print mode first. A session ends with a commit and `gcf verify <id> --mark` (the `GCF-Verified` trailer); a question for the human goes into `.gcf-session/outcome.json` in its worktree, and `run` records the block on main. Then `gcf integrate` merges the verified branches and records each move, and `run` prints the playtest session. Its first step, like `/gcf-run`'s, integrates the verified branches a dead run left. A session that ended with no result leaves its item `ready`; the next `run` resumes its worktree. Logs: `.gcf-session/runs/<id>.log`. One run drives an item at a time (`.gcf-session/runs/<id>.lock`), and a resumed worktree first gets main merged in, so the answer from an `unblock` reaches the session. A worktree sees only what is committed: keep `.ai-factory.json`, the installed extensions and `.claude/` (skills, the readset-guard hook) in git, or the session has no `/gcf-run` and no `ai-factory gcf`.
80
+ **`ai-factory gcf-harness run <item-id…> [--agent "<cmd>"]`** — runs a batch at once, each item in its own worktree (`gcf worktree <id>`) with its own agent session: `claude -p --output-format stream-json --verbose "/gcf-run <id> --worktree"` by default, or the command in `--agent` / `GCF_RUN_AGENT`. The sessions run unattended, so set the agent's permissions for print mode first. A session ends with a commit and `gcf verify <id> --mark` (the `GCF-Verified` trailer); a question for the human goes into `.gcf-session/outcome.json` in its worktree, and `run` records the block on main. Then `gcf integrate` merges the verified branches and records each move, and `run` prints the playtest session. Its first step, like `/gcf-run`'s, integrates the verified branches a dead run left. A session that ended with no result leaves its item `ready`; the next `run` resumes its worktree and the agent's own session (`--resume <session_id>`, kept in `.gcf-session/runs/<id>.agent.json`), in either run mode; `--fresh` starts a new session instead. A session cut off by Claude's usage limit reports `paused (usage limit, resets 3am (…))` and `limit_resets`. Logs: `.gcf-session/runs/<id>.log`, written as the session works: a `start:` line, then one stamped line per agent message, tool call (`tool: Edit <file>`, `tool: Bash <command>`) and failed tool result, and at the end the agent's final text and `done in 34m, 212 turns, $3.41 (…tokens)`; stderr goes in as it is, and a non-Claude `--agent` keeps its own lines. With `--json`, each item carries the session's `cost_usd`, `tokens`, `turns` and `duration_ms` when the agent reported them. One run drives an item at a time (`.gcf-session/runs/<id>.lock`), and a resumed worktree first gets main merged in, so the answer from an `unblock` reaches the session. A worktree sees only what is committed: keep `.ai-factory.json`, the installed extensions and `.claude/` (skills, the readset-guard hook) in git, or the session has no `/gcf-run` and no `ai-factory gcf`.
81
81
 
82
82
  **`/gcf-run-ingest <gdd.md>`** — imports a design document as a fan-out workflow: parallel aspect extraction, provenance verified by code rather than by an agent (a quote either is a verbatim substring of its section or it is not), completeness lenses per candidate, and an interaction matrix computed from the candidates that write the same state.
83
83
 
@@ -0,0 +1,170 @@
1
+ // The live log of an agent session: `claude -p --output-format stream-json` prints one JSON event
2
+ // per line, and the run log gets one readable line per event worth seeing, so an operator can tell
3
+ // a working session from a stuck one. A line that is not JSON is another agent's own output.
4
+
5
+ /**
6
+ * @typedef {object} Cost What a session spent, from its `result` event.
7
+ * @property {number} cost_usd
8
+ * @property {{input: number, output: number, cache_read: number, cache_creation: number}} tokens
9
+ * @property {number} turns
10
+ * @property {number} duration_ms
11
+ */
12
+
13
+ /** `12:03:10`, the local time of day. */
14
+ export const clock = (now = new Date()) => now.toTimeString().slice(0, 8);
15
+
16
+ const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
17
+
18
+ /** The first `n` non-empty lines of a text, trimmed, on one line. */
19
+ const firstLines = (text, n) =>
20
+ String(text)
21
+ .split('\n')
22
+ .map((l) => l.trim())
23
+ .filter(Boolean)
24
+ .slice(0, n)
25
+ .join(' ');
26
+
27
+ // The input field that tells most about a tool call, in this order; with none, the name alone.
28
+ const TELLING = ['file_path', 'command', 'pattern', 'url', 'description'];
29
+
30
+ function toolLine(use) {
31
+ const key = TELLING.find((k) => typeof use.input?.[k] === 'string');
32
+
33
+ return key ? `tool: ${use.name} ${cut(firstLines(use.input[key], 1), 120)}` : `tool: ${use.name}`;
34
+ }
35
+
36
+ /** A tool result's text: a string, or a list of text blocks. */
37
+ const resultText = (content) =>
38
+ Array.isArray(content) ? content.map((c) => c?.text ?? '').join('\n') : String(content ?? '');
39
+
40
+ /** `1.2M`, `85k`, `950`: one decimal below ten of its unit. */
41
+ const count = (n) => (n >= 1e6 ? scaled(n / 1e6, 'M') : n >= 1e3 ? scaled(n / 1e3, 'k') : `${n}`);
42
+ const scaled = (v, unit) => `${v < 10 ? v.toFixed(1) : Math.round(v)}${unit}`;
43
+
44
+ /** `8s`, `34m`, `1h 12m`. */
45
+ function span(ms) {
46
+ const s = Math.round(ms / 1000);
47
+ if (s < 60) return `${s}s`;
48
+ const m = Math.round(s / 60);
49
+
50
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
51
+ }
52
+
53
+ /** @returns {Cost} */
54
+ function costOf(e) {
55
+ const u = e.usage ?? {};
56
+
57
+ return {
58
+ cost_usd: Number(e.total_cost_usd ?? 0),
59
+ tokens: {
60
+ input: Number(u.input_tokens ?? 0),
61
+ output: Number(u.output_tokens ?? 0),
62
+ cache_read: Number(u.cache_read_input_tokens ?? 0),
63
+ cache_creation: Number(u.cache_creation_input_tokens ?? 0),
64
+ },
65
+ turns: Number(e.num_turns ?? 0),
66
+ duration_ms: Number(e.duration_ms ?? 0),
67
+ };
68
+ }
69
+
70
+ function summary(e, c, limited) {
71
+ // A limit ends with subtype `success`: name the limit, not that.
72
+ const end = limited
73
+ ? 'ended (usage limit)'
74
+ : e.is_error
75
+ ? `ended (${e.subtype ?? 'error'})`
76
+ : 'done';
77
+ const t = c.tokens;
78
+
79
+ return (
80
+ `${end} in ${span(c.duration_ms)}, ${c.turns} turns, $${c.cost_usd.toFixed(2)} ` +
81
+ `(in ${count(t.input)} / out ${count(t.output)} tokens, cache read ${count(t.cache_read)}, ` +
82
+ `cache write ${count(t.cache_creation)})`
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Claude's usage-limit ending, `You've hit your session limit · resets 3am (Europe/Berlin)`:
88
+ * `{limit: {resets}}`, the time it names (null without one), or nothing for any other text. An
89
+ * error result is the session's end, so the message anywhere in it counts; an agent's text or a
90
+ * plain line counts only when it is that message whole: prose about some other limit is not.
91
+ * @returns {{limit?: {resets: string | null}}}
92
+ */
93
+ function limitIn(text, whole = true) {
94
+ const s = String(text ?? '').trim();
95
+ // shortcut: any "You've hit your … limit" counts, so an agent's whole-line "You've hit your WIP
96
+ // limit" would pause its item; narrow it once Claude's wordings (Opus, weekly…) are all known.
97
+ const hit = whole ? /^You['’]ve hit your [^\n]*limit[^\n]*$/i : /hit your .*limit/i;
98
+ if (!hit.test(s)) return {};
99
+
100
+ return { limit: { resets: s.match(/resets (.+)$/im)?.[1].trim() || null } };
101
+ }
102
+
103
+ /**
104
+ * One stdout line of a session as log lines: an event worth seeing becomes one stamped line, the
105
+ * rest of the stream (hooks, init, thinking, rate limits) none; the `result` event gives the
106
+ * final text, a summary line and the cost. `session_id` comes with the init event, `limit` with
107
+ * the usage-limit message, in whatever form it arrives: the session can be resumed after either.
108
+ * @param {string} line
109
+ * @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null}}}
110
+ */
111
+ export function activity(line, now = new Date()) {
112
+ let e;
113
+ try {
114
+ e = JSON.parse(line);
115
+ } catch {
116
+ return { lines: [line], ...limitIn(line) };
117
+ }
118
+
119
+ if (typeof e?.type !== 'string') return { lines: [line] };
120
+
121
+ const at = `[${clock(now)}]`;
122
+ 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);
125
+ if (e.type === 'user')
126
+ return {
127
+ lines: content
128
+ .filter((b) => b.type === 'tool_result' && b.is_error)
129
+ .map((b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`),
130
+ };
131
+ if (e.type === 'result') return ended(e, at);
132
+
133
+ return { lines: [] };
134
+ }
135
+
136
+ /** A system event: the init names the session; the rest (hooks, thinking) give nothing. */
137
+ const system = (e) =>
138
+ e.subtype === 'init' && typeof e.session_id === 'string'
139
+ ? { lines: [], session_id: e.session_id }
140
+ : { lines: [] };
141
+
142
+ /** An assistant turn: its tool calls and its text, which may be the usage-limit message. */
143
+ function said(content, at) {
144
+ const texts = content.filter((b) => b.type === 'text').map((b) => b.text);
145
+
146
+ return {
147
+ ...(texts.map((t) => limitIn(t)).find((l) => l.limit) ?? {}),
148
+ lines: content.flatMap((b) => {
149
+ if (b.type === 'tool_use') return [`${at} ${toolLine(b)}`];
150
+ const text = b.type === 'text' ? cut(firstLines(b.text, 2), 300) : '';
151
+
152
+ return text ? [`${at} agent: ${text}`] : [];
153
+ }),
154
+ };
155
+ }
156
+
157
+ /** The `result` event: the final text, a summary line, the cost, and a limit that ended it. */
158
+ function ended(e, at) {
159
+ const cost = costOf(e);
160
+ const text =
161
+ typeof e.result === 'string' && e.result.trim() ? [`${at} result:\n${e.result}`] : [];
162
+ // An error result counts the message anywhere in it; any other only as the whole text.
163
+ const limit = limitIn(e.result, !e.is_error);
164
+
165
+ return {
166
+ lines: [...text, `${at} ${summary(e, cost, Boolean(limit.limit))}`],
167
+ cost,
168
+ ...limit,
169
+ };
170
+ }
@@ -303,7 +303,7 @@ export function register(program) {
303
303
  .description('Run items at once, each in its worktree with its own agent, then integrate')
304
304
  .option(
305
305
  '--agent <command>',
306
- 'the agent command; it gets "/gcf-run <id> --worktree"',
306
+ 'the agent command; it gets "/gcf-run <id> --worktree", or --resume <session> to go on',
307
307
  process.env.GCF_RUN_AGENT ?? DEFAULT_AGENT,
308
308
  )
309
309
  .option(
@@ -312,6 +312,7 @@ export function register(program) {
312
312
  String(DEFAULT_TIMEOUT_MIN),
313
313
  )
314
314
  .option('--mode <mode>', 'worktree | main (default: run_mode of gcf/config.yml)')
315
+ .option('--fresh', 'start new agent sessions: an interrupted one is not resumed')
315
316
  .option('--json', 'machine output')
316
317
  .action(runItems);
317
318
 
@@ -416,6 +417,7 @@ async function runItems(ids, opts) {
416
417
  gate: (batch) => gate(auditReport(), batch),
417
418
  timeoutMs: minutes * 60_000,
418
419
  mode: opts.mode,
420
+ fresh: Boolean(opts.fresh),
419
421
  });
420
422
  if (opts.json) console.log(JSON.stringify(r, null, 2));
421
423
  else printRun(r);
package/commands/run.js CHANGED
@@ -3,11 +3,13 @@
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, rmSync, utimesSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, utimesSync, writeFileSync, writeSync } from 'node:fs';
7
7
  import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
+ import { activity, clock } from './activity.js';
9
10
 
10
- export const DEFAULT_AGENT = 'claude -p';
11
+ // The event stream, not the final answer alone: the log shows the session at work as it goes.
12
+ export const DEFAULT_AGENT = 'claude -p --output-format stream-json --verbose';
11
13
 
12
14
  /** The core CLI: the same ai-factory that runs this command, in the project. */
13
15
  export function cliCore(projectDir) {
@@ -90,36 +92,104 @@ function stopWithSessions() {
90
92
  return () => process.removeListener('SIGTERM', stop);
91
93
  }
92
94
 
95
+ /**
96
+ * Hand a stream to `take` line by line. Returns the flush of a last line with no newline: a
97
+ * stream destroyed under a tool that holds it open never ends.
98
+ */
99
+ function byLine(stream, take) {
100
+ let rest = '';
101
+ stream.setEncoding('utf8');
102
+ stream.on('data', (chunk) => {
103
+ const lines = (rest + chunk).split('\n');
104
+ rest = lines.pop() ?? '';
105
+ for (const l of lines) take(l);
106
+ });
107
+ const flush = () => {
108
+ if (rest) take(rest);
109
+ rest = '';
110
+ };
111
+
112
+ stream.on('end', flush);
113
+
114
+ return flush;
115
+ }
116
+
117
+ // What `claude --resume` prints on stderr for a session it does not have.
118
+ const NO_SESSION = /No conversation found with session ID/;
119
+
93
120
  /**
94
121
  * One agent session on `prompt` in `dir`; resolves with its exit code, or `timeout` when it ran
95
122
  * past `timeoutMs`: a hung session would hold the item's run lock, and every move on it, for
96
- * good.
123
+ * good. Its stdout reaches the log through `activity`, its stderr as it is; `cost` is what a
124
+ * Claude session reported at its end, `session_id` the id a rerun resumes it by, `limit` the
125
+ * usage limit that ended it, `gone` that the session it was to resume does not exist.
126
+ * @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
127
+ * session_id?: string, limit?: {resets: string | null}, gone: boolean}>}
97
128
  */
98
129
  function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
99
130
  return new Promise((resolve) => {
100
- const child = spawn(`${agent} "${prompt}"`, {
131
+ const command = `${agent} "${prompt}"`;
132
+ // A line before the agent's first: the log's time says the session is alive from its start.
133
+ writeSync(fd, `[${clock()}] start: ${command}\n`);
134
+ const child = spawn(command, {
101
135
  cwd: dir,
102
136
  shell: true,
103
137
  // The session's own gate must not refuse the item this run marked as running.
104
138
  env: { ...process.env, GCF_RUN_ITEM: id },
105
139
  detached: !WIN,
106
140
  windowsHide: true,
107
- stdio: ['ignore', fd, fd],
141
+ stdio: ['ignore', 'pipe', 'pipe'],
108
142
  });
109
143
  sessions.add(child);
110
144
  if (child.pid) onSpawn?.(child.pid);
145
+ let open = true;
146
+ let cost;
147
+ let sessionId;
148
+ let limit;
149
+ let gone = false;
150
+ const write = (lines) => {
151
+ try {
152
+ if (open && lines.length) writeSync(fd, lines.map((l) => `${l}\n`).join(''));
153
+ } catch {
154
+ // A log that cannot be written loses its lines, not the run.
155
+ }
156
+ };
157
+
158
+ const flushOut = byLine(child.stdout, (line) => {
159
+ const a = activity(line);
160
+ cost = a.cost ?? cost;
161
+ sessionId = a.session_id ?? sessionId;
162
+ limit = a.limit ?? limit;
163
+ write(a.lines);
164
+ });
165
+ const flushErr = byLine(child.stderr, (line) => {
166
+ gone ||= NO_SESSION.test(line);
167
+ write([line]);
168
+ });
111
169
  const timer = setTimeout(() => {
112
170
  killTree(child);
113
- resolve('timeout');
171
+ end('timeout');
114
172
  }, timeoutMs);
115
- const end = (code) => {
173
+ function end(code) {
174
+ if (!open) return;
175
+ flushOut();
176
+ flushErr();
177
+ open = false;
116
178
  sessions.delete(child);
117
179
  clearTimeout(timer);
118
- resolve(code);
119
- };
180
+ resolve({ code, cost, session_id: sessionId, limit, gone });
181
+ }
120
182
 
121
183
  child.on('error', () => end(-1));
122
- child.on('exit', (code) => end(code ?? -1));
184
+ // `close` waits for the agent's output as well: its last lines, the result, may come after
185
+ // its exit. A tool it left running may hold that output open: the session ends with it.
186
+ child.on('close', (code) => end(code ?? -1));
187
+ child.on('exit', () =>
188
+ setTimeout(() => {
189
+ child.stdout.destroy();
190
+ child.stderr.destroy();
191
+ }, 2000).unref(),
192
+ );
123
193
  });
124
194
  }
125
195
 
@@ -154,16 +224,22 @@ function alive(pid) {
154
224
  */
155
225
  async function claim(logs, id) {
156
226
  const file = path.join(logs, `${id}.lock`);
157
- // Three tries: a breaker a dead run left, then the dead lock itself, is cleared on the way.
158
- for (let attempt = 0; attempt < 3; attempt++) {
227
+ // Three tries: a breaker a dead run left, then the dead lock itself, is cleared on the way. A
228
+ // lock released between the create and the read spends no try, up to six rounds in all.
229
+ for (let attempt = 0, round = 0; attempt < 3 && round < 6; round++) {
159
230
  if (await create(file)) return () => rm(file, { force: true });
160
- const text = await readFile(file, 'utf8').catch(() => null);
161
- // Released meanwhile: the next create may take it, and that try is not spent.
162
- if (text === null) {
163
- attempt--;
164
- continue;
231
+ let text;
232
+ try {
233
+ text = await readFile(file, 'utf8');
234
+ } catch (e) {
235
+ // Released meanwhile: the next create may take it. A lock that cannot be read is busy.
236
+ if (e.code === 'ENOENT') continue;
237
+
238
+ return null;
165
239
  }
166
240
 
241
+ attempt++;
242
+
167
243
  // The run's pid is the first line; a started session adds the item and its own pid.
168
244
  const pid = Number(text.split('\n')[0]);
169
245
  if (pid === process.pid) return () => rm(file, { force: true });
@@ -220,9 +296,14 @@ async function clearDead(file, text) {
220
296
  }
221
297
  }
222
298
 
223
- /** Whether the tip of the worktree's branch carries `GCF-Verified: <id>`. */
299
+ // The item's branch, as the integrator reads it: not HEAD, which a session cut off mid-rebase
300
+ // leaves detached.
301
+ const branchRef = (id) => `refs/heads/work/${id}`;
302
+
303
+ /** Whether the tip of the item's branch carries `GCF-Verified: <id>`. */
224
304
  function verified(dir, id) {
225
- const res = spawnSync('git', ['log', '-1', '--format=%(trailers:key=GCF-Verified,valueonly)'], {
305
+ const format = '--format=%(trailers:key=GCF-Verified,valueonly)';
306
+ const res = spawnSync('git', ['log', '-1', format, branchRef(id)], {
226
307
  cwd: dir,
227
308
  encoding: 'utf8',
228
309
  });
@@ -288,14 +369,98 @@ async function prepare({ claimed, failed }, core, agentOf) {
288
369
  return { ready, failed };
289
370
  }
290
371
 
372
+ /** `<id>.agent.json`: the agent session an item's last run left unfinished, for a rerun. */
373
+ const recordOf = (logs, id) => path.join(logs, `${id}.agent.json`);
374
+
375
+ // A session id goes into the agent's shell command: a UUID, nothing else.
376
+ const SESSION_ID = /^[0-9a-f-]{36}$/i;
377
+
378
+ /** The kept session of an item, or null: a file that is not a record is none. */
379
+ async function kept(logs, id) {
380
+ try {
381
+ const rec = JSON.parse(await readFile(recordOf(logs, id), 'utf8'));
382
+
383
+ return SESSION_ID.test(String(rec?.session_id)) ? rec : null;
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ /**
390
+ * The record of a session that ended with its item still ready: a rerun resumes it with all the
391
+ * agent read and decided. The item's move ends the session for good; an error leaves it as is.
392
+ */
393
+ async function remember(logs, r, item) {
394
+ if (item.result === 'error') return;
395
+ if (!STAYED.test(item.result) || r.lost) return rm(recordOf(logs, r.id), { force: true });
396
+ if (!r.session_id) return;
397
+ const ended = r.limit ? 'limit' : r.code === 'timeout' ? 'timeout' : `exit ${r.code}`;
398
+ const rec = {
399
+ session_id: r.session_id,
400
+ ended,
401
+ ...(r.limit?.resets ? { resets: r.limit.resets } : {}),
402
+ at: new Date().toISOString(),
403
+ };
404
+ await writeFile(recordOf(logs, r.id), `${JSON.stringify(rec, null, 2)}\n`);
405
+ }
406
+
407
+ /**
408
+ * The line of a session that ended with its item still ready: why, and what a rerun does. A
409
+ * usage limit pauses the item; `limit_resets` tells a board when it may go on.
410
+ */
411
+ function noResult(r, rerun) {
412
+ if (r.lost)
413
+ return {
414
+ id: r.id,
415
+ result:
416
+ `no result (its session could not be resumed, agent exit ${r.code}): ` +
417
+ `${r.id} stays ready, a rerun starts fresh`,
418
+ };
419
+ const again = r.session_id ? 'a rerun resumes its session' : rerun;
420
+ if (r.limit) {
421
+ const resets = r.limit.resets ? `, resets ${r.limit.resets}` : '';
422
+
423
+ return {
424
+ id: r.id,
425
+ result: `paused (usage limit${resets}): ${r.id} stays ready, ${again}`,
426
+ limit_resets: r.limit.resets,
427
+ };
428
+ }
429
+
430
+ const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
431
+
432
+ return { id: r.id, result: `no result (${why}): ${r.id} stays ready, ${again}` };
433
+ }
434
+
435
+ const STAYED = / stays ready, a rerun /;
436
+
437
+ /**
438
+ * The agent command and prompt of an item's session: `/gcf-run`, or the resume of the session
439
+ * its last run kept (`--resume`, in the same directory, so the agent finds it) unless `fresh`.
440
+ */
441
+ async function commandOf(r, logs, fresh) {
442
+ const task = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
443
+ if (fresh) await rm(recordOf(logs, r.id), { force: true });
444
+ // `--resume` is a flag of `claude`: another agent gets the task, and the record stays.
445
+ const rec = fresh || !/^claude(\s|$)/.test(r.agent) ? null : await kept(logs, r.id);
446
+ if (!rec) return { agent: r.agent, prompt: task, rec };
447
+ // One plain line: the prompt goes into the shell command in double quotes.
448
+ const ended = String(rec.ended).replace(/[^\w -]/g, '');
449
+
450
+ return {
451
+ agent: `${r.agent} --resume ${rec.session_id}`,
452
+ prompt: `Continue ${task}: the session was cut off (${ended}). Pick up where you stopped.`,
453
+ rec,
454
+ };
455
+ }
456
+
291
457
  /**
292
458
  * One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
293
459
  * its worktree mode; on main it records the item's move itself.
294
460
  */
295
- async function session(r, logs, timeoutMs) {
461
+ async function session(r, logs, timeoutMs, fresh) {
296
462
  /** @type {import('node:fs/promises').FileHandle | null} */
297
463
  let log = null;
298
- const prompt = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
299
464
  // The lock names the item and its session: `stop <id>` ends that session alone, not the run.
300
465
  const lock = path.join(logs, `${r.main ? MAIN_RUN : r.id}.lock`);
301
466
  const onSpawn = (pid) => writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
@@ -303,10 +468,22 @@ async function session(r, logs, timeoutMs) {
303
468
  const mark = path.join(logs, `${r.id}.session`);
304
469
  const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
305
470
  try {
471
+ const { agent, prompt, rec } = await commandOf(r, logs, fresh);
306
472
  log = await open(path.join(logs, `${r.id}.log`), 'a');
307
473
  const opts = { fd: log.fd, timeoutMs, id: r.id, onSpawn };
474
+ const ran = await startAgent(agent, prompt, r.dir, opts);
475
+
476
+ // A resume that claude answered with `No conversation found`: the session is gone, and the
477
+ // next run starts fresh rather than fail on it again. Any other early end (a stop, a crash,
478
+ // no network) keeps it for the next try.
479
+ const lost = Boolean(rec) && ran.gone;
308
480
 
309
- return { ...r, code: await startAgent(r.agent, prompt, r.dir, opts) };
481
+ return {
482
+ ...r,
483
+ ...ran,
484
+ session_id: ran.session_id ?? (lost ? undefined : rec?.session_id),
485
+ lost,
486
+ };
310
487
  } catch (e) {
311
488
  return { ...r, code: null, error: e.message };
312
489
  } finally {
@@ -332,7 +509,11 @@ function touch(file) {
332
509
  */
333
510
  async function recordBlock(r, outcome, core) {
334
511
  const reason = String(outcome.block);
335
- unverify(r);
512
+ // A block recorded over a trailer that stayed would see the branch merged after its unblock,
513
+ // without the answer: the operator gets the error instead.
514
+ const failed = unverify(r);
515
+ if (failed)
516
+ return { id: r.id, result: 'error', reason, error: `${failed}; the block is not recorded` };
336
517
  const b = await core(['block', r.id, '--reason', reason]);
337
518
 
338
519
  return {
@@ -343,40 +524,42 @@ async function recordBlock(r, outcome, core) {
343
524
  };
344
525
  }
345
526
 
527
+ // The bookkeeping commit's author when the worktree has none: the trailer must come off anyway.
528
+ const GCF_IDENT = {
529
+ GIT_AUTHOR_NAME: 'gcf',
530
+ GIT_AUTHOR_EMAIL: 'gcf@localhost',
531
+ GIT_COMMITTER_NAME: 'gcf',
532
+ GIT_COMMITTER_EMAIL: 'gcf@localhost',
533
+ };
534
+
346
535
  /**
347
- * An empty commit takes the trailer off the branch tip: no integrate merges it. `--only` keeps
348
- * what a killed agent staged out of it, and a killed git leaves an index.lock to clear first.
536
+ * An empty commit takes the trailer off the branch tip: no integrate merges it. Plumbing, not
537
+ * `git commit`: it never touches the index, so what a killed agent staged, its index.lock or a
538
+ * merge it left half done cannot stop it.
349
539
  */
350
540
  function unverify(r) {
351
541
  if (!verified(r.dir, r.id)) return null;
352
- const args = ['commit', '-q', '--no-verify', '--allow-empty', '--only', '-m'];
353
- const commit = () =>
354
- spawnSync('git', [...args, `gcf(unverified): ${r.id}`], { cwd: r.dir, encoding: 'utf8' });
355
- if (commit().status === 0) return null;
356
- const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], {
357
- cwd: r.dir,
358
- encoding: 'utf8',
359
- }).stdout.trim();
360
- if (gitDir) rmSync(path.join(gitDir, 'index.lock'), { force: true });
361
- const again = commit();
542
+ const git = (args, env = {}) =>
543
+ spawnSync('git', args, { cwd: r.dir, encoding: 'utf8', env: { ...process.env, ...env } });
544
+ const ref = branchRef(r.id);
545
+ const tip = git(['rev-parse', ref]).stdout.trim();
546
+ const tree = ['commit-tree', `${ref}^{tree}`, '-p', ref, '-m', `gcf(unverified): ${r.id}`];
547
+ let made = git(tree);
548
+ if (made.status !== 0) made = git(tree, GCF_IDENT);
549
+ const moved = made.status === 0 && git(['update-ref', ref, made.stdout.trim(), tip]);
362
550
 
363
551
  // shortcut: the integrate after it still merges a branch whose trailer stayed; the report
364
552
  // says so, upgrade to integrating only the named branches if it ever happens.
365
- return again.status === 0 ? null : `could not take its trailer off: ${again.stderr.trim()}`;
553
+ return moved && moved.status === 0
554
+ ? null
555
+ : `could not take its trailer off: ${(moved || made).stderr.trim()}`;
366
556
  }
367
557
 
368
558
  /** The result line of an item the integrator handled, or why it has none. */
369
559
  function resultOf(run, integrated) {
370
560
  const done = integrated.find((x) => x.id === run.id);
371
561
  if (!done && run.error) return { id: run.id, result: 'error', error: run.error };
372
- if (!done) {
373
- const why = run.code === 'timeout' ? 'the session timed out' : `agent exit ${run.code}`;
374
-
375
- return {
376
- id: run.id,
377
- result: `no result (${why}): ${run.id} stays ready, a rerun resumes its worktree`,
378
- };
379
- }
562
+ if (!done) return noResult(run, 'a rerun resumes its worktree');
380
563
 
381
564
  return {
382
565
  ...done,
@@ -402,9 +585,8 @@ async function mainResult(core, r) {
402
585
  if (!after) return { id: r.id, result: 'close' };
403
586
  if (after.status !== 'ready')
404
587
  return { id: r.id, result: MOVE_OF[after.status] ?? after.status, reason: after.blocked };
405
- const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
406
588
 
407
- return { id: r.id, result: `no result (${why}): ${r.id} stays ready, a rerun picks it up` };
589
+ return noResult(r, 'a rerun picks it up');
408
590
  }
409
591
 
410
592
  // The lock of the main checkout itself: one run works there at a time. Not a slug, so no item.
@@ -416,7 +598,8 @@ export const MAIN_RUN = '.main-run';
416
598
  * carries a session mark (`gcf claim`), not a run lock: a lock would refuse the agent's own
417
599
  * moves. A session that stopped without a move leaves no mark behind.
418
600
  */
419
- async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
601
+ async function runMain(projectDir, ids, ctx) {
602
+ const { logs } = ctx;
420
603
  const releaseMain = await claim(logs, MAIN_RUN);
421
604
  if (!releaseMain)
422
605
  return ids.map((id) => ({
@@ -427,8 +610,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
427
610
 
428
611
  const items = [];
429
612
  try {
430
- for (const id of ids)
431
- items.push(await mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }));
613
+ for (const id of ids) items.push(await mainItem(projectDir, id, ctx));
432
614
  } finally {
433
615
  await releaseMain();
434
616
  }
@@ -437,7 +619,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
437
619
  }
438
620
 
439
621
  /** One item of a main run: marked, its session, its mark cleared, its result read. */
440
- async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
622
+ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs, fresh }) {
441
623
  let r;
442
624
  try {
443
625
  const claimed = await core(['claim', id]);
@@ -445,20 +627,22 @@ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
445
627
  const none = async () => {};
446
628
 
447
629
  const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true };
448
- r = await session(s, logs, timeoutMs);
630
+ r = await session(s, logs, timeoutMs, fresh);
449
631
  } catch (e) {
450
632
  r = { id, error: e.message };
451
633
  }
452
634
 
453
635
  await core(['release', id]);
636
+ const item = await mainResult(core, r);
637
+ await remember(logs, r, item);
454
638
 
455
- return mainResult(core, r);
639
+ return { ...item, ...r.cost };
456
640
  }
457
641
 
458
642
  /** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
459
- async function runWorktrees(claims, { core, logs, agentOf, timeoutMs }) {
643
+ async function runWorktrees(claims, { core, logs, agentOf, timeoutMs, fresh }) {
460
644
  const { ready, failed } = await prepare(claims, core, agentOf);
461
- const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
645
+ const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs, fresh)));
462
646
  const items = [...failed];
463
647
  for (const r of runs) {
464
648
  const outcome = await outcomeOf(r.dir);
@@ -473,14 +657,21 @@ async function runWorktrees(claims, { core, logs, agentOf, timeoutMs }) {
473
657
 
474
658
  const integrated = json(await core(['integrate', '--json']), 'integrate');
475
659
  for (const r of runs) if (!items.some((i) => i.id === r.id)) items.push(resultOf(r, integrated));
660
+ for (const r of runs)
661
+ await remember(
662
+ logs,
663
+ r,
664
+ items.find((i) => i.id === r.id),
665
+ );
476
666
 
477
- return items;
667
+ // What each session spent rides on its item's line, when the agent reported it.
668
+ return items.map((i) => ({ ...i, ...runs.find((r) => r.id === i.id)?.cost }));
478
669
  }
479
670
 
480
671
  /**
481
672
  * Pick up orphans, gate the batch, run the items in the project's run mode (`mode` overrides
482
673
  * it). `gate(ids)` answers `{pass, reason}`; a session past `timeoutMs` is stopped with all it
483
- * started, and its item stays ready.
674
+ * started, and its item stays ready. `fresh` starts new sessions where kept ones would resume.
484
675
  */
485
676
  export async function run(
486
677
  projectDir,
@@ -491,6 +682,7 @@ export async function run(
491
682
  gate,
492
683
  timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
493
684
  mode = undefined,
685
+ fresh = false,
494
686
  },
495
687
  ) {
496
688
  // An id names a lock file and goes into the agent's shell command: a slug, nothing else.
@@ -518,7 +710,7 @@ export async function run(
518
710
  agent,
519
711
  (board.items ?? []).find((i) => i.id === id),
520
712
  );
521
- const ctx = { core, logs, agentOf, timeoutMs };
713
+ const ctx = { core, logs, agentOf, timeoutMs, fresh };
522
714
  const unhook = stopWithSessions();
523
715
  try {
524
716
  const items =
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-harness-aif-ext",
4
- "version": "0.8.4",
4
+ "version": "0.8.8",
5
5
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
6
  "skills": [
7
7
  "skills/gcf-run",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.8.4",
3
+ "version": "0.8.8",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",