gcf-harness-aif-ext 0.9.0 → 0.9.2

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 *)`, 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.
36
+ It is **per project**, not global — run it again in every game repository: it adds the readset-guard and implement-guard entries 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
 
@@ -47,6 +47,18 @@ 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.9.2 holds `/gcf-run` to aif-implement. Run `ai-factory gcf-harness init` again after the update: it registers the new hook.
51
+ - The implement guard: in a session that runs `/gcf-run`, a write to a code file is denied until the session invokes the aif-implement skill (gcf-run step 13), and so is a plan tick, which only aif-implement sets. Planning stays free: `gcf/`, `.ai-factory/`, `.gcf-session/` and any markdown file. After aif-implement every write is free, the fixes of a red DoD and of the review included. It works in the harness's runs and in an interactive `/gcf-run` alike, and touches no other session.
52
+ - `/gcf-run` converges a plan when an improve pass finds nothing blocking: a wrong or missing behavior or task, a broken pillar invariant, a rule conflict, a task no check can prove. Polish (wording, splitting tasks, order) is applied, but no longer keeps the loop going. Before, a pass that changed any task asked for one more, and a fresh reviewer almost always changes something. The report row per pass gives the blocking and polish counts.
53
+ - `/gcf-run` answers aif-plan's own questions (tests, logging, docs) itself, from the item, its DoD and the project's rules.
54
+ - The run report marks an item whose session wrote code without aif-implement: `— code written outside aif-implement` on its line, `process` in `--json` and in its kept record. It reads the session's own events, its subagents' included, so it also covers a project without the guard registered. A write through Bash is seen by neither: the review of the diff is the check there.
55
+ - Lessons (with core 0.7.10): after each agent session the run asks the same session, resumed, what got in its way - tools, rules, the framework, the project template - with the questions of `ai-factory gcf lessons prompt`. Its answers go to the inbox on main as lessons (`gcf lessons add`), and the board shows them on the card, in the drawer and in the inbox. The run passes `--no-lessons` to `/gcf-run`, so the agent carries no lessons instruction while it works. The turn follows a result, a handoff (before the fresh session), a timeout or an error; not a usage limit or a stop, and only for a `claude` agent with a session id. Another agent asks its lessons itself at the end of a run on main; in a worktree it records none. It takes up to 10 minutes; its cost goes on the item, and the report line says `, N lessons`. A failed lessons turn is logged and changes nothing else. A project that turns lessons off, or a core before 0.7.10, gets no turn and no flag.
56
+
57
+ 0.9.1 is a fix release:
58
+ - A stopped session reads as `no result (stopped)`, and its kept record as `ended: stopped`: before, the kill's exit code (`agent exit 1` on Windows).
59
+ - `gcf-harness stop <id>` never kills the run: a stop before the item's session starts (or between two sessions of a handoff) leaves `.gcf-session/runs/<id>.stop`, and that session is stopped as it starts. Before, a stop then killed the whole run with the other items of its batch. A run hung before its first session (in its worktree setup) is not stopped. On main, a stop marks the item too once the run has reached it. Known limit: a stop in the moment between a worktree run's claim of the item and its clearing of an old mark is lost.
60
+ - A stop that comes while the session starts no longer misses it.
61
+
50
62
  0.9.0, to update together with core 0.7.6:
51
63
  - The harness's own agent runs with `--permission-mode bypassPermissions`: it may do everything but the project's deny rules, which hold in every mode. The rules come from the deny groups of `gcf/config.yml` (`ai-factory gcf permissions`, or the board): git history and uncommitted work, recursive delete, publishing, the human's GCF moves and the guards are on by default; secrets, changes outside the project and killing processes are off. `git push` is allowed. Deny rules match the command as written: they catch accidents, not a rewording, and `/gcf-run` makes a refused command a stop with `needs permission: <command>`.
52
64
  - A run loads the project's settings and MCP servers only (`--setting-sources project,local --strict-mcp-config --mcp-config .mcp.json`): the operator's plugins, hooks and claude.ai connectors stay out. So does `env` or `apiKeyHelper` in `~/.claude/settings.json`: an operator who logs in through them, not `claude login`, names the agent with `--agent`. The deny rules and the handoff guard come in `.gcf-session/runs/agent-settings.json` (`--settings`), so a worktree gets them without a commit.
@@ -77,11 +89,11 @@ To remove the extension:
77
89
  ai-factory extension remove gcf-harness-aif-ext
78
90
  ```
79
91
 
80
- Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry whose command ends in `# gcf-readset-guard` by hand, or every Read, Grep and Glob will report a hook error.
92
+ Removal does not touch `.claude/settings.json`: delete the entries whose command ends in `# gcf-readset-guard` or `# gcf-implement-guard` (under `PreToolUse` and `UserPromptSubmit`) by hand, or the tools they match will report a hook error.
81
93
 
82
94
  ## What it adds
83
95
 
84
- **`/gcf-run <item-id…>`** — drives work items through plan, implementation and DoD, with every gate checked *before* the tokens are spent: the audit ritual, the gate below, the context budget. The plan is written by `aif-plan`, then improved in passes, each by a fresh subagent that reads only the plan and the readset and records every default it picks; `ai-factory gcf plan check <id>` runs after each pass, and a pass that changes no task ends the loop. A plan still changing after `plan_passes` passes, or a question only the human can answer (a save format, a public API, a new dependency, a pillar conflict), blocks the item before any code is written. The DoD is `ai-factory gcf verify <id>` — the gate's checks, the item's `live:` delta, and the project's `dod.build` and `dod.test` commands from `gcf/config.yml` — plus a review by a fresh subagent, up to `dod_attempts` attempts. Every item ends in a state the core records: `gcf submit` (gate `verdict` or `decision` — waits for the human), `gcf close` (gate `auto` — done, or a soft unit closed as `unplayed`) or `gcf block` with the reason. The verdict and the decision belong to the human: the pipeline stops at `submit` and prints the playtest list.
96
+ **`/gcf-run <item-id…>`** — drives work items through plan, implementation and DoD, with every gate checked *before* the tokens are spent: the audit ritual, the gate below, the context budget. The plan is written by `aif-plan`, then improved in passes, each by a fresh subagent that reads only the plan and the readset and records every default it picks; `ai-factory gcf plan check <id>` runs after each pass, and a pass with no blocking finding (a wrong or missing behavior or task, a broken pillar invariant, a rule conflict, an unprovable task) ends the loop. A blocking finding left after `plan_passes` passes (3 by default), or a question only the human can answer (a save format, a public API, a new dependency, a pillar conflict), blocks the item before any code is written. The DoD is `ai-factory gcf verify <id>` — the gate's checks, the item's `live:` delta, and the project's `dod.build` and `dod.test` commands from `gcf/config.yml` — plus a review by a fresh subagent, up to `dod_attempts` attempts. Every item ends in a state the core records: `gcf submit` (gate `verdict` or `decision` — waits for the human), `gcf close` (gate `auto` — done, or a soft unit closed as `unplayed`) or `gcf block` with the reason. The verdict and the decision belong to the human: the pipeline stops at `submit` and prints the playtest list.
85
97
 
86
98
  **`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.
87
99
 
@@ -91,6 +103,8 @@ Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry wh
91
103
 
92
104
  **readset-guard** — a `PreToolUse` hook on Read/Grep/Glob. While a run manifest exists at `.gcf-session/readset.json`, a path inside `gcf/` that is not in the readset is denied, and so is a Grep from the project root (no `path`, or a `path` above `gcf/`) unless its `glob` starts with a literal directory other than `gcf` (`src/**`, not `**/*.md`), because such a content search would read the whole tree past the gate. Glob and Greps scoped to the game code are never restricted. No manifest means no restriction: manual work is never gated. An honest limit — `cat` through Bash still bypasses it. This is a discipline tool, not a security perimeter.
93
105
 
106
+ **implement-guard** — a `UserPromptSubmit` and `PreToolUse` hook (Skill, the write tools, Bash and PowerShell). A session that starts `/gcf-run` (typed, passed to `claude -p`, or invoked as a skill) gets a state in `.gcf-session/guard/<session_id>.json`; until it invokes the aif-implement skill, Edit, Write, MultiEdit and NotebookEdit are denied outside `gcf/`, `.ai-factory/`, `.gcf-session/` and markdown, and a markdown write that adds a `- [x]` tick is denied too. Any other session is never touched, and an error lets the call through. The move that ends the item (`gcf block`, `submit` or `close`, through Bash or PowerShell) lets the session go, so the operator's own edits after a blocked run are free again. A plugin's skill (`<plugin>:aif-implement`) counts too. Limits: the flag is per session, so in a batch the first item's aif-implement frees the rest; a write through Bash is not seen, by the run report either.
107
+
94
108
  ## Docs
95
109
 
96
110
  [Harness-PRD](https://github.com/VirtualMaestro/GCF/blob/main/docs/Harness-PRD.md) is the spec: pipeline stages, gates, execution surfaces, the guard's contract.
@@ -109,10 +109,13 @@ function limitIn(text, whole = true) {
109
109
  * @param {string} line
110
110
  * The main agent's turns also carry `context`, the tokens its window holds (a subagent's turns,
111
111
  * `parent_tool_use_id` set, fill a window of their own); the init names the `model`, the result
112
- * the `windows` of its models, and a compaction says `compacted`.
112
+ * the `windows` of its models, and a compaction says `compacted`. A turn's tool calls give
113
+ * `implement` (the aif-implement skill) and `writes` (the files written, by call id); a tool
114
+ * result gives `failed`, the calls that wrote nothing.
113
115
  * @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null},
114
116
  * context?: number, turn?: string, model?: string, windows?: Record<string, number>,
115
- * compacted?: boolean}}
117
+ * compacted?: boolean, implement?: boolean, writes?: {id: string, file: string}[],
118
+ * failed?: string[]}}
116
119
  */
117
120
  export function activity(line, now = new Date()) {
118
121
  let e;
@@ -128,12 +131,18 @@ export function activity(line, now = new Date()) {
128
131
  const content = Array.isArray(e.message?.content) ? e.message.content : [];
129
132
  if (e.type === 'system') return system(e, at);
130
133
  if (e.type === 'assistant') return { ...said(content, at), ...contextOf(e) };
131
- if (e.type === 'user')
134
+ if (e.type === 'user') {
135
+ const errors = content.filter((b) => b.type === 'tool_result' && b.is_error);
136
+
132
137
  return {
133
- lines: content
134
- .filter((b) => b.type === 'tool_result' && b.is_error)
135
- .map((b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`),
138
+ lines: errors.map(
139
+ (b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`,
140
+ ),
141
+ // A refused or failed call wrote nothing: its write does not count.
142
+ ...(errors.length ? { failed: errors.map((b) => String(b.tool_use_id)) } : {}),
136
143
  };
144
+ }
145
+
137
146
  if (e.type === 'result') return ended(e, at);
138
147
 
139
148
  return { lines: [] };
@@ -167,12 +176,32 @@ function contextOf(e) {
167
176
  return { context: tokens, ...(e.message.id ? { turn: String(e.message.id) } : {}) };
168
177
  }
169
178
 
179
+ const WRITES = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
180
+
181
+ /**
182
+ * What a turn's tool calls tell of gcf-run step 13: `implement` when one invokes the aif-implement
183
+ * skill, `writes` the files its write tools name. A subagent's turns count too.
184
+ */
185
+ function steps(uses) {
186
+ const writes = uses
187
+ .filter((u) => WRITES.includes(u.name))
188
+ .map((u) => ({ id: String(u.id), file: u.input?.file_path ?? u.input?.notebook_path }))
189
+ .filter((w) => typeof w.file === 'string');
190
+ // A plugin's skill comes as <plugin>:aif-implement.
191
+ const implement = uses.some(
192
+ (u) => u.name === 'Skill' && /(^|:)aif-implement$/.test(String(u.input?.skill ?? '')),
193
+ );
194
+
195
+ return { ...(implement ? { implement } : {}), ...(writes.length ? { writes } : {}) };
196
+ }
197
+
170
198
  /** An assistant turn: its tool calls and its text, which may be the usage-limit message. */
171
199
  function said(content, at) {
172
200
  const texts = content.filter((b) => b.type === 'text').map((b) => b.text);
173
201
 
174
202
  return {
175
203
  ...(texts.map((t) => limitIn(t)).find((l) => l.limit) ?? {}),
204
+ ...steps(content.filter((b) => b.type === 'tool_use')),
176
205
  lines: content.flatMap((b) => {
177
206
  if (b.type === 'tool_use') return [`${at} ${toolLine(b)}`];
178
207
  const text = b.type === 'text' ? cut(firstLines(b.text, 2), 300) : '';
@@ -192,11 +221,13 @@ function ended(e, at) {
192
221
 
193
222
  // The window of each model the session used: its subagents may run on another.
194
223
  const windows = Object.entries(e.modelUsage ?? {})
195
- .map(([m, u]) => [m, Number(u?.contextWindow)])
224
+ .map(([m, u]) => /** @type {[string, number]} */ ([m, Number(u?.contextWindow)]))
196
225
  .filter(([, n]) => n > 0);
197
226
 
198
227
  return {
199
228
  lines: [...text, `${at} ${summary(e, cost, Boolean(limit.limit))}`],
229
+ // The reply itself: a lessons turn answers with its JSON here.
230
+ ...(typeof e.result === 'string' ? { result: e.result } : {}),
200
231
  cost,
201
232
  ...limit,
202
233
  ...(windows.length ? { windows: Object.fromEntries(windows) } : {}),
@@ -1,4 +1,4 @@
1
- // `ai-factory gcf-harness init` — registers the readset-guard PreToolUse hook in the
1
+ // `ai-factory gcf-harness init` — registers the readset-guard and implement-guard hooks in the
2
2
  // project's .claude/settings.json (the AIF manifest cannot install Claude Code hooks —
3
3
  // same decision as gcf hooks-install for git hooks). Idempotent by marker comment. It also
4
4
  // allows what a headless agent run needs (AGENT_ALLOW).
@@ -11,7 +11,6 @@ import { fileURLToPath } from 'node:url';
11
11
  import { run, cliCore, DEFAULT_AGENT, DEFAULT_TIMEOUT_MIN, MAIN_RUN, stopOf } from './run.js';
12
12
 
13
13
  const EXT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
14
- const MARKER = 'gcf-readset-guard';
15
14
  const SCHEMA = 1; // the only gcf/config.yml schema this harness drives
16
15
  const SESSION_RULE = /^(\/|\*\*\/)?\.gcf-session\/?$/;
17
16
 
@@ -185,20 +184,62 @@ async function readSettings(settingsFile) {
185
184
  }
186
185
  }
187
186
 
187
+ // The hooks `init` registers: the file, the event, its matcher (none for a prompt event).
188
+ /** @type {Array<{name: string, event: string, matcher?: string}>} */
189
+ const GUARDS = [
190
+ { name: 'readset-guard', event: 'PreToolUse', matcher: 'Read|Grep|Glob' },
191
+ {
192
+ name: 'implement-guard',
193
+ event: 'PreToolUse',
194
+ // Bash and PowerShell: a `gcf block|submit|close` ends the run, and the guard lets go.
195
+ matcher: 'Skill|Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell',
196
+ },
197
+ { name: 'implement-guard', event: 'UserPromptSubmit' },
198
+ ];
199
+
200
+ const hooksOf = (e) => (Array.isArray(e?.hooks) ? e.hooks : []);
201
+
202
+ /**
203
+ * A newer version may watch more tools. An entry of ours alone takes the new matcher; one we
204
+ * share with the operator's own hooks keeps theirs, and ours moves to an entry of its own, at
205
+ * most one: a hook copied into several entries, or one already moved, makes no second. Returns
206
+ * how many entries changed.
207
+ */
208
+ function widen(list, matcher, ours, command) {
209
+ const before = JSON.stringify(list);
210
+ const holding = list.filter((x) => hooksOf(x).some((h) => ours.includes(h)));
211
+ const alone = (e) => e.hooks.every((h) => ours.includes(h));
212
+ // Ours alone, the right matcher first; else a new one. It keeps one hook of ours, with the
213
+ // operator's own settings on it (a timeout).
214
+ let own = holding.find((e) => e.matcher === matcher && alone(e)) ?? holding.find(alone);
215
+ if (own) own.hooks = own.hooks.slice(0, 1);
216
+ else list.push((own = { hooks: [{ ...ours[0], command }] }));
217
+ own.matcher = matcher;
218
+ for (const e of holding) if (e !== own) e.hooks = e.hooks.filter((h) => !ours.includes(h));
219
+ // An entry that held only ours is empty now: it goes.
220
+ for (const e of holding) if (e !== own && !e.hooks.length) list.splice(list.indexOf(e), 1);
221
+
222
+ return JSON.stringify(list) === before ? 0 : 1;
223
+ }
224
+
188
225
  /**
189
- * Adds the guard entry, or repoints an existing one: a moved or cloned project keeps the old
190
- * absolute path under the marker, and the registered hook would keep launching a missing file.
191
- * Returns what happened, or null when the settings already hold the right command.
226
+ * Adds the guard entry of `event`, or repoints an existing one: a moved or cloned project keeps
227
+ * the old absolute path under the marker, and the registered hook would keep launching a missing
228
+ * file. Returns what happened, or null when the settings already hold the right command.
229
+ * @param {Record<string, any>} settings
230
+ * @param {{event: string, matcher?: string}} guard
231
+ * @param {string} marker
232
+ * @param {string} command
192
233
  */
193
- function registerGuard(settings, command) {
234
+ function registerGuard(settings, { event, matcher }, marker, command) {
194
235
  settings.hooks ??= {};
195
- settings.hooks.PreToolUse ??= [];
196
- const ours = settings.hooks.PreToolUse.flatMap((e) => e?.hooks ?? []).filter(
197
- (h) => typeof h?.command === 'string' && h.command.includes(MARKER),
198
- );
236
+ settings.hooks[event] ??= [];
237
+ const ours = settings.hooks[event]
238
+ .flatMap(hooksOf)
239
+ .filter((h) => typeof h?.command === 'string' && h.command.includes(marker));
199
240
  if (!ours.length) {
200
- settings.hooks.PreToolUse.push({
201
- matcher: 'Read|Grep|Glob',
241
+ settings.hooks[event].push({
242
+ ...(matcher ? { matcher } : {}),
202
243
  hooks: [{ type: 'command', command }],
203
244
  });
204
245
 
@@ -207,8 +248,9 @@ function registerGuard(settings, command) {
207
248
 
208
249
  const stale = ours.filter((h) => h.command !== command);
209
250
  for (const h of stale) h.command = command;
251
+ const widened = matcher ? widen(settings.hooks[event], matcher, ours, command) : 0;
210
252
 
211
- return stale.length ? 'repointed' : null;
253
+ return stale.length || widened ? 'repointed' : null;
212
254
  }
213
255
 
214
256
  // What a headless `claude -p` run needs at least: it cannot ask, so anything not allowed is
@@ -258,18 +300,22 @@ async function init(program) {
258
300
 
259
301
  const projectDir = process.cwd();
260
302
  const settingsFile = path.join(projectDir, '.claude', 'settings.json');
261
- const guard = path.join(EXT_ROOT, 'hooks', 'readset-guard.js').replaceAll('\\', '/');
262
303
  const settings = await readSettings(settingsFile);
263
- const change = registerGuard(settings, `node "${guard}" # ${MARKER}`);
304
+ const changes = GUARDS.map((g) => {
305
+ const file = path.join(EXT_ROOT, 'hooks', `${g.name}.js`).replaceAll('\\', '/');
306
+ const marker = `gcf-${g.name}`;
307
+ const change = registerGuard(settings, g, marker, `node "${file}" # ${marker}`);
308
+
309
+ return change && `${g.name} (${g.event}) ${change}`;
310
+ }).filter(Boolean);
264
311
  const allowed = allowAgent(settings);
265
- if (change || allowed.length) {
312
+ if (changes.length || allowed.length) {
266
313
  await mkdir(path.dirname(settingsFile), { recursive: true });
267
314
  await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
268
315
  }
269
316
 
270
- console.log(
271
- change ? `readset-guard ${change} → ${settingsFile}` : 'readset-guard already registered',
272
- );
317
+ for (const c of changes) console.log(`${c} → ${settingsFile}`);
318
+ if (!changes.length) console.log('readset-guard and implement-guard already registered');
273
319
  if (allowed.length) console.log(`agent runs may now: ${allowed.join(', ')}`);
274
320
 
275
321
  await ignoreSession(projectDir);
@@ -291,7 +337,8 @@ export function register(program) {
291
337
  harness
292
338
  .command('init')
293
339
  .description(
294
- 'Initialize the harness in this project: readset-guard hook, agent permissions (idempotent)',
340
+ 'Initialize the harness in this project: readset and implement guards, agent permissions ' +
341
+ '(idempotent)',
295
342
  )
296
343
  .action(() => init(program));
297
344
 
@@ -329,7 +376,7 @@ export function register(program) {
329
376
  });
330
377
  harness
331
378
  .command('stop <id>')
332
- .description('Stop the run that drives an item, with every session it started; it stays ready')
379
+ .description('Stop the session that works an item; the run goes on, the item stays ready')
333
380
  .action((id) => console.log(stopRun(process.cwd(), id)));
334
381
  }
335
382
 
@@ -360,12 +407,12 @@ function lockOf(file) {
360
407
  }
361
408
  }
362
409
 
363
- /** Kill a process and everything under it: a tree on Windows, its own group elsewhere. */
364
- function killTree(pid, group) {
410
+ /** Kill a session and everything under it: a tree on Windows, its own group elsewhere. */
411
+ function killTree(pid) {
365
412
  if (WIN) spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
366
413
  else {
367
414
  try {
368
- process.kill(group ? -pid : pid, group ? 'SIGKILL' : 'SIGTERM');
415
+ process.kill(-pid, 'SIGKILL');
369
416
  } catch {
370
417
  // Gone already.
371
418
  }
@@ -375,37 +422,44 @@ function killTree(pid, group) {
375
422
  /**
376
423
  * Stop the session that works `id`: the one its own lock names (worktree mode), or the main
377
424
  * checkout's run when that run works this very item (main mode). Only that session goes: the
378
- * run records it as no result and goes on with the rest. A run that has not started a session
379
- * yet goes down whole. The item stays ready and its work in progress is kept.
425
+ * run records it as stopped and goes on with the rest. The item stays ready and its work in
426
+ * progress is kept.
380
427
  */
381
428
  export function stopRun(projectDir, id, core = cliCore(projectDir)) {
382
429
  const runs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
383
- const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
384
- const lock = lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
385
- // Between two sessions of the item (a context handoff) the lock names none: the mark keeps
386
- // the run from starting the next one.
387
- if (lock?.item === id)
388
- writeFileSync(
389
- stopOf(runs, id),
390
- `${Date.now()}
391
- `,
392
- );
393
- if (lock && lock.session && alive(lock.session)) killTree(lock.session, true);
394
- else if (lock && lock.item !== id) killTree(lock.run, false);
430
+ const lockFor = () => {
431
+ const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
432
+
433
+ return lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
434
+ };
435
+
436
+ // The mark says the session ended by a stop, and stops the next one as it starts: the lock
437
+ // names none before the item's first session, or between two of a handoff. Read again after
438
+ // the mark: a session that started meanwhile did not see it.
439
+ const lock = lockFor();
440
+ if (lock) writeFileSync(stopOf(runs, id), `${Date.now()}\n`);
441
+ const session = lock && lockFor()?.session;
442
+ if (session && alive(session)) killTree(session);
395
443
  core(['release', id]);
396
444
 
397
445
  return lock ? `stopped the run of ${id}; it stays ready` : `no run drives ${id}`;
398
446
  }
399
447
 
448
+ /**
449
+ * An item's line of the run report: its result, why, a skipped step of the process, and the
450
+ * lessons its sessions left in the inbox.
451
+ */
452
+ export const itemLine = (i) =>
453
+ [`${i.id}: ${i.result}`, i.reason ?? i.conflict ?? i.error ?? i.skipped, i.process]
454
+ .filter(Boolean)
455
+ .join(' — ') + (i.lessons ? `, ${i.lessons} lesson${i.lessons > 1 ? 's' : ''}` : '');
456
+
400
457
  /** The run report a human reads, then the playtest sheet of what now waits for play. */
401
458
  function printRun(r) {
402
459
  for (const o of r.orphans)
403
460
  console.log(`orphan ${o.id}: ${o.move ?? o.conflict ?? o.error ?? o.skipped}`);
404
461
  if (r.gate) console.error(`gcf gate: ${r.gate}`);
405
- for (const i of r.items) {
406
- const why = i.reason ?? i.conflict ?? i.error ?? i.skipped;
407
- console.log(`${i.id}: ${i.result}${why ? ` — ${why}` : ''}`);
408
- }
462
+ for (const i of r.items) console.log(itemLine(i));
409
463
 
410
464
  if (!r.gate) console.log(`\n${cliCore(process.cwd())(['playtest', 'session']).out.trim()}`);
411
465
  }