vouchington-tooling 0.45.0 → 0.47.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 (88) hide show
  1. package/README.md +10 -52
  2. package/dist/agent-blackboard/feedback-audit-source.d.mts +4 -10
  3. package/dist/agent-blackboard/feedback-audit-source.mjs +7 -18
  4. package/dist/agent-blackboard/feedback-compose-audit.mjs +6 -14
  5. package/dist/agent-blackboard/feedback-compose.d.mts +1 -4
  6. package/dist/agent-blackboard/feedback-compose.mjs +2 -5
  7. package/dist/agent-blackboard/feedback-fields.mjs +1 -1
  8. package/dist/agent-blackboard/feedback-identity.mjs +1 -1
  9. package/dist/agent-blackboard/feedback-journal-audit.d.mts +6 -8
  10. package/dist/agent-blackboard/feedback-journal-audit.mjs +9 -10
  11. package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +2 -2
  12. package/dist/agent-blackboard/feedback-journal-sandbox.mjs +2 -2
  13. package/dist/agent-blackboard/feedback-outbox-rejected.mjs +1 -1
  14. package/dist/agent-blackboard/feedback-outbox.mjs +2 -2
  15. package/dist/agent-blackboard/index.d.mts +1 -1
  16. package/dist/audit-journal/ci-failures.d.mts +6 -0
  17. package/dist/{session-friction → audit-journal}/ci-failures.mjs +3 -9
  18. package/dist/{session-friction → audit-journal}/journal.d.mts +0 -1
  19. package/dist/{session-friction → audit-journal}/journal.mjs +1 -1
  20. package/dist/audit-journal/session-id.d.mts +1 -0
  21. package/dist/{session-friction → audit-journal}/session-id.mjs +0 -5
  22. package/dist/{session-friction → audit-journal}/text.d.mts +1 -4
  23. package/dist/{session-friction → audit-journal}/text.mjs +4 -22
  24. package/dist/audit-journal/types.d.mts +17 -0
  25. package/dist/cli/commands/gha-runtime-audit.mjs +2 -0
  26. package/dist/gh-cli/env.d.mts +9 -0
  27. package/dist/gh-cli/env.mjs +12 -0
  28. package/dist/gh-cli/exec.d.mts +7 -4
  29. package/dist/gh-cli/exec.mjs +7 -5
  30. package/dist/gh-cli/index.d.mts +1 -0
  31. package/dist/gh-cli/index.mjs +1 -0
  32. package/dist/gha-post-review/github.mjs +2 -1
  33. package/dist/index.d.mts +3 -3
  34. package/dist/index.mjs +2 -2
  35. package/dist/mcp-server/context.d.mts +2 -0
  36. package/dist/mcp-server/dispatch.d.mts +2 -0
  37. package/dist/mcp-server/dispatch.mjs +1 -0
  38. package/dist/mcp-server/harness.test-helpers.d.mts +1 -0
  39. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  40. package/dist/mcp-server/pr-agent.d.mts +12 -0
  41. package/dist/mcp-server/pr-agent.mjs +34 -0
  42. package/dist/mcp-server/pr-body.d.mts +5 -3
  43. package/dist/mcp-server/pr-body.mjs +12 -13
  44. package/dist/mcp-server/pr-gh.mjs +2 -1
  45. package/dist/mcp-server/pr-input.d.mts +5 -2
  46. package/dist/mcp-server/pr-input.mjs +22 -4
  47. package/dist/mcp-server/pr-live.d.mts +1 -0
  48. package/dist/mcp-server/pr-live.mjs +1 -1
  49. package/dist/mcp-server/pr-prepare.mjs +25 -4
  50. package/dist/mcp-server/server.mjs +1 -0
  51. package/dist/mcp-server/tools-pr.mjs +20 -17
  52. package/dist/{session-friction → private-files}/lock.mjs +3 -2
  53. package/dist/private-files/log-file.d.mts +3 -0
  54. package/dist/private-files/log-file.mjs +53 -0
  55. package/dist/{session-friction → private-files}/utf8.mjs +1 -1
  56. package/dist/retrospective-facts/exec.mjs +2 -1
  57. package/docs/mcp-server.md +29 -10
  58. package/package.json +1 -6
  59. package/dist/session-friction/ci-failures.d.mts +0 -13
  60. package/dist/session-friction/classify.d.mts +0 -2
  61. package/dist/session-friction/classify.mjs +0 -98
  62. package/dist/session-friction/command-segments.d.mts +0 -3
  63. package/dist/session-friction/command-segments.mjs +0 -94
  64. package/dist/session-friction/coverage-ledger.d.mts +0 -10
  65. package/dist/session-friction/coverage-ledger.mjs +0 -75
  66. package/dist/session-friction/directory.d.mts +0 -1
  67. package/dist/session-friction/directory.mjs +0 -71
  68. package/dist/session-friction/index.d.mts +0 -6
  69. package/dist/session-friction/index.mjs +0 -5
  70. package/dist/session-friction/log-file.d.mts +0 -7
  71. package/dist/session-friction/log-file.mjs +0 -95
  72. package/dist/session-friction/log.d.mts +0 -8
  73. package/dist/session-friction/log.mjs +0 -163
  74. package/dist/session-friction/normalize.d.mts +0 -1
  75. package/dist/session-friction/normalize.mjs +0 -81
  76. package/dist/session-friction/report.d.mts +0 -16
  77. package/dist/session-friction/report.mjs +0 -91
  78. package/dist/session-friction/sandbox.d.mts +0 -3
  79. package/dist/session-friction/sandbox.mjs +0 -43
  80. package/dist/session-friction/session-id.d.mts +0 -2
  81. package/dist/session-friction/types.d.mts +0 -80
  82. /package/dist/{session-friction → audit-journal}/audit-block.d.mts +0 -0
  83. /package/dist/{session-friction → audit-journal}/audit-block.mjs +0 -0
  84. /package/dist/{session-friction → audit-journal}/audit-fit.d.mts +0 -0
  85. /package/dist/{session-friction → audit-journal}/audit-fit.mjs +0 -0
  86. /package/dist/{session-friction → audit-journal}/types.mjs +0 -0
  87. /package/dist/{session-friction → private-files}/lock.d.mts +0 -0
  88. /package/dist/{session-friction → private-files}/utf8.d.mts +0 -0
@@ -1,16 +1,19 @@
1
1
  /** A text-mode command runner: argv in, stdout out. Rejects on a non-zero exit. */
2
2
  export type RunTextCommand = (args: string[]) => Promise<string>;
3
3
  /** The `child_process.execFile` shape `createCommandRunner` wraps — injectable for tests. */
4
- export type ExecFileText = (command: string, args: string[]) => Promise<{
4
+ export type ExecFileText = (command: string, args: string[], options?: {
5
+ env: NodeJS.ProcessEnv;
6
+ }) => Promise<{
5
7
  stdout: string;
6
8
  }>;
7
9
  /**
8
10
  * Builds a `RunTextCommand` bound to a fixed binary. The default `exec` wraps `execFile` via
9
11
  * `promisify` so a test can inject a fake without spawning a real process; `runGh`/`runGit` below
10
- * are this factory applied to the two binaries this module cares about.
12
+ * are this factory applied to the two binaries this module cares about. `buildEnv` maps the
13
+ * parent environment to the one the child is spawned with (identity by default).
11
14
  */
12
- export declare function createCommandRunner(command: string, exec?: ExecFileText): RunTextCommand;
13
- /** Runs `gh` and returns its stdout. */
15
+ export declare function createCommandRunner(command: string, exec?: ExecFileText, buildEnv?: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv): RunTextCommand;
16
+ /** Runs `gh` and returns its stdout, with `CLICOLOR_FORCE` removed so `--json` output stays plain. */
14
17
  export declare const runGh: RunTextCommand;
15
18
  /** Runs `git` and returns its stdout. */
16
19
  export declare const runGit: RunTextCommand;
@@ -1,18 +1,20 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { ghSpawnEnv } from "./env.mjs";
3
4
  const execFileAsync = promisify(execFile);
4
5
  /**
5
6
  * Builds a `RunTextCommand` bound to a fixed binary. The default `exec` wraps `execFile` via
6
7
  * `promisify` so a test can inject a fake without spawning a real process; `runGh`/`runGit` below
7
- * are this factory applied to the two binaries this module cares about.
8
+ * are this factory applied to the two binaries this module cares about. `buildEnv` maps the
9
+ * parent environment to the one the child is spawned with (identity by default).
8
10
  */
9
- export function createCommandRunner(command, exec = (cmd, args) => execFileAsync(cmd, args)) {
11
+ export function createCommandRunner(command, exec = (cmd, args, options) => execFileAsync(cmd, args, { ...options, encoding: 'utf8' }), buildEnv = (env) => env) {
10
12
  return async (args) => {
11
- const { stdout } = await exec(command, args);
13
+ const { stdout } = await exec(command, args, { env: buildEnv(process.env) });
12
14
  return stdout;
13
15
  };
14
16
  }
15
- /** Runs `gh` and returns its stdout. */
16
- export const runGh = createCommandRunner('gh');
17
+ /** Runs `gh` and returns its stdout, with `CLICOLOR_FORCE` removed so `--json` output stays plain. */
18
+ export const runGh = createCommandRunner('gh', undefined, ghSpawnEnv);
17
19
  /** Runs `git` and returns its stdout. */
18
20
  export const runGit = createCommandRunner('git');
@@ -1,4 +1,5 @@
1
1
  export { createCommandRunner, runGh, runGit } from './exec.mts';
2
+ export { ghSpawnEnv } from './env.mts';
2
3
  export type { ExecFileText, RunTextCommand } from './exec.mts';
3
4
  export { DiffCommandError, processDiffCommand } from './diff.mts';
4
5
  export type { DiffBlockCallback, DiffCommand } from './diff.mts';
@@ -1,4 +1,5 @@
1
1
  export { createCommandRunner, runGh, runGit } from "./exec.mjs";
2
+ export { ghSpawnEnv } from "./env.mjs";
2
3
  export { DiffCommandError, processDiffCommand } from "./diff.mjs";
3
4
  export { GITHUB_BODY_MAX_CHARACTERS, validateGitHubBodyLength } from "./body-length.mjs";
4
5
  export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from "./pr-create.mjs";
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { appendFileSync } from 'node:fs';
3
+ import { ghSpawnEnv } from "../gh-cli/env.mjs";
3
4
  import { ReviewPayloadError } from "../gha-review-payload/index.mjs";
4
5
  import { readRegularReviewPayload } from "../gha-review-payload/index.mjs";
5
6
  import { createActionsClaudeTokenIo, withClaudeAppToken } from "./claude-token.mjs";
@@ -11,7 +12,7 @@ export function createGhExec(exec = execFileSync) {
11
12
  return (args, options) => exec('gh', [...args], {
12
13
  encoding: 'utf8',
13
14
  input: options?.input,
14
- env: options?.env ?? process.env,
15
+ env: ghSpawnEnv(options?.env ?? process.env),
15
16
  }).trim();
16
17
  }
17
18
  export function writePostedOutput(posted, commentCount, outputPath = process.env.GITHUB_OUTPUT) {
package/dist/index.d.mts CHANGED
@@ -8,8 +8,8 @@ export { runRetrospectiveFacts } from './retrospective-facts/index.mts';
8
8
  export type { CommandExecutor, CommandResult, RetrospectiveFactsOptions, } from './retrospective-facts/index.mts';
9
9
  export { appendJournal, assertSessionId, cleanupSnapshotPartitions, partitionSnapshot, probeBlackboard, readJournal, resolveBlackboardConnection, } from './agent-blackboard/index.mts';
10
10
  export type { BlackboardConnection, SnapshotChecksum, SnapshotCleanupReceipt, SnapshotCleanupOptions, SnapshotCounts, SnapshotManifest, SnapshotPartition, SnapshotPartitionOptions, SnapshotPartitionResult, SnapshotSelection, } from './agent-blackboard/index.mts';
11
- export { buildSessionFrictionReport, classifyFrictionObservation, FRICTION_LOG_MAX_EVENTS, isConformingCiFailureBlock, normalizeCommandPrefix, readFrictionLog, recordFriction, } from './session-friction/index.mts';
12
- export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoader, JournalLoadResult, PermissionRequestObservation, SessionFrictionReport, SessionFrictionReportOptions, ToolResultObservation, } from './session-friction/index.mts';
11
+ export { isConformingCiFailureBlock } from './audit-journal/ci-failures.mts';
12
+ export type { JournalEntry, JournalLoader, JournalLoadResult } from './audit-journal/types.mts';
13
13
  export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from './runner-port-policy/index.mts';
14
14
  export type { EphemeralListenerOptions, RunnerPortPolicy } from './runner-port-policy/index.mts';
15
15
  export { extractCreateTableMetadata, extractDefaultFunction, extractFuncCallArgColumnNames, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mts';
@@ -95,7 +95,7 @@ export { validateResolvedPinDelta } from './swift-resolved-pin-delta/index.mts';
95
95
  export type { ResolvedDocument, ResolvedPin, ValidateResolvedPinDeltaOptions, } from './swift-resolved-pin-delta/index.mts';
96
96
  export { DEFAULT_MAX_DIAGNOSTIC_REPORTS, DEFAULT_MAX_FORMATTED_DIAGNOSTIC_REPORTS, formatDiagnosticReportSummaries, HARD_MAX_DIAGNOSTIC_REPORTS, readDiagnosticReportSummaries, summarizeDiagnosticReport, } from './vitest-diagnostics/index.mts';
97
97
  export type { DiagnosticReportLimitOptions, DiagnosticReportSummary, } from './vitest-diagnostics/index.mts';
98
- export { createCommandRunner, runGh, runGit } from './gh-cli/index.mts';
98
+ export { createCommandRunner, ghSpawnEnv, runGh, runGit } from './gh-cli/index.mts';
99
99
  export type { ExecFileText, RunTextCommand } from './gh-cli/index.mts';
100
100
  export { DiffCommandError, processDiffCommand } from './gh-cli/index.mts';
101
101
  export type { DiffBlockCallback, DiffCommand } from './gh-cli/index.mts';
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ export { HARNESS_IDS, inspectHarnessEnvironment, selectHarnessSession, } from ".
4
4
  export { codexChildren, codexIdentity, computeTranscriptFacts, formatTranscriptFacts, formatUnavailable, resolveTranscriptFile, runRetrospectiveTranscript, } from "./retrospective-transcript/index.mjs";
5
5
  export { runRetrospectiveFacts } from "./retrospective-facts/index.mjs";
6
6
  export { appendJournal, assertSessionId, cleanupSnapshotPartitions, partitionSnapshot, probeBlackboard, readJournal, resolveBlackboardConnection, } from "./agent-blackboard/index.mjs";
7
- export { buildSessionFrictionReport, classifyFrictionObservation, FRICTION_LOG_MAX_EVENTS, isConformingCiFailureBlock, normalizeCommandPrefix, readFrictionLog, recordFriction, } from "./session-friction/index.mjs";
7
+ export { isConformingCiFailureBlock } from "./audit-journal/ci-failures.mjs";
8
8
  export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from "./runner-port-policy/index.mjs";
9
9
  export { extractCreateTableMetadata, extractDefaultFunction, extractFuncCallArgColumnNames, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from "./sql-ast/index.mjs";
10
10
  export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from "./sql-scanner/index.mjs";
@@ -52,7 +52,7 @@ export { normalizeSwiftSource } from "./swift-semantic-equal/index.mjs";
52
52
  export { isSwiftCodeOffset, parseUniqueSwiftBinaryTargetChecksum, } from "./swift-source-offset/index.mjs";
53
53
  export { validateResolvedPinDelta } from "./swift-resolved-pin-delta/index.mjs";
54
54
  export { DEFAULT_MAX_DIAGNOSTIC_REPORTS, DEFAULT_MAX_FORMATTED_DIAGNOSTIC_REPORTS, formatDiagnosticReportSummaries, HARD_MAX_DIAGNOSTIC_REPORTS, readDiagnosticReportSummaries, summarizeDiagnosticReport, } from "./vitest-diagnostics/index.mjs";
55
- export { createCommandRunner, runGh, runGit } from "./gh-cli/index.mjs";
55
+ export { createCommandRunner, ghSpawnEnv, runGh, runGit } from "./gh-cli/index.mjs";
56
56
  export { DiffCommandError, processDiffCommand } from "./gh-cli/index.mjs";
57
57
  export { GITHUB_BODY_MAX_CHARACTERS, validateGitHubBodyLength } from "./gh-cli/index.mjs";
58
58
  export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from "./gh-cli/index.mjs";
@@ -21,6 +21,8 @@ export type ToolContext = {
21
21
  runGit: RunTextCommand;
22
22
  /** Test seam: replaces locating and running gh. */
23
23
  runGh?: GhRunner;
24
+ /** The connected MCP client's `clientInfo.name`, once `initialize` has completed. */
25
+ clientName?: () => string | undefined;
24
26
  /** Test seam: replaces the operating system user and host name. */
25
27
  device?: () => {
26
28
  user: string;
@@ -16,6 +16,8 @@ export type ServerEnvironment = {
16
16
  spawnPolicy?: PolicySpawner;
17
17
  /** Test seam: replaces locating and running gh. */
18
18
  runGh?: GhRunner;
19
+ /** The connected MCP client's `clientInfo.name`, once `initialize` has completed. */
20
+ clientName?: () => string | undefined;
19
21
  /** Test seam: replaces the operating system user and host name. */
20
22
  device?: () => {
21
23
  user: string;
@@ -67,6 +67,7 @@ export async function callTool(name, rawArguments, environment) {
67
67
  runGit: environment.runGit,
68
68
  snapshots: environment.snapshots ?? PROCESS_SNAPSHOTS,
69
69
  ...(environment.runGh === undefined ? {} : { runGh: environment.runGh }),
70
+ ...(environment.clientName === undefined ? {} : { clientName: environment.clientName }),
70
71
  ...(environment.device === undefined ? {} : { device: environment.device }),
71
72
  ...(environment.spawnPolicy === undefined ? {} : { spawnPolicy: environment.spawnPolicy }),
72
73
  // The client resolves from this package's install, like the SDK, never from the worktree.
@@ -23,6 +23,7 @@ export declare function harness(fixture: RepoFixture, options?: FakeOptions & {
23
23
  host: string;
24
24
  };
25
25
  snapshots?: SnapshotRegistry;
26
+ clientName?: () => string | undefined;
26
27
  }): Harness;
27
28
  export declare function textOf(result: ToolCallResult): string;
28
29
  export declare function jsonOf(result: ToolCallResult): Record<string, unknown>;
@@ -28,6 +28,7 @@ export function harness(fixture, options = {}) {
28
28
  ...(options.runGh === undefined ? {} : { runGh: options.runGh }),
29
29
  ...(options.device === undefined ? {} : { device: options.device }),
30
30
  ...(options.snapshots === undefined ? {} : { snapshots: options.snapshots }),
31
+ ...(options.clientName === undefined ? {} : { clientName: options.clientName }),
31
32
  };
32
33
  return { fake, call: (name, args) => callTool(name, args, environment) };
33
34
  }
@@ -0,0 +1,12 @@
1
+ import { type HarnessId } from '../agent-harness-identity/index.mts';
2
+ /**
3
+ * The harness that launched this server, from the launch environment first and the MCP client's
4
+ * `initialize` `clientInfo.name` second. Neither comes from a tool argument.
5
+ */
6
+ export declare function deriveAgent(env: NodeJS.ProcessEnv, clientName?: string): HarnessId | undefined;
7
+ /**
8
+ * The text of the `Agent:` line before the session. The agent is the derived harness; a caller
9
+ * `agent` that does not name it, or any `model`, is shown as caller-claimed and never replaces it.
10
+ * With nothing derivable the agent is `unknown`.
11
+ */
12
+ export declare function agentLabel(derived: HarnessId | undefined, claimedAgent: string | undefined, claimedModel: string | undefined): string;
@@ -0,0 +1,34 @@
1
+ import { inspectHarnessEnvironment } from "../agent-harness-identity/index.mjs";
2
+ const PRECEDENCE = ['codex', 'claude', 'cursor', 'grok'];
3
+ /**
4
+ * The harness that launched this server, from the launch environment first and the MCP client's
5
+ * `initialize` `clientInfo.name` second. Neither comes from a tool argument.
6
+ */
7
+ export function deriveAgent(env, clientName) {
8
+ const found = inspectHarnessEnvironment(env);
9
+ const present = {
10
+ codex: Boolean(found.codex.sessionId),
11
+ claude: found.claude.compatMode || Boolean(found.claude.sessionId),
12
+ cursor: found.cursor.agentMarker || Boolean(found.cursor.sessionId),
13
+ grok: found.grok.agentMarker || found.grok.hookEvent || Boolean(found.grok.sessionId),
14
+ };
15
+ const fromEnv = PRECEDENCE.find((id) => present[id]);
16
+ if (fromEnv !== undefined)
17
+ return fromEnv;
18
+ const name = clientName?.toLowerCase() ?? '';
19
+ return PRECEDENCE.find((id) => name.includes(id));
20
+ }
21
+ /**
22
+ * The text of the `Agent:` line before the session. The agent is the derived harness; a caller
23
+ * `agent` that does not name it, or any `model`, is shown as caller-claimed and never replaces it.
24
+ * With nothing derivable the agent is `unknown`.
25
+ */
26
+ export function agentLabel(derived, claimedAgent, claimedModel) {
27
+ const notes = [];
28
+ const claim = claimedAgent?.trim();
29
+ if (claim && !(derived !== undefined && claim.toLowerCase().includes(derived)))
30
+ notes.push(`caller-claimed agent: ${claim}`);
31
+ if (claimedModel)
32
+ notes.push(`caller-claimed model: ${claimedModel}`);
33
+ return `${derived ?? 'unknown'}${notes.length ? ` (${notes.join('; ')})` : ''}`;
34
+ }
@@ -7,12 +7,14 @@ export type PullRequestProvenance = {
7
7
  host: string;
8
8
  /** The checkout label, see {@link worktreeLabel}. */
9
9
  worktree: string;
10
+ /** The Agent line text before the session, see `agentLabel`. */
11
+ agent: string;
10
12
  };
11
13
  /**
12
14
  * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
13
- * provenance lines. With `noSourceIssue` the Related issues section holds `noSourceStatement`
14
- * (default {@link defaultNoSourceStatement}) instead of `- None`. Pure; the caller supplies the
15
- * device and worktree facts.
15
+ * provenance lines. With `noSourceIssue` the Related issues section ends with `noSourceStatement`
16
+ * (default {@link defaultNoSourceStatement}), after any `Refs` lines, instead of `- None`. Pure;
17
+ * the caller supplies the device and worktree facts.
16
18
  */
17
19
  export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance, noSourceStatement?: string): string;
18
20
  /**
@@ -1,7 +1,9 @@
1
1
  import { basename } from 'node:path';
2
+ import { formatReferenceKey } from "../gh-cli/closing-refs.mjs";
2
3
  function relatedLine(issue) {
3
4
  const verb = issue.relation === 'closes' ? 'Closes' : 'Refs';
4
- return `- ${verb} #${issue.number} — ${issue.reason}`;
5
+ const [owner, repo] = issue.repository?.split('/') ?? [];
6
+ return `- ${verb} ${formatReferenceKey({ number: issue.number, owner, repo })} — ${issue.reason}`;
5
7
  }
6
8
  /** The default no-source statement; a policy `renderNoSourceIssue` export may replace it. */
7
9
  function defaultNoSourceStatement(reason) {
@@ -9,19 +11,16 @@ function defaultNoSourceStatement(reason) {
9
11
  }
10
12
  /**
11
13
  * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
12
- * provenance lines. With `noSourceIssue` the Related issues section holds `noSourceStatement`
13
- * (default {@link defaultNoSourceStatement}) instead of `- None`. Pure; the caller supplies the
14
- * device and worktree facts.
14
+ * provenance lines. With `noSourceIssue` the Related issues section ends with `noSourceStatement`
15
+ * (default {@link defaultNoSourceStatement}), after any `Refs` lines, instead of `- None`. Pure;
16
+ * the caller supplies the device and worktree facts.
15
17
  */
16
18
  export function renderPullRequestBody(input, provenance, noSourceStatement) {
17
- const related = input.relatedIssues.length
18
- ? input.relatedIssues.map(relatedLine)
19
- : [
20
- input.noSourceIssue === undefined
21
- ? '- None'
22
- : (noSourceStatement ?? defaultNoSourceStatement(input.noSourceIssue.reason)),
23
- ];
24
- const agent = input.model === undefined ? input.agent : `${input.agent} (${input.model})`;
19
+ const related = input.relatedIssues.map(relatedLine);
20
+ if (input.noSourceIssue !== undefined)
21
+ related.push(noSourceStatement ?? defaultNoSourceStatement(input.noSourceIssue.reason));
22
+ if (!related.length)
23
+ related.push('- None');
25
24
  return [
26
25
  '## Summary',
27
26
  '',
@@ -40,7 +39,7 @@ export function renderPullRequestBody(input, provenance, noSourceStatement) {
40
39
  input.details.join('\n\n'),
41
40
  '',
42
41
  `Workspace setup: ${input.workspaceSetup}`,
43
- `Agent: ${agent} session ${provenance.sessionId}`,
42
+ `Agent: ${provenance.agent} session ${provenance.sessionId}`,
44
43
  `Device: ${provenance.user}@${provenance.host.toLowerCase()}`,
45
44
  `Worktree: ${provenance.worktree}`,
46
45
  '',
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
3
3
  import { access, stat } from 'node:fs/promises';
4
4
  import { delimiter, isAbsolute, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { ghSpawnEnv } from "../gh-cli/env.mjs";
6
7
  const execFileAsync = promisify(execFile);
7
8
  const FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'];
8
9
  const MAX_BUFFER_BYTES = 16_000_000;
@@ -36,7 +37,7 @@ export async function locateGh(env, fallbacks = FALLBACKS) {
36
37
  * GH_REPO, which would point gh at a repository other than the validated worktree's.
37
38
  */
38
39
  export function ghEnvironment(env) {
39
- const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = env;
40
+ const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = ghSpawnEnv(env);
40
41
  return rest;
41
42
  }
42
43
  /** A runner that locates gh once, on first use, from the server's environment. */
@@ -4,19 +4,22 @@ export type RelatedIssue = {
4
4
  number: number;
5
5
  relation: 'closes' | 'refs';
6
6
  reason: string;
7
+ /** `owner/name` of a cross-repository issue (rendered lowercase); absent for this repository. */
8
+ repository?: string;
7
9
  };
8
10
  export type PullRequestInput = {
9
11
  title: string;
10
12
  summary: string;
11
13
  impact: string;
12
14
  relatedIssues: RelatedIssue[];
13
- /** Set instead of relatedIssues when the pull request has no source issue. */
15
+ /** Declares no source issue; relatedIssues beside it must all be `refs`. */
14
16
  noSourceIssue?: {
15
17
  reason: string;
16
18
  };
17
19
  details: string[];
18
20
  base: string;
19
- agent: string;
21
+ /** The caller's claim only; the rendered Agent line comes from `agentLabel`. */
22
+ agent?: string;
20
23
  model?: string;
21
24
  /** The trusted mode, see {@link trustedMode}; never the caller's word alone. */
22
25
  mode: PullRequestMode;
@@ -1,3 +1,4 @@
1
+ import { hasClosingIssueReference } from "../gh-cli/closing-refs.mjs";
1
2
  import { optionalString, rejectUnknown, requireObject, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
2
3
  import { trustedMode } from "./pr-mode.mjs";
3
4
  const PR_MODES = ['interactive', 'autonomous'];
@@ -9,17 +10,30 @@ export function positiveInteger(value, label) {
9
10
  throw new Error(`${label} must be a positive integer`);
10
11
  return value;
11
12
  }
13
+ // The owner is a GitHub login: alphanumerics and single hyphens, at most 39 characters.
14
+ const REPOSITORY_RE = /^(?=[^/]{1,39}\/)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\/[\w.-]+$/;
15
+ function readRepository(issue, label) {
16
+ if (issue.repository === undefined)
17
+ return undefined;
18
+ // Not lowercased here: redaction runs on this value first, and rendering lowercases it.
19
+ const value = singleLine(issue, 'repository');
20
+ if (!REPOSITORY_RE.test(value))
21
+ throw new Error(`${label} must be a single owner/name`);
22
+ return value;
23
+ }
12
24
  function readRelatedIssues(args) {
13
25
  const value = args.relatedIssues;
14
26
  if (!Array.isArray(value))
15
27
  throw new Error('relatedIssues is required and must be an array');
16
28
  return value.map((item, index) => {
17
29
  const issue = requireObject(item);
18
- rejectUnknown(issue, ['number', 'relation', 'reason']);
30
+ rejectUnknown(issue, ['number', 'relation', 'reason', 'repository']);
31
+ const repository = readRepository(issue, `relatedIssues[${index}].repository`);
19
32
  return {
20
33
  number: positiveInteger(issue.number, `relatedIssues[${index}].number`),
21
34
  relation: requiredChoice(issue, 'relation', RELATIONS),
22
35
  reason: requiredString(issue, 'reason'),
36
+ ...(repository === undefined ? {} : { repository }),
23
37
  };
24
38
  });
25
39
  }
@@ -28,8 +42,12 @@ function readNoSourceIssue(args, related) {
28
42
  return undefined;
29
43
  const value = requireObject(args.noSourceIssue);
30
44
  rejectUnknown(value, ['reason']);
31
- if (related.length)
32
- throw new Error('noSourceIssue and a non-empty relatedIssues are mutually exclusive');
45
+ if (related.some((issue) => issue.relation === 'closes'))
46
+ throw new Error('noSourceIssue cannot be combined with a relatedIssues entry that closes');
47
+ related.forEach((issue, index) => {
48
+ if (hasClosingIssueReference(issue.reason))
49
+ throw new Error(`relatedIssues[${index}].reason must not contain a closing reference beside noSourceIssue`);
50
+ });
33
51
  return { reason: singleLine(value, 'reason') };
34
52
  }
35
53
  function readDetails(args) {
@@ -70,7 +88,7 @@ export function readPullRequestInput(args, env) {
70
88
  ...(noSourceIssue === undefined ? {} : { noSourceIssue }),
71
89
  details: readDetails(args),
72
90
  base: optionalString(args, 'base') ?? 'main',
73
- agent: singleLine(args, 'agent'),
91
+ ...(args.agent === undefined ? {} : { agent: singleLine(args, 'agent') }),
74
92
  ...(model === undefined ? {} : { model }),
75
93
  mode: trustedMode(env, requested),
76
94
  // It renders on its own provenance line; a newline would forge the lines after it.
@@ -2,6 +2,7 @@ import type { RunTextCommand } from '../gh-cli/exec.mts';
2
2
  export type LivePullRequest = {
3
3
  body: string;
4
4
  headRefName: string;
5
+ headRefOid: string;
5
6
  baseRefName: string;
6
7
  state: string;
7
8
  };
@@ -1,5 +1,5 @@
1
1
  import { findShepherdJournal } from "./pr-journal.mjs";
2
- const FIELDS = ['body', 'headRefName', 'baseRefName', 'state'];
2
+ const FIELDS = ['body', 'headRefName', 'headRefOid', 'baseRefName', 'state'];
3
3
  /** The live pull request as gh reports it, whatever its state. */
4
4
  export async function readLivePullRequest(gh, pr) {
5
5
  const output = await gh(['pr', 'view', String(pr), '--json', FIELDS.join(',')]);
@@ -1,6 +1,7 @@
1
1
  import { hostname, userInfo } from 'node:os';
2
2
  import { validateGitHubBodyLength } from "../gh-cli/body-length.mjs";
3
3
  import { assertPolicyAllowed } from "./context.mjs";
4
+ import { agentLabel, deriveAgent } from "./pr-agent.mjs";
4
5
  import { renderPullRequestBody, worktreeLabel } from "./pr-body.mjs";
5
6
  import { createGhRunner, ghEnvironment } from "./pr-gh.mjs";
6
7
  import { checkTitleLength, readPullRequestInput } from "./pr-input.mjs";
@@ -41,6 +42,20 @@ async function liveFor(kind, context, pr) {
41
42
  ? livePullRequest(ghIn(context), pr)
42
43
  : readLivePullRequest(ghIn(context), pr);
43
44
  }
45
+ /**
46
+ * The head to validate. An update accepts the worktree branch, or an alias branch whose HEAD is
47
+ * the live head commit, and then uses the live head name; anything else is rejected.
48
+ */
49
+ async function headFor(kind, git, live, pr, worktreeHead) {
50
+ if (worktreeHead === undefined)
51
+ return live.headRefName;
52
+ if (live === undefined || kind !== 'update' || live.headRefName === worktreeHead)
53
+ return worktreeHead;
54
+ const sha = (await git(['rev-parse', 'HEAD'])).trim();
55
+ if (sha !== live.headRefOid)
56
+ throw new Error(`pull request ${pr} has head ${live.headRefName} at ${live.headRefOid}, not the worktree branch ${worktreeHead} at ${sha}`);
57
+ return live.headRefName;
58
+ }
44
59
  /** Renders the body, preserves the live journal for an update, and runs the policy validator. */
45
60
  export async function prepare(args, context, kind, pr) {
46
61
  assertPolicyAllowed(context);
@@ -53,14 +68,15 @@ export async function prepare(args, context, kind, pr) {
53
68
  const live = await liveFor(kind, context, pr);
54
69
  // A read-only validation of an existing pull request judges the head that pull request has.
55
70
  const worktreeHead = live !== undefined && kind === 'validate' ? undefined : await currentBranch(git);
56
- const head = worktreeHead ?? live.headRefName;
71
+ // The local branch is checked before any diagnostic or alias resolution can echo or drop it.
72
+ if (worktreeHead !== undefined && secrets.some((secret) => worktreeHead.includes(secret)))
73
+ throw new Error('the worktree branch name contains a server credential: rename the branch');
74
+ const head = await headFor(kind, git, live, pr, worktreeHead);
57
75
  // Branch names are refs passed to gh as is: redacting one would name a branch that does not exist.
58
76
  if (secrets.some((secret) => head.includes(secret)))
59
77
  throw new Error('the worktree branch name contains a server credential: rename the branch');
60
78
  if (secrets.some((secret) => args.base?.includes(secret)))
61
79
  throw new Error('base contains a server credential: use another base branch');
62
- if (live !== undefined && kind === 'update' && live.headRefName !== head)
63
- throw new Error(`pull request ${pr} has head ${live.headRefName}, not the worktree branch ${head}`);
64
80
  const base = live === undefined
65
81
  ? given.base
66
82
  : liveBase(pr, live, redactCredentials(args.base, secrets));
@@ -70,7 +86,12 @@ export async function prepare(args, context, kind, pr) {
70
86
  throw new Error(`head branch ${head} must differ from base ${base}`);
71
87
  const device = (context.device ?? (() => ({ user: userInfo().username, host: hostname() })))();
72
88
  const statement = await noSourceStatement(policyFor(context), input);
73
- let body = renderPullRequestBody(input, { sessionId: context.sessionId, ...device, worktree: await label(context, git) }, statement);
89
+ let body = renderPullRequestBody(input, {
90
+ sessionId: context.sessionId,
91
+ ...device,
92
+ worktree: await label(context, git),
93
+ agent: agentLabel(deriveAgent(context.env, context.clientName?.()), input.agent, input.model),
94
+ }, statement);
74
95
  if (live !== undefined)
75
96
  body = preserveShepherdJournal(body, live.body);
76
97
  body = redactCredentials(body, secrets);
@@ -15,6 +15,7 @@ const INSTRUCTIONS = 'Journals agent-blackboard feedback for a vouchington consu
15
15
  */
16
16
  export function createMcpServer(sdk, environment, version) {
17
17
  const mcp = new sdk.McpServer({ name: SERVER_NAME, version }, { instructions: INSTRUCTIONS });
18
+ environment.clientName ??= () => mcp.server.getClientVersion()?.name;
18
19
  mcp.server.registerCapabilities({ tools: {} });
19
20
  mcp.server.setRequestHandler(sdk.ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
20
21
  mcp.server.setRequestHandler(sdk.CallToolRequestSchema, async (request) => callTool(request.params.name, request.params.arguments, environment));
@@ -12,14 +12,20 @@ const FIELDS = {
12
12
  impact: text('Who or what is affected, as markdown.'),
13
13
  relatedIssues: {
14
14
  type: 'array',
15
- description: 'Issues this pull request closes or refs. Empty when it has none; with no source issue ' +
16
- 'at all, give noSourceIssue as well (it is rejected beside a non-empty list).',
15
+ description: 'Issues this pull request closes or refs. Empty when it has none; with no source issue, ' +
16
+ 'give noSourceIssue as well (beside a non-empty list every entry must be refs).',
17
17
  items: {
18
18
  type: 'object',
19
19
  properties: {
20
20
  number: { type: 'integer', minimum: 1 },
21
21
  relation: { enum: ['closes', 'refs'] },
22
22
  reason: text('Why the issue is related.'),
23
+ repository: {
24
+ type: 'string',
25
+ pattern: '^(?=[^/]{1,39}/)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/[\\w.-]+$',
26
+ description: 'Optional owner/name (lowercased) of the issue repository, rendered as ' +
27
+ 'owner/name#N; omit for an issue in this repository. Allowed for closes and refs.',
28
+ },
23
29
  },
24
30
  required: ['number', 'relation', 'reason'],
25
31
  additionalProperties: false,
@@ -27,9 +33,10 @@ const FIELDS = {
27
33
  },
28
34
  noSourceIssue: {
29
35
  type: 'object',
30
- description: 'Declares a pull request with no source issue, mutually exclusive with a non-empty ' +
31
- 'relatedIssues. Rendered inside Related issues (the policy may supply the wording) and ' +
32
- 'passed to the policy, which may allow it only in interactive mode.',
36
+ description: 'Declares a pull request with no source issue; rejected beside a closes relatedIssues ' +
37
+ 'entry, allowed beside refs-only ones. Rendered inside Related issues after any Refs ' +
38
+ 'lines (the policy may supply the wording) and passed to the policy, which may allow it ' +
39
+ 'only in interactive mode.',
33
40
  properties: { reason: text('Single-line reason there is no source issue.') },
34
41
  required: ['reason'],
35
42
  additionalProperties: false,
@@ -41,8 +48,12 @@ const FIELDS = {
41
48
  description: 'Markdown paragraphs or bullets, joined by blank lines.',
42
49
  },
43
50
  base: { type: 'string', minLength: 1, description: 'Base branch. Default: main.' },
44
- agent: text('Agent name for the Agent line.'),
45
- model: text('Optional model name, rendered in parentheses after the agent.'),
51
+ agent: text('Optional, and never trusted. The server derives the Agent line from its own launch ' +
52
+ 'environment (harness markers) and the MCP client name; a value that does not name the ' +
53
+ 'derived agent is rendered as "caller-claimed", and with nothing derivable the agent is ' +
54
+ '"unknown".'),
55
+ model: text('Optional, and never trusted. The server cannot derive the model, so it is rendered as ' +
56
+ '"caller-claimed model: <model>" in parentheses after the agent.'),
46
57
  mode: {
47
58
  enum: ['interactive', 'autonomous'],
48
59
  description: 'Optional. The server derives the mode from its own launch environment and fails closed: ' +
@@ -51,15 +62,7 @@ const FIELDS = {
51
62
  },
52
63
  workspaceSetup: text('Text for the Workspace setup line, such as how the worktree was prepared.'),
53
64
  };
54
- const REQUIRED = [
55
- 'title',
56
- 'summary',
57
- 'impact',
58
- 'relatedIssues',
59
- 'details',
60
- 'agent',
61
- 'workspaceSetup',
62
- ];
65
+ const REQUIRED = ['title', 'summary', 'impact', 'relatedIssues', 'details', 'workspaceSetup'];
63
66
  const RENDERING = 'The server renders the body from the structured input (Summary, Impact, Related issues, ' +
64
67
  'Details, then Workspace setup, Agent, Device and Worktree lines) and runs the checkout ' +
65
68
  'policy validatePullRequest export in a child process; the head branch is the ' +
@@ -79,7 +82,7 @@ export const PR_TOOLS = [
79
82
  "repository's stack tooling. Returns { number, url, warnings }.", openWorld(ADDITIVE), FIELDS, REQUIRED),
80
83
  tool('pr_update', "Replaces an open pull request's title and body. " +
81
84
  RENDERING +
82
- "The pull request's head branch must be the worktree branch. The live Shepherd Journal is " +
85
+ "The pull request's head must be the worktree branch, or the worktree HEAD must be the pull request's head commit (an alias branch); the live head name is then used. The live Shepherd Journal is " +
83
86
  'preserved and nothing else is carried over from the live body. Validation errors block ' +
84
87
  'the update. Returns { number, url, warnings }.', openWorld(MUTATING), { ...FIELDS, pr: PR_NUMBER }, [...REQUIRED, 'pr']),
85
88
  ];
@@ -131,6 +131,7 @@ function removeStaleLock(lockPath) {
131
131
  return true;
132
132
  }
133
133
  catch {
134
+ /* v8 ignore next -- requires an unexpected filesystem failure during stale-lock recovery. */
134
135
  return false;
135
136
  }
136
137
  });
@@ -174,7 +175,7 @@ export function withFileLock(path, action) {
174
175
  throw statusError;
175
176
  }
176
177
  if (!lockStatus.isFile())
177
- throw new Error('session-friction log lock must be a regular file');
178
+ throw new Error('log lock must be a regular file');
178
179
  if (removeStaleLock(lockPath))
179
180
  continue;
180
181
  waitForLock();
@@ -189,5 +190,5 @@ export function withFileLock(path, action) {
189
190
  unlinkSync(lockPath);
190
191
  }
191
192
  }
192
- throw new Error(`could not acquire session-friction log lock for ${basename(path)}`);
193
+ throw new Error(`could not acquire file lock for ${basename(path)}`);
193
194
  }
@@ -0,0 +1,3 @@
1
+ export declare function openLogFile(path: string, flags: number): number;
2
+ export declare function readLogContent(descriptor: number, maximumBytes: number): string;
3
+ export declare function writeAll(descriptor: number, value: string): void;