reviewzy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/LICENSE +661 -0
  3. package/README.md +170 -0
  4. package/bin/reviewzy +7 -0
  5. package/package.json +29 -0
  6. package/src/config.ts +76 -0
  7. package/src/daemon/app.ts +159 -0
  8. package/src/daemon/main.ts +103 -0
  9. package/src/daemon/origin.ts +60 -0
  10. package/src/dashboard/auth.ts +122 -0
  11. package/src/dashboard/mount.ts +373 -0
  12. package/src/dashboard/static/components.css +1116 -0
  13. package/src/dashboard/static/dashboard.css +182 -0
  14. package/src/dashboard/static/dashboard.js +169 -0
  15. package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
  16. package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
  17. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  18. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  19. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  20. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  21. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  22. package/src/dashboard/static/htmx.min.js +2 -0
  23. package/src/dashboard/static/tokens.css +213 -0
  24. package/src/dashboard/views.tsx +1388 -0
  25. package/src/db/human-save.ts +242 -0
  26. package/src/db/migrate.ts +71 -0
  27. package/src/db/migrations.ts +149 -0
  28. package/src/db/queries.ts +425 -0
  29. package/src/db/store.ts +83 -0
  30. package/src/db/style-guide.ts +236 -0
  31. package/src/db/sweep.ts +88 -0
  32. package/src/mcp/await-approved.ts +156 -0
  33. package/src/mcp/fetch-approved.ts +132 -0
  34. package/src/mcp/file-entries.ts +219 -0
  35. package/src/mcp/list-entries.ts +107 -0
  36. package/src/mcp/mark-applied.ts +103 -0
  37. package/src/mcp/route.ts +88 -0
  38. package/src/mcp/server.ts +59 -0
  39. package/src/mcp/style-guide-resource.ts +40 -0
  40. package/src/notify.ts +142 -0
  41. package/src/shim/daemon.ts +318 -0
  42. package/src/shim/lockfile.ts +153 -0
  43. package/src/shim/main.ts +172 -0
  44. package/src/shim/semver.ts +29 -0
  45. package/src/version.ts +4 -0
  46. package/tsconfig.json +26 -0
@@ -0,0 +1,242 @@
1
+ import { ulid } from "ulid";
2
+ import type { EntryStatus } from "./queries.ts";
3
+ import type { Store } from "./store.ts";
4
+
5
+ export type HumanSaveInput = {
6
+ readonly id: string;
7
+ readonly text: string;
8
+ };
9
+
10
+ /**
11
+ * Everything `saveHumanText` refuses, with the values the dashboard's message needs: `unknown`
12
+ * became a page's 404; `status` names the terminal status; `max_len` carries the limit and the
13
+ * length actually seen; `placeholder` names the first declared placeholder the text misses. The
14
+ * route layer formats these into user-facing messages; the store never touches prose.
15
+ */
16
+ export type HumanSaveRefusal =
17
+ | { readonly kind: "unknown" }
18
+ | { readonly kind: "status"; readonly status: "applied" | "rejected" }
19
+ | { readonly kind: "max_len"; readonly maxLen: number; readonly length: number }
20
+ | { readonly kind: "placeholder"; readonly placeholder: string }
21
+ | { readonly kind: "empty" };
22
+
23
+ /**
24
+ * `saved` distinguishes a write from a no-op: text identical to the stored `human_text` writes
25
+ * nothing and returns the current state; anything else wrote exactly one revision row, approved
26
+ * the entry, and dispatched the status-change event.
27
+ */
28
+ export type HumanSaveOutcome =
29
+ | { readonly ok: true; readonly saved: boolean; readonly status: EntryStatus; readonly text: string }
30
+ | { readonly ok: false; readonly refusal: HumanSaveRefusal };
31
+
32
+ /** One `entry_revisions` row as the history pane reads it, newest first. */
33
+ export type RevisionRow = {
34
+ readonly id: string;
35
+ readonly human_text: string;
36
+ readonly status: EntryStatus;
37
+ readonly created_at: number;
38
+ };
39
+
40
+ /**
41
+ * The constraints the editor enforces and displays. `max_len` and `placeholders` are enforced at
42
+ * save; `tone` and `notes` are shown only. `undefined`/absent means "no constraint": the stored
43
+ * JSON is foreign data and may be malformed, and an unparseable or shape-broken value must read as
44
+ * no constraint, never as a 500.
45
+ */
46
+ export type Constraints = {
47
+ readonly maxLen: number | undefined;
48
+ readonly placeholders: readonly string[];
49
+ readonly tone: string | undefined;
50
+ readonly notes: string | undefined;
51
+ };
52
+
53
+ const NO_CONSTRAINTS: Constraints = { maxLen: undefined, placeholders: [], tone: undefined, notes: undefined };
54
+
55
+ /**
56
+ * The tolerant parse for `entries.constraints`: malformed JSON, non-objects, and wrongly-typed
57
+ * members all fall back to "no constraint" (the same stance `style-guide.ts`'s glossary and banned
58
+ * words take). `max_len` must be a positive integer; `placeholders` keeps the non-empty strings in
59
+ * order, duplicates dropped, so the checklist never repeats a token.
60
+ */
61
+ export function parseConstraints(json: string): Constraints {
62
+ let parsed: unknown;
63
+ try {
64
+ parsed = JSON.parse(json);
65
+ } catch {
66
+ return NO_CONSTRAINTS;
67
+ }
68
+ if (typeof parsed !== "object" || parsed === null) return NO_CONSTRAINTS;
69
+ const raw = parsed as Record<string, unknown>;
70
+ const maxLen = typeof raw.max_len === "number" && Number.isInteger(raw.max_len) && raw.max_len > 0
71
+ ? raw.max_len
72
+ : undefined;
73
+ const placeholders: string[] = [];
74
+ if (Array.isArray(raw.placeholders)) {
75
+ for (const placeholder of raw.placeholders) {
76
+ if (typeof placeholder === "string" && placeholder !== "" && !placeholders.includes(placeholder)) {
77
+ placeholders.push(placeholder);
78
+ }
79
+ }
80
+ }
81
+ const tone = typeof raw.tone === "string" ? raw.tone : undefined;
82
+ const notes = typeof raw.notes === "string" ? raw.notes : undefined;
83
+ return { maxLen, placeholders, tone, notes };
84
+ }
85
+
86
+ /**
87
+ * Everything an approve or reject refuses, with the value the dashboard's message needs: `unknown`
88
+ * became a page's gone-fragment, `status` names the status that blocked the move (never `draft`,
89
+ * which is the source state both transitions act on), `no_draft` the entry whose agent never
90
+ * produced text. The route layer formats these into user-facing messages; the store never touches
91
+ * prose.
92
+ */
93
+ export type TransitionRefusal =
94
+ | { readonly kind: "unknown" }
95
+ | { readonly kind: "status"; readonly status: Exclude<EntryStatus, "draft"> }
96
+ | { readonly kind: "no_draft" };
97
+
98
+ /**
99
+ * `approveEntry`'s outcome. `ok` carries the status the entry now holds; a refusal means nothing
100
+ * was written, not even `updated_at`.
101
+ */
102
+ export type ApproveOutcome = { readonly ok: true; readonly status: "approved" } | { readonly ok: false; readonly refusal: TransitionRefusal };
103
+
104
+ /** `rejectEntry`'s outcome, mirroring `ApproveOutcome`. */
105
+ export type RejectOutcome = { readonly ok: true; readonly status: "rejected" } | { readonly ok: false; readonly refusal: TransitionRefusal };
106
+
107
+ /**
108
+ * One entry's result in a batch approve, keyed by the id the caller passed. `ok` reports the entry
109
+ * approved; a refusal names why and carries `id` so the route can point at the entry without
110
+ * re-reading a row it was just told to leave alone.
111
+ */
112
+ export type BatchApproveResult = { readonly id: string; readonly ok: true; readonly status: "approved" } | { readonly id: string; readonly ok: false; readonly refusal: TransitionRefusal };
113
+
114
+ /**
115
+ * The dashboard's one save path, owned here so no route decides a status change. The refusal order
116
+ * is deliberate: unknown id, then a terminal status (nothing is ever written on those), then empty
117
+ * text, then the identical-text no-op, then the constraint checks. A save that passes all of them
118
+ * inserts exactly one revision row recording the status the entry held at that moment (a draft save
119
+ * records "draft"), updates the entry to approved, and dispatches the status-change event after the
120
+ * write so a waiter's re-read sees the new status. The insert and the update are one transaction:
121
+ * a crash between them must not leave a revision without its approval. Old revision rows are never
122
+ * mutated; "undo" is restoring a revision's text, which writes a new row like any other save.
123
+ */
124
+ export function saveHumanText(store: Store, input: HumanSaveInput): HumanSaveOutcome {
125
+ const row = store.db.query("SELECT status, human_text, constraints FROM entries WHERE id = ?").get(input.id) as
126
+ | { status: EntryStatus; human_text: string | null; constraints: string }
127
+ | null;
128
+ if (row === null) return { ok: false, refusal: { kind: "unknown" } };
129
+
130
+ // `draft` and `approved` are the two statuses the dashboard owns; the agents own the rest, so
131
+ // anything else is terminal from this side.
132
+ if (row.status !== "draft" && row.status !== "approved") {
133
+ return { ok: false, refusal: { kind: "status", status: row.status } };
134
+ }
135
+
136
+ if (input.text.trim() === "") {
137
+ return { ok: false, refusal: { kind: "empty" } };
138
+ }
139
+
140
+ // The no-op save: identical text writes nothing, returns the current state, and fires no event.
141
+ if (input.text === row.human_text) {
142
+ return { ok: true, saved: false, status: row.status, text: input.text };
143
+ }
144
+
145
+ const constraints = parseConstraints(row.constraints);
146
+ if (constraints.maxLen !== undefined && input.text.length > constraints.maxLen) {
147
+ return { ok: false, refusal: { kind: "max_len", maxLen: constraints.maxLen, length: input.text.length } };
148
+ }
149
+ for (const placeholder of constraints.placeholders) {
150
+ if (!input.text.includes(placeholder)) {
151
+ return { ok: false, refusal: { kind: "placeholder", placeholder } };
152
+ }
153
+ }
154
+
155
+ // The revision's status is the entry's status at this moment, before the transition: the save
156
+ // that approves a draft records "draft", a re-save on an approved entry records "approved".
157
+ const recordedStatus = row.status;
158
+ const now = Date.now();
159
+ store.db.transaction(() => {
160
+ store.db.run(
161
+ "INSERT INTO entry_revisions (id, entry_id, human_text, status, created_at) VALUES (?, ?, ?, ?, ?)",
162
+ [ulid(), input.id, input.text, recordedStatus, now],
163
+ );
164
+ store.db.run("UPDATE entries SET human_text = ?, status = 'approved', updated_at = ? WHERE id = ?", [
165
+ input.text,
166
+ now,
167
+ input.id,
168
+ ]);
169
+ })();
170
+ store.notifyStatusChange(input.id, "approved");
171
+ return { ok: true, saved: true, status: "approved", text: input.text };
172
+ }
173
+
174
+ /**
175
+ * Approves a draft: `human_text` takes the agent's draft verbatim, the entry becomes approved, and
176
+ * the status-change event fires after the write so a waiter's re-read sees the new status. The
177
+ * single UPDATE needs no explicit transaction. Refusals: unknown id, then a status that is not
178
+ * draft, then an entry whose agent never produced a draft; nothing is written on any of them, not
179
+ * even `updated_at`. Approving authors no prose, so it logs no revision row; the save that approves
180
+ * is the only revision writer.
181
+ */
182
+ export function approveEntry(store: Store, id: string): ApproveOutcome {
183
+ const row = store.db.query("SELECT status, agent_draft FROM entries WHERE id = ?").get(id) as
184
+ | { status: EntryStatus; agent_draft: string | null }
185
+ | null;
186
+ if (row === null) return { ok: false, refusal: { kind: "unknown" } };
187
+ if (row.status !== "draft") return { ok: false, refusal: { kind: "status", status: row.status } };
188
+ if (row.agent_draft === null) return { ok: false, refusal: { kind: "no_draft" } };
189
+
190
+ store.db.run("UPDATE entries SET human_text = ?, status = 'approved', updated_at = ? WHERE id = ?", [
191
+ row.agent_draft,
192
+ Date.now(),
193
+ id,
194
+ ]);
195
+ store.notifyStatusChange(id, "approved");
196
+ return { ok: true, status: "approved" };
197
+ }
198
+
199
+ /**
200
+ * Rejects a draft or an approved entry: the entry becomes rejected, and the status-change event
201
+ * fires after the write. Rejection is terminal and erases nothing (the draft and any human text
202
+ * stay in the row; a new entry is the way to start again), so it logs no revision row. Refusals:
203
+ * unknown id, then a status that is not draft or approved; nothing is written on either.
204
+ */
205
+ export function rejectEntry(store: Store, id: string): RejectOutcome {
206
+ const row = store.db.query("SELECT status FROM entries WHERE id = ?").get(id) as { status: EntryStatus } | null;
207
+ if (row === null) return { ok: false, refusal: { kind: "unknown" } };
208
+ if (row.status !== "draft" && row.status !== "approved") {
209
+ return { ok: false, refusal: { kind: "status", status: row.status } };
210
+ }
211
+
212
+ store.db.run("UPDATE entries SET status = 'rejected', updated_at = ? WHERE id = ?", [Date.now(), id]);
213
+ store.notifyStatusChange(id, "rejected");
214
+ return { ok: true, status: "rejected" };
215
+ }
216
+
217
+ /**
218
+ * The batch approve: each id goes through `approveEntry` independently, so one entry's refusal
219
+ * never blocks another's approval and a race can only refuse the entry it actually hit. Results
220
+ * come back in the ids' order; the caller reports per entry. An empty selection approves nothing.
221
+ */
222
+ export function batchApproveEntries(store: Store, ids: readonly string[]): BatchApproveResult[] {
223
+ return ids.map((id) => {
224
+ const outcome = approveEntry(store, id);
225
+ if (outcome.ok) return { id, ok: true, status: "approved" };
226
+ return { id, ok: false, refusal: outcome.refusal };
227
+ });
228
+ }
229
+
230
+ /**
231
+ * The history pane's read: the entry's revision rows, newest first, `rowid` as the same-millisecond
232
+ * tie-break. `id` cannot break that tie: a ulid's random bits never encode recency, so ordering on
233
+ * it shuffles a history saved within one millisecond (same trap as the task-11 batch walk).
234
+ * `rowid` is insertion order, which is exactly the recency the pane promises.
235
+ */
236
+ export function listRevisions(store: Store, entryId: string): RevisionRow[] {
237
+ return store.db
238
+ .query(
239
+ "SELECT id, human_text, status, created_at FROM entry_revisions WHERE entry_id = ? ORDER BY created_at DESC, rowid DESC",
240
+ )
241
+ .all(entryId) as RevisionRow[];
242
+ }
@@ -0,0 +1,71 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { MIGRATIONS, type Migration } from "./migrations.ts";
3
+
4
+ /** A migration's SQL (or its version bump) failed; the whole migration rolled back. */
5
+ export class MigrationError extends Error {
6
+ override readonly name = "MigrationError";
7
+ }
8
+
9
+ // sqlite's `user_version` is a signed 32-bit integer; a value at or above 2^31 cannot round-trip
10
+ // (it silently reads back as something else, and non-finite/non-integer values silently write 0).
11
+ const MAX_MIGRATION_VERSION = 2 ** 31 - 1;
12
+
13
+ /**
14
+ * Refuses the whole set before applying anything if any version is malformed, duplicated, or out
15
+ * of order, so a bad entry can never partially apply.
16
+ */
17
+ function assertWellFormed(migrations: readonly Migration[]): void {
18
+ let previous = 0;
19
+ for (const migration of migrations) {
20
+ if (!Number.isInteger(migration.version) || migration.version < 1 || migration.version > MAX_MIGRATION_VERSION) {
21
+ throw new MigrationError(
22
+ `reviewzy: migration "${migration.name}" has an invalid version (${migration.version}); versions must be finite integers between 1 and ${MAX_MIGRATION_VERSION}.`,
23
+ );
24
+ }
25
+ if (migration.version <= previous) {
26
+ throw new MigrationError(
27
+ `reviewzy: migration ${migration.version} (${migration.name}) is out of order or duplicates a version already seen; migrations must have strictly ascending version numbers.`,
28
+ );
29
+ }
30
+ previous = migration.version;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Applies every migration newer than `PRAGMA user_version`, oldest first, each inside its own
36
+ * transaction so a failure rolls back cleanly and never leaves a half-applied schema. A
37
+ * migration already recorded in `user_version` is skipped, never re-run.
38
+ */
39
+ export function runMigrations(db: Database, migrations: readonly Migration[] = MIGRATIONS): void {
40
+ assertWellFormed(migrations);
41
+
42
+ const current = (db.query("PRAGMA user_version").get() as { user_version: number }).user_version;
43
+
44
+ // A database written by a newer reviewzy carries a version this build has no migration for;
45
+ // booting anyway would silently run with no schema at all.
46
+ const highestKnown = migrations.at(-1)?.version ?? 0;
47
+ if (current > highestKnown) {
48
+ throw new MigrationError(
49
+ `reviewzy: this database is at schema version ${current}, newer than the highest migration this build knows (${highestKnown}); it was written by a newer reviewzy. Upgrade reviewzy, or point REVIEWZY_DB at a different file.`,
50
+ );
51
+ }
52
+
53
+ // `assertWellFormed` already guarantees `migrations` is strictly ascending, so `filter` alone
54
+ // preserves application order without a separate sort.
55
+ const pending = migrations.filter((m) => m.version > current);
56
+
57
+ for (const migration of pending) {
58
+ const apply = db.transaction(() => {
59
+ db.run(migration.sql);
60
+ // PRAGMA does not accept bound parameters; `version` is validated above, never user input.
61
+ db.run(`PRAGMA user_version = ${migration.version}`);
62
+ });
63
+
64
+ try {
65
+ apply();
66
+ } catch (error) {
67
+ const detail = error instanceof Error ? error.message : String(error);
68
+ throw new MigrationError(`reviewzy: migration ${migration.version} (${migration.name}) failed: ${detail}`);
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,149 @@
1
+ export type Migration = {
2
+ readonly version: number;
3
+ readonly name: string;
4
+ readonly sql: string;
5
+ };
6
+
7
+ const STATUS_VALUES = "'draft', 'approved', 'applied', 'rejected'";
8
+ const STATUS_CHECK = `status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN (${STATUS_VALUES}))`;
9
+ const REVISION_STATUS_CHECK = `status TEXT NOT NULL CHECK (status IN (${STATUS_VALUES}))`;
10
+
11
+ // sqlite INTEGER affinity only converts a well-formed integer literal; an ISO string or a float
12
+ // stores as-is otherwise, so the "timestamps are integer ms" decision needs its own CHECK.
13
+ const tsRequired = (col: string) => `${col} INTEGER NOT NULL CHECK (typeof(${col}) = 'integer')`;
14
+ const tsNullable = (col: string) => `${col} INTEGER CHECK (${col} IS NULL OR typeof(${col}) = 'integer')`;
15
+
16
+ /**
17
+ * `docs/mcp-contract.md` "entry schema" and "identity key" own these columns; do not add,
18
+ * rename, or drop a field here without updating that frozen doc first.
19
+ */
20
+ export const MIGRATIONS: Migration[] = [
21
+ {
22
+ version: 1,
23
+ name: "init",
24
+ sql: `
25
+ CREATE TABLE projects (
26
+ id TEXT PRIMARY KEY NOT NULL,
27
+ slug TEXT NOT NULL UNIQUE,
28
+ ${tsRequired("created_at")}
29
+ );
30
+
31
+ CREATE TABLE entries (
32
+ id TEXT PRIMARY KEY NOT NULL,
33
+ project_id TEXT NOT NULL REFERENCES projects(id),
34
+ batch_id TEXT NOT NULL,
35
+ repo TEXT NOT NULL,
36
+ file TEXT NOT NULL,
37
+ anchor_text TEXT NOT NULL,
38
+ anchor_before TEXT NOT NULL,
39
+ anchor_after TEXT NOT NULL,
40
+ anchor_hash TEXT NOT NULL,
41
+ file_hash TEXT NOT NULL,
42
+ agent_draft TEXT,
43
+ human_text TEXT,
44
+ ${STATUS_CHECK},
45
+ context TEXT NOT NULL,
46
+ constraints TEXT NOT NULL,
47
+ filed_by TEXT,
48
+ stale_note TEXT,
49
+ ${tsRequired("created_at")},
50
+ ${tsRequired("updated_at")},
51
+ ${tsNullable("applied_at")},
52
+ ${tsNullable("archived_at")},
53
+ UNIQUE (project_id, repo, file, anchor_hash)
54
+ );
55
+
56
+ -- entry_id keeps a plain NO ACTION foreign key: a sweep that archives an entry without
57
+ -- first moving its revisions must fail loudly rather than orphan or destroy them
58
+ -- (cloudy, 2026-08-15; mcp-contract "three more tables").
59
+ CREATE TABLE entry_revisions (
60
+ id TEXT PRIMARY KEY NOT NULL,
61
+ entry_id TEXT NOT NULL REFERENCES entries(id),
62
+ human_text TEXT NOT NULL,
63
+ ${REVISION_STATUS_CHECK},
64
+ ${tsRequired("created_at")}
65
+ );
66
+
67
+ -- entry_revisions has exactly one access path (by entry_id, task 12's history pane) and no
68
+ -- autoindex covers it.
69
+ CREATE INDEX entry_revisions_entry_id_idx ON entry_revisions(entry_id);
70
+
71
+ CREATE TABLE entries_archive (
72
+ id TEXT PRIMARY KEY NOT NULL,
73
+ project_id TEXT NOT NULL REFERENCES projects(id),
74
+ batch_id TEXT NOT NULL,
75
+ repo TEXT NOT NULL,
76
+ file TEXT NOT NULL,
77
+ anchor_text TEXT NOT NULL,
78
+ anchor_before TEXT NOT NULL,
79
+ anchor_after TEXT NOT NULL,
80
+ anchor_hash TEXT NOT NULL,
81
+ file_hash TEXT NOT NULL,
82
+ agent_draft TEXT,
83
+ human_text TEXT,
84
+ ${STATUS_CHECK},
85
+ context TEXT NOT NULL,
86
+ constraints TEXT NOT NULL,
87
+ filed_by TEXT,
88
+ stale_note TEXT,
89
+ ${tsRequired("created_at")},
90
+ ${tsRequired("updated_at")},
91
+ ${tsNullable("applied_at")},
92
+ ${tsRequired("archived_at")}
93
+ );
94
+
95
+ -- Mirrors entry_revisions plus archived_at; a sweep moves an entry's revisions here in the
96
+ -- same transaction as the entry's own move, so the history has no expiry
97
+ -- (cloudy, 2026-08-15; docs/design.md "revisions").
98
+ CREATE TABLE entry_revisions_archive (
99
+ id TEXT PRIMARY KEY NOT NULL,
100
+ entry_id TEXT NOT NULL REFERENCES entries_archive(id),
101
+ human_text TEXT NOT NULL,
102
+ ${REVISION_STATUS_CHECK},
103
+ ${tsRequired("created_at")},
104
+ ${tsRequired("archived_at")}
105
+ );
106
+ `,
107
+ },
108
+ {
109
+ version: 2,
110
+ name: "style-guides",
111
+ sql: `
112
+ -- One global row (project_id NULL, inherited by every project) plus one row per project;
113
+ -- the merge that serves both lives in src/db/style-guide.ts. The json columns are validated
114
+ -- at write time (the editor's boundary), so the read path must tolerate any stored string
115
+ -- instead of trusting the defaults.
116
+ CREATE TABLE style_guides (
117
+ id TEXT PRIMARY KEY NOT NULL,
118
+ project_id TEXT REFERENCES projects(id),
119
+ markdown TEXT NOT NULL DEFAULT '',
120
+ banned_words TEXT NOT NULL DEFAULT '[]',
121
+ glossary TEXT NOT NULL DEFAULT '{}',
122
+ ${tsRequired("created_at")},
123
+ ${tsRequired("updated_at")},
124
+ UNIQUE (project_id)
125
+ );
126
+
127
+ -- sqlite's UNIQUE lets any number of NULL project_id rows through, and its unique indexes
128
+ -- treat NULLs as distinct from each other too, so the partial index cannot key on the bare
129
+ -- column: ON style_guides(project_id) WHERE project_id IS NULL would still admit unlimited
130
+ -- global rows (measured on a bare sqlite). Keying the partial index on the constant boolean
131
+ -- expression instead makes every global row share one index key, so the UNIQUE pins at most
132
+ -- one. Project rows never enter this index; UNIQUE(project_id) owns them.
133
+ CREATE UNIQUE INDEX style_guides_global_idx ON style_guides(project_id IS NULL) WHERE project_id IS NULL;
134
+ `,
135
+ },
136
+ {
137
+ version: 3,
138
+ name: "mark-applied",
139
+ sql: `
140
+ -- mark_applied stores the hash of the text the agent applied; the contract's param table
141
+ -- listed applied_hash a day before the entry schema had a column to hold it (cloudy,
142
+ -- 2026-08-16), so the column lands now, with the doc edit in the same round.
143
+ ALTER TABLE entries ADD COLUMN applied_hash TEXT;
144
+ -- entries_archive mirrors entries column-for-column: the retention sweep copies applied
145
+ -- rows over whole, so it must not lose the new column.
146
+ ALTER TABLE entries_archive ADD COLUMN applied_hash TEXT;
147
+ `,
148
+ },
149
+ ];