vouchington-tooling 0.36.1 → 0.36.2

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 (40) hide show
  1. package/README.md +21 -0
  2. package/dist/agent-blackboard/client.d.mts +6 -1
  3. package/dist/agent-blackboard/feedback-delivery.d.mts +4 -0
  4. package/dist/agent-blackboard/feedback-delivery.mjs +13 -5
  5. package/dist/agent-blackboard/feedback-online-error.d.mts +1 -1
  6. package/dist/agent-blackboard/feedback-online-error.mjs +2 -2
  7. package/dist/agent-blackboard/feedback-online.mjs +32 -13
  8. package/dist/agent-blackboard/feedback-outbox-counts.d.mts +4 -1
  9. package/dist/agent-blackboard/feedback-outbox-counts.mjs +6 -1
  10. package/dist/agent-blackboard/feedback-outbox-rejected.d.mts +9 -0
  11. package/dist/agent-blackboard/feedback-outbox-rejected.mjs +34 -0
  12. package/dist/agent-blackboard/feedback-outbox.d.mts +14 -1
  13. package/dist/agent-blackboard/feedback-outbox.mjs +12 -8
  14. package/dist/agent-blackboard/feedback-types.d.mts +2 -0
  15. package/dist/cli/commands/agent-blackboard.mjs +9 -2
  16. package/dist/mcp-server/dispatch.mjs +1 -1
  17. package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +1 -1
  18. package/dist/mcp-server/fake-blackboard.test-helpers.mjs +6 -1
  19. package/dist/mcp-server/handlers-journal.mjs +9 -1
  20. package/dist/mcp-server/handlers-outbox.d.mts +1 -0
  21. package/dist/mcp-server/handlers-outbox.mjs +10 -10
  22. package/dist/mcp-server/tools.mjs +5 -2
  23. package/dist/pg-schema-snapshot/index.d.mts +2 -0
  24. package/dist/pg-schema-snapshot/index.mjs +1 -0
  25. package/dist/pg-schema-snapshot/replay-context.d.mts +23 -0
  26. package/dist/pg-schema-snapshot/replay-context.mjs +45 -0
  27. package/dist/sql-ast/index-shapes.d.mts +34 -0
  28. package/dist/sql-ast/index-shapes.mjs +85 -0
  29. package/dist/sql-ast/index.d.mts +3 -0
  30. package/dist/sql-ast/index.mjs +2 -0
  31. package/dist/sql-ast/parser.d.mts +2 -0
  32. package/dist/sql-ast/parser.mjs +11 -1
  33. package/dist/sql-ast/view-declarations.d.mts +11 -0
  34. package/dist/sql-ast/view-declarations.mjs +67 -0
  35. package/docs/mcp-server.md +23 -11
  36. package/docs/postgres-replay-context.md +16 -0
  37. package/package.json +1 -1
  38. package/skills/agent-workflow/SKILL.md +16 -0
  39. package/skills/blackboard/SKILL.md +8 -1
  40. package/skills/planning/SKILL.md +2 -1
package/README.md CHANGED
@@ -657,6 +657,27 @@ TypeScript and route rows from Markdown. Callers configure declaration names and
657
657
  runbook headings, then compare the returned data using their own route policy.
658
658
  See the [API contract](docs/basic-auth-doc-sync.md).
659
659
 
660
+ ## SQL view declarations
661
+
662
+ `vouchington-tooling/sql-ast` exports `extractViewDeclarations(sql)` and the
663
+ `ManagedViewDeclaration` type. Await `initSqlAst()` before calling the extractor. It returns the
664
+ qualified, PostgreSQL-quoted names and kinds of top-level `CREATE VIEW` and
665
+ `CREATE MATERIALIZED VIEW` declarations. It ignores comments, string literals, and other SQL
666
+ statements. Keywords that cannot be bare relation names remain quoted. Blank or unparseable SQL returns an empty
667
+ array; parser initialization and unexpected errors propagate. Consumers own view teardown and
668
+ lifecycle decisions.
669
+
670
+ ## SQL index shapes
671
+
672
+ `vouchington-tooling/sql-ast` exports `extractIndexShapes(sql)`. Await `initSqlAst()` before
673
+ calling it. It returns `{ idxname, table, shapeKey }` for each named top-level `CREATE INDEX`.
674
+ Consumers can group identical `shapeKey` values to find differently named index definitions
675
+ with the same projected physical shape. The key normalizes effective sort and null ordering and
676
+ removes parser locations from expressions and predicates. Invalid SQL throws. Anonymous indexes
677
+ and indexes inside `DO` blocks are omitted. The projection does not model operator classes,
678
+ collations, or `INCLUDE` columns, so callers using those features must extend it before relying
679
+ on shape collisions.
680
+
660
681
  ## Publication writer inventory facts
661
682
 
662
683
  `vouchington-tooling/post-publication-inventory` exports
@@ -5,7 +5,12 @@ export type BlackboardConnection = {
5
5
  };
6
6
  export type BlackboardClientModule = {
7
7
  Sessions: new (connection: BlackboardConnection) => {
8
- ensure(input: unknown): Promise<{
8
+ ensure(input: {
9
+ id: string;
10
+ parentSessionId: string | null;
11
+ agent: string;
12
+ version: string;
13
+ }): Promise<{
9
14
  status: 'created' | 'exists';
10
15
  session: {
11
16
  data: Record<string, unknown>;
@@ -14,5 +14,9 @@ export declare function flushFeedbackOutbox(input: {
14
14
  pendingCount: number;
15
15
  deliveredCount: number;
16
16
  diagnostic?: FeedbackDiagnostic;
17
+ rejected?: Array<{
18
+ sourceEventId: string;
19
+ diagnostic: FeedbackDiagnostic;
20
+ }>;
17
21
  cleanupDiagnostic?: 'outbox-cleanup-failed';
18
22
  }>;
@@ -1,9 +1,11 @@
1
1
  import { createFeedbackEnvelope, validateFeedbackEnvelope } from "./feedback-codec.mjs";
2
2
  import { validateFeedbackIdentity } from "./feedback-identity.mjs";
3
3
  import { deliverFeedbackOnline } from "./feedback-online.mjs";
4
- import { FeedbackDeliveryError, feedbackDiagnostic } from "./feedback-online-error.mjs";
4
+ import { feedbackDiagnostic } from "./feedback-online-error.mjs";
5
5
  import { feedbackOutboxStatus, listFeedbackOutbox, persistFeedbackOutbox, removeFeedbackOutbox, } from "./feedback-outbox.mjs";
6
+ import { rejectFeedbackOutbox } from "./feedback-outbox-rejected.mjs";
6
7
  export { FeedbackDeliveryError } from "./feedback-online-error.mjs";
8
+ const PERMANENT = ['identity-conflict', 'event-conflict', 'archived-session'];
7
9
  export async function writeFeedback(input) {
8
10
  if (input.mode !== 'interactive' && input.mode !== 'autonomous')
9
11
  throw new Error('feedback mode must be explicitly interactive or autonomous');
@@ -30,8 +32,10 @@ export async function writeFeedback(input) {
30
32
  }
31
33
  catch (error) {
32
34
  const diagnostic = feedbackDiagnostic(error);
33
- if (['identity-conflict', 'event-conflict', 'archived-session'].includes(diagnostic))
34
- throw new FeedbackDeliveryError(diagnostic);
35
+ if (PERMANENT.includes(diagnostic)) {
36
+ rejectFeedbackOutbox(input.outboxDirectory, record);
37
+ throw error;
38
+ }
35
39
  return {
36
40
  status: 'pending',
37
41
  sourceEventId: envelope.sourceEventId,
@@ -50,6 +54,7 @@ export async function flushFeedbackOutbox(input) {
50
54
  let deliveredCount = 0;
51
55
  let diagnostic;
52
56
  let cleanupDiagnostic;
57
+ const rejected = [];
53
58
  for (const record of listFeedbackOutbox(input.directory)) {
54
59
  try {
55
60
  await deliverFeedbackOnline({
@@ -63,9 +68,11 @@ export async function flushFeedbackOutbox(input) {
63
68
  }
64
69
  catch (error) {
65
70
  const current = feedbackDiagnostic(error);
66
- diagnostic ??= current;
67
- if (['identity-conflict', 'event-conflict', 'archived-session'].includes(current))
71
+ if (PERMANENT.includes(current)) {
72
+ rejectFeedbackOutbox(input.directory, record);
73
+ rejected.push({ sourceEventId: record.envelope.sourceEventId, diagnostic: current });
68
74
  continue;
75
+ }
69
76
  diagnostic = current;
70
77
  break;
71
78
  }
@@ -75,6 +82,7 @@ export async function flushFeedbackOutbox(input) {
75
82
  deliveredCount,
76
83
  ...(cleanupDiagnostic === undefined ? {} : { cleanupDiagnostic }),
77
84
  ...(diagnostic === undefined ? {} : { diagnostic }),
85
+ ...(rejected.length === 0 ? {} : { rejected }),
78
86
  };
79
87
  }
80
88
  function prepareEnvelope(input) {
@@ -2,6 +2,6 @@ import type { FeedbackDiagnostic } from './feedback-types.mts';
2
2
  export declare class FeedbackDeliveryError extends Error {
3
3
  readonly status = "blocked";
4
4
  readonly diagnostic: FeedbackDiagnostic;
5
- constructor(diagnostic: FeedbackDiagnostic);
5
+ constructor(diagnostic: FeedbackDiagnostic, detail?: string);
6
6
  }
7
7
  export declare function feedbackDiagnostic(error: unknown): FeedbackDiagnostic;
@@ -2,8 +2,8 @@ import { isObject } from "./snapshot-partition-guards.mjs";
2
2
  export class FeedbackDeliveryError extends Error {
3
3
  status = 'blocked';
4
4
  diagnostic;
5
- constructor(diagnostic) {
6
- super(`Blackboard feedback blocked: ${diagnostic}`);
5
+ constructor(diagnostic, detail) {
6
+ super(`Blackboard feedback blocked: ${diagnostic}${detail === undefined ? '' : `: ${detail}`}`);
7
7
  this.name = 'FeedbackDeliveryError';
8
8
  this.diagnostic = diagnostic;
9
9
  }
@@ -5,14 +5,17 @@ import { loadClient, resolveBlackboardConnection } from "./client.mjs";
5
5
  import { normalizeRepositories, validateFeedbackEnvelope } from "./feedback-codec.mjs";
6
6
  import { validateFeedbackIdentity } from "./feedback-identity.mjs";
7
7
  import { isObject } from "./snapshot-partition-guards.mjs";
8
+ function sessionInput(identity, version = identity.version) {
9
+ return {
10
+ id: identity.sessionId,
11
+ parentSessionId: identity.parentSessionId,
12
+ agent: identity.agent,
13
+ version,
14
+ };
15
+ }
8
16
  async function ensureSession(sessions, identity) {
9
17
  try {
10
- return await sessions.ensure({
11
- id: identity.sessionId,
12
- parentSessionId: identity.parentSessionId,
13
- agent: identity.agent,
14
- version: identity.version,
15
- });
18
+ return { ensured: await sessions.ensure(sessionInput(identity)), storedVersion: undefined };
16
19
  }
17
20
  catch (error) {
18
21
  let existing;
@@ -22,11 +25,22 @@ async function ensureSession(sessions, identity) {
22
25
  catch {
23
26
  throw error;
24
27
  }
25
- if (isObject(existing) &&
26
- existing.id === identity.sessionId &&
27
- ['parentSessionId', 'agent', 'version'].some((field) => (existing[field] ?? null) !== identity[field]))
28
- throw new FeedbackDeliveryError('identity-conflict');
29
- throw error;
28
+ if (!isObject(existing) || existing.id !== identity.sessionId)
29
+ throw error;
30
+ const differing = ['parentSessionId', 'agent', 'version'].filter((field) => (existing[field] ?? null) !== identity[field]);
31
+ if (differing.length === 0)
32
+ throw error;
33
+ const storedVersion = existing.version;
34
+ // Entries do not record a version, so a version-only difference (an agent CLI upgrade, or a
35
+ // session another writer created) delivers into the existing session under its stored version.
36
+ if (differing.length === 1 && differing[0] === 'version' && typeof storedVersion === 'string')
37
+ return {
38
+ ensured: await sessions.ensure(sessionInput(identity, storedVersion)),
39
+ storedVersion,
40
+ };
41
+ throw new FeedbackDeliveryError('identity-conflict', differing
42
+ .map((field) => `${field} stored ${JSON.stringify(existing[field] ?? null)}, supplied ${JSON.stringify(identity[field])}`)
43
+ .join('; '));
30
44
  }
31
45
  }
32
46
  async function findEvent(entries, envelope, expectedCreatedAt) {
@@ -91,7 +105,8 @@ export async function deliverFeedbackOnline(input, fresh = false) {
91
105
  const { Sessions, Entries } = client;
92
106
  const sessions = new Sessions(connection);
93
107
  const entries = new Entries(connection);
94
- const ensured = await ensureSession(sessions, input.identity);
108
+ const { ensured, storedVersion } = await ensureSession(sessions, input.identity);
109
+ const stored = storedVersion === undefined ? {} : { storedVersion };
95
110
  if (ensured.session.archivedAt != null)
96
111
  throw new FeedbackDeliveryError('archived-session');
97
112
  const existing = await findEvent(entries.get({ sessionId: input.identity.sessionId, format: 'jsonl' }), input.envelope);
@@ -115,6 +130,7 @@ export async function deliverFeedbackOnline(input, fresh = false) {
115
130
  createdAt: existing.createdAt,
116
131
  timestamp: existing.timestamp,
117
132
  verified: true,
133
+ ...stored,
118
134
  };
119
135
  const appended = await entries.append({
120
136
  sessionId: input.identity.sessionId,
@@ -135,10 +151,13 @@ export async function deliverFeedbackOnline(input, fresh = false) {
135
151
  createdAt: confirmed.createdAt,
136
152
  timestamp: confirmed.timestamp,
137
153
  verified: true,
154
+ ...stored,
138
155
  };
139
156
  }
140
157
  catch (error) {
141
- throw new FeedbackDeliveryError(feedbackDiagnostic(error));
158
+ throw error instanceof FeedbackDeliveryError
159
+ ? error
160
+ : new FeedbackDeliveryError(feedbackDiagnostic(error));
142
161
  }
143
162
  }, input.timeoutMs);
144
163
  }
@@ -1,5 +1,8 @@
1
- /** Counts a session separately from all retry records in the shared worktree outbox. */
1
+ /** Counts a session separately from all retry records in the shared worktree outbox. Rejected
2
+ * records are permanently refused notes kept for inspection; they are never pending. */
2
3
  export declare function feedbackOutboxCounts(directory: string, sessionId: string): {
3
4
  pendingCount: number;
4
5
  worktreePendingCount: number;
6
+ rejectedCount: number;
7
+ worktreeRejectedCount: number;
5
8
  };
@@ -1,11 +1,16 @@
1
1
  import { assertSessionId } from "./session-id.mjs";
2
2
  import { readFeedbackOutbox } from "./feedback-outbox.mjs";
3
- /** Counts a session separately from all retry records in the shared worktree outbox. */
3
+ import { readRejectedFeedbackOutbox } from "./feedback-outbox-rejected.mjs";
4
+ /** Counts a session separately from all retry records in the shared worktree outbox. Rejected
5
+ * records are permanently refused notes kept for inspection; they are never pending. */
4
6
  export function feedbackOutboxCounts(directory, sessionId) {
5
7
  assertSessionId(sessionId);
6
8
  const records = readFeedbackOutbox(directory);
9
+ const rejected = readRejectedFeedbackOutbox(directory);
7
10
  return {
8
11
  pendingCount: records.filter((record) => record.identity.sessionId === sessionId).length,
9
12
  worktreePendingCount: records.length,
13
+ rejectedCount: rejected.filter((record) => record.identity.sessionId === sessionId).length,
14
+ worktreeRejectedCount: rejected.length,
10
15
  };
11
16
  }
@@ -0,0 +1,9 @@
1
+ import type { FeedbackOutboxRecord } from './feedback-types.mts';
2
+ /**
3
+ * Moves a permanently rejected record out of the pending set into the rejected area of the outbox.
4
+ * The content is kept, never deleted, so a corrected retry of the same event is accepted while the
5
+ * rejected note stays inspectable. A record that is no longer pending is left alone.
6
+ */
7
+ export declare function rejectFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): void;
8
+ /** Reads the rejected records without creating the outbox directory. */
9
+ export declare function readRejectedFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
@@ -0,0 +1,34 @@
1
+ import { readdirSync, renameSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { withFileLock } from "../session-friction/lock.mjs";
4
+ import { canonicalFeedback } from "./feedback-canonical.mjs";
5
+ import { ensureOutboxDirectory } from "./feedback-outbox-directory.mjs";
6
+ import { filename, outboxDirectory, readRecord, records, syncDirectory, } from "./feedback-outbox.mjs";
7
+ const rejectedName = (record) => filename(record).replace(/\.json$/, '.rejected');
8
+ /**
9
+ * Moves a permanently rejected record out of the pending set into the rejected area of the outbox.
10
+ * The content is kept, never deleted, so a corrected retry of the same event is accepted while the
11
+ * rejected note stays inspectable. A record that is no longer pending is left alone.
12
+ */
13
+ export function rejectFeedbackOutbox(directory, record) {
14
+ const path = outboxDirectory(directory, true);
15
+ withFileLock(join(path, '.records'), () => {
16
+ const existing = records(path).find((item) => item.path === join(path, filename(record)));
17
+ if (!existing)
18
+ return;
19
+ if (canonicalFeedback(existing.record) !== canonicalFeedback(record))
20
+ throw new Error('feedback outbox changed during delivery');
21
+ renameSync(existing.path, join(path, rejectedName(record)));
22
+ syncDirectory(path);
23
+ });
24
+ }
25
+ /** Reads the rejected records without creating the outbox directory. */
26
+ export function readRejectedFeedbackOutbox(directory) {
27
+ const path = outboxDirectory(directory, false);
28
+ if (!ensureOutboxDirectory(path, false))
29
+ return [];
30
+ return withFileLock(join(path, '.records'), () => readdirSync(path)
31
+ .filter((name) => /^[a-f0-9]{64}\.rejected$/.test(name))
32
+ .sort()
33
+ .map((name) => readRecord(join(path, name)).record));
34
+ }
@@ -1,11 +1,24 @@
1
1
  import type { FeedbackOutboxRecord, FeedbackOutboxStatus } from './feedback-types.mts';
2
+ export declare function outboxDirectory(directory: string, create: boolean): string;
3
+ export declare function filename(record: FeedbackOutboxRecord): string;
4
+ export declare function readRecord(path: string): {
5
+ record: FeedbackOutboxRecord;
6
+ bytes: number;
7
+ };
8
+ export declare function records(directory: string): Array<{
9
+ record: FeedbackOutboxRecord;
10
+ bytes: number;
11
+ path: string;
12
+ }>;
13
+ export declare function syncDirectory(directory: string): void;
2
14
  export declare function feedbackOutboxStatus(directory: string): FeedbackOutboxStatus;
3
15
  export declare function listFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
4
16
  /** Reads the retained records without creating the outbox directory. */
5
17
  export declare function readFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
6
18
  /**
7
19
  * 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
20
+ * the event is the session identity (any `version`), `sourceEventId`, and content, and the earlier
21
+ * `timestamp` wins. The
9
22
  * returned record is the one to deliver.
10
23
  */
11
24
  export declare function persistFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): FeedbackOutboxRecord;
@@ -11,19 +11,19 @@ import { isObject } from "./snapshot-partition-guards.mjs";
11
11
  const FEEDBACK_OUTBOX_MAX_RECORDS = 128;
12
12
  const FEEDBACK_OUTBOX_MAX_BYTES = 2_000_000;
13
13
  const RECORD_MAX_BYTES = 18_000;
14
- function outboxDirectory(directory, create) {
14
+ export function outboxDirectory(directory, create) {
15
15
  if (typeof directory !== 'string' || directory.length > 4096 || !isAbsolute(directory))
16
16
  throw new Error('feedback outbox requires an absolute directory');
17
17
  const path = resolve(directory);
18
18
  ensureOutboxDirectory(path, create);
19
19
  return path;
20
20
  }
21
- function filename(record) {
21
+ export function filename(record) {
22
22
  return `${createHash('sha256')
23
23
  .update(JSON.stringify([record.identity.sessionId, record.envelope.sourceEventId]))
24
24
  .digest('hex')}.json`;
25
25
  }
26
- function readRecord(path) {
26
+ export function readRecord(path) {
27
27
  const descriptor = openLogFile(path, constants.O_RDONLY);
28
28
  try {
29
29
  const status = fstatSync(descriptor);
@@ -42,8 +42,8 @@ function readRecord(path) {
42
42
  closeSync(descriptor);
43
43
  }
44
44
  }
45
- function records(directory) {
46
- const paths = readdirSync(directory).filter((name) => name !== '.records.lock' && name !== '.records.lock.reap');
45
+ export function records(directory) {
46
+ const paths = readdirSync(directory).filter((name) => !name.endsWith('.rejected') && !name.startsWith('.records.lock'));
47
47
  if (paths.length > FEEDBACK_OUTBOX_MAX_RECORDS)
48
48
  throw new Error('feedback outbox capacity exceeded; unsent records retained');
49
49
  const result = paths.sort().map((name) => {
@@ -59,7 +59,7 @@ function records(directory) {
59
59
  throw new Error('feedback outbox byte capacity exceeded; unsent records retained');
60
60
  return result;
61
61
  }
62
- function syncDirectory(directory) {
62
+ export function syncDirectory(directory) {
63
63
  const descriptor = openSync(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
64
64
  try {
65
65
  fsyncSync(descriptor);
@@ -90,7 +90,8 @@ export function readFeedbackOutbox(directory) {
90
90
  }
91
91
  /**
92
92
  * Retains the record unless the same event is already retained, in which case that record stays:
93
- * the event is the session, `sourceEventId`, and content, and the earlier `timestamp` wins. The
93
+ * the event is the session identity (any `version`), `sourceEventId`, and content, and the earlier
94
+ * `timestamp` wins. The
94
95
  * returned record is the one to deliver.
95
96
  */
96
97
  export function persistFeedbackOutbox(directory, record) {
@@ -103,7 +104,10 @@ export function persistFeedbackOutbox(directory, record) {
103
104
  const existing = current.find((item) => item.path === join(path, name));
104
105
  const serialized = JSON.stringify(record);
105
106
  if (existing) {
106
- if (canonicalFeedback(existing.record.identity) !== canonicalFeedback(record.identity) ||
107
+ // A version-only difference is the same event: delivery accepts it into the stored session.
108
+ const { version: _retained, ...retainedIdentity } = existing.record.identity;
109
+ const { version: _supplied, ...suppliedIdentity } = record.identity;
110
+ if (canonicalFeedback(retainedIdentity) !== canonicalFeedback(suppliedIdentity) ||
107
111
  canonicalFeedbackEvent(existing.record.envelope) !== canonicalFeedbackEvent(record.envelope))
108
112
  throw new Error('feedback source event conflicts with a retained unsent record');
109
113
  return existing.record;
@@ -34,6 +34,8 @@ export type FeedbackReceipt = {
34
34
  /** The `timestamp` of the stored entry, which may be earlier than the attempt that confirmed it. */
35
35
  timestamp: string;
36
36
  verified: true;
37
+ /** Set when the session already stored a different `version`; the entry was delivered into it. */
38
+ storedVersion?: string;
37
39
  };
38
40
  export type FeedbackDiagnostic = 'blackboard-unavailable' | 'client-unavailable' | 'configuration-invalid' | 'authentication-rejected' | 'identity-conflict' | 'readback-unconfirmed' | 'event-conflict' | 'archived-session' | 'delivery-timeout';
39
41
  export type FeedbackDeliveryResult = {
@@ -124,8 +124,10 @@ async function runJournal(args) {
124
124
  // Autonomous mode has no outbox, so its receipt must not depend on one.
125
125
  const counts = values.mode === 'autonomous'
126
126
  ? { pendingCount: 0, worktreePendingCount: 0 }
127
- : feedbackOutboxCounts(required(values, 'outbox-directory'), required(values, 'session-id'));
128
- process.stdout.write(`${JSON.stringify({ ...result, ...counts })}\n`);
127
+ : pendingCounts(required(values, 'outbox-directory'), required(values, 'session-id'));
128
+ const storedVersion = result.status === 'delivered' ? result.receipt.storedVersion : undefined;
129
+ // JSON.stringify drops an undefined `storedVersion`.
130
+ process.stdout.write(`${JSON.stringify({ ...result, storedVersion, ...counts })}\n`);
129
131
  return 0;
130
132
  }
131
133
  throw new Error('usage: agent-blackboard journal append|entries|flush|status');
@@ -150,6 +152,11 @@ function requiredRepositories(repositories) {
150
152
  throw new Error('--repository is required');
151
153
  return repositories;
152
154
  }
155
+ /** An append receipt reports only unsent records; rejected records are not pending. */
156
+ function pendingCounts(directory, sessionId) {
157
+ const { pendingCount, worktreePendingCount } = feedbackOutboxCounts(directory, sessionId);
158
+ return { pendingCount, worktreePendingCount };
159
+ }
153
160
  function isNotFound(error) {
154
161
  return (typeof error === 'object' &&
155
162
  error !== null &&
@@ -16,7 +16,7 @@ const HANDLERS = {
16
16
  session_archive: sessionArchive,
17
17
  };
18
18
  const DIAGNOSTIC_HINTS = {
19
- 'identity-conflict': 'the session already exists with a different parent, agent, or version',
19
+ 'identity-conflict': 'the session already exists with a different parent or agent, or an unusable stored version',
20
20
  'event-conflict': 'sourceEventId was already used with different content; use a new sourceEventId',
21
21
  'archived-session': 'the session is archived; write to a new session',
22
22
  'authentication-rejected': 'the blackboard rejected AGENT_BLACKBOARD_TOKEN',
@@ -13,7 +13,7 @@ export type FakeOptions = {
13
13
  omitArchive?: boolean;
14
14
  omitSnapshots?: boolean;
15
15
  exportResult?: unknown;
16
- /** Makes `ensure` fail and `get` report a session with this different identity. */
16
+ /** Makes `ensure` fail for any other identity, and `get` report a session with this one. */
17
17
  conflictingSession?: {
18
18
  id: string;
19
19
  parentSessionId: null;
@@ -24,7 +24,12 @@ export function fakeBlackboard(options = {}) {
24
24
  class Sessions {
25
25
  async ensure(input) {
26
26
  calls.ensure.push(input);
27
- if (options.conflictingSession)
27
+ if (typeof input.id !== 'string')
28
+ throw new Error('ensure requires id');
29
+ if (options.conflictingSession &&
30
+ (options.conflictingSession.parentSessionId !== input.parentSessionId ||
31
+ options.conflictingSession.agent !== input.agent ||
32
+ options.conflictingSession.version !== input.version))
28
33
  throw new Error('session identity differs');
29
34
  return {
30
35
  status: 'created',
@@ -68,7 +68,15 @@ export const journalAppend = async (args, context) => {
68
68
  const counts = mode === 'interactive'
69
69
  ? outboxCounts(context.worktree, context.sessionId)
70
70
  : { pendingCount: 0, worktreePendingCount: 0 };
71
- return { sessionId: context.sessionId, ...result, timestamp, ...counts };
71
+ // A version-only mismatch delivers into the existing session; say which version it stores.
72
+ const storedVersion = result.status === 'delivered' ? result.receipt.storedVersion : undefined;
73
+ return {
74
+ sessionId: context.sessionId,
75
+ ...result,
76
+ timestamp,
77
+ ...(storedVersion === undefined ? {} : { storedVersion }),
78
+ ...counts,
79
+ };
72
80
  };
73
81
  // `Entries.get` documents no order, and `createdAt` is a service-generated ISO 8601 UTC time whose
74
82
  // text order is time order. An entry without a usable `createdAt` sorts first rather than throwing.
@@ -2,6 +2,7 @@ import { type ToolHandler } from './context.mts';
2
2
  /**
3
3
  * The outbox belongs to the worktree, not to one session, so two counts are reported. `pendingCount`
4
4
  * is the caller's session only; `worktreePendingCount` is every session's records.
5
+ * `rejectedCount` and `worktreeRejectedCount` count permanently refused records kept for inspection.
5
6
  */
6
7
  export declare function outboxCounts(worktree: string, sessionId: string): {
7
8
  pendingCount: number;
@@ -3,34 +3,34 @@ import { outboxDirectory } from "./context.mjs";
3
3
  /**
4
4
  * The outbox belongs to the worktree, not to one session, so two counts are reported. `pendingCount`
5
5
  * is the caller's session only; `worktreePendingCount` is every session's records.
6
+ * `rejectedCount` and `worktreeRejectedCount` count permanently refused records kept for inspection.
6
7
  */
7
8
  export function outboxCounts(worktree, sessionId) {
9
+ const { pendingCount, worktreePendingCount } = statusCounts(worktree, sessionId);
10
+ return { pendingCount, worktreePendingCount };
11
+ }
12
+ function statusCounts(worktree, sessionId) {
8
13
  return feedbackOutboxCounts(outboxDirectory(worktree), sessionId);
9
14
  }
10
15
  function sessionStatus(pendingCount) {
11
16
  return pendingCount === 0 ? 'empty' : 'pending';
12
17
  }
13
18
  export const outboxStatus = async (_args, context) => {
14
- const counts = outboxCounts(context.worktree, context.sessionId);
19
+ const counts = statusCounts(context.worktree, context.sessionId);
15
20
  return { sessionId: context.sessionId, status: sessionStatus(counts.pendingCount), ...counts };
16
21
  };
17
22
  export const outboxFlush = async (_args, context) => {
18
23
  // Flushing creates the outbox directory, so an empty or absent outbox is reported as is.
19
- if (outboxCounts(context.worktree, context.sessionId).worktreePendingCount === 0)
20
- return {
21
- sessionId: context.sessionId,
22
- status: 'empty',
23
- pendingCount: 0,
24
- worktreePendingCount: 0,
25
- deliveredCount: 0,
26
- };
24
+ const before = statusCounts(context.worktree, context.sessionId);
25
+ if (before.worktreePendingCount === 0)
26
+ return { sessionId: context.sessionId, status: 'empty', ...before, deliveredCount: 0 };
27
27
  // The flush delivers every session's records; only the counts below are per session.
28
28
  const flushed = await flushFeedbackOutbox({
29
29
  directory: outboxDirectory(context.worktree),
30
30
  env: context.env,
31
31
  dependencies: context.dependencies,
32
32
  });
33
- const counts = outboxCounts(context.worktree, context.sessionId);
33
+ const counts = statusCounts(context.worktree, context.sessionId);
34
34
  return {
35
35
  sessionId: context.sessionId,
36
36
  ...flushed,
@@ -103,10 +103,13 @@ export const TOOLS = [
103
103
  'entry is exactly as the blackboard returns it ({ sessionId, createdAt, data }), including ' +
104
104
  'non-journal types such as retrospective and legacy entries without a feedback envelope.', READ_ONLY),
105
105
  tool('outbox_status', 'Reports unsent journal records in the worktree outbox. pendingCount and status (empty or ' +
106
- 'pending) cover the caller sessionId only; worktreePendingCount covers every session.', READ_ONLY),
106
+ 'pending) cover the caller sessionId only; worktreePendingCount covers every session. ' +
107
+ 'rejectedCount (and worktreeRejectedCount) counts permanently rejected records kept aside.', READ_ONLY),
107
108
  tool('outbox_flush', 'Delivers every retained record in the worktree outbox, whichever session wrote it, and ' +
108
109
  'returns deliveredCount for the whole flush. pendingCount and status cover the remaining ' +
109
- 'records of the caller sessionId; worktreePendingCount covers every session.', IDEMPOTENT_ADDITIVE),
110
+ 'records of the caller sessionId; worktreePendingCount covers every session. ' +
111
+ 'A record with a permanent rejection moves to the rejected area and is listed in rejected ' +
112
+ 'as sourceEventId and diagnostic.', IDEMPOTENT_ADDITIVE),
110
113
  tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
111
114
  'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
112
115
  tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
@@ -4,6 +4,8 @@ export type { CatalogColumnRow, CatalogConstraintRow, CatalogEnumValueRow, Catal
4
4
  export { generateSchemaSnapshot, stableStringify, writeSchemaSnapshot } from './generate.mts';
5
5
  export { detectRenamedIndexes, indexShapeKey } from './index-rename-detect.mts';
6
6
  export type { RenamedIndex, SchemaIndexRenameSnapshot } from './index-rename-detect.mts';
7
+ export { createPostgresReplayContext } from './replay-context.mts';
8
+ export type { GeneratedColumnReferences, PostgresReplayContext } from './replay-context.mts';
7
9
  export { renderSchemaMarkdown } from './render-markdown.mts';
8
10
  export type { SchemaMarkdownFiles } from './render-markdown.mts';
9
11
  export { renderTableDocument } from './render-tables.mts';
@@ -2,5 +2,6 @@ export { buildSchemaSnapshot, buildTableSnapshots } from "./build-snapshot.mjs";
2
2
  export { readColumns, readConstraints, readEnums, readExtensions, readFunctions, readIndexes, readPolicies, readSchemaCatalog, readTables, readTriggers, readViews, } from "./catalog-queries.mjs";
3
3
  export { generateSchemaSnapshot, stableStringify, writeSchemaSnapshot } from "./generate.mjs";
4
4
  export { detectRenamedIndexes, indexShapeKey } from "./index-rename-detect.mjs";
5
+ export { createPostgresReplayContext } from "./replay-context.mjs";
5
6
  export { renderSchemaMarkdown } from "./render-markdown.mjs";
6
7
  export { renderTableDocument } from "./render-tables.mjs";
@@ -0,0 +1,23 @@
1
+ import type { SchemaSnapshot } from './types.mts';
2
+ /** Column references for one STORED generated expression, projected from parser facts. */
3
+ export type GeneratedColumnReferences = {
4
+ table: string;
5
+ column: string;
6
+ sourceColumns: readonly string[];
7
+ };
8
+ export type PostgresReplayContext = {
9
+ /** Unknown tables return undefined so callers can fail closed. */
10
+ triggerTextsForTable(table: string): readonly string[] | undefined;
11
+ /** Memoized per table; unknown tables return undefined and empty dependency sets return an empty map. */
12
+ generatedDependenciesForTable(table: string): ReadonlyMap<string, ReadonlySet<string>> | undefined;
13
+ };
14
+ /**
15
+ * Projects injected schema snapshot data and parser-derived generated-expression references for
16
+ * replay checks. The caller owns snapshot loading and SQL parsing. Every STORED generated column
17
+ * with an expression must have a corresponding reference fact, including expressions with no
18
+ * column references; missing facts throw instead of silently weakening the caller's check.
19
+ */
20
+ export declare function createPostgresReplayContext(input: {
21
+ schema: SchemaSnapshot;
22
+ generatedColumnReferences: readonly GeneratedColumnReferences[];
23
+ }): PostgresReplayContext;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Projects injected schema snapshot data and parser-derived generated-expression references for
3
+ * replay checks. The caller owns snapshot loading and SQL parsing. Every STORED generated column
4
+ * with an expression must have a corresponding reference fact, including expressions with no
5
+ * column references; missing facts throw instead of silently weakening the caller's check.
6
+ */
7
+ export function createPostgresReplayContext(input) {
8
+ const referenceFacts = new Map();
9
+ for (const fact of input.generatedColumnReferences) {
10
+ const key = generatedColumnKey(fact.table, fact.column);
11
+ referenceFacts.set(key, new Set(fact.sourceColumns));
12
+ }
13
+ const dependencyCache = new Map();
14
+ return {
15
+ triggerTextsForTable(table) {
16
+ const snapshotTable = input.schema.tables[table];
17
+ return snapshotTable ? Object.values(snapshotTable.triggers) : undefined;
18
+ },
19
+ generatedDependenciesForTable(table) {
20
+ const cached = dependencyCache.get(table);
21
+ if (cached)
22
+ return cached;
23
+ const snapshotTable = input.schema.tables[table];
24
+ if (!snapshotTable)
25
+ return undefined;
26
+ const dependencies = new Map();
27
+ for (const [column, definition] of Object.entries(snapshotTable.columns)) {
28
+ if (definition.generated !== 'stored' || definition.generatedExpression === null)
29
+ continue;
30
+ const key = generatedColumnKey(table, column);
31
+ const sources = referenceFacts.get(key);
32
+ if (!sources) {
33
+ throw new Error(`Missing generated-column reference facts for ${table}.${column}`);
34
+ }
35
+ if (sources.size > 0)
36
+ dependencies.set(column, sources);
37
+ }
38
+ dependencyCache.set(table, dependencies);
39
+ return dependencies;
40
+ },
41
+ };
42
+ }
43
+ function generatedColumnKey(table, column) {
44
+ return `${table}\0${column}`;
45
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Extracts a canonical "shape key" for every top-level CREATE INDEX statement in the given SQL,
3
+ * for detecting index definitions that build the same physical index under different names.
4
+ *
5
+ * The key is built from normalized `IndexStmt` AST fields (table, uniqueness, access method, each
6
+ * indexed column/expression with its *effective* sort order and null ordering, and the predicate
7
+ * with position-dependent `location` fields stripped) rather than from raw source text. This
8
+ * canonicalizes cosmetic syntax variance that leaves the physical index unchanged:
9
+ * - implicit vs. explicit `ASC` (`SORTBY_DEFAULT` is normalized to `SORTBY_ASC`, PostgreSQL's
10
+ * actual default for btree ordering);
11
+ * - implicit vs. explicit `NULLS FIRST`/`NULLS LAST` (PostgreSQL's real default depends on sort
12
+ * direction — `NULLS LAST` for ascending, `NULLS FIRST` for descending — so
13
+ * `SORTBY_NULLS_DEFAULT` is resolved against the effective ordering rather than compared as-is);
14
+ * - redundant parentheses in a predicate or expression (parentheses are pure grouping and leave no
15
+ * trace in the parsed AST once `location` offsets — which shift under added parens even though
16
+ * the tree shape is unchanged — are stripped before comparison).
17
+ *
18
+ * Operator classes, collations, and INCLUDE columns are intentionally NOT modeled; callers whose
19
+ * generators use them must extend the projection before using this collision policy for those SQL.
20
+ *
21
+ * @libpg-query/parser has no deparse/SQL-regeneration function, so this hashes a normalized JSON
22
+ * projection of the relevant AST subtrees instead of reconstructing SQL text.
23
+ *
24
+ * DO $$ ... END $$; blocks are opaque to the top-level parser — they appear as a single DoStmt,
25
+ * not individual IndexStmt nodes — so nested CREATE INDEX statements are not covered.
26
+ *
27
+ * Unparseable SQL throws rather than returning an empty shape list.
28
+ * Requires initSqlAst() to have resolved before the first call.
29
+ */
30
+ export declare function extractIndexShapes(sql: string): {
31
+ idxname: string;
32
+ table: string;
33
+ shapeKey: string;
34
+ }[];
@@ -0,0 +1,85 @@
1
+ import { parseSql } from "./parser.mjs";
2
+ /**
3
+ * Extracts a canonical "shape key" for every top-level CREATE INDEX statement in the given SQL,
4
+ * for detecting index definitions that build the same physical index under different names.
5
+ *
6
+ * The key is built from normalized `IndexStmt` AST fields (table, uniqueness, access method, each
7
+ * indexed column/expression with its *effective* sort order and null ordering, and the predicate
8
+ * with position-dependent `location` fields stripped) rather than from raw source text. This
9
+ * canonicalizes cosmetic syntax variance that leaves the physical index unchanged:
10
+ * - implicit vs. explicit `ASC` (`SORTBY_DEFAULT` is normalized to `SORTBY_ASC`, PostgreSQL's
11
+ * actual default for btree ordering);
12
+ * - implicit vs. explicit `NULLS FIRST`/`NULLS LAST` (PostgreSQL's real default depends on sort
13
+ * direction — `NULLS LAST` for ascending, `NULLS FIRST` for descending — so
14
+ * `SORTBY_NULLS_DEFAULT` is resolved against the effective ordering rather than compared as-is);
15
+ * - redundant parentheses in a predicate or expression (parentheses are pure grouping and leave no
16
+ * trace in the parsed AST once `location` offsets — which shift under added parens even though
17
+ * the tree shape is unchanged — are stripped before comparison).
18
+ *
19
+ * Operator classes, collations, and INCLUDE columns are intentionally NOT modeled; callers whose
20
+ * generators use them must extend the projection before using this collision policy for those SQL.
21
+ *
22
+ * @libpg-query/parser has no deparse/SQL-regeneration function, so this hashes a normalized JSON
23
+ * projection of the relevant AST subtrees instead of reconstructing SQL text.
24
+ *
25
+ * DO $$ ... END $$; blocks are opaque to the top-level parser — they appear as a single DoStmt,
26
+ * not individual IndexStmt nodes — so nested CREATE INDEX statements are not covered.
27
+ *
28
+ * Unparseable SQL throws rather than returning an empty shape list.
29
+ * Requires initSqlAst() to have resolved before the first call.
30
+ */
31
+ export function extractIndexShapes(sql) {
32
+ if (!sql.trim())
33
+ return [];
34
+ const result = parseSql(sql);
35
+ const shapes = [];
36
+ for (const stmt of result.stmts ?? []) {
37
+ const node = stmt.stmt;
38
+ if (!node || !('IndexStmt' in node))
39
+ continue;
40
+ const indexStmt = node.IndexStmt;
41
+ const idxname = indexStmt.idxname;
42
+ if (!idxname)
43
+ continue;
44
+ const table = indexStmt.relation?.relname ?? '';
45
+ const canonical = {
46
+ table,
47
+ unique: Boolean(indexStmt.unique),
48
+ accessMethod: indexStmt.accessMethod ?? 'btree',
49
+ indexParams: (indexStmt.indexParams ?? []).map(normalizeIndexParam),
50
+ whereClause: indexStmt.whereClause ? stripLocations(indexStmt.whereClause) : null,
51
+ };
52
+ shapes.push({ idxname, table, shapeKey: JSON.stringify(canonical) });
53
+ }
54
+ return shapes;
55
+ }
56
+ function normalizeIndexParam(param) {
57
+ if (!('IndexElem' in param))
58
+ return stripLocations(param);
59
+ const elem = param.IndexElem;
60
+ const ordering = elem.ordering === undefined || elem.ordering === 'SORTBY_DEFAULT' ? 'SORTBY_ASC' : elem.ordering;
61
+ const nullsOrdering = elem.nulls_ordering === undefined || elem.nulls_ordering === 'SORTBY_NULLS_DEFAULT'
62
+ ? ordering === 'SORTBY_DESC'
63
+ ? 'SORTBY_NULLS_FIRST'
64
+ : 'SORTBY_NULLS_LAST'
65
+ : elem.nulls_ordering;
66
+ return {
67
+ key: elem.name ?? stripLocations(elem.expr),
68
+ ordering,
69
+ nullsOrdering,
70
+ };
71
+ }
72
+ function stripLocations(value) {
73
+ if (Array.isArray(value))
74
+ return value.map(stripLocations);
75
+ if (value && typeof value === 'object') {
76
+ const out = {};
77
+ for (const [key, val] of Object.entries(value)) {
78
+ if (key === 'location')
79
+ continue;
80
+ out[key] = stripLocations(val);
81
+ }
82
+ return out;
83
+ }
84
+ return value;
85
+ }
@@ -7,6 +7,9 @@ export { extractDefaultFunction, extractFuncCallArgColumnNames } from './default
7
7
  export { extractDropIndexMetadata } from './drop-index.mts';
8
8
  export type { SqlDropIndexMetadata } from './drop-index.mts';
9
9
  export { extractCreateIndexMetadata } from './index-metadata.mts';
10
+ export { extractIndexShapes } from './index-shapes.mts';
10
11
  export type { SqlCreateIndexMetadata, SqlIndexParam } from './index-metadata.mts';
11
12
  export { lineOfUtf8ByteOffset } from './line-of-offset.mts';
12
13
  export { initSqlAst, MissingSqlAstParserError, parseSql } from './parser.mts';
14
+ export { extractViewDeclarations } from './view-declarations.mts';
15
+ export type { ManagedViewDeclaration } from './view-declarations.mts';
@@ -4,5 +4,7 @@ export { extractCreateTableMetadata } from "./create-table.mjs";
4
4
  export { extractDefaultFunction, extractFuncCallArgColumnNames } from "./default-function.mjs";
5
5
  export { extractDropIndexMetadata } from "./drop-index.mjs";
6
6
  export { extractCreateIndexMetadata } from "./index-metadata.mjs";
7
+ export { extractIndexShapes } from "./index-shapes.mjs";
7
8
  export { lineOfUtf8ByteOffset } from "./line-of-offset.mjs";
8
9
  export { initSqlAst, MissingSqlAstParserError, parseSql } from "./parser.mjs";
10
+ export { extractViewDeclarations } from "./view-declarations.mjs";
@@ -8,4 +8,6 @@ export declare class MissingSqlAstParserError extends Error {
8
8
  */
9
9
  export declare function initSqlAst(importer?: () => Promise<LibPgQuery>): Promise<void>;
10
10
  export declare function parseSql(content: string): ReturnType<LibPgQuery['parseSync']>;
11
+ export declare function isUnsafeUnquotedRelationKeyword(name: string): boolean;
12
+ export declare function isSqlParseError(error: unknown): boolean;
11
13
  export {};
@@ -23,10 +23,20 @@ export function initSqlAst(importer = () => import('@libpg-query/parser')) {
23
23
  }));
24
24
  }
25
25
  export function parseSql(content) {
26
+ return getParser().parseSync(content);
27
+ }
28
+ export function isUnsafeUnquotedRelationKeyword(name) {
29
+ const keywordName = getParser().scanSync(name).tokens[0]?.keywordName;
30
+ return keywordName === 'RESERVED_KEYWORD' || keywordName === 'TYPE_FUNC_NAME_KEYWORD';
31
+ }
32
+ function getParser() {
26
33
  if (!parser) {
27
34
  throw new Error('initSqlAst() must be awaited before calling parse helpers');
28
35
  }
29
- return parser.parseSync(content);
36
+ return parser;
37
+ }
38
+ export function isSqlParseError(error) {
39
+ return Boolean(parser?.SqlError && error instanceof parser.SqlError);
30
40
  }
31
41
  async function loadParser(importer) {
32
42
  try {
@@ -0,0 +1,11 @@
1
+ export type ManagedViewDeclaration = {
2
+ name: string;
3
+ type: 'materialized view' | 'view';
4
+ };
5
+ /**
6
+ * Extracts typed declarations from CREATE [OR REPLACE] VIEW and CREATE MATERIALIZED VIEW
7
+ * statements in the given SQL string. Comments and string literals are ignored by the parser.
8
+ *
9
+ * Requires initSqlAst() to have resolved before the first call.
10
+ */
11
+ export declare function extractViewDeclarations(sql: string): ManagedViewDeclaration[];
@@ -0,0 +1,67 @@
1
+ import { isSqlParseError, isUnsafeUnquotedRelationKeyword, parseSql } from "./parser.mjs";
2
+ /**
3
+ * Returns a PostgreSQL-quoted identifier. Simple lowercase names valid as bare relation names
4
+ * are returned unquoted; keywords requiring quotes and names containing uppercase, spaces,
5
+ * punctuation, or other special characters are wrapped in double-quotes with internal quotes
6
+ * escaped as "".
7
+ */
8
+ function quotePgName(name) {
9
+ if (/^[a-z_][a-z0-9_]*$/.test(name) && !isUnsafeUnquotedRelationKeyword(name))
10
+ return name;
11
+ return `"${name.replace(/"/g, '""')}"`;
12
+ }
13
+ /**
14
+ * Builds the qualified view reference (schema.view or just view) from the
15
+ * unquoted identifiers the parser returns.
16
+ */
17
+ function buildViewRef(view) {
18
+ const name = quotePgName(view.relname);
19
+ if (!view.schemaname)
20
+ return name;
21
+ return `${quotePgName(view.schemaname)}.${name}`;
22
+ }
23
+ /**
24
+ * Extracts typed declarations from CREATE [OR REPLACE] VIEW and CREATE MATERIALIZED VIEW
25
+ * statements in the given SQL string. Comments and string literals are ignored by the parser.
26
+ *
27
+ * Requires initSqlAst() to have resolved before the first call.
28
+ */
29
+ export function extractViewDeclarations(sql) {
30
+ if (!sql.trim())
31
+ return [];
32
+ let result;
33
+ try {
34
+ result = parseSql(sql);
35
+ }
36
+ catch (error) {
37
+ if (isSqlParseError(error))
38
+ return [];
39
+ throw error;
40
+ }
41
+ const declarations = [];
42
+ for (const stmt of result.stmts ?? []) {
43
+ const node = stmt.stmt;
44
+ if (!node)
45
+ continue;
46
+ if ('ViewStmt' in node) {
47
+ const view = node.ViewStmt.view;
48
+ if (view?.relname) {
49
+ declarations.push({
50
+ name: buildViewRef(view),
51
+ type: 'view',
52
+ });
53
+ }
54
+ continue;
55
+ }
56
+ if ('CreateTableAsStmt' in node && node.CreateTableAsStmt.objtype === 'OBJECT_MATVIEW') {
57
+ const view = node.CreateTableAsStmt.into?.rel;
58
+ if (view?.relname) {
59
+ declarations.push({
60
+ name: buildViewRef(view),
61
+ type: 'materialized view',
62
+ });
63
+ }
64
+ }
65
+ }
66
+ return declarations;
67
+ }
@@ -80,15 +80,15 @@ Every tool takes an explicit `sessionId` (`^[A-Za-z0-9._:-]+$`, at most 256 char
80
80
  server never infers, and an optional `worktree`. Unknown arguments are rejected. Failures come back
81
81
  as MCP tool errors (`isError: true`) with an actionable message rather than crashing the server.
82
82
 
83
- | Tool | Arguments besides `sessionId` and `worktree` | Returns |
84
- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
85
- | `journal_append` | `parentSessionId`, `agent`, `version`, `mode` (`interactive` or `autonomous`), `markdown`, `sourceEventId`, `workOutcome`, `repositories`, `feedbackCoverage`, optional `category` | `sessionId`, `timestamp`, delivery `status` (`delivered` with a verified receipt, or `pending`), `pendingCount`, `worktreePendingCount` |
86
- | `journal_entries` | none | `sessionId` and `entries`, every entry oldest first |
87
- | `outbox_status` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount` |
88
- | `outbox_flush` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `deliveredCount` |
89
- | `session_ensure` | `parentSessionId`, `agent`, `version` | `sessionId`, `status` (`created` or `exists`), `archived` |
90
- | `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
91
- | `session_archive` | none | `sessionId`, `archived: true` |
83
+ | Tool | Arguments besides `sessionId` and `worktree` | Returns |
84
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
85
+ | `journal_append` | `parentSessionId`, `agent`, `version`, `mode` (`interactive` or `autonomous`), `markdown`, `sourceEventId`, `workOutcome`, `repositories`, `feedbackCoverage`, optional `category` | `sessionId`, `timestamp`, delivery `status` (`delivered` with a verified receipt, or `pending`), `pendingCount`, `worktreePendingCount` |
86
+ | `journal_entries` | none | `sessionId` and `entries`, every entry oldest first |
87
+ | `outbox_status` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount` |
88
+ | `outbox_flush` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount`, `deliveredCount`, `rejected` (when any) |
89
+ | `session_ensure` | `parentSessionId`, `agent`, `version` | `sessionId`, `status` (`created` or `exists`), `archived` |
90
+ | `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
91
+ | `session_archive` | none | `sessionId`, `archived: true` |
92
92
 
93
93
  `journal_append` validates the whole feedback envelope with `validateFeedbackEnvelope` before
94
94
  anything is written, so a missing required field writes nothing. It returns the verified read-back
@@ -111,6 +111,13 @@ entries exist, but the report names the earliest stored one and later retries re
111
111
  Admission freshness (`verifyFreshFeedback`) keeps its stricter rule: any earlier record of the event,
112
112
  whatever its timestamp, is a conflict.
113
113
 
114
+ A session that already exists with a different `version` (an agent upgrade, or a session another
115
+ writer created) is not a conflict: the entry is delivered into it and the result reports
116
+ `storedVersion`, in `journal_append` and in `vouchington agent-blackboard journal append`. A
117
+ different `parentSessionId` or `agent` is an `identity-conflict`, and the error names each
118
+ differing field with its stored and supplied value, for example
119
+ `identity-conflict: agent stored "claude", supplied "codex"`.
120
+
114
121
  `journal_entries` returns `{ sessionId, entries }`. Each entry is exactly what the `agent-blackboard`
115
122
  client returns (`sessionId`, `createdAt`, `data`), so `data` keeps its envelope fields such as
116
123
  `repositories`, `sourceEventId`, `workOutcome`, and `feedbackCoverage`. Entries of every type come
@@ -124,8 +131,13 @@ unsent records of the caller's `sessionId`, and `status` (`empty` or `pending`)
124
131
  `worktreePendingCount` is the number of unsent records of every session in the worktree outbox. Both
125
132
  are 0 in autonomous mode, which has no outbox. `outbox_flush` delivers every retained record in the
126
133
  worktree, whichever session wrote it, so its `deliveredCount` covers the whole flush while
127
- `pendingCount` and `worktreePendingCount` report what remains. A record that cannot be delivered, for
128
- example after an `event-conflict`, stays in the count of the session that wrote it. Flushing an empty
134
+ `pendingCount` and `worktreePendingCount` report what remains. A record that fails transiently
135
+ (unavailable, timeout, auth) stays in the count of the session that wrote it. A permanent rejection
136
+ (`identity-conflict`, `event-conflict`, `archived-session`), whether it happens on `journal_append`
137
+ or during `outbox_flush`, moves the record out of the pending set into the outbox's rejected area,
138
+ keeping its content. `rejectedCount` and `worktreeRejectedCount` report those records, and a
139
+ corrected retry with the same `sourceEventId` is accepted. `outbox_flush` also returns
140
+ `rejected: [{ sourceEventId, diagnostic }]` for what it moved. Flushing an empty
129
141
  outbox does not create the directory. `snapshot_export` never accepts a destination: the
130
142
  `agent-blackboard` client chooses a private file and returns its path. `session_archive` is the one
131
143
  destructive tool; archived metadata is immutable, while entries stay appendable.
@@ -0,0 +1,16 @@
1
+ # PostgreSQL replay context
2
+
3
+ `createPostgresReplayContext` projects a caller-owned schema snapshot and generated-column
4
+ reference facts into the two lookups used by replay checks. It does not load snapshots, parse SQL,
5
+ or decide INSERT policy.
6
+
7
+ The caller supplies one `{ table, column, sourceColumns }` fact for each snapshot column whose
8
+ `generated` value is `stored` and whose `generatedExpression` is present. Build `sourceColumns` from
9
+ the parser's typed expression column references, retaining each reference's final identifier
10
+ component. Supply an empty array for a valid expression with no column references. Missing facts
11
+ throw when that table's dependencies are requested, so parser failures cannot silently become an
12
+ empty dependency set.
13
+
14
+ Unknown tables return `undefined` from both lookups. Known tables return their trigger text and a
15
+ map of STORED generated columns to nonempty dependency sets; VIRTUAL columns are excluded. A known
16
+ table with no STORED dependencies returns an empty map. Dependency maps are memoized per table.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
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": {
@@ -26,5 +26,21 @@ evidence record.
26
26
  Load only the references required by the current phase. Keep scope changes explicit; a discovered
27
27
  blocker or follow-up does not authorize widening the task.
28
28
 
29
+ ## Parallel by default
30
+
31
+ Split work into independent units and run them concurrently, one worker and worktree each, within
32
+ any cap the human set. Dispatching to a peer session in another repository is a unit.
33
+
34
+ Serialize only on a named dependency:
35
+
36
+ - A consumer change needs an upstream API or fix that is not yet released.
37
+ - Units change the same files or contract. Stack them instead of waiting.
38
+ - Units would write in the same worktree.
39
+
40
+ For upstream work, open every independent pull request at once, shepherd them concurrently, merge
41
+ the ready set together, and cut one release. Start each consumer adoption once an admitted release
42
+ contains what it needs; start consumer work that does not need the unreleased change now. A ready,
43
+ queued, or shepherding pull request does not pause other units.
44
+
29
45
  Consumer wrapper or local instructions own: default branch, runner class, documentation root,
30
46
  review system, merge policy, and command catalog.
@@ -29,6 +29,10 @@ Every call takes an exact `sessionId`, never inferred, whose meaning depends on
29
29
  worktree, so `outbox_status` and `outbox_flush` report two counts: `pendingCount` is the unsent
30
30
  records of that `sessionId`, and `worktreePendingCount` is the unsent records of every session in
31
31
  the worktree outbox. `outbox_flush` delivers every retained record in the worktree.
32
+ A permanently rejected record (`identity-conflict`, `event-conflict`, `archived-session`) is moved out
33
+ of the pending set, not retained: `rejectedCount` and `worktreeRejectedCount` count the kept copies,
34
+ `outbox_flush` reports them as `rejected` (`sourceEventId` and `diagnostic`), and a corrected retry
35
+ with the same `sourceEventId` is accepted.
32
36
 
33
37
  Every call may take `worktree`, the absolute path of a worktree of the current repository (the
34
38
  launch worktree by default). Writing or retrying an entry needs no provider skill, temporary file,
@@ -121,7 +125,10 @@ retention, or delivery checks because the transport changed.
121
125
  `journal_append` ensures that session and merges the entry's `repositories` into the session's
122
126
  cumulative repository union, so list every repository the entry concerns. Never infer another
123
127
  repository from prose or create a child identity from a model guess. Call `session_ensure` with
124
- the exact identity only when a runner must confirm the session before work starts.
128
+ the exact identity only when a runner must confirm the session before work starts. A session
129
+ that already stores a different `version` still receives the entry, and the result reports its
130
+ `storedVersion`. A different `parentSessionId` or `agent` is an `identity-conflict` whose error
131
+ names each differing field with its stored and supplied values.
125
132
  4. Select the repository's trusted `mode` (`interactive` or `autonomous`) explicitly. In interactive
126
133
  mode an outage may retain the validated, sanitized record in the bounded durable outbox, and
127
134
  `journal_append` reports it as pending. Report `pendingCount` (this session's unsent records),
@@ -18,7 +18,8 @@ diagram, independent reviewer, or separate issue are required by this skill.
18
18
  fresh base. Record which evidence supports each proposed path.
19
19
  3. Map affected owners, contracts, operational boundaries, and tests. Use independent review or
20
20
  exploration when the change is cross-cutting or uncertain and local policy calls for it.
21
- 4. Record the chosen approach, steps, validation, and unresolved decisions. Add rollout details
21
+ 4. Record the chosen approach, steps, validation, and unresolved decisions. Mark which steps run
22
+ concurrently and name the dependency that serializes each remaining step. Add rollout details
22
23
  only when an established deployment contract requires them.
23
24
  5. Save the plan once outside Git: an existing issue, a PR description, or a durable native plan
24
25
  file outside the repository. Do not commit task plans or count temporary scratch files as saved