gcf-harness-aif-ext 0.8.2 → 0.8.4

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
@@ -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 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.
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 *)`, and the same two as `PowerShell(…)` rules, the tool the agent runs commands with on Windows — 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
 
@@ -4,7 +4,7 @@
4
4
  // allows what a headless agent run needs (AGENT_ALLOW).
5
5
  // `ai-factory gcf-harness gate <ids…>` — the /gcf-run gate as code (Harness-PRD §4.3).
6
6
  import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
7
- import { existsSync, readFileSync } from 'node:fs';
7
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
@@ -212,8 +212,16 @@ function registerGuard(settings, command) {
212
212
  }
213
213
 
214
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 *)'];
215
+ // denied. The engine's own tools (an editor's MCP server) are the project's to add. On Windows
216
+ // the agent runs commands through its PowerShell tool, which Bash rules do not cover.
217
+ const AGENT_ALLOW = [
218
+ 'Edit',
219
+ 'Write',
220
+ 'Bash(git *)',
221
+ 'Bash(ai-factory *)',
222
+ 'PowerShell(git *)',
223
+ 'PowerShell(ai-factory *)',
224
+ ];
217
225
 
218
226
  /** Adds the rules the settings do not hold yet; returns the ones added. */
219
227
  function allowAgent(settings) {
@@ -401,7 +409,9 @@ async function runItems(ids, opts) {
401
409
  if (!(minutes > 0)) throw new Error('--timeout is a number of minutes above 0');
402
410
  if (opts.mode && !['worktree', 'main'].includes(opts.mode))
403
411
  throw new Error('--mode is worktree or main');
404
- const r = await run(process.cwd(), [...new Set(ids)], {
412
+ // The true-case path: Claude Code trusts a folder by its exact path, and a shell or a board
413
+ // may hand over c: for C:, which turns off every allow rule of the project.
414
+ const r = await run(realpathSync.native(process.cwd()), [...new Set(ids)], {
405
415
  agent: opts.agent,
406
416
  gate: (batch) => gate(auditReport(), batch),
407
417
  timeoutMs: minutes * 60_000,
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, utimesSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, rmSync, 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
 
@@ -154,11 +154,18 @@ function alive(pid) {
154
154
  */
155
155
  async function claim(logs, id) {
156
156
  const file = path.join(logs, `${id}.lock`);
157
- for (let attempt = 0; attempt < 2; attempt++) {
157
+ // Three tries: a breaker a dead run left, then the dead lock itself, is cleared on the way.
158
+ for (let attempt = 0; attempt < 3; attempt++) {
158
159
  if (await create(file)) return () => rm(file, { force: true });
159
160
  const text = await readFile(file, 'utf8').catch(() => null);
160
- if (text === null) continue; // released meanwhile
161
- const pid = Number(text.trim());
161
+ // Released meanwhile: the next create may take it, and that try is not spent.
162
+ if (text === null) {
163
+ attempt--;
164
+ continue;
165
+ }
166
+
167
+ // The run's pid is the first line; a started session adds the item and its own pid.
168
+ const pid = Number(text.split('\n')[0]);
162
169
  if (pid === process.pid) return () => rm(file, { force: true });
163
170
  if (!(await dead(file, pid))) return null;
164
171
  await clearDead(file, text);
@@ -197,7 +204,15 @@ async function dead(file, pid) {
197
204
  */
198
205
  async function clearDead(file, text) {
199
206
  const breaker = `${file}.break`;
200
- if (!(await create(breaker))) return;
207
+ if (!(await create(breaker))) {
208
+ // A breaker is held for a read and a remove: one whose run is gone was left by a crash there.
209
+ const held = await readFile(breaker, 'utf8').catch(() => null);
210
+ if (held !== null && (await dead(breaker, Number(held.split('\n')[0]))))
211
+ await rm(breaker, { force: true });
212
+
213
+ return;
214
+ }
215
+
201
216
  try {
202
217
  if ((await readFile(file, 'utf8').catch(() => null)) === text) await rm(file, { force: true });
203
218
  } finally {
@@ -215,20 +230,50 @@ function verified(dir, id) {
215
230
  return (res.stdout ?? '').split('\n').some((v) => v.trim() === id);
216
231
  }
217
232
 
233
+ // Not a slug, so no item: the lock a run gates its batch and claims its items under.
234
+ const ADMIT = '.admit';
235
+
218
236
  /**
219
- * The worktree and the agent command of each item, claimed for this run; an item that cannot
220
- * start is an error. `agentOf(id)` gives the command.
237
+ * `fn` under the admission lock: two runs started at once (two board clicks) would each pass a
238
+ * gate that sees no lock of the other yet, and run together what the gate keeps apart.
221
239
  */
222
- async function prepare(ids, core, logs, agentOf) {
223
- const ready = [];
240
+ async function admitted(logs, fn) {
241
+ const until = Date.now() + 60_000;
242
+ let release = await claim(logs, ADMIT);
243
+ while (!release) {
244
+ if (Date.now() > until)
245
+ throw new Error(`another run is starting (${ADMIT}.lock in ${logs}): retry when it ends`);
246
+ await new Promise((r) => setTimeout(r, 100));
247
+ release = await claim(logs, ADMIT);
248
+ }
249
+
250
+ try {
251
+ return await fn();
252
+ } finally {
253
+ await release();
254
+ }
255
+ }
256
+
257
+ /** Every item claimed for this run, or an error when another run drives it. */
258
+ async function claimAll(ids, logs) {
259
+ const claimed = [];
224
260
  const failed = [];
225
261
  for (const id of ids) {
226
262
  const release = await claim(logs, id);
227
- if (!release) {
228
- failed.push({ id, result: 'error', error: `${id} is already running in another run` });
229
- continue;
230
- }
263
+ if (release) claimed.push({ id, release });
264
+ else failed.push({ id, result: 'error', error: `${id} is already running in another run` });
265
+ }
231
266
 
267
+ return { claimed, failed };
268
+ }
269
+
270
+ /**
271
+ * The worktree and the agent command of each claimed item; an item that cannot start is an
272
+ * error. `agentOf(id)` gives the command.
273
+ */
274
+ async function prepare({ claimed, failed }, core, agentOf) {
275
+ const ready = [];
276
+ for (const { id, release } of claimed) {
232
277
  try {
233
278
  const agent = agentOf(id);
234
279
  const wt = json(await core(['worktree', id, '--json']), `worktree ${id}`);
@@ -287,8 +332,7 @@ function touch(file) {
287
332
  */
288
333
  async function recordBlock(r, outcome, core) {
289
334
  const reason = String(outcome.block);
290
- if (verified(r.dir, r.id))
291
- spawnSync('git', ['commit', '--allow-empty', '-m', `gcf(unverified): ${r.id}`], { cwd: r.dir });
335
+ unverify(r);
292
336
  const b = await core(['block', r.id, '--reason', reason]);
293
337
 
294
338
  return {
@@ -299,6 +343,28 @@ async function recordBlock(r, outcome, core) {
299
343
  };
300
344
  }
301
345
 
346
+ /**
347
+ * An empty commit takes the trailer off the branch tip: no integrate merges it. `--only` keeps
348
+ * what a killed agent staged out of it, and a killed git leaves an index.lock to clear first.
349
+ */
350
+ function unverify(r) {
351
+ if (!verified(r.dir, r.id)) return null;
352
+ const args = ['commit', '-q', '--no-verify', '--allow-empty', '--only', '-m'];
353
+ const commit = () =>
354
+ spawnSync('git', [...args, `gcf(unverified): ${r.id}`], { cwd: r.dir, encoding: 'utf8' });
355
+ if (commit().status === 0) return null;
356
+ const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], {
357
+ cwd: r.dir,
358
+ encoding: 'utf8',
359
+ }).stdout.trim();
360
+ if (gitDir) rmSync(path.join(gitDir, 'index.lock'), { force: true });
361
+ const again = commit();
362
+
363
+ // shortcut: the integrate after it still merges a branch whose trailer stayed; the report
364
+ // says so, upgrade to integrating only the named branches if it ever happens.
365
+ return again.status === 0 ? null : `could not take its trailer off: ${again.stderr.trim()}`;
366
+ }
367
+
302
368
  /** The result line of an item the integrator handled, or why it has none. */
303
369
  function resultOf(run, integrated) {
304
370
  const done = integrated.find((x) => x.id === run.id);
@@ -389,14 +455,20 @@ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
389
455
  return mainResult(core, r);
390
456
  }
391
457
 
392
- /** Every item in its own worktree at once; blocks recorded on main, verified branches merged. */
393
- async function runWorktrees(ids, { core, logs, agentOf, timeoutMs }) {
394
- const { ready, failed } = await prepare(ids, core, logs, agentOf);
458
+ /** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
459
+ async function runWorktrees(claims, { core, logs, agentOf, timeoutMs }) {
460
+ const { ready, failed } = await prepare(claims, core, agentOf);
395
461
  const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
396
462
  const items = [...failed];
397
463
  for (const r of runs) {
398
464
  const outcome = await outcomeOf(r.dir);
399
465
  if (outcome?.block) items.push(await recordBlock(r, outcome, core));
466
+ // A session cut short (timeout, `stop`, a crash) may have meant to block after its trailer:
467
+ // its item stays ready, and the rerun verifies its branch again.
468
+ else if (r.code !== 0) {
469
+ const failed = unverify(r);
470
+ if (failed) items.push({ id: r.id, result: 'error', error: failed });
471
+ }
400
472
  }
401
473
 
402
474
  const integrated = json(await core(['integrate', '--json']), 'integrate');
@@ -425,13 +497,22 @@ export async function run(
425
497
  const bad = ids.find((id) => !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id));
426
498
  if (bad) throw new Error(`${bad} is not an item id (a lowercase hyphen slug)`);
427
499
  const orphans = json(await core(['integrate', '--json']), 'integrate');
428
- const passed = gate(ids);
429
- if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
430
-
431
500
  const logs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
432
501
  await mkdir(logs, { recursive: true });
433
502
  const board = json(await core(['board', '--json']), 'board');
434
503
  const runMode = mode ?? board.run_mode ?? 'worktree';
504
+ // The gate counts the items other runs hold locks on, so this run's locks go in with its gate.
505
+ // shortcut: a main run marks each item only as its session starts, so the items it has yet
506
+ // to reach do not count for another run's gate; claim them all here if main and worktree
507
+ // runs come to share a project.
508
+ const { passed, claims } = await admitted(logs, async () => {
509
+ const verdict = gate(ids);
510
+ const go = verdict.pass && runMode !== 'main';
511
+
512
+ return { passed: verdict, claims: go ? await claimAll(ids, logs) : null };
513
+ });
514
+ if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
515
+
435
516
  const agentOf = (id) =>
436
517
  agentFor(
437
518
  agent,
@@ -441,7 +522,7 @@ export async function run(
441
522
  const unhook = stopWithSessions();
442
523
  try {
443
524
  const items =
444
- runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(ids, ctx);
525
+ runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(claims, ctx);
445
526
 
446
527
  return { orphans, mode: runMode, items };
447
528
  } finally {
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-harness-aif-ext",
4
- "version": "0.8.2",
4
+ "version": "0.8.4",
5
5
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
6
6
  "skills": [
7
7
  "skills/gcf-run",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -224,8 +224,9 @@ function splitByProvenance({ aspect, units }, bodyOf) {
224
224
  const cited = [];
225
225
  const inferred = [];
226
226
  for (const u of units) {
227
- const body = ws(bodyOf.get(u.anchor) ?? '');
228
- if (u.quote && body.includes(ws(u.quote))) cited.push(u);
227
+ // A blank quote is in every body, even the empty one of an anchor the GDD does not have.
228
+ const quote = ws(u.quote ?? '');
229
+ if (quote && ws(bodyOf.get(u.anchor) ?? '').includes(quote)) cited.push(u);
229
230
  else inferred.push({ ...u, aspect });
230
231
  }
231
232