@evoclock/pi-agentic-driver 1.1.1 → 2.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.
Files changed (118) hide show
  1. package/.agentic-driver/loop-until-done.defaults.json +5 -0
  2. package/README.md +315 -33
  3. package/config/merge-policy.v1.example.json +4 -0
  4. package/config/relay-authority.v1.example.json +7 -0
  5. package/config/treehouse.v1.json +4 -0
  6. package/docs/durable-driver-api.md +23 -0
  7. package/docs/durable-persistence.md +32 -0
  8. package/docs/merge-policy.md +45 -0
  9. package/docs/relay-authority.md +9 -0
  10. package/extensions/aidr.ts +3 -0
  11. package/extensions/harvest-lessons.ts +8 -0
  12. package/extensions/herdr-dispatch.ts +32 -3
  13. package/extensions/loop-until-done.ts +7 -0
  14. package/extensions/recovery-evidence.ts +3 -3
  15. package/extensions/treehouse-recycle.ts +47 -0
  16. package/janus/README.md +3 -6
  17. package/janus/janus/config.ts +2 -2
  18. package/janus/janus/providers.ts +2 -4
  19. package/janus/tests/providers.test.ts +2 -2
  20. package/{extensions → lib}/task-store-adapter.ts +1 -5
  21. package/package.json +161 -7
  22. package/scripts/enforcement/approval_ledger_pi.js +135 -0
  23. package/scripts/enforcement/attended_authority_guard.js +113 -0
  24. package/scripts/enforcement/board_claim_bridge_pi.js +10 -10
  25. package/scripts/enforcement/durable_assignment_pi.js +488 -0
  26. package/scripts/enforcement/durable_driver_pi.d.ts +51 -0
  27. package/scripts/enforcement/durable_driver_pi.js +254 -0
  28. package/scripts/enforcement/herdr_async_dispatch_pi.js +86 -20
  29. package/scripts/enforcement/herdr_async_seam_pi.js +90 -3
  30. package/scripts/enforcement/herdr_communication_pi.js +205 -31
  31. package/scripts/enforcement/herdr_lifecycle_pi.js +730 -81
  32. package/scripts/enforcement/linux_microvm_cutover_pi.js +3 -3
  33. package/scripts/enforcement/linux_microvm_remote_fixture.sh +0 -6
  34. package/scripts/enforcement/loop_until_done_guide.md +13 -0
  35. package/scripts/enforcement/loop_until_done_pi.js +214 -0
  36. package/scripts/enforcement/merge_policy.js +181 -0
  37. package/scripts/enforcement/outward_effect_pi.js +156 -0
  38. package/scripts/enforcement/recovery_evidence_review_pi.js +31 -3
  39. package/scripts/enforcement/storage_owner_lock_pi.js +66 -0
  40. package/scripts/enforcement/task_board_core_pi.js +13 -100
  41. package/scripts/enforcement/task_board_hash_pi.js +86 -0
  42. package/scripts/enforcement/treehouse_recycle_pi.js +145 -0
  43. package/scripts/harvest_lessons.js +219 -0
  44. package/scripts/relay/record_approval.mjs +10 -0
  45. package/scripts/relay/send_request.mjs +26 -0
  46. package/skills/architect-pi/SKILL.md +44 -0
  47. package/skills/arena-pi/SKILL.md +46 -0
  48. package/skills/automate-me-pi/SKILL.md +37 -0
  49. package/skills/benchmark-checklist-pi/SKILL.md +35 -0
  50. package/skills/blast-radius-pi/SKILL.md +24 -0
  51. package/skills/bro-pi/SKILL.md +17 -0
  52. package/skills/correct-pi/SKILL.md +36 -0
  53. package/skills/create-verification-skill-pi/LICENSE +21 -0
  54. package/skills/create-verification-skill-pi/SKILL.md +24 -0
  55. package/skills/decision-to-spec/SKILL.md +65 -0
  56. package/skills/figure-it-out-pi/SKILL.md +37 -0
  57. package/skills/harvest-lessons/SKILL.md +56 -0
  58. package/skills/harvest-lessons/harvest.example.json +8 -0
  59. package/skills/how/LICENSE +24 -0
  60. package/skills/how/WORKFLOW_BOUNDARIES.md +19 -0
  61. package/skills/how-pi/SKILL.md +33 -0
  62. package/skills/in-your-own-words-pi/SKILL.md +15 -0
  63. package/skills/interrogate-pi/SKILL.md +35 -0
  64. package/skills/ketchup-pi/SKILL.md +21 -0
  65. package/skills/maintain-verification-skill-pi/SKILL.md +37 -0
  66. package/skills/make-bot-ui-pi/SKILL.md +33 -0
  67. package/skills/no-comments-pi/SKILL.md +33 -0
  68. package/skills/poteto-help-pi/SKILL.md +50 -0
  69. package/skills/poteto-mode-pi/LICENSE +21 -0
  70. package/skills/poteto-mode-pi/SKILL.md +41 -0
  71. package/skills/poteto-mode-pi/playbooks.md +95 -0
  72. package/skills/principle-attack-the-premise-pi/SKILL.md +17 -0
  73. package/skills/principle-boundary-discipline-pi/SKILL.md +19 -0
  74. package/skills/principle-build-the-lever-pi/SKILL.md +19 -0
  75. package/skills/principle-encode-lessons-in-structure-pi/SKILL.md +17 -0
  76. package/skills/principle-exhaust-the-design-space-pi/SKILL.md +17 -0
  77. package/skills/principle-experience-first-pi/SKILL.md +17 -0
  78. package/skills/principle-explain-the-number-pi/SKILL.md +21 -0
  79. package/skills/principle-fix-root-causes-pi/SKILL.md +21 -0
  80. package/skills/principle-foundational-thinking-pi/SKILL.md +21 -0
  81. package/skills/principle-guard-the-context-window-pi/SKILL.md +19 -0
  82. package/skills/principle-laziness-protocol-pi/SKILL.md +19 -0
  83. package/skills/principle-make-operations-idempotent-pi/SKILL.md +21 -0
  84. package/skills/principle-migrate-callers-then-delete-legacy-apis-pi/SKILL.md +19 -0
  85. package/skills/principle-minimize-reader-load-pi/SKILL.md +19 -0
  86. package/skills/principle-model-the-domain-pi/SKILL.md +19 -0
  87. package/skills/principle-never-block-on-the-human-pi/SKILL.md +19 -0
  88. package/skills/principle-outcome-oriented-execution-pi/SKILL.md +19 -0
  89. package/skills/principle-prove-it-works-pi/SKILL.md +19 -0
  90. package/skills/principle-redesign-from-first-principles-pi/SKILL.md +19 -0
  91. package/skills/principle-separate-before-serializing-shared-state-pi/SKILL.md +19 -0
  92. package/skills/principle-sequence-verifiable-units-pi/SKILL.md +19 -0
  93. package/skills/principle-subtract-before-you-add-pi/SKILL.md +19 -0
  94. package/skills/principle-test-behavior-not-implementation-pi/SKILL.md +21 -0
  95. package/skills/principle-type-system-discipline-pi/SKILL.md +19 -0
  96. package/skills/public-pr-wording/SKILL.md +69 -0
  97. package/skills/public-pr-wording/pr_wording_config.example.json +22 -0
  98. package/skills/public-pr-wording/pr_wording_lint.py +249 -0
  99. package/skills/public-pr-wording/pr_wording_rules.json +60 -0
  100. package/skills/pulse/SKILL.md +4 -2
  101. package/skills/recall-pi/SKILL.md +29 -0
  102. package/skills/reflect-pi/SKILL.md +27 -0
  103. package/skills/setup-pstack-pi/SKILL.md +23 -0
  104. package/skills/setup-pstack-pi/assets/pstack-model-roles.v1.example.json +7 -0
  105. package/skills/sharpen-pi/SKILL.md +19 -0
  106. package/skills/show-me-your-work-pi/SKILL.md +27 -0
  107. package/skills/swarm-pi/SKILL.md +27 -0
  108. package/skills/tasks/SKILL.md +4 -2
  109. package/skills/tdd-pi/SKILL.md +21 -0
  110. package/skills/teach-pi/SKILL.md +21 -0
  111. package/skills/technical-writing-pi/SKILL.md +27 -0
  112. package/skills/typescript-best-practices-pi/SKILL.md +21 -0
  113. package/skills/unslop-pi/SKILL.md +27 -0
  114. package/skills/why-pi/SKILL.md +37 -0
  115. package/PROVENANCE.md +0 -83
  116. package/config/herdr-worker-repositories.v1.json +0 -9
  117. package/docs/codebase-inventory.md +0 -15
  118. package/docs/recovery-evidence-store.md +0 -9
@@ -8,16 +8,18 @@
8
8
  // allowlist constant. Pane placement uses pane split directly; tab placement
9
9
  // uses tab create. Both capture the returned pane identity and start once.
10
10
  import { createHash } from "node:crypto";
11
- import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from "node:fs";
11
+ import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync, lstatSync, writeFileSync, copyFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { join, resolve } from "node:path";
14
- import { spawnSync } from "node:child_process";
13
+ import { basename, join, resolve } from "node:path";
14
+ import { spawn, spawnSync } from "node:child_process";
15
15
  import {
16
16
  HERDR_ROLE_PATTERN,
17
17
  HERDR_ROLE_POLICY,
18
18
  resolveTrustedHerdrExecutable,
19
19
  } from "./herdr_communication_pi.js";
20
20
  import { isAttendedElectronContext, isNativeTuiContext } from "./native_tui_context.js";
21
+ import { reserveApprovalUse, recordApprovalUse, authorizeApproval, checkRelayRequests } from "./approval_ledger_pi.js";
22
+ import { needsOwner } from "./attended_authority_guard.js";
21
23
 
22
24
  export const HERDR_SPAWN_WORKER_TOOL = "agentic_herdr_spawn_worker";
23
25
  export const HERDR_LIFECYCLE_SCHEMA = "agentic-driver.herdr-lifecycle.v1";
@@ -40,7 +42,7 @@ const AGENT_READY_TIMEOUT_MS = 120_000;
40
42
  // The active Pi profile's model roll is user configuration, read read-only.
41
43
  // It resolves from PI_CODING_AGENT_DIR (the active profile directory); only
42
44
  // when that env var is unset does it fall back to the default per-user path
43
- // ~/.pi/agent/models.json (recorded in docs/package-0.1.3-design-record.md).
45
+ // the installed agent model catalog (recorded in the package design record).
44
46
  function resolvePiModelsPath() {
45
47
  const profileDir = process.env.PI_CODING_AGENT_DIR;
46
48
  if (typeof profileDir === "string" && profileDir.trim()) {
@@ -51,7 +53,7 @@ function resolvePiModelsPath() {
51
53
  // A model selection is either a unique local model id or a provider followed
52
54
  // by one or more bounded model-id segments. Pi model IDs may use colons, but
53
55
  // provider names may not. validateSpawnParams separately bounds total length.
54
- const MODEL_ID_REGEXP = /^[a-z0-9][a-z0-9._:-]{0,127}$|^[a-z0-9][a-z0-9._-]{0,63}(?:\/[a-z0-9][a-z0-9._:-]{0,127})+$/;
56
+ const MODEL_ID_REGEXP = /^(?:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}|[a-z0-9][a-z0-9._-]{0,63}(?:\/[a-z0-9][a-z0-9._:-]{0,127})+|[A-Za-z0-9]+(?: [A-Za-z0-9]+){1,8})$/;
55
57
  // Closed response keys. Shapes beyond `.result.move_result.pane.pane_id` are
56
58
  // provisional from tagged-source evidence (Tranche 07) and fail closed on
57
59
  // anything extra.
@@ -73,6 +75,11 @@ export const HERDR_SPAWN_WORKER_PARAMETERS = Object.freeze({
73
75
  role: { type: "string", pattern: HERDR_ROLE_PATTERN, maxLength: 64 },
74
76
  model: { type: "string", pattern: MODEL_ID_REGEXP.source, maxLength: 192 },
75
77
  repository: { type: "string", pattern: SAFE_NAME_REGEXP.source, maxLength: 64 },
78
+ // Optional bounded worktree target. It is a request, never authority: it
79
+ // must resolve to the canonical Git worktree root of the registered
80
+ // repository and carry an active Treehouse lease observed read-only.
81
+ worktreePath: { type: "string", minLength: 1, maxLength: 1024 },
82
+ authority: { type: "object", additionalProperties: false, properties: { ledgerId: { type: "string" } }, required: ["ledgerId"] },
76
83
  },
77
84
  required: ["placement", "role", "model", "repository"],
78
85
  });
@@ -166,11 +173,27 @@ export function validateSpawnParams(params) {
166
173
  if (!isPlainObject(params)) {
167
174
  throw lifecycleError("invalid_parameters", "spawn parameters must be an object", "denied");
168
175
  }
176
+ const requiredKeys = ["placement", "role", "model", "repository"];
177
+ const optionalKeys = ["worktreePath", "authority"];
169
178
  const keys = Object.keys(params);
170
- if (keys.length !== 4 || ["placement", "role", "model", "repository"].some((k) => !keys.includes(k))) {
171
- throw lifecycleError("closed_parameters", "spawn_worker accepts exactly placement, role, model, and repository", "denied");
179
+ if (keys.length < requiredKeys.length || keys.length > requiredKeys.length + optionalKeys.length
180
+ || requiredKeys.some((k) => !keys.includes(k))
181
+ || keys.some((k) => !requiredKeys.includes(k) && !optionalKeys.includes(k))
182
+ || (params.authority !== undefined && (!isPlainObject(params.authority) || Object.keys(params.authority).length !== 1 || typeof params.authority.ledgerId !== "string"))) {
183
+ throw lifecycleError("closed_parameters", "spawn_worker accepts placement, role, model, repository, and optional worktreePath or authority ledgerId", "denied");
184
+ }
185
+ const { placement, role, model, repository, worktreePath } = params;
186
+ if (worktreePath !== undefined) {
187
+ if (typeof worktreePath !== "string" || worktreePath.length > 1024 || !worktreePath.trim()
188
+ || worktreePath.includes("\0")) {
189
+ throw lifecycleError("worktree_target_invalid", "worktreePath must be a bounded absolute path", "denied");
190
+ }
191
+ // A relative target is refused here; canonical/realpath identity is
192
+ // decided later against real Git and Treehouse evidence.
193
+ if (!worktreePath.startsWith("/")) {
194
+ throw lifecycleError("worktree_target_invalid", "worktreePath must be an absolute path", "denied");
195
+ }
172
196
  }
173
- const { placement, role, model, repository } = params;
174
197
  if (!SPAWN_PLACEMENTS.includes(placement)) {
175
198
  throw lifecycleError("unsupported_placement", "placement must be tab, right, or below", "denied");
176
199
  }
@@ -184,7 +207,11 @@ export function validateSpawnParams(params) {
184
207
  if (typeof model !== "string" || model.length > 192 || !MODEL_ID_REGEXP.test(model)) {
185
208
  throw lifecycleError("model_denied", "the model must be an installed Pi provider/id or unique model id", "denied");
186
209
  }
187
- return { placement, role, model, repository };
210
+ return {
211
+ placement, role, model, repository,
212
+ ...(worktreePath === undefined ? {} : { worktreePath }),
213
+ ...(params.authority ? { authority: params.authority } : {}),
214
+ };
188
215
  }
189
216
 
190
217
  function parseJsonEnvelope(raw, label, { requireResult = false } = {}) {
@@ -408,12 +435,39 @@ function validateRegistry(registryPath) {
408
435
  return registry.repositories;
409
436
  }
410
437
 
411
- function gitRoot(path) {
412
- const value = spawnSync("git", ["-C", path, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
413
- if (value.status !== 0 || !value.stdout.trim()) {
414
- throw lifecycleError("repository_not_git_root", "the requested repository is not a Git work tree root");
438
+ const GIT_PROBE_TIMEOUT_MS = 10_000;
439
+
440
+ // Repository-selection environment is process input, never tool input, but an
441
+ // inherited GIT_DIR/GIT_COMMON_DIR/GIT_WORK_TREE would silently redirect an
442
+ // authoritative Git probe to another repository. Every lifecycle Git probe
443
+ // therefore runs with all GIT_* variables removed, bounded by timeout and
444
+ // fixed argv with shell:false. This is host-level defense in depth: it does not
445
+ // claim containment of a deliberately hostile parent process tree.
446
+ function controlledEnv() {
447
+ const env = {};
448
+ for (const [key, value] of Object.entries(process.env)) {
449
+ if (/^GIT_/i.test(key)) continue;
450
+ env[key] = value;
451
+ }
452
+ return env;
453
+ }
454
+
455
+ function gitProbe(path, args, code = "repository_not_git_root") {
456
+ const value = spawnSync("git", ["-C", path, ...args], {
457
+ encoding: "utf8",
458
+ shell: false,
459
+ env: controlledEnv(),
460
+ timeout: GIT_PROBE_TIMEOUT_MS,
461
+ maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
462
+ });
463
+ if (value.error || value.status !== 0 || typeof value.stdout !== "string" || !value.stdout.trim()) {
464
+ throw lifecycleError(code, "an authoritative Git probe could not be completed");
415
465
  }
416
- return resolve(value.stdout.trim());
466
+ return value.stdout.trim();
467
+ }
468
+
469
+ function gitRoot(path) {
470
+ return resolve(gitProbe(path, ["rev-parse", "--show-toplevel"]));
417
471
  }
418
472
 
419
473
  // Resolves the requested registry name BEFORE any mutation and re-checks it
@@ -422,7 +476,7 @@ function gitRoot(path) {
422
476
  // Registry lookup order (coordinator decision): the session repository's
423
477
  // own config/herdr-worker-repositories.v1.json first; when missing (ENOENT
424
478
  // only) the active Pi profile's shared config under
425
- // {PI_CODING_AGENT_DIR}/config/ (fallback ~/.pi/agent/config/). A malformed
479
+ // {PI_CODING_AGENT_DIR}/config/ (fallback to the installed agent config directory). A malformed
426
480
  // registry at either level is a stop, never a silent fallthrough; only a
427
481
  // missing one falls back.
428
482
  function resolveProfileConfigPath(relative) {
@@ -485,13 +539,371 @@ export function resolveTrustedSpawnRepository(coordinatorCwd, requestedName) {
485
539
  if (candidateReal !== resolve(coordinatorReal, "..", requestedName)) {
486
540
  throw lifecycleError("repository_untrusted", "the requested repository entry is not a canonical sibling", "denied");
487
541
  }
542
+ let standaloneRoot = false;
543
+ try {
544
+ standaloneRoot = statSync(candidateReal).isDirectory()
545
+ && !lstatSync(candidate).isSymbolicLink()
546
+ && lstatSync(resolve(candidateReal, ".git")).isDirectory();
547
+ } catch { /* missing or non-directory .git is not a standalone repository */ }
548
+ if (basename(candidate) !== basename(candidate).toLowerCase() || !standaloneRoot) {
549
+ throw lifecycleError("repository_not_git_root", "the requested repository must be a lowercase, canonical Git repository root (not a worktree or symlink)", "denied");
550
+ }
488
551
  const root = gitRoot(candidateReal);
489
552
  if (root !== candidateReal) {
490
553
  throw lifecycleError("repository_not_git_root", "the requested repository resolved outside its Git root", "denied");
491
554
  }
555
+ try {
556
+ const dotGit = join(candidateReal, ".git");
557
+ const common = gitProbe(candidateReal, ["rev-parse", "--git-common-dir"]);
558
+ if (!statSync(dotGit).isDirectory() || realpathSync(dotGit) !== dotGit
559
+ || resolve(candidateReal, common) !== dotGit) throw new Error("non-primary Git directory");
560
+ } catch {
561
+ throw lifecycleError("repository_not_git_root", "the requested repository is not a primary clone", "denied");
562
+ }
492
563
  return candidateReal;
493
564
  }
494
565
 
566
+ // --- Optional worktree target (real Git identity + Treehouse lease) ---------
567
+ // The requested worktreePath is a request, never authority. It is trusted only
568
+ // when ALL of the following hold, each observed read-only:
569
+ // 1. it is absolute, lexically canonical, and resolves (realpath) to itself,
570
+ // so a symlink, a `..` segment, or a copied directory never qualifies;
571
+ // 2. it is a genuine registered linked worktree of the registry-authorized
572
+ // canonical repository: same Git common directory, a per-worktree
573
+ // administrative directory under <common>/worktrees/<name>, and an exact
574
+ // `gitdir` backpointer from that administrative directory to the target's
575
+ // own `.git` file. Root/common-dir string equality alone is NOT identity;
576
+ // 3. the stable physical identity of the target directory, canonical
577
+ // repository root, common directory, and administrative directory
578
+ // (device + inode) is bound, so a rename-and-recreate at identical paths
579
+ // is a different target. HEAD and dirty/clean state are deliberately NOT
580
+ // identity: an ordinary legitimately dirty worktree stays valid;
581
+ // 4. Treehouse reports exactly one active lease on that path and its lease id
582
+ // is bound for the rest of the operation.
583
+ // The bound identity is re-observed after confirmation, after coordinator pane
584
+ // observation and immediately before the split, between mutations, after agent
585
+ // start, and after the final read-back; any change fails closed with truthful
586
+ // partial state.
587
+ const MAX_WORKTREE_PATH_CHARS = 1024;
588
+ const LEASE_ID_REGEXP = /^[a-f0-9]{32}$/i;
589
+ const TREEHOUSE_STATUS_TIMEOUT_MS = 10_000;
590
+
591
+ function treehouseStatusJson(options, targetPath) {
592
+ const injected = options?.treehouseStatus;
593
+ if (typeof injected === "function") {
594
+ const value = injected(targetPath);
595
+ if (value && typeof value.then === "function") {
596
+ throw lifecycleError("worktree_lease_untrusted", "asynchronous Treehouse evidence is not supported");
597
+ }
598
+ return value;
599
+ }
600
+ if (typeof injected === "string") return injected;
601
+ // Production path: the read-only status query only. No lease acquire, no
602
+ // return, no prune, no mutation of any kind. Repository-selection variables
603
+ // are stripped for the same reason as the Git probes.
604
+ const result = spawnSync("treehouse", ["status", "--json"], {
605
+ encoding: "utf8",
606
+ shell: false,
607
+ cwd: targetPath,
608
+ env: controlledEnv(),
609
+ timeout: TREEHOUSE_STATUS_TIMEOUT_MS,
610
+ maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
611
+ });
612
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") {
613
+ throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease state could not be read", "denied");
614
+ }
615
+ return result.stdout;
616
+ }
617
+
618
+ function gitCommonDir(path) {
619
+ try {
620
+ return realpathSync(gitProbe(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"],
621
+ "worktree_target_invalid"));
622
+ } catch {
623
+ return null;
624
+ }
625
+ }
626
+
627
+ function gitAbsoluteDir(path) {
628
+ try {
629
+ return realpathSync(gitProbe(path, ["rev-parse", "--absolute-git-dir"], "worktree_target_invalid"));
630
+ } catch {
631
+ return null;
632
+ }
633
+ }
634
+
635
+ function statIdentity(path, code, label) {
636
+ let stats;
637
+ try {
638
+ stats = statSync(path);
639
+ } catch {
640
+ throw lifecycleError(code, `${label} could not be observed`);
641
+ }
642
+ if (!stats.isDirectory()) {
643
+ throw lifecycleError(code, `${label} is not a directory`);
644
+ }
645
+ return `${stats.dev}:${stats.ino}`;
646
+ }
647
+
648
+ function fileIdentity(path, code, label) {
649
+ let stats;
650
+ try {
651
+ stats = lstatSync(path);
652
+ } catch {
653
+ throw lifecycleError(code, `${label} could not be observed`);
654
+ }
655
+ if (!stats.isFile()) {
656
+ throw lifecycleError(code, `${label} is not a regular file`);
657
+ }
658
+ return `${stats.dev}:${stats.ino}`;
659
+ }
660
+
661
+ function containedWorktreeAdminDir(commonDir, adminDir) {
662
+ // A linked worktree's administrative directory is one direct child of
663
+ // <common>/worktrees. Anything else is not a registered linked worktree.
664
+ const root = join(commonDir, "worktrees");
665
+ let rootReal;
666
+ try {
667
+ rootReal = realpathSync(root);
668
+ } catch {
669
+ return null;
670
+ }
671
+ if (rootReal !== root) return null;
672
+ const prefix = `${root}/`;
673
+ if (!adminDir.startsWith(prefix)) return null;
674
+ const name = adminDir.slice(prefix.length);
675
+ if (!name || name.includes("/") || name === "." || name === "..") return null;
676
+ return adminDir;
677
+ }
678
+
679
+ function assertCanonicalWorktreePath(worktreePath) {
680
+ if (typeof worktreePath !== "string" || worktreePath.length > MAX_WORKTREE_PATH_CHARS
681
+ || !worktreePath.startsWith("/") || worktreePath.trim() !== worktreePath) {
682
+ throw lifecycleError("worktree_target_invalid", "worktreePath must be a bounded absolute path", "denied");
683
+ }
684
+ // Lexical canonicality: reject `..`, doubled separators, and a trailing
685
+ // separator even when the filesystem would resolve them.
686
+ if (resolve(worktreePath) !== worktreePath) {
687
+ throw lifecycleError("worktree_target_invalid", "worktreePath is not lexically canonical", "denied");
688
+ }
689
+ let observedReal;
690
+ try {
691
+ observedReal = realpathSync(worktreePath);
692
+ } catch {
693
+ throw lifecycleError("worktree_target_unavailable", "worktreePath does not exist", "denied");
694
+ }
695
+ if (observedReal !== worktreePath) {
696
+ throw lifecycleError("worktree_target_invalid", "worktreePath resolves through a link or is not canonical", "denied");
697
+ }
698
+ return observedReal;
699
+ }
700
+
701
+ function worktreeAdministrativeIdentity(targetReal, canonicalRoot) {
702
+ if (targetReal === canonicalRoot) {
703
+ throw lifecycleError("worktree_target_untrusted", "worktreePath is the canonical repository root, not a worktree", "denied");
704
+ }
705
+ let targetRoot;
706
+ try {
707
+ targetRoot = gitRoot(targetReal);
708
+ } catch {
709
+ throw lifecycleError("worktree_target_invalid", "worktreePath is not a Git work tree root", "denied");
710
+ }
711
+ if (realpathSync(targetRoot) !== targetReal) {
712
+ throw lifecycleError("worktree_target_invalid", "worktreePath is not the Git work tree root", "denied");
713
+ }
714
+ const registeredCommon = gitCommonDir(canonicalRoot);
715
+ const targetCommon = gitCommonDir(targetReal);
716
+ if (!registeredCommon || !targetCommon || registeredCommon !== targetCommon) {
717
+ throw lifecycleError("worktree_target_untrusted", "worktreePath is not a worktree of the registered repository", "denied");
718
+ }
719
+ const adminDir = gitAbsoluteDir(targetReal);
720
+ const registeredAdminDir = containedWorktreeAdminDir(registeredCommon, adminDir ?? "");
721
+ if (!adminDir || !registeredAdminDir) {
722
+ throw lifecycleError("worktree_target_untrusted", "worktreePath has no registered per-worktree Git administration", "denied");
723
+ }
724
+ // Genuine backpointer: the administrative directory must point back at THIS
725
+ // worktree's own .git file. A copied .git pointer that borrows another
726
+ // slot's administration fails here even when root/common-dir match.
727
+ const dotGit = join(targetReal, ".git");
728
+ let dotGitStats;
729
+ try {
730
+ dotGitStats = lstatSync(dotGit);
731
+ } catch {
732
+ throw lifecycleError("worktree_target_untrusted", "worktreePath has no Git worktree pointer", "denied");
733
+ }
734
+ if (!dotGitStats.isFile() || dotGitStats.isSymbolicLink()) {
735
+ throw lifecycleError("worktree_target_untrusted", "worktreePath does not use a linked-worktree Git pointer", "denied");
736
+ }
737
+ let pointerText;
738
+ try {
739
+ pointerText = readFileSync(dotGit, "utf8").trim();
740
+ } catch {
741
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer is unreadable", "denied");
742
+ }
743
+ if (!pointerText.startsWith("gitdir: ")) {
744
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer is malformed", "denied");
745
+ }
746
+ let pointerAdmin;
747
+ try {
748
+ pointerAdmin = realpathSync(resolve(targetReal, pointerText.slice("gitdir: ".length).trim()));
749
+ } catch {
750
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer does not resolve", "denied");
751
+ }
752
+ if (pointerAdmin !== adminDir) {
753
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer names a different administrative directory", "denied");
754
+ }
755
+ let backpointerText;
756
+ try {
757
+ backpointerText = readFileSync(join(adminDir, "gitdir"), "utf8").trim();
758
+ } catch {
759
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git administrative record is unreadable", "denied");
760
+ }
761
+ let backpointer;
762
+ try {
763
+ backpointer = realpathSync(resolve(adminDir, backpointerText));
764
+ } catch {
765
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git administrative backpointer does not resolve", "denied");
766
+ }
767
+ let dotGitReal;
768
+ try {
769
+ dotGitReal = realpathSync(dotGit);
770
+ } catch {
771
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer cannot be canonicalized", "denied");
772
+ }
773
+ if (backpointer !== dotGitReal) {
774
+ throw lifecycleError("worktree_target_untrusted", "worktreePath Git administrative backpointer names a different worktree", "denied");
775
+ }
776
+ return Object.freeze({
777
+ repositoryPath: canonicalRoot,
778
+ target: targetReal,
779
+ commonDir: registeredCommon,
780
+ adminDir,
781
+ dotGit: dotGitReal,
782
+ targetIdentity: statIdentity(targetReal, "worktree_target_untrusted", "the worktree target"),
783
+ repositoryIdentity: statIdentity(canonicalRoot, "worktree_target_untrusted", "the registered repository"),
784
+ commonIdentity: statIdentity(registeredCommon, "worktree_target_untrusted", "the Git common directory"),
785
+ adminIdentity: statIdentity(adminDir, "worktree_target_untrusted", "the worktree administrative directory"),
786
+ dotGitIdentity: fileIdentity(dotGitReal, "worktree_target_untrusted", "the worktree Git pointer"),
787
+ });
788
+ }
789
+
790
+ function observedLeaseIdentity(targetReal, options) {
791
+ const raw = treehouseStatusJson(options, targetReal);
792
+ if (typeof raw !== "string" || !raw.trim()
793
+ || Buffer.byteLength(raw, "utf8") > MAX_PROCESS_OUTPUT_BYTES) {
794
+ throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease evidence is unusable", "denied");
795
+ }
796
+ let entries;
797
+ try {
798
+ entries = JSON.parse(raw);
799
+ } catch {
800
+ throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease evidence is malformed", "denied");
801
+ }
802
+ if (!Array.isArray(entries) || entries.length > 256) {
803
+ throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease evidence is malformed", "denied");
804
+ }
805
+ const matches = entries.filter((entry) => {
806
+ if (!isPlainObject(entry) || typeof entry.path !== "string" || !entry.path.startsWith("/")) return false;
807
+ try {
808
+ return realpathSync(entry.path) === targetReal;
809
+ } catch {
810
+ return false;
811
+ }
812
+ });
813
+ if (matches.length !== 1) {
814
+ throw lifecycleError("worktree_lease_untrusted", "the worktree target is not a managed pool worktree", "denied");
815
+ }
816
+ const entry = matches[0];
817
+ if (entry.status !== "leased") {
818
+ throw lifecycleError("worktree_lease_untrusted", "the worktree target is not actively leased", "denied");
819
+ }
820
+ if (typeof entry.lease_id !== "string" || !LEASE_ID_REGEXP.test(entry.lease_id)) {
821
+ throw lifecycleError("worktree_lease_untrusted", "the worktree lease identity is not trustworthy", "denied");
822
+ }
823
+ const holder = typeof entry.lease_holder === "string" && entry.lease_holder.length <= 128
824
+ ? entry.lease_holder
825
+ : null;
826
+ return Object.freeze({ leaseId: entry.lease_id.toLowerCase(), holder });
827
+ }
828
+
829
+ function resolveWorktreeIdentity(canonicalRoot, worktreePath, options) {
830
+ const targetReal = assertCanonicalWorktreePath(worktreePath);
831
+ const admin = worktreeAdministrativeIdentity(targetReal, canonicalRoot);
832
+ const lease = observedLeaseIdentity(targetReal, options);
833
+ return Object.freeze({
834
+ worktreePath: targetReal,
835
+ leaseId: lease.leaseId,
836
+ leaseHolder: lease.holder,
837
+ repositoryPath: admin.repositoryPath,
838
+ commonDir: admin.commonDir,
839
+ adminDir: admin.adminDir,
840
+ dotGit: admin.dotGit,
841
+ identity: Object.freeze({
842
+ target: admin.targetIdentity,
843
+ repository: admin.repositoryIdentity,
844
+ common: admin.commonIdentity,
845
+ admin: admin.adminIdentity,
846
+ dotGit: admin.dotGitIdentity,
847
+ }),
848
+ });
849
+ }
850
+
851
+ export function resolveWorktreeSpawnTarget(canonicalRoot, worktreePath, options = {}) {
852
+ return resolveWorktreeIdentity(canonicalRoot, worktreePath, options);
853
+ }
854
+
855
+ export function worktreeTargetUnchanged(left, right) {
856
+ if (!left || !right) return false;
857
+ if (left.worktreePath !== right.worktreePath || left.leaseId !== right.leaseId) return false;
858
+ if (left.repositoryPath !== right.repositoryPath
859
+ || left.commonDir !== right.commonDir
860
+ || left.adminDir !== right.adminDir
861
+ || left.dotGit !== right.dotGit) {
862
+ return false;
863
+ }
864
+ for (const key of ["target", "repository", "common", "admin", "dotGit"]) {
865
+ if (left.identity?.[key] !== right.identity?.[key]) return false;
866
+ }
867
+ return true;
868
+ }
869
+
870
+ function revalidateWorktreeTarget(canonicalRoot, request, bound, options) {
871
+ if (!request.worktreePath) return null;
872
+ let observed;
873
+ try {
874
+ observed = resolveWorktreeIdentity(canonicalRoot, request.worktreePath, options);
875
+ } catch (error) {
876
+ throw lifecycleError(
877
+ "worktree_target_changed",
878
+ `the confirmed worktree target is no longer valid: ${error?.message || "revalidation failed"}`,
879
+ "blocked",
880
+ );
881
+ }
882
+ if (!worktreeTargetUnchanged(observed, bound)) {
883
+ throw lifecycleError(
884
+ "worktree_target_changed",
885
+ "the worktree physical identity, Git administrative association, or lease identity changed during the spawn",
886
+ "blocked",
887
+ );
888
+ }
889
+ return observed;
890
+ }
891
+
892
+ // Registry membership is authority for the whole spawn, not only for the
893
+ // pre-mutation resolve. Every boundary that already re-observes the bound
894
+ // target and lease also re-resolves the CURRENT registry, so a membership
895
+ // revocation during an asynchronous observation fails closed at that boundary
896
+ // instead of at a later one (or after a success report). This composes the
897
+ // existing resolvers; it is not a second validation owner.
898
+ function revalidateSpawnAuthority(coordinatorCwd, requestedName, canonicalRoot, request, bound, options, boundary) {
899
+ const currentRoot = resolveTrustedSpawnRepository(coordinatorCwd, requestedName);
900
+ if (currentRoot !== canonicalRoot) {
901
+ throw lifecycleError("repository_mismatch", `the trusted repository changed ${boundary}`);
902
+ }
903
+ if (request.worktreePath) revalidateWorktreeTarget(currentRoot, request, bound, options);
904
+ return currentRoot;
905
+ }
906
+
495
907
  // --- Installed Pi model roll (read-only; no allowlist constant) -------------
496
908
  // The authoritative installed roll is the merged Pi catalog: built-in provider
497
909
  // models plus the profile's custom models.json entries. It resolves read-only
@@ -579,18 +991,99 @@ export function resolveInstalledModel(requested, options = {}) {
579
991
  ? options.modelsPath
580
992
  : resolvePiModelsPath();
581
993
  for (const pair of readCustomModelPairs(modelsPath)) pairs.add(pair);
582
- const slash = requested.indexOf("/");
583
- const matches = slash < 0
584
- ? [...pairs].filter((pair) => pair.slice(pair.indexOf("/") + 1) === requested)
585
- : (pairs.has(requested) ? [requested] : []);
586
- if (matches.length === 0) {
587
- throw lifecycleError("model_unknown", "the requested model is not in the installed Pi model roll", "denied");
994
+ if (typeof requested !== "string" || !MODEL_ID_REGEXP.test(requested)) {
995
+ throw lifecycleError("model_denied", "the requested model is invalid", "denied");
996
+ }
997
+ // The local policy owns names, provider classes, forbidden pairs and budgets.
998
+ // A missing or malformed policy never authorizes an inferred gateway route.
999
+ let policy = options.routePolicy;
1000
+ if (policy === undefined) {
1001
+ const path = options.policyPath ?? resolveProfileConfigPath("config/model-route-policy.v1.local.json").path;
1002
+ try {
1003
+ const info = lstatSync(path);
1004
+ // A symlink at the file or ANY parent of the path fails closed.
1005
+ if (info.isSymbolicLink() || realpathSync(path) !== path) {
1006
+ throw lifecycleError("model_policy_symlink", `model routing policy path is a symlink: ${path}`, "denied");
1007
+ }
1008
+ if (!info.isFile() || info.size > 16_384) {
1009
+ throw lifecycleError("model_policy_invalid", `model routing policy file is invalid: ${path}`, "denied");
1010
+ }
1011
+ policy = JSON.parse(readFileSync(path, "utf8"));
1012
+ } catch (error) {
1013
+ if (error?.name === "HerdrLifecycleError") throw error;
1014
+ if (error?.code === "ENOENT") {
1015
+ throw lifecycleError("model_policy_missing", `model routing policy is unavailable: ${path}; fill it in locally before spawning`, "denied");
1016
+ }
1017
+ throw lifecycleError("model_policy_invalid", `model routing policy could not be read: ${path}`, "denied");
1018
+ }
1019
+ }
1020
+ if (policy?.schema !== "agentic-driver.model-route-policy.v1" || !Array.isArray(policy.routes)
1021
+ || !Array.isArray(policy.bannedPairs) || !isPlainObject(policy.budgets)
1022
+ || policy.routes.length > 128 || policy.bannedPairs.length > 128
1023
+ || policy.routes.some((route) => !isPlainObject(route) || typeof route.provider !== "string"
1024
+ || typeof route.model !== "string" || !PROVIDER_TOKEN_REGEXP.test(route.provider)
1025
+ || !MODEL_TOKEN_REGEXP.test(route.model) || typeof route.family !== "string"
1026
+ || !/^[a-z0-9-]{1,64}$/.test(route.family) || typeof route.subscription !== "boolean"
1027
+ || typeof route.gateway !== "boolean" || route.subscription === route.gateway
1028
+ || !Array.isArray(route.aliases) || route.aliases.length > 16
1029
+ || route.aliases.some((alias) => typeof alias !== "string" || !/^[a-zA-Z0-9 ._-]{1,128}$/.test(alias)))
1030
+ || policy.bannedPairs.some((pair) => !isPlainObject(pair)
1031
+ || typeof pair.family !== "string" || typeof pair.provider !== "string")
1032
+ || new Set(policy.routes.map((route) => `${route.provider}/${route.model}`)).size !== policy.routes.length) {
1033
+ throw lifecycleError("model_policy_invalid", "the local model routing policy is invalid", "denied");
1034
+ }
1035
+ const normalize = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
1036
+ const explicit = requested.includes("/");
1037
+ const matching = policy.routes.filter((route) => {
1038
+ const pair = `${route.provider}/${route.model}`;
1039
+ return pairs.has(pair) && (explicit ? pair === requested
1040
+ : [route.model, ...route.aliases].some((alias) => normalize(alias) === normalize(requested)));
1041
+ });
1042
+ if (!matching.length) throw lifecycleError("model_unknown", "requested model has no installed policy route", "denied");
1043
+ const family = matching[0].family;
1044
+ if (matching.some((route) => route.family !== family)) {
1045
+ throw lifecycleError("model_ambiguous", "the name matches multiple model families; specify a provider", "denied");
1046
+ }
1047
+ const routes = policy.routes.filter((route) => route.family === family && pairs.has(`${route.provider}/${route.model}`));
1048
+ const subscriptions = routes.filter((route) => route.subscription);
1049
+ // Production currently treats Pi roll membership as availability. The probe
1050
+ // seam is test-only until a live non-credential availability signal is wired.
1051
+ const available = subscriptions.filter((route) => options.isRouteAvailable?.(`${route.provider}/${route.model}`) !== false);
1052
+ const suggestion = subscriptions.length
1053
+ ? subscriptions.map((route) => `${route.provider}/${route.model}`).join(", ")
1054
+ : "no installed subscription route";
1055
+ const banned = (route) => policy.bannedPairs.some((pair) => pair.family === route.family && pair.provider === route.provider);
1056
+ if (explicit && banned(matching[0])) {
1057
+ throw lifecycleError("model_gateway_denied", `route forbidden; use subscription route ${suggestion}`, "denied");
1058
+ }
1059
+ if (!explicit && available.length > 1) {
1060
+ throw lifecycleError("model_ambiguous", `multiple subscription routes match; specify one: ${suggestion}`, "denied");
1061
+ }
1062
+ if (!explicit && available.length === 1) {
1063
+ if (banned(available[0])) {
1064
+ throw lifecycleError("model_gateway_denied", `route forbidden; use another subscription route (available: ${suggestion})`, "denied");
1065
+ }
1066
+ return Object.freeze(["--model", `${available[0].provider}/${available[0].model}`]);
1067
+ }
1068
+ if (explicit && matching[0].subscription) {
1069
+ // An exact request cannot silently switch to a gateway; only a loose name
1070
+ // can use a separately budgeted fallback when its subscription is unavailable.
1071
+ if (!available.includes(matching[0])) throw lifecycleError("model_unavailable", `subscription route ${requested} is unavailable`, "denied");
1072
+ return Object.freeze(["--model", requested]);
1073
+ }
1074
+ if (explicit && available.length) {
1075
+ throw lifecycleError("model_subscription_preferred", `use subscription route ${suggestion}; explicit provider will not be silently switched`, "denied");
588
1076
  }
589
- if (matches.length > 1) {
590
- throw lifecycleError("model_ambiguous", "the requested model id matches more than one installed provider model", "denied");
1077
+ const eligible = (explicit ? matching : routes).filter((route) => route.gateway && !banned(route));
1078
+ if (eligible.length !== 1) {
1079
+ throw lifecycleError("model_ambiguous", `requested model has ${eligible.length} eligible gateway routes; specify a provider (subscription: ${suggestion})`, "denied");
591
1080
  }
592
- // Documented Pi selection flag; no thinking suffix, no extra arguments.
593
- return Object.freeze(["--model", matches[0]]);
1081
+ const chosen = eligible[0];
1082
+ const budget = policy.budgets[chosen.provider]?.[chosen.model];
1083
+ if (budget !== "no-limit" && !(typeof budget === "number" && Number.isFinite(budget) && budget > 0)) {
1084
+ throw lifecycleError("model_budget_required", `gateway route ${chosen.provider}/${chosen.model} needs an explicit per-provider, per-model budget or no-limit; subscription route: ${suggestion}`, "denied");
1085
+ }
1086
+ return Object.freeze(["--model", `${chosen.provider}/${chosen.model}`]);
594
1087
  }
595
1088
 
596
1089
  // --- Fixed argv builders ----------------------------------------------------
@@ -660,7 +1153,8 @@ async function invokeHerdr(argv, canonicalRoot, options, signal) {
660
1153
  // Production path: the shared bounded spawner in the communication adapter
661
1154
  // is process-level; spawn here is reserved to the fixed argv builders via
662
1155
  // the same trusted executable pin, so the native spawn seam stays closed.
663
- const { spawn } = await import("node:child_process");
1156
+ // Import statically: dispatch must not yield after the caller's final
1157
+ // authority observation and before the actual subprocess is created.
664
1158
  raw = await new Promise((resolveResult) => {
665
1159
  const child = spawn(executable, spec.argv, {
666
1160
  ...spec.spawnOptions,
@@ -732,6 +1226,13 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
732
1226
  // Resolve BEFORE any mutation: registry + realpath + Git root.
733
1227
  let canonicalRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
734
1228
  const modelArgv = resolveInstalledModel(model, options);
1229
+ // An optional worktree target is validated read-only here (canonical path,
1230
+ // real Git repository identity, active Treehouse lease) and its observed
1231
+ // lease identity is bound for the rest of the operation.
1232
+ let worktreeTarget = request.worktreePath
1233
+ ? resolveWorktreeSpawnTarget(canonicalRoot, request.worktreePath, options)
1234
+ : null;
1235
+ let spawnCwd = worktreeTarget ? worktreeTarget.worktreePath : canonicalRoot;
735
1236
 
736
1237
  // The calling pane must be a Herdr session pane; `pane current --current`
737
1238
  // resolves the coordinator pane identity for the final move step.
@@ -742,7 +1243,7 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
742
1243
 
743
1244
  // Native confirmation is required before any mutation; the cheap local
744
1245
  // gate comes before any Herdr observation call.
745
- if (!isNativeTuiContext(context)) {
1246
+ if (!request.authority && !isNativeTuiContext(context)) {
746
1247
  throw lifecycleError("native_confirmation_required", "spawn_worker requires the interactive Pi TUI", "blocked");
747
1248
  }
748
1249
 
@@ -751,7 +1252,20 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
751
1252
  if (duplicateRoleExists(listed, role)) {
752
1253
  throw lifecycleError("duplicate_worker_name", "a live agent already holds this name", "denied");
753
1254
  }
754
- const confirmed = await context.ui.confirm(
1255
+ let confirmed;
1256
+ if (request.authority) {
1257
+ try {
1258
+ // A ledger grant is bound to the exact target when a worktree is
1259
+ // requested: a repository-only grant never covers an unrecorded path.
1260
+ await reserveApprovalUse(resolveProfileConfigPath(".").base, request.authority.ledgerId, {
1261
+ tier: 1, repository, action: "spawn", model, role,
1262
+ ...(worktreeTarget ? { path: worktreeTarget.worktreePath } : {}),
1263
+ });
1264
+ confirmed = true;
1265
+ } catch (error) {
1266
+ return errorResult(lifecycleError("approval_missing", error.message, "needs-owner"), needsOwner(1, ["spawn approval covering repository, model and role"], repository));
1267
+ }
1268
+ } else confirmed = await context.ui.confirm(
755
1269
  "Spawn Herdr worker",
756
1270
  [
757
1271
  "One guarded composite spawn: create the requested layout, then start and verify one Pi agent.",
@@ -761,6 +1275,12 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
761
1275
  : `Placement: ${placement} (Herdr split: ${HERDR_DIRECTIONS[placement]})`,
762
1276
  `Model: ${modelArgv[1]} (argv: ${modelArgv.join(" ")})`,
763
1277
  `Repository: ${canonicalRoot}`,
1278
+ ...(worktreeTarget
1279
+ ? [
1280
+ `Worktree target: ${worktreeTarget.worktreePath}`,
1281
+ `Treehouse lease: ${worktreeTarget.leaseId}${worktreeTarget.leaseHolder ? ` (holder: ${worktreeTarget.leaseHolder})` : ""}`,
1282
+ ]
1283
+ : []),
764
1284
  "No rollback is automatic; a failed step leaves the created state for human cleanup.",
765
1285
  ].join("\n"),
766
1286
  );
@@ -769,18 +1289,23 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
769
1289
  }
770
1290
 
771
1291
  // Revalidation point after confirmation, before the first mutation.
772
- const reconfirmedRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
773
- if (reconfirmedRoot !== canonicalRoot) {
774
- throw lifecycleError("repository_mismatch", "the trusted repository changed after confirmation");
775
- }
776
- canonicalRoot = reconfirmedRoot;
1292
+ canonicalRoot = revalidateSpawnAuthority(
1293
+ context?.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after confirmation",
1294
+ );
1295
+ if (request.worktreePath) spawnCwd = worktreeTarget.worktreePath;
777
1296
 
778
1297
  // Herdr owns new-shell readiness. Follow its documented topology:
779
1298
  // split directly for pane placement, or create a tab for tab placement.
780
1299
  let coordinator = null;
781
1300
  if (placement !== "tab") {
782
- const currentRaw = await invokeHerdr(currentPaneArgv(), canonicalRoot, options, signal);
1301
+ const currentRaw = await invokeHerdr(currentPaneArgv(), spawnCwd, options, signal);
783
1302
  coordinator = parseCurrentPane(currentRaw);
1303
+ // The coordinator observation is asynchronous; a registry, lease, or
1304
+ // association change during it must fail closed before the split.
1305
+ canonicalRoot = revalidateSpawnAuthority(
1306
+ context?.cwd, repository, canonicalRoot, request, worktreeTarget, options,
1307
+ "during the coordinator pane observation",
1308
+ );
784
1309
  }
785
1310
 
786
1311
  const layoutPartial = placement === "tab" ? "tab_created" : "pane_created";
@@ -789,12 +1314,12 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
789
1314
  let tabId;
790
1315
  try {
791
1316
  if (placement === "tab") {
792
- layoutRaw = await invokeHerdr(tabCreateArgv(canonicalRoot, role), canonicalRoot, options, signal);
1317
+ layoutRaw = await invokeHerdr(tabCreateArgv(spawnCwd, role), spawnCwd, options, signal);
793
1318
  ({ rootPaneId, tabId } = parseTabCreate(layoutRaw));
794
1319
  } else {
795
1320
  layoutRaw = await invokeHerdr(
796
- splitArgv(coordinator.paneId, canonicalRoot, placement),
797
- canonicalRoot, options, signal,
1321
+ splitArgv(coordinator.paneId, spawnCwd, placement),
1322
+ spawnCwd, options, signal,
798
1323
  );
799
1324
  const split = parsePaneSplit(layoutRaw, coordinator.tabId);
800
1325
  rootPaneId = split.paneId;
@@ -809,17 +1334,16 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
809
1334
 
810
1335
  let agentStartSucceeded = false;
811
1336
  try {
812
- // Re-check the registry between the mutations.
813
- const betweenRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
814
- if (betweenRoot !== canonicalRoot) {
815
- throw lifecycleError("repository_mismatch", "the trusted repository changed between mutations");
816
- }
1337
+ // Re-check registry authorization and the bound target between mutations.
1338
+ canonicalRoot = revalidateSpawnAuthority(
1339
+ context?.cwd, repository, canonicalRoot, request, worktreeTarget, options, "between mutations",
1340
+ );
817
1341
 
818
1342
  // Start exactly once in the shell pane returned by the layout command.
819
1343
  const startCall = startArgv(role, rootPaneId, modelArgv);
820
1344
  let startRaw;
821
1345
  try {
822
- startRaw = await invokeHerdr(startCall, canonicalRoot, options, signal);
1346
+ startRaw = await invokeHerdr(startCall, spawnCwd, options, signal);
823
1347
  agentStartSucceeded = true;
824
1348
  } catch (error) {
825
1349
  throw lifecycleError("start_failed", error?.message || "Herdr agent start failed");
@@ -827,23 +1351,37 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
827
1351
 
828
1352
  // Agent start is a mutation, so authorization must be resolved again
829
1353
  // before accepting any start/read-back facts.
830
- const postStartRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
831
- if (postStartRoot !== canonicalRoot) {
832
- throw lifecycleError("repository_mismatch", "the trusted repository changed after agent start");
833
- }
834
- canonicalRoot = postStartRoot;
1354
+ canonicalRoot = revalidateSpawnAuthority(
1355
+ context?.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after agent start",
1356
+ );
835
1357
 
836
- const startedPaneId = readBackStart(startRaw, role, canonicalRoot, [...modelArgv]);
1358
+ const startedPaneId = readBackStart(startRaw, role, spawnCwd, [...modelArgv]);
837
1359
  if (startedPaneId !== rootPaneId) {
838
1360
  throw lifecycleError("pane_mismatch", "agent start returned a different pane identity");
839
1361
  }
840
- const agentRaw = await invokeHerdr(["agent", "get", role], canonicalRoot, options, signal);
841
- const agentReadBack = readBackAgent(agentRaw, role, canonicalRoot, [...modelArgv]);
1362
+ const agentRaw = await invokeHerdr(["agent", "get", role], spawnCwd, options, signal);
1363
+ const agentReadBack = readBackAgent(agentRaw, role, spawnCwd, [...modelArgv]);
842
1364
  if (agentReadBack.paneId !== rootPaneId) {
843
1365
  throw lifecycleError("pane_mismatch", "agent get returned a different pane identity");
844
1366
  }
845
- const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], canonicalRoot, options, signal);
846
- readBackPane(paneRaw, rootPaneId, canonicalRoot);
1367
+ const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], spawnCwd, options, signal);
1368
+ readBackPane(paneRaw, rootPaneId, spawnCwd);
1369
+ // The read-backs are asynchronous too. A registry, lease, or
1370
+ // association change during them must not be reported as a successful
1371
+ // spawn.
1372
+ revalidateSpawnAuthority(
1373
+ context?.cwd, repository, canonicalRoot, request, worktreeTarget, options,
1374
+ "during the final read-backs",
1375
+ );
1376
+ if (request.authority) {
1377
+ await recordApprovalUse(resolveProfileConfigPath(".").base, request.authority.ledgerId, "spawn", repository, "spawned");
1378
+ // The audit write can await its writer lock. Recheck authority after
1379
+ // that wait, before reporting success for the already-started agent.
1380
+ revalidateSpawnAuthority(
1381
+ context?.cwd, repository, canonicalRoot, request, worktreeTarget, options,
1382
+ "during the final approval-use audit write",
1383
+ );
1384
+ }
847
1385
 
848
1386
  return {
849
1387
  schema: HERDR_LIFECYCLE_SCHEMA,
@@ -858,6 +1396,9 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
858
1396
  tabId,
859
1397
  modelArgv: [...modelArgv],
860
1398
  repository: canonicalRoot,
1399
+ ...(worktreeTarget
1400
+ ? { worktreePath: worktreeTarget.worktreePath, leaseId: worktreeTarget.leaseId }
1401
+ : {}),
861
1402
  hashes: Object.freeze({
862
1403
  request: sha256(JSON.stringify(request)),
863
1404
  layoutCreateResponse: sha256(layoutRaw),
@@ -937,7 +1478,12 @@ function attendedElectronParams(params) {
937
1478
  function resolveAttendedElectronPrerequisites(request, context, options) {
938
1479
  const canonicalRoot = resolveTrustedSpawnRepository(context.cwd, request.repository);
939
1480
  const modelArgv = resolveInstalledModel(request.model, options);
940
- return { canonicalRoot, modelArgv };
1481
+ // The optional worktree target follows exactly the same rules on this
1482
+ // boundary: canonical path, real Git repository identity, active lease.
1483
+ const worktreeTarget = request.worktreePath
1484
+ ? resolveWorktreeSpawnTarget(canonicalRoot, request.worktreePath, options)
1485
+ : null;
1486
+ return { canonicalRoot, modelArgv, worktreeTarget };
941
1487
  }
942
1488
 
943
1489
  function assertHerdrExecutableAvailable(options) {
@@ -975,8 +1521,12 @@ export async function preflightAttendedElectronSpawn(params, context, options =
975
1521
  attendedElectronContextError(context);
976
1522
  const request = attendedElectronParams(params);
977
1523
  assertHerdrExecutableAvailable(options);
978
- const { canonicalRoot, modelArgv } = resolveAttendedElectronPrerequisites(request, context, options);
979
- await observeDuplicateRole(canonicalRoot, request.role, options, null);
1524
+ const { canonicalRoot, modelArgv, worktreeTarget } = resolveAttendedElectronPrerequisites(request, context, options);
1525
+ await observeDuplicateRole(worktreeTarget ? worktreeTarget.worktreePath : canonicalRoot, request.role, options, null);
1526
+ revalidateSpawnAuthority(
1527
+ context.cwd, request.repository, canonicalRoot, request, worktreeTarget, options,
1528
+ "during the preflight agent-list observation",
1529
+ );
980
1530
  return {
981
1531
  schema: HERDR_LIFECYCLE_SCHEMA,
982
1532
  ok: true,
@@ -985,6 +1535,9 @@ export async function preflightAttendedElectronSpawn(params, context, options =
985
1535
  placement: "tab",
986
1536
  role: request.role,
987
1537
  repository: canonicalRoot,
1538
+ ...(worktreeTarget
1539
+ ? { worktreePath: worktreeTarget.worktreePath, leaseId: worktreeTarget.leaseId }
1540
+ : {}),
988
1541
  modelArgv: [...modelArgv],
989
1542
  nonAuthorizing: true,
990
1543
  authorityCreated: false,
@@ -994,13 +1547,19 @@ export async function preflightAttendedElectronSpawn(params, context, options =
994
1547
  }
995
1548
  }
996
1549
 
997
- function attendedElectronConfirmBody(role, modelArgv, canonicalRoot) {
1550
+ function attendedElectronConfirmBody(role, modelArgv, canonicalRoot, worktreeTarget = null) {
998
1551
  return [
999
1552
  "One guarded attended-Electron composite spawn: create one tab, then start and verify one Pi agent.",
1000
1553
  `Role: ${role}`,
1001
1554
  "Placement: individual tab (the Electron host has no Herdr session pane)",
1002
1555
  `Model: ${modelArgv[1]} (argv: ${modelArgv.join(" ")})`,
1003
1556
  `Repository: ${canonicalRoot}`,
1557
+ ...(worktreeTarget
1558
+ ? [
1559
+ `Worktree target: ${worktreeTarget.worktreePath}`,
1560
+ `Treehouse lease: ${worktreeTarget.leaseId}${worktreeTarget.leaseHolder ? ` (holder: ${worktreeTarget.leaseHolder})` : ""}`,
1561
+ ]
1562
+ : []),
1004
1563
  "No rollback is automatic; a failed step leaves the created state for human cleanup.",
1005
1564
  ].join("\n");
1006
1565
  }
@@ -1016,7 +1575,11 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
1016
1575
  // installed model roll. Duplicate live names are denied at spawn.
1017
1576
  let canonicalRoot = resolveTrustedSpawnRepository(context.cwd, repository);
1018
1577
  const modelArgv = resolveInstalledModel(model, options);
1019
- await observeDuplicateRole(canonicalRoot, role, options, signal);
1578
+ let worktreeTarget = request.worktreePath
1579
+ ? resolveWorktreeSpawnTarget(canonicalRoot, request.worktreePath, options)
1580
+ : null;
1581
+ let spawnCwd = worktreeTarget ? worktreeTarget.worktreePath : canonicalRoot;
1582
+ await observeDuplicateRole(spawnCwd, role, options, signal);
1020
1583
 
1021
1584
  // ONE native owner confirmation from the attended host. A denial is a
1022
1585
  // stop; a throw is a structured refusal. Never re-asked, never retried.
@@ -1024,7 +1587,7 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
1024
1587
  try {
1025
1588
  confirmed = await context.ui.confirm(
1026
1589
  "Spawn Herdr worker (attended Electron)",
1027
- attendedElectronConfirmBody(role, modelArgv, canonicalRoot),
1590
+ attendedElectronConfirmBody(role, modelArgv, canonicalRoot, worktreeTarget),
1028
1591
  );
1029
1592
  } catch (error) {
1030
1593
  throw lifecycleError(
@@ -1040,11 +1603,10 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
1040
1603
  // Revalidation point after confirmation, before the first mutation. A
1041
1604
  // change here is a post-confirmation race: the caller reports it as a
1042
1605
  // refusal; nothing has been mutated yet.
1043
- const reconfirmedRoot = resolveTrustedSpawnRepository(context.cwd, repository);
1044
- if (reconfirmedRoot !== canonicalRoot) {
1045
- throw lifecycleError("repository_mismatch", "the trusted repository changed after confirmation");
1046
- }
1047
- canonicalRoot = reconfirmedRoot;
1606
+ canonicalRoot = revalidateSpawnAuthority(
1607
+ context.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after confirmation",
1608
+ );
1609
+ if (request.worktreePath) spawnCwd = worktreeTarget.worktreePath;
1048
1610
 
1049
1611
  // Tab topology only: no coordinator pane identity is resolved, used, or
1050
1612
  // manufactured anywhere on this path.
@@ -1052,7 +1614,7 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
1052
1614
  let rootPaneId;
1053
1615
  let layoutRaw;
1054
1616
  try {
1055
- layoutRaw = await invokeHerdr(tabCreateArgv(canonicalRoot, role), canonicalRoot, options, signal);
1617
+ layoutRaw = await invokeHerdr(tabCreateArgv(spawnCwd, role), spawnCwd, options, signal);
1056
1618
  ({ rootPaneId, tabId } = parseTabCreate(layoutRaw));
1057
1619
  } catch (error) {
1058
1620
  return partialResult(error, {
@@ -1063,39 +1625,40 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
1063
1625
 
1064
1626
  let agentStartSucceeded = false;
1065
1627
  try {
1066
- // Re-check the registry between the mutations.
1067
- const betweenRoot = resolveTrustedSpawnRepository(context.cwd, repository);
1068
- if (betweenRoot !== canonicalRoot) {
1069
- throw lifecycleError("repository_mismatch", "the trusted repository changed between mutations");
1070
- }
1628
+ // Re-check registry authorization and the bound target between mutations.
1629
+ canonicalRoot = revalidateSpawnAuthority(
1630
+ context.cwd, repository, canonicalRoot, request, worktreeTarget, options, "between mutations",
1631
+ );
1071
1632
 
1072
1633
  // Start exactly once in the tab's root pane.
1073
1634
  const startCall = startArgv(role, rootPaneId, modelArgv);
1074
1635
  let startRaw;
1075
1636
  try {
1076
- startRaw = await invokeHerdr(startCall, canonicalRoot, options, signal);
1637
+ startRaw = await invokeHerdr(startCall, spawnCwd, options, signal);
1077
1638
  agentStartSucceeded = true;
1078
1639
  } catch (error) {
1079
1640
  throw lifecycleError("start_failed", error?.message || "Herdr agent start failed");
1080
1641
  }
1081
1642
 
1082
- const postStartRoot = resolveTrustedSpawnRepository(context.cwd, repository);
1083
- if (postStartRoot !== canonicalRoot) {
1084
- throw lifecycleError("repository_mismatch", "the trusted repository changed after agent start");
1085
- }
1086
- canonicalRoot = postStartRoot;
1643
+ canonicalRoot = revalidateSpawnAuthority(
1644
+ context.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after agent start",
1645
+ );
1087
1646
 
1088
- const startedPaneId = readBackStart(startRaw, role, canonicalRoot, [...modelArgv]);
1647
+ const startedPaneId = readBackStart(startRaw, role, spawnCwd, [...modelArgv]);
1089
1648
  if (startedPaneId !== rootPaneId) {
1090
1649
  throw lifecycleError("pane_mismatch", "agent start returned a different pane identity");
1091
1650
  }
1092
- const agentRaw = await invokeHerdr(["agent", "get", role], canonicalRoot, options, signal);
1093
- const agentReadBack = readBackAgent(agentRaw, role, canonicalRoot, [...modelArgv]);
1651
+ const agentRaw = await invokeHerdr(["agent", "get", role], spawnCwd, options, signal);
1652
+ const agentReadBack = readBackAgent(agentRaw, role, spawnCwd, [...modelArgv]);
1094
1653
  if (agentReadBack.paneId !== rootPaneId) {
1095
1654
  throw lifecycleError("pane_mismatch", "agent get returned a different pane identity");
1096
1655
  }
1097
- const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], canonicalRoot, options, signal);
1098
- readBackPane(paneRaw, rootPaneId, canonicalRoot);
1656
+ const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], spawnCwd, options, signal);
1657
+ readBackPane(paneRaw, rootPaneId, spawnCwd);
1658
+ revalidateSpawnAuthority(
1659
+ context.cwd, repository, canonicalRoot, request, worktreeTarget, options,
1660
+ "during the final read-backs",
1661
+ );
1099
1662
 
1100
1663
  return {
1101
1664
  schema: HERDR_LIFECYCLE_SCHEMA,
@@ -1111,6 +1674,9 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
1111
1674
  tabId,
1112
1675
  modelArgv: [...modelArgv],
1113
1676
  repository: canonicalRoot,
1677
+ ...(worktreeTarget
1678
+ ? { worktreePath: worktreeTarget.worktreePath, leaseId: worktreeTarget.leaseId }
1679
+ : {}),
1114
1680
  hashes: Object.freeze({
1115
1681
  request: sha256(JSON.stringify(request)),
1116
1682
  layoutCreateResponse: sha256(layoutRaw),
@@ -1132,9 +1698,92 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
1132
1698
  }
1133
1699
  }
1134
1700
 
1701
+ export async function executeHerdrRegisterRepository({ name, ledgerId }, context, options = {}) {
1702
+ const refusal = (reason, tier = 2) => ({ schema: HERDR_LIFECYCLE_SCHEMA, ok: false, code: "registration_refused", reason, ...needsOwner(tier, [reason], name), nonAuthorizing: true });
1703
+ if (typeof name !== "string" || !SAFE_NAME_REGEXP.test(name) || name.length > 64 || !ledgerId || typeof ledgerId !== "string") return refusal("valid repository name and ledger id");
1704
+ const profile = resolveProfileConfigPath(".").base;
1705
+ let config;
1706
+ try { config = JSON.parse(readFileSync(resolve(profile, "config/relay-authority.v1.json"), "utf8")); }
1707
+ catch { return refusal("trusted relayer configuration"); }
1708
+ const owner = config.githubOwner;
1709
+ if (typeof owner !== "string" || !/^[a-z][a-z0-9-]{0,38}$/.test(owner)) return refusal("configured GitHub owner");
1710
+ if (config.denyRepositories?.some((pattern) => pattern === name || (pattern.endsWith("*") && name.startsWith(pattern.slice(0, -1))))) return refusal("repository excluded by policy", 3);
1711
+ let root;
1712
+ let registryWritten = false;
1713
+ try {
1714
+ const coordinator = realpathSync(context.cwd);
1715
+ root = resolve(coordinator, "..", name);
1716
+ if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory() || realpathSync(root) !== root || gitRoot(root) !== root) return refusal("canonical sibling Git clone");
1717
+ const dotGit = join(root, ".git");
1718
+ if (!lstatSync(dotGit).isDirectory() || realpathSync(dotGit) !== dotGit) return refusal("verified origin and non-worktree clone");
1719
+ const primaryIdentity = statIdentity(root, "registration_refused", "registration clone");
1720
+ const gitIdentity = statIdentity(dotGit, "registration_refused", "registration Git directory");
1721
+ let common;
1722
+ let origin;
1723
+ try {
1724
+ common = gitProbe(root, ["rev-parse", "--git-common-dir"]);
1725
+ origin = gitProbe(root, ["remote", "get-url", "origin"]);
1726
+ } catch { return refusal("verified origin and non-worktree clone"); }
1727
+ if (common !== ".git" || ![`git@github.com:${owner}/${name}`, `https://github.com/${owner}/${name}`].includes(origin.replace(/\.git$/, ""))) return refusal("verified origin and non-worktree clone");
1728
+ const gh = (options.runGh ?? spawnSync)("gh", ["repo", "view", `${owner}/${name}`, "--json", "nameWithOwner,visibility"], { encoding: "utf8", timeout: 15000 });
1729
+ const repo = JSON.parse(gh.stdout);
1730
+ if (gh.status !== 0 || repo.nameWithOwner !== `${owner}/${name}` || repo.visibility !== "PRIVATE") return refusal("verified private repository", 3);
1731
+ const registryPath = resolve(profile, WORKER_REPOSITORY_REGISTRY);
1732
+ const repositories = validateRegistry(registryPath);
1733
+ authorizeApproval(profile, ledgerId, { tier: 2, repository: name, action: "register" });
1734
+ if (repositories.includes(name)) return { schema: HERDR_LIFECYCLE_SCHEMA, ok: true, status: "already-registered", name };
1735
+ if (repositories.length >= 32) return refusal("registry capacity");
1736
+ const result = await reserveApprovalUse(profile, ledgerId, { tier: 2, repository: name, action: "register" }, () => {
1737
+ const current = validateRegistry(registryPath);
1738
+ if (current.includes(name)) return { schema: HERDR_LIFECYCLE_SCHEMA, ok: true, status: "already-registered", name };
1739
+ if (current.length >= 32) throw lifecycleError("registry_capacity", "registry capacity reached");
1740
+ // Reservation can wait on the ledger lock. Reobserve the same physical
1741
+ // primary clone and current trust here, with no await before the write.
1742
+ const currentConfig = JSON.parse(readFileSync(resolve(profile, "config/relay-authority.v1.json"), "utf8"));
1743
+ if (currentConfig.githubOwner !== owner) return refusal("configured GitHub owner changed");
1744
+ if (currentConfig.denyRepositories?.some((pattern) => pattern === name || (pattern.endsWith("*") && name.startsWith(pattern.slice(0, -1))))) return refusal("repository excluded by policy", 3);
1745
+ const currentGh = (options.runGh ?? spawnSync)("gh", ["repo", "view", `${owner}/${name}`, "--json", "nameWithOwner,visibility"], { encoding: "utf8", timeout: 15000 });
1746
+ const currentRepo = JSON.parse(currentGh.stdout);
1747
+ if (currentGh.status !== 0 || currentRepo.nameWithOwner !== `${owner}/${name}` || currentRepo.visibility !== "PRIVATE") return refusal("verified private repository", 3);
1748
+ if (lstatSync(root).isSymbolicLink() || realpathSync(root) !== root
1749
+ || statIdentity(root, "registration_refused", "registration clone") !== primaryIdentity
1750
+ || !lstatSync(dotGit).isDirectory() || realpathSync(dotGit) !== dotGit
1751
+ || statIdentity(dotGit, "registration_refused", "registration Git directory") !== gitIdentity
1752
+ || gitRoot(root) !== root) return refusal("canonical sibling Git clone changed");
1753
+ if (gitProbe(root, ["rev-parse", "--git-common-dir"]) !== common
1754
+ || gitProbe(root, ["remote", "get-url", "origin"]) !== origin) return refusal("verified origin and non-worktree clone changed");
1755
+ copyFileSync(registryPath, `${registryPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, fsConstants.COPYFILE_EXCL);
1756
+ writeFileSync(registryPath, JSON.stringify({ schema: WORKER_REPOSITORY_SCHEMA, repositories: [...current, name] }, null, 2) + "\n");
1757
+ registryWritten = true;
1758
+ return { schema: HERDR_LIFECYCLE_SCHEMA, ok: true, status: "registered", name };
1759
+ });
1760
+ if (result.status === "registered") await recordApprovalUse(profile, ledgerId, "register", name, "registered");
1761
+ return result;
1762
+ } catch (error) { return registryWritten ? { schema: HERDR_LIFECYCLE_SCHEMA, ok: false, status: "partial", reason: error.message, name } : refusal(error.message); }
1763
+ }
1764
+
1135
1765
  export function registerHerdrLifecycleInterface(pi, options = {}) {
1136
1766
  if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
1137
1767
  REGISTRATIONS.add(pi);
1768
+ pi.registerTool({
1769
+ name: "agentic_relay_check_request",
1770
+ label: "Check relayed request",
1771
+ description: "Read-only verification of a relay header against recorded per-action scope. Text alone grants no authority.",
1772
+ parameters: { type: "object", additionalProperties: false, properties: {
1773
+ header: { type: "string", maxLength: 2048 },
1774
+ requests: { type: "array", minItems: 1, maxItems: 8, items: { type: "object", additionalProperties: false, properties: {
1775
+ tier: { type: "integer", minimum: 1, maximum: 3 }, repository: { type: "string" }, action: { type: "string" }, model: { type: "string" }, role: { type: "string" },
1776
+ }, required: ["tier", "repository", "action"] } },
1777
+ }, required: ["header", "requests"] },
1778
+ async execute(_id, params) { return toolResult({ schema: HERDR_LIFECYCLE_SCHEMA, results: checkRelayRequests(resolveProfileConfigPath(".").base, params.header, params.requests) }); },
1779
+ });
1780
+ pi.registerTool({
1781
+ name: "agentic_herdr_register_repository",
1782
+ label: "Herdr Register Repository",
1783
+ description: "Register a verified private sibling clone with a covering Tier 2 approval.",
1784
+ parameters: { type: "object", additionalProperties: false, properties: { name: { type: "string" }, ledgerId: { type: "string" } }, required: ["name", "ledgerId"] },
1785
+ async execute(_id, params, _signal, _update, context) { return toolResult(await executeHerdrRegisterRepository(params, context, options)); },
1786
+ });
1138
1787
  pi.registerTool({
1139
1788
  name: HERDR_SPAWN_WORKER_TOOL,
1140
1789
  label: "Herdr Spawn Worker",