vouchington-tooling 0.42.0 → 0.43.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 (33) hide show
  1. package/dist/cli/usage.d.mts +1 -1
  2. package/dist/cli/usage.mjs +1 -0
  3. package/dist/gh-cli/pr-create.d.mts +2 -0
  4. package/dist/gh-cli/pr-create.mjs +5 -1
  5. package/dist/mcp-server/context.d.mts +11 -0
  6. package/dist/mcp-server/dispatch.d.mts +8 -0
  7. package/dist/mcp-server/dispatch.mjs +7 -0
  8. package/dist/mcp-server/handlers-pr.d.mts +4 -0
  9. package/dist/mcp-server/handlers-pr.mjs +143 -0
  10. package/dist/mcp-server/harness.test-helpers.d.mts +8 -1
  11. package/dist/mcp-server/harness.test-helpers.mjs +3 -1
  12. package/dist/mcp-server/pr-body-file.d.mts +4 -0
  13. package/dist/mcp-server/pr-body-file.mjs +22 -0
  14. package/dist/mcp-server/pr-body.d.mts +20 -0
  15. package/dist/mcp-server/pr-body.mjs +47 -0
  16. package/dist/mcp-server/pr-defaults.d.mts +16 -0
  17. package/dist/mcp-server/pr-defaults.mjs +35 -0
  18. package/dist/mcp-server/pr-gh.d.mts +16 -0
  19. package/dist/mcp-server/pr-gh.mjs +56 -0
  20. package/dist/mcp-server/pr-input.d.mts +26 -0
  21. package/dist/mcp-server/pr-input.mjs +62 -0
  22. package/dist/mcp-server/pr-journal.d.mts +12 -0
  23. package/dist/mcp-server/pr-journal.mjs +86 -0
  24. package/dist/mcp-server/pr-live.d.mts +24 -0
  25. package/dist/mcp-server/pr-live.mjs +49 -0
  26. package/dist/mcp-server/pr-tools.test-helpers.d.mts +54 -0
  27. package/dist/mcp-server/pr-tools.test-helpers.mjs +67 -0
  28. package/dist/mcp-server/tools-pr.d.mts +2 -0
  29. package/dist/mcp-server/tools-pr.mjs +69 -0
  30. package/dist/mcp-server/tools.mjs +2 -0
  31. package/docs/mcp-server.md +80 -6
  32. package/package.json +1 -1
  33. package/skills/pr-description/SKILL.md +8 -0
@@ -1,4 +1,4 @@
1
- export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n require-up-to-date Require HEAD to include a fetched remote branch\n gitleaks-directory-scan Scan a directory with Gitleaks\n ast-grep-examples Run AST-grep rule examples\n ast-grep-pack Print shipped unconditional AST-grep pack paths as JSON\n gha-workspace-policy Check GitHub Actions workspace safety policy\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n download-optional-run-artifacts Download optional artifacts from the current run\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n diagnose-port-collision Capture bounded localhost port diagnostics\n prepare-trivy-db Download the Trivy vulnerability database\n gha-artifacts-cleanup Delete classified GitHub Actions artifacts\n http-origin Validate an optional HTTP(S) origin\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n vitest-report-attempt Write or read a Vitest report-attempt marker\n prepare-vitest-reports Validate and select Vitest report JSON files\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n check-cache-size Measure a path and decide whether to save a GHA cache\n make-shard-matrix Emit a [1..N] GitHub Actions shard matrix\n load-runner-env Overlay a runner env file onto GITHUB_ENV with injection guards\n clean-workspace Reset a persistent-runner workspace with a fork-PR trust gate\n install-github-release Download a checksum-verified GitHub Release binary\n run-with-timeout Run a command with GNU timeout or a Perl fallback\n lint-links Two-pass lychee: internal links fail, external warn\n materialize-pr-context Dump PR title/body/files/diff/comments and #N crawl\n wait-for-apt-locks Wait until apt/dpkg lock files are free\n install-playwright-chromium-arm64 Install Playwright Chromium from browsers.json\n ghcr-package-retention Delete old GHCR package versions past KEEP_MIN\n harness-admission-lane Compute a GITHUB_RUN_ID admission lane for fleet fan-out\n harness-assert-gates Fail if any named HARNESS_*_ENABLED gate is enabled\n gha-collaborator-trust Classify authors as write-access collaborators or not\n nuget-central-version Validate a Directory.Packages.props PackageVersion delta\n swift-semantic-equal Compare Swift sources ignoring comments and whitespace\n post-review Post one COMMENT review from a staged payload file\n stage-review-payload Validate a review payload file into a staging directory\n retrospective-transcript Format facts from Claude-compatible, Codex, or Grok transcripts\n link-skill Link one packaged skill into an explicit consumer directory\n retrospective-facts Gather immutable facts for a retrospective\n agent-blackboard Probe and journal an Agent Blackboard deployment\n mcp Serve the agent-blackboard journal tools over stdio (MCP)\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n [--median-threshold-floor <seconds>] Off unless set. Must be below the ceiling\n [--median-threshold-ceiling <seconds>] Default 360. Flags a five-sample median above this\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\nrequire-up-to-date --remote <name> --branch <name>\ngitleaks-directory-scan --config <path> [--directory <path>]\nast-grep-examples --rules <directory> --config <path>\nast-grep-pack\ngha-workspace-policy [--root <directory>] [--workflow-directory <directory>] [--action-directory <directory>]\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\ndownload-optional-run-artifacts (--name <name>... | --pattern <pattern>) --dir <directory>\nhost-pressure-diagnostics\nallocate-browser-safe-ports <count> [--policy path] [--forbidden-ports path]\ndiagnose-port-collision [--ports \"2200 2216\"] [--output-dir PATH]\nprepare-trivy-db\ngha-artifacts-cleanup run --run-id <id> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\ngha-artifacts-cleanup sweep --older-than-hours <n> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\nhttp-origin [--field NAME] [value]\nvitest-blob-manifest <suite> [reports-directory]\nvitest-report-attempt <write DIRECTORY SUITE|read ROOT>\nprepare-vitest-reports [primary-directory] [fallback-directory] [output-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\ncheck-cache-size <path> <max-bytes> <label>\nmake-shard-matrix <total>\nload-runner-env\nclean-workspace\ninstall-github-release --repo owner/name --version X --asset 'name-{platform}.tar.gz' --bin name [--tag-prefix PREFIX] [--expected-sha256 SHA256] [--no-checksum] [--checksums-asset NAME] [--version-flag FLAG] [--bin-dir DIR]\nrun-with-timeout <timeout-seconds> <kill-after-seconds> <command...>\nlint-links [--offline] [--config PATH] [--glob PATTERN] [files...]\nmaterialize-pr-context\nwait-for-apt-locks\ninstall-playwright-chromium-arm64 [name:archive...]\nghcr-package-retention <url-encoded-package>...\nharness-admission-lane <lanes>\nharness-assert-gates <gate>...\ngha-collaborator-trust <owner/repo> [--allow-bot <login>]... < authors.json\nnuget-central-version <trusted-props> <candidate-props> <metadata-json> <output-props>\nswift-semantic-equal <base> <head> <file.swift>\npost-review\nstage-review-payload optional|required <source> <destination>\nretrospective-transcript [--session-id ID] [--jsonl PATH] [--projects-dir PATH] [--codex-sessions-dir PATH] [--grok-sessions-dir PATH]\nlink-skill <name> --source-root <skills-dir> --target-root <consumer-skills-dir> Link a packaged or repository-local skill\nretrospective-facts (--pr NUMBER | --branch NAME | --no-pr) [--repo OWNER/NAME] [--raw]\nagent-blackboard probe\nagent-blackboard journal append --mode interactive|autonomous --source-event-id ID --work-outcome OUTCOME --coverage-status STATUS --session-id UUID --agent NAME --version VERSION --file PATH --repository OWNER/NAME [--repository OWNER/NAME ...] [--parent-session-id UUID] [--timestamp ISO8601] [--coverage-source SOURCE,...] [--dropped-count N] [--outbox-directory PATH]\nagent-blackboard journal entries --session-id UUID\nagent-blackboard journal status --session-id UUID --outbox-directory PATH\nagent-blackboard journal flush --outbox-directory PATH [--session-id UUID]\nInteractive journal append requires --outbox-directory PATH.\nagent-blackboard journal is deprecated and being phased out: agents journal through the mcp server.\nagent-blackboard snapshot partition --snapshot PATH --checksum SHA256 --counts '{\"sessions\":N,\"entries\":N,\"records\":N,\"bytes\":N}'\nagent-blackboard snapshot cleanup [--snapshot PATH] [--partition-directory PATH --receipt JSON]\nmcp\n (no args) Serve journal_append, journal_entries, outbox_status, outbox_flush,\n session_ensure, retrospective_check, retrospective_save,\n snapshot_export, and session_archive over stdio.\n Needs the optional @modelcontextprotocol/sdk peer (and zod).\n Launch it from inside the repository's git worktree.\n";
1
+ export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n require-up-to-date Require HEAD to include a fetched remote branch\n gitleaks-directory-scan Scan a directory with Gitleaks\n ast-grep-examples Run AST-grep rule examples\n ast-grep-pack Print shipped unconditional AST-grep pack paths as JSON\n gha-workspace-policy Check GitHub Actions workspace safety policy\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n download-optional-run-artifacts Download optional artifacts from the current run\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n diagnose-port-collision Capture bounded localhost port diagnostics\n prepare-trivy-db Download the Trivy vulnerability database\n gha-artifacts-cleanup Delete classified GitHub Actions artifacts\n http-origin Validate an optional HTTP(S) origin\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n vitest-report-attempt Write or read a Vitest report-attempt marker\n prepare-vitest-reports Validate and select Vitest report JSON files\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n check-cache-size Measure a path and decide whether to save a GHA cache\n make-shard-matrix Emit a [1..N] GitHub Actions shard matrix\n load-runner-env Overlay a runner env file onto GITHUB_ENV with injection guards\n clean-workspace Reset a persistent-runner workspace with a fork-PR trust gate\n install-github-release Download a checksum-verified GitHub Release binary\n run-with-timeout Run a command with GNU timeout or a Perl fallback\n lint-links Two-pass lychee: internal links fail, external warn\n materialize-pr-context Dump PR title/body/files/diff/comments and #N crawl\n wait-for-apt-locks Wait until apt/dpkg lock files are free\n install-playwright-chromium-arm64 Install Playwright Chromium from browsers.json\n ghcr-package-retention Delete old GHCR package versions past KEEP_MIN\n harness-admission-lane Compute a GITHUB_RUN_ID admission lane for fleet fan-out\n harness-assert-gates Fail if any named HARNESS_*_ENABLED gate is enabled\n gha-collaborator-trust Classify authors as write-access collaborators or not\n nuget-central-version Validate a Directory.Packages.props PackageVersion delta\n swift-semantic-equal Compare Swift sources ignoring comments and whitespace\n post-review Post one COMMENT review from a staged payload file\n stage-review-payload Validate a review payload file into a staging directory\n retrospective-transcript Format facts from Claude-compatible, Codex, or Grok transcripts\n link-skill Link one packaged skill into an explicit consumer directory\n retrospective-facts Gather immutable facts for a retrospective\n agent-blackboard Probe and journal an Agent Blackboard deployment\n mcp Serve the agent-blackboard journal tools over stdio (MCP)\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n [--median-threshold-floor <seconds>] Off unless set. Must be below the ceiling\n [--median-threshold-ceiling <seconds>] Default 360. Flags a five-sample median above this\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\nrequire-up-to-date --remote <name> --branch <name>\ngitleaks-directory-scan --config <path> [--directory <path>]\nast-grep-examples --rules <directory> --config <path>\nast-grep-pack\ngha-workspace-policy [--root <directory>] [--workflow-directory <directory>] [--action-directory <directory>]\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\ndownload-optional-run-artifacts (--name <name>... | --pattern <pattern>) --dir <directory>\nhost-pressure-diagnostics\nallocate-browser-safe-ports <count> [--policy path] [--forbidden-ports path]\ndiagnose-port-collision [--ports \"2200 2216\"] [--output-dir PATH]\nprepare-trivy-db\ngha-artifacts-cleanup run --run-id <id> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\ngha-artifacts-cleanup sweep --older-than-hours <n> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\nhttp-origin [--field NAME] [value]\nvitest-blob-manifest <suite> [reports-directory]\nvitest-report-attempt <write DIRECTORY SUITE|read ROOT>\nprepare-vitest-reports [primary-directory] [fallback-directory] [output-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\ncheck-cache-size <path> <max-bytes> <label>\nmake-shard-matrix <total>\nload-runner-env\nclean-workspace\ninstall-github-release --repo owner/name --version X --asset 'name-{platform}.tar.gz' --bin name [--tag-prefix PREFIX] [--expected-sha256 SHA256] [--no-checksum] [--checksums-asset NAME] [--version-flag FLAG] [--bin-dir DIR]\nrun-with-timeout <timeout-seconds> <kill-after-seconds> <command...>\nlint-links [--offline] [--config PATH] [--glob PATTERN] [files...]\nmaterialize-pr-context\nwait-for-apt-locks\ninstall-playwright-chromium-arm64 [name:archive...]\nghcr-package-retention <url-encoded-package>...\nharness-admission-lane <lanes>\nharness-assert-gates <gate>...\ngha-collaborator-trust <owner/repo> [--allow-bot <login>]... < authors.json\nnuget-central-version <trusted-props> <candidate-props> <metadata-json> <output-props>\nswift-semantic-equal <base> <head> <file.swift>\npost-review\nstage-review-payload optional|required <source> <destination>\nretrospective-transcript [--session-id ID] [--jsonl PATH] [--projects-dir PATH] [--codex-sessions-dir PATH] [--grok-sessions-dir PATH]\nlink-skill <name> --source-root <skills-dir> --target-root <consumer-skills-dir> Link a packaged or repository-local skill\nretrospective-facts (--pr NUMBER | --branch NAME | --no-pr) [--repo OWNER/NAME] [--raw]\nagent-blackboard probe\nagent-blackboard journal append --mode interactive|autonomous --source-event-id ID --work-outcome OUTCOME --coverage-status STATUS --session-id UUID --agent NAME --version VERSION --file PATH --repository OWNER/NAME [--repository OWNER/NAME ...] [--parent-session-id UUID] [--timestamp ISO8601] [--coverage-source SOURCE,...] [--dropped-count N] [--outbox-directory PATH]\nagent-blackboard journal entries --session-id UUID\nagent-blackboard journal status --session-id UUID --outbox-directory PATH\nagent-blackboard journal flush --outbox-directory PATH [--session-id UUID]\nInteractive journal append requires --outbox-directory PATH.\nagent-blackboard journal is deprecated and being phased out: agents journal through the mcp server.\nagent-blackboard snapshot partition --snapshot PATH --checksum SHA256 --counts '{\"sessions\":N,\"entries\":N,\"records\":N,\"bytes\":N}'\nagent-blackboard snapshot cleanup [--snapshot PATH] [--partition-directory PATH --receipt JSON]\nmcp\n (no args) Serve journal_append, journal_entries, outbox_status, outbox_flush,\n session_ensure, retrospective_check, retrospective_save,\n pr_validate, pr_create, pr_update,\n snapshot_export, and session_archive over stdio.\n Needs the optional @modelcontextprotocol/sdk peer (and zod).\n Launch it from inside the repository's git worktree.\n";
2
2
  export declare function printUsage(stream?: NodeJS.WritableStream): void;
3
3
  export declare function commandNames(usage?: string): string[];
4
4
  export declare function commandUsage(command: string, usage?: string): string;
@@ -127,6 +127,7 @@ agent-blackboard snapshot cleanup [--snapshot PATH] [--partition-directory PATH
127
127
  mcp
128
128
  (no args) Serve journal_append, journal_entries, outbox_status, outbox_flush,
129
129
  session_ensure, retrospective_check, retrospective_save,
130
+ pr_validate, pr_create, pr_update,
130
131
  snapshot_export, and session_archive over stdio.
131
132
  Needs the optional @modelcontextprotocol/sdk peer (and zod).
132
133
  Launch it from inside the repository's git worktree.
@@ -39,6 +39,8 @@ export type BuildGhPrCreateArgsOptions = {
39
39
  draft?: boolean;
40
40
  head: string;
41
41
  labels?: readonly string[];
42
+ milestone?: string;
43
+ projects?: readonly string[];
42
44
  reviewers?: readonly string[];
43
45
  title: string;
44
46
  };
@@ -53,7 +53,7 @@ export async function assertHeadPushed(runGit, { branch, remote = 'origin' }) {
53
53
  }
54
54
  /** Pure argv builder for `gh pr create`. `head` is always passed explicitly (never omitted). */
55
55
  export function buildGhPrCreateArgs(options) {
56
- const { base, bodyFile, draft = false, head, labels = [], reviewers = [], title } = options;
56
+ const { base, bodyFile, draft = false, head, labels = [], milestone, projects = [], reviewers = [], title, } = options;
57
57
  const args = ['pr', 'create', '--title', title, '--body-file', bodyFile, '--head', head];
58
58
  if (base !== undefined)
59
59
  args.push('--base', base);
@@ -61,6 +61,10 @@ export function buildGhPrCreateArgs(options) {
61
61
  args.push('--draft');
62
62
  for (const label of labels)
63
63
  args.push('--label', label);
64
+ if (milestone !== undefined)
65
+ args.push('--milestone', milestone);
66
+ for (const project of projects)
67
+ args.push('--project', project);
64
68
  for (const reviewer of reviewers)
65
69
  args.push('--reviewer', reviewer);
66
70
  return args;
@@ -1,5 +1,7 @@
1
1
  import type { BlackboardClientDependencies } from '../agent-blackboard/client.mts';
2
2
  import type { FeedbackIdentity } from '../agent-blackboard/feedback-types.mts';
3
+ import type { RunTextCommand } from '../gh-cli/exec.mts';
4
+ import type { GhRunner } from './pr-gh.mts';
3
5
  import type { PolicySpawner } from './policy-spawn.mts';
4
6
  import { type Args } from './args.mts';
5
7
  /** What a handler may rely on: every field here was validated before the handler ran. */
@@ -12,6 +14,15 @@ export type ToolContext = {
12
14
  launchRoot: string | undefined;
13
15
  /** Test seam: replaces the child process a policy export runs in. */
14
16
  spawnPolicy?: PolicySpawner;
17
+ /** Runs git with the isolated environment; callers add their own `-C <worktree>`. */
18
+ runGit: RunTextCommand;
19
+ /** Test seam: replaces locating and running gh. */
20
+ runGh?: GhRunner;
21
+ /** Test seam: replaces the operating system user and host name. */
22
+ device?: () => {
23
+ user: string;
24
+ host: string;
25
+ };
15
26
  };
16
27
  export type ToolHandler = (args: Args, context: ToolContext) => Promise<Record<string, unknown>>;
17
28
  /**
@@ -1,5 +1,6 @@
1
1
  import type { BlackboardClientDependencies } from '../agent-blackboard/client.mts';
2
2
  import type { RunTextCommand } from '../gh-cli/exec.mts';
3
+ import type { GhRunner } from './pr-gh.mts';
3
4
  import type { PolicySpawner } from './policy-spawn.mts';
4
5
  export type ServerEnvironment = {
5
6
  /** The launch directory's worktree top level, or undefined when it is not inside one. */
@@ -12,6 +13,13 @@ export type ServerEnvironment = {
12
13
  blackboard?: BlackboardClientDependencies;
13
14
  /** Test seam: replaces the child process a policy export runs in. */
14
15
  spawnPolicy?: PolicySpawner;
16
+ /** Test seam: replaces locating and running gh. */
17
+ runGh?: GhRunner;
18
+ /** Test seam: replaces the operating system user and host name. */
19
+ device?: () => {
20
+ user: string;
21
+ host: string;
22
+ };
15
23
  };
16
24
  export type ToolCallResult = {
17
25
  content: Array<{
@@ -5,6 +5,7 @@ import { journalAppend, journalEntries } from "./handlers-journal.mjs";
5
5
  import { retrospectiveCheck, retrospectiveSave } from "./handlers-retrospective.mjs";
6
6
  import { outboxFlush, outboxStatus } from "./handlers-outbox.mjs";
7
7
  import { sessionArchive, sessionEnsure, snapshotExport } from "./handlers-session.mjs";
8
+ import { handlePrCreate, handlePrUpdate, handlePrValidate } from "./handlers-pr.mjs";
8
9
  import { TOOLS } from "./tools.mjs";
9
10
  import { resolveWorktree } from "./worktree.mjs";
10
11
  const HANDLERS = {
@@ -15,6 +16,9 @@ const HANDLERS = {
15
16
  session_ensure: sessionEnsure,
16
17
  retrospective_check: retrospectiveCheck,
17
18
  retrospective_save: retrospectiveSave,
19
+ pr_validate: handlePrValidate,
20
+ pr_create: handlePrCreate,
21
+ pr_update: handlePrUpdate,
18
22
  snapshot_export: snapshotExport,
19
23
  session_archive: sessionArchive,
20
24
  };
@@ -55,6 +59,9 @@ export async function callTool(name, rawArguments, environment) {
55
59
  worktree,
56
60
  env: environment.env,
57
61
  launchRoot: environment.launchRoot,
62
+ runGit: environment.runGit,
63
+ ...(environment.runGh === undefined ? {} : { runGh: environment.runGh }),
64
+ ...(environment.device === undefined ? {} : { device: environment.device }),
58
65
  ...(environment.spawnPolicy === undefined ? {} : { spawnPolicy: environment.spawnPolicy }),
59
66
  // The client resolves from this package's install, like the SDK, never from the worktree.
60
67
  dependencies: {
@@ -0,0 +1,4 @@
1
+ import { type ToolHandler } from './context.mts';
2
+ export declare const handlePrValidate: ToolHandler;
3
+ export declare const handlePrCreate: ToolHandler;
4
+ export declare const handlePrUpdate: ToolHandler;
@@ -0,0 +1,143 @@
1
+ import { hostname, userInfo } from 'node:os';
2
+ import { validateGitHubBodyLength } from "../gh-cli/body-length.mjs";
3
+ import { createPullRequest } from "../gh-cli/pr-create.mjs";
4
+ import { assertPolicyAllowed } from "./context.mjs";
5
+ import { runValidator } from "./policy-run.mjs";
6
+ import { renderPullRequestBody, worktreeLabel } from "./pr-body.mjs";
7
+ import { pullRequestNumber, withBodyFile } from "./pr-body-file.mjs";
8
+ import { pullRequestDefaults } from "./pr-defaults.mjs";
9
+ import { createGhRunner, ghEnvironment } from "./pr-gh.mjs";
10
+ import { checkTitleLength, positiveInteger, readPullRequestInput, } from "./pr-input.mjs";
11
+ import { preserveShepherdJournal } from "./pr-journal.mjs";
12
+ import { assertJournalUnchanged, credentials, liveBase, livePullRequest, redactCredentials, } from "./pr-live.mjs";
13
+ import { parseWorktreeList } from "./worktree.mjs";
14
+ function gitIn(context) {
15
+ return (args) => context.runGit(['-C', context.worktree, ...args]);
16
+ }
17
+ function ghIn(context) {
18
+ const run = context.runGh ?? createGhRunner(context.env);
19
+ const env = ghEnvironment(context.env);
20
+ return (args) => run(args, { cwd: context.worktree, env });
21
+ }
22
+ async function currentBranch(git) {
23
+ const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
24
+ if (branch === 'HEAD')
25
+ throw new Error('the worktree has a detached HEAD: check out a branch');
26
+ return branch;
27
+ }
28
+ async function label(context, git) {
29
+ const listing = await git(['worktree', 'list', '--porcelain', '-z']);
30
+ return worktreeLabel(context.worktree, parseWorktreeList(listing)[0]);
31
+ }
32
+ function readPr(args, required) {
33
+ if (args.pr === undefined && !required)
34
+ return undefined;
35
+ return positiveInteger(args.pr, 'pr');
36
+ }
37
+ function policyFor(context) {
38
+ return {
39
+ worktree: context.worktree,
40
+ env: context.env,
41
+ ...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
42
+ };
43
+ }
44
+ /** Renders the body, preserves the live journal for an update, and runs the policy validator. */
45
+ async function prepare(args, context, pr) {
46
+ assertPolicyAllowed(context);
47
+ // No server credential may reach the policy or gh, whatever the caller wrote: every field is
48
+ // redacted before anything is rendered or forwarded.
49
+ const secrets = credentials(context.env);
50
+ const given = redactCredentials(readPullRequestInput(args), secrets);
51
+ checkTitleLength(given.title);
52
+ const git = gitIn(context);
53
+ const head = await currentBranch(git);
54
+ // Branch names are refs passed to gh as is: redacting one would name a branch that does not exist.
55
+ if (secrets.some((secret) => head.includes(secret)))
56
+ throw new Error('the worktree branch name contains a server credential: rename the branch');
57
+ if (secrets.some((secret) => args.base?.includes(secret)))
58
+ throw new Error('base contains a server credential: use another base branch');
59
+ const live = pr === undefined ? undefined : await livePullRequest(ghIn(context), pr);
60
+ if (live !== undefined && live.headRefName !== head)
61
+ throw new Error(`pull request ${pr} has head ${live.headRefName}, not the worktree branch ${head}`);
62
+ const base = live === undefined
63
+ ? given.base
64
+ : liveBase(pr, live, redactCredentials(args.base, secrets));
65
+ const input = { ...given, base };
66
+ if (head === base)
67
+ throw new Error(`head branch ${head} must differ from base ${base}`);
68
+ const device = (context.device ?? (() => ({ user: userInfo().username, host: hostname() })))();
69
+ let body = renderPullRequestBody(input, {
70
+ sessionId: context.sessionId,
71
+ ...device,
72
+ worktree: await label(context, git),
73
+ });
74
+ if (live !== undefined)
75
+ body = preserveShepherdJournal(body, live.body);
76
+ body = redactCredentials(body, secrets);
77
+ const length = validateGitHubBodyLength(body);
78
+ if (!length.ok)
79
+ throw new Error(`the rendered body has ${length.characterCount} characters; GitHub allows ${length.maxCharacterCount}`);
80
+ const report = await runValidator({
81
+ ...policyFor(context),
82
+ export: 'validatePullRequest',
83
+ input: {
84
+ title: input.title,
85
+ body,
86
+ base: input.base,
87
+ head,
88
+ mode: input.mode,
89
+ ...(pr === undefined ? {} : { pr }),
90
+ },
91
+ });
92
+ const errors = 'errors' in report ? report.errors : [];
93
+ const warnings = 'warnings' in report ? report.warnings : [];
94
+ return {
95
+ input,
96
+ head,
97
+ body,
98
+ errors,
99
+ warnings,
100
+ ...(live === undefined ? {} : { liveBody: live.body }),
101
+ };
102
+ }
103
+ function rejectErrors(prepared) {
104
+ if (prepared.errors.length)
105
+ throw new Error(`policy rejected the pull request:\n- ${prepared.errors.join('\n- ')}`);
106
+ }
107
+ export const handlePrValidate = async (args, context) => {
108
+ const { body, errors, warnings } = await prepare(args, context, readPr(args, false));
109
+ return { body, errors, warnings };
110
+ };
111
+ export const handlePrCreate = async (args, context) => {
112
+ const prepared = await prepare(args, context, undefined);
113
+ rejectErrors(prepared);
114
+ const { input, head, body } = prepared;
115
+ const defaults = await pullRequestDefaults(policyFor(context), input);
116
+ const url = await withBodyFile(body, (bodyFile) => createPullRequest({ runGh: ghIn(context), runGit: gitIn(context) }, {
117
+ bodyFile,
118
+ draft: true,
119
+ title: input.title,
120
+ base: input.base,
121
+ head,
122
+ labels: defaults.labels,
123
+ ...(defaults.milestone === undefined ? {} : { milestone: defaults.milestone }),
124
+ ...(defaults.project === undefined ? {} : { projects: [defaults.project] }),
125
+ }));
126
+ return { number: pullRequestNumber(url), url, warnings: prepared.warnings };
127
+ };
128
+ export const handlePrUpdate = async (args, context) => {
129
+ const pr = readPr(args, true);
130
+ const prepared = await prepare(args, context, pr);
131
+ rejectErrors(prepared);
132
+ await assertJournalUnchanged(ghIn(context), pr, prepared.liveBody, prepared.input.base);
133
+ const url = await withBodyFile(prepared.body, async (bodyFile) => (await ghIn(context)([
134
+ 'pr',
135
+ 'edit',
136
+ String(pr),
137
+ '--title',
138
+ prepared.input.title,
139
+ '--body-file',
140
+ bodyFile,
141
+ ])).trim());
142
+ return { number: pr, url, warnings: prepared.warnings };
143
+ };
@@ -1,6 +1,7 @@
1
- import { type ToolCallResult } from './dispatch.mts';
1
+ import { type ServerEnvironment, type ToolCallResult } from './dispatch.mts';
2
2
  import { type FakeBlackboard, type FakeOptions } from './fake-blackboard.test-helpers.mts';
3
3
  import { type RepoFixture } from './git-fixture.test-helpers.mts';
4
+ import type { GhRunner } from './pr-gh.mts';
4
5
  import type { PolicySpawner } from './policy-spawn.mts';
5
6
  /** A fresh pair of real repositories for every test. */
6
7
  export declare function useRepoFixture(): () => RepoFixture;
@@ -14,6 +15,12 @@ export declare function harness(fixture: RepoFixture, options?: FakeOptions & {
14
15
  env?: NodeJS.ProcessEnv;
15
16
  launchRoot?: string | undefined;
16
17
  spawnPolicy?: PolicySpawner;
18
+ runGit?: ServerEnvironment['runGit'];
19
+ runGh?: GhRunner;
20
+ device?: () => {
21
+ user: string;
22
+ host: string;
23
+ };
17
24
  }): Harness;
18
25
  export declare function textOf(result: ToolCallResult): string;
19
26
  export declare function jsonOf(result: ToolCallResult): Record<string, unknown>;
@@ -22,9 +22,11 @@ export function harness(fixture, options = {}) {
22
22
  const environment = {
23
23
  launchRoot: 'launchRoot' in options ? options.launchRoot : fixture.main,
24
24
  env: options.env ?? BLACKBOARD_ENV,
25
- runGit: runIsolatedGit,
25
+ runGit: options.runGit ?? runIsolatedGit,
26
26
  blackboard: fake.dependencies,
27
27
  ...(options.spawnPolicy === undefined ? {} : { spawnPolicy: options.spawnPolicy }),
28
+ ...(options.runGh === undefined ? {} : { runGh: options.runGh }),
29
+ ...(options.device === undefined ? {} : { device: options.device }),
28
30
  };
29
31
  return { fake, call: (name, args) => callTool(name, args, environment) };
30
32
  }
@@ -0,0 +1,4 @@
1
+ /** Writes the body to a private temp file for the duration of `use`, then removes it. */
2
+ export declare function withBodyFile<T>(body: string, use: (file: string) => Promise<T>): Promise<T>;
3
+ /** The pull request number at the end of the URL `gh pr create` prints. */
4
+ export declare function pullRequestNumber(url: string): number;
@@ -0,0 +1,22 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /** Writes the body to a private temp file for the duration of `use`, then removes it. */
5
+ export async function withBodyFile(body, use) {
6
+ const directory = await mkdtemp(join(tmpdir(), 'vouchington-pr-'));
7
+ try {
8
+ const file = join(directory, 'body.md');
9
+ await writeFile(file, body, { mode: 0o600 });
10
+ return await use(file);
11
+ }
12
+ finally {
13
+ await rm(directory, { recursive: true, force: true });
14
+ }
15
+ }
16
+ /** The pull request number at the end of the URL `gh pr create` prints. */
17
+ export function pullRequestNumber(url) {
18
+ const match = /\/pull\/(\d+)\s*$/.exec(url);
19
+ if (!match)
20
+ throw new Error(`could not read the pull request number from: ${url}`);
21
+ return Number(match[1]);
22
+ }
@@ -0,0 +1,20 @@
1
+ import type { PullRequestInput } from './pr-input.mts';
2
+ export type PullRequestProvenance = {
3
+ sessionId: string;
4
+ /** The operating system user name. */
5
+ user: string;
6
+ /** The host name; lowercased when rendered. */
7
+ host: string;
8
+ /** The checkout label, see {@link worktreeLabel}. */
9
+ worktree: string;
10
+ };
11
+ /**
12
+ * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
13
+ * provenance lines. Pure; the caller supplies the device and worktree facts.
14
+ */
15
+ export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance): string;
16
+ /**
17
+ * The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
18
+ * worktree, else the directory name.
19
+ */
20
+ export declare function worktreeLabel(worktree: string, mainWorktree: string | undefined): string;
@@ -0,0 +1,47 @@
1
+ import { basename } from 'node:path';
2
+ function relatedLine(issue) {
3
+ const verb = issue.relation === 'closes' ? 'Closes' : 'Refs';
4
+ return `- ${verb} #${issue.number} — ${issue.reason}`;
5
+ }
6
+ /**
7
+ * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
8
+ * provenance lines. Pure; the caller supplies the device and worktree facts.
9
+ */
10
+ export function renderPullRequestBody(input, provenance) {
11
+ const related = input.relatedIssues.length ? input.relatedIssues.map(relatedLine) : ['- None'];
12
+ const agent = input.model === undefined ? input.agent : `${input.agent} (${input.model})`;
13
+ return [
14
+ '## Summary',
15
+ '',
16
+ input.summary,
17
+ '',
18
+ '## Impact',
19
+ '',
20
+ input.impact,
21
+ '',
22
+ '## Related issues',
23
+ '',
24
+ ...related,
25
+ '',
26
+ '## Details',
27
+ '',
28
+ input.details.join('\n\n'),
29
+ '',
30
+ `Workspace setup: ${input.workspaceSetup}`,
31
+ `Agent: ${agent} session ${provenance.sessionId}`,
32
+ `Device: ${provenance.user}@${provenance.host.toLowerCase()}`,
33
+ `Worktree: ${provenance.worktree}`,
34
+ '',
35
+ ].join('\n');
36
+ }
37
+ const MARKER = '/worktrees/';
38
+ /**
39
+ * The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
40
+ * worktree, else the directory name.
41
+ */
42
+ export function worktreeLabel(worktree, mainWorktree) {
43
+ const marker = worktree.indexOf(MARKER);
44
+ if (marker !== -1)
45
+ return worktree.slice(marker + MARKER.length);
46
+ return worktree === mainWorktree ? 'main' : basename(worktree);
47
+ }
@@ -0,0 +1,16 @@
1
+ import { type PolicyRun } from './policy-run.mts';
2
+ import type { RelatedIssue } from './pr-input.mts';
3
+ export type PullRequestDefaults = {
4
+ labels: string[];
5
+ milestone?: string;
6
+ project?: string;
7
+ };
8
+ /**
9
+ * Labels, milestone and project from the policy's `pullRequestDefaults` export; empty when the
10
+ * checkout declares no policy or the policy lacks the export.
11
+ */
12
+ export declare function pullRequestDefaults(policy: Omit<PolicyRun, 'export' | 'input'>, input: {
13
+ title: string;
14
+ base: string;
15
+ relatedIssues: RelatedIssue[];
16
+ }): Promise<PullRequestDefaults>;
@@ -0,0 +1,35 @@
1
+ import { runPolicyExport } from "./policy-run.mjs";
2
+ function optionalName(value, field) {
3
+ if (value === undefined || value === null)
4
+ return undefined;
5
+ if (typeof value !== 'string' || !value.trim())
6
+ throw new Error(`policy pullRequestDefaults ${field} must be a non-empty string`);
7
+ return value;
8
+ }
9
+ /**
10
+ * Labels, milestone and project from the policy's `pullRequestDefaults` export; empty when the
11
+ * checkout declares no policy or the policy lacks the export.
12
+ */
13
+ export async function pullRequestDefaults(policy, input) {
14
+ const { title, base, relatedIssues } = input;
15
+ const outcome = await runPolicyExport({
16
+ ...policy,
17
+ export: 'pullRequestDefaults',
18
+ input: { title, base, relatedIssues },
19
+ });
20
+ if (outcome.status !== 'ok')
21
+ return { labels: [] };
22
+ const result = (outcome.result ?? {});
23
+ if (typeof result !== 'object' || Array.isArray(result))
24
+ throw new Error('policy pullRequestDefaults must return an object');
25
+ const labels = result.labels ?? [];
26
+ if (!Array.isArray(labels) || labels.some((label) => typeof label !== 'string' || !label.trim()))
27
+ throw new Error('policy pullRequestDefaults labels must be an array of non-empty strings');
28
+ const milestone = optionalName(result.milestone, 'milestone');
29
+ const project = optionalName(result.project, 'project');
30
+ return {
31
+ labels: labels,
32
+ ...(milestone === undefined ? {} : { milestone }),
33
+ ...(project === undefined ? {} : { project }),
34
+ };
35
+ }
@@ -0,0 +1,16 @@
1
+ export type GhRunner = (args: string[], options: {
2
+ cwd: string;
3
+ env: NodeJS.ProcessEnv;
4
+ }) => Promise<string>;
5
+ /**
6
+ * `gh` on the server's PATH, else a common absolute location. Relative PATH entries are skipped:
7
+ * gh runs with the target worktree as cwd, where a relative path could name a checkout file.
8
+ */
9
+ export declare function locateGh(env: NodeJS.ProcessEnv, fallbacks?: readonly string[]): Promise<string>;
10
+ /**
11
+ * The environment gh runs in: the server's, without the blackboard credentials, and without
12
+ * GH_REPO, which would point gh at a repository other than the validated worktree's.
13
+ */
14
+ export declare function ghEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
15
+ /** A runner that locates gh once, on first use, from the server's environment. */
16
+ export declare function createGhRunner(serverEnv: NodeJS.ProcessEnv, locate?: (env: NodeJS.ProcessEnv) => Promise<string>): GhRunner;
@@ -0,0 +1,56 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, stat } from 'node:fs/promises';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ const FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'];
8
+ const MAX_BUFFER_BYTES = 16_000_000;
9
+ /** The longest one gh call may run before it is killed. */
10
+ const GH_TIMEOUT_MS = 120_000;
11
+ async function isExecutableFile(path) {
12
+ try {
13
+ await access(path, constants.X_OK);
14
+ return (await stat(path)).isFile();
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * `gh` on the server's PATH, else a common absolute location. Relative PATH entries are skipped:
22
+ * gh runs with the target worktree as cwd, where a relative path could name a checkout file.
23
+ */
24
+ export async function locateGh(env, fallbacks = FALLBACKS) {
25
+ const onPath = (env.PATH ?? '')
26
+ .split(delimiter)
27
+ .filter((directory) => isAbsolute(directory))
28
+ .map((directory) => join(directory, 'gh'));
29
+ for (const candidate of [...onPath, ...fallbacks])
30
+ if (await isExecutableFile(candidate))
31
+ return candidate;
32
+ throw new Error("gh CLI not found on PATH; install gh or add it to the server's PATH");
33
+ }
34
+ /**
35
+ * The environment gh runs in: the server's, without the blackboard credentials, and without
36
+ * GH_REPO, which would point gh at a repository other than the validated worktree's.
37
+ */
38
+ export function ghEnvironment(env) {
39
+ const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = env;
40
+ return rest;
41
+ }
42
+ /** A runner that locates gh once, on first use, from the server's environment. */
43
+ export function createGhRunner(serverEnv, locate = locateGh) {
44
+ let located;
45
+ return async (args, options) => {
46
+ located ??= locate(serverEnv);
47
+ const { stdout } = await execFileAsync(await located, args, {
48
+ cwd: options.cwd,
49
+ env: options.env,
50
+ maxBuffer: MAX_BUFFER_BYTES,
51
+ timeout: GH_TIMEOUT_MS,
52
+ killSignal: 'SIGKILL',
53
+ });
54
+ return stdout;
55
+ };
56
+ }
@@ -0,0 +1,26 @@
1
+ import { type Args } from './args.mts';
2
+ declare const PR_MODES: readonly ["interactive", "autonomous"];
3
+ export type RelatedIssue = {
4
+ number: number;
5
+ relation: 'closes' | 'refs';
6
+ reason: string;
7
+ };
8
+ export type PullRequestInput = {
9
+ title: string;
10
+ summary: string;
11
+ impact: string;
12
+ relatedIssues: RelatedIssue[];
13
+ details: string[];
14
+ base: string;
15
+ agent: string;
16
+ model?: string;
17
+ mode: (typeof PR_MODES)[number];
18
+ workspaceSetup: string;
19
+ };
20
+ /** A positive integer argument, such as a pull request number or an issue number. */
21
+ export declare function positiveInteger(value: unknown, label: string): number;
22
+ /** Rechecked after redaction, which can lengthen a title. */
23
+ export declare function checkTitleLength(title: string): void;
24
+ /** Reads and validates the structured pull request fields shared by the three pr_* tools. */
25
+ export declare function readPullRequestInput(args: Args): PullRequestInput;
26
+ export {};
@@ -0,0 +1,62 @@
1
+ import { optionalString, rejectUnknown, requireObject, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
2
+ const PR_MODES = ['interactive', 'autonomous'];
3
+ const RELATIONS = ['closes', 'refs'];
4
+ const MAX_TITLE_CHARACTERS = 256;
5
+ /** A positive integer argument, such as a pull request number or an issue number. */
6
+ export function positiveInteger(value, label) {
7
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1)
8
+ throw new Error(`${label} must be a positive integer`);
9
+ return value;
10
+ }
11
+ function readRelatedIssues(args) {
12
+ const value = args.relatedIssues;
13
+ if (!Array.isArray(value))
14
+ throw new Error('relatedIssues is required and must be an array');
15
+ return value.map((item, index) => {
16
+ const issue = requireObject(item);
17
+ rejectUnknown(issue, ['number', 'relation', 'reason']);
18
+ return {
19
+ number: positiveInteger(issue.number, `relatedIssues[${index}].number`),
20
+ relation: requiredChoice(issue, 'relation', RELATIONS),
21
+ reason: requiredString(issue, 'reason'),
22
+ };
23
+ });
24
+ }
25
+ function readDetails(args) {
26
+ const details = requiredStringArray(args, 'details');
27
+ details.forEach((detail, index) => {
28
+ if (!detail.trim())
29
+ throw new Error(`details[${index}] must not be empty`);
30
+ });
31
+ return details;
32
+ }
33
+ /** Rechecked after redaction, which can lengthen a title. */
34
+ export function checkTitleLength(title) {
35
+ if (Array.from(title).length > MAX_TITLE_CHARACTERS)
36
+ throw new Error(`title must be at most ${MAX_TITLE_CHARACTERS} characters`);
37
+ }
38
+ function singleLine(args, key) {
39
+ const value = requiredString(args, key);
40
+ if (/[\r\n]/.test(value))
41
+ throw new Error(`${key} must be a single line`);
42
+ return value;
43
+ }
44
+ /** Reads and validates the structured pull request fields shared by the three pr_* tools. */
45
+ export function readPullRequestInput(args) {
46
+ const title = singleLine(args, 'title');
47
+ checkTitleLength(title);
48
+ const model = args.model === undefined ? undefined : singleLine(args, 'model');
49
+ return {
50
+ title,
51
+ summary: requiredString(args, 'summary'),
52
+ impact: requiredString(args, 'impact'),
53
+ relatedIssues: readRelatedIssues(args),
54
+ details: readDetails(args),
55
+ base: optionalString(args, 'base') ?? 'main',
56
+ agent: singleLine(args, 'agent'),
57
+ ...(model === undefined ? {} : { model }),
58
+ mode: requiredChoice(args, 'mode', PR_MODES),
59
+ // It renders on its own provenance line; a newline would forge the lines after it.
60
+ workspaceSetup: singleLine(args, 'workspaceSetup'),
61
+ };
62
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Shepherd Journal of a pull request body, verbatim: a `<details>` block whose summary is
3
+ * "Shepherd Journal", else a legacy `## Shepherd Journal` section running to the next H2 or the
4
+ * end. Examples inside fenced code blocks are ignored. Undefined when the body has none; more than
5
+ * one candidate, or an unclosed one, is ambiguous and throws rather than guessing.
6
+ */
7
+ export declare function findShepherdJournal(body: string): string | undefined;
8
+ /**
9
+ * Appends the live body's journal to `rendered`. The rendered fields must not carry a journal of
10
+ * their own, and must not leave a code fence open: the journal would be hidden inside it.
11
+ */
12
+ export declare function preserveShepherdJournal(rendered: string, live: string): string;
@@ -0,0 +1,86 @@
1
+ const DETAILS_OPEN = /<details(?:\s[^>]*)?>/gi;
2
+ const DETAILS_CLOSE = /<\/details>/gi;
3
+ const SUMMARY = '<summary>Shepherd Journal</summary>';
4
+ /** An ATX H2 "Shepherd Journal", with optional indent and closing hashes. */
5
+ const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
6
+ /** A real ATX H2: `##` then whitespace or the end of the line (so not `###` or `##text`). */
7
+ const H2 = /^ {0,3}##(?:[ \t]|$)/;
8
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
9
+ /** A closing fence carries nothing but whitespace after its marker. */
10
+ const CLOSING_FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
11
+ const count = (line, expression) => [...line.matchAll(expression)].length;
12
+ /**
13
+ * The lines with every fenced code block blanked, so journal-shaped examples there never match,
14
+ * and whether the text ends inside a fence that never closed.
15
+ */
16
+ function outsideFences(lines) {
17
+ let fence;
18
+ const visible = lines.map((line) => {
19
+ if (fence === undefined) {
20
+ const marker = FENCE.exec(line)?.[1];
21
+ if (marker === undefined)
22
+ return line;
23
+ fence = marker;
24
+ return '';
25
+ }
26
+ const marker = CLOSING_FENCE.exec(line)?.[1];
27
+ if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length)
28
+ fence = undefined;
29
+ return '';
30
+ });
31
+ return { visible, open: fence !== undefined };
32
+ }
33
+ function detailsStarts(lines) {
34
+ return lines.flatMap((line, index) => line.trim() === SUMMARY && lines[index - 1]?.trim() === '<details>' ? [index] : []);
35
+ }
36
+ /** Depth is tracked on the fence-blanked lines; the block is sliced from the original ones. */
37
+ function detailsBlock(lines, visible, start) {
38
+ let depth = 1;
39
+ for (const [index, line] of visible.entries()) {
40
+ if (index < start)
41
+ continue;
42
+ depth += count(line, DETAILS_OPEN) - count(line, DETAILS_CLOSE);
43
+ if (depth <= 0)
44
+ return lines.slice(start - 1, index + 1);
45
+ }
46
+ // Treating an unclosed journal as absent would let an update overwrite its history.
47
+ throw new Error('the Shepherd Journal <details> block is never closed; repair it by hand');
48
+ }
49
+ function legacySection(lines, visible, start) {
50
+ const next = visible.findIndex((line, index) => index > start && H2.test(line));
51
+ const section = lines.slice(start, next === -1 ? lines.length : next);
52
+ while (section.at(-1)?.trim() === '')
53
+ section.pop();
54
+ return section;
55
+ }
56
+ /**
57
+ * The Shepherd Journal of a pull request body, verbatim: a `<details>` block whose summary is
58
+ * "Shepherd Journal", else a legacy `## Shepherd Journal` section running to the next H2 or the
59
+ * end. Examples inside fenced code blocks are ignored. Undefined when the body has none; more than
60
+ * one candidate, or an unclosed one, is ambiguous and throws rather than guessing.
61
+ */
62
+ export function findShepherdJournal(body) {
63
+ const lines = body.replaceAll('\r\n', '\n').split('\n');
64
+ const { visible } = outsideFences(lines);
65
+ const details = detailsStarts(visible);
66
+ const legacy = visible.flatMap((line, index) => (LEGACY.test(line) ? [index] : []));
67
+ if (details.length + legacy.length > 1)
68
+ throw new Error('the pull request body has more than one Shepherd Journal; resolve it by hand');
69
+ if (details.length === 1)
70
+ return detailsBlock(lines, visible, details[0]).join('\n');
71
+ return legacy.length === 1 ? legacySection(lines, visible, legacy[0]).join('\n') : undefined;
72
+ }
73
+ /**
74
+ * Appends the live body's journal to `rendered`. The rendered fields must not carry a journal of
75
+ * their own, and must not leave a code fence open: the journal would be hidden inside it.
76
+ */
77
+ export function preserveShepherdJournal(rendered, live) {
78
+ if (findShepherdJournal(rendered) !== undefined)
79
+ throw new Error('the pull request fields contain a Shepherd Journal; the server preserves the live one');
80
+ const journal = findShepherdJournal(live);
81
+ if (journal === undefined)
82
+ return rendered;
83
+ if (outsideFences(rendered.replaceAll('\r\n', '\n').split('\n')).open)
84
+ throw new Error('the pull request fields leave a code fence open; close it');
85
+ return `${rendered.trimEnd()}\n\n${journal}\n`;
86
+ }
@@ -0,0 +1,24 @@
1
+ import type { RunTextCommand } from '../gh-cli/exec.mts';
2
+ export type LivePullRequest = {
3
+ body: string;
4
+ headRefName: string;
5
+ baseRefName: string;
6
+ state: string;
7
+ };
8
+ /** The live pull request as gh reports it, rejected unless it is open. */
9
+ export declare function livePullRequest(gh: RunTextCommand, pr: number): Promise<LivePullRequest>;
10
+ /**
11
+ * The base to validate an existing pull request against: its live base. An explicit `base` that
12
+ * differs is rejected, because `gh pr edit` here changes only the title and body.
13
+ */
14
+ export declare function liveBase(pr: number, live: LivePullRequest, given: string | undefined): string;
15
+ /**
16
+ * Re-reads the live pull request just before the edit and aborts if its Shepherd Journal or base
17
+ * changed since the body was prepared and validated, so an entry appended in between is never
18
+ * overwritten and a retargeted PR never gets a body validated against its old base.
19
+ */
20
+ export declare function assertJournalUnchanged(gh: RunTextCommand, pr: number, preparedFrom: string, base: string): Promise<void>;
21
+ /** The server's credential values that must never reach the policy or GitHub, longest first. */
22
+ export declare function credentials(env: NodeJS.ProcessEnv): string[];
23
+ /** Replaces every server credential in every string of a JSON-shaped value. */
24
+ export declare function redactCredentials<T>(value: T, secrets: string[]): T;
@@ -0,0 +1,49 @@
1
+ import { findShepherdJournal } from "./pr-journal.mjs";
2
+ const FIELDS = ['body', 'headRefName', 'baseRefName', 'state'];
3
+ /** The live pull request as gh reports it, rejected unless it is open. */
4
+ export async function livePullRequest(gh, pr) {
5
+ const output = await gh(['pr', 'view', String(pr), '--json', FIELDS.join(',')]);
6
+ const live = JSON.parse(output);
7
+ if (!isLive(live))
8
+ throw new Error(`gh pr view ${pr} did not return ${FIELDS.join(', ')}`);
9
+ // A closed or merged PR can share a reused branch name; its history must not be rewritten.
10
+ if (live.state !== 'OPEN')
11
+ throw new Error(`pull request ${pr} is ${live.state}, not OPEN`);
12
+ return live;
13
+ }
14
+ function isLive(value) {
15
+ return FIELDS.every((field) => typeof value?.[field] === 'string');
16
+ }
17
+ /**
18
+ * The base to validate an existing pull request against: its live base. An explicit `base` that
19
+ * differs is rejected, because `gh pr edit` here changes only the title and body.
20
+ */
21
+ export function liveBase(pr, live, given) {
22
+ if (given !== undefined && given !== live.baseRefName)
23
+ throw new Error(`pull request ${pr} targets ${live.baseRefName}, not ${given}`);
24
+ return live.baseRefName;
25
+ }
26
+ /**
27
+ * Re-reads the live pull request just before the edit and aborts if its Shepherd Journal or base
28
+ * changed since the body was prepared and validated, so an entry appended in between is never
29
+ * overwritten and a retargeted PR never gets a body validated against its old base.
30
+ */
31
+ export async function assertJournalUnchanged(gh, pr, preparedFrom, base) {
32
+ const now = await livePullRequest(gh, pr);
33
+ if (now.baseRefName !== base)
34
+ throw new Error(`pull request ${pr} was retargeted to ${now.baseRefName}; run pr_update again`);
35
+ if (findShepherdJournal(now.body) !== findShepherdJournal(preparedFrom))
36
+ throw new Error(`the Shepherd Journal of pull request ${pr} changed meanwhile; run pr_update again`);
37
+ }
38
+ /** The server's credential values that must never reach the policy or GitHub, longest first. */
39
+ export function credentials(env) {
40
+ const values = [env.AGENT_BLACKBOARD_TOKEN, env.GH_TOKEN, env.GITHUB_TOKEN].filter((value) => value !== undefined && value.length >= 4);
41
+ return [...new Set(values)].sort((a, b) => b.length - a.length);
42
+ }
43
+ /** Replaces every server credential in every string of a JSON-shaped value. */
44
+ export function redactCredentials(value, secrets) {
45
+ if (value === undefined || secrets.length === 0)
46
+ return value;
47
+ const json = secrets.reduce((text, secret) => text.split(JSON.stringify(secret).slice(1, -1)).join('[REDACTED]'), JSON.stringify(value));
48
+ return JSON.parse(json);
49
+ }
@@ -0,0 +1,54 @@
1
+ import type { RepoFixture } from './git-fixture.test-helpers.mts';
2
+ import type { GhRunner } from './pr-gh.mts';
3
+ import type { PolicyOutcome, PolicyRequest } from './policy-spawn.mts';
4
+ export declare const PR_ARGS: {
5
+ sessionId: string;
6
+ title: string;
7
+ summary: string;
8
+ impact: string;
9
+ relatedIssues: {
10
+ number: number;
11
+ relation: string;
12
+ reason: string;
13
+ }[];
14
+ details: string[];
15
+ base: string;
16
+ agent: string;
17
+ mode: string;
18
+ workspaceSetup: string;
19
+ };
20
+ export declare function declarePolicy(fixture: RepoFixture): void;
21
+ type Reply = (name: string, input: Record<string, unknown>) => unknown;
22
+ export declare function policy(reply: Reply): {
23
+ requests: {
24
+ name: string;
25
+ input: Record<string, unknown>;
26
+ }[];
27
+ spawnPolicy: (request: PolicyRequest) => Promise<PolicyOutcome>;
28
+ };
29
+ type GhCall = {
30
+ args: string[];
31
+ cwd: string;
32
+ env: NodeJS.ProcessEnv;
33
+ body?: string;
34
+ };
35
+ /** A fake gh: records calls (reading any --body-file while it still exists) and replies by verb. */
36
+ export declare function fakeGh(replies?: {
37
+ view?: unknown;
38
+ create?: string;
39
+ edit?: string;
40
+ }): {
41
+ calls: GhCall[];
42
+ bodyFiles: {
43
+ path: string;
44
+ mode: number;
45
+ }[];
46
+ runGh: GhRunner;
47
+ };
48
+ export declare const device: () => {
49
+ user: string;
50
+ host: string;
51
+ };
52
+ /** Gives the fixture an `origin` that already has the linked branch at its current commit. */
53
+ export declare function usePushedRemote(fixture: () => RepoFixture): void;
54
+ export {};
@@ -0,0 +1,67 @@
1
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { beforeEach } from 'vitest';
4
+ export const PR_ARGS = {
5
+ sessionId: 'native:owner',
6
+ title: 'Add the thing',
7
+ summary: 'Adds the thing.',
8
+ impact: 'Nobody breaks.',
9
+ relatedIssues: [{ number: 7, relation: 'closes', reason: 'tracks the work' }],
10
+ details: ['First paragraph.', '- a bullet'],
11
+ base: 'trunk',
12
+ agent: 'codex',
13
+ mode: 'autonomous',
14
+ workspaceSetup: 'fresh worktree',
15
+ };
16
+ export function declarePolicy(fixture) {
17
+ writeFileSync(join(fixture.linked, 'package.json'), JSON.stringify({ 'vouchington-tooling': { policy: 'policy.mts' } }));
18
+ writeFileSync(join(fixture.linked, 'policy.mts'), '');
19
+ }
20
+ export function policy(reply) {
21
+ const requests = [];
22
+ const spawnPolicy = async (request) => {
23
+ const { export: name, input } = JSON.parse(request.input);
24
+ requests.push({ name, input });
25
+ const result = reply(name, input);
26
+ const stdout = result === undefined ? { ok: true, skipped: true } : { ok: true, result };
27
+ return {
28
+ exitCode: 0,
29
+ stdout: JSON.stringify(stdout),
30
+ stderr: '',
31
+ timedOut: false,
32
+ overflowed: false,
33
+ };
34
+ };
35
+ return { requests, spawnPolicy };
36
+ }
37
+ /** A fake gh: records calls (reading any --body-file while it still exists) and replies by verb. */
38
+ export function fakeGh(replies = {}) {
39
+ const calls = [];
40
+ const bodyFiles = [];
41
+ const runGh = async (args, options) => {
42
+ const call = { args, ...options };
43
+ const file = args[args.indexOf('--body-file') + 1];
44
+ if (args.includes('--body-file') && file !== undefined) {
45
+ call.body = readFileSync(file, 'utf8');
46
+ bodyFiles.push({ path: file, mode: statSync(file).mode & 0o777 });
47
+ }
48
+ calls.push(call);
49
+ if (args[1] === 'view')
50
+ return JSON.stringify(replies.view);
51
+ if (args[1] === 'create')
52
+ return replies.create ?? 'https://github.com/o/r/pull/12\n';
53
+ return replies.edit ?? 'https://github.com/o/r/pull/5\n';
54
+ };
55
+ return { calls, bodyFiles, runGh };
56
+ }
57
+ export const device = () => ({ user: 'Jong', host: 'Laptop.Local' });
58
+ /** Gives the fixture an `origin` that already has the linked branch at its current commit. */
59
+ export function usePushedRemote(fixture) {
60
+ beforeEach(() => {
61
+ const f = fixture();
62
+ const origin = join(f.root, 'origin.git');
63
+ f.git(f.root, 'init', '--quiet', '--bare', origin);
64
+ f.git(f.linked, 'remote', 'add', 'origin', origin);
65
+ f.git(f.linked, 'push', '--quiet', 'origin', 'linked');
66
+ });
67
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from './tools-schema.mts';
2
+ export declare const PR_TOOLS: readonly ToolDefinition[];
@@ -0,0 +1,69 @@
1
+ import { ADDITIVE, MUTATING, READ_ONLY, tool, } from "./tools-schema.mjs";
2
+ /** These tools read or write GitHub, outside the server's own state. */
3
+ const openWorld = (annotations) => ({
4
+ ...annotations,
5
+ openWorldHint: true,
6
+ });
7
+ const text = (description) => ({ type: 'string', minLength: 1, description });
8
+ const PR_NUMBER = { type: 'integer', minimum: 1, description: 'Pull request number.' };
9
+ const FIELDS = {
10
+ title: { type: 'string', minLength: 1, maxLength: 256, description: 'Single-line PR title.' },
11
+ summary: text('What the change does, as markdown.'),
12
+ impact: text('Who or what is affected, as markdown.'),
13
+ relatedIssues: {
14
+ type: 'array',
15
+ description: 'Issues this pull request closes or refs; may be empty.',
16
+ items: {
17
+ type: 'object',
18
+ properties: {
19
+ number: { type: 'integer', minimum: 1 },
20
+ relation: { enum: ['closes', 'refs'] },
21
+ reason: text('Why the issue is related.'),
22
+ },
23
+ required: ['number', 'relation', 'reason'],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ details: {
28
+ type: 'array',
29
+ minItems: 1,
30
+ items: { type: 'string', minLength: 1 },
31
+ description: 'Markdown paragraphs or bullets, joined by blank lines.',
32
+ },
33
+ base: { type: 'string', minLength: 1, description: 'Base branch. Default: main.' },
34
+ agent: text('Agent name for the Agent line.'),
35
+ model: text('Optional model name, rendered in parentheses after the agent.'),
36
+ mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode, given to the policy.' },
37
+ workspaceSetup: text('Text for the Workspace setup line, such as how the worktree was prepared.'),
38
+ };
39
+ const REQUIRED = [
40
+ 'title',
41
+ 'summary',
42
+ 'impact',
43
+ 'relatedIssues',
44
+ 'details',
45
+ 'agent',
46
+ 'mode',
47
+ 'workspaceSetup',
48
+ ];
49
+ const RENDERING = 'The server renders the body from the structured input (Summary, Impact, Related issues, ' +
50
+ 'Details, then Workspace setup, Agent, Device and Worktree lines) and runs the checkout ' +
51
+ 'policy validatePullRequest export in a child process; the head branch is the ' +
52
+ "worktree's current branch. Policy tools need a server launched inside a repository worktree. ";
53
+ export const PR_TOOLS = [
54
+ tool('pr_validate', 'Renders the pull request body and validates it with the checkout policy; writes nothing. ' +
55
+ RENDERING +
56
+ 'Pass pr to validate an update of that pull request: the live body is read and its Shepherd ' +
57
+ 'Journal preserved. Returns { body, errors, warnings }.', openWorld(READ_ONLY), { ...FIELDS, pr: PR_NUMBER }, REQUIRED),
58
+ tool('pr_create', 'Creates a draft pull request from the worktree branch. ' +
59
+ RENDERING +
60
+ 'Validation errors block creation. The policy pullRequestDefaults export may add labels, a ' +
61
+ 'milestone and a project. The branch must already be pushed and current. It sees no stack ' +
62
+ 'membership, so a policy may reject a non-default base; create stacked layers with the ' +
63
+ "repository's stack tooling. Returns { number, url, warnings }.", openWorld(ADDITIVE), FIELDS, REQUIRED),
64
+ tool('pr_update', "Replaces an open pull request's title and body. " +
65
+ RENDERING +
66
+ "The pull request's head branch must be the worktree branch. The live Shepherd Journal is " +
67
+ 'preserved and nothing else is carried over from the live body. Validation errors block ' +
68
+ 'the update. Returns { number, url, warnings }.', openWorld(MUTATING), { ...FIELDS, pr: PR_NUMBER }, [...REQUIRED, 'pr']),
69
+ ];
@@ -1,4 +1,5 @@
1
1
  import { ADDITIVE, AGENT, COVERAGE, IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED, ID_PATTERN, MUTATING, OUTCOMES, PARENT_SESSION_ID, READ_ONLY, VERSION, tool, } from "./tools-schema.mjs";
2
+ import { PR_TOOLS } from "./tools-pr.mjs";
2
3
  import { RETROSPECTIVE_TOOLS } from "./tools-retrospective.mjs";
3
4
  export const TOOLS = [
4
5
  tool('journal_append', 'Appends one journal entry to the caller session and returns the verified read-back receipt. ' +
@@ -46,6 +47,7 @@ export const TOOLS = [
46
47
  tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
47
48
  'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
48
49
  ...RETROSPECTIVE_TOOLS,
50
+ ...PR_TOOLS,
49
51
  tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
50
52
  'client. Returns the path, counts, checksum, manifest, and cleanupToken; never the records. ' +
51
53
  'sessionId identifies the caller.', ADDITIVE, {
@@ -7,7 +7,7 @@ existing `vouchington-tooling/agent-blackboard` exports and adds no provider pro
7
7
 
8
8
  Phase 1 of [vouchington-tooling#324](https://github.com/vouchington/vouchington-tooling/issues/324)
9
9
  covers the journal, outbox, session, and snapshot tools below. Phase 2 adds the retrospective tools
10
- and the policy contract they run. Pull request tools are a later phase.
10
+ and the policy contract they run. Phase 3 adds the pull request tools.
11
11
 
12
12
  ## Install
13
13
 
@@ -225,16 +225,90 @@ whole process group is killed, so nothing it started outlives the call. The runn
225
225
  `{ policyVersion: 1, export, input }` on stdin and writes `{ ok: true, result }` (or
226
226
  `{ ok: false, error }`) on stdout; a missing export is skipped.
227
227
 
228
- | Export | Input | Result | Timeout |
229
- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ------- |
230
- | `validateRetrospective` | `sessionId`, `parentSessionId`, `mode`, `workOutcome`, `feedbackCoverage`, `repositories`, `markdown`, `sourceEventId`, `metadata` (`date`, `issues`, `prs`, `description`) | `{ errors: string[], warnings?: string[] }` | 30 s |
231
- | `pullRequestDefaults` | `{}` | `{ repositories: string[] }` | 120 s |
232
- | `validatePullRequest` | reserved for the pull request tools | `{ errors: string[], warnings?: string[] }` | 120 s |
228
+ | Export | Input | Result | Timeout |
229
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------- |
230
+ | `validateRetrospective` | `sessionId`, `parentSessionId`, `mode`, `workOutcome`, `feedbackCoverage`, `repositories`, `markdown`, `sourceEventId`, `metadata` (`date`, `issues`, `prs`, `description`) | `{ errors: string[], warnings?: string[] }` | 30 s |
231
+ | `pullRequestDefaults` | `{}` from `retrospective_save`; `{ title, base, relatedIssues }` from `pr_create` | `{ repositories?, labels?, milestone?, project? }` (an object) | 120 s |
232
+ | `validatePullRequest` | `title`, `body`, `base`, `head`, `mode`, and `pr` for an update | `{ errors: string[], warnings?: string[] }` | 120 s |
233
233
 
234
234
  Any `errors` reject the save and write nothing. A non-zero exit, a timeout (reported as
235
235
  `policy <export> timed out after <N>s`), output over 1 MB, malformed JSON, or a result of the wrong
236
236
  shape is an error, never a pass.
237
237
 
238
+ ## Pull request tools
239
+
240
+ `vouchington mcp` serves `pr_validate`, `pr_create` and `pr_update` besides the journal and
241
+ retrospective tools. All three take `sessionId` (required) and `worktree` (optional), need a server
242
+ launched inside a repository worktree (they run the checkout policy), and share this structured
243
+ input: `title` (single line, at most 256 characters), `summary`, `impact`, `relatedIssues`
244
+ (`[{ number, relation: "closes" | "refs", reason }]`, may be empty), `details` (string array),
245
+ `base` (default `main`), `agent`, optional `model`, `mode` (`interactive` | `autonomous`) and
246
+ `workspaceSetup`. `pr_update` also requires `pr`; `pr_validate` accepts an optional `pr`.
247
+
248
+ ### Rendering
249
+
250
+ ```
251
+ ## Summary
252
+
253
+ <summary>
254
+
255
+ ## Impact
256
+
257
+ <impact>
258
+
259
+ ## Related issues
260
+
261
+ - Closes #N — <reason> (or "Refs #N"; "- None" when empty)
262
+
263
+ ## Details
264
+
265
+ <details joined by blank lines>
266
+
267
+ Workspace setup: <workspaceSetup>
268
+ Agent: <agent>[ (<model>)] session <sessionId>
269
+ Device: <os user>@<lowercased hostname>
270
+ Worktree: <path after "/worktrees/", "main" for the main worktree, else the directory name>
271
+ ```
272
+
273
+ The head branch is the worktree's current branch; a detached HEAD, or a head equal to `base`, is
274
+ rejected. The body must stay within GitHub's 65536 characters. `pr_create` sees no stack membership,
275
+ so a consumer policy may reject any `base` other than the default branch; create stacked layers
276
+ with the repository's stack tooling instead.
277
+
278
+ ### Tools
279
+
280
+ - `pr_validate` (read-only): renders the body, runs the policy `validatePullRequest({ title, body,
281
+ base, head, mode, pr? })` export and returns `{ body, errors, warnings }`. With `pr`, the live
282
+ body is read (never written) so its Shepherd Journal is preserved as in `pr_update`.
283
+ - `pr_create` (additive): renders and validates (errors block), then calls the policy
284
+ `pullRequestDefaults({ title, base, relatedIssues })` export for optional `labels`, `milestone`
285
+ and `project`. It checks the head is pushed and current, writes the body to a mode 0600 temp file
286
+ (removed afterwards) and runs `gh pr create --draft` with the labels, `--milestone` and
287
+ `--project`. Returns `{ number, url, warnings }`.
288
+ - `pr_update` (mutating): reads `gh pr view <pr> --json body,headRefName,baseRefName,state`,
289
+ rejects a pull request that is not `OPEN` or whose head is not the worktree branch, and validates
290
+ against the live base (an explicit `base` must equal it). It renders, appends the live Shepherd
291
+ Journal (a `<details><summary>Shepherd Journal</summary>` block, or a legacy `## Shepherd Journal`
292
+ section to the next H2 or the end; examples in fenced code blocks are ignored), validates with
293
+ `pr` set (errors block), then runs `gh pr edit <pr> --title --body-file`. More than one journal
294
+ in the live body, one in the rendered fields, an unclosed journal, or rendered fields that leave
295
+ a code fence open are rejected rather than guessed. Just before the edit the live body is read
296
+ again, and the update aborts if its journal changed meanwhile. Nothing else is carried over from
297
+ the live body. Returns `{ number, url, warnings }`.
298
+
299
+ Every field is redacted of `AGENT_BLACKBOARD_TOKEN`, `GH_TOKEN`, and `GITHUB_TOKEN` before
300
+ rendering, the policy, or `gh`; a worktree branch name containing one is rejected. `title` and
301
+ `workspaceSetup` must be single lines, and the title is limited to 256 characters after redaction.
302
+
303
+ ### gh access
304
+
305
+ `gh` is found in the absolute directories of the server's `PATH` (relative entries are skipped,
306
+ since they would resolve inside the target worktree), else at `/opt/homebrew/bin/gh`, `/usr/local/bin/gh` or
307
+ `/usr/bin/gh`; otherwise the tool fails with "gh CLI not found on PATH; install gh or add it to the
308
+ server's PATH". It runs with the worktree as its working directory and the server environment
309
+ without `AGENT_BLACKBOARD_URL`, `AGENT_BLACKBOARD_TOKEN`, and `GH_REPO` (which would point it at
310
+ another repository), and each call is killed after 120 seconds.
311
+
238
312
  ## Worktree validation
239
313
 
240
314
  `worktree` is checked on every call. It must be an absolute path that, after resolving symlinks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -54,6 +54,14 @@ not evidence of a miss; classify genuine CI-only or external infrastructure fail
54
54
  Retain confirmed gap records after CI turns green. Diagnose the failure through the repository's
55
55
  CI investigation workflow; a failed status alone does not establish a gap.
56
56
 
57
+ Create, update, and validate pull requests through the `pr_create`, `pr_update`, and `pr_validate`
58
+ MCP tools with structured input, not by hand-writing a body: pass `title`, `summary`, `impact`,
59
+ `relatedIssues` (`closes` or `refs`, each with a reason), `details` (Markdown paragraphs or
60
+ bullets), `base`, `agent`, optional `model`, `mode`, and `workspaceSetup`. The server renders the
61
+ required headings and provenance lines, runs the repository's policy validation, and blocks on its
62
+ errors, so put the guidance above into `summary`, `impact`, and `details`. Run `pr_validate` first
63
+ to preview the rendered body. `pr_update` preserves the live Shepherd Journal.
64
+
57
65
  Use the repository's approved PR tooling and preserve sections managed by its review automation
58
66
  verbatim. Close only issues fully resolved and explain non-closing issue links. Apply this guidance
59
67
  to descriptions being created or updated, not a bulk rewrite of historical pull requests.