vouchington-tooling 0.40.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 (52) hide show
  1. package/README.md +9 -7
  2. package/bin/policy-runner.mjs +26 -0
  3. package/dist/agent-blackboard/feedback-codec.d.mts +1 -0
  4. package/dist/agent-blackboard/feedback-codec.mjs +1 -1
  5. package/dist/cli/usage.d.mts +1 -1
  6. package/dist/cli/usage.mjs +3 -1
  7. package/dist/dependency-license-policy/audit-store.d.mts +1 -4
  8. package/dist/dependency-license-policy/audit-store.mjs +1 -16
  9. package/dist/dependency-license-policy/collect.d.mts +0 -1
  10. package/dist/dependency-license-policy/collect.mjs +2 -2
  11. package/dist/mcp-server/context.d.mts +11 -2
  12. package/dist/mcp-server/context.mjs +14 -1
  13. package/dist/mcp-server/dispatch.d.mts +3 -0
  14. package/dist/mcp-server/dispatch.mjs +5 -0
  15. package/dist/mcp-server/handlers-journal.d.mts +14 -1
  16. package/dist/mcp-server/handlers-journal.mjs +14 -9
  17. package/dist/mcp-server/handlers-retrospective.d.mts +12 -0
  18. package/dist/mcp-server/handlers-retrospective.mjs +154 -0
  19. package/dist/mcp-server/harness.test-helpers.d.mts +2 -0
  20. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  21. package/dist/mcp-server/journal-read.d.mts +9 -0
  22. package/dist/mcp-server/journal-read.mjs +33 -0
  23. package/dist/mcp-server/policy-config.d.mts +6 -0
  24. package/dist/mcp-server/policy-config.mjs +60 -0
  25. package/dist/mcp-server/policy-run.d.mts +40 -0
  26. package/dist/mcp-server/policy-run.mjs +116 -0
  27. package/dist/mcp-server/policy-spawn.d.mts +24 -0
  28. package/dist/mcp-server/policy-spawn.mjs +97 -0
  29. package/dist/mcp-server/retrospective-facts-input.d.mts +6 -0
  30. package/dist/mcp-server/retrospective-facts-input.mjs +25 -0
  31. package/dist/mcp-server/retrospective-front-matter.d.mts +22 -0
  32. package/dist/mcp-server/retrospective-front-matter.mjs +88 -0
  33. package/dist/mcp-server/retrospective-input.d.mts +29 -0
  34. package/dist/mcp-server/retrospective-input.mjs +145 -0
  35. package/dist/mcp-server/retrospective-resolve.d.mts +15 -0
  36. package/dist/mcp-server/retrospective-resolve.mjs +41 -0
  37. package/dist/mcp-server/tools-retrospective.d.mts +2 -0
  38. package/dist/mcp-server/tools-retrospective.mjs +48 -0
  39. package/dist/mcp-server/tools-schema.d.mts +54 -0
  40. package/dist/mcp-server/tools-schema.mjs +70 -0
  41. package/dist/mcp-server/tools.d.mts +1 -18
  42. package/dist/mcp-server/tools.mjs +4 -70
  43. package/dist/mcp-server/worktree.d.mts +11 -4
  44. package/dist/mcp-server/worktree.mjs +31 -6
  45. package/dist/pg-schema-snapshot/generated-arbiter-facts.d.mts +1 -1
  46. package/docs/mcp-server.md +94 -18
  47. package/package.json +2 -2
  48. package/skills/blackboard/SKILL.md +28 -14
  49. package/skills/manifest.json +4 -4
  50. package/skills/postgres-node-performance-tuning/SKILL.md +2 -0
  51. package/skills/postgres-node-performance-tuning/references/performance-patterns.md +23 -0
  52. package/skills/retrospective/SKILL.md +5 -2
@@ -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[];
@@ -0,0 +1,48 @@
1
+ import { ADDITIVE, COVERAGE, IDENTITY, IDENTITY_REQUIRED, ID_PATTERN, OUTCOMES, READ_ONLY, tool, } from "./tools-schema.mjs";
2
+ export const RETROSPECTIVE_TOOLS = [
3
+ tool('retrospective_check', 'Reports whether the session already holds a retrospective: { sessionId, saved, entries } ' +
4
+ 'where each entry is { sourceEventId, timestamp, issues, prs } for an entry of type ' +
5
+ 'retrospective, oldest first. sourceEventId narrows the result. A session that was never ' +
6
+ 'created reports saved: false.', READ_ONLY, { sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Only this event id.' } }),
7
+ tool('retrospective_save', 'Saves the session retrospective after the checkout policy accepts it, and returns the same ' +
8
+ 'verified receipt as journal_append (status, receipt, timestamp, pendingCount, ' +
9
+ 'worktreePendingCount) plus policy.warnings. Pass exactly one of markdown (a finished ' +
10
+ 'document opening with the front matter composeRetrospective writes: date, issues, prs ' +
11
+ 'and optionally session_id, description, work_outcome, feedback_coverage, repositories, ' +
12
+ 'each line key: <JSON>; any that repeat an argument must equal it) or compose (the retrospective ' +
13
+ 'composer input: narrative, date, issues, prs, description, facts, transcript, tools, ' +
14
+ 'architecture, optional knownSensitiveValues; the server adds the rest). compose.transcript ' +
15
+ 'takes only { sessionId?, harnessSessionId? } (sessionId must be the call sessionId; the ' +
16
+ 'transcript is found by harnessSessionId, the harness session UUID, else by sessionId when ' +
17
+ 'that is a UUID, in the default session roots of the worktree) or { status, reason } to report it ' +
18
+ 'unassessed; any path override is rejected. The server redacts its blackboard token ' +
19
+ 'from the document before the policy sees it. dryRun returns ' +
20
+ '{ markdown, policy: { warnings } } and writes nothing. The worktree package.json may ' +
21
+ 'declare "vouchington-tooling": { "policy": "<module>" }; its validateRetrospective export ' +
22
+ 'runs in a child process, and any error it reports rejects the save with nothing written. ' +
23
+ 'Policy tools need a server launched inside a repository worktree. The server owns the ' +
24
+ 'timestamp. sourceEventId defaults to retrospective-<sessionId> (retrospective-<sha256 hex of the ' +
25
+ 'sessionId> when that exceeds 256 bytes); saving changed content ' +
26
+ 'under the same sourceEventId is an event-conflict, so a revision needs a new ' +
27
+ 'sourceEventId. Repeat an identical markdown call to retry. compose regenerates the document, ' +
28
+ 'so deliver a retained compose save with outbox_flush instead of repeating it. Counts ' +
29
+ 'follow journal_append.', ADDITIVE, {
30
+ ...IDENTITY,
31
+ mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
32
+ workOutcome: { enum: OUTCOMES, description: 'Outcome of the work being reported.' },
33
+ feedbackCoverage: COVERAGE,
34
+ repositories: {
35
+ type: 'array',
36
+ items: { type: 'string' },
37
+ minItems: 1,
38
+ description: 'Exact owner/name repositories; stored sorted, deduplicated and lowercase. Default: ' +
39
+ 'the front matter repositories, else the policy pullRequestDefaults repositories; ' +
40
+ 'an error when none.',
41
+ },
42
+ markdown: { type: 'string', description: 'Finished retrospective markdown.' },
43
+ compose: { type: 'object', description: 'Composer input; exclusive with markdown.' },
44
+ sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Stable event id.' },
45
+ category: { type: 'string', description: 'Optional context; never changes entry type.' },
46
+ dryRun: { type: 'boolean', description: 'Compose and validate only; write nothing.' },
47
+ }, [...IDENTITY_REQUIRED, 'mode', 'workOutcome', 'feedbackCoverage']),
48
+ ];
@@ -0,0 +1,54 @@
1
+ export type JsonSchema = Record<string, unknown>;
2
+ export type ToolDefinition = {
3
+ name: string;
4
+ description: string;
5
+ inputSchema: {
6
+ type: 'object';
7
+ properties: Record<string, JsonSchema>;
8
+ required: string[];
9
+ additionalProperties: false;
10
+ };
11
+ annotations: {
12
+ readOnlyHint: boolean;
13
+ destructiveHint: boolean;
14
+ idempotentHint: boolean;
15
+ openWorldHint: boolean;
16
+ };
17
+ };
18
+ export declare const ID_PATTERN = "^[A-Za-z0-9._:-]+$";
19
+ export declare const PARENT_SESSION_ID: JsonSchema;
20
+ export declare const AGENT: JsonSchema;
21
+ export declare const VERSION: JsonSchema;
22
+ export declare const OUTCOMES: string[];
23
+ export declare const COVERAGE: JsonSchema;
24
+ export declare const READ_ONLY: {
25
+ readOnlyHint: boolean;
26
+ destructiveHint: boolean;
27
+ idempotentHint: boolean;
28
+ openWorldHint: boolean;
29
+ };
30
+ export declare const ADDITIVE: {
31
+ readOnlyHint: boolean;
32
+ destructiveHint: boolean;
33
+ idempotentHint: boolean;
34
+ openWorldHint: boolean;
35
+ };
36
+ export declare const IDEMPOTENT_ADDITIVE: {
37
+ readOnlyHint: boolean;
38
+ destructiveHint: boolean;
39
+ idempotentHint: boolean;
40
+ openWorldHint: boolean;
41
+ };
42
+ export declare const MUTATING: {
43
+ readOnlyHint: boolean;
44
+ destructiveHint: boolean;
45
+ idempotentHint: boolean;
46
+ openWorldHint: boolean;
47
+ };
48
+ export declare function tool(name: string, description: string, annotations: ToolDefinition['annotations'], properties?: Record<string, JsonSchema>, required?: string[]): ToolDefinition;
49
+ export declare const IDENTITY: {
50
+ parentSessionId: JsonSchema;
51
+ agent: JsonSchema;
52
+ version: JsonSchema;
53
+ };
54
+ export declare const IDENTITY_REQUIRED: string[];
@@ -0,0 +1,70 @@
1
+ export const ID_PATTERN = '^[A-Za-z0-9._:-]+$';
2
+ const SESSION_ID = {
3
+ type: 'string',
4
+ pattern: ID_PATTERN,
5
+ maxLength: 256,
6
+ description: 'Explicit session id (URL-safe). Never inferred by the server.',
7
+ };
8
+ const WORKTREE = {
9
+ type: 'string',
10
+ description: 'Absolute path of the top level of a git worktree (`git rev-parse --show-toplevel`), not a ' +
11
+ 'subdirectory. Defaults to the launch directory worktree; required when the server was ' +
12
+ 'launched outside a git worktree.',
13
+ };
14
+ export const PARENT_SESSION_ID = {
15
+ type: ['string', 'null'],
16
+ pattern: ID_PATTERN,
17
+ maxLength: 256,
18
+ description: 'Direct parent session id, or null for a root session.',
19
+ };
20
+ export const AGENT = { type: 'string', maxLength: 128, description: 'Agent name.' };
21
+ export const VERSION = { type: 'string', maxLength: 128, description: 'Agent version.' };
22
+ export const OUTCOMES = [
23
+ 'in-progress',
24
+ 'success',
25
+ 'failure',
26
+ 'cancelled',
27
+ 'timed-out',
28
+ 'no-change',
29
+ 'policy-refusal',
30
+ 'unknown',
31
+ ];
32
+ const COVERAGE_STATUSES = ['complete', 'partial', 'unavailable', 'not-assessed', 'not-started'];
33
+ export const COVERAGE = {
34
+ type: 'object',
35
+ properties: {
36
+ status: { enum: COVERAGE_STATUSES },
37
+ sources: { type: 'array', items: { type: 'string' } },
38
+ droppedCount: { type: 'integer', minimum: 0 },
39
+ },
40
+ required: ['status'],
41
+ additionalProperties: false,
42
+ description: 'Explicit coverage; sources default to [] and droppedCount to 0.',
43
+ };
44
+ export const READ_ONLY = flags(true, false, true);
45
+ export const ADDITIVE = flags(false, false, false);
46
+ export const IDEMPOTENT_ADDITIVE = flags(false, false, true);
47
+ export const MUTATING = flags(false, true, true);
48
+ function flags(readOnly, destructive, idempotent) {
49
+ return {
50
+ readOnlyHint: readOnly,
51
+ destructiveHint: destructive,
52
+ idempotentHint: idempotent,
53
+ openWorldHint: false,
54
+ };
55
+ }
56
+ export function tool(name, description, annotations, properties = {}, required = []) {
57
+ return {
58
+ name,
59
+ description,
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: { sessionId: SESSION_ID, worktree: WORKTREE, ...properties },
63
+ required: ['sessionId', ...required],
64
+ additionalProperties: false,
65
+ },
66
+ annotations,
67
+ };
68
+ }
69
+ export const IDENTITY = { parentSessionId: PARENT_SESSION_ID, agent: AGENT, version: VERSION };
70
+ export const IDENTITY_REQUIRED = ['parentSessionId', 'agent', 'version'];
@@ -1,19 +1,2 @@
1
- type JsonSchema = Record<string, unknown>;
2
- export type ToolDefinition = {
3
- name: string;
4
- description: string;
5
- inputSchema: {
6
- type: 'object';
7
- properties: Record<string, JsonSchema>;
8
- required: string[];
9
- additionalProperties: false;
10
- };
11
- annotations: {
12
- readOnlyHint: boolean;
13
- destructiveHint: boolean;
14
- idempotentHint: boolean;
15
- openWorldHint: boolean;
16
- };
17
- };
1
+ import { type ToolDefinition } from './tools-schema.mts';
18
2
  export declare const TOOLS: readonly ToolDefinition[];
19
- export {};
@@ -1,62 +1,5 @@
1
- const ID_PATTERN = '^[A-Za-z0-9._:-]+$';
2
- const SESSION_ID = {
3
- type: 'string',
4
- pattern: ID_PATTERN,
5
- maxLength: 256,
6
- description: 'Explicit session id (URL-safe). Never inferred by the server.',
7
- };
8
- const WORKTREE = {
9
- type: 'string',
10
- description: 'Absolute path of the top level of a git worktree (`git rev-parse --show-toplevel`), not a ' +
11
- 'subdirectory. Defaults to the launch directory worktree; required when the server was ' +
12
- 'launched outside a git worktree.',
13
- };
14
- const PARENT_SESSION_ID = {
15
- type: ['string', 'null'],
16
- pattern: ID_PATTERN,
17
- maxLength: 256,
18
- description: 'Direct parent session id, or null for a root session.',
19
- };
20
- const AGENT = { type: 'string', maxLength: 128, description: 'Agent name.' };
21
- const VERSION = { type: 'string', maxLength: 128, description: 'Agent version.' };
22
- const OUTCOMES = [
23
- 'in-progress',
24
- 'success',
25
- 'failure',
26
- 'cancelled',
27
- 'timed-out',
28
- 'no-change',
29
- 'policy-refusal',
30
- 'unknown',
31
- ];
32
- const COVERAGE_STATUSES = ['complete', 'partial', 'unavailable', 'not-assessed', 'not-started'];
33
- const READ_ONLY = flags(true, false, true);
34
- const ADDITIVE = flags(false, false, false);
35
- const IDEMPOTENT_ADDITIVE = flags(false, false, true);
36
- const MUTATING = flags(false, true, true);
37
- function flags(readOnly, destructive, idempotent) {
38
- return {
39
- readOnlyHint: readOnly,
40
- destructiveHint: destructive,
41
- idempotentHint: idempotent,
42
- openWorldHint: false,
43
- };
44
- }
45
- function tool(name, description, annotations, properties = {}, required = []) {
46
- return {
47
- name,
48
- description,
49
- inputSchema: {
50
- type: 'object',
51
- properties: { sessionId: SESSION_ID, worktree: WORKTREE, ...properties },
52
- required: ['sessionId', ...required],
53
- additionalProperties: false,
54
- },
55
- annotations,
56
- };
57
- }
58
- const IDENTITY = { parentSessionId: PARENT_SESSION_ID, agent: AGENT, version: VERSION };
59
- const IDENTITY_REQUIRED = ['parentSessionId', 'agent', 'version'];
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 { RETROSPECTIVE_TOOLS } from "./tools-retrospective.mjs";
60
3
  export const TOOLS = [
61
4
  tool('journal_append', 'Appends one journal entry to the caller session and returns the verified read-back receipt. ' +
62
5
  'Nothing is written unless every required field validates. Interactive mode retains the ' +
@@ -78,17 +21,7 @@ export const TOOLS = [
78
21
  minItems: 1,
79
22
  description: 'Exact owner/name repositories this entry concerns.',
80
23
  },
81
- feedbackCoverage: {
82
- type: 'object',
83
- properties: {
84
- status: { enum: COVERAGE_STATUSES },
85
- sources: { type: 'array', items: { type: 'string' } },
86
- droppedCount: { type: 'integer', minimum: 0 },
87
- },
88
- required: ['status'],
89
- additionalProperties: false,
90
- description: 'Explicit coverage; sources default to [] and droppedCount to 0.',
91
- },
24
+ feedbackCoverage: COVERAGE,
92
25
  category: { type: 'string', description: 'Optional context; never changes entry type.' },
93
26
  }, [
94
27
  ...IDENTITY_REQUIRED,
@@ -112,6 +45,7 @@ export const TOOLS = [
112
45
  'as sourceEventId and diagnostic.', IDEMPOTENT_ADDITIVE),
113
46
  tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
114
47
  'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
48
+ ...RETROSPECTIVE_TOOLS,
115
49
  tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
116
50
  'client. Returns the path, counts, checksum, manifest, and cleanupToken; never the records. ' +
117
51
  'sessionId identifies the caller.', ADDITIVE, {
@@ -11,12 +11,19 @@ export declare const runIsolatedGit: RunTextCommand;
11
11
  * or an unsafe repository, is thrown with the cause rather than reported as "outside a worktree".
12
12
  */
13
13
  export declare function launchWorktreeRoot(cwd: string, runGit: RunTextCommand): Promise<string | undefined>;
14
+ /**
15
+ * The worktree paths in `git worktree list --porcelain -z` output: NUL-terminated fields, an empty
16
+ * field ending each record, so a path containing a newline cannot forge a record.
17
+ */
18
+ export declare function parseWorktreeList(output: string): string[];
14
19
  /**
15
20
  * Resolves the worktree a tool call may act on. It must be an absolute path that, after
16
- * resolving symlinks, is exactly the top level of a git worktree (of any repository on the
17
- * machine). A subdirectory, a relative or nonexistent path, and a non-git directory are rejected.
18
- * Git is asked on every call, so worktrees created after the server started are accepted and
19
- * removed ones are not. Without `requested`, the launch worktree is used when there is one.
21
+ * resolving symlinks, is exactly the top level of a git worktree. A subdirectory, a relative or
22
+ * nonexistent path, and a non-git directory are rejected. When the server was launched inside a
23
+ * repository, the path must also be one of that repository's worktrees (primary or linked), so an
24
+ * unrelated `git init` directory is refused. A server launched outside any repository accepts the
25
+ * top level of any worktree. Git is asked on every call, so worktrees created after the server
26
+ * started are accepted and removed ones are not. Without `requested`, the launch worktree is used.
20
27
  */
21
28
  export declare function resolveWorktree(input: {
22
29
  launchRoot: string | undefined;
@@ -52,12 +52,33 @@ export async function launchWorktreeRoot(cwd, runGit) {
52
52
  }
53
53
  return realpath(output.trim());
54
54
  }
55
+ /**
56
+ * The worktree paths in `git worktree list --porcelain -z` output: NUL-terminated fields, an empty
57
+ * field ending each record, so a path containing a newline cannot forge a record.
58
+ */
59
+ export function parseWorktreeList(output) {
60
+ return output
61
+ .split('\0')
62
+ .filter((field) => field.startsWith('worktree '))
63
+ .map((field) => field.slice('worktree '.length));
64
+ }
65
+ /**
66
+ * The real paths of every worktree of the repository containing `root` (the primary worktree and
67
+ * each linked one).
68
+ */
69
+ async function repositoryWorktrees(root, runGit) {
70
+ const output = await runGit(['-C', root, 'worktree', 'list', '--porcelain', '-z']);
71
+ const paths = parseWorktreeList(output).map(canonical);
72
+ return new Set((await Promise.all(paths)).filter((path) => path !== undefined));
73
+ }
55
74
  /**
56
75
  * Resolves the worktree a tool call may act on. It must be an absolute path that, after
57
- * resolving symlinks, is exactly the top level of a git worktree (of any repository on the
58
- * machine). A subdirectory, a relative or nonexistent path, and a non-git directory are rejected.
59
- * Git is asked on every call, so worktrees created after the server started are accepted and
60
- * removed ones are not. Without `requested`, the launch worktree is used when there is one.
76
+ * resolving symlinks, is exactly the top level of a git worktree. A subdirectory, a relative or
77
+ * nonexistent path, and a non-git directory are rejected. When the server was launched inside a
78
+ * repository, the path must also be one of that repository's worktrees (primary or linked), so an
79
+ * unrelated `git init` directory is refused. A server launched outside any repository accepts the
80
+ * top level of any worktree. Git is asked on every call, so worktrees created after the server
81
+ * started are accepted and removed ones are not. Without `requested`, the launch worktree is used.
61
82
  */
62
83
  export async function resolveWorktree(input) {
63
84
  const { launchRoot, requested, runGit } = input;
@@ -68,8 +89,12 @@ export async function resolveWorktree(input) {
68
89
  if (!isAbsolute(path))
69
90
  throw new Error('worktree must be an absolute path to a git worktree');
70
91
  const target = await canonical(path);
71
- if (target !== undefined && (await launchWorktreeRoot(target, runGit)) === target)
72
- return target;
92
+ if (target !== undefined && (await launchWorktreeRoot(target, runGit)) === target) {
93
+ if (launchRoot === undefined || (await repositoryWorktrees(launchRoot, runGit)).has(target))
94
+ return target;
95
+ throw new Error(`worktree ${path} is not a worktree of the repository this server was launched in ` +
96
+ `(${launchRoot}); \`git worktree list\` there names the worktrees it may act on`);
97
+ }
73
98
  throw new Error(`worktree ${path} is not the top level of a git worktree: it must exist and be the ` +
74
99
  'directory `git rev-parse --show-toplevel` reports, not a subdirectory of it');
75
100
  }
@@ -11,7 +11,7 @@ type GeneratedArbiterRoot = {
11
11
  kind: 'parenthesized';
12
12
  expression: GeneratedArbiterRoot;
13
13
  } | {
14
- kind: 'functionCall' | 'cast' | 'nullTest' | 'distinctness' | 'parameter' | 'typedLiteral' | 'literal' | 'binary' | 'unary' | 'case' | 'subquery' | 'other';
14
+ kind: 'functionCall' | 'cast' | 'nullTest' | 'distinctness' | 'parameter' | 'typedLiteral' | 'literal' | 'binary' | 'unary' | 'array' | 'case' | 'subquery' | 'other';
15
15
  };
16
16
  export type GeneratedArbiterExpression = {
17
17
  columns: readonly GeneratedArbiterName[];
@@ -6,8 +6,8 @@ a tool argument; there is no temp file, `--file`, or replay command. The server
6
6
  existing `vouchington-tooling/agent-blackboard` exports and adds no provider protocol.
7
7
 
8
8
  Phase 1 of [vouchington-tooling#324](https://github.com/vouchington/vouchington-tooling/issues/324)
9
- covers the journal, outbox, session, and snapshot tools below. Retrospective and pull request tools
10
- are later phases.
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.
11
11
 
12
12
  ## Install
13
13
 
@@ -40,8 +40,10 @@ environment. The server never searches for, prints, or mints them.
40
40
 
41
41
  ## CLI fallback
42
42
 
43
- If MCP is unavailable, use `vouchington agent-blackboard journal append|entries|flush|status` as described
44
- in the blackboard skill. Record the fallback and delivery status in the journal and tell the human. The CLI resolves its
43
+ MCP is the supported path. If it is unavailable, restore it first as the blackboard skill describes.
44
+ As a last resort, use `vouchington agent-blackboard journal append|entries|flush|status` as described
45
+ there; sandboxed Claude Code and Codex withhold the token, so it can only retain records in the
46
+ outbox the server later flushes. Record the fallback and delivery status in the journal and tell the human. The CLI resolves its
45
47
  client from its own installation, so install `agent-blackboard` alongside that trusted CLI.
46
48
  Run the script with a verified absolute `node` and a cleared environment (`env -i`), never through
47
49
  its `#!/usr/bin/env node` line, which takes `node` from a possibly repository-controlled `PATH`.
@@ -80,15 +82,17 @@ Every tool takes an explicit `sessionId` (`^[A-Za-z0-9._:-]+$`, at most 256 char
80
82
  server never infers, and an optional `worktree`. Unknown arguments are rejected. Failures come back
81
83
  as MCP tool errors (`isError: true`) with an actionable message rather than crashing the server.
82
84
 
83
- | Tool | Arguments besides `sessionId` and `worktree` | Returns |
84
- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
85
- | `journal_append` | `parentSessionId`, `agent`, `version`, `mode` (`interactive` or `autonomous`), `markdown`, `sourceEventId`, `workOutcome`, `repositories`, `feedbackCoverage`, optional `category` | `sessionId`, `timestamp`, delivery `status` (`delivered` with a verified receipt, or `pending`), `pendingCount`, `worktreePendingCount` |
86
- | `journal_entries` | none | `sessionId` and `entries`, every entry oldest first |
87
- | `outbox_status` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount` |
88
- | `outbox_flush` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount`, `deliveredCount`, `rejected` (when any) |
89
- | `session_ensure` | `parentSessionId`, `agent`, `version` | `sessionId`, `status` (`created` or `exists`), `archived` |
90
- | `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
91
- | `session_archive` | none | `sessionId`, `archived: true` |
85
+ | Tool | Arguments besides `sessionId` and `worktree` | Returns |
86
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
87
+ | `journal_append` | `parentSessionId`, `agent`, `version`, `mode` (`interactive` or `autonomous`), `markdown`, `sourceEventId`, `workOutcome`, `repositories`, `feedbackCoverage`, optional `category` | `sessionId`, `timestamp`, delivery `status` (`delivered` with a verified receipt, or `pending`), `pendingCount`, `worktreePendingCount` |
88
+ | `journal_entries` | none | `sessionId` and `entries`, every entry oldest first |
89
+ | `outbox_status` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount` |
90
+ | `outbox_flush` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount`, `deliveredCount`, `rejected` (when any) |
91
+ | `session_ensure` | `parentSessionId`, `agent`, `version` | `sessionId`, `status` (`created` or `exists`), `archived` |
92
+ | `retrospective_check` | optional `sourceEventId` | `sessionId`, `saved`, `entries` (`sourceEventId`, `timestamp`, `issues`, `prs` of each retrospective, oldest first) |
93
+ | `retrospective_save` | `parentSessionId`, `agent`, `version`, `mode`, `workOutcome`, `feedbackCoverage`, exactly one of `markdown` or `compose`, optional `repositories`, `sourceEventId`, `category`, `dryRun` | as `journal_append` plus `policy.warnings`; with `dryRun`, `markdown` and `policy.warnings` |
94
+ | `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
95
+ | `session_archive` | none | `sessionId`, `archived: true` |
92
96
 
93
97
  `journal_append` validates the whole feedback envelope with `validateFeedbackEnvelope` before
94
98
  anything is written, so a missing required field writes nothing. It returns the verified read-back
@@ -161,20 +165,92 @@ is unavailable.
161
165
  The server sends the same list in its MCP `instructions`, so a harness that surfaces server
162
166
  instructions shows it to the agent.
163
167
 
168
+ ## Retrospective tools and the policy contract
169
+
170
+ `retrospective_check` reads the session through the same client as `journal_entries` and reports the
171
+ entries whose envelope `type` is `retrospective`. A session that was never created (the blackboard
172
+ answers 404) reports `saved: false`.
173
+
174
+ `retrospective_save` builds a `retrospective` envelope and writes it exactly as `journal_append`
175
+ does (same receipt, `pendingCount`, `worktreePendingCount`, outbox rules, and server-owned
176
+ timestamp; a `timestamp` argument is rejected). The document comes from `markdown` or from
177
+ `compose`, the input of `composeRetrospective` (`narrative`, `date`, `issues`, `prs`,
178
+ `description`, `facts`, `transcript`, `tools`, `architecture`, optional `knownSensitiveValues`;
179
+ unknown keys are rejected, and the journal audit reads through the server's own client).
180
+ `compose.transcript` accepts only `{ sessionId?, harnessSessionId? }` (`sessionId` must equal the
181
+ call's `sessionId`) or `{ status, reason }` for an unassessed transcript. The transcript is found by
182
+ `harnessSessionId`, the harness's own session UUID, else by the call's `sessionId` when that is a
183
+ UUID; otherwise the call fails rather than reporting the transcript unavailable. When the server's environment names its harness session (`CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, and so on), only that session's transcript is read (after `/clear` or a thread switch, reconnect the server, or pass `{ status, reason }`); `jsonlPath`, `projectsDir`, `codexSessionsDir`,
184
+ `grokSessionsDir`, and `cwd` are rejected, and the server reads the transcript from the worktree
185
+ and its default session roots. The server's blackboard token, and any `knownSensitiveValues`, are
186
+ redacted from the document before the policy sees it or the envelope is stored. Facts commands
187
+ are killed after 120 seconds. `compose.facts` takes `pr`, `branch`, `noPr`, or `{ status, reason }`;
188
+ `repo` is rejected, so the server's GitHub token never queries a repository other than the
189
+ worktree's. Facts run `git` and `gh` in the worktree as the server's user, honoring its
190
+ repository configuration (which can select helpers that run code), so the worktree, including
191
+ `.git/config`, must be as trusted as the policy module. `dryRun`
192
+ runs every check and returns the document without writing.
193
+
194
+ The document must open with the front matter `composeRetrospective` writes: lines of
195
+ `key: <JSON value>` between two `---` lines. Recognized keys are `date`, `issues`, `prs` (required),
196
+ `session_id`, `description`, `work_outcome`, `feedback_coverage` (`status`, `sources`,
197
+ `droppedCount`), and `repositories`; anything else, a duplicate, or a malformed line is rejected.
198
+ `session_id`, `work_outcome`, `feedback_coverage`, and `repositories` must equal the arguments when
199
+ present. `repositories` resolves from the argument, else the front matter, else the policy's
200
+ `pullRequestDefaults()` result, else an error, and is stored sorted, deduplicated, and lowercase.
201
+ The default `sourceEventId` is `retrospective-<sessionId>`, or `retrospective-<sha256 hex of the
202
+ sessionId>` when that would exceed 256 bytes. Re-saving identical content returns the
203
+ stored receipt and timestamp. Changed content under the same `sourceEventId` is an
204
+ `event-conflict`; a revision needs a new `sourceEventId`. `compose` regenerates the document from
205
+ the live journal and repository, so repeating a `compose` call is not a retry: when an interactive
206
+ save is retained in the outbox, deliver it with `outbox_flush`.
207
+
208
+ ### Policy module
209
+
210
+ The consumer's root `package.json` may declare
211
+ `"vouchington-tooling": { "policy": "dev/agent-policy.mts" }`. The path must be relative and, after
212
+ resolving symlinks, stay inside the worktree. Without a declaration the policy checks are skipped.
213
+ Policy tools also require a server launched inside a repository worktree on macOS or Linux (they
214
+ refuse to run on Windows, where the server cannot reap a policy's descendants by process group),
215
+ and refuse to run when `AGENT_BLACKBOARD_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` is set to a value
216
+ shorter than four characters, which could not be redacted from policy output. Each call runs one named
217
+ export in a fresh child process (`bin/policy-runner.mjs`, started with the server's own Node), never
218
+ inside the server, with the worktree as its working directory and an environment of only `HOME`,
219
+ `PATH` (`/usr/bin`, `/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, the Node directory, and the
220
+ directory of the first `gh` on the server's `PATH`, deduplicated), and `GH_TOKEN` / `GITHUB_TOKEN` when the server has
221
+ them. Blackboard credentials and `NODE_OPTIONS` are not passed. The child still runs as the
222
+ server's user, so this is not a sandbox: on Linux it could read the server's environment from
223
+ `/proc/<server pid>/environ`. Treat the policy module as trusted code. After the runner exits, its
224
+ whole process group is killed, so nothing it started outlives the call. The runner reads
225
+ `{ policyVersion: 1, export, input }` on stdin and writes `{ ok: true, result }` (or
226
+ `{ ok: false, error }`) on stdout; a missing export is skipped.
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 |
233
+
234
+ Any `errors` reject the save and write nothing. A non-zero exit, a timeout (reported as
235
+ `policy <export> timed out after <N>s`), output over 1 MB, malformed JSON, or a result of the wrong
236
+ shape is an error, never a pass.
237
+
164
238
  ## Worktree validation
165
239
 
166
240
  `worktree` is checked on every call. It must be an absolute path that, after resolving symlinks,
167
241
  equals the top level of a git worktree, that is, what `git rev-parse --show-toplevel` prints when
168
- run in it. Any git worktree on the machine is accepted, including one of a repository unrelated to
169
- the launch directory, so the tools can act on every checkout the user can reach. Worktrees created
170
- after launch are accepted and removed ones are refused. Rejected: a subdirectory of a worktree, a
242
+ run in it. When the server was launched inside a repository, the real path must also equal one of
243
+ the `worktree` entries of `git worktree list --porcelain` run in the launch root (the primary
244
+ worktree or a linked one), so an unrelated repository is refused. A server launched outside any
245
+ repository accepts the top level of any git worktree for the journal tools, but refuses the policy
246
+ tools. Worktrees created after launch are accepted and removed ones are refused. Rejected: a subdirectory of a worktree, a
171
247
  relative or nonexistent path, and a directory that is not in a git worktree. Git runs without
172
248
  `GIT_*` variables, so `GIT_DIR` cannot redirect the check, and with `LC_ALL=C` and no `LANGUAGE`, so
173
249
  its "not a git repository" diagnostic is never translated.
174
250
 
175
251
  `worktree` only chooses where the outbox lives. It is not a trust boundary for loading code: the
176
- server never imports modules from it, and a tool that needs to must first get a repository allowlist
177
- from the machine registration.
252
+ server never imports modules from it. The policy tools run the declared module only in a child
253
+ process without blackboard credentials, as described above.
178
254
 
179
255
  When `worktree` is omitted, the launch directory's worktree top level is used if the launch
180
256
  directory is inside one, and it is validated like any explicit path. If the server was launched
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.40.0",
3
+ "version": "0.42.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": {
@@ -377,7 +377,7 @@
377
377
  },
378
378
  "devDependencies": {
379
379
  "@ast-grep/cli": "0.45.3",
380
- "@modelcontextprotocol/sdk": "1.31.0",
380
+ "@modelcontextprotocol/sdk": "1.32.0",
381
381
  "@types/picomatch": "^4.0.3",
382
382
  "@types/spdx-expression-parse": "4.0.0",
383
383
  "agent-blackboard": "^0.6.1",