gcf-harness-aif-ext 0.8.3 → 0.8.7
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 +1 -1
- package/commands/activity.js +161 -0
- package/commands/harness.js +3 -1
- package/commands/run.js +330 -57
- package/extension.json +1 -1
- package/package.json +1 -1
- package/workflows/ingest.js +3 -2
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry wh
|
|
|
77
77
|
|
|
78
78
|
**`ai-factory gcf-harness gate <item-id…>`** — the `/gcf-run` gate as code. It reads one `gcf audit --json` report and exits 1 with a one-line reason when the items may not go: a core older than 0.4.0, a `schema:` other than 1 in `gcf/config.yml`, an id with no work item, a feature (run its children; the feature closes when they are finished), an item that is not `ready`, an unplayed dependency, no WIP room for the batch (items awaiting a verdict + the batch's `gate: verdict` items over `wip_built_limit`), or two items of the batch touching the same system unless both say `parallel: ok`. The tune limit is not a gate rule: `verdict tune` itself refuses at the limit, and an item tuned up to it still gets its last build. The skill runs it and stops on a non-zero exit — no model compares the numbers.
|
|
79
79
|
|
|
80
|
-
**`ai-factory gcf-harness run <item-id…> [--agent "<cmd>"]`** — runs a batch at once, each item in its own worktree (`gcf worktree <id>`) with its own agent session: `claude -p "/gcf-run <id> --worktree"` by default, or the command in `--agent` / `GCF_RUN_AGENT`. The sessions run unattended, so set the agent's permissions for print mode first. A session ends with a commit and `gcf verify <id> --mark` (the `GCF-Verified` trailer); a question for the human goes into `.gcf-session/outcome.json` in its worktree, and `run` records the block on main. Then `gcf integrate` merges the verified branches and records each move, and `run` prints the playtest session. Its first step, like `/gcf-run`'s, integrates the verified branches a dead run left. A session that ended with no result leaves its item `ready`; the next `run` resumes its worktree. Logs: `.gcf-session/runs/<id>.log
|
|
80
|
+
**`ai-factory gcf-harness run <item-id…> [--agent "<cmd>"]`** — runs a batch at once, each item in its own worktree (`gcf worktree <id>`) with its own agent session: `claude -p --output-format stream-json --verbose "/gcf-run <id> --worktree"` by default, or the command in `--agent` / `GCF_RUN_AGENT`. The sessions run unattended, so set the agent's permissions for print mode first. A session ends with a commit and `gcf verify <id> --mark` (the `GCF-Verified` trailer); a question for the human goes into `.gcf-session/outcome.json` in its worktree, and `run` records the block on main. Then `gcf integrate` merges the verified branches and records each move, and `run` prints the playtest session. Its first step, like `/gcf-run`'s, integrates the verified branches a dead run left. A session that ended with no result leaves its item `ready`; the next `run` resumes its worktree and the agent's own session (`--resume <session_id>`, kept in `.gcf-session/runs/<id>.agent.json`), in either run mode; `--fresh` starts a new session instead. A session cut off by Claude's usage limit reports `paused (usage limit, resets 3am (…))` and `limit_resets`. Logs: `.gcf-session/runs/<id>.log`, written as the session works: a `start:` line, then one stamped line per agent message, tool call (`tool: Edit <file>`, `tool: Bash <command>`) and failed tool result, and at the end the agent's final text and `done in 34m, 212 turns, $3.41 (…tokens)`; stderr goes in as it is, and a non-Claude `--agent` keeps its own lines. With `--json`, each item carries the session's `cost_usd`, `tokens`, `turns` and `duration_ms` when the agent reported them. One run drives an item at a time (`.gcf-session/runs/<id>.lock`), and a resumed worktree first gets main merged in, so the answer from an `unblock` reaches the session. A worktree sees only what is committed: keep `.ai-factory.json`, the installed extensions and `.claude/` (skills, the readset-guard hook) in git, or the session has no `/gcf-run` and no `ai-factory gcf`.
|
|
81
81
|
|
|
82
82
|
**`/gcf-run-ingest <gdd.md>`** — imports a design document as a fan-out workflow: parallel aspect extraction, provenance verified by code rather than by an agent (a quote either is a verbatim substring of its section or it is not), completeness lenses per candidate, and an interaction matrix computed from the candidates that write the same state.
|
|
83
83
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The live log of an agent session: `claude -p --output-format stream-json` prints one JSON event
|
|
2
|
+
// per line, and the run log gets one readable line per event worth seeing, so an operator can tell
|
|
3
|
+
// a working session from a stuck one. A line that is not JSON is another agent's own output.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} Cost What a session spent, from its `result` event.
|
|
7
|
+
* @property {number} cost_usd
|
|
8
|
+
* @property {{input: number, output: number, cache_read: number, cache_creation: number}} tokens
|
|
9
|
+
* @property {number} turns
|
|
10
|
+
* @property {number} duration_ms
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** `12:03:10`, the local time of day. */
|
|
14
|
+
export const clock = (now = new Date()) => now.toTimeString().slice(0, 8);
|
|
15
|
+
|
|
16
|
+
const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
17
|
+
|
|
18
|
+
/** The first `n` non-empty lines of a text, trimmed, on one line. */
|
|
19
|
+
const firstLines = (text, n) =>
|
|
20
|
+
String(text)
|
|
21
|
+
.split('\n')
|
|
22
|
+
.map((l) => l.trim())
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.slice(0, n)
|
|
25
|
+
.join(' ');
|
|
26
|
+
|
|
27
|
+
// The input field that tells most about a tool call, in this order; with none, the name alone.
|
|
28
|
+
const TELLING = ['file_path', 'command', 'pattern', 'url'];
|
|
29
|
+
|
|
30
|
+
function toolLine(use) {
|
|
31
|
+
const key = TELLING.find((k) => typeof use.input?.[k] === 'string');
|
|
32
|
+
|
|
33
|
+
return key ? `tool: ${use.name} ${cut(firstLines(use.input[key], 1), 120)}` : `tool: ${use.name}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A tool result's text: a string, or a list of text blocks. */
|
|
37
|
+
const resultText = (content) =>
|
|
38
|
+
Array.isArray(content) ? content.map((c) => c?.text ?? '').join('\n') : String(content ?? '');
|
|
39
|
+
|
|
40
|
+
/** `1.2M`, `85k`, `950`: one decimal below ten of its unit. */
|
|
41
|
+
const count = (n) => (n >= 1e6 ? scaled(n / 1e6, 'M') : n >= 1e3 ? scaled(n / 1e3, 'k') : `${n}`);
|
|
42
|
+
const scaled = (v, unit) => `${v < 10 ? v.toFixed(1) : Math.round(v)}${unit}`;
|
|
43
|
+
|
|
44
|
+
/** `8s`, `34m`, `1h 12m`. */
|
|
45
|
+
function span(ms) {
|
|
46
|
+
const s = Math.round(ms / 1000);
|
|
47
|
+
if (s < 60) return `${s}s`;
|
|
48
|
+
const m = Math.round(s / 60);
|
|
49
|
+
|
|
50
|
+
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns {Cost} */
|
|
54
|
+
function costOf(e) {
|
|
55
|
+
const u = e.usage ?? {};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
cost_usd: Number(e.total_cost_usd ?? 0),
|
|
59
|
+
tokens: {
|
|
60
|
+
input: Number(u.input_tokens ?? 0),
|
|
61
|
+
output: Number(u.output_tokens ?? 0),
|
|
62
|
+
cache_read: Number(u.cache_read_input_tokens ?? 0),
|
|
63
|
+
cache_creation: Number(u.cache_creation_input_tokens ?? 0),
|
|
64
|
+
},
|
|
65
|
+
turns: Number(e.num_turns ?? 0),
|
|
66
|
+
duration_ms: Number(e.duration_ms ?? 0),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function summary(e, c) {
|
|
71
|
+
const end = e.is_error ? `ended (${e.subtype ?? 'error'})` : 'done';
|
|
72
|
+
const t = c.tokens;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
`${end} in ${span(c.duration_ms)}, ${c.turns} turns, $${c.cost_usd.toFixed(2)} ` +
|
|
76
|
+
`(in ${count(t.input)} / out ${count(t.output)} tokens, cache read ${count(t.cache_read)}, ` +
|
|
77
|
+
`cache write ${count(t.cache_creation)})`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Claude's usage-limit ending, `You've hit your session limit · resets 3am (Europe/Berlin)`:
|
|
83
|
+
* `{limit: {resets}}`, the time it names (null without one), or nothing for any other text. An
|
|
84
|
+
* error result is the session's end, so the message anywhere in it counts; an agent's text or a
|
|
85
|
+
* plain line counts only when it is that message whole: prose about some other limit is not.
|
|
86
|
+
* @returns {{limit?: {resets: string | null}}}
|
|
87
|
+
*/
|
|
88
|
+
function limitIn(text, whole = true) {
|
|
89
|
+
const s = String(text ?? '').trim();
|
|
90
|
+
const hit = whole ? /^You['’]ve hit your [^\n]*limit[^\n]*$/i : /hit your .*limit/i;
|
|
91
|
+
if (!hit.test(s)) return {};
|
|
92
|
+
|
|
93
|
+
return { limit: { resets: s.match(/resets (.+)$/im)?.[1].trim() || null } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One stdout line of a session as log lines: an event worth seeing becomes one stamped line, the
|
|
98
|
+
* rest of the stream (hooks, init, thinking, rate limits) none; the `result` event gives the
|
|
99
|
+
* final text, a summary line and the cost. `session_id` comes with the init event, `limit` with
|
|
100
|
+
* the usage-limit message, in whatever form it arrives: the session can be resumed after either.
|
|
101
|
+
* @param {string} line
|
|
102
|
+
* @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null}}}
|
|
103
|
+
*/
|
|
104
|
+
export function activity(line, now = new Date()) {
|
|
105
|
+
let e;
|
|
106
|
+
try {
|
|
107
|
+
e = JSON.parse(line);
|
|
108
|
+
} catch {
|
|
109
|
+
return { lines: [line], ...limitIn(line) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof e?.type !== 'string') return { lines: [line] };
|
|
113
|
+
|
|
114
|
+
const at = `[${clock(now)}]`;
|
|
115
|
+
const content = Array.isArray(e.message?.content) ? e.message.content : [];
|
|
116
|
+
if (e.type === 'system') return system(e);
|
|
117
|
+
if (e.type === 'assistant') return said(content, at);
|
|
118
|
+
if (e.type === 'user')
|
|
119
|
+
return {
|
|
120
|
+
lines: content
|
|
121
|
+
.filter((b) => b.type === 'tool_result' && b.is_error)
|
|
122
|
+
.map((b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`),
|
|
123
|
+
};
|
|
124
|
+
if (e.type === 'result') return ended(e, at);
|
|
125
|
+
|
|
126
|
+
return { lines: [] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A system event: the init names the session; the rest (hooks, thinking) give nothing. */
|
|
130
|
+
const system = (e) =>
|
|
131
|
+
e.subtype === 'init' && typeof e.session_id === 'string'
|
|
132
|
+
? { lines: [], session_id: e.session_id }
|
|
133
|
+
: { lines: [] };
|
|
134
|
+
|
|
135
|
+
/** An assistant turn: its tool calls and its text, which may be the usage-limit message. */
|
|
136
|
+
function said(content, at) {
|
|
137
|
+
const texts = content.filter((b) => b.type === 'text').map((b) => b.text);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...(texts.map((t) => limitIn(t)).find((l) => l.limit) ?? {}),
|
|
141
|
+
lines: content.flatMap((b) => {
|
|
142
|
+
if (b.type === 'tool_use') return [`${at} ${toolLine(b)}`];
|
|
143
|
+
const text = b.type === 'text' ? cut(firstLines(b.text, 2), 300) : '';
|
|
144
|
+
|
|
145
|
+
return text ? [`${at} agent: ${text}`] : [];
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The `result` event: the final text, a summary line, the cost, and a limit that ended it. */
|
|
151
|
+
function ended(e, at) {
|
|
152
|
+
const cost = costOf(e);
|
|
153
|
+
const text =
|
|
154
|
+
typeof e.result === 'string' && e.result.trim() ? [`${at} result:\n${e.result}`] : [];
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
lines: [...text, `${at} ${summary(e, cost)}`],
|
|
158
|
+
cost,
|
|
159
|
+
...(e.is_error ? limitIn(e.result, false) : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
package/commands/harness.js
CHANGED
|
@@ -303,7 +303,7 @@ export function register(program) {
|
|
|
303
303
|
.description('Run items at once, each in its worktree with its own agent, then integrate')
|
|
304
304
|
.option(
|
|
305
305
|
'--agent <command>',
|
|
306
|
-
'the agent command; it gets "/gcf-run <id> --worktree"',
|
|
306
|
+
'the agent command; it gets "/gcf-run <id> --worktree", or --resume <session> to go on',
|
|
307
307
|
process.env.GCF_RUN_AGENT ?? DEFAULT_AGENT,
|
|
308
308
|
)
|
|
309
309
|
.option(
|
|
@@ -312,6 +312,7 @@ export function register(program) {
|
|
|
312
312
|
String(DEFAULT_TIMEOUT_MIN),
|
|
313
313
|
)
|
|
314
314
|
.option('--mode <mode>', 'worktree | main (default: run_mode of gcf/config.yml)')
|
|
315
|
+
.option('--fresh', 'start new agent sessions: an interrupted one is not resumed')
|
|
315
316
|
.option('--json', 'machine output')
|
|
316
317
|
.action(runItems);
|
|
317
318
|
|
|
@@ -416,6 +417,7 @@ async function runItems(ids, opts) {
|
|
|
416
417
|
gate: (batch) => gate(auditReport(), batch),
|
|
417
418
|
timeoutMs: minutes * 60_000,
|
|
418
419
|
mode: opts.mode,
|
|
420
|
+
fresh: Boolean(opts.fresh),
|
|
419
421
|
});
|
|
420
422
|
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
421
423
|
else printRun(r);
|
package/commands/run.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
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, utimesSync, writeFileSync, writeSync } from 'node:fs';
|
|
7
7
|
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { activity, clock } from './activity.js';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// The event stream, not the final answer alone: the log shows the session at work as it goes.
|
|
12
|
+
export const DEFAULT_AGENT = 'claude -p --output-format stream-json --verbose';
|
|
11
13
|
|
|
12
14
|
/** The core CLI: the same ai-factory that runs this command, in the project. */
|
|
13
15
|
export function cliCore(projectDir) {
|
|
@@ -90,36 +92,104 @@ function stopWithSessions() {
|
|
|
90
92
|
return () => process.removeListener('SIGTERM', stop);
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Hand a stream to `take` line by line. Returns the flush of a last line with no newline: a
|
|
97
|
+
* stream destroyed under a tool that holds it open never ends.
|
|
98
|
+
*/
|
|
99
|
+
function byLine(stream, take) {
|
|
100
|
+
let rest = '';
|
|
101
|
+
stream.setEncoding('utf8');
|
|
102
|
+
stream.on('data', (chunk) => {
|
|
103
|
+
const lines = (rest + chunk).split('\n');
|
|
104
|
+
rest = lines.pop() ?? '';
|
|
105
|
+
for (const l of lines) take(l);
|
|
106
|
+
});
|
|
107
|
+
const flush = () => {
|
|
108
|
+
if (rest) take(rest);
|
|
109
|
+
rest = '';
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
stream.on('end', flush);
|
|
113
|
+
|
|
114
|
+
return flush;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What `claude --resume` prints on stderr for a session it does not have.
|
|
118
|
+
const NO_SESSION = /No conversation found with session ID/;
|
|
119
|
+
|
|
93
120
|
/**
|
|
94
121
|
* One agent session on `prompt` in `dir`; resolves with its exit code, or `timeout` when it ran
|
|
95
122
|
* past `timeoutMs`: a hung session would hold the item's run lock, and every move on it, for
|
|
96
|
-
* good.
|
|
123
|
+
* good. Its stdout reaches the log through `activity`, its stderr as it is; `cost` is what a
|
|
124
|
+
* Claude session reported at its end, `session_id` the id a rerun resumes it by, `limit` the
|
|
125
|
+
* usage limit that ended it, `gone` that the session it was to resume does not exist.
|
|
126
|
+
* @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
|
|
127
|
+
* session_id?: string, limit?: {resets: string | null}, gone: boolean}>}
|
|
97
128
|
*/
|
|
98
129
|
function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
|
|
99
130
|
return new Promise((resolve) => {
|
|
100
|
-
const
|
|
131
|
+
const command = `${agent} "${prompt}"`;
|
|
132
|
+
// A line before the agent's first: the log's time says the session is alive from its start.
|
|
133
|
+
writeSync(fd, `[${clock()}] start: ${command}\n`);
|
|
134
|
+
const child = spawn(command, {
|
|
101
135
|
cwd: dir,
|
|
102
136
|
shell: true,
|
|
103
137
|
// The session's own gate must not refuse the item this run marked as running.
|
|
104
138
|
env: { ...process.env, GCF_RUN_ITEM: id },
|
|
105
139
|
detached: !WIN,
|
|
106
140
|
windowsHide: true,
|
|
107
|
-
stdio: ['ignore',
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
108
142
|
});
|
|
109
143
|
sessions.add(child);
|
|
110
144
|
if (child.pid) onSpawn?.(child.pid);
|
|
145
|
+
let open = true;
|
|
146
|
+
let cost;
|
|
147
|
+
let sessionId;
|
|
148
|
+
let limit;
|
|
149
|
+
let gone = false;
|
|
150
|
+
const write = (lines) => {
|
|
151
|
+
try {
|
|
152
|
+
if (open && lines.length) writeSync(fd, lines.map((l) => `${l}\n`).join(''));
|
|
153
|
+
} catch {
|
|
154
|
+
// A log that cannot be written loses its lines, not the run.
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const flushOut = byLine(child.stdout, (line) => {
|
|
159
|
+
const a = activity(line);
|
|
160
|
+
cost = a.cost ?? cost;
|
|
161
|
+
sessionId = a.session_id ?? sessionId;
|
|
162
|
+
limit = a.limit ?? limit;
|
|
163
|
+
write(a.lines);
|
|
164
|
+
});
|
|
165
|
+
const flushErr = byLine(child.stderr, (line) => {
|
|
166
|
+
gone ||= NO_SESSION.test(line);
|
|
167
|
+
write([line]);
|
|
168
|
+
});
|
|
111
169
|
const timer = setTimeout(() => {
|
|
112
170
|
killTree(child);
|
|
113
|
-
|
|
171
|
+
end('timeout');
|
|
114
172
|
}, timeoutMs);
|
|
115
|
-
|
|
173
|
+
function end(code) {
|
|
174
|
+
if (!open) return;
|
|
175
|
+
flushOut();
|
|
176
|
+
flushErr();
|
|
177
|
+
open = false;
|
|
116
178
|
sessions.delete(child);
|
|
117
179
|
clearTimeout(timer);
|
|
118
|
-
resolve(code);
|
|
119
|
-
}
|
|
180
|
+
resolve({ code, cost, session_id: sessionId, limit, gone });
|
|
181
|
+
}
|
|
120
182
|
|
|
121
183
|
child.on('error', () => end(-1));
|
|
122
|
-
|
|
184
|
+
// `close` waits for the agent's output as well: its last lines, the result, may come after
|
|
185
|
+
// its exit. A tool it left running may hold that output open: the session ends with it.
|
|
186
|
+
child.on('close', (code) => end(code ?? -1));
|
|
187
|
+
child.on('exit', () =>
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
child.stdout.destroy();
|
|
190
|
+
child.stderr.destroy();
|
|
191
|
+
}, 2000).unref(),
|
|
192
|
+
);
|
|
123
193
|
});
|
|
124
194
|
}
|
|
125
195
|
|
|
@@ -154,11 +224,24 @@ function alive(pid) {
|
|
|
154
224
|
*/
|
|
155
225
|
async function claim(logs, id) {
|
|
156
226
|
const file = path.join(logs, `${id}.lock`);
|
|
157
|
-
|
|
227
|
+
// Three tries: a breaker a dead run left, then the dead lock itself, is cleared on the way. A
|
|
228
|
+
// lock released between the create and the read spends no try, up to six rounds in all.
|
|
229
|
+
for (let attempt = 0, round = 0; attempt < 3 && round < 6; round++) {
|
|
158
230
|
if (await create(file)) return () => rm(file, { force: true });
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
231
|
+
let text;
|
|
232
|
+
try {
|
|
233
|
+
text = await readFile(file, 'utf8');
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// Released meanwhile: the next create may take it. A lock that cannot be read is busy.
|
|
236
|
+
if (e.code === 'ENOENT') continue;
|
|
237
|
+
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
attempt++;
|
|
242
|
+
|
|
243
|
+
// The run's pid is the first line; a started session adds the item and its own pid.
|
|
244
|
+
const pid = Number(text.split('\n')[0]);
|
|
162
245
|
if (pid === process.pid) return () => rm(file, { force: true });
|
|
163
246
|
if (!(await dead(file, pid))) return null;
|
|
164
247
|
await clearDead(file, text);
|
|
@@ -197,7 +280,15 @@ async function dead(file, pid) {
|
|
|
197
280
|
*/
|
|
198
281
|
async function clearDead(file, text) {
|
|
199
282
|
const breaker = `${file}.break`;
|
|
200
|
-
if (!(await create(breaker)))
|
|
283
|
+
if (!(await create(breaker))) {
|
|
284
|
+
// A breaker is held for a read and a remove: one whose run is gone was left by a crash there.
|
|
285
|
+
const held = await readFile(breaker, 'utf8').catch(() => null);
|
|
286
|
+
if (held !== null && (await dead(breaker, Number(held.split('\n')[0]))))
|
|
287
|
+
await rm(breaker, { force: true });
|
|
288
|
+
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
201
292
|
try {
|
|
202
293
|
if ((await readFile(file, 'utf8').catch(() => null)) === text) await rm(file, { force: true });
|
|
203
294
|
} finally {
|
|
@@ -205,9 +296,14 @@ async function clearDead(file, text) {
|
|
|
205
296
|
}
|
|
206
297
|
}
|
|
207
298
|
|
|
208
|
-
|
|
299
|
+
// The item's branch, as the integrator reads it: not HEAD, which a session cut off mid-rebase
|
|
300
|
+
// leaves detached.
|
|
301
|
+
const branchRef = (id) => `refs/heads/work/${id}`;
|
|
302
|
+
|
|
303
|
+
/** Whether the tip of the item's branch carries `GCF-Verified: <id>`. */
|
|
209
304
|
function verified(dir, id) {
|
|
210
|
-
const
|
|
305
|
+
const format = '--format=%(trailers:key=GCF-Verified,valueonly)';
|
|
306
|
+
const res = spawnSync('git', ['log', '-1', format, branchRef(id)], {
|
|
211
307
|
cwd: dir,
|
|
212
308
|
encoding: 'utf8',
|
|
213
309
|
});
|
|
@@ -215,20 +311,50 @@ function verified(dir, id) {
|
|
|
215
311
|
return (res.stdout ?? '').split('\n').some((v) => v.trim() === id);
|
|
216
312
|
}
|
|
217
313
|
|
|
314
|
+
// Not a slug, so no item: the lock a run gates its batch and claims its items under.
|
|
315
|
+
const ADMIT = '.admit';
|
|
316
|
+
|
|
218
317
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
318
|
+
* `fn` under the admission lock: two runs started at once (two board clicks) would each pass a
|
|
319
|
+
* gate that sees no lock of the other yet, and run together what the gate keeps apart.
|
|
221
320
|
*/
|
|
222
|
-
async function
|
|
223
|
-
const
|
|
321
|
+
async function admitted(logs, fn) {
|
|
322
|
+
const until = Date.now() + 60_000;
|
|
323
|
+
let release = await claim(logs, ADMIT);
|
|
324
|
+
while (!release) {
|
|
325
|
+
if (Date.now() > until)
|
|
326
|
+
throw new Error(`another run is starting (${ADMIT}.lock in ${logs}): retry when it ends`);
|
|
327
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
328
|
+
release = await claim(logs, ADMIT);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
return await fn();
|
|
333
|
+
} finally {
|
|
334
|
+
await release();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Every item claimed for this run, or an error when another run drives it. */
|
|
339
|
+
async function claimAll(ids, logs) {
|
|
340
|
+
const claimed = [];
|
|
224
341
|
const failed = [];
|
|
225
342
|
for (const id of ids) {
|
|
226
343
|
const release = await claim(logs, id);
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
344
|
+
if (release) claimed.push({ id, release });
|
|
345
|
+
else failed.push({ id, result: 'error', error: `${id} is already running in another run` });
|
|
346
|
+
}
|
|
231
347
|
|
|
348
|
+
return { claimed, failed };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The worktree and the agent command of each claimed item; an item that cannot start is an
|
|
353
|
+
* error. `agentOf(id)` gives the command.
|
|
354
|
+
*/
|
|
355
|
+
async function prepare({ claimed, failed }, core, agentOf) {
|
|
356
|
+
const ready = [];
|
|
357
|
+
for (const { id, release } of claimed) {
|
|
232
358
|
try {
|
|
233
359
|
const agent = agentOf(id);
|
|
234
360
|
const wt = json(await core(['worktree', id, '--json']), `worktree ${id}`);
|
|
@@ -243,14 +369,98 @@ async function prepare(ids, core, logs, agentOf) {
|
|
|
243
369
|
return { ready, failed };
|
|
244
370
|
}
|
|
245
371
|
|
|
372
|
+
/** `<id>.agent.json`: the agent session an item's last run left unfinished, for a rerun. */
|
|
373
|
+
const recordOf = (logs, id) => path.join(logs, `${id}.agent.json`);
|
|
374
|
+
|
|
375
|
+
// A session id goes into the agent's shell command: a UUID, nothing else.
|
|
376
|
+
const SESSION_ID = /^[0-9a-f-]{36}$/i;
|
|
377
|
+
|
|
378
|
+
/** The kept session of an item, or null: a file that is not a record is none. */
|
|
379
|
+
async function kept(logs, id) {
|
|
380
|
+
try {
|
|
381
|
+
const rec = JSON.parse(await readFile(recordOf(logs, id), 'utf8'));
|
|
382
|
+
|
|
383
|
+
return SESSION_ID.test(String(rec?.session_id)) ? rec : null;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The record of a session that ended with its item still ready: a rerun resumes it with all the
|
|
391
|
+
* agent read and decided. The item's move ends the session for good; an error leaves it as is.
|
|
392
|
+
*/
|
|
393
|
+
async function remember(logs, r, item) {
|
|
394
|
+
if (item.result === 'error') return;
|
|
395
|
+
if (!STAYED.test(item.result) || r.lost) return rm(recordOf(logs, r.id), { force: true });
|
|
396
|
+
if (!r.session_id) return;
|
|
397
|
+
const ended = r.limit ? 'limit' : r.code === 'timeout' ? 'timeout' : `exit ${r.code}`;
|
|
398
|
+
const rec = {
|
|
399
|
+
session_id: r.session_id,
|
|
400
|
+
ended,
|
|
401
|
+
...(r.limit?.resets ? { resets: r.limit.resets } : {}),
|
|
402
|
+
at: new Date().toISOString(),
|
|
403
|
+
};
|
|
404
|
+
await writeFile(recordOf(logs, r.id), `${JSON.stringify(rec, null, 2)}\n`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The line of a session that ended with its item still ready: why, and what a rerun does. A
|
|
409
|
+
* usage limit pauses the item; `limit_resets` tells a board when it may go on.
|
|
410
|
+
*/
|
|
411
|
+
function noResult(r, rerun) {
|
|
412
|
+
if (r.lost)
|
|
413
|
+
return {
|
|
414
|
+
id: r.id,
|
|
415
|
+
result:
|
|
416
|
+
`no result (its session could not be resumed, agent exit ${r.code}): ` +
|
|
417
|
+
`${r.id} stays ready, a rerun starts fresh`,
|
|
418
|
+
};
|
|
419
|
+
const again = r.session_id ? 'a rerun resumes its session' : rerun;
|
|
420
|
+
if (r.limit) {
|
|
421
|
+
const resets = r.limit.resets ? `, resets ${r.limit.resets}` : '';
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
id: r.id,
|
|
425
|
+
result: `paused (usage limit${resets}): ${r.id} stays ready, ${again}`,
|
|
426
|
+
limit_resets: r.limit.resets,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
|
|
431
|
+
|
|
432
|
+
return { id: r.id, result: `no result (${why}): ${r.id} stays ready, ${again}` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const STAYED = / stays ready, a rerun /;
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The agent command and prompt of an item's session: `/gcf-run`, or the resume of the session
|
|
439
|
+
* its last run kept (`--resume`, in the same directory, so the agent finds it) unless `fresh`.
|
|
440
|
+
*/
|
|
441
|
+
async function commandOf(r, logs, fresh) {
|
|
442
|
+
const task = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
|
|
443
|
+
if (fresh) await rm(recordOf(logs, r.id), { force: true });
|
|
444
|
+
// `--resume` is a flag of `claude`: another agent gets the task, and the record stays.
|
|
445
|
+
const rec = fresh || !/^claude(\s|$)/.test(r.agent) ? null : await kept(logs, r.id);
|
|
446
|
+
if (!rec) return { agent: r.agent, prompt: task, rec };
|
|
447
|
+
// One plain line: the prompt goes into the shell command in double quotes.
|
|
448
|
+
const ended = String(rec.ended).replace(/[^\w -]/g, '');
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
agent: `${r.agent} --resume ${rec.session_id}`,
|
|
452
|
+
prompt: `Continue ${task}: the session was cut off (${ended}). Pick up where you stopped.`,
|
|
453
|
+
rec,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
246
457
|
/**
|
|
247
458
|
* One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
|
|
248
459
|
* its worktree mode; on main it records the item's move itself.
|
|
249
460
|
*/
|
|
250
|
-
async function session(r, logs, timeoutMs) {
|
|
461
|
+
async function session(r, logs, timeoutMs, fresh) {
|
|
251
462
|
/** @type {import('node:fs/promises').FileHandle | null} */
|
|
252
463
|
let log = null;
|
|
253
|
-
const prompt = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
|
|
254
464
|
// The lock names the item and its session: `stop <id>` ends that session alone, not the run.
|
|
255
465
|
const lock = path.join(logs, `${r.main ? MAIN_RUN : r.id}.lock`);
|
|
256
466
|
const onSpawn = (pid) => writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
|
|
@@ -258,10 +468,22 @@ async function session(r, logs, timeoutMs) {
|
|
|
258
468
|
const mark = path.join(logs, `${r.id}.session`);
|
|
259
469
|
const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
|
|
260
470
|
try {
|
|
471
|
+
const { agent, prompt, rec } = await commandOf(r, logs, fresh);
|
|
261
472
|
log = await open(path.join(logs, `${r.id}.log`), 'a');
|
|
262
473
|
const opts = { fd: log.fd, timeoutMs, id: r.id, onSpawn };
|
|
474
|
+
const ran = await startAgent(agent, prompt, r.dir, opts);
|
|
475
|
+
|
|
476
|
+
// A resume that claude answered with `No conversation found`: the session is gone, and the
|
|
477
|
+
// next run starts fresh rather than fail on it again. Any other early end (a stop, a crash,
|
|
478
|
+
// no network) keeps it for the next try.
|
|
479
|
+
const lost = Boolean(rec) && ran.gone;
|
|
263
480
|
|
|
264
|
-
return {
|
|
481
|
+
return {
|
|
482
|
+
...r,
|
|
483
|
+
...ran,
|
|
484
|
+
session_id: ran.session_id ?? (lost ? undefined : rec?.session_id),
|
|
485
|
+
lost,
|
|
486
|
+
};
|
|
265
487
|
} catch (e) {
|
|
266
488
|
return { ...r, code: null, error: e.message };
|
|
267
489
|
} finally {
|
|
@@ -287,8 +509,11 @@ function touch(file) {
|
|
|
287
509
|
*/
|
|
288
510
|
async function recordBlock(r, outcome, core) {
|
|
289
511
|
const reason = String(outcome.block);
|
|
290
|
-
|
|
291
|
-
|
|
512
|
+
// A block recorded over a trailer that stayed would see the branch merged after its unblock,
|
|
513
|
+
// without the answer: the operator gets the error instead.
|
|
514
|
+
const failed = unverify(r);
|
|
515
|
+
if (failed)
|
|
516
|
+
return { id: r.id, result: 'error', reason, error: `${failed}; the block is not recorded` };
|
|
292
517
|
const b = await core(['block', r.id, '--reason', reason]);
|
|
293
518
|
|
|
294
519
|
return {
|
|
@@ -299,18 +524,42 @@ async function recordBlock(r, outcome, core) {
|
|
|
299
524
|
};
|
|
300
525
|
}
|
|
301
526
|
|
|
527
|
+
// The bookkeeping commit's author when the worktree has none: the trailer must come off anyway.
|
|
528
|
+
const GCF_IDENT = {
|
|
529
|
+
GIT_AUTHOR_NAME: 'gcf',
|
|
530
|
+
GIT_AUTHOR_EMAIL: 'gcf@localhost',
|
|
531
|
+
GIT_COMMITTER_NAME: 'gcf',
|
|
532
|
+
GIT_COMMITTER_EMAIL: 'gcf@localhost',
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* An empty commit takes the trailer off the branch tip: no integrate merges it. Plumbing, not
|
|
537
|
+
* `git commit`: it never touches the index, so what a killed agent staged, its index.lock or a
|
|
538
|
+
* merge it left half done cannot stop it.
|
|
539
|
+
*/
|
|
540
|
+
function unverify(r) {
|
|
541
|
+
if (!verified(r.dir, r.id)) return null;
|
|
542
|
+
const git = (args, env = {}) =>
|
|
543
|
+
spawnSync('git', args, { cwd: r.dir, encoding: 'utf8', env: { ...process.env, ...env } });
|
|
544
|
+
const ref = branchRef(r.id);
|
|
545
|
+
const tip = git(['rev-parse', ref]).stdout.trim();
|
|
546
|
+
const tree = ['commit-tree', `${ref}^{tree}`, '-p', ref, '-m', `gcf(unverified): ${r.id}`];
|
|
547
|
+
let made = git(tree);
|
|
548
|
+
if (made.status !== 0) made = git(tree, GCF_IDENT);
|
|
549
|
+
const moved = made.status === 0 && git(['update-ref', ref, made.stdout.trim(), tip]);
|
|
550
|
+
|
|
551
|
+
// shortcut: the integrate after it still merges a branch whose trailer stayed; the report
|
|
552
|
+
// says so, upgrade to integrating only the named branches if it ever happens.
|
|
553
|
+
return moved && moved.status === 0
|
|
554
|
+
? null
|
|
555
|
+
: `could not take its trailer off: ${(moved || made).stderr.trim()}`;
|
|
556
|
+
}
|
|
557
|
+
|
|
302
558
|
/** The result line of an item the integrator handled, or why it has none. */
|
|
303
559
|
function resultOf(run, integrated) {
|
|
304
560
|
const done = integrated.find((x) => x.id === run.id);
|
|
305
561
|
if (!done && run.error) return { id: run.id, result: 'error', error: run.error };
|
|
306
|
-
if (!done)
|
|
307
|
-
const why = run.code === 'timeout' ? 'the session timed out' : `agent exit ${run.code}`;
|
|
308
|
-
|
|
309
|
-
return {
|
|
310
|
-
id: run.id,
|
|
311
|
-
result: `no result (${why}): ${run.id} stays ready, a rerun resumes its worktree`,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
562
|
+
if (!done) return noResult(run, 'a rerun resumes its worktree');
|
|
314
563
|
|
|
315
564
|
return {
|
|
316
565
|
...done,
|
|
@@ -336,9 +585,8 @@ async function mainResult(core, r) {
|
|
|
336
585
|
if (!after) return { id: r.id, result: 'close' };
|
|
337
586
|
if (after.status !== 'ready')
|
|
338
587
|
return { id: r.id, result: MOVE_OF[after.status] ?? after.status, reason: after.blocked };
|
|
339
|
-
const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
|
|
340
588
|
|
|
341
|
-
return
|
|
589
|
+
return noResult(r, 'a rerun picks it up');
|
|
342
590
|
}
|
|
343
591
|
|
|
344
592
|
// The lock of the main checkout itself: one run works there at a time. Not a slug, so no item.
|
|
@@ -350,7 +598,8 @@ export const MAIN_RUN = '.main-run';
|
|
|
350
598
|
* carries a session mark (`gcf claim`), not a run lock: a lock would refuse the agent's own
|
|
351
599
|
* moves. A session that stopped without a move leaves no mark behind.
|
|
352
600
|
*/
|
|
353
|
-
async function runMain(projectDir, ids,
|
|
601
|
+
async function runMain(projectDir, ids, ctx) {
|
|
602
|
+
const { logs } = ctx;
|
|
354
603
|
const releaseMain = await claim(logs, MAIN_RUN);
|
|
355
604
|
if (!releaseMain)
|
|
356
605
|
return ids.map((id) => ({
|
|
@@ -361,8 +610,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
|
|
|
361
610
|
|
|
362
611
|
const items = [];
|
|
363
612
|
try {
|
|
364
|
-
for (const id of ids)
|
|
365
|
-
items.push(await mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }));
|
|
613
|
+
for (const id of ids) items.push(await mainItem(projectDir, id, ctx));
|
|
366
614
|
} finally {
|
|
367
615
|
await releaseMain();
|
|
368
616
|
}
|
|
@@ -371,7 +619,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
|
|
|
371
619
|
}
|
|
372
620
|
|
|
373
621
|
/** One item of a main run: marked, its session, its mark cleared, its result read. */
|
|
374
|
-
async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
|
|
622
|
+
async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs, fresh }) {
|
|
375
623
|
let r;
|
|
376
624
|
try {
|
|
377
625
|
const claimed = await core(['claim', id]);
|
|
@@ -379,36 +627,51 @@ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
|
|
|
379
627
|
const none = async () => {};
|
|
380
628
|
|
|
381
629
|
const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true };
|
|
382
|
-
r = await session(s, logs, timeoutMs);
|
|
630
|
+
r = await session(s, logs, timeoutMs, fresh);
|
|
383
631
|
} catch (e) {
|
|
384
632
|
r = { id, error: e.message };
|
|
385
633
|
}
|
|
386
634
|
|
|
387
635
|
await core(['release', id]);
|
|
636
|
+
const item = await mainResult(core, r);
|
|
637
|
+
await remember(logs, r, item);
|
|
388
638
|
|
|
389
|
-
return
|
|
639
|
+
return { ...item, ...r.cost };
|
|
390
640
|
}
|
|
391
641
|
|
|
392
|
-
/** Every item in its
|
|
393
|
-
async function runWorktrees(
|
|
394
|
-
const { ready, failed } = await prepare(
|
|
395
|
-
const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
|
|
642
|
+
/** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
|
|
643
|
+
async function runWorktrees(claims, { core, logs, agentOf, timeoutMs, fresh }) {
|
|
644
|
+
const { ready, failed } = await prepare(claims, core, agentOf);
|
|
645
|
+
const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs, fresh)));
|
|
396
646
|
const items = [...failed];
|
|
397
647
|
for (const r of runs) {
|
|
398
648
|
const outcome = await outcomeOf(r.dir);
|
|
399
649
|
if (outcome?.block) items.push(await recordBlock(r, outcome, core));
|
|
650
|
+
// A session cut short (timeout, `stop`, a crash) may have meant to block after its trailer:
|
|
651
|
+
// its item stays ready, and the rerun verifies its branch again.
|
|
652
|
+
else if (r.code !== 0) {
|
|
653
|
+
const failed = unverify(r);
|
|
654
|
+
if (failed) items.push({ id: r.id, result: 'error', error: failed });
|
|
655
|
+
}
|
|
400
656
|
}
|
|
401
657
|
|
|
402
658
|
const integrated = json(await core(['integrate', '--json']), 'integrate');
|
|
403
659
|
for (const r of runs) if (!items.some((i) => i.id === r.id)) items.push(resultOf(r, integrated));
|
|
660
|
+
for (const r of runs)
|
|
661
|
+
await remember(
|
|
662
|
+
logs,
|
|
663
|
+
r,
|
|
664
|
+
items.find((i) => i.id === r.id),
|
|
665
|
+
);
|
|
404
666
|
|
|
405
|
-
|
|
667
|
+
// What each session spent rides on its item's line, when the agent reported it.
|
|
668
|
+
return items.map((i) => ({ ...i, ...runs.find((r) => r.id === i.id)?.cost }));
|
|
406
669
|
}
|
|
407
670
|
|
|
408
671
|
/**
|
|
409
672
|
* Pick up orphans, gate the batch, run the items in the project's run mode (`mode` overrides
|
|
410
673
|
* it). `gate(ids)` answers `{pass, reason}`; a session past `timeoutMs` is stopped with all it
|
|
411
|
-
* started, and its item stays ready.
|
|
674
|
+
* started, and its item stays ready. `fresh` starts new sessions where kept ones would resume.
|
|
412
675
|
*/
|
|
413
676
|
export async function run(
|
|
414
677
|
projectDir,
|
|
@@ -419,29 +682,39 @@ export async function run(
|
|
|
419
682
|
gate,
|
|
420
683
|
timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
|
|
421
684
|
mode = undefined,
|
|
685
|
+
fresh = false,
|
|
422
686
|
},
|
|
423
687
|
) {
|
|
424
688
|
// An id names a lock file and goes into the agent's shell command: a slug, nothing else.
|
|
425
689
|
const bad = ids.find((id) => !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id));
|
|
426
690
|
if (bad) throw new Error(`${bad} is not an item id (a lowercase hyphen slug)`);
|
|
427
691
|
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
692
|
const logs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
|
|
432
693
|
await mkdir(logs, { recursive: true });
|
|
433
694
|
const board = json(await core(['board', '--json']), 'board');
|
|
434
695
|
const runMode = mode ?? board.run_mode ?? 'worktree';
|
|
696
|
+
// The gate counts the items other runs hold locks on, so this run's locks go in with its gate.
|
|
697
|
+
// shortcut: a main run marks each item only as its session starts, so the items it has yet
|
|
698
|
+
// to reach do not count for another run's gate; claim them all here if main and worktree
|
|
699
|
+
// runs come to share a project.
|
|
700
|
+
const { passed, claims } = await admitted(logs, async () => {
|
|
701
|
+
const verdict = gate(ids);
|
|
702
|
+
const go = verdict.pass && runMode !== 'main';
|
|
703
|
+
|
|
704
|
+
return { passed: verdict, claims: go ? await claimAll(ids, logs) : null };
|
|
705
|
+
});
|
|
706
|
+
if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
|
|
707
|
+
|
|
435
708
|
const agentOf = (id) =>
|
|
436
709
|
agentFor(
|
|
437
710
|
agent,
|
|
438
711
|
(board.items ?? []).find((i) => i.id === id),
|
|
439
712
|
);
|
|
440
|
-
const ctx = { core, logs, agentOf, timeoutMs };
|
|
713
|
+
const ctx = { core, logs, agentOf, timeoutMs, fresh };
|
|
441
714
|
const unhook = stopWithSessions();
|
|
442
715
|
try {
|
|
443
716
|
const items =
|
|
444
|
-
runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(
|
|
717
|
+
runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(claims, ctx);
|
|
445
718
|
|
|
446
719
|
return { orphans, mode: runMode, items };
|
|
447
720
|
} 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.
|
|
4
|
+
"version": "0.8.7",
|
|
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
package/workflows/ingest.js
CHANGED
|
@@ -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
|
-
|
|
228
|
-
|
|
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
|
|