gcf-harness-aif-ext 0.7.0 → 0.8.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
@@ -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.6.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
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
 
@@ -33,7 +33,7 @@ This installs the `gcf-run` and `gcf-run-ingest` skills and registers the `gcf-h
33
33
  ai-factory gcf-harness init # per project: .claude/settings.json + .gitignore
34
34
  ```
35
35
 
36
- It is **per project**, not global — run it again in every game repository: it adds the readset-guard `PreToolUse` entry to the project's `.claude/settings.json` (never `~/.claude`) and `.gcf-session/` to `.gitignore`. It is idempotent — a re-run after moving or cloning the project repoints the hook at the new path — refuses when the `gcf` command is not registered, and refuses to rewrite a `settings.json` it cannot parse. It is a separate step for the same reason as `gcf hooks-install`: an AI Factory manifest cannot install Claude Code hooks.
36
+ It is **per project**, not global — run it again in every game repository: it adds the readset-guard `PreToolUse` entry to the project's `.claude/settings.json` (never `~/.claude`) and `.gcf-session/` to `.gitignore`. It also allows what an unattended `claude -p` run needs at least — `Edit`, `Write`, `Bash(git *)`, `Bash(ai-factory *)` — because print mode cannot ask and denies the rest; add your engine's tools yourself (for example `mcp__<editor-server>` for an editor driven over MCP). Commit `.claude/settings.json`: the rules apply to every Claude session in the project. It is idempotent — a re-run after moving or cloning the project repoints the hook at the new path — refuses when the `gcf` command is not registered, and refuses to rewrite a `settings.json` it cannot parse. It is a separate step for the same reason as `gcf hooks-install`: an AI Factory manifest cannot install Claude Code hooks.
37
37
 
38
38
  ## Upgrade
39
39
 
@@ -47,6 +47,14 @@ Updating keeps the hook entry valid — it points into the extension's install d
47
47
 
48
48
  0.5.1 is a fix release. During a run, `gcf/config.yml` is always readable, and a Grep inside `gcf/` reads only readset files. An import breaks a `depends:` cycle the pair checks closed and asks a D1 about it. It cuts a section longer than a segment at paragraphs, and lens and pair prompts carry a window around the quote. `/gcf-run` checks the plan against each pillar's invariant and asks each pillar's review question over the diff. Use it with core 0.4.1, which brings the parent feature into the readset.
49
49
 
50
+ 0.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
+
50
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.
51
59
 
52
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.
@@ -1,13 +1,14 @@
1
1
  // `ai-factory gcf-harness init` — registers the readset-guard PreToolUse hook in the
2
2
  // project's .claude/settings.json (the AIF manifest cannot install Claude Code hooks —
3
- // same decision as gcf hooks-install for git hooks). Idempotent by marker comment.
3
+ // same decision as gcf hooks-install for git hooks). Idempotent by marker comment. It also
4
+ // allows what a headless agent run needs (AGENT_ALLOW).
4
5
  // `ai-factory gcf-harness gate <ids…>` — the /gcf-run gate as code (Harness-PRD §4.3).
5
6
  import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
6
- import { existsSync } from 'node:fs';
7
+ import { existsSync, readFileSync } from 'node:fs';
7
8
  import { spawnSync } from 'node:child_process';
8
9
  import path from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
- import { run, cliCore, DEFAULT_AGENT, DEFAULT_TIMEOUT_MIN } from './run.js';
11
+ import { run, cliCore, DEFAULT_AGENT, DEFAULT_TIMEOUT_MIN, MAIN_RUN } from './run.js';
11
12
 
12
13
  const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
13
14
  const MARKER = 'gcf-readset-guard';
@@ -27,7 +28,9 @@ function itemProblem(id, item, report) {
27
28
  return `${id} is ${item.status} — only ready items are driven`;
28
29
  }
29
30
 
30
- if (item.running) return `${id} is driven by another harness run — wait until it ends`;
31
+ // The session a run started for this very item gates it too: its own run is not "another".
32
+ if (item.running && process.env.GCF_RUN_ITEM !== id)
33
+ return `${id} is driven by another harness run — wait until it ends`;
31
34
 
32
35
  // A child of a feature paused after a death does not start: the decision may kill it.
33
36
  const parent = item.parent ? report.items.find((p) => p.id === item.parent) : null;
@@ -85,6 +88,26 @@ function batchProblem(items, wip, elsewhere = []) {
85
88
 
86
89
  const verdict = (reason) => ({ pass: !reason, reason: reason ?? '' });
87
90
 
91
+ const rank = (i) => i.priority ?? Number.MAX_SAFE_INTEGER;
92
+
93
+ /**
94
+ * The ready items that can run together now, up to `count`, by priority: each is taken while
95
+ * the batch with it still passes the gate — a dependency not played, a shared subsystem, a full
96
+ * WIP leave it out. The gate decides; a board only shows and runs the answer.
97
+ */
98
+ export function nextBatch(report, count) {
99
+ const batch = [];
100
+ const ready = (report.items ?? [])
101
+ .filter((i) => i.status === 'ready' && !i.running && i.type !== 'feature')
102
+ .sort((a, b) => rank(a) - rank(b));
103
+ for (const i of ready) {
104
+ if (batch.length >= count) break;
105
+ if (gate(report, [...batch, i.id]).pass) batch.push(i.id);
106
+ }
107
+
108
+ return batch;
109
+ }
110
+
88
111
  /**
89
112
  * Decides whether the work items may enter the paid stages, from one `gcf audit --json` report.
90
113
  * Pure: the report is the whole input, so every rule has a test and no model compares numbers.
@@ -188,6 +211,20 @@ function registerGuard(settings, command) {
188
211
  return stale.length ? 'repointed' : null;
189
212
  }
190
213
 
214
+ // What a headless `claude -p` run needs at least: it cannot ask, so anything not allowed is
215
+ // denied. The engine's own tools (an editor's MCP server) are the project's to add.
216
+ const AGENT_ALLOW = ['Edit', 'Write', 'Bash(git *)', 'Bash(ai-factory *)'];
217
+
218
+ /** Adds the rules the settings do not hold yet; returns the ones added. */
219
+ function allowAgent(settings) {
220
+ settings.permissions ??= {};
221
+ settings.permissions.allow ??= [];
222
+ const added = AGENT_ALLOW.filter((r) => !settings.permissions.allow.includes(r));
223
+ settings.permissions.allow.push(...added);
224
+
225
+ return added;
226
+ }
227
+
191
228
  /**
192
229
  * Only an active rule ignoring the root .gcf-session/ counts — not a comment or a pattern
193
230
  * for some other directory that happens to contain the name.
@@ -213,14 +250,17 @@ async function init(program) {
213
250
  const guard = path.join(EXT_ROOT, 'hooks', 'readset-guard.js').replaceAll('\\', '/');
214
251
  const settings = await readSettings(settingsFile);
215
252
  const change = registerGuard(settings, `node "${guard}" # ${MARKER}`);
216
- if (change) {
253
+ const allowed = allowAgent(settings);
254
+ if (change || allowed.length) {
217
255
  await mkdir(path.dirname(settingsFile), { recursive: true });
218
256
  await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
219
- console.log(`readset-guard ${change} → ${settingsFile}`);
220
- } else {
221
- console.log('readset-guard already registered');
222
257
  }
223
258
 
259
+ console.log(
260
+ change ? `readset-guard ${change} → ${settingsFile}` : 'readset-guard already registered',
261
+ );
262
+ if (allowed.length) console.log(`agent runs may now: ${allowed.join(', ')}`);
263
+
224
264
  await ignoreSession(projectDir);
225
265
  }
226
266
 
@@ -239,7 +279,9 @@ export function register(program) {
239
279
 
240
280
  harness
241
281
  .command('init')
242
- .description('Initialize the harness in this project: readset-guard hook (idempotent)')
282
+ .description(
283
+ 'Initialize the harness in this project: readset-guard hook, agent permissions (idempotent)',
284
+ )
243
285
  .action(() => init(program));
244
286
 
245
287
  harness
@@ -261,8 +303,78 @@ export function register(program) {
261
303
  'stop a session that runs longer; its item stays ready',
262
304
  String(DEFAULT_TIMEOUT_MIN),
263
305
  )
306
+ .option('--mode <mode>', 'worktree | main (default: run_mode of gcf/config.yml)')
264
307
  .option('--json', 'machine output')
265
308
  .action(runItems);
309
+
310
+ harness
311
+ .command('next [count]')
312
+ .description('The ready items that can run together now, by priority (the gate decides)')
313
+ .option('--json', 'machine output: a list of ids')
314
+ .action((count, o) => {
315
+ const ids = nextBatch(auditReport(), Math.max(1, Number(count ?? 1) || 1));
316
+ console.log(o.json ? JSON.stringify(ids) : ids.join('\n') || 'nothing can run now');
317
+ });
318
+ harness
319
+ .command('stop <id>')
320
+ .description('Stop the run that drives an item, with every session it started; it stays ready')
321
+ .action((id) => console.log(stopRun(process.cwd(), id)));
322
+ }
323
+
324
+ const WIN = process.platform === 'win32';
325
+
326
+ const alive = (pid) => {
327
+ try {
328
+ process.kill(pid, 0);
329
+
330
+ return true;
331
+ } catch {
332
+ return false;
333
+ }
334
+ };
335
+
336
+ /**
337
+ * A run lock: `<run pid>\n<item>\n<session pid>\n` once the session started, the run pid alone
338
+ * before. Null when its run is gone.
339
+ */
340
+ function lockOf(file) {
341
+ try {
342
+ const [run, item, session] = readFileSync(file, 'utf8').split('\n');
343
+ if (!alive(Number(run))) return null;
344
+
345
+ return { run: Number(run), item: item || null, session: Number(session) || null };
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ /** Kill a process and everything under it: a tree on Windows, its own group elsewhere. */
352
+ function killTree(pid, group) {
353
+ if (WIN) spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
354
+ else {
355
+ try {
356
+ process.kill(group ? -pid : pid, group ? 'SIGKILL' : 'SIGTERM');
357
+ } catch {
358
+ // Gone already.
359
+ }
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Stop the session that works `id`: the one its own lock names (worktree mode), or the main
365
+ * checkout's run when that run works this very item (main mode). Only that session goes: the
366
+ * run records it as no result and goes on with the rest. A run that has not started a session
367
+ * yet goes down whole. The item stays ready and its work in progress is kept.
368
+ */
369
+ export function stopRun(projectDir, id, core = cliCore(projectDir)) {
370
+ const runs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
371
+ const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
372
+ const lock = lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
373
+ if (lock && lock.session && alive(lock.session)) killTree(lock.session, true);
374
+ else if (lock) killTree(lock.run, false);
375
+ core(['release', id]);
376
+
377
+ return lock ? `stopped the run of ${id}; it stays ready` : `no run drives ${id}`;
266
378
  }
267
379
 
268
380
  /** The run report a human reads, then the playtest sheet of what now waits for play. */
@@ -287,10 +399,13 @@ async function runItems(ids, opts) {
287
399
  try {
288
400
  const minutes = Number(opts.timeout);
289
401
  if (!(minutes > 0)) throw new Error('--timeout is a number of minutes above 0');
402
+ if (opts.mode && !['worktree', 'main'].includes(opts.mode))
403
+ throw new Error('--mode is worktree or main');
290
404
  const r = await run(process.cwd(), [...new Set(ids)], {
291
405
  agent: opts.agent,
292
406
  gate: (batch) => gate(auditReport(), batch),
293
407
  timeoutMs: minutes * 60_000,
408
+ mode: opts.mode,
294
409
  });
295
410
  if (opts.json) console.log(JSON.stringify(r, null, 2));
296
411
  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
 
@@ -69,25 +69,49 @@ function killTree(child) {
69
69
  }
70
70
  }
71
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
+
72
91
  /**
73
- * One agent session in the item's worktree; resolves with its exit code, or `timeout` when it
74
- * ran past `timeoutMs`: a hung session would hold the item's run lock, and every move on it,
75
- * for good.
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.
76
95
  */
77
- function startAgent(agent, id, dir, { fd, timeoutMs }) {
96
+ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
78
97
  return new Promise((resolve) => {
79
- const child = spawn(`${agent} "/gcf-run ${id} --worktree"`, {
98
+ const child = spawn(`${agent} "${prompt}"`, {
80
99
  cwd: dir,
81
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 },
82
103
  detached: !WIN,
83
104
  windowsHide: true,
84
105
  stdio: ['ignore', fd, fd],
85
106
  });
107
+ sessions.add(child);
108
+ if (child.pid) onSpawn?.(child.pid);
86
109
  const timer = setTimeout(() => {
87
110
  killTree(child);
88
111
  resolve('timeout');
89
112
  }, timeoutMs);
90
113
  const end = (code) => {
114
+ sessions.delete(child);
91
115
  clearTimeout(timer);
92
116
  resolve(code);
93
117
  };
@@ -217,22 +241,44 @@ async function prepare(ids, core, logs, agentOf) {
217
241
  return { ready, failed };
218
242
  }
219
243
 
220
- /** One session per item, its output in `.gcf-session/runs/<id>.log`. */
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
+ */
221
248
  async function session(r, logs, timeoutMs) {
222
249
  /** @type {import('node:fs/promises').FileHandle | null} */
223
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;
224
258
  try {
225
259
  log = await open(path.join(logs, `${r.id}.log`), 'a');
260
+ const opts = { fd: log.fd, timeoutMs, id: r.id, onSpawn };
226
261
 
227
- return { ...r, code: await startAgent(r.agent, r.id, r.dir, { fd: log.fd, timeoutMs }) };
262
+ return { ...r, code: await startAgent(r.agent, prompt, r.dir, opts) };
228
263
  } catch (e) {
229
264
  return { ...r, code: null, error: e.message };
230
265
  } finally {
266
+ if (keep) clearInterval(keep);
231
267
  await log?.close();
232
268
  await r.release();
233
269
  }
234
270
  }
235
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
+
236
282
  /**
237
283
  * A run that asked to block is recorded on main. Asked after its branch was verified, the
238
284
  * block wins: an empty commit takes the trailer off the tip, so no integrate merges it.
@@ -272,10 +318,95 @@ function resultOf(run, integrated) {
272
318
 
273
319
  export const DEFAULT_TIMEOUT_MIN = 120;
274
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
+
345
+ /**
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
+
275
406
  /**
276
- * Pick up orphans, gate the batch, run every item in its worktree at once, record each block
277
- * on main, integrate the verified branches. `gate(ids)` answers `{pass, reason}`; a session
278
- * past `timeoutMs` is stopped with all it started, and its item stays ready.
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.
279
410
  */
280
411
  export async function run(
281
412
  projectDir,
@@ -285,30 +416,33 @@ export async function run(
285
416
  agent = DEFAULT_AGENT,
286
417
  gate,
287
418
  timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
419
+ mode = undefined,
288
420
  },
289
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)`);
290
425
  const orphans = json(await core(['integrate', '--json']), 'integrate');
291
426
  const passed = gate(ids);
292
427
  if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
293
428
 
294
429
  const logs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
295
430
  await mkdir(logs, { recursive: true });
296
- const onBoard = json(await core(['board', '--json']), 'board').items ?? [];
431
+ const board = json(await core(['board', '--json']), 'board');
432
+ const runMode = mode ?? board.run_mode ?? 'worktree';
297
433
  const agentOf = (id) =>
298
434
  agentFor(
299
435
  agent,
300
- onBoard.find((i) => i.id === id),
436
+ (board.items ?? []).find((i) => i.id === id),
301
437
  );
302
- const { ready, failed } = await prepare(ids, core, logs, agentOf);
303
- const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
304
- const items = [...failed];
305
- for (const r of runs) {
306
- const outcome = await outcomeOf(r.dir);
307
- if (outcome?.block) items.push(await recordBlock(r, outcome, core));
308
- }
309
-
310
- const integrated = json(await core(['integrate', '--json']), 'integrate');
311
- for (const r of runs) if (!items.some((i) => i.id === r.id)) items.push(resultOf(r, integrated));
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);
312
443
 
313
- return { orphans, items };
444
+ return { orphans, mode: runMode, items };
445
+ } finally {
446
+ unhook();
447
+ }
314
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.7.0",
4
+ "version": "0.8.1",
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.7.0",
3
+ "version": "0.8.1",
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.6.0"
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