gcf-harness-aif-ext 0.6.1 → 0.7.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.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
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,8 @@ 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.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
+
50
52
  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
53
 
52
54
  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.
@@ -7,7 +7,7 @@ import { existsSync } 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 } 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';
@@ -49,20 +49,26 @@ function itemProblem(id, item, report) {
49
49
  return null;
50
50
  }
51
51
 
52
- /** Why the batch as a whole may not go, or null. */
53
- function batchProblem(items, wip) {
52
+ /**
53
+ * Why the batch as a whole may not go, or null. `elsewhere` are the items other runs drive now:
54
+ * a board starts one run per click, so each run sees the others only through their locks.
55
+ */
56
+ function batchProblem(items, wip, elsewhere = []) {
54
57
  // 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.
58
+ // count === limit, and this run adds every such item of the batch — and of the other runs.
56
59
  const waiting = items.filter((i) => i.gate === 'verdict').length;
57
- if (waiting && wip.count + waiting > wip.limit) {
60
+ const coming = elsewhere.filter((i) => i.gate === 'verdict').length;
61
+ if (waiting && wip.count + coming + waiting > wip.limit) {
62
+ const others = coming ? ` + ${coming} in other runs` : '';
63
+
58
64
  return (
59
- `WIP: ${wip.count} awaiting a verdict + ${waiting} in this run exceeds the limit of ` +
60
- `${wip.limit} — playtest first`
65
+ `WIP: ${wip.count} awaiting a verdict${others} + ${waiting} in this run exceeds the ` +
66
+ `limit of ${wip.limit} — playtest first`
61
67
  );
62
68
  }
63
69
 
64
70
  for (let i = 0; i < items.length; i++) {
65
- for (const other of items.slice(i + 1)) {
71
+ for (const other of [...items.slice(i + 1), ...elsewhere]) {
66
72
  const shared = items[i].system.filter((s) => other.system.includes(s));
67
73
  // `parallel: ok` on both is the operator's explicit permission (Methodology §7).
68
74
  const allowed = items[i].parallel === 'ok' && other.parallel === 'ok';
@@ -104,6 +110,7 @@ export function gate(report, ids) {
104
110
  batchProblem(
105
111
  ids.map((id) => byId.get(id)),
106
112
  report.wip,
113
+ report.items.filter((c) => c.running && !ids.includes(c.id)),
107
114
  ),
108
115
  );
109
116
  }
@@ -249,6 +256,11 @@ export function register(program) {
249
256
  'the agent command; it gets "/gcf-run <id> --worktree"',
250
257
  process.env.GCF_RUN_AGENT ?? DEFAULT_AGENT,
251
258
  )
259
+ .option(
260
+ '--timeout <minutes>',
261
+ 'stop a session that runs longer; its item stays ready',
262
+ String(DEFAULT_TIMEOUT_MIN),
263
+ )
252
264
  .option('--json', 'machine output')
253
265
  .action(runItems);
254
266
  }
@@ -273,9 +285,12 @@ const failed = (r) =>
273
285
 
274
286
  async function runItems(ids, opts) {
275
287
  try {
288
+ const minutes = Number(opts.timeout);
289
+ if (!(minutes > 0)) throw new Error('--timeout is a number of minutes above 0');
276
290
  const r = await run(process.cwd(), [...new Set(ids)], {
277
291
  agent: opts.agent,
278
292
  gate: (batch) => gate(auditReport(), batch),
293
+ timeoutMs: minutes * 60_000,
279
294
  });
280
295
  if (opts.json) console.log(JSON.stringify(r, null, 2));
281
296
  else printRun(r);
package/commands/run.js CHANGED
@@ -31,16 +31,69 @@ 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
+ /**
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.
76
+ */
77
+ function startAgent(agent, id, dir, { fd, timeoutMs }) {
36
78
  return new Promise((resolve) => {
37
79
  const child = spawn(`${agent} "/gcf-run ${id} --worktree"`, {
38
80
  cwd: dir,
39
81
  shell: true,
82
+ detached: !WIN,
83
+ windowsHide: true,
40
84
  stdio: ['ignore', fd, fd],
41
85
  });
42
- child.on('error', () => resolve(-1));
43
- child.on('exit', (code) => resolve(code ?? -1));
86
+ const timer = setTimeout(() => {
87
+ killTree(child);
88
+ resolve('timeout');
89
+ }, timeoutMs);
90
+ const end = (code) => {
91
+ clearTimeout(timer);
92
+ resolve(code);
93
+ };
94
+
95
+ child.on('error', () => end(-1));
96
+ child.on('exit', (code) => end(code ?? -1));
44
97
  });
45
98
  }
46
99
 
@@ -136,8 +189,11 @@ function verified(dir, id) {
136
189
  return (res.stdout ?? '').split('\n').some((v) => v.trim() === id);
137
190
  }
138
191
 
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) {
192
+ /**
193
+ * The worktree and the agent command of each item, claimed for this run; an item that cannot
194
+ * start is an error. `agentOf(id)` gives the command.
195
+ */
196
+ async function prepare(ids, core, logs, agentOf) {
141
197
  const ready = [];
142
198
  const failed = [];
143
199
  for (const id of ids) {
@@ -148,9 +204,10 @@ async function prepare(ids, core, logs) {
148
204
  }
149
205
 
150
206
  try {
207
+ const agent = agentOf(id);
151
208
  const wt = json(await core(['worktree', id, '--json']), `worktree ${id}`);
152
209
  if (wt.sync) throw new Error(wt.sync);
153
- ready.push({ id, dir: wt.path, release });
210
+ ready.push({ id, dir: wt.path, agent, release });
154
211
  } catch (e) {
155
212
  await release();
156
213
  failed.push({ id, result: 'error', error: e.message });
@@ -161,12 +218,13 @@ async function prepare(ids, core, logs) {
161
218
  }
162
219
 
163
220
  /** One session per item, its output in `.gcf-session/runs/<id>.log`. */
164
- async function session(r, agent, logs) {
221
+ async function session(r, logs, timeoutMs) {
222
+ /** @type {import('node:fs/promises').FileHandle | null} */
165
223
  let log = null;
166
224
  try {
167
225
  log = await open(path.join(logs, `${r.id}.log`), 'a');
168
226
 
169
- return { ...r, code: await startAgent(agent, r.id, r.dir, log.fd) };
227
+ return { ...r, code: await startAgent(r.agent, r.id, r.dir, { fd: log.fd, timeoutMs }) };
170
228
  } catch (e) {
171
229
  return { ...r, code: null, error: e.message };
172
230
  } finally {
@@ -197,13 +255,14 @@ async function recordBlock(r, outcome, core) {
197
255
  function resultOf(run, integrated) {
198
256
  const done = integrated.find((x) => x.id === run.id);
199
257
  if (!done && run.error) return { id: run.id, result: 'error', error: run.error };
200
- if (!done)
258
+ if (!done) {
259
+ const why = run.code === 'timeout' ? 'the session timed out' : `agent exit ${run.code}`;
260
+
201
261
  return {
202
262
  id: run.id,
203
- result:
204
- `no result (agent exit ${run.code}): ${run.id} stays ready, a rerun resumes its ` +
205
- 'worktree',
263
+ result: `no result (${why}): ${run.id} stays ready, a rerun resumes its worktree`,
206
264
  };
265
+ }
207
266
 
208
267
  return {
209
268
  ...done,
@@ -211,14 +270,22 @@ function resultOf(run, integrated) {
211
270
  };
212
271
  }
213
272
 
273
+ export const DEFAULT_TIMEOUT_MIN = 120;
274
+
214
275
  /**
215
276
  * 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}`.
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.
217
279
  */
218
280
  export async function run(
219
281
  projectDir,
220
282
  ids,
221
- { core = cliCore(projectDir), agent = DEFAULT_AGENT, gate },
283
+ {
284
+ core = cliCore(projectDir),
285
+ agent = DEFAULT_AGENT,
286
+ gate,
287
+ timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
288
+ },
222
289
  ) {
223
290
  const orphans = json(await core(['integrate', '--json']), 'integrate');
224
291
  const passed = gate(ids);
@@ -226,8 +293,14 @@ export async function run(
226
293
 
227
294
  const logs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
228
295
  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)));
296
+ const onBoard = json(await core(['board', '--json']), 'board').items ?? [];
297
+ const agentOf = (id) =>
298
+ agentFor(
299
+ agent,
300
+ onBoard.find((i) => i.id === id),
301
+ );
302
+ const { ready, failed } = await prepare(ids, core, logs, agentOf);
303
+ const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
231
304
  const items = [...failed];
232
305
  for (const r of runs) {
233
306
  const outcome = await outcomeOf(r.dir);
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.7.0",
5
5
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
6
  "skills": [
7
7
  "skills/gcf-run",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.6.1",
3
+ "version": "0.7.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.6.0"
32
32
  },
33
33
  "files": [
34
34
  "extension.json",