vouchington-tooling 0.41.0 → 0.42.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 (42) hide show
  1. package/bin/policy-runner.mjs +26 -0
  2. package/dist/agent-blackboard/feedback-codec.d.mts +1 -0
  3. package/dist/agent-blackboard/feedback-codec.mjs +1 -1
  4. package/dist/cli/usage.d.mts +1 -1
  5. package/dist/cli/usage.mjs +2 -1
  6. package/dist/mcp-server/context.d.mts +11 -2
  7. package/dist/mcp-server/context.mjs +14 -1
  8. package/dist/mcp-server/dispatch.d.mts +3 -0
  9. package/dist/mcp-server/dispatch.mjs +5 -0
  10. package/dist/mcp-server/handlers-journal.d.mts +14 -1
  11. package/dist/mcp-server/handlers-journal.mjs +14 -9
  12. package/dist/mcp-server/handlers-retrospective.d.mts +12 -0
  13. package/dist/mcp-server/handlers-retrospective.mjs +154 -0
  14. package/dist/mcp-server/harness.test-helpers.d.mts +2 -0
  15. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  16. package/dist/mcp-server/journal-read.d.mts +9 -0
  17. package/dist/mcp-server/journal-read.mjs +33 -0
  18. package/dist/mcp-server/policy-config.d.mts +6 -0
  19. package/dist/mcp-server/policy-config.mjs +60 -0
  20. package/dist/mcp-server/policy-run.d.mts +40 -0
  21. package/dist/mcp-server/policy-run.mjs +116 -0
  22. package/dist/mcp-server/policy-spawn.d.mts +24 -0
  23. package/dist/mcp-server/policy-spawn.mjs +97 -0
  24. package/dist/mcp-server/retrospective-facts-input.d.mts +6 -0
  25. package/dist/mcp-server/retrospective-facts-input.mjs +25 -0
  26. package/dist/mcp-server/retrospective-front-matter.d.mts +22 -0
  27. package/dist/mcp-server/retrospective-front-matter.mjs +88 -0
  28. package/dist/mcp-server/retrospective-input.d.mts +29 -0
  29. package/dist/mcp-server/retrospective-input.mjs +145 -0
  30. package/dist/mcp-server/retrospective-resolve.d.mts +15 -0
  31. package/dist/mcp-server/retrospective-resolve.mjs +41 -0
  32. package/dist/mcp-server/tools-retrospective.d.mts +2 -0
  33. package/dist/mcp-server/tools-retrospective.mjs +48 -0
  34. package/dist/mcp-server/tools-schema.d.mts +54 -0
  35. package/dist/mcp-server/tools-schema.mjs +70 -0
  36. package/dist/mcp-server/tools.d.mts +1 -18
  37. package/dist/mcp-server/tools.mjs +4 -70
  38. package/dist/mcp-server/worktree.d.mts +11 -4
  39. package/dist/mcp-server/worktree.mjs +31 -6
  40. package/docs/mcp-server.md +90 -16
  41. package/package.json +1 -1
  42. package/skills/retrospective/SKILL.md +5 -2
@@ -0,0 +1,116 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { resolvePolicyModule } from "./policy-config.mjs";
3
+ import { policyEnvironment, spawnPolicy, } from "./policy-spawn.mjs";
4
+ /** How long each policy export may run before its process is killed, in milliseconds. */
5
+ export const POLICY_TIMEOUTS_MS = {
6
+ validateRetrospective: 30_000,
7
+ validatePullRequest: 120_000,
8
+ pullRequestDefaults: 120_000,
9
+ };
10
+ const POLICY_MAX_OUTPUT_BYTES = 1_000_000;
11
+ const STDERR_EXCERPT_BYTES = 2_000;
12
+ const RUNNER = fileURLToPath(new URL('../../bin/policy-runner.mjs', import.meta.url));
13
+ function failure(name, reason, outcome) {
14
+ const stderr = outcome?.stderr.trim().slice(-STDERR_EXCERPT_BYTES);
15
+ return new Error(`policy ${name} ${reason}${stderr ? `: ${stderr}` : ''}`);
16
+ }
17
+ function parseReply(name, outcome) {
18
+ let reply;
19
+ try {
20
+ reply = JSON.parse(outcome.stdout);
21
+ }
22
+ catch {
23
+ throw failure(name, 'produced output that is not valid JSON', outcome);
24
+ }
25
+ if (reply?.ok === false)
26
+ throw failure(name, `failed: ${String(reply.error)}`);
27
+ if (reply?.ok !== true)
28
+ throw failure(name, 'produced an unrecognized reply');
29
+ return reply.skipped === true ? { status: 'skipped' } : { status: 'ok', result: reply.result };
30
+ }
31
+ /**
32
+ * Runs one export of the worktree's declared policy module in a fresh child process (never in
33
+ * this one). Resolves `no-policy` when the worktree declares none and `skipped` when the module
34
+ * lacks the export. A timeout, crash, non-zero exit, oversized output, or unusable reply throws:
35
+ * a failed policy is never a pass.
36
+ */
37
+ export async function runPolicyExport(run) {
38
+ const secrets = credentialValues(run.env);
39
+ // A policy can echo a credential it was given (GH_TOKEN) in a stack, stderr, or result; the
40
+ // caller of an MCP tool must never receive one. Diagnostics may carry it JSON-escaped too.
41
+ const escaped = secrets.map((secret) => JSON.stringify(secret).slice(1, -1));
42
+ const forms = [...new Set([...escaped, ...secrets])].sort((a, b) => b.length - a.length);
43
+ const spawn = run.spawn ?? spawnPolicy;
44
+ // stderr is scrubbed whole, before its excerpt could cut a credential into an unmatched suffix.
45
+ const scrubbing = async (request) => {
46
+ const outcome = await spawn(request);
47
+ return { ...outcome, stderr: scrubText(outcome.stderr, forms) };
48
+ };
49
+ try {
50
+ const result = await runUnscrubbed(run, scrubbing);
51
+ return result.status === 'ok'
52
+ ? { status: 'ok', result: scrubValue(result.result, secrets) }
53
+ : result;
54
+ }
55
+ catch (error) {
56
+ throw new Error(scrubText(error.message, forms), { cause: error });
57
+ }
58
+ }
59
+ const CREDENTIAL_NAMES = ['AGENT_BLACKBOARD_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'];
60
+ /**
61
+ * Credential values held by the server, longest first. A value too short to redact without
62
+ * mangling every output fails closed: the policy never runs rather than risk echoing it.
63
+ */
64
+ function credentialValues(env) {
65
+ const values = CREDENTIAL_NAMES.map((name) => env[name]).filter((value) => Boolean(value));
66
+ if (values.some((value) => value.length < 4))
67
+ throw new Error(`a credential (${CREDENTIAL_NAMES.join(', ')}) is too short to redact: policy tools refuse to run`);
68
+ return [...new Set(values)].sort((a, b) => b.length - a.length);
69
+ }
70
+ function scrubText(text, secrets) {
71
+ return secrets.reduce((scrubbed, secret) => scrubbed.replaceAll(secret, '[REDACTED]'), text);
72
+ }
73
+ function scrubValue(value, secrets) {
74
+ if (secrets.length === 0 || value === undefined)
75
+ return value;
76
+ // Each secret is matched in its JSON-escaped form so the round trip stays valid JSON.
77
+ const escaped = secrets.map((secret) => JSON.stringify(secret).slice(1, -1));
78
+ return JSON.parse(scrubText(JSON.stringify(value), escaped));
79
+ }
80
+ async function runUnscrubbed(run, spawn) {
81
+ const { export: name } = run;
82
+ const module = await resolvePolicyModule(run.worktree);
83
+ if (module === undefined)
84
+ return { status: 'no-policy' };
85
+ const timeoutMs = POLICY_TIMEOUTS_MS[name];
86
+ const outcome = await spawn({
87
+ command: process.execPath,
88
+ args: [RUNNER, module],
89
+ cwd: run.worktree,
90
+ env: policyEnvironment(run.env),
91
+ input: JSON.stringify({ policyVersion: 1, export: name, input: run.input }),
92
+ timeoutMs,
93
+ maxOutputBytes: POLICY_MAX_OUTPUT_BYTES,
94
+ });
95
+ if (outcome.timedOut)
96
+ throw failure(name, `timed out after ${timeoutMs / 1000}s`);
97
+ if (outcome.overflowed)
98
+ throw failure(name, `wrote more than ${POLICY_MAX_OUTPUT_BYTES} bytes of output`);
99
+ if (outcome.exitCode !== 0)
100
+ throw failure(name, `exited with code ${outcome.exitCode}`, outcome);
101
+ return parseReply(name, outcome);
102
+ }
103
+ function isStringArray(value) {
104
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
105
+ }
106
+ /** Runs a validator export and checks its result is `{ errors: string[], warnings?: string[] }`. */
107
+ export async function runValidator(run) {
108
+ const outcome = await runPolicyExport(run);
109
+ if (outcome.status !== 'ok')
110
+ return outcome;
111
+ const result = outcome.result;
112
+ if (!isStringArray(result?.errors) ||
113
+ (result.warnings !== undefined && !isStringArray(result.warnings)))
114
+ throw new Error(`policy ${run.export} must return { errors: string[], warnings?: string[] }`);
115
+ return { errors: result.errors, warnings: result.warnings ?? [] };
116
+ }
@@ -0,0 +1,24 @@
1
+ export type PolicyRequest = {
2
+ command: string;
3
+ args: string[];
4
+ cwd: string;
5
+ env: NodeJS.ProcessEnv;
6
+ input: string;
7
+ timeoutMs: number;
8
+ maxOutputBytes: number;
9
+ };
10
+ export type PolicyOutcome = {
11
+ exitCode: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ timedOut: boolean;
15
+ /** True when stdout or stderr exceeded `maxOutputBytes` and the child was killed. */
16
+ overflowed: boolean;
17
+ };
18
+ export type PolicySpawner = (request: PolicyRequest) => Promise<PolicyOutcome>;
19
+ /**
20
+ * The child's whole environment. Blackboard credentials and NODE_OPTIONS are never passed; GitHub
21
+ * tokens are passed only when the server has them, for the checks that call `gh`.
22
+ */
23
+ export declare function policyEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
24
+ export declare const spawnPolicy: PolicySpawner;
@@ -0,0 +1,97 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { accessSync, constants } from 'node:fs';
3
+ import { delimiter, dirname, join } from 'node:path';
4
+ function isExecutable(path) {
5
+ try {
6
+ accessSync(path, constants.X_OK);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /** The directory of the first `gh` on the server's PATH, so policy checks can call it. */
14
+ function ghDirectory(env) {
15
+ const found = (env.PATH ?? '')
16
+ .split(delimiter)
17
+ .filter((directory) => directory !== '')
18
+ .find((directory) => isExecutable(join(directory, 'gh')));
19
+ return found === undefined ? [] : [found];
20
+ }
21
+ /** The fixed system directories, node's own, and the directory holding `gh`, deduplicated. */
22
+ function policyPath(env) {
23
+ const directories = [
24
+ '/usr/bin',
25
+ '/bin',
26
+ '/usr/local/bin',
27
+ '/opt/homebrew/bin',
28
+ dirname(process.execPath),
29
+ ...ghDirectory(env),
30
+ ];
31
+ return [...new Set(directories)].join(delimiter);
32
+ }
33
+ /**
34
+ * The child's whole environment. Blackboard credentials and NODE_OPTIONS are never passed; GitHub
35
+ * tokens are passed only when the server has them, for the checks that call `gh`.
36
+ */
37
+ export function policyEnvironment(env) {
38
+ const child = { PATH: policyPath(env) };
39
+ for (const name of ['HOME', 'GH_TOKEN', 'GITHUB_TOKEN']) {
40
+ const value = env[name];
41
+ if (value !== undefined)
42
+ child[name] = value;
43
+ }
44
+ return child;
45
+ }
46
+ export const spawnPolicy = (request) => new Promise((resolve, reject) => {
47
+ const child = spawn(request.command, request.args, {
48
+ cwd: request.cwd,
49
+ env: request.env,
50
+ stdio: ['pipe', 'pipe', 'pipe'],
51
+ // Its own process group, so every grandchild the policy started can be killed with it.
52
+ detached: process.platform !== 'win32',
53
+ });
54
+ const killGroup = () => {
55
+ try {
56
+ process.kill(-child.pid, 'SIGKILL');
57
+ }
58
+ catch {
59
+ child.kill('SIGKILL');
60
+ }
61
+ };
62
+ const chunks = { stdout: [], stderr: [] };
63
+ const sizes = { stdout: 0, stderr: 0 };
64
+ let timedOut = false;
65
+ let overflowed = false;
66
+ const timer = setTimeout(() => {
67
+ timedOut = true;
68
+ killGroup();
69
+ }, request.timeoutMs);
70
+ for (const name of ['stdout', 'stderr'])
71
+ child[name].on('data', (chunk) => {
72
+ sizes[name] += chunk.length;
73
+ if (sizes[name] > request.maxOutputBytes) {
74
+ overflowed = true;
75
+ killGroup();
76
+ }
77
+ else
78
+ chunks[name].push(chunk);
79
+ });
80
+ child.on('error', (error) => {
81
+ clearTimeout(timer);
82
+ reject(error);
83
+ });
84
+ // Reap anything the policy left running in its group as soon as the runner exits: a descendant
85
+ // would otherwise keep the GitHub token it inherited, and one holding an inherited pipe would
86
+ // delay `close` until the timeout.
87
+ child.on('exit', () => {
88
+ clearTimeout(timer);
89
+ killGroup();
90
+ });
91
+ child.on('close', (exitCode) => {
92
+ const text = (name) => Buffer.concat(chunks[name]).toString('utf8');
93
+ resolve({ exitCode, stdout: text('stdout'), stderr: text('stderr'), timedOut, overflowed });
94
+ });
95
+ child.stdin.on('error', () => { });
96
+ child.stdin.end(request.input);
97
+ });
@@ -0,0 +1,6 @@
1
+ import type { Args } from './args.mts';
2
+ /**
3
+ * Checks `compose.facts`: either `{ status, reason }` (unassessed) or a live collection selector,
4
+ * never both. Returns whether live collection was requested.
5
+ */
6
+ export declare function checkFactsInput(facts: Args): boolean;
@@ -0,0 +1,25 @@
1
+ /** A plain branch name: no scheme or colon, no leading dash, no `..`. */
2
+ const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/;
3
+ /**
4
+ * Checks `compose.facts`: either `{ status, reason }` (unassessed) or a live collection selector,
5
+ * never both. Returns whether live collection was requested.
6
+ */
7
+ export function checkFactsInput(facts) {
8
+ const selectors = ['pr', 'branch', 'noPr'].filter((key) => key in facts);
9
+ if ('status' in facts) {
10
+ if (selectors.length)
11
+ throw new Error(`compose.facts: status is exclusive with ${selectors.join(', ')}`);
12
+ return false;
13
+ }
14
+ if ('reason' in facts)
15
+ throw new Error('compose.facts.reason requires a status');
16
+ // The collector trusts these types; a string "false" must not read as noPr: true.
17
+ // gh accepts a pull request URL for either, which would reach another repository.
18
+ if ('pr' in facts && (typeof facts.pr !== 'string' || !/^\d+$/.test(facts.pr)))
19
+ throw new Error('compose.facts.pr must be a pull request number string');
20
+ if ('branch' in facts && (typeof facts.branch !== 'string' || !BRANCH.test(facts.branch)))
21
+ throw new Error('compose.facts.branch must be a branch name');
22
+ if ('noPr' in facts && typeof facts.noPr !== 'boolean')
23
+ throw new Error('compose.facts.noPr must be a boolean');
24
+ return true;
25
+ }
@@ -0,0 +1,22 @@
1
+ import type { FeedbackReference } from '../agent-blackboard/index.mts';
2
+ /** The fields of the front matter `composeRetrospective` writes, with camelCase names. */
3
+ export type RetrospectiveFrontMatter = {
4
+ date: string;
5
+ issues: FeedbackReference[];
6
+ prs: FeedbackReference[];
7
+ sessionId?: string;
8
+ description?: string;
9
+ workOutcome?: string;
10
+ feedbackCoverage?: {
11
+ status: string;
12
+ sources: string[];
13
+ droppedCount: number;
14
+ };
15
+ repositories?: string[];
16
+ };
17
+ /**
18
+ * Parses the block from the leading `---` line to the next `---` line. Every line is
19
+ * `key: <JSON value>`; unknown keys, duplicates, malformed lines and ill-typed values are errors,
20
+ * and `date`, `issues`, and `prs` are required.
21
+ */
22
+ export declare function parseRetrospectiveFrontMatter(markdown: string): RetrospectiveFrontMatter;
@@ -0,0 +1,88 @@
1
+ const KEYS = [
2
+ 'date',
3
+ 'issues',
4
+ 'prs',
5
+ 'session_id',
6
+ 'description',
7
+ 'work_outcome',
8
+ 'feedback_coverage',
9
+ 'repositories',
10
+ ];
11
+ const isString = (value) => typeof value === 'string';
12
+ const isStrings = (value) => Array.isArray(value) && value.every(isString);
13
+ const isReference = (value) => (isString(value) && value.trim() !== '') || (Number.isSafeInteger(value) && value > 0);
14
+ function fail(message) {
15
+ throw new Error(`retrospective front matter: ${message}`);
16
+ }
17
+ function parseLines(block) {
18
+ const values = new Map();
19
+ // composeRetrospective joins its sections with blank lines, so blank lines are allowed.
20
+ for (const line of block.split('\n').filter((candidate) => candidate !== '')) {
21
+ const separator = line.indexOf(': ');
22
+ const key = separator < 0 ? '' : line.slice(0, separator);
23
+ if (!KEYS.includes(key))
24
+ fail(separator < 0 || !/^[a-z_]+$/.test(key)
25
+ ? `malformed line ${JSON.stringify(line)}; each line is "key: <JSON value>"`
26
+ : `unknown key ${key}`);
27
+ if (values.has(key))
28
+ fail(`duplicate key ${key}`);
29
+ try {
30
+ values.set(key, JSON.parse(line.slice(separator + 2)));
31
+ }
32
+ catch {
33
+ fail(`${key} is not a JSON value`);
34
+ }
35
+ }
36
+ return values;
37
+ }
38
+ function coverageOf(coverage) {
39
+ if (typeof coverage !== 'object' ||
40
+ coverage === null ||
41
+ Object.keys(coverage).some((key) => !['status', 'sources', 'droppedCount'].includes(key)) ||
42
+ !isString(coverage.status) ||
43
+ !isStrings(coverage.sources) ||
44
+ !Number.isSafeInteger(coverage.droppedCount))
45
+ fail('feedback_coverage must be { status, sources, droppedCount }');
46
+ return coverage;
47
+ }
48
+ /**
49
+ * Parses the block from the leading `---` line to the next `---` line. Every line is
50
+ * `key: <JSON value>`; unknown keys, duplicates, malformed lines and ill-typed values are errors,
51
+ * and `date`, `issues`, and `prs` are required.
52
+ */
53
+ export function parseRetrospectiveFrontMatter(markdown) {
54
+ const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(markdown.replaceAll('\r\n', '\n'))?.[1];
55
+ if (block === undefined)
56
+ fail('the document must start with a block between two "---" lines, as composeRetrospective writes');
57
+ const values = parseLines(block);
58
+ const { date, issues, prs } = Object.fromEntries(values);
59
+ if (!isString(date) || !/^\d{4}-\d{2}-\d{2}$/.test(date))
60
+ fail('date must be a YYYY-MM-DD string');
61
+ for (const [name, list] of [
62
+ ['issues', issues],
63
+ ['prs', prs],
64
+ ])
65
+ if (!Array.isArray(list) || !list.every(isReference))
66
+ fail(`${name} must be an array of positive integers or non-empty strings`);
67
+ const text = (key) => {
68
+ const value = values.get(key);
69
+ if (value !== undefined && !isString(value))
70
+ fail(`${key} must be a string`);
71
+ return value;
72
+ };
73
+ const repositories = values.get('repositories');
74
+ if (repositories !== undefined && !isStrings(repositories))
75
+ fail('repositories must be an array of strings');
76
+ const coverage = values.get('feedback_coverage');
77
+ const parsed = {
78
+ date,
79
+ issues: issues,
80
+ prs: prs,
81
+ sessionId: text('session_id'),
82
+ description: text('description'),
83
+ workOutcome: text('work_outcome'),
84
+ feedbackCoverage: coverage === undefined ? undefined : coverageOf(coverage),
85
+ repositories: repositories,
86
+ };
87
+ return Object.fromEntries(Object.entries(parsed).filter(([, value]) => value !== undefined));
88
+ }
@@ -0,0 +1,29 @@
1
+ import { type FeedbackCoverage, type WorkOutcome } from '../agent-blackboard/index.mts';
2
+ import type { BlackboardClientDependencies } from '../agent-blackboard/client.mts';
3
+ import { type Args } from './args.mts';
4
+ /** The longest a facts subprocess may run before it is killed. */
5
+ export declare const FACTS_TIMEOUT_MS = 120000;
6
+ type ComposeBase = {
7
+ sessionId: string;
8
+ repositories: string[];
9
+ workOutcome: WorkOutcome;
10
+ feedbackCoverage: FeedbackCoverage;
11
+ worktree: string;
12
+ env: NodeJS.ProcessEnv;
13
+ dependencies: BlackboardClientDependencies;
14
+ };
15
+ /**
16
+ * Facts commands run in the worktree and never see the blackboard credentials, NODE_OPTIONS, or
17
+ * GIT_* overrides that could point git away from the validated worktree.
18
+ */
19
+ export declare function worktreeExecutor(worktree: string, env: NodeJS.ProcessEnv, timeoutMs?: number): (command: string, args: string[]) => Promise<{
20
+ ok: boolean;
21
+ stdout: string;
22
+ stderr: string;
23
+ exitCode: number | null;
24
+ }>;
25
+ /** The caller's `compose.knownSensitiveValues` plus the server's blackboard token, when set. */
26
+ export declare function sensitiveValues(compose: Args | undefined, env: NodeJS.ProcessEnv): string[];
27
+ /** Builds the retrospective markdown with `composeRetrospective`, rejecting unknown input keys. */
28
+ export declare function composeFromArgs(compose: Args, base: ComposeBase): Promise<string>;
29
+ export {};
@@ -0,0 +1,145 @@
1
+ import { composeRetrospective, } from "../agent-blackboard/index.mjs";
2
+ import { rejectUnknown } from "./args.mjs";
3
+ import { inspectHarnessEnvironment, selectHarnessSession, } from "../agent-harness-identity/index.mjs";
4
+ import { SESSION_ID } from "../retrospective-transcript/resolve-transcript-file.mjs";
5
+ import { serverJournalLoader } from "./journal-read.mjs";
6
+ import { checkFactsInput } from "./retrospective-facts-input.mjs";
7
+ import { spawnPolicy } from "./policy-spawn.mjs";
8
+ const HARNESSES = ['codex', 'claude', 'cursor', 'grok'];
9
+ const COMPOSE_KEYS = [
10
+ 'narrative',
11
+ 'date',
12
+ 'issues',
13
+ 'prs',
14
+ 'description',
15
+ 'facts',
16
+ 'transcript',
17
+ 'tools',
18
+ 'architecture',
19
+ 'knownSensitiveValues',
20
+ ];
21
+ const COMPOSE_REQUIRED = COMPOSE_KEYS.filter((key) => key !== 'knownSensitiveValues');
22
+ /** No `repo`: a caller must not aim the server's GitHub token at another repository. */
23
+ const FACTS_KEYS = ['pr', 'branch', 'noPr', 'status', 'reason'];
24
+ /** No path overrides: the server reads the session's transcript from its own default roots. */
25
+ const TRANSCRIPT_KEYS = ['sessionId', 'harnessSessionId', 'status', 'reason'];
26
+ /** The longest a facts subprocess may run before it is killed. */
27
+ export const FACTS_TIMEOUT_MS = 120_000;
28
+ const MAX_OUTPUT_BYTES = 10_000_000;
29
+ function section(compose, name, keys) {
30
+ const value = compose[name];
31
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
32
+ throw new Error(`compose.${name} must be an object`);
33
+ rejectUnknown(value, keys);
34
+ return value;
35
+ }
36
+ /**
37
+ * Facts commands run in the worktree and never see the blackboard credentials, NODE_OPTIONS, or
38
+ * GIT_* overrides that could point git away from the validated worktree.
39
+ */
40
+ export function worktreeExecutor(worktree, env, timeoutMs = FACTS_TIMEOUT_MS) {
41
+ const safe = Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith('AGENT_BLACKBOARD_') &&
42
+ !name.startsWith('GIT_') &&
43
+ name !== 'NODE_OPTIONS'));
44
+ // Each command runs in its own process group, killed whole when it exits or times out, so a
45
+ // helper that git or gh starts cannot outlive the call with an inherited GH_TOKEN.
46
+ return async (command, args) => {
47
+ let outcome;
48
+ try {
49
+ outcome = await spawnPolicy({
50
+ command,
51
+ args,
52
+ cwd: worktree,
53
+ env: safe,
54
+ input: '',
55
+ timeoutMs,
56
+ maxOutputBytes: MAX_OUTPUT_BYTES,
57
+ });
58
+ }
59
+ catch (error) {
60
+ return { ok: false, stdout: '', stderr: error.message, exitCode: null };
61
+ }
62
+ const { stdout, stderr, exitCode, timedOut, overflowed } = outcome;
63
+ if (timedOut || overflowed) {
64
+ const reason = timedOut
65
+ ? `timed out after ${timeoutMs} ms`
66
+ : `wrote more than ${MAX_OUTPUT_BYTES} bytes`;
67
+ return { ok: false, stdout, stderr: `${stderr}${command} ${reason}`, exitCode: null };
68
+ }
69
+ return { ok: exitCode === 0, stdout, stderr, exitCode };
70
+ };
71
+ }
72
+ /** The caller's `compose.knownSensitiveValues` plus the server's blackboard token, when set. */
73
+ export function sensitiveValues(compose, env) {
74
+ const given = compose?.knownSensitiveValues;
75
+ if (given !== undefined && (!Array.isArray(given) || given.some((v) => typeof v !== 'string')))
76
+ throw new Error('compose.knownSensitiveValues must be an array of strings');
77
+ // Redaction ignores values under four characters, so promising to redact one would be false.
78
+ if (given?.some((value) => value.length < 4))
79
+ throw new Error('compose.knownSensitiveValues entries must be at least 4 characters');
80
+ // Facts commands inherit the GitHub tokens, so their output is redacted of those values too.
81
+ const credentials = [env.AGENT_BLACKBOARD_TOKEN, env.GH_TOKEN, env.GITHUB_TOKEN].filter((value) => Boolean(value));
82
+ const values = [...(given ?? []), ...credentials];
83
+ // The composer JSON-escapes structured fields, so each value is also redacted in that form.
84
+ const escaped = values.map((value) => JSON.stringify(value).slice(1, -1));
85
+ // Longest first, so a shorter value that is a prefix of a credential cannot break its match.
86
+ return [...new Set([...values, ...escaped])].sort((a, b) => b.length - a.length);
87
+ }
88
+ /**
89
+ * The transcript collector input: the server's worktree and default session roots only. A caller
90
+ * may name the session (it must be this one), give the harness's own UUID session id when the
91
+ * blackboard session id is not one, or report the transcript as unassessed.
92
+ */
93
+ function transcriptInput(transcript, base) {
94
+ if ('status' in transcript) {
95
+ if ('sessionId' in transcript || 'harnessSessionId' in transcript)
96
+ throw new Error('compose.transcript: sessionId and harnessSessionId are exclusive with status');
97
+ return transcript;
98
+ }
99
+ // A reason without a status is malformed, not a request to read the transcript.
100
+ if ('reason' in transcript)
101
+ throw new Error('compose.transcript.reason requires a status');
102
+ if (transcript.sessionId !== undefined && transcript.sessionId !== base.sessionId)
103
+ throw new Error('compose.transcript.sessionId must equal the sessionId of the call');
104
+ const harnessId = transcript.harnessSessionId ?? base.sessionId;
105
+ // Transcripts are found by the harness's UUID; never let a lookup fail into "unavailable".
106
+ if (typeof harnessId !== 'string' || !SESSION_ID.test(harnessId))
107
+ throw new Error('compose.transcript needs a UUID session id: pass harnessSessionId (the harness session ' +
108
+ 'UUID) or { status, reason }');
109
+ // When the harness that launched the server names its session, no other transcript is read.
110
+ const own = selectHarnessSession(inspectHarnessEnvironment(base.env), HARNESSES)?.sessionId;
111
+ if (own !== undefined && own.toLowerCase() !== harnessId.toLowerCase())
112
+ // A server can outlive its session (Claude Code /clear, a Codex thread switch).
113
+ throw new Error('compose.transcript may read only the harness session that launched the server: if the ' +
114
+ 'session changed since (/clear, a thread switch), reconnect the server (/mcp in Claude ' +
115
+ 'Code, or restart Codex), or pass { status, reason }');
116
+ return { sessionId: harnessId, cwd: base.worktree };
117
+ }
118
+ function factsInput(facts, base) {
119
+ return checkFactsInput(facts)
120
+ ? { ...facts, execute: worktreeExecutor(base.worktree, base.env) }
121
+ : facts;
122
+ }
123
+ /** Builds the retrospective markdown with `composeRetrospective`, rejecting unknown input keys. */
124
+ export async function composeFromArgs(compose, base) {
125
+ rejectUnknown(compose, COMPOSE_KEYS);
126
+ const missing = COMPOSE_REQUIRED.filter((key) => compose[key] === undefined);
127
+ if (missing.length)
128
+ throw new Error(`compose is missing: ${missing.join(', ')}`);
129
+ if (typeof compose.narrative !== 'string' || !compose.narrative.trim())
130
+ throw new Error('compose.narrative must be a non-empty string');
131
+ const facts = section(compose, 'facts', FACTS_KEYS);
132
+ const transcript = section(compose, 'transcript', TRANSCRIPT_KEYS);
133
+ const input = {
134
+ ...compose,
135
+ sessionId: base.sessionId,
136
+ repositories: base.repositories,
137
+ workOutcome: base.workOutcome,
138
+ feedbackCoverage: base.feedbackCoverage,
139
+ facts: factsInput(facts, base),
140
+ transcript: transcriptInput(transcript, base),
141
+ knownSensitiveValues: sensitiveValues(compose, base.env),
142
+ journal: { journalLoader: serverJournalLoader(base.env, base.dependencies) },
143
+ };
144
+ return composeRetrospective(input);
145
+ }
@@ -0,0 +1,15 @@
1
+ import type { FeedbackCoverage } from '../agent-blackboard/index.mts';
2
+ import { type PolicyRun } from './policy-run.mts';
3
+ import type { RetrospectiveFrontMatter } from './retrospective-front-matter.mts';
4
+ /** Rejects, naming the field, any argument the document's own front matter contradicts. */
5
+ export declare function checkFrontMatter(front: RetrospectiveFrontMatter, args: {
6
+ sessionId: string;
7
+ workOutcome: string;
8
+ feedbackCoverage: FeedbackCoverage;
9
+ repositories: string[] | undefined;
10
+ }): void;
11
+ /**
12
+ * The canonical (sorted, deduplicated, lowercase) repositories: the argument, else the
13
+ * document's front matter, else the policy's `pullRequestDefaults().repositories`, else an error.
14
+ */
15
+ export declare function resolveRepositories(args: string[] | undefined, front: string[] | undefined, policy: Omit<PolicyRun, 'export' | 'input'>): Promise<string[]>;
@@ -0,0 +1,41 @@
1
+ import { normalizeRepositories } from "../agent-blackboard/feedback-codec.mjs";
2
+ import { runPolicyExport } from "./policy-run.mjs";
3
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
4
+ /** Rejects, naming the field, any argument the document's own front matter contradicts. */
5
+ export function checkFrontMatter(front, args) {
6
+ const mismatches = [];
7
+ if (front.sessionId !== undefined && front.sessionId !== args.sessionId)
8
+ mismatches.push('session_id does not match sessionId');
9
+ if (front.workOutcome !== undefined && front.workOutcome !== args.workOutcome)
10
+ mismatches.push('work_outcome does not match workOutcome');
11
+ if (front.feedbackCoverage !== undefined) {
12
+ // Field by field, so the document's JSON key order does not matter; sources stay ordered.
13
+ const { status, sources, droppedCount } = args.feedbackCoverage;
14
+ const given = front.feedbackCoverage;
15
+ if (given.status !== status ||
16
+ given.droppedCount !== droppedCount ||
17
+ !same(given.sources, sources))
18
+ mismatches.push('feedback_coverage does not match feedbackCoverage');
19
+ }
20
+ if (front.repositories !== undefined &&
21
+ args.repositories !== undefined &&
22
+ !same(normalizeRepositories(front.repositories), normalizeRepositories(args.repositories)))
23
+ mismatches.push('repositories does not match repositories');
24
+ if (mismatches.length)
25
+ throw new Error(`retrospective front matter contradicts the arguments: ${mismatches.join('; ')}`);
26
+ }
27
+ /**
28
+ * The canonical (sorted, deduplicated, lowercase) repositories: the argument, else the
29
+ * document's front matter, else the policy's `pullRequestDefaults().repositories`, else an error.
30
+ */
31
+ export async function resolveRepositories(args, front, policy) {
32
+ const given = args ?? front;
33
+ if (given !== undefined)
34
+ return normalizeRepositories(given);
35
+ const defaults = await runPolicyExport({ ...policy, export: 'pullRequestDefaults', input: {} });
36
+ const repositories = (defaults.status === 'ok' ? defaults.result : undefined);
37
+ if (Array.isArray(repositories?.repositories) && repositories.repositories.length)
38
+ return normalizeRepositories(repositories.repositories);
39
+ throw new Error('repositories is required: pass it, put it in the front matter, or have the policy ' +
40
+ 'pullRequestDefaults export return { repositories }');
41
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from './tools-schema.mts';
2
+ export declare const RETROSPECTIVE_TOOLS: readonly ToolDefinition[];