@hcflabs/pi-subagent 27.10.8 → 28.0.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/CONTEXT.md CHANGED
@@ -29,7 +29,7 @@ A display and continuation reference for one recorded direct worker, such as `D7
29
29
  A new direct worker that resumes a finished worker's saved Pi conversation in a new tab with a new Direct reference.
30
30
 
31
31
  **Checkout admission**:
32
- The exclusive permission held by a direct write request that prevents Main and other direct writers from writing to the shared checkout. It remains held until worker termination is proven; read classification is not a sandbox.
32
+ The exclusive permission held by a direct write request that prevents Main and other direct writers from writing to the shared checkout. A request with Repository targets holds the checkout of each target. From a non-Git parent directory, a Main call that can write covers the checkout of every Repository target in it. It remains held until worker termination is proven; read classification is not a sandbox.
33
33
 
34
34
  **Isolated request**:
35
35
  A durable goal and dependency graph of text and changeset tasks, with checked results and recovery state.
package/README.md CHANGED
@@ -35,6 +35,7 @@ Commands, prompts, and UI are for you. Tools and the packaged skill are for Main
35
35
  | `/review [target] [focus]` | prompt | Tell Main to review a change with parallel `reviewer` tasks and give one ship verdict. See [Review a change](#review-a-change). |
36
36
  | `/subagent recover` | command | Classify interrupted isolated work in the canonical repository. Send Main request IDs, blockers, and next actions. Requires no UI or Herdr pane. |
37
37
  | `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. With `dryRun: true`, check the request and show the resolved plan without a launch. |
38
+ | `subagent_repos` | tool | List the child Git repositories below the Pi working directory and the `repo` selector of each. Read-only. |
38
39
  | `subagent_status` | tool | Read saved isolated state, exact identities, failed review verdicts, and allowed continuation. Does not replay work. Supports structured output for `codemode`. |
39
40
  | `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation, not staging or integration. Same-worker correction retries accept optional `instruction` text. |
40
41
  | `subagent_stage` | tool | Stage, resolve, reject, or revise an exact candidate. Rejecting a staged candidate freezes its generation. |
@@ -95,7 +96,21 @@ A task packet above 64 KiB uses a private file, including the full previous answ
95
96
 
96
97
  Pi's `read` can retrieve lines of at most 50 KiB (51,200 bytes), with line offsets for more content. If any line is longer, the worker uses `bash`, even when the Role also has `read`. Without `bash`, that task fails before tab creation; use shorter lines or a Role with `bash`. A Role with neither tool also fails before tab creation. Chain context is never cut to the inline answer budget.
97
98
 
98
- To continue a finished worker, add `"continueFrom": "D7.1"` with the same `role`. This opens a new tab with the saved conversation and a new reference. Continue the newest reference from the original checkout and workspace. For eligibility and stop behavior, see [Continue or stop one direct worker](./docs/orchestration.md#continue-or-stop-one-direct-worker).
99
+ ### Repository targets
100
+
101
+ When Pi runs in a parent directory that holds many repositories, set `repo` to select a child repository for a direct task. Use `subagent_repos` to list the selectors. Set `repo` on the request to apply it to all tasks, or on one task to override the request value:
102
+
103
+ ```json
104
+ { "mode": "direct", "repo": "team/service", "role": "scout", "name": "Map sign-in", "task": "Trace sign-in.", "access": "read" }
105
+ ```
106
+
107
+ `repo` is a path relative to the Pi working directory. It must be a Git top-level directory, must not contain `..`, and must not resolve outside that directory. A subdirectory of a repository fails. No tool selects a child repository automatically.
108
+
109
+ The worker opens in the current Herdr workspace with the Repository target as its working directory. Change reports and stale-read checks use that checkout, and each result names its `repo`. A write request reserves the checkout of each target at one time, so writers in different repositories can run together. When Pi runs in a parent directory that is not a Git repository, each Main call that can write covers the whole parent. Such a call waits while a direct writer owns the checkout of any Repository target in it. Main can still read files and use `subagent_repos`.
110
+
111
+ Isolated mode does not accept `repo` yet.
112
+
113
+ To continue a finished worker, add `"continueFrom": "D7.1"` with the same `role`. This opens a new tab with the saved conversation and a new reference. Continue the newest reference from the original checkout and workspace. For a worker that ran with `repo`, select the same `repo`. For eligibility and stop behavior, see [Continue or stop one direct worker](./docs/orchestration.md#continue-or-stop-one-direct-worker).
99
114
 
100
115
  ### Review a change
101
116
 
@@ -138,7 +153,7 @@ Use isolated mode for checked implementation. An isolated request has one durabl
138
153
  }
139
154
  ```
140
155
 
141
- `dependsOn` controls scheduling. `contextFrom` also schedules dependencies and supplies completed text outputs in order. Do not repeat a task in both fields. Changesets require focused checks; graphs with changesets require final checks. The canonical root `package.json` must have a test script. The runner adds `pnpm test` to final checks if absent.
156
+ `dependsOn` controls scheduling. `contextFrom` also schedules dependencies and supplies completed text outputs in order. Do not repeat a task in both fields. Changesets require focused checks; graphs with changesets require final checks. Admission selects one Full suite for the repository and adds it to the final checks if absent. See [Full suite](#full-suite).
142
157
 
143
158
  Workers use Main's pinned committed branch, HEAD, and tree, not staged, unstaged, or untracked edits. Branch or HEAD changes block new waves. Candidates must be committed with no tracked or untracked changes. Ignored generated files permit candidacy but prevent automatic cleanup.
144
159
 
@@ -159,6 +174,24 @@ Reuse assumes that checks use only tracked content. Ignored files, for example `
159
174
 
160
175
  The extension sends follow-ups when a candidate becomes ready, a worker needs attention, and the wave finishes. A missed follow-up does not authorize replay. Read [Isolated contract](./docs/orchestration.md#isolated-contract) and [Exact integration actions](./docs/orchestration.md#exact-integration-actions) for graph, judgment, revision, refresh, and release details.
161
176
 
177
+ ### Full suite
178
+
179
+ Each isolated request with a changeset task must pass one Full suite on the combined tip before promotion. Admission selects it in this order:
180
+
181
+ 1. The `repoSuites` entry for the repository in the config file.
182
+ 2. `pnpm test`, when the root `package.json` has a non-empty `scripts.test`.
183
+ 3. `make test`, when the root `GNUmakefile`, `makefile`, or `Makefile` declares a literal `test` target, such as `test:` or `test::`.
184
+
185
+ If no source applies, the request fails before it saves state. A read or parse error also fails. It never counts as an absent source. The extension reads the Makefile as text and never runs Make to find targets, because Make can run repository code. Generated, included, or pattern targets are not found. Use `repoSuites` for them:
186
+
187
+ ```json
188
+ { "repoSuites": { "/home/me/code/service": { "command": "uv", "args": ["run", "pytest", "-q"] } } }
189
+ ```
190
+
191
+ The key is the absolute path of the primary checkout. Two keys for the same checkout block the request. The command runs without a shell. To use a shell, name it as the command.
192
+
193
+ Admission saves the selected suite and its source with the request. Validation, resume, and recovery use the saved suite, so a later config change does not change an admitted request. Validation rejects a combined tip that changes the selected `package.json` test script or Makefile. A dry run shows the selected suite.
194
+
162
195
  ### Inspect work
163
196
 
164
197
  Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. The menu groups rows under **Direct**, **Isolated**, and **Orphaned** headers. Each isolated row shows the request ID, the status, and a short goal summary. **Refresh** or a header reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
@@ -202,6 +235,7 @@ Package-owned: `~/.pi/agent/config/pi-subagent/config.json`. Missing files silen
202
235
  | `timeout.idleMinutes` | Direct worker and ephemeral child idle timeout | Positive; within Node's timer range | `10` |
203
236
  | `timeout.maxMinutes` | Ephemeral child hard runtime | Greater than idle; within Node's timer range | `30` |
204
237
  | `roleDefaults` | Shared resources for built-in and user Roles | Object with `extensions`, `tools`, or `skills` arrays; same validation as Role fields | — |
238
+ | `repoSuites` | Full suite for isolated changesets in one repository | Object keyed by the absolute path of the primary checkout. Each value is `{ "command": "<executable>", "args": [...] }` | — |
205
239
 
206
240
  `roleDefaults` entries precede Role entries; merging removes duplicates before resource resolution. Use it for a provider extension that every child needs. Limits come only from this file. Requests cannot override or replenish them. Durable requests keep recorded policy; a lower current correction limit can tighten recovery. There is no whole-run timeout.
207
241
 
@@ -210,6 +244,7 @@ The extension reports each new problem once per session. It reads config at laun
210
244
  - Unknown keys warn and are ignored. A likely misspelling, such as `maxToken` for missing `maxTokens`, counts as invalid for that key.
211
245
  - Invalid `maxSubagents`, `maxTurns`, `maxCorrections`, or `timeout` values warn and use finite defaults. If maximum timeout does not exceed idle timeout, the whole `timeout` uses defaults.
212
246
  - Unknown or invalid `roleDefaults` keys warn; only those keys are ignored, so Roles receive fewer default resources.
247
+ - An invalid `repoSuites` value blocks delegation. One bad entry blocks the whole value, so a bad entry cannot select a different Full suite.
213
248
  - Invalid or misspelled `maxTokens` blocks delegation. Malformed JSON, invalid UTF-8, files above 64 KiB, and unreadable files also block delegation. The extension does not fall back to unlimited tokens.
214
249
 
215
250
  `/subagent` remains available during a block. Correct the file to enable delegation. The next worker or isolated Role launch fails while the block remains; running workers retain their original limits. If the problem existed at session start, also run `/reload`, because that session took default execution limits.
@@ -266,7 +301,7 @@ The executor accepts concurrency, turn/token, idle/hard-runtime limits, and abor
266
301
 
267
302
  Private durable state lives under `config/pi-subagent/state/`. Isolated worker sessions use mode `0600` under `config/pi-subagent/leases/`; worker umask stays unchanged. Reads stop at 16 MiB. Larger sessions require inspection and cannot prove completion.
268
303
 
269
- Sessions are removed only after exact worker-tab cleanup. They can contain prompts and answers: do not share them. State v5 rejects v4 and older files without migration. Preserve old state and recover its work manually.
304
+ Sessions are removed only after exact worker-tab cleanup. They can contain prompts and answers: do not share them. State v6 rejects v5 and older files without migration. An unfinished v4 or v5 request blocks new isolated work in its repository. Finish or recover isolated requests with the earlier version before you upgrade. Preserve old state and recover its work manually.
270
305
 
271
306
  ## Limits and recovery
272
307
 
@@ -0,0 +1,34 @@
1
+ import type { AdmittedFullSuite, CheckCommand } from "./schema.ts";
2
+ export type { AdmittedFullSuite };
3
+ /** Make reads the first of these files that exists. */
4
+ declare const MAKEFILE_NAMES: readonly ["GNUmakefile", "makefile", "Makefile"];
5
+ /** The root `package.json` test script, or `undefined` when the file or the script is absent. A malformed file throws. */
6
+ export declare function packageTestScript(root: string): string | undefined;
7
+ /**
8
+ * Return true when the Makefile text declares a literal `test` target, for example `test:` or `test check::`.
9
+ * This is a static check. It never runs Make, because Make can run repository code while it reads a file.
10
+ * Generated, included, or pattern targets are not found. Configure `repoSuites` for them.
11
+ */
12
+ export declare function declaresMakeTest(text: string): boolean;
13
+ /** The Makefile that Make would read in `root`, with its content hash. `undefined` when no Makefile exists. */
14
+ export declare function makefileIdentity(root: string): {
15
+ file: (typeof MAKEFILE_NAMES)[number];
16
+ text: string;
17
+ sha256: string;
18
+ } | undefined;
19
+ /**
20
+ * Select the Full suite for `root`. The order is: the configured suite, then `pnpm test` when the root
21
+ * `package.json` has a non-empty `scripts.test`, then `make test` when the root Makefile declares `test`.
22
+ * A read or parse error throws. It never counts as an absent source.
23
+ */
24
+ export declare function resolveFullSuite(root: string, configured?: CheckCommand): AdmittedFullSuite;
25
+ /** Throw when the checkout at `root` changed the source of an admitted Full suite. A configured suite has no source file. */
26
+ export declare function assertFullSuiteSource(suite: AdmittedFullSuite, root: string): void;
27
+ export declare const sameCheck: (left: CheckCommand, right: CheckCommand) => boolean;
28
+ /** A human-readable source label for plans and status. */
29
+ export declare function describeFullSuite(suite: AdmittedFullSuite): string;
30
+ /**
31
+ * The `repoSuites` entry for the primary checkout `primary`. Keys are compared by real path.
32
+ * Two keys for the same checkout throw, because the choice between them is not defined.
33
+ */
34
+ export declare function configuredSuiteFor(repoSuites: Readonly<Record<string, CheckCommand>>, primary: string): CheckCommand | undefined;
@@ -0,0 +1,142 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /** Make reads the first of these files that exists. */
5
+ const MAKEFILE_NAMES = ["GNUmakefile", "makefile", "Makefile"];
6
+ const MANIFEST_MAX_BYTES = 1024 * 1024;
7
+ function readOptional(path) {
8
+ let text;
9
+ try {
10
+ text = readFileSync(path, "utf8");
11
+ }
12
+ catch (error) {
13
+ const code = error.code;
14
+ if (code === "ENOENT" || code === "ENOTDIR")
15
+ return undefined;
16
+ throw new Error(`Full suite detection cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
17
+ }
18
+ if (Buffer.byteLength(text, "utf8") > MANIFEST_MAX_BYTES)
19
+ throw new Error(`Full suite detection refuses ${path}: the file is larger than 1 MiB.`);
20
+ return text;
21
+ }
22
+ /** The root `package.json` test script, or `undefined` when the file or the script is absent. A malformed file throws. */
23
+ export function packageTestScript(root) {
24
+ const path = join(root, "package.json");
25
+ const text = readOptional(path);
26
+ if (text === undefined)
27
+ return undefined;
28
+ let manifest;
29
+ try {
30
+ manifest = JSON.parse(text);
31
+ }
32
+ catch (error) {
33
+ throw new Error(`Full suite detection cannot parse ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
34
+ }
35
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
36
+ throw new Error(`Full suite detection found no JSON object in ${path}.`);
37
+ const scripts = manifest.scripts;
38
+ if (scripts === undefined)
39
+ return undefined;
40
+ if (!scripts || typeof scripts !== "object" || Array.isArray(scripts))
41
+ throw new Error(`Full suite detection found a malformed scripts field in ${path}.`);
42
+ const test = scripts.test;
43
+ if (test === undefined)
44
+ return undefined;
45
+ if (typeof test !== "string")
46
+ throw new Error(`Full suite detection found a test script that is not text in ${path}.`);
47
+ return test.trim() ? test : undefined;
48
+ }
49
+ /**
50
+ * Return true when the Makefile text declares a literal `test` target, for example `test:` or `test check::`.
51
+ * This is a static check. It never runs Make, because Make can run repository code while it reads a file.
52
+ * Generated, included, or pattern targets are not found. Configure `repoSuites` for them.
53
+ */
54
+ export function declaresMakeTest(text) {
55
+ let continued = false;
56
+ for (const line of text.split(/\r?\n/)) {
57
+ const previous = continued;
58
+ continued = /(^|[^\\])(\\\\)*\\$/.test(line);
59
+ // Recipe lines start with a tab. Continuation lines belong to the line before them.
60
+ if (previous || line.startsWith("\t"))
61
+ continue;
62
+ const rule = /^([^#:=]+?)::?(?![=:])/.exec(line);
63
+ if (!rule)
64
+ continue;
65
+ if (rule[1].trim().split(/\s+/).includes("test"))
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ /** The Makefile that Make would read in `root`, with its content hash. `undefined` when no Makefile exists. */
71
+ export function makefileIdentity(root) {
72
+ // Compare exact names. A case-insensitive file system would otherwise report Makefile as makefile.
73
+ let names;
74
+ try {
75
+ names = new Set(readdirSync(root));
76
+ }
77
+ catch (error) {
78
+ throw new Error(`Full suite detection cannot list ${root}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
79
+ }
80
+ for (const file of MAKEFILE_NAMES) {
81
+ if (!names.has(file))
82
+ continue;
83
+ const text = readOptional(join(root, file));
84
+ if (text !== undefined)
85
+ return { file, text, sha256: createHash("sha256").update(text).digest("hex") };
86
+ }
87
+ return undefined;
88
+ }
89
+ /**
90
+ * Select the Full suite for `root`. The order is: the configured suite, then `pnpm test` when the root
91
+ * `package.json` has a non-empty `scripts.test`, then `make test` when the root Makefile declares `test`.
92
+ * A read or parse error throws. It never counts as an absent source.
93
+ */
94
+ export function resolveFullSuite(root, configured) {
95
+ if (configured)
96
+ return { check: { command: configured.command, args: [...configured.args] }, source: { kind: "config" } };
97
+ const testScript = packageTestScript(root);
98
+ if (testScript !== undefined)
99
+ return { check: { command: "pnpm", args: ["test"] }, source: { kind: "package-json", testScript } };
100
+ const makefile = makefileIdentity(root);
101
+ if (makefile && declaresMakeTest(makefile.text)) {
102
+ return { check: { command: "make", args: ["test"] }, source: { kind: "makefile", file: makefile.file, sha256: makefile.sha256 } };
103
+ }
104
+ throw new Error(`No Full suite is available for ${root}. Configure repoSuites in config/pi-subagent/config.json, add a package.json scripts.test, or add a Makefile test target.`);
105
+ }
106
+ /** Throw when the checkout at `root` changed the source of an admitted Full suite. A configured suite has no source file. */
107
+ export function assertFullSuiteSource(suite, root) {
108
+ const { source } = suite;
109
+ if (source.kind === "package-json" && packageTestScript(root) !== source.testScript) {
110
+ throw new Error("Combined tip changed the admitted root package.json test script.");
111
+ }
112
+ if (source.kind === "makefile") {
113
+ const makefile = makefileIdentity(root);
114
+ if (makefile?.file !== source.file || makefile.sha256 !== source.sha256)
115
+ throw new Error(`Combined tip changed the admitted root ${source.file}.`);
116
+ }
117
+ }
118
+ export const sameCheck = (left, right) => left.command === right.command && left.args.length === right.args.length && left.args.every((arg, index) => arg === right.args[index]);
119
+ /** A human-readable source label for plans and status. */
120
+ export function describeFullSuite(suite) {
121
+ const command = [suite.check.command, ...suite.check.args].join(" ");
122
+ return suite.source.kind === "config" ? `${command} (from repoSuites config)`
123
+ : suite.source.kind === "package-json" ? `${command} (from package.json scripts.test)`
124
+ : `${command} (from ${suite.source.file} test target)`;
125
+ }
126
+ /**
127
+ * The `repoSuites` entry for the primary checkout `primary`. Keys are compared by real path.
128
+ * Two keys for the same checkout throw, because the choice between them is not defined.
129
+ */
130
+ export function configuredSuiteFor(repoSuites, primary) {
131
+ const matches = Object.entries(repoSuites).filter(([key]) => {
132
+ try {
133
+ return realpathSync.native(key) === primary;
134
+ }
135
+ catch {
136
+ return key === primary;
137
+ }
138
+ });
139
+ if (matches.length > 1)
140
+ throw new Error(`repoSuites has ${matches.length} entries for ${primary}: ${matches.map(([key]) => key).join(", ")}. Keep one entry.`);
141
+ return matches[0]?.[1];
142
+ }
package/dist/index.d.ts CHANGED
@@ -94,6 +94,16 @@ export interface ResolvedRoleSkills {
94
94
  export declare function parseRoleName(value: unknown, source?: string): RoleName;
95
95
  /** Validate `config.json` `roleDefaults` with the same rules as Role fields; `label` prefixes errors. */
96
96
  export declare function parseRoleDefaults(value: unknown, label?: string): RoleDefaults;
97
+ /** A configured Full suite: one executable and its argument list. No shell runs unless the executable is a shell. */
98
+ export interface RepoSuite {
99
+ command: string;
100
+ args: string[];
101
+ }
102
+ /**
103
+ * Strict `repoSuites`: any bad entry makes the whole value invalid, and the field disables delegation.
104
+ * Dropping one bad entry could select a different Full suite without notice.
105
+ */
106
+ export declare function parseRepoSuites(raw: unknown): Record<string, RepoSuite>;
97
107
  export interface SubagentConfig {
98
108
  maxSubagents: number;
99
109
  maxTurns: number;
@@ -104,6 +114,8 @@ export interface SubagentConfig {
104
114
  maxMinutes: number;
105
115
  };
106
116
  roleDefaults: RoleDefaults;
117
+ /** Full suite overrides for isolated changesets, keyed by absolute primary checkout path. */
118
+ repoSuites: Record<string, RepoSuite>;
107
119
  }
108
120
  export declare const DEFAULT_SUBAGENT_CONFIG: Readonly<SubagentConfig>;
109
121
  /**
@@ -121,6 +133,7 @@ export declare function createSubagentConfigStore(agentDir?: string): import("@h
121
133
  maxMinutes: number;
122
134
  }>;
123
135
  roleDefaults: import("@hcflabs/pi-config-store").Field<RoleDefaults>;
136
+ repoSuites: import("@hcflabs/pi-config-store").Field<Record<string, RepoSuite>>;
124
137
  }>>;
125
138
  /**
126
139
  * Report `roleDefaults` issues at each session start, and return a function that reports them after a lazy
package/dist/index.js CHANGED
@@ -194,6 +194,41 @@ const roleDefaultsField = (raw, report) => {
194
194
  }
195
195
  return defaults;
196
196
  };
197
+ const MAX_REPO_SUITES = 200;
198
+ const MAX_SUITE_ARGS = 128;
199
+ const MAX_SUITE_TEXT = 16_000;
200
+ /**
201
+ * Strict `repoSuites`: any bad entry makes the whole value invalid, and the field disables delegation.
202
+ * Dropping one bad entry could select a different Full suite without notice.
203
+ */
204
+ export function parseRepoSuites(raw) {
205
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
206
+ throw new Error("must be a JSON object keyed by absolute primary checkout paths");
207
+ const entries = Object.entries(raw);
208
+ if (entries.length > MAX_REPO_SUITES)
209
+ throw new Error(`has ${entries.length} entries; the limit is ${MAX_REPO_SUITES}`);
210
+ const suites = {};
211
+ for (const [key, value] of entries) {
212
+ const label = JSON.stringify(key);
213
+ if (!isAbsolute(key) || key.length > 4096 || /[\u0000-\u001f\u007f]/.test(key))
214
+ throw new Error(`key ${label} must be an absolute path without control characters`);
215
+ if (!value || typeof value !== "object" || Array.isArray(value))
216
+ throw new Error(`${label} must be an object with command and args`);
217
+ for (const field of Object.keys(value)) {
218
+ if (field !== "command" && field !== "args")
219
+ throw new Error(`${label} has unknown key ${JSON.stringify(field)}; expected command and args`);
220
+ }
221
+ const { command, args } = value;
222
+ if (typeof command !== "string" || !command.trim() || command !== command.trim() || command.length > MAX_SUITE_TEXT || /[\u0000-\u001f\u007f]/.test(command)) {
223
+ throw new Error(`${label}.command must be one executable name or path without spaces at the ends or control characters`);
224
+ }
225
+ if (!Array.isArray(args) || args.length > MAX_SUITE_ARGS || args.some((arg) => typeof arg !== "string" || arg.length > MAX_SUITE_TEXT || arg.includes("\0"))) {
226
+ throw new Error(`${label}.args must be a list of at most ${MAX_SUITE_ARGS} strings without NUL`);
227
+ }
228
+ suites[key] = { command, args: [...args] };
229
+ }
230
+ return suites;
231
+ }
197
232
  export const DEFAULT_SUBAGENT_CONFIG = Object.freeze({
198
233
  maxSubagents: 5,
199
234
  maxTurns: DEFAULT_MAX_TURNS,
@@ -201,6 +236,7 @@ export const DEFAULT_SUBAGENT_CONFIG = Object.freeze({
201
236
  maxCorrections: 1,
202
237
  timeout: Object.freeze({ idleMinutes: 10, maxMinutes: 30 }),
203
238
  roleDefaults: Object.freeze({}),
239
+ repoSuites: Object.freeze({}),
204
240
  });
205
241
  const timeoutMinutes = parsers.number({ greaterThan: 0, max: 2_147_483_647 / 60_000 });
206
242
  const timeoutField = (raw, report) => {
@@ -229,6 +265,7 @@ export function createSubagentConfigStore(agentDir = getAgentDir()) {
229
265
  maxCorrections: field(parsers.integer({ min: 0 }), DEFAULT_SUBAGENT_CONFIG.maxCorrections),
230
266
  timeout: field(timeoutField, DEFAULT_SUBAGENT_CONFIG.timeout),
231
267
  roleDefaults: field(roleDefaultsField, DEFAULT_SUBAGENT_CONFIG.roleDefaults),
268
+ repoSuites: field((raw) => parseRepoSuites(raw), DEFAULT_SUBAGENT_CONFIG.repoSuites, { onInvalid: "disable" }),
232
269
  },
233
270
  });
234
271
  }
package/dist/runner.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type DiscardGuards, type DiscardPlan } from "./discard.ts";
2
2
  import { type EphemeralSubagentExecutor } from "./ephemeral.ts";
3
3
  import type { PruneOwnership } from "./prune.ts";
4
4
  import { IntegrationGit } from "./integration-git.ts";
5
- import { type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
5
+ import { type AdmittedFullSuite, type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
6
6
  import { FileRunStore } from "./store.ts";
7
7
  /** One durable follow-up per attempt; this instruction permits a worker commit, never discarded work. */
8
8
  export declare const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
@@ -100,6 +100,8 @@ export type ResolvedRoute = Pick<VerifiedLaunch, "role" | "modelClass" | "model"
100
100
  /** A dry-run result: the normalized request and the resolved routes; no state exists for it. */
101
101
  export interface ExecutePlan {
102
102
  request: ExecuteRequest;
103
+ /** The Full suite for a request with changeset tasks. */
104
+ fullSuite?: AdmittedFullSuite;
103
105
  root: string;
104
106
  main: WorkspaceIdentity;
105
107
  routes: readonly ResolvedRoute[];
@@ -311,9 +313,11 @@ export declare class IsolatedRunner {
311
313
  private readonly integrationGit;
312
314
  private readonly textExecutor;
313
315
  private readonly currentPolicy;
316
+ /** The configured `repoSuites`, read at admission only. */
317
+ private readonly repoSuites;
314
318
  private readonly productiveLifecycles;
315
319
  private readonly followupControls;
316
- constructor(coordinatorRuntime: CoordinatorRuntime, hostRuntime: HostRuntime, gitRuntime: GitRuntime, store: FileRunStore | undefined, textExecutor: EphemeralSubagentExecutor, policy?: ExecutionPolicySnapshot, currentPolicy?: () => ExecutionPolicySnapshot, integrationGit?: IntegrationGit);
320
+ constructor(coordinatorRuntime: CoordinatorRuntime, hostRuntime: HostRuntime, gitRuntime: GitRuntime, store: FileRunStore | undefined, textExecutor: EphemeralSubagentExecutor, policy?: ExecutionPolicySnapshot, currentPolicy?: () => ExecutionPolicySnapshot, integrationGit?: IntegrationGit, repoSuites?: () => Readonly<Record<string, CheckCommand>>);
317
321
  listRequests(root: string): Promise<{
318
322
  requests: Array<{
319
323
  id: string;
package/dist/runner.js CHANGED
@@ -1,7 +1,8 @@
1
- import { readFileSync, realpathSync } from "node:fs";
1
+ import { realpathSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { inspectDiscard, primaryCheckout, removeDiscardResources } from "./discard.js";
4
4
  import { utf8Prefix } from "./ephemeral.js";
5
+ import { assertFullSuiteSource, configuredSuiteFor, resolveFullSuite, sameCheck } from "./full-suite.js";
5
6
  import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
6
7
  import { runGit } from "./git-process.js";
7
8
  import { IntegrationGit } from "./integration-git.js";
@@ -19,23 +20,8 @@ export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
19
20
  export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
20
21
  export const STATUS_INSPECTION_BUDGET_MS = 5_000;
21
22
  const ALLOCATION_KINDS = ["worktree", "workspace", "worker_tab", "agent"];
22
- const FULL_SUITE = { command: "pnpm", args: ["test"] };
23
23
  const MAX_RETAINED_INTEGRATION_GENERATIONS = 2;
24
- function requireRootSuite(root) {
25
- let manifest;
26
- try {
27
- manifest = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
28
- }
29
- catch {
30
- throw new Error("Canonical root package.json with a test script is required for isolated changesets.");
31
- }
32
- if (!manifest || typeof manifest !== "object" || !('scripts' in manifest)
33
- || !manifest.scripts || typeof manifest.scripts !== "object"
34
- || !('test' in manifest.scripts) || typeof manifest.scripts.test !== "string"
35
- || !manifest.scripts.test.trim())
36
- throw new Error("Canonical root test script is missing.");
37
- return manifest.scripts.test;
38
- }
24
+ const MAX_FINAL_CHECKS = 32;
39
25
  /** Run one launch action and surface cleanup failure, including after action failure or abort. */
40
26
  export async function withTransientLaunch(handle, operation) {
41
27
  let failed = false;
@@ -616,12 +602,15 @@ export class IsolatedRunner {
616
602
  integrationGit;
617
603
  textExecutor;
618
604
  currentPolicy;
605
+ /** The configured `repoSuites`, read at admission only. */
606
+ repoSuites;
619
607
  productiveLifecycles = new Map();
620
608
  followupControls = new Map();
621
609
  constructor(coordinatorRuntime, hostRuntime, gitRuntime, store = new FileRunStore(), textExecutor, policy = {
622
610
  maxSubagents: 5, maxTurns: 50, childIdleMs: 600_000, childMaxMs: 1_800_000,
623
611
  maxCorrections: 1,
624
- }, currentPolicy = () => policy, integrationGit = new IntegrationGit()) {
612
+ }, currentPolicy = () => policy, integrationGit = new IntegrationGit(), repoSuites = () => ({})) {
613
+ this.repoSuites = repoSuites;
625
614
  this.coordinatorRuntime = coordinatorRuntime;
626
615
  this.hostRuntime = hostRuntime;
627
616
  this.gitRuntime = gitRuntime;
@@ -833,11 +822,17 @@ export class IsolatedRunner {
833
822
  const root = realpathSync.native(prepared.root);
834
823
  if (root !== prepared.root)
835
824
  throw new Error("Preflight repository root must be canonical.");
825
+ let fullSuite;
836
826
  if (request.tasks.some((task) => task.kind === "changeset")) {
837
- requireRootSuite(root);
838
- if (!request.finalChecks.some((check) => check.command === FULL_SUITE.command
839
- && check.args.length === 1 && check.args[0] === FULL_SUITE.args[0])) {
840
- request.finalChecks = [FULL_SUITE, ...request.finalChecks];
827
+ // Main is clean and committed here, so the working files are the committed sources.
828
+ const repoSuites = this.repoSuites();
829
+ const configured = Object.keys(repoSuites).length ? configuredSuiteFor(repoSuites, await primaryCheckout(root)) : undefined;
830
+ fullSuite = resolveFullSuite(root, configured);
831
+ if (!request.finalChecks.some((check) => sameCheck(check, fullSuite.check))) {
832
+ if (request.finalChecks.length >= MAX_FINAL_CHECKS) {
833
+ throw new Error(`The Full suite ${formatCheckCommand(fullSuite.check)} cannot be added: the request already has ${MAX_FINAL_CHECKS} final checks.`);
834
+ }
835
+ request.finalChecks = [fullSuite.check, ...request.finalChecks];
841
836
  }
842
837
  }
843
838
  const preparedMain = runtimeIdentity(prepared.main, "Preflight Main identity");
@@ -846,22 +841,23 @@ export class IsolatedRunner {
846
841
  if (this.hasAnyActiveControl(root)) {
847
842
  throw new Error("Another Pi Subagent request has active changeset work in this repository.");
848
843
  }
849
- return { request, policy, root, preparedMain, routes: prepared.routes ?? [] };
844
+ return { request, policy, root, preparedMain, routes: prepared.routes ?? [], ...(fullSuite ? { fullSuite } : {}) };
850
845
  }
851
846
  /** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
852
847
  async plan(value, cwd, outerSignal) {
853
- const { request, root, preparedMain, routes } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true);
854
- return { request, root, main: preparedMain, routes };
848
+ const { request, root, preparedMain, routes, fullSuite } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true);
849
+ return { request, root, main: preparedMain, routes, ...(fullSuite ? { fullSuite } : {}) };
855
850
  }
856
851
  async execute(value, cwd, outerSignal) {
857
852
  const scope = new ProductiveScope(outerSignal);
858
- const { request, policy, root, preparedMain } = await this.admitExecute(value, cwd, scope, false);
853
+ const { request, policy, root, preparedMain, fullSuite } = await this.admitExecute(value, cwd, scope, false);
859
854
  return await this.withProductiveRun(root, async (lifecycle) => {
860
855
  const createdAt = this.coordinatorRuntime.now();
861
856
  const state = {
862
857
  version: RUN_STATE_VERSION,
863
858
  request,
864
859
  policy,
860
+ ...(fullSuite ? { fullSuite } : {}),
865
861
  correctionCount: 0,
866
862
  root,
867
863
  requestStartMain: preparedMain,
@@ -1478,12 +1474,12 @@ export class IsolatedRunner {
1478
1474
  generation.status = "validating";
1479
1475
  await this.saveProductive(handle); // Record the exact validation intent before running checks.
1480
1476
  try {
1481
- if (!request.finalChecks.some((check) => check.command === FULL_SUITE.command
1482
- && check.args.length === 1 && check.args[0] === FULL_SUITE.args[0]))
1483
- throw new Error("Canonical full-suite command was not recorded at admission.");
1484
- if (requireRootSuite(generation.worktree.path) !== requireRootSuite(root)) {
1485
- throw new Error("Combined tip changed the admitted canonical root test script.");
1477
+ const fullSuite = state.fullSuite;
1478
+ if (!fullSuite || !request.finalChecks.some((check) => sameCheck(check, fullSuite.check))) {
1479
+ throw new Error("The Full suite was not recorded at admission.");
1486
1480
  }
1481
+ // The saved suite runs as admitted. A later config edit does not change it.
1482
+ assertFullSuiteSource(fullSuite, generation.worktree.path);
1487
1483
  await this.requireExactIntegration(handle, generation, receipts, scope);
1488
1484
  const sources = stagedCheckSources(generation, state.integration.candidates);
1489
1485
  const checks = await this.runCheckBatch(handle, request.finalChecks, action.expectedTip, "final", scope, undefined, generation.worktree.path, request.finalChecks.map((check) => reusableCheckSource(check, action.expectedTip, sources)));
package/dist/schema.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Type, type Static } from "typebox";
2
- export declare const RUN_STATE_VERSION = 5;
2
+ export declare const RUN_STATE_VERSION = 6;
3
3
  export declare const MAX_TASKS = 8;
4
4
  export declare const MAX_EXECUTE_REQUEST_BYTES: number;
5
5
  export declare const MAX_PERSISTED_RUNTIME_TEXT_BYTES: number;
@@ -10,6 +10,24 @@ export declare const CheckCommandSchema: Type.TObject<{
10
10
  command: Type.TString;
11
11
  args: Type.TArray<Type.TString>;
12
12
  }>;
13
+ /** The Full suite fixed at admission, with its source. Validation checks the same source again. */
14
+ export declare const FullSuiteSchema: Type.TObject<{
15
+ check: Type.TObject<{
16
+ command: Type.TString;
17
+ args: Type.TArray<Type.TString>;
18
+ }>;
19
+ source: Type.TUnion<[Type.TObject<{
20
+ kind: Type.TLiteral<"config">;
21
+ }>, Type.TObject<{
22
+ kind: Type.TLiteral<"package-json">;
23
+ testScript: Type.TString;
24
+ }>, Type.TObject<{
25
+ kind: Type.TLiteral<"makefile">;
26
+ file: Type.TUnion<[Type.TLiteral<"GNUmakefile">, Type.TLiteral<"makefile">, Type.TLiteral<"Makefile">]>;
27
+ sha256: Type.TString;
28
+ }>]>;
29
+ }>;
30
+ export type AdmittedFullSuite = Static<typeof FullSuiteSchema>;
13
31
  /** One display form for a check command; quoted args keep argv boundaries visible. */
14
32
  export declare function formatCheckCommand({ command, args }: {
15
33
  command: string;
@@ -1756,7 +1774,7 @@ export type IntegrationState = Static<typeof IntegrationStateSchema>;
1756
1774
  declare const TerminalRunStatusSchema: Type.TUnion<[Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
1757
1775
  export declare const TERMINAL_RUN_STATUSES: readonly Static<typeof TerminalRunStatusSchema>[];
1758
1776
  declare const RunStateSchema: Type.TObject<{
1759
- version: Type.TLiteral<5>;
1777
+ version: Type.TLiteral<6>;
1760
1778
  request: Type.TObject<{
1761
1779
  mode: Type.TLiteral<"isolated">;
1762
1780
  id: Type.TString;
@@ -1807,6 +1825,23 @@ declare const RunStateSchema: Type.TObject<{
1807
1825
  childMaxMs: Type.TNumber;
1808
1826
  maxCorrections: Type.TInteger;
1809
1827
  }>;
1828
+ /** Present exactly when the request has a changeset task. */
1829
+ fullSuite: Type.TOptional<Type.TObject<{
1830
+ check: Type.TObject<{
1831
+ command: Type.TString;
1832
+ args: Type.TArray<Type.TString>;
1833
+ }>;
1834
+ source: Type.TUnion<[Type.TObject<{
1835
+ kind: Type.TLiteral<"config">;
1836
+ }>, Type.TObject<{
1837
+ kind: Type.TLiteral<"package-json">;
1838
+ testScript: Type.TString;
1839
+ }>, Type.TObject<{
1840
+ kind: Type.TLiteral<"makefile">;
1841
+ file: Type.TUnion<[Type.TLiteral<"GNUmakefile">, Type.TLiteral<"makefile">, Type.TLiteral<"Makefile">]>;
1842
+ sha256: Type.TString;
1843
+ }>]>;
1844
+ }>>;
1810
1845
  correctionCount: Type.TInteger;
1811
1846
  root: Type.TString;
1812
1847
  requestStartMain: Type.TObject<{
@@ -2429,7 +2464,7 @@ declare const RunStateSchema: Type.TObject<{
2429
2464
  */
2430
2465
  export declare const StatusOutputSchema: Type.TObject<{
2431
2466
  state: Type.TObject<{
2432
- version: Type.TLiteral<5>;
2467
+ version: Type.TLiteral<6>;
2433
2468
  id: Type.TString;
2434
2469
  status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"needs_attention">, Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
2435
2470
  accepted: Type.TBoolean;