gcf-harness-aif-ext 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,11 @@ Updating keeps the hook entry valid — it points into the extension's install d
47
47
 
48
48
  0.5.1 is a fix release. During a run, `gcf/config.yml` is always readable, and a Grep inside `gcf/` reads only readset files. An import breaks a `depends:` cycle the pair checks closed and asks a D1 about it. It cuts a section longer than a segment at paragraphs, and lens and pair prompts carry a window around the quote. `/gcf-run` checks the plan against each pillar's invariant and asks each pillar's review question over the diff. Use it with core 0.4.1, which brings the parent feature into the readset.
49
49
 
50
+ 0.9.1 is a fix release:
51
+ - A stopped session reads as `no result (stopped)`, and its kept record as `ended: stopped`: before, the kill's exit code (`agent exit 1` on Windows).
52
+ - `gcf-harness stop <id>` never kills the run: a stop before the item's session starts (or between two sessions of a handoff) leaves `.gcf-session/runs/<id>.stop`, and that session is stopped as it starts. Before, a stop then killed the whole run with the other items of its batch. A run hung before its first session (in its worktree setup) is not stopped. On main, a stop marks the item too once the run has reached it. Known limit: a stop in the moment between a worktree run's claim of the item and its clearing of an old mark is lost.
53
+ - A stop that comes while the session starts no longer misses it.
54
+
50
55
  0.9.0, to update together with core 0.7.6:
51
56
  - The harness's own agent runs with `--permission-mode bypassPermissions`: it may do everything but the project's deny rules, which hold in every mode. The rules come from the deny groups of `gcf/config.yml` (`ai-factory gcf permissions`, or the board): git history and uncommitted work, recursive delete, publishing, the human's GCF moves and the guards are on by default; secrets, changes outside the project and killing processes are off. `git push` is allowed. Deny rules match the command as written: they catch accidents, not a rewording, and `/gcf-run` makes a refused command a stop with `needs permission: <command>`.
52
57
  - A run loads the project's settings and MCP servers only (`--setting-sources project,local --strict-mcp-config --mcp-config .mcp.json`): the operator's plugins, hooks and claude.ai connectors stay out. So does `env` or `apiKeyHelper` in `~/.claude/settings.json`: an operator who logs in through them, not `claude login`, names the agent with `--agent`. The deny rules and the handoff guard come in `.gcf-session/runs/agent-settings.json` (`--settings`), so a worktree gets them without a commit.
@@ -192,7 +192,7 @@ function ended(e, at) {
192
192
 
193
193
  // The window of each model the session used: its subagents may run on another.
194
194
  const windows = Object.entries(e.modelUsage ?? {})
195
- .map(([m, u]) => [m, Number(u?.contextWindow)])
195
+ .map(([m, u]) => /** @type {[string, number]} */ ([m, Number(u?.contextWindow)]))
196
196
  .filter(([, n]) => n > 0);
197
197
 
198
198
  return {
@@ -329,7 +329,7 @@ export function register(program) {
329
329
  });
330
330
  harness
331
331
  .command('stop <id>')
332
- .description('Stop the run that drives an item, with every session it started; it stays ready')
332
+ .description('Stop the session that works an item; the run goes on, the item stays ready')
333
333
  .action((id) => console.log(stopRun(process.cwd(), id)));
334
334
  }
335
335
 
@@ -360,12 +360,12 @@ function lockOf(file) {
360
360
  }
361
361
  }
362
362
 
363
- /** Kill a process and everything under it: a tree on Windows, its own group elsewhere. */
364
- function killTree(pid, group) {
363
+ /** Kill a session and everything under it: a tree on Windows, its own group elsewhere. */
364
+ function killTree(pid) {
365
365
  if (WIN) spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
366
366
  else {
367
367
  try {
368
- process.kill(group ? -pid : pid, group ? 'SIGKILL' : 'SIGTERM');
368
+ process.kill(-pid, 'SIGKILL');
369
369
  } catch {
370
370
  // Gone already.
371
371
  }
@@ -375,23 +375,24 @@ function killTree(pid, group) {
375
375
  /**
376
376
  * Stop the session that works `id`: the one its own lock names (worktree mode), or the main
377
377
  * checkout's run when that run works this very item (main mode). Only that session goes: the
378
- * run records it as no result and goes on with the rest. A run that has not started a session
379
- * yet goes down whole. The item stays ready and its work in progress is kept.
378
+ * run records it as stopped and goes on with the rest. The item stays ready and its work in
379
+ * progress is kept.
380
380
  */
381
381
  export function stopRun(projectDir, id, core = cliCore(projectDir)) {
382
382
  const runs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
383
- const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
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
- );
393
- if (lock && lock.session && alive(lock.session)) killTree(lock.session, true);
394
- else if (lock && lock.item !== id) killTree(lock.run, false);
383
+ const lockFor = () => {
384
+ const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
385
+
386
+ return lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
387
+ };
388
+
389
+ // The mark says the session ended by a stop, and stops the next one as it starts: the lock
390
+ // names none before the item's first session, or between two of a handoff. Read again after
391
+ // the mark: a session that started meanwhile did not see it.
392
+ const lock = lockFor();
393
+ if (lock) writeFileSync(stopOf(runs, id), `${Date.now()}\n`);
394
+ const session = lock && lockFor()?.session;
395
+ if (session && alive(session)) killTree(session);
395
396
  core(['release', id]);
396
397
 
397
398
  return lock ? `stopped the run of ${id}; it stays ready` : `no run drives ${id}`;
package/commands/run.js CHANGED
@@ -156,12 +156,12 @@ function killTree(child) {
156
156
  }
157
157
  }
158
158
 
159
- // The sessions this run has going: a stop (`gcf-harness stop`) takes them down with the run.
159
+ // The sessions this run has going: a run that is killed (SIGTERM) takes them down with it.
160
160
  const sessions = new Set();
161
161
 
162
162
  /**
163
- * `gcf-harness stop` signals the run (SIGTERM); its sessions lead their own process groups, so
164
- * the run takes each down before it exits. On Windows the stop kills the whole tree itself.
163
+ * A run that gets SIGTERM (`gcf-harness stop <id>` stops one session, never the run): its
164
+ * sessions lead their own process groups, so the run takes each down before it exits.
165
165
  * Returns the unhook.
166
166
  */
167
167
  function stopWithSessions() {
@@ -202,6 +202,91 @@ function byLine(stream, take) {
202
202
  // What `claude --resume` prints on stderr for a session it does not have.
203
203
  const NO_SESSION = /No conversation found with session ID/;
204
204
 
205
+ /** Keep what a session's activity told: the last of each, the model's window from the result. */
206
+ function absorb(seen, a) {
207
+ seen.cost = a.cost ?? seen.cost;
208
+ seen.session_id = a.session_id ?? seen.session_id;
209
+ seen.limit = a.limit ?? seen.limit;
210
+ seen.model = a.model ?? seen.model;
211
+ // The init model's window; the largest when the result does not name that model.
212
+ if (a.windows) seen.window = a.windows[seen.model] ?? Math.max(...Object.values(a.windows));
213
+ }
214
+
215
+ /** Write the flag the handoff guard reads into the session's checkout. */
216
+ function writeFlag(dir, flag) {
217
+ try {
218
+ mkdirSync(path.join(dir, '.gcf-session'), { recursive: true });
219
+ writeFileSync(path.join(dir, HANDOFF_FLAG), `${JSON.stringify(flag)}\n`);
220
+ } catch {
221
+ // No flag: the turns past it stop the session all the same.
222
+ }
223
+ }
224
+
225
+ /**
226
+ * An agent session's shell: its own process group off Windows, so a stop takes all it started.
227
+ * The guards read the session's own checkout, not one an outer Claude session named.
228
+ */
229
+ function spawnAgent(command, dir, env) {
230
+ return spawn(command, {
231
+ cwd: dir,
232
+ shell: true,
233
+ // GCF_RUN_ITEM: the session's own gate must not refuse the item this run marked as running.
234
+ env: { ...process.env, ...env, CLAUDE_PROJECT_DIR: dir },
235
+ detached: !WIN,
236
+ windowsHide: true,
237
+ stdio: ['ignore', 'pipe', 'pipe'],
238
+ });
239
+ }
240
+
241
+ /**
242
+ * The handoff watch of one session (see `startAgent`): `see(a, model)` takes each activity, flags
243
+ * the session past its share or at a compaction, and calls `stop` once it works on past more.
244
+ */
245
+ function handoffWatch(handoff, dir, write, stop) {
246
+ const w = { flagged: false, after: 0, context: 0, turn: undefined, unknown: false };
247
+ const unknownOnce = (model) => {
248
+ if (w.unknown) return;
249
+ w.unknown = true;
250
+ write([`[${clock()}] handoff: off, the window of ${model ?? 'the model'} is unknown`]);
251
+ };
252
+
253
+ const flag = (win, pct, compacted) => {
254
+ w.flagged = true;
255
+ writeFlag(dir, { pct, tokens: w.context, window: win, plan: handoff.plan ?? null });
256
+ const why = compacted
257
+ ? 'a compaction'
258
+ : `context ${pct}% (${count(w.context)} of ${count(win)})`;
259
+ write([`[${clock()}] handoff: ${why}, the session hands over`]);
260
+ };
261
+
262
+ const check = (model, compacted) => {
263
+ const win = handoff.windowOf(model);
264
+ const pct = win ? Math.round((100 * w.context) / win) : null;
265
+ // No share is under every threshold: a session of an unknown window hands over at a compaction.
266
+ const share = pct ?? 0;
267
+ if (pct === null && !compacted) unknownOnce(model);
268
+ if (!w.flagged && (compacted || share >= handoff.pct)) flag(win, pct, compacted);
269
+ if (w.flagged && (w.after > HARD_TURNS || share >= handoff.pct + HARD_OVER)) {
270
+ write([`[${clock()}] handoff: the session went on past its flag, stopped`]);
271
+ stop();
272
+ }
273
+ };
274
+
275
+ return {
276
+ state: w,
277
+ see(a, model) {
278
+ if (a.context !== undefined) {
279
+ w.context = a.context;
280
+ if (w.flagged && a.turn !== w.turn) w.after++;
281
+ w.turn = a.turn;
282
+ }
283
+
284
+ if (handoff?.pct && (a.context !== undefined || a.compacted))
285
+ check(model, Boolean(a.compacted));
286
+ },
287
+ };
288
+ }
289
+
205
290
  /**
206
291
  * One agent session on `prompt` in `dir`; resolves with its exit code, or `timeout` when it ran
207
292
  * past `timeoutMs`: a hung session would hold the item's run lock, and every move on it, for
@@ -212,40 +297,23 @@ const NO_SESSION = /No conversation found with session ID/;
212
297
  * `handoff` ({pct, windowOf(model), plan}) watches the main agent's context: past `pct` of the
213
298
  * window, or at a compaction, the session gets `.gcf-session/handoff.json`, and the handoff
214
299
  * 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.
300
+ * stopped. `handoff: true` says the session was told to hand over, `handedOver` that it did.
216
301
  * @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
217
302
  * session_id?: string, limit?: {resets: string | null}, gone: boolean, handoff: boolean,
218
- * model?: string, window?: number}>}
303
+ * handedOver: boolean, model?: string, window?: number}>}
219
304
  */
220
305
  function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {}, handoff }) {
221
306
  return new Promise((resolve) => {
222
307
  const command = `${agent} "${prompt}"`;
223
308
  // A line before the agent's first: the log's time says the session is alive from its start.
224
309
  writeSync(fd, `[${clock()}] start: ${command}\n`);
225
- const child = spawn(command, {
226
- cwd: dir,
227
- shell: true,
228
- // The session's own gate must not refuse the item this run marked as running.
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 },
231
- detached: !WIN,
232
- windowsHide: true,
233
- stdio: ['ignore', 'pipe', 'pipe'],
234
- });
310
+ const child = spawnAgent(command, dir, { ...env, GCF_RUN_ITEM: id });
235
311
  sessions.add(child);
236
312
  if (child.pid) onSpawn?.(child.pid);
237
313
  let open = true;
238
- let cost;
239
- let sessionId;
240
- let limit;
314
+ /** @type {Record<string, any>} */
315
+ const seen = {};
241
316
  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;
249
317
  const write = (lines) => {
250
318
  try {
251
319
  if (open && lines.length) writeSync(fd, lines.map((l) => `${l}\n`).join(''));
@@ -254,50 +322,15 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {},
254
322
  }
255
323
  };
256
324
 
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
-
325
+ const watch = handoffWatch(handoff, dir, write, () => {
326
+ killTree(child);
327
+ end('handoff');
328
+ });
286
329
  const flushOut = byLine(child.stdout, (line) => {
287
330
  const a = activity(line);
288
- cost = a.cost ?? cost;
289
- sessionId = a.session_id ?? sessionId;
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));
331
+ absorb(seen, a);
294
332
  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));
333
+ if (open) watch.see(a, seen.model);
301
334
  });
302
335
  const flushErr = byLine(child.stderr, (line) => {
303
336
  gone ||= NO_SESSION.test(line);
@@ -314,20 +347,11 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {},
314
347
  open = false;
315
348
  sessions.delete(child);
316
349
  clearTimeout(timer);
350
+ const { flagged, after } = watch.state;
317
351
  // Told to hand over, and it went on past the flag: it read the guard's instruction, or the
318
352
  // run stopped it. A flag on its last turn found a session already done.
319
353
  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
- });
354
+ resolve({ ...seen, code, gone, handoff: flagged, handedOver });
331
355
  }
332
356
 
333
357
  child.on('error', () => end(-1));
@@ -491,8 +515,14 @@ async function claimAll(ids, logs) {
491
515
  const failed = [];
492
516
  for (const id of ids) {
493
517
  const release = await claim(logs, id);
494
- if (release) claimed.push({ id, release });
495
- else failed.push({ id, result: 'error', error: `${id} is already running in another run` });
518
+ if (!release) {
519
+ failed.push({ id, result: 'error', error: `${id} is already running in another run` });
520
+ continue;
521
+ }
522
+
523
+ // A dead run's stop mark goes; one from now on stops the item's session as it starts.
524
+ await rm(stopOf(logs, id), { force: true });
525
+ claimed.push({ id, release });
496
526
  }
497
527
 
498
528
  return { claimed, failed };
@@ -519,7 +549,10 @@ async function prepare({ claimed, failed }, core, agentOf) {
519
549
  return { ready, failed };
520
550
  }
521
551
 
522
- /** `<id>.stop`: `gcf-harness stop` between two sessions of the item: no fresh one starts. */
552
+ /**
553
+ * `<id>.stop`: `gcf-harness stop` while the item has no session, before its first or between two
554
+ * of a handoff: the next one is stopped as it starts.
555
+ */
523
556
  export const stopOf = (logs, id) => path.join(logs, `${id}.stop`);
524
557
 
525
558
  /** `<id>.agent.json`: the agent session an item's last run left unfinished, for a rerun. */
@@ -542,6 +575,19 @@ async function kept(logs, id) {
542
575
  }
543
576
  }
544
577
 
578
+ /**
579
+ * Why a kept session ended. A session that handed over is not resumed: the rerun starts fresh
580
+ * from the plan. So does one after a handoff that reported no id: the full session before it is
581
+ * never resumed.
582
+ */
583
+ function endedOf(r) {
584
+ if (r.handoffPending || !r.session_id) return 'handoff';
585
+ if (r.limit) return 'limit';
586
+ if (r.stopped) return 'stopped';
587
+
588
+ return r.code === 'timeout' ? 'timeout' : `exit ${r.code}`;
589
+ }
590
+
545
591
  /**
546
592
  * The record of a session that ended with its item still ready: a rerun resumes it with all the
547
593
  * agent read and decided. The item's move ends the session for good; an error leaves it as is.
@@ -550,19 +596,14 @@ async function remember(logs, r, item) {
550
596
  if (item.result === 'error') return;
551
597
  if (!STAYED.test(item.result) || r.lost) return rm(recordOf(logs, r.id), { force: true });
552
598
  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}`;
563
- const rec = {
599
+ await writeFile(recordOf(logs, r.id), `${JSON.stringify(recordFor(r), null, 2)}\n`);
600
+ }
601
+
602
+ /** The `<id>.agent.json` of a session that ended with its item still ready. */
603
+ function recordFor(r) {
604
+ return {
564
605
  ...(r.session_id ? { session_id: r.session_id } : {}),
565
- ended,
606
+ ended: endedOf(r),
566
607
  ...(r.limit?.resets ? { resets: r.limit.resets } : {}),
567
608
  // The handoffs so far: the cap holds across runs.
568
609
  ...(r.handoffs ? { handoffs: r.handoffs } : {}),
@@ -571,7 +612,6 @@ async function remember(logs, r, item) {
571
612
  ...(!r.handoffPending && !r.session_id ? { counted: true } : {}),
572
613
  at: new Date().toISOString(),
573
614
  };
574
- await writeFile(recordOf(logs, r.id), `${JSON.stringify(rec, null, 2)}\n`);
575
615
  }
576
616
 
577
617
  /**
@@ -593,10 +633,12 @@ function noResult(r, rerun) {
593
633
 
594
634
  return {
595
635
  id: r.id,
596
- result: `paused (${limit}context handoff): ${r.id} stays ready, a rerun goes on from the plan`,
636
+ result:
637
+ `paused (${limit}context handoff): ${r.id} stays ready, ` + 'a rerun goes on from the plan',
597
638
  ...(r.limit ? { limit_resets: r.limit.resets } : {}),
598
639
  };
599
640
  }
641
+
600
642
  const again = r.session_id ? 'a rerun resumes its session' : rerun;
601
643
  if (r.limit) {
602
644
  const resets = r.limit.resets ? `, resets ${r.limit.resets}` : '';
@@ -608,7 +650,11 @@ function noResult(r, rerun) {
608
650
  };
609
651
  }
610
652
 
611
- const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
653
+ const why = r.stopped
654
+ ? 'stopped'
655
+ : r.code === 'timeout'
656
+ ? 'the session timed out'
657
+ : `agent exit ${r.code}`;
612
658
 
613
659
  return { id: r.id, result: `no result (${why}): ${r.id} stays ready, ${again}` };
614
660
  }
@@ -639,6 +685,110 @@ async function commandOf(r, logs, fresh) {
639
685
  };
640
686
  }
641
687
 
688
+ /**
689
+ * One agent session of the item `s.r`, its handoff flag cleared around it. `stopped` says a
690
+ * `gcf-harness stop` ended it.
691
+ */
692
+ async function agentSession(s, agent, prompt) {
693
+ const { r, fd, deadline, launch } = s;
694
+ const flag = path.join(r.dir, HANDOFF_FLAG);
695
+ await rm(flag, { force: true });
696
+ const flags = launch?.flags(r.dir);
697
+ const handoff = launch?.handoff && { ...launch.handoff, plan: launch.planOf?.(r.id) };
698
+ const left = Math.max(deadline - Date.now(), 1000);
699
+ const opts = { fd, timeoutMs: left, id: r.id, onSpawn: s.onSpawn, env: launch?.env, handoff };
700
+ const ran = await startAgent(flags ? `${agent} ${flags}` : agent, prompt, r.dir, opts);
701
+ await rm(flag, { force: true });
702
+ // Between two sessions the lock names none: a stop then marks the item, not the run.
703
+ writeFileSync(s.lock, `${process.pid}\n${r.id}\n`);
704
+ if (launch?.learned) await learnWindow(s.logs, launch.learned, ran);
705
+
706
+ return { ...ran, stopped: existsSync(stopOf(s.logs, r.id)) };
707
+ }
708
+
709
+ /**
710
+ * Count the item's next handoff, or say the cap is reached: the item is then blocked. The cap
711
+ * counts each handoff once, the one a kept record still owes a session included.
712
+ */
713
+ async function capped(count, r) {
714
+ if (count.handoffs < MAX_HANDOFFS) {
715
+ count.handoffs++;
716
+
717
+ return false;
718
+ }
719
+
720
+ count.limit = true;
721
+ // A worktree records no move: the main checkout reads the block from its outcome.
722
+ if (!r.main) {
723
+ await mkdir(path.join(r.dir, '.gcf-session'), { recursive: true });
724
+ const outcome = `${JSON.stringify({ block: HANDOFF_LIMIT })}\n`;
725
+ await writeFile(path.join(r.dir, '.gcf-session', 'outcome.json'), outcome);
726
+ }
727
+
728
+ return true;
729
+ }
730
+
731
+ /**
732
+ * Only a session that handed over as told goes on: one that saw its flag and ended its reply,
733
+ * or that the run stopped past it. A stop, a limit or the deadline leaves the handoff to the
734
+ * next run.
735
+ */
736
+ async function goesOn(s, ran) {
737
+ const over = ran.handedOver && (ran.code === 0 || ran.code === 'handoff') && !ran.limit;
738
+
739
+ return over && Date.now() < s.deadline && !ran.stopped && !(await s.r.done?.());
740
+ }
741
+
742
+ /**
743
+ * Whether the session `rec` kept is gone: a resume that claude answered with `No conversation
744
+ * found`, so the next run starts fresh rather than fail on it again. Any other early end (a stop,
745
+ * a crash, no network) keeps it for the next try, and the resumed session's id stands in for one
746
+ * its own run did not report. A fresh session after a handoff has none to borrow: the full one is
747
+ * never resumed.
748
+ */
749
+ function resumeLost(rec, ran) {
750
+ if (!rec || rec.ended === 'handoff') return false;
751
+ if (ran.gone) return true;
752
+ ran.session_id ??= rec.session_id;
753
+
754
+ return false;
755
+ }
756
+
757
+ /**
758
+ * The sessions of one item: the first runs its command; while one hands over as told, a fresh
759
+ * one goes on from the plan, up to MAX_HANDOFFS.
760
+ */
761
+ async function handOver(s, { agent, prompt, rec }) {
762
+ const { r } = s;
763
+ const count = { handoffs: Number(rec?.handoffs) || 0, limit: false };
764
+ const fromHandoff = rec?.ended === 'handoff';
765
+ // A record whose handoff its own run already counted (`counted`) owes none.
766
+ if (fromHandoff && !rec.counted && (await capped(count, r)))
767
+ return { code: 0, handoffs: count.handoffs, handoffLimit: true, handoffPending: false };
768
+ let ran = await agentSession(s, agent, prompt);
769
+ let cost = ran.cost;
770
+ const lost = resumeLost(rec, ran);
771
+ while ((await goesOn(s, ran)) && !(await capped(count, r))) {
772
+ writeSync(
773
+ s.fd,
774
+ `[${clock()}] handoff ${count.handoffs}: a fresh session goes on from the plan\n`,
775
+ );
776
+ ran = await agentSession(s, r.agent, `${taskOf(r)} --handoff`);
777
+ cost = addCost(cost, ran.cost);
778
+ }
779
+
780
+ return {
781
+ ...ran,
782
+ cost,
783
+ handoffs: count.handoffs,
784
+ handoffLimit: count.limit,
785
+ // The session was told to hand over and no fresh one took it: the next run does.
786
+ // A limit there too: the rerun after the reset starts fresh, it does not resume the full one.
787
+ handoffPending: ran.handoff && !count.limit,
788
+ lost,
789
+ };
790
+ }
791
+
642
792
  /**
643
793
  * One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
644
794
  * its worktree mode; on main it records the item's move itself.
@@ -653,97 +803,19 @@ async function session(r, { logs, timeoutMs, fresh, launch }) {
653
803
  // A stop that came while the lock named no session left only its mark: it ends this one.
654
804
  if (existsSync(stopOf(logs, r.id))) killTree({ pid });
655
805
  };
806
+
656
807
  // On main the item carries a session mark; a stage with no gcf command must not expire it.
657
808
  const mark = path.join(logs, `${r.id}.session`);
658
809
  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 });
661
810
  try {
662
- let { agent, prompt, rec } = await commandOf(r, logs, fresh);
811
+ const cmd = await commandOf(r, logs, fresh);
663
812
  log = await open(path.join(logs, `${r.id}.log`), 'a');
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
813
  // One deadline for the item, however many sessions it takes.
668
814
  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
- }
692
-
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
- }
736
815
 
737
816
  return {
738
817
  ...r,
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,
746
- lost,
818
+ ...(await handOver({ r, logs, lock, fd: log.fd, deadline, launch, onSpawn }, cmd)),
747
819
  };
748
820
  } catch (e) {
749
821
  return { ...r, code: null, error: e.message };
@@ -855,7 +927,7 @@ async function launchOf(logs, managed, board) {
855
927
  windowOf: (model) => windowFor(model, ctx.window, learned),
856
928
  },
857
929
  learned,
858
- planOf: () => null,
930
+ planOf: (id) => board.items?.find((i) => i.id === id)?.plan_file ?? null,
859
931
  };
860
932
  }
861
933
 
@@ -912,10 +984,14 @@ async function runMain(projectDir, ids, ctx) {
912
984
  async function mainItem(projectDir, id, ctx) {
913
985
  const { core, logs, agentOf } = ctx;
914
986
  let r;
987
+ await rm(stopOf(logs, id), { force: true });
988
+ // The checkout's lock names the item from now on: a stop before its session marks it.
989
+ writeFileSync(path.join(logs, `${MAIN_RUN}.lock`), `${process.pid}\n${id}\n`);
915
990
  try {
916
991
  const claimed = await core(['claim', id]);
917
992
  if (!claimed.ok) throw new Error((claimed.err || claimed.out).trim());
918
993
  const none = async () => {};
994
+
919
995
  // The agent recorded the item's move: no fresh session takes it over.
920
996
  // A board that cannot be read is no move: the run goes on, and the result read says why.
921
997
  const done = async () => {
@@ -1023,7 +1099,6 @@ export async function run(
1023
1099
  const itemOf = (id) => (board.items ?? []).find((i) => i.id === id);
1024
1100
  const agentOf = (id) => agentFor(agent, itemOf(id));
1025
1101
  const ctx = { core, logs, agentOf, timeoutMs, fresh, launch };
1026
- launch.planOf = (id) => itemOf(id)?.plan_file ?? null;
1027
1102
  const unhook = stopWithSessions();
1028
1103
  try {
1029
1104
  const items =
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-harness-aif-ext",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
6
  "skills": [
7
7
  "skills/gcf-run",
@@ -57,6 +57,7 @@ export function readFlag(projectDir) {
57
57
  } catch {
58
58
  return null;
59
59
  }
60
+
60
61
  try {
61
62
  return JSON.parse(text) ?? {};
62
63
  } catch {
@@ -67,12 +68,14 @@ export function readFlag(projectDir) {
67
68
  // Hook entry: only runs when executed directly, so tests can import decide().
68
69
  const entry = process.argv[1]?.replaceAll('\\', '/');
69
70
  if (import.meta.url === `file://${entry}` || entry?.endsWith('handoff-guard.js')) {
71
+ /** @type {Record<string, any> | null} */
70
72
  let input = null;
71
73
  try {
72
74
  input = JSON.parse(readFileSync(0, 'utf8'));
73
75
  } catch {
74
76
  // No input: nothing to judge.
75
77
  }
78
+
76
79
  // The directory the session started in: `cwd` follows a `cd` of the agent.
77
80
  const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input?.cwd ?? process.cwd();
78
81
  const verdict = decide(input, projectDir, readFlag(projectDir));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",