vouchington-tooling 0.44.0 → 0.45.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 (51) hide show
  1. package/dist/agent-blackboard/client.d.mts +6 -0
  2. package/dist/cli/usage.d.mts +1 -1
  3. package/dist/cli/usage.mjs +1 -1
  4. package/dist/mcp-server/context.d.mts +3 -0
  5. package/dist/mcp-server/dispatch.d.mts +3 -0
  6. package/dist/mcp-server/dispatch.mjs +7 -1
  7. package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +2 -0
  8. package/dist/mcp-server/fake-blackboard.test-helpers.mjs +3 -0
  9. package/dist/mcp-server/handlers-pr.d.mts +1 -1
  10. package/dist/mcp-server/handlers-pr.mjs +6 -97
  11. package/dist/mcp-server/handlers-retrospective-save.d.mts +10 -0
  12. package/dist/mcp-server/handlers-retrospective-save.mjs +138 -0
  13. package/dist/mcp-server/handlers-retrospective.d.mts +1 -12
  14. package/dist/mcp-server/handlers-retrospective.mjs +1 -154
  15. package/dist/mcp-server/handlers-session.mjs +25 -6
  16. package/dist/mcp-server/handlers-snapshot-cleanup.d.mts +7 -0
  17. package/dist/mcp-server/handlers-snapshot-cleanup.mjs +36 -0
  18. package/dist/mcp-server/harness.test-helpers.d.mts +2 -0
  19. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  20. package/dist/mcp-server/policy-run.d.mts +1 -0
  21. package/dist/mcp-server/policy-run.mjs +1 -0
  22. package/dist/mcp-server/pr-body.d.mts +4 -2
  23. package/dist/mcp-server/pr-body.mjs +15 -3
  24. package/dist/mcp-server/pr-defaults.d.mts +3 -0
  25. package/dist/mcp-server/pr-defaults.mjs +7 -2
  26. package/dist/mcp-server/pr-input.d.mts +12 -5
  27. package/dist/mcp-server/pr-input.mjs +21 -4
  28. package/dist/mcp-server/pr-live.d.mts +3 -1
  29. package/dist/mcp-server/pr-live.mjs +7 -2
  30. package/dist/mcp-server/pr-mode.d.mts +7 -0
  31. package/dist/mcp-server/pr-mode.mjs +34 -0
  32. package/dist/mcp-server/pr-no-source.d.mts +9 -0
  33. package/dist/mcp-server/pr-no-source.mjs +22 -0
  34. package/dist/mcp-server/pr-prepare.d.mts +25 -0
  35. package/dist/mcp-server/pr-prepare.mjs +103 -0
  36. package/dist/mcp-server/preview-registry.d.mts +25 -0
  37. package/dist/mcp-server/preview-registry.mjs +38 -0
  38. package/dist/mcp-server/retrospective-check.d.mts +3 -0
  39. package/dist/mcp-server/retrospective-check.mjs +38 -0
  40. package/dist/mcp-server/retrospective-input.mjs +2 -2
  41. package/dist/mcp-server/retrospective-preview.d.mts +17 -0
  42. package/dist/mcp-server/retrospective-preview.mjs +60 -0
  43. package/dist/mcp-server/snapshot-registry.d.mts +25 -0
  44. package/dist/mcp-server/snapshot-registry.mjs +32 -0
  45. package/dist/mcp-server/tools-pr.mjs +20 -4
  46. package/dist/mcp-server/tools-retrospective.mjs +25 -5
  47. package/dist/mcp-server/tools.mjs +6 -0
  48. package/docs/mcp-server.md +95 -23
  49. package/package.json +3 -3
  50. package/skills/retrospective/SKILL.md +3 -1
  51. package/skills/retrospective-distill/SKILL.md +5 -0
@@ -0,0 +1,38 @@
1
+ import { validateFeedbackEnvelope } from "../agent-blackboard/index.mjs";
2
+ import { optionalString } from "./args.mjs";
3
+ import { byCreatedAt } from "./handlers-journal.mjs";
4
+ import { readEntriesIfPresent } from "./journal-read.mjs";
5
+ function retrospectiveData(entry) {
6
+ const data = entry?.data;
7
+ if (data?.type !== 'retrospective')
8
+ return undefined;
9
+ // A schema-versioned entry must be a valid envelope; unversioned legacy entries stay accepted.
10
+ if (data.schemaVersion !== undefined) {
11
+ try {
12
+ validateFeedbackEnvelope(data);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ return data;
19
+ }
20
+ /** Whether the session already holds a retrospective, and which (sourceEventId, issues, prs). */
21
+ export const retrospectiveCheck = async (args, context) => {
22
+ const sourceEventId = optionalString(args, 'sourceEventId');
23
+ const entries = await readEntriesIfPresent(context.sessionId, context.env, context.dependencies);
24
+ const found = (entries ?? [])
25
+ .toSorted(byCreatedAt)
26
+ .flatMap((entry) => {
27
+ const data = retrospectiveData(entry);
28
+ return data === undefined ? [] : [data];
29
+ })
30
+ .filter((data) => sourceEventId === undefined || data.sourceEventId === sourceEventId)
31
+ .map((data) => ({
32
+ sourceEventId: data.sourceEventId,
33
+ timestamp: data.timestamp,
34
+ issues: data.issues ?? [],
35
+ prs: data.prs ?? [],
36
+ }));
37
+ return { sessionId: context.sessionId, saved: found.length > 0, entries: found };
38
+ };
@@ -73,10 +73,10 @@ export function worktreeExecutor(worktree, env, timeoutMs = FACTS_TIMEOUT_MS) {
73
73
  export function sensitiveValues(compose, env) {
74
74
  const given = compose?.knownSensitiveValues;
75
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');
76
+ throw new Error('knownSensitiveValues must be an array of strings');
77
77
  // Redaction ignores values under four characters, so promising to redact one would be false.
78
78
  if (given?.some((value) => value.length < 4))
79
- throw new Error('compose.knownSensitiveValues entries must be at least 4 characters');
79
+ throw new Error('knownSensitiveValues entries must be at least 4 characters');
80
80
  // Facts commands inherit the GitHub tokens, so their output is redacted of those values too.
81
81
  const credentials = [env.AGENT_BLACKBOARD_TOKEN, env.GH_TOKEN, env.GITHUB_TOKEN].filter((value) => Boolean(value));
82
82
  const values = [...(given ?? []), ...credentials];
@@ -0,0 +1,17 @@
1
+ import { type Args } from './args.mts';
2
+ import { type ToolContext } from './context.mts';
3
+ import type { FeedbackEnvelope, FeedbackIdentity } from '../agent-blackboard/feedback-types.mts';
4
+ type Mode = 'interactive' | 'autonomous';
5
+ /** Writes a validated envelope through the same verified path as `journal_append`. */
6
+ export declare function deliver(envelope: FeedbackEnvelope, identity: FeedbackIdentity, mode: Mode, context: ToolContext): Promise<Record<string, unknown>>;
7
+ /**
8
+ * Saves exactly the content a dry run previewed. The token is single use and bound to the
9
+ * session, the worktree and the event id; an unknown, spent, restarted or mismatched token is
10
+ * one error. The policy accepted this content at preview time, so it is not run again.
11
+ */
12
+ export declare function saveFromPreview(args: Args, context: ToolContext, identity: FeedbackIdentity, mode: Mode, sourceEventId: string): Promise<{
13
+ policy: {
14
+ warnings: string[];
15
+ };
16
+ }>;
17
+ export {};
@@ -0,0 +1,60 @@
1
+ import { rejectUnknown, requiredString } from "./args.mjs";
2
+ import { outboxDirectory } from "./context.mjs";
3
+ import { deliveryResponse } from "./handlers-journal.mjs";
4
+ import { previewRegistry } from "./preview-registry.mjs";
5
+ import { validateFeedbackEnvelope, writeFeedback } from "../agent-blackboard/index.mjs";
6
+ /** Writes a validated envelope through the same verified path as `journal_append`. */
7
+ export async function deliver(envelope, identity, mode, context) {
8
+ const directory = mode === 'interactive' ? outboxDirectory(context.worktree) : undefined;
9
+ const result = await writeFeedback({
10
+ identity,
11
+ envelope,
12
+ mode,
13
+ ...(directory === undefined ? {} : { outboxDirectory: directory }),
14
+ env: context.env,
15
+ dependencies: context.dependencies,
16
+ });
17
+ return deliveryResponse(result, context, mode);
18
+ }
19
+ /** The only arguments a token save takes: everything else was fixed by the dry run. */
20
+ const TOKEN_SAVE_ARGS = [
21
+ 'sessionId',
22
+ 'worktree',
23
+ 'parentSessionId',
24
+ 'agent',
25
+ 'version',
26
+ 'mode',
27
+ 'sourceEventId',
28
+ 'previewToken',
29
+ ];
30
+ /**
31
+ * Saves exactly the content a dry run previewed. The token is single use and bound to the
32
+ * session, the worktree and the event id; an unknown, spent, restarted or mismatched token is
33
+ * one error. The policy accepted this content at preview time, so it is not run again.
34
+ */
35
+ export async function saveFromPreview(args, context, identity, mode, sourceEventId) {
36
+ rejectUnknown(args, TOKEN_SAVE_ARGS);
37
+ const token = requiredString(args, 'previewToken');
38
+ const preview = previewRegistry.take(token, {
39
+ sessionId: context.sessionId,
40
+ parentSessionId: identity.parentSessionId,
41
+ mode,
42
+ worktree: context.worktree,
43
+ sourceEventId,
44
+ });
45
+ if (preview === undefined)
46
+ throw new Error('previewToken is unknown, already used, expired by a restart, or bound to a different ' +
47
+ 'session, worktree or sourceEventId: run the dry run again');
48
+ // The server owns the timestamp, as for every other write.
49
+ const envelope = { ...preview.envelope, timestamp: new Date().toISOString() };
50
+ try {
51
+ validateFeedbackEnvelope(envelope);
52
+ const response = await deliver(envelope, identity, mode, context);
53
+ return { ...response, policy: { warnings: preview.warnings } };
54
+ }
55
+ catch (error) {
56
+ // Nothing was stored, so the caller may retry with the same token.
57
+ previewRegistry.restore(token, preview);
58
+ throw error;
59
+ }
60
+ }
@@ -0,0 +1,25 @@
1
+ import type { SnapshotCleanupReceipt } from '../agent-blackboard/snapshot-types.mts';
2
+ /** What the server itself recorded when it exported a snapshot. Never read from a caller. */
3
+ export type SnapshotRecord = {
4
+ sessionId: string;
5
+ worktree: string;
6
+ path: string;
7
+ /** The exporting client's own capability; kept server-side and never returned to a caller. */
8
+ clientToken?: string;
9
+ directory?: string;
10
+ receipt?: SnapshotCleanupReceipt;
11
+ };
12
+ export type SnapshotRegistry = {
13
+ /** Records an export and returns the server-minted, unguessable cleanup token. */
14
+ register: (record: SnapshotRecord) => string;
15
+ /** Removes and returns the record when the token is known and bound to this caller. */
16
+ take: (token: string, sessionId: string, worktree: string) => SnapshotRecord;
17
+ /** Puts a taken record back after a failed cleanup so the same token can retry. */
18
+ restore: (token: string, record: SnapshotRecord) => void;
19
+ };
20
+ export declare const MAX_RECORDS = 256;
21
+ /**
22
+ * Tokens live in memory only. A server restart forgets every binding, so an old token is then
23
+ * rejected as unknown and the file is left for the operating system to reap from its temp directory.
24
+ */
25
+ export declare function createSnapshotRegistry(): SnapshotRegistry;
@@ -0,0 +1,32 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ export const MAX_RECORDS = 256;
3
+ const REJECTED = 'cleanupToken is unknown, already used, or bound to another session or worktree; nothing was deleted';
4
+ /**
5
+ * Tokens live in memory only. A server restart forgets every binding, so an old token is then
6
+ * rejected as unknown and the file is left for the operating system to reap from its temp directory.
7
+ */
8
+ export function createSnapshotRegistry() {
9
+ const records = new Map();
10
+ const store = (token, record) => {
11
+ records.set(token, record);
12
+ // A long-lived server must not grow without bound; the oldest export is forgotten first.
13
+ while (records.size > MAX_RECORDS)
14
+ records.delete(records.keys().next().value);
15
+ };
16
+ return {
17
+ register(record) {
18
+ const token = randomBytes(32).toString('base64url');
19
+ store(token, record);
20
+ return token;
21
+ },
22
+ take(token, sessionId, worktree) {
23
+ const record = records.get(token);
24
+ // One message for every rejection, so a probe learns nothing about other sessions' tokens.
25
+ if (!record || record.sessionId !== sessionId || record.worktree !== worktree)
26
+ throw new Error(REJECTED);
27
+ records.delete(token);
28
+ return record;
29
+ },
30
+ restore: store,
31
+ };
32
+ }
@@ -12,7 +12,8 @@ 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; may be empty.',
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).',
16
17
  items: {
17
18
  type: 'object',
18
19
  properties: {
@@ -24,6 +25,15 @@ const FIELDS = {
24
25
  additionalProperties: false,
25
26
  },
26
27
  },
28
+ noSourceIssue: {
29
+ 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.',
33
+ properties: { reason: text('Single-line reason there is no source issue.') },
34
+ required: ['reason'],
35
+ additionalProperties: false,
36
+ },
27
37
  details: {
28
38
  type: 'array',
29
39
  minItems: 1,
@@ -33,7 +43,12 @@ const FIELDS = {
33
43
  base: { type: 'string', minLength: 1, description: 'Base branch. Default: main.' },
34
44
  agent: text('Agent name for the Agent line.'),
35
45
  model: text('Optional model name, rendered in parentheses after the agent.'),
36
- mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode, given to the policy.' },
46
+ mode: {
47
+ enum: ['interactive', 'autonomous'],
48
+ description: 'Optional. The server derives the mode from its own launch environment and fails closed: ' +
49
+ 'an automation marker (CI, GITHUB_ACTIONS and similar) means autonomous. This argument ' +
50
+ 'can only narrow interactive to autonomous, never widen it.',
51
+ },
37
52
  workspaceSetup: text('Text for the Workspace setup line, such as how the worktree was prepared.'),
38
53
  };
39
54
  const REQUIRED = [
@@ -43,7 +58,6 @@ const REQUIRED = [
43
58
  'relatedIssues',
44
59
  'details',
45
60
  'agent',
46
- 'mode',
47
61
  'workspaceSetup',
48
62
  ];
49
63
  const RENDERING = 'The server renders the body from the structured input (Summary, Impact, Related issues, ' +
@@ -54,7 +68,9 @@ export const PR_TOOLS = [
54
68
  tool('pr_validate', 'Renders the pull request body and validates it with the checkout policy; writes nothing. ' +
55
69
  RENDERING +
56
70
  'Pass pr to validate an update of that pull request: the live body is read and its Shepherd ' +
57
- 'Journal preserved. Returns { body, errors, warnings }.', openWorld(READ_ONLY), { ...FIELDS, pr: PR_NUMBER }, REQUIRED),
71
+ 'Journal preserved; the pull request may be in any state and on any head (the head given to ' +
72
+ 'the policy is its own, and no liveness check applies, unlike pr_update). Returns ' +
73
+ '{ body, errors, warnings }.', openWorld(READ_ONLY), { ...FIELDS, pr: PR_NUMBER }, REQUIRED),
58
74
  tool('pr_create', 'Creates a draft pull request from the worktree branch. ' +
59
75
  RENDERING +
60
76
  'Validation errors block creation. The policy pullRequestDefaults export may add labels, a ' +
@@ -15,9 +15,15 @@ export const RETROSPECTIVE_TOOLS = [
15
15
  'takes only { sessionId?, harnessSessionId? } (sessionId must be the call sessionId; the ' +
16
16
  'transcript is found by harnessSessionId, the harness session UUID, else by sessionId when ' +
17
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 ' +
18
+ 'unassessed; any path override is rejected. The server redacts its blackboard token and ' +
19
+ 'knownSensitiveValues (also accepted beside markdown, not beside compose) from the ' +
20
+ 'document, category and feedbackCoverage before the policy sees them. dryRun returns ' +
21
+ '{ markdown, previewToken, policy: { warnings } } and writes nothing; the previewToken is ' +
22
+ 'opaque, single use, held in server memory (a restart drops it) and bound to the sessionId, ' +
23
+ 'worktree and sourceEventId. To save exactly the previewed content, call again with ' +
24
+ 'previewToken, the identity, mode and the same sourceEventId, and no markdown, compose, ' +
25
+ 'workOutcome, feedbackCoverage, repositories, category or knownSensitiveValues. The ' +
26
+ 'policy is not run again for a token save. The worktree package.json may ' +
21
27
  'declare "vouchington-tooling": { "policy": "<module>" }; its validateRetrospective export ' +
22
28
  'runs in a child process, and any error it reports rejects the save with nothing written. ' +
23
29
  'Policy tools need a server launched inside a repository worktree. The server owns the ' +
@@ -29,7 +35,10 @@ export const RETROSPECTIVE_TOOLS = [
29
35
  'follow journal_append.', ADDITIVE, {
30
36
  ...IDENTITY,
31
37
  mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
32
- workOutcome: { enum: OUTCOMES, description: 'Outcome of the work being reported.' },
38
+ workOutcome: {
39
+ enum: OUTCOMES,
40
+ description: 'Outcome of the work being reported; required without previewToken.',
41
+ },
33
42
  feedbackCoverage: COVERAGE,
34
43
  repositories: {
35
44
  type: 'array',
@@ -44,5 +53,16 @@ export const RETROSPECTIVE_TOOLS = [
44
53
  sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Stable event id.' },
45
54
  category: { type: 'string', description: 'Optional context; never changes entry type.' },
46
55
  dryRun: { type: 'boolean', description: 'Compose and validate only; write nothing.' },
47
- }, [...IDENTITY_REQUIRED, 'mode', 'workOutcome', 'feedbackCoverage']),
56
+ knownSensitiveValues: {
57
+ type: 'array',
58
+ items: { type: 'string', minLength: 4 },
59
+ description: 'Values redacted from every argument of a markdown save.',
60
+ },
61
+ previewToken: {
62
+ type: 'string',
63
+ description: 'Token from a dry run; saves exactly that content. Exclusive with the content.',
64
+ },
65
+ },
66
+ // workOutcome and feedbackCoverage are required unless previewToken carries them.
67
+ [...IDENTITY_REQUIRED, 'mode']),
48
68
  ];
@@ -58,5 +58,11 @@ export const TOOLS = [
58
58
  dataArrayContains: { type: 'object', description: 'Exact string membership in arrays.' },
59
59
  inactiveForHours: { type: 'number', exclusiveMinimum: 0 },
60
60
  }),
61
+ tool('snapshot_cleanup', 'Deletes the snapshot file (and partition directory, when one was recorded) that ' +
62
+ 'snapshot_export produced. Takes only the cleanupToken it returned: the server resolves the ' +
63
+ 'paths from its own record, never from the caller. The token is bound to the sessionId and ' +
64
+ 'worktree that exported, is single-use, and is forgotten when the server restarts (the ' +
65
+ 'file is then left for the OS to reap). A failed cleanup keeps the token valid and names ' +
66
+ 'any retained tombstone so the same call can retry. Call it as a finally step.', { ...MUTATING, idempotentHint: false }, { cleanupToken: { type: 'string', description: 'The cleanupToken snapshot_export returned.' } }, ['cleanupToken']),
61
67
  tool('session_archive', 'Marks a session as distilled. Its metadata becomes immutable; entries stay appendable.', MUTATING),
62
68
  ];
@@ -82,17 +82,18 @@ Every tool takes an explicit `sessionId` (`^[A-Za-z0-9._:-]+$`, at most 256 char
82
82
  server never infers, and an optional `worktree`. Unknown arguments are rejected. Failures come back
83
83
  as MCP tool errors (`isError: true`) with an actionable message rather than crashing the server.
84
84
 
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` |
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`, `knownSensitiveValues` (markdown only); or `previewToken` with identity, `mode` and `sourceEventId` | as `journal_append` plus `policy.warnings`; with `dryRun`, `markdown`, `previewToken` and `policy.warnings` |
94
+ | `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
95
+ | `snapshot_cleanup` | `cleanupToken` | `sessionId`, `cleaned: true` |
96
+ | `session_archive` | none | `sessionId`, `archived: true` |
96
97
 
97
98
  `journal_append` validates the whole feedback envelope with `validateFeedbackEnvelope` before
98
99
  anything is written, so a missing required field writes nothing. It returns the verified read-back
@@ -146,6 +147,35 @@ outbox does not create the directory. `snapshot_export` never accepts a destinat
146
147
  `agent-blackboard` client chooses a private file and returns its path. `session_archive` is the one
147
148
  destructive tool; archived metadata is immutable, while entries stay appendable.
148
149
 
150
+ ### Snapshot cleanup
151
+
152
+ `snapshot_export` writes a private file in the OS temp directory, and `snapshot_cleanup` is the only
153
+ way to delete it through the server. It was chosen over cleanup inside `session_archive` because
154
+ the exporting session and the archived sessions differ during distillation, and cleanup must run
155
+ whether or not archiving happened. Call it as a finally step after processing, on success and on
156
+ failure.
157
+
158
+ - **No caller-chosen path.** The tool takes only `cleanupToken` (`path`, `directory`, and `receipt`
159
+ are unsupported arguments). The server records the export's file path itself, plus a partition
160
+ directory and its signed receipt when the client reports both, and cleans exactly that record.
161
+ When the client's export carried its own cleanup capability, the server keeps it (never
162
+ returning it) and cleans through the client's `cleanupSnapshotPartitions`, so the client's
163
+ ownership marker goes with the file; otherwise it uses the in-tree `cleanupSnapshotPartitions`.
164
+ - **Server-minted, bound token.** `snapshot_export` replaces the client's `cleanupToken` with 32
165
+ random bytes the server generates, recorded with the caller's `sessionId` and resolved `worktree`.
166
+ The binding is checked before any filesystem call.
167
+ - **Single use.** A successful cleanup spends the token; a repeat fails as unknown and deletes
168
+ nothing. The record is taken synchronously, so concurrent calls cannot both clean.
169
+ - **Failure modes.** An unknown, forged (including a path used as a token), spent, other-session, or
170
+ other-worktree token fails with one message and deletes nothing; a mismatched caller does not spend
171
+ the owner's token. If the cleanup itself fails, the token stays valid and the error carries the
172
+ reason, including the retained tombstone path when a partition directory was half removed, so the
173
+ same call can retry. A file that is already gone counts as cleaned.
174
+ - **Server restart.** Bindings are in memory only. After a restart every earlier token is unknown, so
175
+ the file is left for the operating system to reap from its temp directory; run the distill pass
176
+ again to export a fresh snapshot. The registry also remembers at most 256 exports and forgets the
177
+ oldest first.
178
+
149
179
  The outbox lives at `<worktree>/.local/blackboard-outbox` and is used only in interactive mode.
150
180
  Autonomous mode forbids a filesystem outbox, so a failed delivery is an error.
151
181
 
@@ -168,7 +198,9 @@ instructions shows it to the agent.
168
198
  ## Retrospective tools and the policy contract
169
199
 
170
200
  `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
201
+ entries whose envelope `type` is `retrospective`. A schema-versioned entry counts only when its
202
+ envelope passes `validateFeedbackEnvelope`; a malformed one is not saved, while an unversioned legacy
203
+ entry is still accepted. A session that was never created (the blackboard
172
204
  answers 404) reports `saved: false`.
173
205
 
174
206
  `retrospective_save` builds a `retrospective` envelope and writes it exactly as `journal_append`
@@ -189,7 +221,20 @@ are killed after 120 seconds. `compose.facts` takes `pr`, `branch`, `noPr`, or `
189
221
  worktree's. Facts run `git` and `gh` in the worktree as the server's user, honoring its
190
222
  repository configuration (which can select helpers that run code), so the worktree, including
191
223
  `.git/config`, must be as trusted as the policy module. `dryRun`
192
- runs every check and returns the document without writing.
224
+ runs every check and returns the document without writing, plus a `previewToken`.
225
+
226
+ `knownSensitiveValues` (each at least 4 characters) is also accepted beside `markdown`, and
227
+ redacts the markdown, `category` and `feedbackCoverage` just as `compose.knownSensitiveValues`
228
+ does; with `compose`, pass it inside `compose`. The `previewToken` is 32 random bytes (base64url),
229
+ opaque, single use, and held only in server memory: a restart drops every token, and the registry
230
+ keeps the 32 most recent. It is bound to the `sessionId`, `parentSessionId`, `mode`, the resolved worktree, the `sourceEventId`
231
+ and the exact redacted content. Save it by calling `retrospective_save` again with the identity,
232
+ the same `parentSessionId`, `mode`, `sourceEventId` and `previewToken`, and none of `markdown`, `compose`,
233
+ `workOutcome`, `feedbackCoverage`, `repositories`, `category`, `knownSensitiveValues` or `dryRun`.
234
+ The server stores exactly the previewed content (so a sensitive value never has to be sent
235
+ again), mints a new timestamp, and does not run the policy again. An unknown, spent, restarted or
236
+ mismatched token is one error and the dry run must be repeated; a mismatch does not spend the
237
+ token, and neither does a failed write.
193
238
 
194
239
  The document must open with the front matter `composeRetrospective` writes: lines of
195
240
  `key: <JSON value>` between two `---` lines. Recognized keys are `date`, `issues`, `prs` (required),
@@ -229,7 +274,7 @@ whole process group is killed, so nothing it started outlives the call. The runn
229
274
  | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------- |
230
275
  | `validateRetrospective` | `sessionId`, `parentSessionId`, `mode`, `workOutcome`, `feedbackCoverage`, `repositories`, `markdown`, `sourceEventId`, `metadata` (`date`, `issues`, `prs`, `description`) | `{ errors: string[], warnings?: string[] }` | 30 s |
231
276
  | `pullRequestDefaults` | `{}` from `retrospective_save`; `{ title, base, relatedIssues }` from `pr_create` | `{ repositories?, labels?, milestone?, project? }` (an object) | 120 s |
232
- | `validatePullRequest` | `title`, `body`, `base`, `head`, `mode`, and `pr` for an update | `{ errors: string[], warnings?: string[] }` | 120 s |
277
+ | `validatePullRequest` | `title`, `body`, `base`, `head`, the trusted `mode`, `noSourceIssue` when given, and `pr` when set | `{ errors: string[], warnings?: string[] }` | 120 s |
233
278
 
234
279
  Any `errors` reject the save and write nothing. A non-zero exit, a timeout (reported as
235
280
  `policy <export> timed out after <N>s`), output over 1 MB, malformed JSON, or a result of the wrong
@@ -241,9 +286,33 @@ shape is an error, never a pass.
241
286
  retrospective tools. All three take `sessionId` (required) and `worktree` (optional), need a server
242
287
  launched inside a repository worktree (they run the checkout policy), and share this structured
243
288
  input: `title` (single line, at most 256 characters), `summary`, `impact`, `relatedIssues`
244
- (`[{ number, relation: "closes" | "refs", reason }]`, may be empty), `details` (string array),
245
- `base` (default `main`), `agent`, optional `model`, `mode` (`interactive` | `autonomous`) and
246
- `workspaceSetup`. `pr_update` also requires `pr`; `pr_validate` accepts an optional `pr`.
289
+ (`[{ number, relation: "closes" | "refs", reason }]`, may be empty), optional `noSourceIssue`
290
+ (`{ reason }`, see below), `details` (string array), `base` (default `main`), `agent`, optional
291
+ `model`, optional `mode` (`interactive` | `autonomous`) and `workspaceSetup`. `pr_update` also
292
+ requires `pr`; `pr_validate` accepts an optional `pr`.
293
+
294
+ ### Trusted mode
295
+
296
+ The mode the policy sees is derived by the server from its own launch environment, never from the
297
+ caller. It fails closed: when any automation marker is set to something other than an empty
298
+ string, `0` or `false` (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`,
299
+ `JENKINS_URL`, `TF_BUILD`, `CODEBUILD_BUILD_ID`, `VOUCHINGTON_AUTOMATION`), the mode is
300
+ `autonomous` whatever the caller passes. `CLAUDE_CODE_SESSION_ATTENDED` set to exactly `0` (Claude
301
+ Code's marker for an unattended `claude -p` run, inherited by MCP servers) also means `autonomous`;
302
+ a missing value or `1` never does. This list is a superset of the downstream check it replaces,
303
+ which was exactly `GITHUB_ACTIONS === 'true' || CI === 'true'`. Otherwise the mode is `interactive`. The optional `mode`
304
+ argument can only narrow `interactive` to `autonomous` (it never widens), so an autonomous run
305
+ cannot unlock an interactive-only policy exception. The policy child receives no env markers, so it
306
+ relies on this value.
307
+
308
+ ### Pull requests with no source issue
309
+
310
+ Pass `noSourceIssue: { reason }` (a single line) with an empty `relatedIssues`; a non-empty
311
+ `relatedIssues` beside it is rejected. The Related issues section then holds the generic statement
312
+ `- No source issue — <reason>` instead of `- None`. A policy may replace the statement by
313
+ exporting `renderNoSourceIssue({ reason })`, returning a non-empty string (30 s). The field also
314
+ reaches `validatePullRequest` (with the trusted `mode`, so the policy can apply an interactive-only
315
+ rule) and `pullRequestDefaults`.
247
316
 
248
317
  ### Rendering
249
318
 
@@ -258,7 +327,8 @@ input: `title` (single line, at most 256 characters), `summary`, `impact`, `rela
258
327
 
259
328
  ## Related issues
260
329
 
261
- - Closes #N — <reason> (or "Refs #N"; "- None" when empty)
330
+ - Closes #N — <reason> (or "Refs #N"; "- None" when empty;
331
+ "- No source issue — <reason>" for noSourceIssue)
262
332
 
263
333
  ## Details
264
334
 
@@ -270,18 +340,20 @@ Device: <os user>@<lowercased hostname>
270
340
  Worktree: <path after "/worktrees/", "main" for the main worktree, else the directory name>
271
341
  ```
272
342
 
273
- The head branch is the worktree's current branch; a detached HEAD, or a head equal to `base`, is
274
- rejected. The body must stay within GitHub's 65536 characters. `pr_create` sees no stack membership,
343
+ The head branch is the worktree's current branch (for `pr_validate` with `pr`, the live pull
344
+ request's head, which may equal `base` for a fork); otherwise a detached HEAD, or a head equal to `base`, is rejected. The body must stay within GitHub's 65536 characters. `pr_create` sees no stack membership,
275
345
  so a consumer policy may reject any `base` other than the default branch; create stacked layers
276
346
  with the repository's stack tooling instead.
277
347
 
278
348
  ### Tools
279
349
 
280
350
  - `pr_validate` (read-only): renders the body, runs the policy `validatePullRequest({ title, body,
281
- base, head, mode, pr? })` export and returns `{ body, errors, warnings }`. With `pr`, the live
282
- body is read (never written) so its Shepherd Journal is preserved as in `pr_update`.
351
+ base, head, mode, noSourceIssue?, pr? })` export and returns `{ body, errors, warnings }`. With
352
+ `pr`, the live body is read (never written) so its Shepherd Journal is preserved as in
353
+ `pr_update`. The liveness checks of `pr_update` do not apply: any state (merged or closed
354
+ included) and any head are accepted, and the policy sees the pull request's own head and base.
283
355
  - `pr_create` (additive): renders and validates (errors block), then calls the policy
284
- `pullRequestDefaults({ title, base, relatedIssues })` export for optional `labels`, `milestone`
356
+ `pullRequestDefaults({ title, base, relatedIssues, noSourceIssue? })` export for optional `labels`, `milestone`
285
357
  and `project`. It checks the head is pushed and current, writes the body to a mode 0600 temp file
286
358
  (removed afterwards) and runs `gh pr create --draft` with the labels, `--milestone` and
287
359
  `--project`. Returns `{ number, url, warnings }`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.44.0",
3
+ "version": "0.45.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": {
@@ -391,13 +391,13 @@
391
391
  "yaml": "2.9.1"
392
392
  },
393
393
  "devDependencies": {
394
- "@ast-grep/cli": "0.45.3",
394
+ "@ast-grep/cli": "0.50.0",
395
395
  "@modelcontextprotocol/sdk": "1.32.1",
396
396
  "@types/picomatch": "^4.0.3",
397
397
  "@types/spdx-expression-parse": "4.0.0",
398
398
  "agent-blackboard": "^0.6.1",
399
399
  "no-mistakes": "0.83.0",
400
- "smol-toml": "1.9.0",
400
+ "smol-toml": "1.9.1",
401
401
  "typescript5": "npm:typescript@5.9.3"
402
402
  },
403
403
  "peerDependencies": {
@@ -15,7 +15,9 @@ local `AGENTS.md`, and journal guidance first.
15
15
  1. Read the agent's own journal oldest-first and check for an existing retrospective. Capture a
16
16
  material new observation before filing a follow-up issue. Preserve entry-level repository tags.
17
17
  2. Generate routine repository and transcript facts with the approved composer: pass `compose` to
18
- the `retrospective_save` MCP tool and preview the composition with `dryRun`, never a CLI. Keep exact factual
18
+ the `retrospective_save` MCP tool and preview the composition with `dryRun`, never a CLI. Save the preview by passing its
19
+ `previewToken` to `retrospective_save` (no markdown); put sensitive values only in
20
+ `compose.knownSensitiveValues`, or `knownSensitiveValues` for a markdown save. Keep exact factual
19
21
  section markers generated, preserve unavailable counts, and never replace missing evidence with
20
22
  invented zero statistics. Do not paste raw collectors' output or manually reconstruct facts.
21
23
  3. Record work outcome, feedback coverage, and delivery independently. Success, failure,
@@ -126,6 +126,11 @@ difference is a conflicting payload and stays quarantined.
126
126
  sessions into retrospective and stale (checkpoint-only, journal-only, zero-entry child, and
127
127
  zero-entry root).
128
128
 
129
+ Cleanup, as a finally step: whether processing succeeded or failed, call `snapshot_cleanup` with
130
+ the `cleanupToken` from `snapshot_export`, with the same `sessionId` and `worktree`, once the
131
+ snapshot is no longer needed. The token is single-use and server-restart-scoped; if cleanup fails,
132
+ retry the same call.
133
+
129
134
  Use source records only for local verification and leave them in the repository's approved journal
130
135
  or retention system. Public issue bodies contain only the minimum bounded facts or redacted
131
136
  summaries needed to establish the problem, proposed work, relevant areas, and validation. Never