@hcflabs/pi-subagent 27.11.0 → 28.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -3
- package/dist/full-suite.d.ts +34 -0
- package/dist/full-suite.js +142 -0
- package/dist/herdr-runtime.d.ts +7 -1
- package/dist/herdr-runtime.js +11 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +37 -0
- package/dist/launch-runtime.d.ts +2 -0
- package/dist/launch-runtime.js +4 -1
- package/dist/runner.d.ts +32 -5
- package/dist/runner.js +45 -35
- package/dist/schema.d.ts +38 -3
- package/dist/schema.js +23 -1
- package/dist/store.d.ts +10 -2
- package/dist/store.js +20 -11
- package/docs/orchestration.md +2 -2
- package/extensions/isolated.ts +177 -63
- package/extensions/subagent-command.ts +12 -3
- package/extensions/subagent.ts +4 -2
- package/extensions/workflow.ts +8 -5
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -108,7 +108,11 @@ When Pi runs in a parent directory that holds many repositories, set `repo` to s
|
|
|
108
108
|
|
|
109
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
110
|
|
|
111
|
-
|
|
111
|
+
An isolated request accepts one `repo` for the whole graph. Tasks cannot select another repository. The request then uses the Repository target as its checkout. State, worker worktrees, validation, and promotion belong to that repository. The Pi working directory does not change.
|
|
112
|
+
|
|
113
|
+
Your Herdr workspace can belong to another checkout or to none. Herdr opens the primary workspace of the target when it opens the first worker worktree, and it does not close that workspace later. Pass the same `repo` to `subagent_status`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort`.
|
|
114
|
+
|
|
115
|
+
Run `/subagent` in a parent directory that is not a Git repository. It lists the isolated requests of each Repository target below it, with their `repo`. `/subagent recover` recovers each Repository target that has saved requests. `/subagent prune` still needs a Git checkout.
|
|
112
116
|
|
|
113
117
|
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).
|
|
114
118
|
|
|
@@ -153,7 +157,7 @@ Use isolated mode for checked implementation. An isolated request has one durabl
|
|
|
153
157
|
}
|
|
154
158
|
```
|
|
155
159
|
|
|
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.
|
|
160
|
+
`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).
|
|
157
161
|
|
|
158
162
|
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.
|
|
159
163
|
|
|
@@ -174,6 +178,24 @@ Reuse assumes that checks use only tracked content. Ignored files, for example `
|
|
|
174
178
|
|
|
175
179
|
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.
|
|
176
180
|
|
|
181
|
+
### Full suite
|
|
182
|
+
|
|
183
|
+
Each isolated request with a changeset task must pass one Full suite on the combined tip before promotion. Admission selects it in this order:
|
|
184
|
+
|
|
185
|
+
1. The `repoSuites` entry for the repository in the config file.
|
|
186
|
+
2. `pnpm test`, when the root `package.json` has a non-empty `scripts.test`.
|
|
187
|
+
3. `make test`, when the root `GNUmakefile`, `makefile`, or `Makefile` declares a literal `test` target, such as `test:` or `test::`.
|
|
188
|
+
|
|
189
|
+
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:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{ "repoSuites": { "/home/me/code/service": { "command": "uv", "args": ["run", "pytest", "-q"] } } }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
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.
|
|
196
|
+
|
|
197
|
+
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.
|
|
198
|
+
|
|
177
199
|
### Inspect work
|
|
178
200
|
|
|
179
201
|
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.
|
|
@@ -217,6 +239,7 @@ Package-owned: `~/.pi/agent/config/pi-subagent/config.json`. Missing files silen
|
|
|
217
239
|
| `timeout.idleMinutes` | Direct worker and ephemeral child idle timeout | Positive; within Node's timer range | `10` |
|
|
218
240
|
| `timeout.maxMinutes` | Ephemeral child hard runtime | Greater than idle; within Node's timer range | `30` |
|
|
219
241
|
| `roleDefaults` | Shared resources for built-in and user Roles | Object with `extensions`, `tools`, or `skills` arrays; same validation as Role fields | — |
|
|
242
|
+
| `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": [...] }` | — |
|
|
220
243
|
|
|
221
244
|
`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.
|
|
222
245
|
|
|
@@ -225,6 +248,7 @@ The extension reports each new problem once per session. It reads config at laun
|
|
|
225
248
|
- Unknown keys warn and are ignored. A likely misspelling, such as `maxToken` for missing `maxTokens`, counts as invalid for that key.
|
|
226
249
|
- 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.
|
|
227
250
|
- Unknown or invalid `roleDefaults` keys warn; only those keys are ignored, so Roles receive fewer default resources.
|
|
251
|
+
- An invalid `repoSuites` value blocks delegation. One bad entry blocks the whole value, so a bad entry cannot select a different Full suite.
|
|
228
252
|
- 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.
|
|
229
253
|
|
|
230
254
|
`/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.
|
|
@@ -281,7 +305,7 @@ The executor accepts concurrency, turn/token, idle/hard-runtime limits, and abor
|
|
|
281
305
|
|
|
282
306
|
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.
|
|
283
307
|
|
|
284
|
-
Sessions are removed only after exact worker-tab cleanup. They can contain prompts and answers: do not share them. State
|
|
308
|
+
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.
|
|
285
309
|
|
|
286
310
|
## Limits and recovery
|
|
287
311
|
|
|
@@ -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/herdr-runtime.d.ts
CHANGED
|
@@ -38,9 +38,15 @@ export declare class HerdrHostRuntime implements HostRuntime {
|
|
|
38
38
|
private readonly lsofCommand;
|
|
39
39
|
private readonly executionBudget?;
|
|
40
40
|
constructor(options: HerdrHostRuntimeOptions);
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Fail-closed Herdr caller/capability gate. This method creates no state or host resource.
|
|
43
|
+
* Without `targeted`, the caller's workspace must be the checkout `root`. With `targeted`, `root` is a
|
|
44
|
+
* Repository target, and the caller's workspace can belong to any checkout or to none.
|
|
45
|
+
* Herdr opens the primary workspace of the target when it opens an owned worktree.
|
|
46
|
+
*/
|
|
42
47
|
preflightHost(input: {
|
|
43
48
|
root: string;
|
|
49
|
+
targeted?: boolean;
|
|
44
50
|
}, context: OperationContext): Promise<void>;
|
|
45
51
|
planHostAllocation(input: {
|
|
46
52
|
readonly requestId: ExecuteRequest["id"];
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -270,7 +270,12 @@ export class HerdrHostRuntime {
|
|
|
270
270
|
this.lsofCommand = options.lsofCommand ?? "lsof";
|
|
271
271
|
this.executionBudget = options.executionBudget;
|
|
272
272
|
}
|
|
273
|
-
/**
|
|
273
|
+
/**
|
|
274
|
+
* Fail-closed Herdr caller/capability gate. This method creates no state or host resource.
|
|
275
|
+
* Without `targeted`, the caller's workspace must be the checkout `root`. With `targeted`, `root` is a
|
|
276
|
+
* Repository target, and the caller's workspace can belong to any checkout or to none.
|
|
277
|
+
* Herdr opens the primary workspace of the target when it opens an owned worktree.
|
|
278
|
+
*/
|
|
274
279
|
async preflightHost(input, context) {
|
|
275
280
|
context.signal.throwIfAborted();
|
|
276
281
|
if (this.env.HERDR_ENV !== "1")
|
|
@@ -306,6 +311,11 @@ export class HerdrHostRuntime {
|
|
|
306
311
|
const workspaceId = exactString(pane.workspace_id, "Herdr current workspace_id");
|
|
307
312
|
const workspaceResponse = await this.herdr.json(["workspace", "get", workspaceId], this.processOptions(root, context, HERDR_OPERATION_CAP_MS));
|
|
308
313
|
const workspace = parseWorkspaceInfo(workspaceResponse, workspaceId);
|
|
314
|
+
if (input.targeted) {
|
|
315
|
+
// The worker worktrees open from the primary checkout of the target, not from the caller's workspace.
|
|
316
|
+
await this.repositoryIdentity(root, context);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
309
319
|
const worktree = record(workspace.worktree, "Herdr current workspace worktree");
|
|
310
320
|
const checkout = await realpath(exactString(worktree.checkout_path, "Herdr current checkout_path"));
|
|
311
321
|
if (checkout !== root)
|
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/launch-runtime.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface LaunchRuntimeOptions {
|
|
|
16
16
|
request: ExecuteRequest;
|
|
17
17
|
cwd: string;
|
|
18
18
|
root: string;
|
|
19
|
+
targeted?: boolean;
|
|
19
20
|
}, context: OperationContext): Promise<void>;
|
|
20
21
|
inspectMainBase(input: {
|
|
21
22
|
root: string;
|
|
@@ -44,6 +45,7 @@ export declare class RoleLaunchRuntime implements CoordinatorRuntime {
|
|
|
44
45
|
request: ExecuteRequest;
|
|
45
46
|
cwd: string;
|
|
46
47
|
dryRun?: boolean;
|
|
48
|
+
targeted?: boolean;
|
|
47
49
|
}, context: OperationContext): Promise<{
|
|
48
50
|
root: string;
|
|
49
51
|
main: WorkspaceIdentity;
|
package/dist/launch-runtime.js
CHANGED
|
@@ -140,12 +140,15 @@ export class RoleLaunchRuntime {
|
|
|
140
140
|
const root = await realpath(resolvedRoot);
|
|
141
141
|
if (root !== resolvedRoot)
|
|
142
142
|
throw new Error("Pi Subagent root resolver returned a non-canonical path.");
|
|
143
|
+
// A Repository target must still be its own Git top-level directory before the host gate runs.
|
|
144
|
+
if (input.targeted && root !== await realpath(input.cwd))
|
|
145
|
+
throw new Error(`Repository target ${input.cwd} now resolves to ${root}; nothing was saved. Retry the request.`);
|
|
143
146
|
const rootInfo = await lstat(root);
|
|
144
147
|
if (!rootInfo.isDirectory())
|
|
145
148
|
throw new Error("Pi Subagent root must be an existing local directory.");
|
|
146
149
|
context.signal?.throwIfAborted();
|
|
147
150
|
if (!input.dryRun && input.request.tasks.some((task) => task.kind === "changeset")) {
|
|
148
|
-
await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root }, context);
|
|
151
|
+
await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root, ...(input.targeted ? { targeted: true } : {}) }, context);
|
|
149
152
|
}
|
|
150
153
|
const main = await this.options.inspectMainBase({ root }, context);
|
|
151
154
|
const required = new Map();
|
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[];
|
|
@@ -117,11 +119,15 @@ export declare function withTransientLaunch<Launch extends VerifiedLaunch, Resul
|
|
|
117
119
|
export interface CoordinatorRuntime {
|
|
118
120
|
now(): number;
|
|
119
121
|
randomToken(): string;
|
|
120
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* `dryRun` skips checks that call Herdr; every other check runs.
|
|
124
|
+
* `targeted` means that `cwd` is a Repository target, so the caller's Herdr workspace can belong to another checkout.
|
|
125
|
+
*/
|
|
121
126
|
preflight(input: {
|
|
122
127
|
request: ExecuteRequest;
|
|
123
128
|
cwd: string;
|
|
124
129
|
dryRun?: boolean;
|
|
130
|
+
targeted?: boolean;
|
|
125
131
|
}, context: OperationContext): Promise<{
|
|
126
132
|
root: string;
|
|
127
133
|
main: WorkspaceIdentity;
|
|
@@ -311,9 +317,11 @@ export declare class IsolatedRunner {
|
|
|
311
317
|
private readonly integrationGit;
|
|
312
318
|
private readonly textExecutor;
|
|
313
319
|
private readonly currentPolicy;
|
|
320
|
+
/** The configured `repoSuites`, read at admission only. */
|
|
321
|
+
private readonly repoSuites;
|
|
314
322
|
private readonly productiveLifecycles;
|
|
315
323
|
private readonly followupControls;
|
|
316
|
-
constructor(coordinatorRuntime: CoordinatorRuntime, hostRuntime: HostRuntime, gitRuntime: GitRuntime, store: FileRunStore | undefined, textExecutor: EphemeralSubagentExecutor, policy?: ExecutionPolicySnapshot, currentPolicy?: () => ExecutionPolicySnapshot, integrationGit?: IntegrationGit);
|
|
324
|
+
constructor(coordinatorRuntime: CoordinatorRuntime, hostRuntime: HostRuntime, gitRuntime: GitRuntime, store: FileRunStore | undefined, textExecutor: EphemeralSubagentExecutor, policy?: ExecutionPolicySnapshot, currentPolicy?: () => ExecutionPolicySnapshot, integrationGit?: IntegrationGit, repoSuites?: () => Readonly<Record<string, CheckCommand>>);
|
|
317
325
|
listRequests(root: string): Promise<{
|
|
318
326
|
requests: Array<{
|
|
319
327
|
id: string;
|
|
@@ -357,8 +365,13 @@ export declare class IsolatedRunner {
|
|
|
357
365
|
/** Every execute check before durable state; a dry run also skips the Herdr host preflight. */
|
|
358
366
|
private admitExecute;
|
|
359
367
|
/** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
|
|
360
|
-
|
|
361
|
-
|
|
368
|
+
/** `options.targeted` marks `cwd` as a Repository target that the caller selected with `repo`. */
|
|
369
|
+
plan(value: unknown, cwd: string, outerSignal?: AbortSignal, options?: {
|
|
370
|
+
targeted?: boolean;
|
|
371
|
+
}): Promise<ExecutePlan>;
|
|
372
|
+
execute(value: unknown, cwd: string, outerSignal?: AbortSignal, options?: {
|
|
373
|
+
targeted?: boolean;
|
|
374
|
+
}): Promise<RunResponse>;
|
|
362
375
|
resume(value: unknown, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
|
|
363
376
|
/** Main selects an exact retained candidate; only the owned integration checkout is writable. */
|
|
364
377
|
stage(value: unknown, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
|
|
@@ -382,6 +395,20 @@ export declare class IsolatedRunner {
|
|
|
382
395
|
tasks: never[];
|
|
383
396
|
}[];
|
|
384
397
|
}>;
|
|
398
|
+
/** Orphaned requests of every primary checkout in one state scan, each with the primary checkout that owns it. */
|
|
399
|
+
listAllOrphanRequests(): Promise<{
|
|
400
|
+
invalidIds: string[];
|
|
401
|
+
requests: {
|
|
402
|
+
id: string;
|
|
403
|
+
name: string;
|
|
404
|
+
status: string;
|
|
405
|
+
directory: string;
|
|
406
|
+
root: string;
|
|
407
|
+
orphaned: true;
|
|
408
|
+
tasks: never[];
|
|
409
|
+
primary: string;
|
|
410
|
+
}[];
|
|
411
|
+
}>;
|
|
385
412
|
private discardGuards;
|
|
386
413
|
discardPlan(cwd: string, id: string, directory: string | undefined, guards: DiscardGuards): Promise<DiscardPlan>;
|
|
387
414
|
/** Command-only explicit discard. No tool calls this method. */
|