gcf-harness-aif-ext 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ The harness turns policy-as-text into an executable pipeline. It has **no memory
11
11
 
12
12
  ## Requirements
13
13
 
14
- - [`gcf-aif-ext`](../gcf-aif-ext) `^0.5.3` installed, with a `gcf/` tree in the project — declared as a peer dependency, and `gcf-harness init` refuses when the `gcf` command is not registered
14
+ - [`gcf-aif-ext`](../gcf-aif-ext) `^0.7.0` installed, with a `gcf/` tree in the project — declared as a peer dependency, and `gcf-harness init` refuses when the `gcf` command is not registered
15
15
  - Claude Code 2.1.270+ (hooks, dynamic workflows) — the only supported runtime
16
16
  - AI Factory 2.x, Node.js ≥ 22.14
17
17
 
@@ -47,6 +47,16 @@ 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.8.0, to update together with core 0.7.0:
51
+ - `run_mode: main` in `gcf/config.yml` (or `run --mode main`): items run one after another in the main checkout, for an agent that works through an editor bound to the project folder. The item carries the core's session mark (`gcf claim`), not a run lock, so the agent records its own move; one run works the checkout at a time (`.gcf-session/runs/.main-run.lock`).
52
+ - `gcf-harness next [N] --json`: the ready items that can run together now, by priority; each is taken while the batch with it passes the gate.
53
+ - `gcf-harness stop <id>`: stops the session that works the item (its run lock names it); the run records no result for it and goes on with the rest. The item stays ready and its work is kept. Known limit: the session is killed at once, so a `gcf` move it was in the middle of may leave `.gcf-session/main.lock` behind (taken over once its pid is gone).
54
+ - A run on main refreshes its item's mark every 5 minutes, and the readset-guard hook refreshes live marks on each read: a long build without a gcf command does not expire it.
55
+ - `run` refuses an id that is not a slug before it names a lock file or builds the agent's command line.
56
+ - The gate lets a session through for the item its own run marked (`GCF_RUN_ITEM`); `/gcf-run` outside a worktree claims each item after its gate and releases one it leaves without a move.
57
+
58
+ 0.7.0, to update together with core 0.6.0: an item's `model:` and `effort:` (`gcf item new --model <m> --effort <e>`) go to its agent as `--model <m> --effort <e>`, read from `gcf board --json`; a value that is not one plain word fails that item and starts no session. `--timeout <minutes>` (120 by default) stops a session that runs longer, with every process it started; its item stays ready, and its run lock is freed. The gate counts the items other runs drive now: their places under the WIP limit, and their systems — a board starts one run per click. Requires `gcf-aif-ext` 0.6.
59
+
50
60
  0.6.1 is a fix release, to update together with core 0.5.3. The run lock `.gcf-session/runs/<id>.lock` is created exclusively, and a lock left by a dead run is taken over; while it is live, the gate refuses the item to another run and the core refuses any move on it, `/gcf-run` on main included. A session whose log cannot open ends as an error and releases its claim.
51
61
 
52
62
  0.6.0 adds `ai-factory gcf-harness run` (a batch at once, a worktree and an agent session per item, then the integrator) and the `--worktree` mode of `/gcf-run`, whose session ritual now integrates the verified branches a dead run left. It requires `gcf-aif-ext` 0.5.0 (`gcf worktree`, `gcf integrate`, `gcf verify`) — update both together and run `ai-factory gcf init`. The DoD of `/gcf-run` is `ai-factory gcf verify <id>`, and the item's `live:` delta is written before it.
@@ -3,11 +3,11 @@
3
3
  // same decision as gcf hooks-install for git hooks). Idempotent by marker comment.
4
4
  // `ai-factory gcf-harness gate <ids…>` — the /gcf-run gate as code (Harness-PRD §4.3).
5
5
  import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
6
- import { existsSync } from 'node:fs';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { run, cliCore, DEFAULT_AGENT } from './run.js';
10
+ import { run, cliCore, DEFAULT_AGENT, DEFAULT_TIMEOUT_MIN, MAIN_RUN } from './run.js';
11
11
 
12
12
  const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
13
13
  const MARKER = 'gcf-readset-guard';
@@ -27,7 +27,9 @@ function itemProblem(id, item, report) {
27
27
  return `${id} is ${item.status} — only ready items are driven`;
28
28
  }
29
29
 
30
- if (item.running) return `${id} is driven by another harness run — wait until it ends`;
30
+ // The session a run started for this very item gates it too: its own run is not "another".
31
+ if (item.running && process.env.GCF_RUN_ITEM !== id)
32
+ return `${id} is driven by another harness run — wait until it ends`;
31
33
 
32
34
  // A child of a feature paused after a death does not start: the decision may kill it.
33
35
  const parent = item.parent ? report.items.find((p) => p.id === item.parent) : null;
@@ -49,20 +51,26 @@ function itemProblem(id, item, report) {
49
51
  return null;
50
52
  }
51
53
 
52
- /** Why the batch as a whole may not go, or null. */
53
- function batchProblem(items, wip) {
54
+ /**
55
+ * Why the batch as a whole may not go, or null. `elsewhere` are the items other runs drive now:
56
+ * a board starts one run per click, so each run sees the others only through their locks.
57
+ */
58
+ function batchProblem(items, wip, elsewhere = []) {
54
59
  // Only an item that ends waiting for a verdict takes a WIP place; `wip.over` is false at
55
- // count === limit, and this run adds every such item of the batch.
60
+ // count === limit, and this run adds every such item of the batch — and of the other runs.
56
61
  const waiting = items.filter((i) => i.gate === 'verdict').length;
57
- if (waiting && wip.count + waiting > wip.limit) {
62
+ const coming = elsewhere.filter((i) => i.gate === 'verdict').length;
63
+ if (waiting && wip.count + coming + waiting > wip.limit) {
64
+ const others = coming ? ` + ${coming} in other runs` : '';
65
+
58
66
  return (
59
- `WIP: ${wip.count} awaiting a verdict + ${waiting} in this run exceeds the limit of ` +
60
- `${wip.limit} — playtest first`
67
+ `WIP: ${wip.count} awaiting a verdict${others} + ${waiting} in this run exceeds the ` +
68
+ `limit of ${wip.limit} — playtest first`
61
69
  );
62
70
  }
63
71
 
64
72
  for (let i = 0; i < items.length; i++) {
65
- for (const other of items.slice(i + 1)) {
73
+ for (const other of [...items.slice(i + 1), ...elsewhere]) {
66
74
  const shared = items[i].system.filter((s) => other.system.includes(s));
67
75
  // `parallel: ok` on both is the operator's explicit permission (Methodology §7).
68
76
  const allowed = items[i].parallel === 'ok' && other.parallel === 'ok';
@@ -79,6 +87,26 @@ function batchProblem(items, wip) {
79
87
 
80
88
  const verdict = (reason) => ({ pass: !reason, reason: reason ?? '' });
81
89
 
90
+ const rank = (i) => i.priority ?? Number.MAX_SAFE_INTEGER;
91
+
92
+ /**
93
+ * The ready items that can run together now, up to `count`, by priority: each is taken while
94
+ * the batch with it still passes the gate — a dependency not played, a shared subsystem, a full
95
+ * WIP leave it out. The gate decides; a board only shows and runs the answer.
96
+ */
97
+ export function nextBatch(report, count) {
98
+ const batch = [];
99
+ const ready = (report.items ?? [])
100
+ .filter((i) => i.status === 'ready' && !i.running && i.type !== 'feature')
101
+ .sort((a, b) => rank(a) - rank(b));
102
+ for (const i of ready) {
103
+ if (batch.length >= count) break;
104
+ if (gate(report, [...batch, i.id]).pass) batch.push(i.id);
105
+ }
106
+
107
+ return batch;
108
+ }
109
+
82
110
  /**
83
111
  * Decides whether the work items may enter the paid stages, from one `gcf audit --json` report.
84
112
  * Pure: the report is the whole input, so every rule has a test and no model compares numbers.
@@ -104,6 +132,7 @@ export function gate(report, ids) {
104
132
  batchProblem(
105
133
  ids.map((id) => byId.get(id)),
106
134
  report.wip,
135
+ report.items.filter((c) => c.running && !ids.includes(c.id)),
107
136
  ),
108
137
  );
109
138
  }
@@ -249,8 +278,83 @@ export function register(program) {
249
278
  'the agent command; it gets "/gcf-run <id> --worktree"',
250
279
  process.env.GCF_RUN_AGENT ?? DEFAULT_AGENT,
251
280
  )
281
+ .option(
282
+ '--timeout <minutes>',
283
+ 'stop a session that runs longer; its item stays ready',
284
+ String(DEFAULT_TIMEOUT_MIN),
285
+ )
286
+ .option('--mode <mode>', 'worktree | main (default: run_mode of gcf/config.yml)')
252
287
  .option('--json', 'machine output')
253
288
  .action(runItems);
289
+
290
+ harness
291
+ .command('next [count]')
292
+ .description('The ready items that can run together now, by priority (the gate decides)')
293
+ .option('--json', 'machine output: a list of ids')
294
+ .action((count, o) => {
295
+ const ids = nextBatch(auditReport(), Math.max(1, Number(count ?? 1) || 1));
296
+ console.log(o.json ? JSON.stringify(ids) : ids.join('\n') || 'nothing can run now');
297
+ });
298
+ harness
299
+ .command('stop <id>')
300
+ .description('Stop the run that drives an item, with every session it started; it stays ready')
301
+ .action((id) => console.log(stopRun(process.cwd(), id)));
302
+ }
303
+
304
+ const WIN = process.platform === 'win32';
305
+
306
+ const alive = (pid) => {
307
+ try {
308
+ process.kill(pid, 0);
309
+
310
+ return true;
311
+ } catch {
312
+ return false;
313
+ }
314
+ };
315
+
316
+ /**
317
+ * A run lock: `<run pid>\n<item>\n<session pid>\n` once the session started, the run pid alone
318
+ * before. Null when its run is gone.
319
+ */
320
+ function lockOf(file) {
321
+ try {
322
+ const [run, item, session] = readFileSync(file, 'utf8').split('\n');
323
+ if (!alive(Number(run))) return null;
324
+
325
+ return { run: Number(run), item: item || null, session: Number(session) || null };
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ /** Kill a process and everything under it: a tree on Windows, its own group elsewhere. */
332
+ function killTree(pid, group) {
333
+ if (WIN) spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
334
+ else {
335
+ try {
336
+ process.kill(group ? -pid : pid, group ? 'SIGKILL' : 'SIGTERM');
337
+ } catch {
338
+ // Gone already.
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Stop the session that works `id`: the one its own lock names (worktree mode), or the main
345
+ * checkout's run when that run works this very item (main mode). Only that session goes: the
346
+ * run records it as no result and goes on with the rest. A run that has not started a session
347
+ * yet goes down whole. The item stays ready and its work in progress is kept.
348
+ */
349
+ export function stopRun(projectDir, id, core = cliCore(projectDir)) {
350
+ const runs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
351
+ const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
352
+ const lock = lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
353
+ if (lock && lock.session && alive(lock.session)) killTree(lock.session, true);
354
+ else if (lock) killTree(lock.run, false);
355
+ core(['release', id]);
356
+
357
+ return lock ? `stopped the run of ${id}; it stays ready` : `no run drives ${id}`;
254
358
  }
255
359
 
256
360
  /** The run report a human reads, then the playtest sheet of what now waits for play. */
@@ -273,9 +377,15 @@ const failed = (r) =>
273
377
 
274
378
  async function runItems(ids, opts) {
275
379
  try {
380
+ const minutes = Number(opts.timeout);
381
+ if (!(minutes > 0)) throw new Error('--timeout is a number of minutes above 0');
382
+ if (opts.mode && !['worktree', 'main'].includes(opts.mode))
383
+ throw new Error('--mode is worktree or main');
276
384
  const r = await run(process.cwd(), [...new Set(ids)], {
277
385
  agent: opts.agent,
278
386
  gate: (batch) => gate(auditReport(), batch),
387
+ timeoutMs: minutes * 60_000,
388
+ mode: opts.mode,
279
389
  });
280
390
  if (opts.json) console.log(JSON.stringify(r, null, 2));
281
391
  else printRun(r);
package/commands/run.js CHANGED
@@ -3,7 +3,7 @@
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 } from 'node:fs';
6
+ import { existsSync, utimesSync, writeFileSync } from 'node:fs';
7
7
  import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
 
@@ -31,16 +31,93 @@ function json(res, what) {
31
31
  }
32
32
  }
33
33
 
34
- /** One agent session in the item's worktree; resolves with its exit code. */
35
- function startAgent(agent, id, dir, fd) {
34
+ // The core's rule for `item new`: a leading hyphen would read as a flag.
35
+ const RUN_WORD = /^[A-Za-z0-9][\w.-]*$/;
36
+
37
+ /**
38
+ * The agent command of one item: its `model:` and `effort:` become `--model` and `--effort`,
39
+ * the flags of `claude`. The command goes through a shell, and an item file can be edited by
40
+ * hand: a value that is not one plain word is refused.
41
+ */
42
+ export function agentFor(agent, item) {
43
+ const flags = [];
44
+ for (const k of ['model', 'effort']) {
45
+ const v = item?.[k];
46
+ if (v === null || v === undefined) continue;
47
+ if (!RUN_WORD.test(String(v))) throw new Error(`${k}: ${v} is not one plain word`);
48
+ flags.push(`--${k} ${v}`);
49
+ }
50
+
51
+ return [agent, ...flags].join(' ');
52
+ }
53
+
54
+ const WIN = process.platform === 'win32';
55
+
56
+ /**
57
+ * Stop a session and everything it started: the shell, the agent, its tools. Windows has no
58
+ * process groups to signal, `taskkill /T` walks the tree; elsewhere the session leads its own
59
+ * group (`detached`), and the group gets the signal.
60
+ */
61
+ function killTree(child) {
62
+ if (WIN) spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
63
+ else {
64
+ try {
65
+ process.kill(-child.pid, 'SIGKILL');
66
+ } catch {
67
+ // Gone already.
68
+ }
69
+ }
70
+ }
71
+
72
+ // The sessions this run has going: a stop (`gcf-harness stop`) takes them down with the run.
73
+ const sessions = new Set();
74
+
75
+ /**
76
+ * `gcf-harness stop` signals the run (SIGTERM); its sessions lead their own process groups, so
77
+ * the run takes each down before it exits. On Windows the stop kills the whole tree itself.
78
+ * Returns the unhook.
79
+ */
80
+ function stopWithSessions() {
81
+ const stop = () => {
82
+ for (const c of sessions) killTree(c);
83
+ process.exit(143);
84
+ };
85
+
86
+ process.once('SIGTERM', stop);
87
+
88
+ return () => process.removeListener('SIGTERM', stop);
89
+ }
90
+
91
+ /**
92
+ * One agent session on `prompt` in `dir`; resolves with its exit code, or `timeout` when it ran
93
+ * past `timeoutMs`: a hung session would hold the item's run lock, and every move on it, for
94
+ * good.
95
+ */
96
+ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
36
97
  return new Promise((resolve) => {
37
- const child = spawn(`${agent} "/gcf-run ${id} --worktree"`, {
98
+ const child = spawn(`${agent} "${prompt}"`, {
38
99
  cwd: dir,
39
100
  shell: true,
101
+ // The session's own gate must not refuse the item this run marked as running.
102
+ env: { ...process.env, GCF_RUN_ITEM: id },
103
+ detached: !WIN,
104
+ windowsHide: true,
40
105
  stdio: ['ignore', fd, fd],
41
106
  });
42
- child.on('error', () => resolve(-1));
43
- child.on('exit', (code) => resolve(code ?? -1));
107
+ sessions.add(child);
108
+ if (child.pid) onSpawn?.(child.pid);
109
+ const timer = setTimeout(() => {
110
+ killTree(child);
111
+ resolve('timeout');
112
+ }, timeoutMs);
113
+ const end = (code) => {
114
+ sessions.delete(child);
115
+ clearTimeout(timer);
116
+ resolve(code);
117
+ };
118
+
119
+ child.on('error', () => end(-1));
120
+ child.on('exit', (code) => end(code ?? -1));
44
121
  });
45
122
  }
46
123
 
@@ -136,8 +213,11 @@ function verified(dir, id) {
136
213
  return (res.stdout ?? '').split('\n').some((v) => v.trim() === id);
137
214
  }
138
215
 
139
- /** The worktree of each item, claimed for this run; an item that cannot start is an error. */
140
- async function prepare(ids, core, logs) {
216
+ /**
217
+ * The worktree and the agent command of each item, claimed for this run; an item that cannot
218
+ * start is an error. `agentOf(id)` gives the command.
219
+ */
220
+ async function prepare(ids, core, logs, agentOf) {
141
221
  const ready = [];
142
222
  const failed = [];
143
223
  for (const id of ids) {
@@ -148,9 +228,10 @@ async function prepare(ids, core, logs) {
148
228
  }
149
229
 
150
230
  try {
231
+ const agent = agentOf(id);
151
232
  const wt = json(await core(['worktree', id, '--json']), `worktree ${id}`);
152
233
  if (wt.sync) throw new Error(wt.sync);
153
- ready.push({ id, dir: wt.path, release });
234
+ ready.push({ id, dir: wt.path, agent, release });
154
235
  } catch (e) {
155
236
  await release();
156
237
  failed.push({ id, result: 'error', error: e.message });
@@ -160,21 +241,44 @@ async function prepare(ids, core, logs) {
160
241
  return { ready, failed };
161
242
  }
162
243
 
163
- /** One session per item, its output in `.gcf-session/runs/<id>.log`. */
164
- async function session(r, agent, logs) {
244
+ /**
245
+ * One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
246
+ * its worktree mode; on main it records the item's move itself.
247
+ */
248
+ async function session(r, logs, timeoutMs) {
249
+ /** @type {import('node:fs/promises').FileHandle | null} */
165
250
  let log = null;
251
+ const prompt = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
252
+ // The lock names the item and its session: `stop <id>` ends that session alone, not the run.
253
+ const lock = path.join(logs, `${r.main ? MAIN_RUN : r.id}.lock`);
254
+ const onSpawn = (pid) => writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
255
+ // On main the item carries a session mark; a stage with no gcf command must not expire it.
256
+ const mark = path.join(logs, `${r.id}.session`);
257
+ const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
166
258
  try {
167
259
  log = await open(path.join(logs, `${r.id}.log`), 'a');
260
+ const opts = { fd: log.fd, timeoutMs, id: r.id, onSpawn };
168
261
 
169
- return { ...r, code: await startAgent(agent, r.id, r.dir, log.fd) };
262
+ return { ...r, code: await startAgent(r.agent, prompt, r.dir, opts) };
170
263
  } catch (e) {
171
264
  return { ...r, code: null, error: e.message };
172
265
  } finally {
266
+ if (keep) clearInterval(keep);
173
267
  await log?.close();
174
268
  await r.release();
175
269
  }
176
270
  }
177
271
 
272
+ /** Refresh a file's time, when it is still there. */
273
+ function touch(file) {
274
+ try {
275
+ const now = new Date();
276
+ utimesSync(file, now, now);
277
+ } catch {
278
+ // Released meanwhile.
279
+ }
280
+ }
281
+
178
282
  /**
179
283
  * A run that asked to block is recorded on main. Asked after its branch was verified, the
180
284
  * block wins: an empty commit takes the trailer off the tip, so no integrate merges it.
@@ -197,13 +301,14 @@ async function recordBlock(r, outcome, core) {
197
301
  function resultOf(run, integrated) {
198
302
  const done = integrated.find((x) => x.id === run.id);
199
303
  if (!done && run.error) return { id: run.id, result: 'error', error: run.error };
200
- if (!done)
304
+ if (!done) {
305
+ const why = run.code === 'timeout' ? 'the session timed out' : `agent exit ${run.code}`;
306
+
201
307
  return {
202
308
  id: run.id,
203
- result:
204
- `no result (agent exit ${run.code}): ${run.id} stays ready, a rerun resumes its ` +
205
- 'worktree',
309
+ result: `no result (${why}): ${run.id} stays ready, a rerun resumes its worktree`,
206
310
  };
311
+ }
207
312
 
208
313
  return {
209
314
  ...done,
@@ -211,31 +316,133 @@ function resultOf(run, integrated) {
211
316
  };
212
317
  }
213
318
 
319
+ export const DEFAULT_TIMEOUT_MIN = 120;
320
+
321
+ // The move an item's status shows after a run on main: the agent recorded it itself.
322
+ const MOVE_OF = {
323
+ 'awaiting-verdict': 'submit',
324
+ 'awaiting-decision': 'submit',
325
+ unplayed: 'close',
326
+ blocked: 'block',
327
+ };
328
+
329
+ /** What a run on main left: the item's move read from its status, or why there is none. */
330
+ async function mainResult(core, r) {
331
+ const after = json(await core(['board', '--json']), 'board').items?.find((i) => i.id === r.id);
332
+ if (r.error) return { id: r.id, result: 'error', error: r.error };
333
+ // An item that ended left the board: its run closed it.
334
+ if (!after) return { id: r.id, result: 'close' };
335
+ if (after.status !== 'ready')
336
+ return { id: r.id, result: MOVE_OF[after.status] ?? after.status, reason: after.blocked };
337
+ const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
338
+
339
+ return { id: r.id, result: `no result (${why}): ${r.id} stays ready, a rerun picks it up` };
340
+ }
341
+
342
+ // The lock of the main checkout itself: one run works there at a time. Not a slug, so no item.
343
+ export const MAIN_RUN = '.main-run';
344
+
214
345
  /**
215
- * Pick up orphans, gate the batch, run every item in its worktree at once, record each block
216
- * on main, integrate the verified branches. `gate(ids)` answers `{pass, reason}`.
346
+ * `run_mode: main` (Methodology §7): one item after another in the main checkout, where the
347
+ * editor the agent works through lives. The agent records each item's move itself, so the item
348
+ * carries a session mark (`gcf claim`), not a run lock: a lock would refuse the agent's own
349
+ * moves. A session that stopped without a move leaves no mark behind.
350
+ */
351
+ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
352
+ const releaseMain = await claim(logs, MAIN_RUN);
353
+ if (!releaseMain)
354
+ return ids.map((id) => ({
355
+ id,
356
+ result: 'error',
357
+ error: 'another run works in the main checkout: wait for it, or stop it',
358
+ }));
359
+
360
+ const items = [];
361
+ try {
362
+ for (const id of ids)
363
+ items.push(await mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }));
364
+ } finally {
365
+ await releaseMain();
366
+ }
367
+
368
+ return items;
369
+ }
370
+
371
+ /** One item of a main run: marked, its session, its mark cleared, its result read. */
372
+ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
373
+ let r;
374
+ try {
375
+ const claimed = await core(['claim', id]);
376
+ if (!claimed.ok) throw new Error((claimed.err || claimed.out).trim());
377
+ const none = async () => {};
378
+
379
+ const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true };
380
+ r = await session(s, logs, timeoutMs);
381
+ } catch (e) {
382
+ r = { id, error: e.message };
383
+ }
384
+
385
+ await core(['release', id]);
386
+
387
+ return mainResult(core, r);
388
+ }
389
+
390
+ /** Every item in its own worktree at once; blocks recorded on main, verified branches merged. */
391
+ async function runWorktrees(ids, { core, logs, agentOf, timeoutMs }) {
392
+ const { ready, failed } = await prepare(ids, core, logs, agentOf);
393
+ const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
394
+ const items = [...failed];
395
+ for (const r of runs) {
396
+ const outcome = await outcomeOf(r.dir);
397
+ if (outcome?.block) items.push(await recordBlock(r, outcome, core));
398
+ }
399
+
400
+ const integrated = json(await core(['integrate', '--json']), 'integrate');
401
+ for (const r of runs) if (!items.some((i) => i.id === r.id)) items.push(resultOf(r, integrated));
402
+
403
+ return items;
404
+ }
405
+
406
+ /**
407
+ * Pick up orphans, gate the batch, run the items in the project's run mode (`mode` overrides
408
+ * it). `gate(ids)` answers `{pass, reason}`; a session past `timeoutMs` is stopped with all it
409
+ * started, and its item stays ready.
217
410
  */
218
411
  export async function run(
219
412
  projectDir,
220
413
  ids,
221
- { core = cliCore(projectDir), agent = DEFAULT_AGENT, gate },
414
+ {
415
+ core = cliCore(projectDir),
416
+ agent = DEFAULT_AGENT,
417
+ gate,
418
+ timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
419
+ mode = undefined,
420
+ },
222
421
  ) {
422
+ // An id names a lock file and goes into the agent's shell command: a slug, nothing else.
423
+ const bad = ids.find((id) => !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id));
424
+ if (bad) throw new Error(`${bad} is not an item id (a lowercase hyphen slug)`);
223
425
  const orphans = json(await core(['integrate', '--json']), 'integrate');
224
426
  const passed = gate(ids);
225
427
  if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
226
428
 
227
429
  const logs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
228
430
  await mkdir(logs, { recursive: true });
229
- const { ready, failed } = await prepare(ids, core, logs);
230
- const runs = await Promise.all(ready.map((r) => session(r, agent, logs)));
231
- const items = [...failed];
232
- for (const r of runs) {
233
- const outcome = await outcomeOf(r.dir);
234
- if (outcome?.block) items.push(await recordBlock(r, outcome, core));
235
- }
236
-
237
- const integrated = json(await core(['integrate', '--json']), 'integrate');
238
- for (const r of runs) if (!items.some((i) => i.id === r.id)) items.push(resultOf(r, integrated));
431
+ const board = json(await core(['board', '--json']), 'board');
432
+ const runMode = mode ?? board.run_mode ?? 'worktree';
433
+ const agentOf = (id) =>
434
+ agentFor(
435
+ agent,
436
+ (board.items ?? []).find((i) => i.id === id),
437
+ );
438
+ const ctx = { core, logs, agentOf, timeoutMs };
439
+ const unhook = stopWithSessions();
440
+ try {
441
+ const items =
442
+ runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(ids, ctx);
239
443
 
240
- return { orphans, items };
444
+ return { orphans, mode: runMode, items };
445
+ } finally {
446
+ unhook();
447
+ }
241
448
  }
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.6.1",
4
+ "version": "0.8.0",
5
5
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
6
  "skills": [
7
7
  "skills/gcf-run",
@@ -6,7 +6,7 @@
6
6
  // are never touched, and neither is a search that cannot reach gcf/ — the project's own
7
7
  // code stays freely greppable. Honest limit: Bash `cat` bypasses this; it is a discipline
8
8
  // tool for a solo developer, not a security perimeter.
9
- import { readFileSync } from 'node:fs';
9
+ import { readFileSync, readdirSync, statSync, utimesSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
 
12
12
  function readStdin() {
@@ -115,11 +115,34 @@ function readManifest(projectDir) {
115
115
  }
116
116
  }
117
117
 
118
+ // The core's SESSION_TTL_MS (gcf-aif-ext lib/session-mark.js): a mark older than this is dead.
119
+ const SESSION_TTL_MS = 120 * 60_000;
120
+
121
+ /**
122
+ * Keep the running marks of this checkout fresh (`gcf claim`): an agent reading files is a
123
+ * session at work, even through a long stage with no gcf command. A mark already expired stays
124
+ * expired: a session that stopped is not revived. Best effort, never in the way of the read.
125
+ * shortcut: it refreshes every live mark of the checkout, not only this session's; one agent
126
+ * works a main checkout at a time, so that is the same mark — key marks by session when two do.
127
+ */
128
+ export function keepSessionsAlive(projectDir, now = Date.now()) {
129
+ const runs = path.join(projectDir, '.gcf-session', 'runs');
130
+ try {
131
+ for (const name of readdirSync(runs).filter((n) => n.endsWith('.session'))) {
132
+ const file = path.join(runs, name);
133
+ if (now - statSync(file).mtimeMs < SESSION_TTL_MS) utimesSync(file, now / 1000, now / 1000);
134
+ }
135
+ } catch {
136
+ // No runs directory, or a mark released meanwhile.
137
+ }
138
+ }
139
+
118
140
  // Hook entry: only runs when executed directly, so tests can import decide().
119
141
  const entry = process.argv[1]?.replaceAll('\\', '/');
120
142
  if (import.meta.url === `file://${entry}` || entry?.endsWith('readset-guard.js')) {
121
143
  const input = readStdin();
122
144
  const projectDir = input?.cwd ?? process.cwd();
145
+ keepSessionsAlive(projectDir);
123
146
  const verdict = decide(input, projectDir, readManifest(projectDir));
124
147
  if (!verdict.allow) {
125
148
  console.error(`gcf readset-guard: ${verdict.reason}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "prepack": "node -e \"const p=require('./package.json'),e=require('./extension.json');if(p.version!==e.version)throw new Error('version mismatch: package.json '+p.version+' vs extension.json '+e.version)\""
29
29
  },
30
30
  "peerDependencies": {
31
- "gcf-aif-ext": "^0.5.3"
31
+ "gcf-aif-ext": "^0.7.0"
32
32
  },
33
33
  "files": [
34
34
  "extension.json",
@@ -8,6 +8,8 @@ mode: pipeline
8
8
  purpose:
9
9
  - drive a work item through gate, readset, plan, implement and its DoD with the stop rules checked before each paid stage
10
10
  - end every item in a state the core records: `submit` (waits for the human), `close` (gate auto, done) or `block` (needs the human)
11
+ - outside `--worktree`, mark each item running after its gate passes: `ai-factory gcf claim <id>`; a move clears the mark, and an item left without one gets `ai-factory gcf release <id>`
12
+ - a claimed item takes only its session's moves: run `submit`, `close` and `block` on it with `--claimed`
11
13
 
12
14
  inputs:
13
15
  - 1 or more work item or design unit ids; a batch shares one build and one playtest list