gcf-harness-aif-ext 0.8.3 → 0.8.7

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,161 @@
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'];
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) {
71
+ const end = e.is_error ? `ended (${e.subtype ?? 'error'})` : 'done';
72
+ const t = c.tokens;
73
+
74
+ return (
75
+ `${end} in ${span(c.duration_ms)}, ${c.turns} turns, $${c.cost_usd.toFixed(2)} ` +
76
+ `(in ${count(t.input)} / out ${count(t.output)} tokens, cache read ${count(t.cache_read)}, ` +
77
+ `cache write ${count(t.cache_creation)})`
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Claude's usage-limit ending, `You've hit your session limit · resets 3am (Europe/Berlin)`:
83
+ * `{limit: {resets}}`, the time it names (null without one), or nothing for any other text. An
84
+ * error result is the session's end, so the message anywhere in it counts; an agent's text or a
85
+ * plain line counts only when it is that message whole: prose about some other limit is not.
86
+ * @returns {{limit?: {resets: string | null}}}
87
+ */
88
+ function limitIn(text, whole = true) {
89
+ const s = String(text ?? '').trim();
90
+ const hit = whole ? /^You['’]ve hit your [^\n]*limit[^\n]*$/i : /hit your .*limit/i;
91
+ if (!hit.test(s)) return {};
92
+
93
+ return { limit: { resets: s.match(/resets (.+)$/im)?.[1].trim() || null } };
94
+ }
95
+
96
+ /**
97
+ * One stdout line of a session as log lines: an event worth seeing becomes one stamped line, the
98
+ * rest of the stream (hooks, init, thinking, rate limits) none; the `result` event gives the
99
+ * final text, a summary line and the cost. `session_id` comes with the init event, `limit` with
100
+ * the usage-limit message, in whatever form it arrives: the session can be resumed after either.
101
+ * @param {string} line
102
+ * @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null}}}
103
+ */
104
+ export function activity(line, now = new Date()) {
105
+ let e;
106
+ try {
107
+ e = JSON.parse(line);
108
+ } catch {
109
+ return { lines: [line], ...limitIn(line) };
110
+ }
111
+
112
+ if (typeof e?.type !== 'string') return { lines: [line] };
113
+
114
+ const at = `[${clock(now)}]`;
115
+ const content = Array.isArray(e.message?.content) ? e.message.content : [];
116
+ if (e.type === 'system') return system(e);
117
+ if (e.type === 'assistant') return said(content, at);
118
+ if (e.type === 'user')
119
+ return {
120
+ lines: content
121
+ .filter((b) => b.type === 'tool_result' && b.is_error)
122
+ .map((b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`),
123
+ };
124
+ if (e.type === 'result') return ended(e, at);
125
+
126
+ return { lines: [] };
127
+ }
128
+
129
+ /** A system event: the init names the session; the rest (hooks, thinking) give nothing. */
130
+ const system = (e) =>
131
+ e.subtype === 'init' && typeof e.session_id === 'string'
132
+ ? { lines: [], session_id: e.session_id }
133
+ : { lines: [] };
134
+
135
+ /** An assistant turn: its tool calls and its text, which may be the usage-limit message. */
136
+ function said(content, at) {
137
+ const texts = content.filter((b) => b.type === 'text').map((b) => b.text);
138
+
139
+ return {
140
+ ...(texts.map((t) => limitIn(t)).find((l) => l.limit) ?? {}),
141
+ lines: content.flatMap((b) => {
142
+ if (b.type === 'tool_use') return [`${at} ${toolLine(b)}`];
143
+ const text = b.type === 'text' ? cut(firstLines(b.text, 2), 300) : '';
144
+
145
+ return text ? [`${at} agent: ${text}`] : [];
146
+ }),
147
+ };
148
+ }
149
+
150
+ /** The `result` event: the final text, a summary line, the cost, and a limit that ended it. */
151
+ function ended(e, at) {
152
+ const cost = costOf(e);
153
+ const text =
154
+ typeof e.result === 'string' && e.result.trim() ? [`${at} result:\n${e.result}`] : [];
155
+
156
+ return {
157
+ lines: [...text, `${at} ${summary(e, cost)}`],
158
+ cost,
159
+ ...(e.is_error ? limitIn(e.result, false) : {}),
160
+ };
161
+ }
@@ -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, 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,11 +224,24 @@ function alive(pid) {
154
224
  */
155
225
  async function claim(logs, id) {
156
226
  const file = path.join(logs, `${id}.lock`);
157
- for (let attempt = 0; attempt < 2; 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++) {
158
230
  if (await create(file)) return () => rm(file, { force: true });
159
- const text = await readFile(file, 'utf8').catch(() => null);
160
- if (text === null) continue; // released meanwhile
161
- const pid = Number(text.trim());
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;
239
+ }
240
+
241
+ attempt++;
242
+
243
+ // The run's pid is the first line; a started session adds the item and its own pid.
244
+ const pid = Number(text.split('\n')[0]);
162
245
  if (pid === process.pid) return () => rm(file, { force: true });
163
246
  if (!(await dead(file, pid))) return null;
164
247
  await clearDead(file, text);
@@ -197,7 +280,15 @@ async function dead(file, pid) {
197
280
  */
198
281
  async function clearDead(file, text) {
199
282
  const breaker = `${file}.break`;
200
- if (!(await create(breaker))) return;
283
+ if (!(await create(breaker))) {
284
+ // A breaker is held for a read and a remove: one whose run is gone was left by a crash there.
285
+ const held = await readFile(breaker, 'utf8').catch(() => null);
286
+ if (held !== null && (await dead(breaker, Number(held.split('\n')[0]))))
287
+ await rm(breaker, { force: true });
288
+
289
+ return;
290
+ }
291
+
201
292
  try {
202
293
  if ((await readFile(file, 'utf8').catch(() => null)) === text) await rm(file, { force: true });
203
294
  } finally {
@@ -205,9 +296,14 @@ async function clearDead(file, text) {
205
296
  }
206
297
  }
207
298
 
208
- /** 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>`. */
209
304
  function verified(dir, id) {
210
- 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)], {
211
307
  cwd: dir,
212
308
  encoding: 'utf8',
213
309
  });
@@ -215,20 +311,50 @@ function verified(dir, id) {
215
311
  return (res.stdout ?? '').split('\n').some((v) => v.trim() === id);
216
312
  }
217
313
 
314
+ // Not a slug, so no item: the lock a run gates its batch and claims its items under.
315
+ const ADMIT = '.admit';
316
+
218
317
  /**
219
- * The worktree and the agent command of each item, claimed for this run; an item that cannot
220
- * start is an error. `agentOf(id)` gives the command.
318
+ * `fn` under the admission lock: two runs started at once (two board clicks) would each pass a
319
+ * gate that sees no lock of the other yet, and run together what the gate keeps apart.
221
320
  */
222
- async function prepare(ids, core, logs, agentOf) {
223
- const ready = [];
321
+ async function admitted(logs, fn) {
322
+ const until = Date.now() + 60_000;
323
+ let release = await claim(logs, ADMIT);
324
+ while (!release) {
325
+ if (Date.now() > until)
326
+ throw new Error(`another run is starting (${ADMIT}.lock in ${logs}): retry when it ends`);
327
+ await new Promise((r) => setTimeout(r, 100));
328
+ release = await claim(logs, ADMIT);
329
+ }
330
+
331
+ try {
332
+ return await fn();
333
+ } finally {
334
+ await release();
335
+ }
336
+ }
337
+
338
+ /** Every item claimed for this run, or an error when another run drives it. */
339
+ async function claimAll(ids, logs) {
340
+ const claimed = [];
224
341
  const failed = [];
225
342
  for (const id of ids) {
226
343
  const release = await claim(logs, id);
227
- if (!release) {
228
- failed.push({ id, result: 'error', error: `${id} is already running in another run` });
229
- continue;
230
- }
344
+ if (release) claimed.push({ id, release });
345
+ else failed.push({ id, result: 'error', error: `${id} is already running in another run` });
346
+ }
231
347
 
348
+ return { claimed, failed };
349
+ }
350
+
351
+ /**
352
+ * The worktree and the agent command of each claimed item; an item that cannot start is an
353
+ * error. `agentOf(id)` gives the command.
354
+ */
355
+ async function prepare({ claimed, failed }, core, agentOf) {
356
+ const ready = [];
357
+ for (const { id, release } of claimed) {
232
358
  try {
233
359
  const agent = agentOf(id);
234
360
  const wt = json(await core(['worktree', id, '--json']), `worktree ${id}`);
@@ -243,14 +369,98 @@ async function prepare(ids, core, logs, agentOf) {
243
369
  return { ready, failed };
244
370
  }
245
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
+
246
457
  /**
247
458
  * One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
248
459
  * its worktree mode; on main it records the item's move itself.
249
460
  */
250
- async function session(r, logs, timeoutMs) {
461
+ async function session(r, logs, timeoutMs, fresh) {
251
462
  /** @type {import('node:fs/promises').FileHandle | null} */
252
463
  let log = null;
253
- const prompt = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
254
464
  // The lock names the item and its session: `stop <id>` ends that session alone, not the run.
255
465
  const lock = path.join(logs, `${r.main ? MAIN_RUN : r.id}.lock`);
256
466
  const onSpawn = (pid) => writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
@@ -258,10 +468,22 @@ async function session(r, logs, timeoutMs) {
258
468
  const mark = path.join(logs, `${r.id}.session`);
259
469
  const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
260
470
  try {
471
+ const { agent, prompt, rec } = await commandOf(r, logs, fresh);
261
472
  log = await open(path.join(logs, `${r.id}.log`), 'a');
262
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;
263
480
 
264
- 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
+ };
265
487
  } catch (e) {
266
488
  return { ...r, code: null, error: e.message };
267
489
  } finally {
@@ -287,8 +509,11 @@ function touch(file) {
287
509
  */
288
510
  async function recordBlock(r, outcome, core) {
289
511
  const reason = String(outcome.block);
290
- if (verified(r.dir, r.id))
291
- spawnSync('git', ['commit', '--allow-empty', '-m', `gcf(unverified): ${r.id}`], { cwd: r.dir });
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` };
292
517
  const b = await core(['block', r.id, '--reason', reason]);
293
518
 
294
519
  return {
@@ -299,18 +524,42 @@ async function recordBlock(r, outcome, core) {
299
524
  };
300
525
  }
301
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
+
535
+ /**
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.
539
+ */
540
+ function unverify(r) {
541
+ if (!verified(r.dir, r.id)) return null;
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]);
550
+
551
+ // shortcut: the integrate after it still merges a branch whose trailer stayed; the report
552
+ // says so, upgrade to integrating only the named branches if it ever happens.
553
+ return moved && moved.status === 0
554
+ ? null
555
+ : `could not take its trailer off: ${(moved || made).stderr.trim()}`;
556
+ }
557
+
302
558
  /** The result line of an item the integrator handled, or why it has none. */
303
559
  function resultOf(run, integrated) {
304
560
  const done = integrated.find((x) => x.id === run.id);
305
561
  if (!done && run.error) return { id: run.id, result: 'error', error: run.error };
306
- if (!done) {
307
- const why = run.code === 'timeout' ? 'the session timed out' : `agent exit ${run.code}`;
308
-
309
- return {
310
- id: run.id,
311
- result: `no result (${why}): ${run.id} stays ready, a rerun resumes its worktree`,
312
- };
313
- }
562
+ if (!done) return noResult(run, 'a rerun resumes its worktree');
314
563
 
315
564
  return {
316
565
  ...done,
@@ -336,9 +585,8 @@ async function mainResult(core, r) {
336
585
  if (!after) return { id: r.id, result: 'close' };
337
586
  if (after.status !== 'ready')
338
587
  return { id: r.id, result: MOVE_OF[after.status] ?? after.status, reason: after.blocked };
339
- const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
340
588
 
341
- 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');
342
590
  }
343
591
 
344
592
  // The lock of the main checkout itself: one run works there at a time. Not a slug, so no item.
@@ -350,7 +598,8 @@ export const MAIN_RUN = '.main-run';
350
598
  * carries a session mark (`gcf claim`), not a run lock: a lock would refuse the agent's own
351
599
  * moves. A session that stopped without a move leaves no mark behind.
352
600
  */
353
- async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
601
+ async function runMain(projectDir, ids, ctx) {
602
+ const { logs } = ctx;
354
603
  const releaseMain = await claim(logs, MAIN_RUN);
355
604
  if (!releaseMain)
356
605
  return ids.map((id) => ({
@@ -361,8 +610,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
361
610
 
362
611
  const items = [];
363
612
  try {
364
- for (const id of ids)
365
- items.push(await mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }));
613
+ for (const id of ids) items.push(await mainItem(projectDir, id, ctx));
366
614
  } finally {
367
615
  await releaseMain();
368
616
  }
@@ -371,7 +619,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
371
619
  }
372
620
 
373
621
  /** One item of a main run: marked, its session, its mark cleared, its result read. */
374
- async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
622
+ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs, fresh }) {
375
623
  let r;
376
624
  try {
377
625
  const claimed = await core(['claim', id]);
@@ -379,36 +627,51 @@ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
379
627
  const none = async () => {};
380
628
 
381
629
  const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true };
382
- r = await session(s, logs, timeoutMs);
630
+ r = await session(s, logs, timeoutMs, fresh);
383
631
  } catch (e) {
384
632
  r = { id, error: e.message };
385
633
  }
386
634
 
387
635
  await core(['release', id]);
636
+ const item = await mainResult(core, r);
637
+ await remember(logs, r, item);
388
638
 
389
- return mainResult(core, r);
639
+ return { ...item, ...r.cost };
390
640
  }
391
641
 
392
- /** Every item in its own worktree at once; blocks recorded on main, verified branches merged. */
393
- async function runWorktrees(ids, { core, logs, agentOf, timeoutMs }) {
394
- const { ready, failed } = await prepare(ids, core, logs, agentOf);
395
- const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
642
+ /** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
643
+ async function runWorktrees(claims, { core, logs, agentOf, timeoutMs, fresh }) {
644
+ const { ready, failed } = await prepare(claims, core, agentOf);
645
+ const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs, fresh)));
396
646
  const items = [...failed];
397
647
  for (const r of runs) {
398
648
  const outcome = await outcomeOf(r.dir);
399
649
  if (outcome?.block) items.push(await recordBlock(r, outcome, core));
650
+ // A session cut short (timeout, `stop`, a crash) may have meant to block after its trailer:
651
+ // its item stays ready, and the rerun verifies its branch again.
652
+ else if (r.code !== 0) {
653
+ const failed = unverify(r);
654
+ if (failed) items.push({ id: r.id, result: 'error', error: failed });
655
+ }
400
656
  }
401
657
 
402
658
  const integrated = json(await core(['integrate', '--json']), 'integrate');
403
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
+ );
404
666
 
405
- 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 }));
406
669
  }
407
670
 
408
671
  /**
409
672
  * Pick up orphans, gate the batch, run the items in the project's run mode (`mode` overrides
410
673
  * it). `gate(ids)` answers `{pass, reason}`; a session past `timeoutMs` is stopped with all it
411
- * started, and its item stays ready.
674
+ * started, and its item stays ready. `fresh` starts new sessions where kept ones would resume.
412
675
  */
413
676
  export async function run(
414
677
  projectDir,
@@ -419,29 +682,39 @@ export async function run(
419
682
  gate,
420
683
  timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
421
684
  mode = undefined,
685
+ fresh = false,
422
686
  },
423
687
  ) {
424
688
  // An id names a lock file and goes into the agent's shell command: a slug, nothing else.
425
689
  const bad = ids.find((id) => !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id));
426
690
  if (bad) throw new Error(`${bad} is not an item id (a lowercase hyphen slug)`);
427
691
  const orphans = json(await core(['integrate', '--json']), 'integrate');
428
- const passed = gate(ids);
429
- if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
430
-
431
692
  const logs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
432
693
  await mkdir(logs, { recursive: true });
433
694
  const board = json(await core(['board', '--json']), 'board');
434
695
  const runMode = mode ?? board.run_mode ?? 'worktree';
696
+ // The gate counts the items other runs hold locks on, so this run's locks go in with its gate.
697
+ // shortcut: a main run marks each item only as its session starts, so the items it has yet
698
+ // to reach do not count for another run's gate; claim them all here if main and worktree
699
+ // runs come to share a project.
700
+ const { passed, claims } = await admitted(logs, async () => {
701
+ const verdict = gate(ids);
702
+ const go = verdict.pass && runMode !== 'main';
703
+
704
+ return { passed: verdict, claims: go ? await claimAll(ids, logs) : null };
705
+ });
706
+ if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
707
+
435
708
  const agentOf = (id) =>
436
709
  agentFor(
437
710
  agent,
438
711
  (board.items ?? []).find((i) => i.id === id),
439
712
  );
440
- const ctx = { core, logs, agentOf, timeoutMs };
713
+ const ctx = { core, logs, agentOf, timeoutMs, fresh };
441
714
  const unhook = stopWithSessions();
442
715
  try {
443
716
  const items =
444
- runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(ids, ctx);
717
+ runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(claims, ctx);
445
718
 
446
719
  return { orphans, mode: runMode, items };
447
720
  } finally {
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.3",
4
+ "version": "0.8.7",
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.3",
3
+ "version": "0.8.7",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -224,8 +224,9 @@ function splitByProvenance({ aspect, units }, bodyOf) {
224
224
  const cited = [];
225
225
  const inferred = [];
226
226
  for (const u of units) {
227
- const body = ws(bodyOf.get(u.anchor) ?? '');
228
- if (u.quote && body.includes(ws(u.quote))) cited.push(u);
227
+ // A blank quote is in every body, even the empty one of an anchor the GDD does not have.
228
+ const quote = ws(u.quote ?? '');
229
+ if (quote && ws(bodyOf.get(u.anchor) ?? '').includes(quote)) cited.push(u);
229
230
  else inferred.push({ ...u, aspect });
230
231
  }
231
232