@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/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;
@@ -825,19 +814,30 @@ export class IsolatedRunner {
825
814
  throw error;
826
815
  }
827
816
  /** Every execute check before durable state; a dry run also skips the Herdr host preflight. */
828
- async admitExecute(value, cwd, scope, dryRun) {
817
+ async admitExecute(value, cwd, scope, dryRun, targeted) {
829
818
  const request = parseExecuteRequest(value);
830
819
  const policy = Object.freeze({ ...this.currentPolicy() });
831
820
  const canonicalCwd = realpathSync.native(cwd);
832
- const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({ request, cwd: canonicalCwd, ...(dryRun ? { dryRun } : {}) }, context));
821
+ const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({
822
+ request, cwd: canonicalCwd, ...(dryRun ? { dryRun } : {}), ...(targeted ? { targeted } : {}),
823
+ }, context));
833
824
  const root = realpathSync.native(prepared.root);
834
825
  if (root !== prepared.root)
835
826
  throw new Error("Preflight repository root must be canonical.");
827
+ // A Repository target is its own root. A different root means the checkout changed after the target check.
828
+ if (targeted && root !== canonicalCwd)
829
+ throw new Error(`Repository target ${canonicalCwd} now resolves to ${root}; nothing was saved. Retry the request.`);
830
+ let fullSuite;
836
831
  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];
832
+ // Main is clean and committed here, so the working files are the committed sources.
833
+ const repoSuites = this.repoSuites();
834
+ const configured = Object.keys(repoSuites).length ? configuredSuiteFor(repoSuites, await primaryCheckout(root)) : undefined;
835
+ fullSuite = resolveFullSuite(root, configured);
836
+ if (!request.finalChecks.some((check) => sameCheck(check, fullSuite.check))) {
837
+ if (request.finalChecks.length >= MAX_FINAL_CHECKS) {
838
+ throw new Error(`The Full suite ${formatCheckCommand(fullSuite.check)} cannot be added: the request already has ${MAX_FINAL_CHECKS} final checks.`);
839
+ }
840
+ request.finalChecks = [fullSuite.check, ...request.finalChecks];
841
841
  }
842
842
  }
843
843
  const preparedMain = runtimeIdentity(prepared.main, "Preflight Main identity");
@@ -846,22 +846,24 @@ export class IsolatedRunner {
846
846
  if (this.hasAnyActiveControl(root)) {
847
847
  throw new Error("Another Pi Subagent request has active changeset work in this repository.");
848
848
  }
849
- return { request, policy, root, preparedMain, routes: prepared.routes ?? [] };
849
+ return { request, policy, root, preparedMain, routes: prepared.routes ?? [], ...(fullSuite ? { fullSuite } : {}) };
850
850
  }
851
851
  /** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
852
- 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 };
852
+ /** `options.targeted` marks `cwd` as a Repository target that the caller selected with `repo`. */
853
+ async plan(value, cwd, outerSignal, options = {}) {
854
+ const { request, root, preparedMain, routes, fullSuite } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true, options.targeted === true);
855
+ return { request, root, main: preparedMain, routes, ...(fullSuite ? { fullSuite } : {}) };
855
856
  }
856
- async execute(value, cwd, outerSignal) {
857
+ async execute(value, cwd, outerSignal, options = {}) {
857
858
  const scope = new ProductiveScope(outerSignal);
858
- const { request, policy, root, preparedMain } = await this.admitExecute(value, cwd, scope, false);
859
+ const { request, policy, root, preparedMain, fullSuite } = await this.admitExecute(value, cwd, scope, false, options.targeted === true);
859
860
  return await this.withProductiveRun(root, async (lifecycle) => {
860
861
  const createdAt = this.coordinatorRuntime.now();
861
862
  const state = {
862
863
  version: RUN_STATE_VERSION,
863
864
  request,
864
865
  policy,
866
+ ...(fullSuite ? { fullSuite } : {}),
865
867
  correctionCount: 0,
866
868
  root,
867
869
  requestStartMain: preparedMain,
@@ -1478,12 +1480,12 @@ export class IsolatedRunner {
1478
1480
  generation.status = "validating";
1479
1481
  await this.saveProductive(handle); // Record the exact validation intent before running checks.
1480
1482
  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.");
1483
+ const fullSuite = state.fullSuite;
1484
+ if (!fullSuite || !request.finalChecks.some((check) => sameCheck(check, fullSuite.check))) {
1485
+ throw new Error("The Full suite was not recorded at admission.");
1486
1486
  }
1487
+ // The saved suite runs as admitted. A later config edit does not change it.
1488
+ assertFullSuiteSource(fullSuite, generation.worktree.path);
1487
1489
  await this.requireExactIntegration(handle, generation, receipts, scope);
1488
1490
  const sources = stagedCheckSources(generation, state.integration.candidates);
1489
1491
  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)));
@@ -1792,6 +1794,14 @@ export class IsolatedRunner {
1792
1794
  directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
1793
1795
  })) };
1794
1796
  }
1797
+ /** Orphaned requests of every primary checkout in one state scan, each with the primary checkout that owns it. */
1798
+ async listAllOrphanRequests() {
1799
+ const { states, invalidIds, primaries } = await this.store.listOrphans();
1800
+ return { invalidIds, requests: states.map((handle) => ({
1801
+ id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state, true),
1802
+ directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [], primary: primaries.get(handle),
1803
+ })) };
1804
+ }
1795
1805
  discardGuards(directory, root, guards) {
1796
1806
  return { ...guards, blocked: async () => {
1797
1807
  if (this.productiveLifecycles.has(root))
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;
package/dist/schema.js CHANGED
@@ -2,7 +2,7 @@ import { isAbsolute } from "node:path";
2
2
  import { parseRoleName } from "./index.js";
3
3
  import { Type } from "typebox";
4
4
  import { Check, Errors } from "typebox/value";
5
- export const RUN_STATE_VERSION = 5;
5
+ export const RUN_STATE_VERSION = 6;
6
6
  export const MAX_TASKS = 8;
7
7
  export const MAX_EXECUTE_REQUEST_BYTES = 256 * 1024;
8
8
  export const MAX_PERSISTED_RUNTIME_TEXT_BYTES = 8 * 1024;
@@ -26,6 +26,19 @@ export const CheckCommandSchema = Type.Object({
26
26
  command: TextSchema,
27
27
  args: Type.Array(Type.String({ maxLength: 16_000 }), { maxItems: 128 }),
28
28
  }, { additionalProperties: false });
29
+ /** The Full suite fixed at admission, with its source. Validation checks the same source again. */
30
+ export const FullSuiteSchema = Type.Object({
31
+ check: CheckCommandSchema,
32
+ source: Type.Union([
33
+ Type.Object({ kind: Type.Literal("config") }, { additionalProperties: false }),
34
+ Type.Object({ kind: Type.Literal("package-json"), testScript: Type.String({ minLength: 1, maxLength: 16_000 }) }, { additionalProperties: false }),
35
+ Type.Object({
36
+ kind: Type.Literal("makefile"),
37
+ file: Type.Union([Type.Literal("GNUmakefile"), Type.Literal("makefile"), Type.Literal("Makefile")]),
38
+ sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }),
39
+ }, { additionalProperties: false }),
40
+ ]),
41
+ }, { additionalProperties: false });
29
42
  /** One display form for a check command; quoted args keep argv boundaries visible. */
30
43
  export function formatCheckCommand({ command, args }) {
31
44
  return [command, ...args.map((arg) => JSON.stringify(arg))].join(" ");
@@ -420,6 +433,8 @@ const RunStateSchema = Type.Object({
420
433
  version: Type.Literal(RUN_STATE_VERSION),
421
434
  request: ExecuteRequestSchema,
422
435
  policy: ExecutionPolicySnapshotSchema,
436
+ /** Present exactly when the request has a changeset task. */
437
+ fullSuite: Type.Optional(FullSuiteSchema),
423
438
  correctionCount: Type.Integer({ minimum: 0 }),
424
439
  root: TextSchema,
425
440
  requestStartMain: WorkspaceSchema,
@@ -1015,6 +1030,13 @@ export function parseRunState(value) {
1015
1030
  const state = value;
1016
1031
  const request = parseExecuteRequest(state.request);
1017
1032
  parseIntegrationState(state.integration, request);
1033
+ const changeset = request.tasks.some((task) => task.kind === "changeset");
1034
+ if (changeset !== (state.fullSuite !== undefined))
1035
+ throw new Error("A Full suite must be recorded exactly for a request with changeset tasks.");
1036
+ if (state.fullSuite && !request.finalChecks.some(({ command, args }) => command === state.fullSuite.check.command
1037
+ && args.length === state.fullSuite.check.args.length && args.every((arg, index) => arg === state.fullSuite.check.args[index]))) {
1038
+ throw new Error("The recorded Full suite is not in the request final checks.");
1039
+ }
1018
1040
  if (state.status !== "aborted" && state.integration.generations.some((generation) => generation.supersededFrom === "discarded")) {
1019
1041
  throw new Error("User-discarded generations require an aborted request.");
1020
1042
  }
package/dist/store.d.ts CHANGED
@@ -47,13 +47,21 @@ export declare class FileRunStore {
47
47
  private loadFromValidatedDirectory;
48
48
  hasProductiveRunLeaseInDirectory(directory: string): Promise<boolean>;
49
49
  withDirectoryLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
50
- /** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
50
+ /**
51
+ * Missing roots only; repoRoot records the stable primary checkout, not the removed parent.
52
+ * Without `primary`, return the orphans of every primary checkout, each with its primary.
53
+ */
51
54
  listOrphans(primary: string): Promise<{
52
55
  states: RunStateHandle[];
53
56
  invalidIds: string[];
54
57
  }>;
58
+ listOrphans(primary?: undefined): Promise<{
59
+ states: RunStateHandle[];
60
+ invalidIds: string[];
61
+ primaries: Map<RunStateHandle, string>;
62
+ }>;
55
63
  private stateEntries;
56
- /** Read only version and request status; never migrate or interpret v4 worker evidence. */
64
+ /** Read only version and request status; never migrate or interpret v4 or v5 worker evidence. */
57
65
  assertLegacyAdmissionSafe(root: string): Promise<void>;
58
66
  list(root: string): Promise<{
59
67
  states: RunStateHandle[];
package/dist/store.js CHANGED
@@ -18,8 +18,10 @@ const INITIAL_STATE_MAX_BYTES = 2 * 1024 * 1024;
18
18
  const STATE_MAX_BYTES = 128 * 1024 * 1024;
19
19
  const ADMISSION_MAX_FILES = 256;
20
20
  const ADMISSION_MAX_BYTES = STATE_MAX_BYTES;
21
- const V4_TERMINAL_STATUSES = new Set(["completed", "final_failed", "superseded", "aborted"]);
22
- const V4_UNFINISHED_STATUSES = new Set(["pending", "running", "needs_attention"]);
21
+ // v4 and v5 use the same request statuses. Admission reads only the version and the status of older files.
22
+ const LEGACY_VERSIONS = new Set([4, 5]);
23
+ const LEGACY_TERMINAL_STATUSES = new Set(["completed", "final_failed", "superseded", "aborted"]);
24
+ const LEGACY_UNFINISHED_STATUSES = new Set(["pending", "running", "needs_attention"]);
23
25
  const LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 };
24
26
  const LIFECYCLE_LOCK_OPTIONS = {
25
27
  ...LOCK_OPTIONS,
@@ -253,8 +255,8 @@ export class FileRunStore {
253
255
  await release();
254
256
  }
255
257
  }
256
- /** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
257
258
  async listOrphans(primary) {
259
+ const primaries = new Map();
258
260
  const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
259
261
  let directories;
260
262
  try {
@@ -262,7 +264,7 @@ export class FileRunStore {
262
264
  }
263
265
  catch (error) {
264
266
  if (isMissing(error))
265
- return { states: [], invalidIds: [] };
267
+ return { states: [], invalidIds: [], ...(primary === undefined ? { primaries } : {}) };
266
268
  throw error;
267
269
  }
268
270
  const states = [];
@@ -289,7 +291,14 @@ export class FileRunStore {
289
291
  throw error;
290
292
  }
291
293
  const worktrees = discardResources(handle.state).flatMap((resource) => resource.worktree ? [resource.worktree] : []);
292
- if (worktrees.length && worktrees.every((item) => item.repoRoot === primary))
294
+ const owner = worktrees[0]?.repoRoot;
295
+ if (owner === undefined || !worktrees.every((item) => item.repoRoot === owner))
296
+ continue;
297
+ if (primary === undefined) {
298
+ primaries.set(handle, owner);
299
+ states.push(handle);
300
+ }
301
+ else if (owner === primary)
293
302
  states.push(handle);
294
303
  }
295
304
  catch {
@@ -303,7 +312,7 @@ export class FileRunStore {
303
312
  invalidIds.push(directory);
304
313
  }
305
314
  }
306
- return { states, invalidIds };
315
+ return { states, invalidIds, ...(primary === undefined ? { primaries } : {}) };
307
316
  }
308
317
  async stateEntries(root) {
309
318
  try {
@@ -317,7 +326,7 @@ export class FileRunStore {
317
326
  throw error;
318
327
  }
319
328
  }
320
- /** Read only version and request status; never migrate or interpret v4 worker evidence. */
329
+ /** Read only version and request status; never migrate or interpret v4 or v5 worker evidence. */
321
330
  async assertLegacyAdmissionSafe(root) {
322
331
  const directory = this.stateDirectory(root);
323
332
  await this.assertSafeDestination(root, directory);
@@ -348,12 +357,12 @@ export class FileRunStore {
348
357
  }
349
358
  if (value.version === RUN_STATE_VERSION)
350
359
  continue;
351
- if (value.version !== 4 || !("status" in value) || typeof value.status !== "string"
352
- || (!V4_TERMINAL_STATUSES.has(value.status) && !V4_UNFINISHED_STATUSES.has(value.status))) {
360
+ if (typeof value.version !== "number" || !LEGACY_VERSIONS.has(value.version) || !("status" in value) || typeof value.status !== "string"
361
+ || (!LEGACY_TERMINAL_STATUSES.has(value.status) && !LEGACY_UNFINISHED_STATUSES.has(value.status))) {
353
362
  throw new Error(`Pi Subagent malformed or unsupported legacy state ${id} blocks safe admission. ${recovery}`);
354
363
  }
355
- if (V4_UNFINISHED_STATUSES.has(value.status)) {
356
- throw new Error(`Pi Subagent unfinished v4 request ${id} (${value.status}) blocks new isolated work. ${recovery}`);
364
+ if (LEGACY_UNFINISHED_STATUSES.has(value.status)) {
365
+ throw new Error(`Pi Subagent unfinished v${value.version} request ${id} (${value.status}) blocks new isolated work. ${recovery}`);
357
366
  }
358
367
  }
359
368
  }
@@ -81,7 +81,7 @@ Direct `changeset` is rejected; use isolated mode for checked integration. Resou
81
81
 
82
82
  ### Dry run
83
83
 
84
- Set `"dryRun": true` on a direct or isolated `delegate_task` request to check it without a launch. The dry run does the same checks as a launch: request shape, Role lookup, route and model with thinking level, Skills, tools, MCP allowlist, Role extensions, config, and `continueFrom` eligibility. For an isolated request, it also checks the task graph and that Main is clean and committed. If the request has a changeset task, it also checks the canonical root `package.json` test script and adds `pnpm test` to the final checks. A failed check gives the same error as a launch.
84
+ Set `"dryRun": true` on a direct or isolated `delegate_task` request to check it without a launch. The dry run does the same checks as a launch: request shape, Role lookup, route and model with thinking level, Skills, tools, MCP allowlist, Role extensions, config, and `continueFrom` eligibility. For an isolated request, it also checks the task graph and that Main is clean and committed. If the request has a changeset task, it also selects the Full suite, adds it to the final checks, and shows it. A failed check gives the same error as a launch.
85
85
 
86
86
  The result is a bounded plan. It shows each task's Role, resolved `provider/model`, thinking level, access or kind, dependencies, and checks. An isolated plan also shows the final checks. The dry run makes no Herdr call, Herdr tab, session record, saved state, worktree, lease, checkout admission, or follow-up. The plan lists each check it cannot do without such an effect as `Not checked: <reason>`. Examples are the Herdr host preflight, checkout admission, and the stop of a continued agent.
87
87
 
@@ -257,7 +257,7 @@ Every task declares `id`, `kind`, `role`, `modelClass`, `requirements`, `deliver
257
257
  - A `changeset` task requires focused checks and owns one worktree, Herdr workspace, worker tab, and retained worker.
258
258
  - `dependsOn` controls ready waves; dependent tasks can run from a selected staged snapshot only after Main explicitly advances it.
259
259
  - `contextFrom` may reference completed text tasks only, implies scheduling dependencies, and preserves declaration order. It must not overlap `dependsOn`.
260
- - A graph containing changesets requires final checks. The repository root must have a `package.json` test script; admission prepends canonical `pnpm test` if absent from final checks. Combined validation rejects a changed root test script.
260
+ - A graph containing changesets requires final checks. Admission selects one Full suite: the `repoSuites` config entry, then `pnpm test` for a root `package.json` test script, then `make test` for a literal Makefile `test` target. It prepends the suite to the final checks if absent and saves the suite with its source in state v6. Combined validation rejects a changed root test script or Makefile. A config change after admission does not change the saved suite.
261
261
 
262
262
  Admission and worker-wave dispatch pin Main's exact committed branch, HEAD, and tree without inspecting or modifying its staged, unstaged, or untracked edits. Worker allocation verifies the same committed identity around worktree creation, including resumed waves; staged-dependent waves verify their clean integration snapshot. A dirty Main can therefore produce a checked retained candidate, but branch/HEAD drift stops new waves. The runner executes ready worker tasks concurrently, inspects clean committed candidates, runs preliminary task checks and optional preliminary judgment, then records ready candidates for Main. **It does not automatically integrate them.**
263
263