vouchington-tooling 0.26.0 → 0.27.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.
- package/README.md +4 -1
- package/dist/agent-blackboard/feedback-canonical.d.mts +7 -0
- package/dist/agent-blackboard/feedback-canonical.mjs +8 -0
- package/dist/agent-blackboard/feedback-delivery.mjs +8 -6
- package/dist/agent-blackboard/feedback-online.mjs +11 -4
- package/dist/agent-blackboard/feedback-outbox.d.mts +8 -1
- package/dist/agent-blackboard/feedback-outbox.mjs +17 -4
- package/dist/agent-blackboard/feedback-types.d.mts +4 -0
- package/dist/agent-blackboard/index.d.mts +1 -1
- package/dist/agent-blackboard/index.mjs +1 -1
- package/dist/mcp-server/dispatch.mjs +5 -4
- package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +3 -0
- package/dist/mcp-server/fake-blackboard.test-helpers.mjs +7 -1
- package/dist/mcp-server/handlers-journal.d.mts +0 -2
- package/dist/mcp-server/handlers-journal.mjs +22 -30
- package/dist/mcp-server/handlers-outbox.d.mts +11 -0
- package/dist/mcp-server/handlers-outbox.mjs +45 -0
- package/dist/mcp-server/harness.test-helpers.d.mts +6 -1
- package/dist/mcp-server/harness.test-helpers.mjs +14 -2
- package/dist/mcp-server/server.mjs +5 -1
- package/dist/mcp-server/tools.mjs +12 -10
- package/dist/pnpm-install/pnpm-install-fake-pnpm.test-helpers.mjs +2 -2
- package/docs/mcp-server.md +58 -14
- package/package.json +1 -1
- package/skills/blackboard/SKILL.md +27 -17
- package/skills/manifest.json +27 -21
- package/skills/postgres-node-performance-tuning/references/performance-patterns.md +22 -0
- package/skills/postgres-partitioning-uuid-v7/SKILL.md +3 -0
- package/skills/postgres-schema-design/SKILL.md +21 -0
- package/skills/postgres-schema-design/references/naming.md +34 -0
- package/skills/postgres-schema-design/references/relations.md +46 -0
- package/skills/postgres-schema-design/references/table-shapes.md +56 -0
- package/skills/retrospective-distill/SKILL.md +13 -8
package/README.md
CHANGED
|
@@ -554,7 +554,10 @@ Identity, content, and archived-session conflicts fail in either mode while pres
|
|
|
554
554
|
record; they are not reported as transient pending delivery. Replay retains permanently conflicted
|
|
555
555
|
records and continues to deliver unrelated pending records; transient failures stop that replay.
|
|
556
556
|
Verified delivery retains its receipt when local cleanup fails, returning
|
|
557
|
-
`cleanupDiagnostic` with value `"outbox-cleanup-failed"`. A retained or crash-reappearing record is
|
|
557
|
+
`cleanupDiagnostic` with value `"outbox-cleanup-failed"`. A retained or crash-reappearing record is
|
|
558
|
+
safe to replay: an event is identified by session, `sourceEventId`, and content, and a duplicate that
|
|
559
|
+
differs only in `timestamp` is the same event, so it reports the stored record's `timestamp` and
|
|
560
|
+
receipt and the retained duplicate is removed.
|
|
558
561
|
Use `vouchington agent-blackboard journal flush --outbox-directory PATH` to replay unchanged pending
|
|
559
562
|
records after connectivity returns.
|
|
560
563
|
|
|
@@ -1 +1,8 @@
|
|
|
1
|
+
import type { FeedbackEnvelope } from './feedback-types.mts';
|
|
1
2
|
export declare function canonicalFeedback(value: unknown): string;
|
|
3
|
+
/**
|
|
4
|
+
* The identity of a feedback event is its session, `sourceEventId`, and content. The `timestamp` is
|
|
5
|
+
* assigned by the server on each attempt, so a retry that differs only in `timestamp` is the same
|
|
6
|
+
* event and is left out of the comparison.
|
|
7
|
+
*/
|
|
8
|
+
export declare function canonicalFeedbackEvent(envelope: FeedbackEnvelope): string;
|
|
@@ -2,6 +2,14 @@ import { isObject } from './snapshot-partition-guards.mjs';
|
|
|
2
2
|
export function canonicalFeedback(value) {
|
|
3
3
|
return JSON.stringify(ordered(value));
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* The identity of a feedback event is its session, `sourceEventId`, and content. The `timestamp` is
|
|
7
|
+
* assigned by the server on each attempt, so a retry that differs only in `timestamp` is the same
|
|
8
|
+
* event and is left out of the comparison.
|
|
9
|
+
*/
|
|
10
|
+
export function canonicalFeedbackEvent(envelope) {
|
|
11
|
+
return canonicalFeedback(Object.fromEntries(Object.entries(envelope).filter(([key]) => key !== 'timestamp')));
|
|
12
|
+
}
|
|
5
13
|
function ordered(value) {
|
|
6
14
|
if (Array.isArray(value))
|
|
7
15
|
return value.map(ordered);
|
|
@@ -18,13 +18,14 @@ export async function writeFeedback(input) {
|
|
|
18
18
|
}
|
|
19
19
|
if (!input.outboxDirectory)
|
|
20
20
|
throw new Error('interactive feedback requires an explicit durable outbox directory');
|
|
21
|
-
|
|
21
|
+
// A retained record for the same event keeps its own timestamp and is the one delivered.
|
|
22
|
+
const record = persistFeedbackOutbox(input.outboxDirectory, {
|
|
23
|
+
identity: input.identity,
|
|
24
|
+
envelope,
|
|
25
|
+
});
|
|
22
26
|
try {
|
|
23
|
-
const receipt = await deliverFeedbackOnline({ ...input, envelope });
|
|
24
|
-
const cleanup = removeFeedbackOutbox(input.outboxDirectory,
|
|
25
|
-
identity: input.identity,
|
|
26
|
-
envelope,
|
|
27
|
-
});
|
|
27
|
+
const receipt = await deliverFeedbackOnline({ ...input, envelope: record.envelope });
|
|
28
|
+
const cleanup = removeFeedbackOutbox(input.outboxDirectory, record);
|
|
28
29
|
return { status: 'delivered', sourceEventId: envelope.sourceEventId, ...cleanup, receipt };
|
|
29
30
|
}
|
|
30
31
|
catch (error) {
|
|
@@ -34,6 +35,7 @@ export async function writeFeedback(input) {
|
|
|
34
35
|
return {
|
|
35
36
|
status: 'pending',
|
|
36
37
|
sourceEventId: envelope.sourceEventId,
|
|
38
|
+
timestamp: record.envelope.timestamp,
|
|
37
39
|
pendingCount: feedbackOutboxStatus(input.outboxDirectory).pendingCount,
|
|
38
40
|
diagnostic,
|
|
39
41
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { feedbackDeadline } from './feedback-deadline.mjs';
|
|
2
2
|
import { FeedbackDeliveryError, feedbackDiagnostic } from './feedback-online-error.mjs';
|
|
3
|
-
import { canonicalFeedback } from './feedback-canonical.mjs';
|
|
3
|
+
import { canonicalFeedback, canonicalFeedbackEvent } from './feedback-canonical.mjs';
|
|
4
4
|
import { loadClient, resolveBlackboardConnection } from './client.mjs';
|
|
5
5
|
import { normalizeRepositories, validateFeedbackEnvelope } from './feedback-codec.mjs';
|
|
6
6
|
import { validateFeedbackIdentity } from './feedback-identity.mjs';
|
|
@@ -48,13 +48,18 @@ async function findEvent(entries, envelope, expectedCreatedAt) {
|
|
|
48
48
|
catch {
|
|
49
49
|
throw new FeedbackDeliveryError('event-conflict');
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
// Same event means same content; the stored `timestamp` may differ from this attempt's.
|
|
52
|
+
if (canonicalFeedbackEvent(entry.data) !== canonicalFeedbackEvent(envelope) ||
|
|
52
53
|
typeof entry.createdAt !== 'string')
|
|
53
54
|
throw new FeedbackDeliveryError('event-conflict');
|
|
54
55
|
if (++matching > 1 && expectedCreatedAt !== undefined)
|
|
55
56
|
throw new FeedbackDeliveryError('event-conflict');
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
// Duplicates of one event (a late write beside a retry) report the earliest stored record, so
|
|
58
|
+
// every retry returns the same timestamp.
|
|
59
|
+
if (expectedCreatedAt === undefined
|
|
60
|
+
? found === undefined || entry.createdAt < found.createdAt
|
|
61
|
+
: entry.createdAt === expectedCreatedAt)
|
|
62
|
+
found = { createdAt: entry.createdAt, timestamp: entry.data.timestamp };
|
|
58
63
|
}
|
|
59
64
|
return found;
|
|
60
65
|
}
|
|
@@ -108,6 +113,7 @@ export async function deliverFeedbackOnline(input, fresh = false) {
|
|
|
108
113
|
sessionId: input.identity.sessionId,
|
|
109
114
|
sourceEventId: input.envelope.sourceEventId,
|
|
110
115
|
createdAt: existing.createdAt,
|
|
116
|
+
timestamp: existing.timestamp,
|
|
111
117
|
verified: true,
|
|
112
118
|
};
|
|
113
119
|
const appended = await entries.append({
|
|
@@ -127,6 +133,7 @@ export async function deliverFeedbackOnline(input, fresh = false) {
|
|
|
127
133
|
sessionId: input.identity.sessionId,
|
|
128
134
|
sourceEventId: input.envelope.sourceEventId,
|
|
129
135
|
createdAt: confirmed.createdAt,
|
|
136
|
+
timestamp: confirmed.timestamp,
|
|
130
137
|
verified: true,
|
|
131
138
|
};
|
|
132
139
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { FeedbackOutboxRecord, FeedbackOutboxStatus } from './feedback-types.mts';
|
|
2
2
|
export declare function feedbackOutboxStatus(directory: string): FeedbackOutboxStatus;
|
|
3
3
|
export declare function listFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
|
|
4
|
-
|
|
4
|
+
/** Reads the retained records without creating the outbox directory. */
|
|
5
|
+
export declare function readFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
|
|
6
|
+
/**
|
|
7
|
+
* Retains the record unless the same event is already retained, in which case that record stays:
|
|
8
|
+
* the event is the session, `sourceEventId`, and content, and the earlier `timestamp` wins. The
|
|
9
|
+
* returned record is the one to deliver.
|
|
10
|
+
*/
|
|
11
|
+
export declare function persistFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): FeedbackOutboxRecord;
|
|
5
12
|
export declare function removeFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): {
|
|
6
13
|
pendingCount: number;
|
|
7
14
|
cleanupDiagnostic?: 'outbox-cleanup-failed';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { canonicalFeedback } from './feedback-canonical.mjs';
|
|
1
|
+
import { canonicalFeedback, canonicalFeedbackEvent } from './feedback-canonical.mjs';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { closeSync, constants, fsyncSync, fstatSync, openSync, readdirSync, renameSync, unlinkSync, } from 'node:fs';
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
@@ -83,6 +83,18 @@ export function listFeedbackOutbox(directory) {
|
|
|
83
83
|
const path = outboxDirectory(directory, true);
|
|
84
84
|
return withFileLock(join(path, '.records'), () => records(path).map((item) => item.record));
|
|
85
85
|
}
|
|
86
|
+
/** Reads the retained records without creating the outbox directory. */
|
|
87
|
+
export function readFeedbackOutbox(directory) {
|
|
88
|
+
const path = outboxDirectory(directory, false);
|
|
89
|
+
if (!ensurePrivateDirectory(path, false))
|
|
90
|
+
return [];
|
|
91
|
+
return withFileLock(join(path, '.records'), () => records(path).map((item) => item.record));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Retains the record unless the same event is already retained, in which case that record stays:
|
|
95
|
+
* the event is the session, `sourceEventId`, and content, and the earlier `timestamp` wins. The
|
|
96
|
+
* returned record is the one to deliver.
|
|
97
|
+
*/
|
|
86
98
|
export function persistFeedbackOutbox(directory, record) {
|
|
87
99
|
validateFeedbackIdentity(record.identity);
|
|
88
100
|
validateFeedbackEnvelope(record.envelope);
|
|
@@ -93,9 +105,10 @@ export function persistFeedbackOutbox(directory, record) {
|
|
|
93
105
|
const existing = current.find((item) => item.path === join(path, name));
|
|
94
106
|
const serialized = JSON.stringify(record);
|
|
95
107
|
if (existing) {
|
|
96
|
-
if (canonicalFeedback(existing.record) !== canonicalFeedback(record)
|
|
108
|
+
if (canonicalFeedback(existing.record.identity) !== canonicalFeedback(record.identity) ||
|
|
109
|
+
canonicalFeedbackEvent(existing.record.envelope) !== canonicalFeedbackEvent(record.envelope))
|
|
97
110
|
throw new Error('feedback source event conflicts with a retained unsent record');
|
|
98
|
-
return
|
|
111
|
+
return existing.record;
|
|
99
112
|
}
|
|
100
113
|
const bytes = Buffer.byteLength(serialized);
|
|
101
114
|
if (bytes > RECORD_MAX_BYTES ||
|
|
@@ -128,7 +141,7 @@ export function persistFeedbackOutbox(directory, record) {
|
|
|
128
141
|
catch { }
|
|
129
142
|
throw error;
|
|
130
143
|
}
|
|
131
|
-
return
|
|
144
|
+
return record;
|
|
132
145
|
});
|
|
133
146
|
}
|
|
134
147
|
export function removeFeedbackOutbox(directory, record) {
|
|
@@ -31,6 +31,8 @@ export type FeedbackReceipt = {
|
|
|
31
31
|
sessionId: string;
|
|
32
32
|
sourceEventId: string;
|
|
33
33
|
createdAt: string;
|
|
34
|
+
/** The `timestamp` of the stored entry, which may be earlier than the attempt that confirmed it. */
|
|
35
|
+
timestamp: string;
|
|
34
36
|
verified: true;
|
|
35
37
|
};
|
|
36
38
|
export type FeedbackDiagnostic = 'blackboard-unavailable' | 'client-unavailable' | 'configuration-invalid' | 'authentication-rejected' | 'identity-conflict' | 'readback-unconfirmed' | 'event-conflict' | 'archived-session' | 'delivery-timeout';
|
|
@@ -43,6 +45,8 @@ export type FeedbackDeliveryResult = {
|
|
|
43
45
|
} | {
|
|
44
46
|
status: 'pending';
|
|
45
47
|
sourceEventId: string;
|
|
48
|
+
/** The `timestamp` of the retained record that a later flush delivers. */
|
|
49
|
+
timestamp: string;
|
|
46
50
|
pendingCount: number;
|
|
47
51
|
diagnostic: FeedbackDiagnostic;
|
|
48
52
|
};
|
|
@@ -19,7 +19,7 @@ export type { BlackboardConnection, BlackboardClientModule, BlackboardClientDepe
|
|
|
19
19
|
export type * from './feedback-types.mts';
|
|
20
20
|
export { createFeedbackEnvelope, validateFeedbackEnvelope } from './feedback-codec.mts';
|
|
21
21
|
export { writeFeedback, verifyFreshFeedback, flushFeedbackOutbox, FeedbackDeliveryError, } from './feedback-delivery.mts';
|
|
22
|
-
export { feedbackOutboxStatus } from './feedback-outbox.mts';
|
|
22
|
+
export { feedbackOutboxStatus, readFeedbackOutbox } from './feedback-outbox.mts';
|
|
23
23
|
export { composeRetrospective } from './feedback-compose.mts';
|
|
24
24
|
export type { RetrospectiveCompositionInput, FeedbackAssessment } from './feedback-compose.mts';
|
|
25
25
|
export type { JournalAuditOptions } from './feedback-journal-audit.mts';
|
|
@@ -61,5 +61,5 @@ export function formatJournalEntries(sessionId, entries) {
|
|
|
61
61
|
export { resolveBlackboardConnection } from './client.mjs';
|
|
62
62
|
export { createFeedbackEnvelope, validateFeedbackEnvelope } from './feedback-codec.mjs';
|
|
63
63
|
export { writeFeedback, verifyFreshFeedback, flushFeedbackOutbox, FeedbackDeliveryError, } from './feedback-delivery.mjs';
|
|
64
|
-
export { feedbackOutboxStatus } from './feedback-outbox.mjs';
|
|
64
|
+
export { feedbackOutboxStatus, readFeedbackOutbox } from './feedback-outbox.mjs';
|
|
65
65
|
export { composeRetrospective } from './feedback-compose.mjs';
|
|
@@ -2,7 +2,8 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { FeedbackDeliveryError } from '../agent-blackboard/index.mjs';
|
|
3
3
|
import { optionalString, rejectUnknown, requireObject } from './args.mjs';
|
|
4
4
|
import { readSessionId } from './context.mjs';
|
|
5
|
-
import { journalAppend, journalEntries
|
|
5
|
+
import { journalAppend, journalEntries } from './handlers-journal.mjs';
|
|
6
|
+
import { outboxFlush, outboxStatus } from './handlers-outbox.mjs';
|
|
6
7
|
import { sessionArchive, sessionEnsure, snapshotExport } from './handlers-session.mjs';
|
|
7
8
|
import { TOOLS } from './tools.mjs';
|
|
8
9
|
import { resolveWorktree } from './worktree.mjs';
|
|
@@ -17,14 +18,14 @@ const HANDLERS = {
|
|
|
17
18
|
};
|
|
18
19
|
const DIAGNOSTIC_HINTS = {
|
|
19
20
|
'identity-conflict': 'the session already exists with a different parent, agent, or version',
|
|
20
|
-
'event-conflict': 'sourceEventId was already used with different content
|
|
21
|
+
'event-conflict': 'sourceEventId was already used with different content; use a new sourceEventId',
|
|
21
22
|
'archived-session': 'the session is archived; write to a new session',
|
|
22
23
|
'authentication-rejected': 'the blackboard rejected AGENT_BLACKBOARD_TOKEN',
|
|
23
24
|
'blackboard-unavailable': 'the blackboard could not be reached or returned an error',
|
|
24
25
|
'configuration-invalid': 'AGENT_BLACKBOARD_URL or AGENT_BLACKBOARD_TOKEN is missing or invalid',
|
|
25
26
|
'client-unavailable': 'agent-blackboard is not installed in the worktree',
|
|
26
|
-
'readback-unconfirmed': 'the write could not be confirmed; retry
|
|
27
|
-
'delivery-timeout': 'delivery timed out; retry
|
|
27
|
+
'readback-unconfirmed': 'the write could not be confirmed; retry the same call (same sourceEventId and content)',
|
|
28
|
+
'delivery-timeout': 'delivery timed out; retry the same call (same sourceEventId and content)',
|
|
28
29
|
};
|
|
29
30
|
function describeError(error) {
|
|
30
31
|
if (error instanceof FeedbackDeliveryError)
|
|
@@ -22,6 +22,8 @@ export type FakeOptions = {
|
|
|
22
22
|
};
|
|
23
23
|
entries?: StoredEntry[];
|
|
24
24
|
entriesError?: unknown;
|
|
25
|
+
/** An entry another caller writes just before the next append, after that append's read. */
|
|
26
|
+
lateEntry?: StoredEntry;
|
|
25
27
|
};
|
|
26
28
|
export type FakeBlackboard = {
|
|
27
29
|
dependencies: BlackboardClientDependencies;
|
|
@@ -29,6 +31,7 @@ export type FakeBlackboard = {
|
|
|
29
31
|
ensure: unknown[];
|
|
30
32
|
patch: unknown[];
|
|
31
33
|
append: unknown[];
|
|
34
|
+
get: unknown[];
|
|
32
35
|
archive: string[];
|
|
33
36
|
export: unknown[];
|
|
34
37
|
};
|
|
@@ -15,6 +15,7 @@ export function fakeBlackboard(options = {}) {
|
|
|
15
15
|
ensure: [],
|
|
16
16
|
patch: [],
|
|
17
17
|
append: [],
|
|
18
|
+
get: [],
|
|
18
19
|
archive: [],
|
|
19
20
|
export: [],
|
|
20
21
|
};
|
|
@@ -44,9 +45,13 @@ export function fakeBlackboard(options = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
if (options.omitArchive)
|
|
46
47
|
delete Sessions.prototype.archive;
|
|
48
|
+
let lateEntry = options.lateEntry;
|
|
47
49
|
class Entries {
|
|
48
50
|
async append(input) {
|
|
49
51
|
calls.append.push(input);
|
|
52
|
+
if (lateEntry)
|
|
53
|
+
stored.push(lateEntry);
|
|
54
|
+
lateEntry = undefined;
|
|
50
55
|
const entry = {
|
|
51
56
|
createdAt: `2026-01-01T00:00:${String(stored.length).padStart(2, '0')}.000Z`,
|
|
52
57
|
data: input.data,
|
|
@@ -54,7 +59,8 @@ export function fakeBlackboard(options = {}) {
|
|
|
54
59
|
stored.push(entry);
|
|
55
60
|
return entry;
|
|
56
61
|
}
|
|
57
|
-
async *get() {
|
|
62
|
+
async *get(input) {
|
|
63
|
+
calls.get.push(input);
|
|
58
64
|
if (options.entriesError !== undefined)
|
|
59
65
|
throw options.entriesError;
|
|
60
66
|
yield* stored;
|
|
@@ -2,5 +2,3 @@ import { type ToolHandler } from './context.mts';
|
|
|
2
2
|
export declare const journalAppend: ToolHandler;
|
|
3
3
|
/** Every entry as the client returns it, oldest first. Nothing is filtered or rewritten. */
|
|
4
4
|
export declare const journalEntries: ToolHandler;
|
|
5
|
-
export declare const outboxStatus: ToolHandler;
|
|
6
|
-
export declare const outboxFlush: ToolHandler;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { appendJournalMarkdown,
|
|
1
|
+
import { appendJournalMarkdown, readJournal, } from '../agent-blackboard/index.mjs';
|
|
2
2
|
import { optionalRecord, optionalString, requiredChoice, requiredString, requiredStringArray, rejectUnknown, } from './args.mjs';
|
|
3
3
|
import { outboxDirectory, readIdentity } from './context.mjs';
|
|
4
|
+
import { outboxCounts } from './handlers-outbox.mjs';
|
|
4
5
|
const MODES = ['interactive', 'autonomous'];
|
|
5
6
|
const OUTCOMES = [
|
|
6
7
|
'in-progress',
|
|
@@ -39,26 +40,35 @@ function readCoverage(args) {
|
|
|
39
40
|
export const journalAppend = async (args, context) => {
|
|
40
41
|
const identity = readIdentity(args, context.sessionId);
|
|
41
42
|
const mode = requiredChoice(args, 'mode', MODES);
|
|
42
|
-
|
|
43
|
-
// default here and returning it lets the caller do that.
|
|
44
|
-
const timestamp = optionalString(args, 'timestamp') ?? new Date().toISOString();
|
|
43
|
+
const sourceEventId = requiredString(args, 'sourceEventId');
|
|
45
44
|
const category = optionalString(args, 'category');
|
|
46
|
-
const
|
|
47
|
-
...identity,
|
|
48
|
-
mode,
|
|
49
|
-
// Interactive delivery needs a durable outbox; autonomous delivery forbids one.
|
|
50
|
-
...(mode === 'interactive' ? { outboxDirectory: outboxDirectory(context.worktree) } : {}),
|
|
45
|
+
const entry = {
|
|
51
46
|
markdown: requiredString(args, 'markdown'),
|
|
52
|
-
sourceEventId: requiredString(args, 'sourceEventId'),
|
|
53
47
|
workOutcome: requiredChoice(args, 'workOutcome', OUTCOMES),
|
|
54
48
|
repositories: requiredStringArray(args, 'repositories'),
|
|
55
49
|
feedbackCoverage: readCoverage(args),
|
|
56
|
-
|
|
50
|
+
};
|
|
51
|
+
// Interactive delivery needs a durable outbox; autonomous delivery forbids one.
|
|
52
|
+
const directory = mode === 'interactive' ? outboxDirectory(context.worktree) : undefined;
|
|
53
|
+
const result = await appendJournalMarkdown({
|
|
54
|
+
...identity,
|
|
55
|
+
...entry,
|
|
56
|
+
mode,
|
|
57
|
+
sourceEventId,
|
|
58
|
+
...(directory === undefined ? {} : { outboxDirectory: directory }),
|
|
57
59
|
...(category === undefined ? {} : { category }),
|
|
58
60
|
env: context.env,
|
|
59
61
|
dependencies: context.dependencies,
|
|
60
62
|
});
|
|
61
|
-
|
|
63
|
+
// The server owns the timestamp: a first append takes the current time, and a retry of the same
|
|
64
|
+
// event (same session, sourceEventId, and content) reports the one already stored or retained.
|
|
65
|
+
const timestamp = result.status === 'delivered' ? result.receipt.timestamp : result.timestamp;
|
|
66
|
+
// Autonomous delivery never uses an outbox, so it has nothing pending. Interactive delivery
|
|
67
|
+
// reports the outbox as it is after delivery, split into the caller's session and the worktree.
|
|
68
|
+
const counts = mode === 'interactive'
|
|
69
|
+
? outboxCounts(context.worktree, context.sessionId)
|
|
70
|
+
: { pendingCount: 0, worktreePendingCount: 0 };
|
|
71
|
+
return { sessionId: context.sessionId, ...result, timestamp, ...counts };
|
|
62
72
|
};
|
|
63
73
|
// `Entries.get` documents no order, and `createdAt` is a service-generated ISO 8601 UTC time whose
|
|
64
74
|
// text order is time order. An entry without a usable `createdAt` sorts first rather than throwing.
|
|
@@ -78,21 +88,3 @@ export const journalEntries = async (_args, context) => ({
|
|
|
78
88
|
sessionId: context.sessionId,
|
|
79
89
|
entries: (await readJournal(context.sessionId, context.env, context.dependencies)).toSorted(byCreatedAt),
|
|
80
90
|
});
|
|
81
|
-
export const outboxStatus = async (_args, context) => ({
|
|
82
|
-
sessionId: context.sessionId,
|
|
83
|
-
...feedbackOutboxStatus(outboxDirectory(context.worktree)),
|
|
84
|
-
});
|
|
85
|
-
export const outboxFlush = async (_args, context) => {
|
|
86
|
-
const directory = outboxDirectory(context.worktree);
|
|
87
|
-
// Flushing creates the outbox directory, so an empty or absent outbox is reported as is.
|
|
88
|
-
if (feedbackOutboxStatus(directory).pendingCount === 0)
|
|
89
|
-
return { sessionId: context.sessionId, status: 'empty', pendingCount: 0, deliveredCount: 0 };
|
|
90
|
-
return {
|
|
91
|
-
sessionId: context.sessionId,
|
|
92
|
-
...(await flushFeedbackOutbox({
|
|
93
|
-
directory,
|
|
94
|
-
env: context.env,
|
|
95
|
-
dependencies: context.dependencies,
|
|
96
|
-
})),
|
|
97
|
-
};
|
|
98
|
-
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ToolHandler } from './context.mts';
|
|
2
|
+
/**
|
|
3
|
+
* The outbox belongs to the worktree, not to one session, so two counts are reported. `pendingCount`
|
|
4
|
+
* is the caller's session only; `worktreePendingCount` is every session's records.
|
|
5
|
+
*/
|
|
6
|
+
export declare function outboxCounts(worktree: string, sessionId: string): {
|
|
7
|
+
pendingCount: number;
|
|
8
|
+
worktreePendingCount: number;
|
|
9
|
+
};
|
|
10
|
+
export declare const outboxStatus: ToolHandler;
|
|
11
|
+
export declare const outboxFlush: ToolHandler;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { flushFeedbackOutbox, readFeedbackOutbox } from '../agent-blackboard/index.mjs';
|
|
2
|
+
import { outboxDirectory } from './context.mjs';
|
|
3
|
+
/**
|
|
4
|
+
* The outbox belongs to the worktree, not to one session, so two counts are reported. `pendingCount`
|
|
5
|
+
* is the caller's session only; `worktreePendingCount` is every session's records.
|
|
6
|
+
*/
|
|
7
|
+
export function outboxCounts(worktree, sessionId) {
|
|
8
|
+
// A read that never creates the directory, so asking about an empty outbox leaves no trace.
|
|
9
|
+
const records = readFeedbackOutbox(outboxDirectory(worktree));
|
|
10
|
+
return {
|
|
11
|
+
pendingCount: records.filter((record) => record.identity.sessionId === sessionId).length,
|
|
12
|
+
worktreePendingCount: records.length,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function sessionStatus(pendingCount) {
|
|
16
|
+
return pendingCount === 0 ? 'empty' : 'pending';
|
|
17
|
+
}
|
|
18
|
+
export const outboxStatus = async (_args, context) => {
|
|
19
|
+
const counts = outboxCounts(context.worktree, context.sessionId);
|
|
20
|
+
return { sessionId: context.sessionId, status: sessionStatus(counts.pendingCount), ...counts };
|
|
21
|
+
};
|
|
22
|
+
export const outboxFlush = async (_args, context) => {
|
|
23
|
+
// Flushing creates the outbox directory, so an empty or absent outbox is reported as is.
|
|
24
|
+
if (outboxCounts(context.worktree, context.sessionId).worktreePendingCount === 0)
|
|
25
|
+
return {
|
|
26
|
+
sessionId: context.sessionId,
|
|
27
|
+
status: 'empty',
|
|
28
|
+
pendingCount: 0,
|
|
29
|
+
worktreePendingCount: 0,
|
|
30
|
+
deliveredCount: 0,
|
|
31
|
+
};
|
|
32
|
+
// The flush delivers every session's records; only the counts below are per session.
|
|
33
|
+
const flushed = await flushFeedbackOutbox({
|
|
34
|
+
directory: outboxDirectory(context.worktree),
|
|
35
|
+
env: context.env,
|
|
36
|
+
dependencies: context.dependencies,
|
|
37
|
+
});
|
|
38
|
+
const counts = outboxCounts(context.worktree, context.sessionId);
|
|
39
|
+
return {
|
|
40
|
+
sessionId: context.sessionId,
|
|
41
|
+
...flushed,
|
|
42
|
+
status: sessionStatus(counts.pendingCount),
|
|
43
|
+
...counts,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -29,5 +29,10 @@ export declare const JOURNAL_ARGS: {
|
|
|
29
29
|
sources: string[];
|
|
30
30
|
droppedCount: number;
|
|
31
31
|
};
|
|
32
|
-
|
|
32
|
+
};
|
|
33
|
+
/** The time `useFixedClock` pins, which is the timestamp the server mints for a first append. */
|
|
34
|
+
export declare const NOW = "2026-01-01T00:00:00.000Z";
|
|
35
|
+
/** Pins `Date` only, so timers and child processes keep running. */
|
|
36
|
+
export declare function useFixedClock(): {
|
|
37
|
+
advance: (milliseconds: number) => void;
|
|
33
38
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { afterEach, beforeEach } from 'vitest';
|
|
2
|
+
import { afterEach, beforeEach, vi } from 'vitest';
|
|
3
3
|
import { callTool } from './dispatch.mjs';
|
|
4
4
|
import { BLACKBOARD_ENV, fakeBlackboard, } from './fake-blackboard.test-helpers.mjs';
|
|
5
5
|
import { createRepoFixture } from './git-fixture.test-helpers.mjs';
|
|
@@ -44,5 +44,17 @@ export const JOURNAL_ARGS = {
|
|
|
44
44
|
workOutcome: 'success',
|
|
45
45
|
repositories: ['owner/repo'],
|
|
46
46
|
feedbackCoverage: { status: 'complete', sources: ['tool-result'], droppedCount: 0 },
|
|
47
|
-
timestamp: '2026-01-01T00:00:00.000Z',
|
|
48
47
|
};
|
|
48
|
+
/** The time `useFixedClock` pins, which is the timestamp the server mints for a first append. */
|
|
49
|
+
export const NOW = '2026-01-01T00:00:00.000Z';
|
|
50
|
+
/** Pins `Date` only, so timers and child processes keep running. */
|
|
51
|
+
export function useFixedClock() {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
54
|
+
vi.setSystemTime(new Date(NOW));
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
});
|
|
59
|
+
return { advance: (milliseconds) => vi.setSystemTime(new Date(Date.now() + milliseconds)) };
|
|
60
|
+
}
|
|
@@ -3,7 +3,11 @@ import { TOOLS } from './tools.mjs';
|
|
|
3
3
|
export const SERVER_NAME = 'vouchington-tooling';
|
|
4
4
|
const INSTRUCTIONS = 'Journals agent-blackboard feedback for a vouchington consumer. Every tool takes an explicit ' +
|
|
5
5
|
'sessionId and an optional worktree (an absolute path listed by `git worktree list` for the ' +
|
|
6
|
-
'repository this server was launched from).
|
|
6
|
+
'repository this server was launched from). Hosts prefix the tool names differently: Claude ' +
|
|
7
|
+
'Code mcp__vouchington-tooling__<tool>, Codex mcp__vouchington_tooling__<tool>, Grok ' +
|
|
8
|
+
'vouchington-tooling__<tool> (through search_tool and use_tool), Cursor the vouchington-tooling ' +
|
|
9
|
+
'namespace (through GetDynamicTools and CallDynamicTool). Search for journal_append before ' +
|
|
10
|
+
'concluding this server is unavailable. Tool errors are actionable; do not fall back to a CLI.';
|
|
7
11
|
/**
|
|
8
12
|
* Builds the server. The tool list and call handlers go on the low-level `server` because the
|
|
9
13
|
* high-level `registerTool` path needs zod schemas, and this package keeps zod out of its own code.
|
|
@@ -59,7 +59,13 @@ const IDENTITY_REQUIRED = ['parentSessionId', 'agent', 'version'];
|
|
|
59
59
|
export const TOOLS = [
|
|
60
60
|
tool('journal_append', 'Appends one journal entry to the caller session and returns the verified read-back receipt. ' +
|
|
61
61
|
'Nothing is written unless every required field validates. Interactive mode retains the ' +
|
|
62
|
-
'envelope in the worktree outbox when the blackboard is unreachable.'
|
|
62
|
+
'envelope in the worktree outbox when the blackboard is unreachable. The server owns the ' +
|
|
63
|
+
'entry timestamp and returns it. An event is identified by sessionId, sourceEventId, and ' +
|
|
64
|
+
'content, never by timestamp: to retry, repeat the identical call, and a retry of an ' +
|
|
65
|
+
'event already stored or retained reports the stored timestamp instead of a new one. The ' +
|
|
66
|
+
'same sourceEventId with different content is an event-conflict. pendingCount counts ' +
|
|
67
|
+
'unsent records of the caller sessionId; worktreePendingCount counts those of every ' +
|
|
68
|
+
'session in the worktree outbox (both 0 in autonomous mode, which never uses an outbox).', ADDITIVE, {
|
|
63
69
|
...IDENTITY,
|
|
64
70
|
mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
|
|
65
71
|
markdown: { type: 'string', description: 'Journal note as markdown text (max 12000 bytes).' },
|
|
@@ -82,11 +88,6 @@ export const TOOLS = [
|
|
|
82
88
|
additionalProperties: false,
|
|
83
89
|
description: 'Explicit coverage; sources default to [] and droppedCount to 0.',
|
|
84
90
|
},
|
|
85
|
-
timestamp: {
|
|
86
|
-
type: 'string',
|
|
87
|
-
description: 'Optional ISO 8601 time; defaults to now and is returned. Reuse the same sourceEventId ' +
|
|
88
|
-
'and timestamp when retrying, because the whole envelope must match.',
|
|
89
|
-
},
|
|
90
91
|
category: { type: 'string', description: 'Optional context; never changes entry type.' },
|
|
91
92
|
}, [
|
|
92
93
|
...IDENTITY_REQUIRED,
|
|
@@ -100,10 +101,11 @@ export const TOOLS = [
|
|
|
100
101
|
tool('journal_entries', 'Returns every entry of one session, oldest first, as JSON: { sessionId, entries }. Each ' +
|
|
101
102
|
'entry is exactly as the blackboard returns it ({ sessionId, createdAt, data }), including ' +
|
|
102
103
|
'non-journal types such as retrospective and legacy entries without a feedback envelope.', READ_ONLY),
|
|
103
|
-
tool('outbox_status', 'Reports
|
|
104
|
-
'the caller;
|
|
105
|
-
tool('outbox_flush', 'Delivers retained
|
|
106
|
-
'
|
|
104
|
+
tool('outbox_status', 'Reports unsent journal records in the worktree outbox. pendingCount and status (empty or ' +
|
|
105
|
+
'pending) cover the caller sessionId only; worktreePendingCount covers every session.', READ_ONLY),
|
|
106
|
+
tool('outbox_flush', 'Delivers every retained record in the worktree outbox, whichever session wrote it, and ' +
|
|
107
|
+
'returns deliveredCount for the whole flush. pendingCount and status cover the remaining ' +
|
|
108
|
+
'records of the caller sessionId; worktreePendingCount covers every session.', IDEMPOTENT_ADDITIVE),
|
|
107
109
|
tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
|
|
108
110
|
'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
|
|
109
111
|
tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
|
|
@@ -7,7 +7,7 @@ if [ "\${1:-}" = m ]; then
|
|
|
7
7
|
exit 0
|
|
8
8
|
fi
|
|
9
9
|
if [ "\${1:-}" = --version ]; then
|
|
10
|
-
printf '%s\\n' "\${PNPM_VERSION:-
|
|
10
|
+
printf '%s\\n' "\${PNPM_VERSION:-1.0.0}"
|
|
11
11
|
exit 0
|
|
12
12
|
fi
|
|
13
13
|
printf '%s\\n' "$*" >> "$PNPM_LOG"
|
|
@@ -23,7 +23,7 @@ write_modules() {
|
|
|
23
23
|
print_release_age_violation() {
|
|
24
24
|
printf '%s\\n' '✗ Lockfile failed supply-chain policy check (1 entries in 0.1s)'
|
|
25
25
|
printf '%s\\n' '[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:'
|
|
26
|
-
printf '%s\\n' '
|
|
26
|
+
printf '%s\\n' ' fixture-package@1.0.0 was published at 2026-08-03T15:06:33.000Z, within the minimumReleaseAge cutoff (2026-08-02T04:48:10.357Z)'
|
|
27
27
|
}
|
|
28
28
|
if [ "\${PNPM_FAIL_CALL:-0}" = "$calls" ]; then
|
|
29
29
|
if [ -n "\${PNPM_FAIL_RELEASE_AGE:-}" ]; then print_release_age_violation; fi
|
package/docs/mcp-server.md
CHANGED
|
@@ -42,22 +42,36 @@ Every tool takes an explicit `sessionId` (`^[A-Za-z0-9._:-]+$`, at most 256 char
|
|
|
42
42
|
server never infers, and an optional `worktree`. Unknown arguments are rejected. Failures come back
|
|
43
43
|
as MCP tool errors (`isError: true`) with an actionable message rather than crashing the server.
|
|
44
44
|
|
|
45
|
-
| Tool | Arguments besides `sessionId` and `worktree`
|
|
46
|
-
| ----------------- |
|
|
47
|
-
| `journal_append` | `parentSessionId`, `agent`, `version`, `mode` (`interactive` or `autonomous`), `markdown`, `sourceEventId`, `workOutcome`, `repositories`, `feedbackCoverage`, optional `
|
|
48
|
-
| `journal_entries` | none
|
|
49
|
-
| `outbox_status` | none
|
|
50
|
-
| `outbox_flush` | none
|
|
51
|
-
| `session_ensure` | `parentSessionId`, `agent`, `version`
|
|
52
|
-
| `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours`
|
|
53
|
-
| `session_archive` | none
|
|
45
|
+
| Tool | Arguments besides `sessionId` and `worktree` | Returns |
|
|
46
|
+
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
47
|
+
| `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` |
|
|
48
|
+
| `journal_entries` | none | `sessionId` and `entries`, every entry oldest first |
|
|
49
|
+
| `outbox_status` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount` |
|
|
50
|
+
| `outbox_flush` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `deliveredCount` |
|
|
51
|
+
| `session_ensure` | `parentSessionId`, `agent`, `version` | `sessionId`, `status` (`created` or `exists`), `archived` |
|
|
52
|
+
| `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
|
|
53
|
+
| `session_archive` | none | `sessionId`, `archived: true` |
|
|
54
54
|
|
|
55
55
|
`journal_append` validates the whole feedback envelope with `validateFeedbackEnvelope` before
|
|
56
56
|
anything is written, so a missing required field writes nothing. It returns the verified read-back
|
|
57
|
-
result of `writeFeedback`. Delivery is at least once.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
result of `writeFeedback`. Delivery is at least once. Markdown is limited to 12000 bytes.
|
|
58
|
+
|
|
59
|
+
The server owns the entry `timestamp`: the tool takes no `timestamp` argument and rejects one, so an
|
|
60
|
+
agent cannot fabricate it, and the result returns the timestamp it reports. An event is identified by
|
|
61
|
+
`sessionId`, `sourceEventId`, and content; the timestamp is not part of that identity. The server
|
|
62
|
+
stamps each attempt with the current time, and the delivery step compares the new attempt with what
|
|
63
|
+
the blackboard or the worktree outbox already holds while ignoring only `timestamp`. To retry, repeat
|
|
64
|
+
the identical call. A retry of an event that is already stored, or already retained in the outbox,
|
|
65
|
+
is the same event: it writes nothing, drops the duplicate retained record, and returns the stored
|
|
66
|
+
record's `timestamp` and `receipt`, so repeated retries report the same timestamp, including after a
|
|
67
|
+
server restart and when the provider was unreachable for the retry. An outbox record that was
|
|
68
|
+
delivered before the retry is recognized the same way when it is flushed, so it drains instead of
|
|
69
|
+
blocking. Any difference in the other envelope fields under the same `sessionId` and
|
|
70
|
+
`sourceEventId` is an `event-conflict` (or a conflict with the retained unsent record) and writes
|
|
71
|
+
nothing. If an autonomous write of the same event lands between the check and the append, both
|
|
72
|
+
entries exist, but the report names the earliest stored one and later retries report it again.
|
|
73
|
+
Admission freshness (`verifyFreshFeedback`) keeps its stricter rule: any earlier record of the event,
|
|
74
|
+
whatever its timestamp, is a conflict.
|
|
61
75
|
|
|
62
76
|
`journal_entries` returns `{ sessionId, entries }`. Each entry is exactly what the `agent-blackboard`
|
|
63
77
|
client returns (`sessionId`, `createdAt`, `data`), so `data` keeps its envelope fields such as
|
|
@@ -67,7 +81,13 @@ order, so they are sorted stably by `createdAt`, oldest first. An empty session
|
|
|
67
81
|
`entries: []`. `vouchington agent-blackboard journal entries` still prints journal markdown only.
|
|
68
82
|
|
|
69
83
|
`sessionId` on `outbox_status`, `outbox_flush`, and `snapshot_export` only identifies the caller.
|
|
70
|
-
The outbox is per worktree
|
|
84
|
+
The outbox is per worktree, so the results name two counts apart. `pendingCount` is the number of
|
|
85
|
+
unsent records of the caller's `sessionId`, and `status` (`empty` or `pending`) follows it.
|
|
86
|
+
`worktreePendingCount` is the number of unsent records of every session in the worktree outbox. Both
|
|
87
|
+
are 0 in autonomous mode, which has no outbox. `outbox_flush` delivers every retained record in the
|
|
88
|
+
worktree, whichever session wrote it, so its `deliveredCount` covers the whole flush while
|
|
89
|
+
`pendingCount` and `worktreePendingCount` report what remains. A record that cannot be delivered, for
|
|
90
|
+
example after an `event-conflict`, stays in the count of the session that wrote it. Flushing an empty
|
|
71
91
|
outbox does not create the directory. `snapshot_export` never accepts a destination: the
|
|
72
92
|
`agent-blackboard` client chooses a private file and returns its path. `session_archive` is the one
|
|
73
93
|
destructive tool; archived metadata is immutable, while entries stay appendable.
|
|
@@ -75,6 +95,22 @@ destructive tool; archived metadata is immutable, while entries stay appendable.
|
|
|
75
95
|
The outbox lives at `<worktree>/.local/blackboard-outbox` and is used only in interactive mode.
|
|
76
96
|
Autonomous mode forbids a filesystem outbox, so a failed delivery is an error.
|
|
77
97
|
|
|
98
|
+
### Tool names by harness
|
|
99
|
+
|
|
100
|
+
Each harness names the same tools differently, and some load them on demand. The names in this page
|
|
101
|
+
are the bare tool names; find yours by searching for `journal_append` before concluding the server
|
|
102
|
+
is unavailable.
|
|
103
|
+
|
|
104
|
+
| Harness | `journal_append` is called |
|
|
105
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
106
|
+
| Claude Code | `mcp__vouchington-tooling__journal_append` |
|
|
107
|
+
| Codex | `mcp__vouchington_tooling__journal_append` (the server name's hyphen becomes an underscore) |
|
|
108
|
+
| Cursor | deferred: look it up with `GetDynamicTools` and call it with `CallDynamicTool` under the `vouchington-tooling` namespace |
|
|
109
|
+
| Grok | `vouchington-tooling__journal_append`, found with `search_tool` and called with `use_tool` |
|
|
110
|
+
|
|
111
|
+
The server sends the same list in its MCP `instructions`, so a harness that surfaces server
|
|
112
|
+
instructions shows it to the agent.
|
|
113
|
+
|
|
78
114
|
## Worktree validation
|
|
79
115
|
|
|
80
116
|
`worktree` must be an absolute path that, after resolving symlinks, exactly matches an entry of
|
|
@@ -119,6 +155,11 @@ Approving every tool of the server at once:
|
|
|
119
155
|
- Cursor permission rule: `Mcp(vouchington-tooling:*)`
|
|
120
156
|
- Codex: `default_tools_approval_mode = "approve"` above
|
|
121
157
|
|
|
158
|
+
Cursor also asks each user to approve loading a project server, separately from the permission rule
|
|
159
|
+
above, and the rule does not replace it. Approve it once, interactively, with
|
|
160
|
+
`cursor-agent mcp enable vouchington-tooling`. Headless `cursor-agent -p` does not keep that
|
|
161
|
+
approval: pass `--approve-mcps` on every run.
|
|
162
|
+
|
|
122
163
|
## What is verified
|
|
123
164
|
|
|
124
165
|
Covered by this package's tests: the tool list and schemas, every tool's success and rejection
|
|
@@ -126,6 +167,9 @@ paths against a faked blackboard client, the worktree gate against real temporar
|
|
|
126
167
|
an in-process client and server round trip through the SDK's in-memory transport, and a spawned
|
|
127
168
|
`vouchington mcp` process driven over real stdio.
|
|
128
169
|
|
|
170
|
+
The per-harness tool names and the Cursor approval steps come from headless smoke runs of the
|
|
171
|
+
server in each harness, not from this package's tests.
|
|
172
|
+
|
|
129
173
|
Not verified, and tracked as the unchecked Phase 0 checklist in
|
|
130
174
|
[#324](https://github.com/vouchington/vouchington-tooling/issues/324): each harness's approval
|
|
131
175
|
syntax and whether the rules above take effect, the working directory after entering a worktree,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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": {
|
|
@@ -15,21 +15,29 @@ subagent-identity rules.
|
|
|
15
15
|
|
|
16
16
|
Journal operations are tools of the `vouchington-tooling` MCP server (`vouchington mcp`):
|
|
17
17
|
`journal_append`, `journal_entries`, `outbox_status`, `outbox_flush`, `session_ensure`,
|
|
18
|
-
`snapshot_export`, and `session_archive`.
|
|
18
|
+
`snapshot_export`, and `session_archive`. Harnesses name them differently, so match the bare tool
|
|
19
|
+
name: `mcp__vouchington-tooling__<tool>`, `mcp__vouchington_tooling__<tool>` (the hyphen becomes an
|
|
20
|
+
underscore), or `vouchington-tooling__<tool>` reached through a `search_tool` and `use_tool` pair,
|
|
21
|
+
or the `vouchington-tooling` namespace reached through `GetDynamicTools` and `CallDynamicTool`.
|
|
19
22
|
Every call takes an exact `sessionId`, never inferred, whose meaning depends on the tool:
|
|
20
23
|
|
|
21
24
|
- `journal_append` and `session_ensure`: the session written or ensured, which is the caller's own
|
|
22
25
|
session unless a runner ensures a child identity.
|
|
23
26
|
- `journal_entries` and `session_archive`: the session read or archived, which during distillation
|
|
24
27
|
belongs to another agent.
|
|
25
|
-
- `snapshot_export`, `outbox_status`, and `outbox_flush`: only the caller. The outbox
|
|
26
|
-
|
|
28
|
+
- `snapshot_export`, `outbox_status`, and `outbox_flush`: only the caller. The outbox is per
|
|
29
|
+
worktree, so `outbox_status` and `outbox_flush` report two counts: `pendingCount` is the unsent
|
|
30
|
+
records of that `sessionId`, and `worktreePendingCount` is the unsent records of every session in
|
|
31
|
+
the worktree outbox. `outbox_flush` delivers every retained record in the worktree.
|
|
27
32
|
|
|
28
33
|
Every call may take `worktree`, the absolute path of a worktree of the current repository (the
|
|
29
34
|
launch worktree by default). Writing or retrying an entry needs no provider skill, temporary file,
|
|
30
35
|
`--file` flag, or replay command.
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
Search for `journal_append` (by bare name, so every prefix matches) before concluding the server is
|
|
37
|
+
unavailable; a deferred harness lists the tool only on request. If the search finds no such tool
|
|
38
|
+
because the server is not registered, not connected, or not approved, stop and report that the
|
|
39
|
+
journal server is unavailable, and name any per-user approval the harness still needs to load
|
|
40
|
+
it (the consumer wrapper documents it). Do not fall back to a CLI command.
|
|
33
41
|
|
|
34
42
|
1. Capture consequential observations before filing an issue: failed or recovered checks, denied or
|
|
35
43
|
approved permissions, repeated fixes, scope changes, first-party tool behavior, and architectural
|
|
@@ -40,11 +48,10 @@ report that the journal server is unavailable. Do not fall back to a CLI command
|
|
|
40
48
|
2. Record each observation with one `journal_append` call and pass the note as its `markdown`
|
|
41
49
|
argument (at most 12000 bytes). Required arguments are `sessionId`, `parentSessionId` (null for a
|
|
42
50
|
root session), `agent`, `version`, `mode`, `markdown`, a stable `sourceEventId`, `workOutcome`,
|
|
43
|
-
canonical exact `repositories`, and explicit `feedbackCoverage`.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
never changes storage type or distillation eligibility. Generate routine facts and metadata
|
|
51
|
+
canonical exact `repositories`, and explicit `feedbackCoverage`. Never pass a `timestamp`: the
|
|
52
|
+
server owns it, rejects the argument, and returns the timestamp it used. Nothing is written
|
|
53
|
+
unless the whole envelope validates. The tool writes journal entries; `category` is optional
|
|
54
|
+
context and never changes storage type or distillation eligibility. Generate routine facts and metadata
|
|
48
55
|
through the approved composer and pass its output as `markdown` instead of hand-authoring
|
|
49
56
|
factual markers. `journal_append` stores only journal entries: save a composed retrospective
|
|
50
57
|
through the retrospective skill's writer, never through `journal_append`.
|
|
@@ -55,10 +62,11 @@ report that the journal server is unavailable. Do not fall back to a CLI command
|
|
|
55
62
|
the exact identity only when a runner must confirm the session before work starts.
|
|
56
63
|
4. Select the repository's trusted `mode` (`interactive` or `autonomous`) explicitly. In interactive
|
|
57
64
|
mode an outage may retain the validated, sanitized record in the bounded durable outbox, and
|
|
58
|
-
`journal_append` reports it as pending. Report
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
`journal_append` reports it as pending. Report `pendingCount` (this session's unsent records),
|
|
66
|
+
and `worktreePendingCount` when other sessions' records are also unsent, from `journal_append`
|
|
67
|
+
or `outbox_status`. Deliver with `outbox_flush` once the provider is reachable, and continue
|
|
68
|
+
primary work only after persistence succeeds. A full, unsafe, or unwritable outbox blocks
|
|
69
|
+
capture; never evict or silently discard an unsent record.
|
|
62
70
|
5. Autonomous runners require online `session_ensure`, a fresh admission `journal_append`, and
|
|
63
71
|
readback before launching an attempt. Give each attempt's admission entry a `sourceEventId`
|
|
64
72
|
unique to that attempt: reusing an earlier one returns the earlier receipt, which does not
|
|
@@ -66,9 +74,11 @@ report that the journal server is unavailable. Do not fall back to a CLI command
|
|
|
66
74
|
Terminal reporting preserves work outcome separately from feedback coverage and delivery. A
|
|
67
75
|
filesystem outbox never grants admission or acknowledged completion.
|
|
68
76
|
6. Use at-least-once delivery with consumer deduplication by exact session/source identity. To
|
|
69
|
-
retry a pending or failed append,
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
retry a pending or failed append, repeat the identical `journal_append` call with the same
|
|
78
|
+
`sourceEventId` and content. The server's timestamp is not part of an event's identity, so a
|
|
79
|
+
retry of an event already stored or retained reports the stored timestamp, even after a restart
|
|
80
|
+
or an outage. The same `sourceEventId` with different content is rejected as a conflict, so use
|
|
81
|
+
a new `sourceEventId` for a changed note. A transport timeout can leave a late
|
|
72
82
|
durable write; pending or blocked is never an acknowledged receipt.
|
|
73
83
|
7. Read the session's entries oldest-first with `journal_entries` for retrospectives. It returns
|
|
74
84
|
every entry with its envelope, including its type and repository tags, so a retrospective can
|
package/skills/manifest.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
{
|
|
5
5
|
"name": "agent-workflow",
|
|
6
6
|
"plugin": "vouchington-workflow",
|
|
7
|
-
"pluginVersion": "0.
|
|
7
|
+
"pluginVersion": "0.11.0",
|
|
8
8
|
"path": "agent-workflow/SKILL.md",
|
|
9
9
|
"prerequisites": ["github-issue", "pr-description", "review-ci-logs"]
|
|
10
10
|
},
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
{
|
|
19
19
|
"name": "blackboard",
|
|
20
20
|
"plugin": "vouchington-workflow",
|
|
21
|
-
"pluginVersion": "0.
|
|
21
|
+
"pluginVersion": "0.11.0",
|
|
22
22
|
"path": "blackboard/SKILL.md"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"name": "dependabot",
|
|
26
26
|
"plugin": "vouchington-workflow",
|
|
27
|
-
"pluginVersion": "0.
|
|
27
|
+
"pluginVersion": "0.11.0",
|
|
28
28
|
"path": "dependabot/SKILL.md",
|
|
29
29
|
"prerequisites": ["github-actions-checklist"]
|
|
30
30
|
},
|
|
@@ -38,26 +38,26 @@
|
|
|
38
38
|
{
|
|
39
39
|
"name": "git-commit-checklist",
|
|
40
40
|
"plugin": "vouchington-workflow",
|
|
41
|
-
"pluginVersion": "0.
|
|
41
|
+
"pluginVersion": "0.11.0",
|
|
42
42
|
"path": "git-commit-checklist/SKILL.md"
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"name": "github-actions-authoring",
|
|
46
46
|
"plugin": "vouchington-workflow",
|
|
47
|
-
"pluginVersion": "0.
|
|
47
|
+
"pluginVersion": "0.11.0",
|
|
48
48
|
"path": "github-actions-authoring/SKILL.md",
|
|
49
49
|
"prerequisites": ["github-actions-checklist"]
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"name": "github-actions-checklist",
|
|
53
53
|
"plugin": "vouchington-workflow",
|
|
54
|
-
"pluginVersion": "0.
|
|
54
|
+
"pluginVersion": "0.11.0",
|
|
55
55
|
"path": "github-actions-checklist/SKILL.md"
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"name": "github-issue",
|
|
59
59
|
"plugin": "vouchington-workflow",
|
|
60
|
-
"pluginVersion": "0.
|
|
60
|
+
"pluginVersion": "0.11.0",
|
|
61
61
|
"path": "github-issue/SKILL.md"
|
|
62
62
|
},
|
|
63
63
|
{
|
|
@@ -70,26 +70,26 @@
|
|
|
70
70
|
{
|
|
71
71
|
"name": "npm-publishing",
|
|
72
72
|
"plugin": "vouchington-workflow",
|
|
73
|
-
"pluginVersion": "0.
|
|
73
|
+
"pluginVersion": "0.11.0",
|
|
74
74
|
"path": "npm-publishing/SKILL.md"
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"name": "organize-github-issues",
|
|
78
78
|
"plugin": "vouchington-workflow",
|
|
79
|
-
"pluginVersion": "0.
|
|
79
|
+
"pluginVersion": "0.11.0",
|
|
80
80
|
"path": "organize-github-issues/SKILL.md",
|
|
81
81
|
"prerequisites": ["github-issue"]
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"name": "package-json-checklist",
|
|
85
85
|
"plugin": "vouchington-workflow",
|
|
86
|
-
"pluginVersion": "0.
|
|
86
|
+
"pluginVersion": "0.11.0",
|
|
87
87
|
"path": "package-json-checklist/SKILL.md"
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"name": "planning",
|
|
91
91
|
"plugin": "vouchington-workflow",
|
|
92
|
-
"pluginVersion": "0.
|
|
92
|
+
"pluginVersion": "0.11.0",
|
|
93
93
|
"path": "planning/SKILL.md",
|
|
94
94
|
"prerequisites": ["github-issue"]
|
|
95
95
|
},
|
|
@@ -103,65 +103,71 @@
|
|
|
103
103
|
{
|
|
104
104
|
"name": "postgres-node-performance-tuning",
|
|
105
105
|
"plugin": "vouchington-database",
|
|
106
|
-
"pluginVersion": "0.
|
|
106
|
+
"pluginVersion": "0.3.0",
|
|
107
107
|
"path": "postgres-node-performance-tuning/SKILL.md"
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
"name": "postgres-partitioning-uuid-v7",
|
|
111
111
|
"plugin": "vouchington-database",
|
|
112
|
-
"pluginVersion": "0.
|
|
112
|
+
"pluginVersion": "0.3.0",
|
|
113
113
|
"path": "postgres-partitioning-uuid-v7/SKILL.md"
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
"name": "postgres-schema-design",
|
|
117
|
+
"plugin": "vouchington-database",
|
|
118
|
+
"pluginVersion": "0.3.0",
|
|
119
|
+
"path": "postgres-schema-design/SKILL.md"
|
|
120
|
+
},
|
|
115
121
|
{
|
|
116
122
|
"name": "pr-description",
|
|
117
123
|
"plugin": "vouchington-workflow",
|
|
118
|
-
"pluginVersion": "0.
|
|
124
|
+
"pluginVersion": "0.11.0",
|
|
119
125
|
"path": "pr-description/SKILL.md"
|
|
120
126
|
},
|
|
121
127
|
{
|
|
122
128
|
"name": "retrospective",
|
|
123
129
|
"plugin": "vouchington-workflow",
|
|
124
|
-
"pluginVersion": "0.
|
|
130
|
+
"pluginVersion": "0.11.0",
|
|
125
131
|
"path": "retrospective/SKILL.md"
|
|
126
132
|
},
|
|
127
133
|
{
|
|
128
134
|
"name": "retrospective-distill",
|
|
129
135
|
"plugin": "vouchington-workflow",
|
|
130
|
-
"pluginVersion": "0.
|
|
136
|
+
"pluginVersion": "0.11.0",
|
|
131
137
|
"path": "retrospective-distill/SKILL.md",
|
|
132
138
|
"prerequisites": ["github-issue"]
|
|
133
139
|
},
|
|
134
140
|
{
|
|
135
141
|
"name": "review-ci-logs",
|
|
136
142
|
"plugin": "vouchington-workflow",
|
|
137
|
-
"pluginVersion": "0.
|
|
143
|
+
"pluginVersion": "0.11.0",
|
|
138
144
|
"path": "review-ci-logs/SKILL.md",
|
|
139
145
|
"prerequisites": ["github-actions-checklist", "pr-description"]
|
|
140
146
|
},
|
|
141
147
|
{
|
|
142
148
|
"name": "review-github-issue-taxonomy",
|
|
143
149
|
"plugin": "vouchington-workflow",
|
|
144
|
-
"pluginVersion": "0.
|
|
150
|
+
"pluginVersion": "0.11.0",
|
|
145
151
|
"path": "review-github-issue-taxonomy/SKILL.md",
|
|
146
152
|
"prerequisites": ["github-issue"]
|
|
147
153
|
},
|
|
148
154
|
{
|
|
149
155
|
"name": "revisit-followups",
|
|
150
156
|
"plugin": "vouchington-workflow",
|
|
151
|
-
"pluginVersion": "0.
|
|
157
|
+
"pluginVersion": "0.11.0",
|
|
152
158
|
"path": "revisit-followups/SKILL.md",
|
|
153
159
|
"prerequisites": ["github-issue"]
|
|
154
160
|
},
|
|
155
161
|
{
|
|
156
162
|
"name": "stacked-prs",
|
|
157
163
|
"plugin": "vouchington-workflow",
|
|
158
|
-
"pluginVersion": "0.
|
|
164
|
+
"pluginVersion": "0.11.0",
|
|
159
165
|
"path": "stacked-prs/SKILL.md"
|
|
160
166
|
},
|
|
161
167
|
{
|
|
162
168
|
"name": "static-analysis-checklist",
|
|
163
169
|
"plugin": "vouchington-workflow",
|
|
164
|
-
"pluginVersion": "0.
|
|
170
|
+
"pluginVersion": "0.11.0",
|
|
165
171
|
"path": "static-analysis-checklist/SKILL.md",
|
|
166
172
|
"prerequisites": ["github-actions-checklist"]
|
|
167
173
|
},
|
|
@@ -12,3 +12,25 @@ Check query predicates, joins, ordering, and selected columns against index shap
|
|
|
12
12
|
evidence to confirm planner behavior. Add extended statistics only when observed estimates show a
|
|
13
13
|
correlation problem; verify the statistics are collected and used. Partitioning can reduce scanned
|
|
14
14
|
data, but it does not replace suitable local indexes or predicates that permit pruning.
|
|
15
|
+
|
|
16
|
+
## Query shapes
|
|
17
|
+
|
|
18
|
+
Test existence with `EXISTS`, not `COUNT(*) > 0`. Use correlated `NOT EXISTS` rather than
|
|
19
|
+
`NOT IN (SELECT ...)`, whose NULL behavior can silently exclude expected rows. Filter and order
|
|
20
|
+
UUIDv7 rows by `id`, not generated `created_at`, so the key's index supports the query.
|
|
21
|
+
Read and return tables with explicit columns: no `SELECT *`, `alias.*`, or `RETURNING *`.
|
|
22
|
+
A view may use `*` when its reviewed column list is the reader contract.
|
|
23
|
+
|
|
24
|
+
Use keyset pagination, never `OFFSET`. An optimizer fence is a `MATERIALIZED` CTE, not
|
|
25
|
+
`OFFSET 0`. Constrain a partitioned table's partition key, or document the reason for a
|
|
26
|
+
cross-partition query. Choose the partition key from dominant access patterns; a hot reader
|
|
27
|
+
of `RANGE (id)` needs an id or time bound.
|
|
28
|
+
|
|
29
|
+
A UUIDv7 child cannot predate its parent. For a `RANGE (id)` child read by parent id, use a
|
|
30
|
+
shared lower-bound helper equivalent to
|
|
31
|
+
`id >= min_uuidv7(uuid_extract_timestamp(parent_id) - interval '1 hour')` to prune earlier
|
|
32
|
+
partitions; the overlap allows clock skew. Rate limits query an actor-keyed table rather than
|
|
33
|
+
recipient-keyed fan-out rows. Verify actual pruning with EXPLAIN.
|
|
34
|
+
|
|
35
|
+
Generic `jonathanong/no-mistakes` checks include `postgres-no-offset`; inspect available rules
|
|
36
|
+
and configure reviewed cross-partition exceptions rather than treating a rule as plan evidence.
|
|
@@ -9,6 +9,9 @@ Read applicable `AGENTS.md` and their declared adapter alias, otherwise
|
|
|
9
9
|
`.agents/skills/postgres-partitioning-uuid-v7/SKILL.md` from the repository root if present.
|
|
10
10
|
Local policy overrides these defaults; do not reload an already-read adapter or canonical skill.
|
|
11
11
|
|
|
12
|
+
For schema names, references, and canonical table shapes, read
|
|
13
|
+
[postgres-schema-design](../postgres-schema-design/SKILL.md).
|
|
14
|
+
|
|
12
15
|
Partition only after lifecycle, retention, and query predicates show that it helps.
|
|
13
16
|
|
|
14
17
|
- Treat a UUIDv7 as time ordered. Where the meaning matters, keep an explicit business timestamp.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: postgres-schema-design
|
|
3
|
+
description: Design PostgreSQL names, relations, history, work queues, and cursors.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PostgreSQL schema design
|
|
7
|
+
|
|
8
|
+
Read applicable `AGENTS.md` and the repository's local database adapter when present.
|
|
9
|
+
Local policy overrides these portable conventions.
|
|
10
|
+
|
|
11
|
+
- Names explain the stored thing; read [naming](references/naming.md) when naming schema objects.
|
|
12
|
+
- Columns say their type and FK target; finite sets use enums or lookup tables.
|
|
13
|
+
- Joined identities use foreign keys; read [relations](references/relations.md) before modeling references.
|
|
14
|
+
- Split differing shapes, share identical values, and keep history documents as JSON.
|
|
15
|
+
- Use canonical [table shapes](references/table-shapes.md) for revisions, changes, work, and cursors.
|
|
16
|
+
- Reuse parameterized trigger functions instead of copying bodies.
|
|
17
|
+
- Comment every table, column, and view; document reviewed exceptions explicitly.
|
|
18
|
+
|
|
19
|
+
For query shape, read [performance patterns](../postgres-node-performance-tuning/references/performance-patterns.md).
|
|
20
|
+
For partition lifecycle, use [UUIDv7 partitioning](../postgres-partitioning-uuid-v7/SKILL.md).
|
|
21
|
+
Consumer policy owns lifecycle, retention, migration strategy, and enforcement configuration.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Naming
|
|
2
|
+
|
|
3
|
+
Use full words in snake_case. Tables read as `<owner>_<thing>s`, with only the last word
|
|
4
|
+
plural: `account_payment_methods`, not `accounts_payments_methods`. Core entities may use one
|
|
5
|
+
word (`accounts`, `orders`, `invoices`) when local policy permits it. Enum names follow the same
|
|
6
|
+
plural convention. Choose a shorter full-word name when a base identifier would exceed 63 bytes.
|
|
7
|
+
|
|
8
|
+
Indexes use `idx_<table>__<suffix>`; a unique index may use `uq_`. The suffix describes its
|
|
9
|
+
purpose or keys. The table portion may abbreviate only when it expands unambiguously to the
|
|
10
|
+
indexed table: preserve every word, its order, and separators. Each shortened word keeps its
|
|
11
|
+
first letter and at least three letters in their original order; words of three or fewer letters
|
|
12
|
+
stay whole. `idx_ord_line_itms__invoice_id` can name an index on `order_line_items`.
|
|
13
|
+
Initialisms and dropped words cannot. All identifiers stay within 63 bytes.
|
|
14
|
+
|
|
15
|
+
Column names and types imply each other:
|
|
16
|
+
|
|
17
|
+
- `timestamptz` ends in `_at`; dates use `day` or `_on`.
|
|
18
|
+
- Booleans contain a predicate prefix `is_`, `has_`, `can_`, or `should_` at a word boundary.
|
|
19
|
+
- FK columns end in the target's final word, singular: `invoice_id` references `invoices`.
|
|
20
|
+
Drop redundant owner words when the column's table already supplies them.
|
|
21
|
+
- A column referencing a natural key may use that key's name. A retained identity reference
|
|
22
|
+
keeps the entity's name even though the retained table ends in `identities`.
|
|
23
|
+
- Reserve `_user_id` and actor `_by_id` for references to users or retained user identities.
|
|
24
|
+
Adapt the actor convention to the repository's account model; never use an actor suffix for
|
|
25
|
+
an unrelated FK or an opaque token.
|
|
26
|
+
|
|
27
|
+
Trigger functions use `fn_<verb>_<what>` with this closed verb list:
|
|
28
|
+
`reject` (raise on invalid writes), `update` (set the current row), `project` (derived state in
|
|
29
|
+
other rows), `create` (companion rows), `lock`, `register`, and `ensure` (retained identity).
|
|
30
|
+
Ordinary functions use a name that explains their return value.
|
|
31
|
+
|
|
32
|
+
Generic enforcement: `postgres-object-naming`, `postgres-column-naming`, and
|
|
33
|
+
`postgres-identifier-length` in `jonathanong/no-mistakes`. Check the installed rule's supported options before enabling it;
|
|
34
|
+
an upstream rule name does not establish that the installed release includes it.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Relations
|
|
2
|
+
|
|
3
|
+
An identity that readers join is an FK column or an FK child row. JSON holds structured,
|
|
4
|
+
schemaless data and history snapshots, including identities recorded as they were at the time.
|
|
5
|
+
Do not use UUID arrays, JSON ids, or type/id pairs for live relations. A column never names a
|
|
6
|
+
table: use one table per target and generate any per-relation progress enum from the same metadata.
|
|
7
|
+
Cursor positions and random fencing tokens are intentionally not live entity references; record
|
|
8
|
+
their reviewed exception instead of inventing a foreign key.
|
|
9
|
+
|
|
10
|
+
Split tables when their columns or FK targets differ; share a table when only a value differs.
|
|
11
|
+
Identical lookup values share a row without a source discriminator. Put subtype-only columns in
|
|
12
|
+
a subtype table whose primary key also references the base row (`business_accounts.account_id`
|
|
13
|
+
references `accounts.id`). A 1:N child remains an ordinary child table.
|
|
14
|
+
|
|
15
|
+
For one of several sources, use an exclusive arc:
|
|
16
|
+
|
|
17
|
+
```sql
|
|
18
|
+
CHECK (num_nonnulls(order_id, invoice_id) = 1)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Both source columns have their own FK. If a reference must belong to the same account, enforce
|
|
22
|
+
`FOREIGN KEY (account_id, invoice_id) REFERENCES invoices (account_id, id)` with a corresponding
|
|
23
|
+
unique key on the target. A validation trigger is unnecessary. With nullable `invoice_id`,
|
|
24
|
+
default `MATCH SIMPLE` skips that composite check when the reference is absent.
|
|
25
|
+
|
|
26
|
+
A ledger that survives deletion references a retained identity row with explicit deletion
|
|
27
|
+
behavior. Retained identities preserve history; they do not authorize operations on a deleted
|
|
28
|
+
account, order, or invoice. Index FK columns with a leading key usable by referential checks.
|
|
29
|
+
|
|
30
|
+
Finite code-defined values use enums. Use a lookup table when values carry attributes or arrive
|
|
31
|
+
at runtime. Authenticated vendor-defined values are inserted into a shared lookup on first sight,
|
|
32
|
+
with validation and concurrent insertion handled explicitly. Do not let untrusted strangers
|
|
33
|
+
create unbounded permanent lookup rows; reviewed raw text can be appropriate for their input.
|
|
34
|
+
Free text and a lookup's own natural key are not finite sets.
|
|
35
|
+
|
|
36
|
+
Arrays are for enums or outside-defined lists; live identity collections use FK child rows,
|
|
37
|
+
never `uuid[]`. User-submitted URLs go through one registry with centralized normalization and
|
|
38
|
+
validation, then use URL FKs. Keep exact external protocol identifiers, capability URLs, and
|
|
39
|
+
evidence snapshots unchanged when their contract requires it; document the exception.
|
|
40
|
+
Do not duplicate a child collection into a parent column unless a trigger maintains the projection.
|
|
41
|
+
|
|
42
|
+
Generic enforcement in `jonathanong/no-mistakes`: `postgres-column-naming`,
|
|
43
|
+
`postgres-array-columns`, `postgres-table-shape`, `postgres-fk-index`, and
|
|
44
|
+
`postgres-require-fk-on-delete`. These cover naming, configured shapes, and FK mechanics;
|
|
45
|
+
inspect the installed rule versions and options rather than assuming complete semantic coverage.
|
|
46
|
+
Comments and reviewed exceptions remain necessary.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Table shapes
|
|
2
|
+
|
|
3
|
+
Choose a shape by lifecycle and meaning. Do not store hand-synchronized status beside lifecycle
|
|
4
|
+
timestamps. A final lifecycle derives enum status with an immutable generated expression over
|
|
5
|
+
timestamps such as `claimed_at`, `completed_at`, and `failed_at`. A time-based expiry needs an
|
|
6
|
+
explicit sweeper-written timestamp; `now()` cannot appear in an immutable generated expression.
|
|
7
|
+
When state keeps returning to earlier states, record append-only attempts or changes and derive
|
|
8
|
+
current state from the latest row rather than erasing the previous lifecycle.
|
|
9
|
+
|
|
10
|
+
| Concept | Shape |
|
|
11
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
12
|
+
| Field-diff history | `<entity>_revisions`: UUIDv7 `id`, entity FK, nullable revision actor FK with `ON DELETE SET NULL`, revision-type enum, `changes jsonb`. |
|
|
13
|
+
| Typed lifecycle ledger | `<entity>_changes`: UUIDv7 `id`, entity FK, change-type enum, retained actor identity FK with `ON DELETE RESTRICT`, typed joined payload and JSON for unjoined data. Time comes from `id`. |
|
|
14
|
+
| Current state from a log | Latest change by `id DESC`, projected through a shared trigger or read through a view. |
|
|
15
|
+
| Per-entity work | `<entity>_<job>_work_items`: entity FK, lease and final-outcome columns, claim index, retention. |
|
|
16
|
+
| Job progress | `<job>_cursors`: finite enum key or checked singleton, keyset positions without FKs, optional sweep upper bound. |
|
|
17
|
+
| Inbound or protocol events | Append-only `<source>_events` with `occurred_at` recording the event's business time. |
|
|
18
|
+
|
|
19
|
+
Revisions and changes are append-only through a generic `fn_reject_mutation`, parameterized by
|
|
20
|
+
actor columns that may be erased to NULL. Permit only those erasures; parent-cascade deletion
|
|
21
|
+
may be permitted by policy. A trigger-depth test alone cannot prove a delete came from a parent
|
|
22
|
+
cascade, because another trigger also increases depth. Do not copy the parent into each ledger
|
|
23
|
+
row. Keep historical before/after snapshots as JSON rather than joined history child tables.
|
|
24
|
+
A system change has a NULL actor. Retained actors remain historical identities, without authority.
|
|
25
|
+
|
|
26
|
+
## Work leases
|
|
27
|
+
|
|
28
|
+
The lease lives on a row that exists only for the work. If an invoice outlives a delivery job,
|
|
29
|
+
use `invoice_delivery_work_items`, with an invoice FK and `ON DELETE CASCADE`, rather than putting
|
|
30
|
+
the lease on the invoice. Do not create a shared polymorphic lease table.
|
|
31
|
+
|
|
32
|
+
Use `lease_token uuid`, `leased_at`, `lease_expires_at`, `attempt_count`, `available_at`, and
|
|
33
|
+
`completed_at` plus timestamps for final outcomes. Add `generation` only when re-enqueue can race
|
|
34
|
+
a running worker. Each claim or reclaim gets a fresh opaque token. Every renewal, completion,
|
|
35
|
+
and failure must include `WHERE lease_token = $mine`; zero affected rows means ownership was lost.
|
|
36
|
+
Keep retries in an optional `_attempts` ledger with `attempt_number` when their lifecycle must
|
|
37
|
+
survive. One-shot compare-and-set claims and human assignments need their own shapes.
|
|
38
|
+
|
|
39
|
+
## Cursors and shared functions
|
|
40
|
+
|
|
41
|
+
A cursor key names the finite thing being swept. A singleton uses
|
|
42
|
+
`is_singleton boolean PRIMARY KEY DEFAULT true CHECK (is_singleton)`; the job seeds its own row
|
|
43
|
+
with `ON CONFLICT DO NOTHING`. `cursor_<thing>_id` and optional `sweep_upper_bound_<thing>_id` are
|
|
44
|
+
FK-free UUIDv7 positions; NULL means the start. Per-unit work progress belongs in work items.
|
|
45
|
+
A UUIDv7 is assigned at insert, not commit: jobs that must see every committed row re-scan an
|
|
46
|
+
overlap rather than assuming an id high-water mark proves commit order.
|
|
47
|
+
|
|
48
|
+
Use `updated_at` only for mutable rows, always maintained by its trigger; application code never
|
|
49
|
+
writes it. Comment every table, column, and view, including generated objects. Self-explanatory
|
|
50
|
+
columns may have explicit reviewed local exceptions; view comments describe their reader boundary.
|
|
51
|
+
Reuse generic trigger functions with `TG_ARGV` and `TG_TABLE_NAME`; quote dynamic identifiers
|
|
52
|
+
with `format('%I', ...)`. A trigger function and a SQL-callable helper are separate functions.
|
|
53
|
+
|
|
54
|
+
Generic enforcement in `jonathanong/no-mistakes`: `postgres-table-shape` and
|
|
55
|
+
`postgres-duplicate-function-body`. Configure canonical shapes explicitly and verify which
|
|
56
|
+
rules the installed release supports; lifecycle and authority decisions still need review.
|
|
@@ -17,11 +17,15 @@ Use when completed retrospectives or journals should become durable follow-up wo
|
|
|
17
17
|
`data` fields, `dataArrayContains`, and `inactiveForHours`. It exports non-archived sessions to a
|
|
18
18
|
private local file and returns the path, counts, checksum, and manifest, never the records.
|
|
19
19
|
Before exporting, call `outbox_flush` and then `outbox_status` for every worktree that
|
|
20
|
-
`git worktree list` reports, passing each path as `worktree`, and stop if any
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
`git worktree list` reports, passing each path as `worktree`, and stop if any reports a
|
|
21
|
+
`worktreePendingCount` above 0. Use the worktree count, never `pendingCount` or `status`, which
|
|
22
|
+
cover only the caller's own session: the sessions to distill belong to other agents, and an
|
|
23
|
+
archived session refuses later delivery, so a retained record would never be distilled. Outboxes
|
|
24
|
+
in removed worktrees or on other machines cannot be drained this way, so select with
|
|
25
|
+
`inactiveForHours` to give them time to deliver. Leave in-progress sessions intact. If a search
|
|
26
|
+
for `snapshot_export` (the tool names carry a harness prefix, see the
|
|
27
|
+
[blackboard skill](../blackboard/SKILL.md)) finds no server, stop and report it; do not fall back
|
|
28
|
+
to a CLI command.
|
|
25
29
|
2. Before using any record, verify the file against the returned checksum, counts, and terminal
|
|
26
30
|
manifest with the consumer's approved snapshot reader, and stop on a mismatch rather than
|
|
27
31
|
treating missing records as absent. Read full records, with their storage type, repository
|
|
@@ -34,7 +38,8 @@ Use when completed retrospectives or journals should become durable follow-up wo
|
|
|
34
38
|
3. Validate storage type and the shared envelope independently of optional category. Never repair
|
|
35
39
|
unknown legacy types or repository provenance by inference. Quarantine malformed sessions with
|
|
36
40
|
an explicit reason while processing valid unrelated sessions. Deduplicate at-least-once records
|
|
37
|
-
by exact session/source identity and reject conflicting duplicate content.
|
|
41
|
+
by exact session/source identity and reject conflicting duplicate content. Records that differ
|
|
42
|
+
only in their server-assigned `timestamp` are one event. Retain coverage,
|
|
38
43
|
dropped counts, requested versus observed outcomes, and useful resolved findings.
|
|
39
44
|
4. Cluster findings by root cause. Prefer a few broad actionable themes over many narrow issues.
|
|
40
45
|
Treat a finding already linked to an open tracker as context, not a duplicate.
|
|
@@ -47,8 +52,8 @@ Use when completed retrospectives or journals should become durable follow-up wo
|
|
|
47
52
|
[github-issue](../github-issue/SKILL.md), including its repository gate, label approval, and
|
|
48
53
|
denied-external tracking behavior.
|
|
49
54
|
7. Archive only verified records fully processed across every represented repository, and never
|
|
50
|
-
while any worktree's `outbox_status` reports
|
|
51
|
-
rules. Make one `session_archive` call per session, whose `sessionId` is the archived session,
|
|
55
|
+
while any worktree's `outbox_status` reports a `worktreePendingCount` above 0, under the
|
|
56
|
+
repository's retention rules. Make one `session_archive` call per session, whose `sessionId` is the archived session,
|
|
52
57
|
not the caller. Immediately before each call, read that session with `journal_entries` and
|
|
53
58
|
archive only if its entries match the verified snapshot's by source identity; otherwise leave it
|
|
54
59
|
for the next pass, because an entry added after the export would never be distilled.
|