vouchington-tooling 0.36.0 → 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.
- package/README.md +21 -0
- package/dist/agent-blackboard/client.d.mts +6 -1
- package/dist/agent-blackboard/feedback-delivery.d.mts +4 -0
- package/dist/agent-blackboard/feedback-delivery.mjs +13 -5
- package/dist/agent-blackboard/feedback-online-error.d.mts +1 -1
- package/dist/agent-blackboard/feedback-online-error.mjs +2 -2
- package/dist/agent-blackboard/feedback-online.mjs +32 -13
- package/dist/agent-blackboard/feedback-outbox-counts.d.mts +4 -1
- package/dist/agent-blackboard/feedback-outbox-counts.mjs +6 -1
- package/dist/agent-blackboard/feedback-outbox-rejected.d.mts +9 -0
- package/dist/agent-blackboard/feedback-outbox-rejected.mjs +34 -0
- package/dist/agent-blackboard/feedback-outbox.d.mts +14 -1
- package/dist/agent-blackboard/feedback-outbox.mjs +12 -8
- package/dist/agent-blackboard/feedback-types.d.mts +2 -0
- package/dist/cli/commands/agent-blackboard.mjs +9 -2
- package/dist/mcp-server/dispatch.mjs +1 -1
- package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +1 -1
- package/dist/mcp-server/fake-blackboard.test-helpers.mjs +6 -1
- package/dist/mcp-server/handlers-journal.mjs +9 -1
- package/dist/mcp-server/handlers-outbox.d.mts +1 -0
- package/dist/mcp-server/handlers-outbox.mjs +10 -10
- package/dist/mcp-server/tools.mjs +5 -2
- package/dist/pg-schema-snapshot/index.d.mts +2 -0
- package/dist/pg-schema-snapshot/index.mjs +1 -0
- package/dist/pg-schema-snapshot/replay-context.d.mts +23 -0
- package/dist/pg-schema-snapshot/replay-context.mjs +45 -0
- package/dist/sql-ast/index-shapes.d.mts +34 -0
- package/dist/sql-ast/index-shapes.mjs +85 -0
- package/dist/sql-ast/index.d.mts +3 -0
- package/dist/sql-ast/index.mjs +2 -0
- package/dist/sql-ast/parser.d.mts +2 -0
- package/dist/sql-ast/parser.mjs +11 -1
- package/dist/sql-ast/view-declarations.d.mts +11 -0
- package/dist/sql-ast/view-declarations.mjs +67 -0
- package/docs/mcp-server.md +23 -11
- package/docs/postgres-replay-context.md +16 -0
- package/package.json +2 -2
- package/skills/agent-workflow/SKILL.md +16 -0
- package/skills/blackboard/SKILL.md +8 -1
- 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:
|
|
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 {
|
|
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 (
|
|
34
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
128
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
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.'
|
|
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.'
|
|
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
|
+
}
|
package/dist/sql-ast/index.d.mts
CHANGED
|
@@ -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';
|
package/dist/sql-ast/index.mjs
CHANGED
|
@@ -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 {};
|
package/dist/sql-ast/parser.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/docs/mcp-server.md
CHANGED
|
@@ -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
|
|
128
|
-
|
|
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.
|
|
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": {
|
|
@@ -359,7 +359,7 @@
|
|
|
359
359
|
"dependencies": {
|
|
360
360
|
"@types/mdast": "4.0.4",
|
|
361
361
|
"@types/unist": "3.0.3",
|
|
362
|
-
"@typescript-eslint/parser": "^8.71.
|
|
362
|
+
"@typescript-eslint/parser": "^8.71.0",
|
|
363
363
|
"csv-parse": "7.0.3",
|
|
364
364
|
"csv-stringify": "6.9.0",
|
|
365
365
|
"dockerfile-ast": "0.7.1",
|
|
@@ -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),
|
package/skills/planning/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|