hippo-memory 1.55.0 → 1.57.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 (77) hide show
  1. package/README.md +11 -0
  2. package/dist/api.d.ts +19 -9
  3. package/dist/api.js +112 -35
  4. package/dist/card-detail.d.ts +1 -1
  5. package/dist/card-detail.js +1 -1
  6. package/dist/cli/shared.d.ts +137 -0
  7. package/dist/cli/shared.js +830 -0
  8. package/dist/cli/sleep.d.ts +10 -0
  9. package/dist/cli/sleep.js +171 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +313 -1806
  12. package/dist/config.d.ts +5 -0
  13. package/dist/config.js +21 -0
  14. package/dist/connectors/github/webhook.d.ts +19 -0
  15. package/dist/connectors/github/webhook.js +313 -0
  16. package/dist/connectors/slack/webhook.d.ts +22 -0
  17. package/dist/connectors/slack/webhook.js +203 -0
  18. package/dist/consolidate.js +3 -2
  19. package/dist/context-auto.d.ts +3 -0
  20. package/dist/context-auto.js +34 -0
  21. package/dist/customer-notes.js +2 -1
  22. package/dist/dashboard.js +2 -1
  23. package/dist/db.js +67 -1
  24. package/dist/decisions.js +2 -1
  25. package/dist/delivery-recorder.d.ts +127 -0
  26. package/dist/delivery-recorder.js +218 -0
  27. package/dist/eval-stats.d.ts +58 -0
  28. package/dist/eval-stats.js +111 -0
  29. package/dist/goals.d.ts +49 -25
  30. package/dist/goals.js +39 -22
  31. package/dist/graph-extract.js +1 -1
  32. package/dist/graph-recall.d.ts +1 -1
  33. package/dist/graph-recall.js +1 -1
  34. package/dist/graph.js +1 -1
  35. package/dist/hooks.d.ts +1 -3
  36. package/dist/hooks.js +2 -4
  37. package/dist/http-util.d.ts +31 -0
  38. package/dist/http-util.js +46 -0
  39. package/dist/incidents.js +2 -1
  40. package/dist/index.d.ts +5 -2
  41. package/dist/index.js +5 -2
  42. package/dist/mcp/server.js +173 -285
  43. package/dist/memory.d.ts +19 -0
  44. package/dist/memory.js +38 -0
  45. package/dist/policies.js +2 -1
  46. package/dist/predictions.js +2 -1
  47. package/dist/processes.js +2 -1
  48. package/dist/project-briefs.js +3 -1
  49. package/dist/prompt-recall.js +1 -1
  50. package/dist/recall-history.d.ts +5 -0
  51. package/dist/recall-history.js +9 -0
  52. package/dist/recall-pipeline.d.ts +101 -0
  53. package/dist/recall-pipeline.js +313 -0
  54. package/dist/recall-scope.d.ts +22 -0
  55. package/dist/recall-scope.js +27 -1
  56. package/dist/recall-trace.d.ts +69 -0
  57. package/dist/recall-trace.js +136 -0
  58. package/dist/search.d.ts +0 -20
  59. package/dist/search.js +2 -49
  60. package/dist/server.js +1901 -2384
  61. package/dist/skills.js +2 -1
  62. package/dist/store-cards.d.ts +53 -0
  63. package/dist/store-cards.js +512 -0
  64. package/dist/store.d.ts +2 -89
  65. package/dist/store.js +6 -562
  66. package/dist/tenant.d.ts +22 -0
  67. package/dist/tenant.js +26 -0
  68. package/dist/token-ledger.d.ts +2 -0
  69. package/dist/token-ledger.js +5 -0
  70. package/dist/tokenize.d.ts +2 -0
  71. package/dist/tokenize.js +8 -0
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  75. package/extensions/openclaw-plugin/package.json +1 -1
  76. package/openclaw.plugin.json +1 -1
  77. package/package.json +2 -1
package/dist/skills.js CHANGED
@@ -22,7 +22,8 @@
22
22
  * closed (retired). Export renders ACTIVE skills only.
23
23
  */
24
24
  import { openHippoDb, closeHippoDb } from './db.js';
25
- import { writeEntry, assertTenantId } from './store.js';
25
+ import { writeEntry } from './store.js';
26
+ import { assertTenantId } from './tenant.js';
26
27
  import { createMemory, Layer } from './memory.js';
27
28
  import { appendAuditEvent } from './audit.js';
28
29
  import { objectHalfLifeDays } from './half-life-migration.js';
@@ -0,0 +1,53 @@
1
+ import { type DatabaseSyncLike } from './db.js';
2
+ import { SessionHandoff, HandoffOutcome } from './handoff.js';
3
+ import { Card, CardStatus, CardRun, CardComment } from './card.js';
4
+ export declare function transitionCard(db: DatabaseSyncLike, tenantId: string, cardId: string, from: CardStatus[], to: CardStatus, extra?: {
5
+ setSql?: string;
6
+ whereSql?: string;
7
+ params?: unknown[];
8
+ }): number;
9
+ /** Creates a card; status is ready with no deps or once every dependsOn id is done, else backlog. An unknown dependsOn id throws and commits nothing. A repeated dependsOn id is recorded once. */
10
+ export declare function createCard(hippoRoot: string, tenantId: string, input: {
11
+ title: string;
12
+ repo?: string;
13
+ contract?: string;
14
+ budget?: number;
15
+ dependsOn?: string[];
16
+ }): Card;
17
+ /** Returns the card row for id, or null if it does not exist under this tenant. */
18
+ export declare function loadCard(hippoRoot: string, tenantId: string, id: string): Card | null;
19
+ /** Lists cards for this tenant, optionally filtered to one status, newest-updated first. */
20
+ export declare function listCards(hippoRoot: string, tenantId: string, opts?: {
21
+ status?: CardStatus;
22
+ }): Card[];
23
+ /** Returns this card's parent and child ids from card_deps. */
24
+ export declare function loadCardDeps(hippoRoot: string, tenantId: string, id: string): {
25
+ parents: string[];
26
+ children: string[];
27
+ };
28
+ /** Returns this card's run history, most recent first. */
29
+ export declare function loadCardRuns(hippoRoot: string, tenantId: string, id: string): CardRun[];
30
+ /** Returns this card's comments, most recent first. */
31
+ export declare function loadCardComments(hippoRoot: string, tenantId: string, id: string): CardComment[];
32
+ /** Read side of the card <-> handoff round trip: the newest handoff filed against this card. */
33
+ export declare function loadLatestHandoffForCard(hippoRoot: string, tenantId: string, cardId: string): SessionHandoff | null;
34
+ /** Atomic claim: WHERE status IN (ready, blocked) AND assignee_runtime IS NULL decides the race. Throws on an unknown card id; returns null for a card not ready/blocked or already claimed. Sets a CARD_LEASE_MS lease and returns the new run's id as runId. */
35
+ export declare function claimCard(hippoRoot: string, tenantId: string, id: string, runtime: string, sessionId?: string): (Card & {
36
+ runId: number;
37
+ }) | null;
38
+ /** Moves a running card's lease to CARD_LEASE_MS from now and records the heartbeat; updated_at is left alone. Throws on an unknown card id or a run id that is not a positive integer; returns null unless the card is running and runId is its live run. */
39
+ export declare function heartbeatCard(hippoRoot: string, tenantId: string, id: string, runId: number): Card | null;
40
+ /** Requires the card be running; closes the live run as blocked and files reason as a comment. Throws on an unknown card id; returns null for a card not running. When runId is given, returns null unless it is the card's live run. */
41
+ export declare function blockCard(hippoRoot: string, tenantId: string, id: string, reason: string, runId?: number): Card | null;
42
+ /** Requires the card be running; moves it to review, clearing its lease and heartbeat and keeping its live run. When runId is given, returns null unless it is the card's live run. Throws on an unknown card id; returns null for a card not running. */
43
+ export declare function reviewCard(hippoRoot: string, tenantId: string, id: string, runId?: number): Card | null;
44
+ /** Requires the card be in review; closes the live run with outcome. Outcome 'success' moves the card to done and, in the same transaction, promotes any child whose parents are now all done; 'failure' or 'partial' moves it to shelved and promotes nothing. Throws on an unknown card id; returns null for a card not in review. When runId is given, returns null unless it is the card's live run. */
45
+ export declare function completeCard(hippoRoot: string, tenantId: string, id: string, outcome: HandoffOutcome, runId?: number): {
46
+ card: Card;
47
+ promotedChildren: string[];
48
+ } | null;
49
+ /** Returns to ready every running card of the tenant whose lease has expired or is missing: clears its assignee, closes its live run as 'reclaimed' and leaves its handoffs alone, all in one write transaction. Returns the reclaimed card ids in id order. */
50
+ export declare function reclaimExpiredCards(hippoRoot: string, tenantId: string): string[];
51
+ /** Appends a comment to cardId in any card status; throws if cardId is not a card of this tenant. */
52
+ export declare function addCardComment(hippoRoot: string, tenantId: string, cardId: string, author: string, body: string): CardComment;
53
+ //# sourceMappingURL=store-cards.d.ts.map
@@ -0,0 +1,512 @@
1
+ import { generateId } from './memory.js';
2
+ import { closeHippoDb } from './db.js';
3
+ import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
4
+ import { CARD_TRANSITIONS, CARD_LEASE_MS } from './card.js';
5
+ import { assertTenantId } from './tenant.js';
6
+ import { openStore, HANDOFF_COLUMNS } from './store.js';
7
+ const CARD_COLUMNS = 'id, title, status, assignee_runtime, repo, contract, budget, lease_until, heartbeat_at, created_at, updated_at, tenant_id, scope';
8
+ function rowToCard(row) {
9
+ return {
10
+ id: row.id,
11
+ title: row.title,
12
+ status: row.status,
13
+ assigneeRuntime: row.assignee_runtime,
14
+ repo: row.repo,
15
+ contract: row.contract,
16
+ budget: row.budget,
17
+ leaseUntil: row.lease_until,
18
+ heartbeatAt: row.heartbeat_at,
19
+ createdAt: row.created_at,
20
+ updatedAt: row.updated_at,
21
+ tenantId: row.tenant_id,
22
+ scope: row.scope,
23
+ };
24
+ }
25
+ function loadCardRow(db, tenantId, id) {
26
+ // SAFETY: row's shape matches CARD_COLUMNS; status only ever holds a CardStatus value.
27
+ const row = db.prepare(`SELECT ${CARD_COLUMNS} FROM cards WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
28
+ return row ? rowToCard(row) : null;
29
+ }
30
+ function rowToCardRun(row) {
31
+ return {
32
+ id: row.id,
33
+ card: row.card,
34
+ runtime: row.runtime,
35
+ sessionId: row.session_id,
36
+ started: row.started,
37
+ ended: row.ended,
38
+ outcome: row.outcome,
39
+ };
40
+ }
41
+ function rowToCardComment(row) {
42
+ return { id: row.id, cardId: row.card_id, author: row.author, body: row.body, createdAt: row.created_at };
43
+ }
44
+ function insertCardComment(db, tenantId, cardId, author, body) {
45
+ const now = new Date().toISOString();
46
+ const result = db.prepare(`
47
+ INSERT INTO card_comments (card_id, author, body, created_at, tenant_id)
48
+ SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM cards WHERE id = ? AND tenant_id = ?)
49
+ `).run(cardId, author, body, now, tenantId, cardId, tenantId);
50
+ if (Number(result.changes ?? 0) === 0) {
51
+ throw new Error(`unknown card id: ${cardId}`);
52
+ }
53
+ const id = Number(result.lastInsertRowid ?? 0);
54
+ return { id, cardId, author, body, createdAt: now };
55
+ }
56
+ function leaseUntilFrom(now) {
57
+ return new Date(Date.parse(now) + CARD_LEASE_MS).toISOString();
58
+ }
59
+ function assertRunId(runId) {
60
+ if (!Number.isSafeInteger(runId) || runId <= 0) {
61
+ throw new Error(`Invalid run id: ${runId} (expected a positive integer)`);
62
+ }
63
+ }
64
+ // A second run that has not ended means a corrupt store; throw rather than guess which run the caller means.
65
+ function isLiveRun(db, tenantId, cardId, runId) {
66
+ // SAFETY: rows' shape matches the single `id` column named in the SELECT below.
67
+ const rows = db.prepare(`SELECT id FROM card_runs WHERE tenant_id = ? AND card = ? AND ended IS NULL`).all(tenantId, cardId);
68
+ if (rows.length > 1) {
69
+ throw new Error(`card ${cardId} has ${rows.length} runs that have not ended`);
70
+ }
71
+ return rows[0]?.id === runId;
72
+ }
73
+ function closeLiveRun(db, tenantId, cardId, outcome, now) {
74
+ db.prepare(`
75
+ UPDATE card_runs SET ended = ?, outcome = ?, updated_at = ?
76
+ WHERE card = ? AND tenant_id = ? AND ended IS NULL
77
+ `).run(now, outcome, now, cardId, tenantId);
78
+ }
79
+ // The single status-mutating seam (rule 15): CARD_TRANSITIONS is the one
80
+ // runtime authority, so a hand-copied wrong `from` list fails fast here.
81
+ export function transitionCard(db, tenantId, cardId, from, to, extra) {
82
+ for (const status of from) {
83
+ if (!CARD_TRANSITIONS[status].includes(to)) {
84
+ throw new Error(`illegal card transition: ${status} -> ${to}`);
85
+ }
86
+ }
87
+ const now = new Date().toISOString();
88
+ // Lease columns follow status: set on the move to running, cleared on every other move (rule 15).
89
+ const lease = to === 'running' ? [leaseUntilFrom(now), now] : [null, null];
90
+ const fromPlaceholders = from.map(() => '?').join(', ');
91
+ const sql = `
92
+ UPDATE cards SET status = ?, updated_at = ?, lease_until = ?, heartbeat_at = ?${extra?.setSql ? `, ${extra.setSql}` : ''}
93
+ WHERE id = ? AND tenant_id = ? AND status IN (${fromPlaceholders})${extra?.whereSql ? ` AND ${extra.whereSql}` : ''}
94
+ `;
95
+ const params = [to, now, ...lease, ...(extra?.params ?? []), cardId, tenantId, ...from];
96
+ const result = db.prepare(sql).run(...params);
97
+ return Number(result.changes ?? 0);
98
+ }
99
+ /** Creates a card; status is ready with no deps or once every dependsOn id is done, else backlog. An unknown dependsOn id throws and commits nothing. A repeated dependsOn id is recorded once. */
100
+ export function createCard(hippoRoot, tenantId, input) {
101
+ assertTenantId('createCard', tenantId);
102
+ if (input.title.trim() === '') {
103
+ throw new Error('title must not be empty');
104
+ }
105
+ if (input.budget !== undefined && !(Number.isSafeInteger(input.budget) && input.budget > 0)) {
106
+ throw new Error(`Invalid budget: ${input.budget} (expected a positive integer)`);
107
+ }
108
+ const db = openStore(hippoRoot);
109
+ try {
110
+ const dependsOn = [...new Set(input.dependsOn ?? [])];
111
+ let id = '';
112
+ db.exec('BEGIN IMMEDIATE');
113
+ try {
114
+ // Probe runs inside the transaction (mirrors batchWriteAndDelete): a parent
115
+ // completing between an outside-the-lock read and the INSERT would strand the child.
116
+ let allParentsDone = true;
117
+ if (dependsOn.length > 0) {
118
+ const placeholders = dependsOn.map(() => '?').join(', ');
119
+ // SAFETY: rows' shape matches the two columns named in the SELECT below.
120
+ const rows = db.prepare(`SELECT id, status FROM cards WHERE tenant_id = ? AND id IN (${placeholders})`).all(tenantId, ...dependsOn);
121
+ const found = new Map(rows.map((r) => [r.id, r.status]));
122
+ // Pre-check before any write: a typo'd --depends-on can never commit a card row.
123
+ for (const parentId of dependsOn) {
124
+ if (!found.has(parentId)) {
125
+ throw new Error(`unknown parent card id: ${parentId}`);
126
+ }
127
+ }
128
+ allParentsDone = dependsOn.every((pid) => found.get(pid) === 'done');
129
+ }
130
+ id = generateId('card');
131
+ const now = new Date().toISOString();
132
+ const status = dependsOn.length === 0 || allParentsDone ? 'ready' : 'backlog';
133
+ db.prepare(`
134
+ INSERT INTO cards (id, title, status, repo, contract, budget, created_at, updated_at, tenant_id)
135
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
136
+ `).run(id, input.title, status, input.repo ?? null, input.contract ?? null, input.budget ?? null, now, now, tenantId);
137
+ for (const parentId of dependsOn) {
138
+ db.prepare(`
139
+ INSERT INTO card_deps (parent, child, tenant_id, created_at) VALUES (?, ?, ?, ?)
140
+ `).run(parentId, id, tenantId, now);
141
+ }
142
+ db.exec('COMMIT');
143
+ }
144
+ catch (error) {
145
+ try {
146
+ db.exec('ROLLBACK');
147
+ }
148
+ catch { /* commit may have already rolled back */ }
149
+ throw error;
150
+ }
151
+ return loadCardRow(db, tenantId, id);
152
+ }
153
+ finally {
154
+ closeHippoDb(db);
155
+ }
156
+ }
157
+ /** Returns the card row for id, or null if it does not exist under this tenant. */
158
+ export function loadCard(hippoRoot, tenantId, id) {
159
+ assertTenantId('loadCard', tenantId);
160
+ const db = openStore(hippoRoot);
161
+ try {
162
+ return loadCardRow(db, tenantId, id);
163
+ }
164
+ finally {
165
+ closeHippoDb(db);
166
+ }
167
+ }
168
+ /** Lists cards for this tenant, optionally filtered to one status, newest-updated first. */
169
+ export function listCards(hippoRoot, tenantId, opts = {}) {
170
+ assertTenantId('listCards', tenantId);
171
+ const db = openStore(hippoRoot);
172
+ try {
173
+ const conditions = ['tenant_id = ?'];
174
+ const params = [tenantId];
175
+ if (opts.status) {
176
+ conditions.push('status = ?');
177
+ params.push(opts.status);
178
+ }
179
+ // SAFETY: rows' shape matches CARD_COLUMNS; status only ever holds a CardStatus value.
180
+ const rows = db.prepare(`
181
+ SELECT ${CARD_COLUMNS} FROM cards WHERE ${conditions.join(' AND ')} ORDER BY updated_at DESC, id DESC
182
+ `).all(...params);
183
+ return rows.map(rowToCard);
184
+ }
185
+ finally {
186
+ closeHippoDb(db);
187
+ }
188
+ }
189
+ /** Returns this card's parent and child ids from card_deps. */
190
+ export function loadCardDeps(hippoRoot, tenantId, id) {
191
+ assertTenantId('loadCardDeps', tenantId);
192
+ const db = openStore(hippoRoot);
193
+ try {
194
+ // SAFETY: rows' shape matches the single `parent` column named in the SELECT below.
195
+ const parents = db.prepare(`SELECT parent FROM card_deps WHERE tenant_id = ? AND child = ?`).all(tenantId, id).map((r) => r.parent);
196
+ // SAFETY: rows' shape matches the single `child` column named in the SELECT below.
197
+ const children = db.prepare(`SELECT child FROM card_deps WHERE tenant_id = ? AND parent = ?`).all(tenantId, id).map((r) => r.child);
198
+ return { parents, children };
199
+ }
200
+ finally {
201
+ closeHippoDb(db);
202
+ }
203
+ }
204
+ /** Returns this card's run history, most recent first. */
205
+ export function loadCardRuns(hippoRoot, tenantId, id) {
206
+ assertTenantId('loadCardRuns', tenantId);
207
+ const db = openStore(hippoRoot);
208
+ try {
209
+ // SAFETY: rows' shape matches CardRunRow.
210
+ const rows = db.prepare(`
211
+ SELECT id, card, runtime, session_id, started, ended, outcome
212
+ FROM card_runs WHERE tenant_id = ? AND card = ? ORDER BY started DESC, id DESC
213
+ `).all(tenantId, id);
214
+ return rows.map(rowToCardRun);
215
+ }
216
+ finally {
217
+ closeHippoDb(db);
218
+ }
219
+ }
220
+ /** Returns this card's comments, most recent first. */
221
+ export function loadCardComments(hippoRoot, tenantId, id) {
222
+ assertTenantId('loadCardComments', tenantId);
223
+ const db = openStore(hippoRoot);
224
+ try {
225
+ // SAFETY: rows' shape matches CardCommentRow.
226
+ const rows = db.prepare(`
227
+ SELECT id, card_id, author, body, created_at
228
+ FROM card_comments WHERE tenant_id = ? AND card_id = ? ORDER BY created_at DESC, id DESC
229
+ `).all(tenantId, id);
230
+ return rows.map(rowToCardComment);
231
+ }
232
+ finally {
233
+ closeHippoDb(db);
234
+ }
235
+ }
236
+ /** Read side of the card <-> handoff round trip: the newest handoff filed against this card. */
237
+ export function loadLatestHandoffForCard(hippoRoot, tenantId, cardId) {
238
+ assertTenantId('loadLatestHandoffForCard', tenantId);
239
+ const db = openStore(hippoRoot);
240
+ try {
241
+ // SAFETY: row's shape matches HANDOFF_COLUMNS.
242
+ const row = db.prepare(`
243
+ SELECT ${HANDOFF_COLUMNS} FROM session_handoffs
244
+ WHERE tenant_id = ? AND card_id = ? ORDER BY created_at DESC, id DESC LIMIT 1
245
+ `).get(tenantId, cardId);
246
+ return row ? rowToSessionHandoff(row) : null;
247
+ }
248
+ finally {
249
+ closeHippoDb(db);
250
+ }
251
+ }
252
+ /** Atomic claim: WHERE status IN (ready, blocked) AND assignee_runtime IS NULL decides the race. Throws on an unknown card id; returns null for a card not ready/blocked or already claimed. Sets a CARD_LEASE_MS lease and returns the new run's id as runId. */
253
+ export function claimCard(hippoRoot, tenantId, id, runtime, sessionId) {
254
+ assertTenantId('claimCard', tenantId);
255
+ if (runtime.trim() === '') {
256
+ throw new Error('runtime must not be empty');
257
+ }
258
+ const db = openStore(hippoRoot);
259
+ try {
260
+ db.exec('BEGIN IMMEDIATE');
261
+ let runId = 0;
262
+ try {
263
+ const changes = transitionCard(db, tenantId, id, ['ready', 'blocked'], 'running', {
264
+ setSql: 'assignee_runtime = ?',
265
+ whereSql: 'assignee_runtime IS NULL',
266
+ params: [runtime],
267
+ });
268
+ if (changes === 0) {
269
+ if (!loadCardRow(db, tenantId, id)) {
270
+ throw new Error(`unknown card id: ${id}`);
271
+ }
272
+ db.exec('ROLLBACK');
273
+ return null;
274
+ }
275
+ const now = new Date().toISOString();
276
+ const insert = db.prepare(`
277
+ INSERT INTO card_runs (card, runtime, session_id, started, created_at, updated_at, tenant_id)
278
+ VALUES (?, ?, ?, ?, ?, ?, ?)
279
+ `).run(id, runtime, sessionId ?? null, now, now, now, tenantId);
280
+ runId = Number(insert.lastInsertRowid ?? 0);
281
+ db.exec('COMMIT');
282
+ }
283
+ catch (error) {
284
+ try {
285
+ db.exec('ROLLBACK');
286
+ }
287
+ catch { /* commit may have already rolled back */ }
288
+ throw error;
289
+ }
290
+ return { ...loadCardRow(db, tenantId, id), runId };
291
+ }
292
+ finally {
293
+ closeHippoDb(db);
294
+ }
295
+ }
296
+ /** Moves a running card's lease to CARD_LEASE_MS from now and records the heartbeat; updated_at is left alone. Throws on an unknown card id or a run id that is not a positive integer; returns null unless the card is running and runId is its live run. */
297
+ export function heartbeatCard(hippoRoot, tenantId, id, runId) {
298
+ assertTenantId('heartbeatCard', tenantId);
299
+ assertRunId(runId);
300
+ const db = openStore(hippoRoot);
301
+ try {
302
+ db.exec('BEGIN IMMEDIATE');
303
+ try {
304
+ const card = loadCardRow(db, tenantId, id);
305
+ if (!card) {
306
+ throw new Error(`unknown card id: ${id}`);
307
+ }
308
+ if (card.status !== 'running' || !isLiveRun(db, tenantId, id, runId)) {
309
+ db.exec('ROLLBACK');
310
+ return null;
311
+ }
312
+ const now = new Date().toISOString();
313
+ db.prepare(`UPDATE cards SET lease_until = ?, heartbeat_at = ? WHERE id = ? AND tenant_id = ?`)
314
+ .run(leaseUntilFrom(now), now, id, tenantId);
315
+ db.exec('COMMIT');
316
+ }
317
+ catch (error) {
318
+ try {
319
+ db.exec('ROLLBACK');
320
+ }
321
+ catch { /* commit may have already rolled back */ }
322
+ throw error;
323
+ }
324
+ return loadCardRow(db, tenantId, id);
325
+ }
326
+ finally {
327
+ closeHippoDb(db);
328
+ }
329
+ }
330
+ /** Requires the card be running; closes the live run as blocked and files reason as a comment. Throws on an unknown card id; returns null for a card not running. When runId is given, returns null unless it is the card's live run. */
331
+ export function blockCard(hippoRoot, tenantId, id, reason, runId) {
332
+ assertTenantId('blockCard', tenantId);
333
+ if (reason.trim() === '') {
334
+ throw new Error('reason must not be empty');
335
+ }
336
+ if (runId !== undefined)
337
+ assertRunId(runId);
338
+ const db = openStore(hippoRoot);
339
+ try {
340
+ db.exec('BEGIN IMMEDIATE');
341
+ try {
342
+ const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
343
+ const changes = allowed ? transitionCard(db, tenantId, id, ['running'], 'blocked', { setSql: 'assignee_runtime = NULL' }) : 0;
344
+ if (changes === 0) {
345
+ if (!loadCardRow(db, tenantId, id)) {
346
+ throw new Error(`unknown card id: ${id}`);
347
+ }
348
+ db.exec('ROLLBACK');
349
+ return null;
350
+ }
351
+ const now = new Date().toISOString();
352
+ // Close the interrupted run here so completeCard's ended IS NULL scope only ever matches the live run.
353
+ closeLiveRun(db, tenantId, id, 'blocked', now);
354
+ insertCardComment(db, tenantId, id, 'system', reason);
355
+ db.exec('COMMIT');
356
+ }
357
+ catch (error) {
358
+ try {
359
+ db.exec('ROLLBACK');
360
+ }
361
+ catch { /* commit may have already rolled back */ }
362
+ throw error;
363
+ }
364
+ return loadCardRow(db, tenantId, id);
365
+ }
366
+ finally {
367
+ closeHippoDb(db);
368
+ }
369
+ }
370
+ /** Requires the card be running; moves it to review, clearing its lease and heartbeat and keeping its live run. When runId is given, returns null unless it is the card's live run. Throws on an unknown card id; returns null for a card not running. */
371
+ export function reviewCard(hippoRoot, tenantId, id, runId) {
372
+ assertTenantId('reviewCard', tenantId);
373
+ if (runId !== undefined)
374
+ assertRunId(runId);
375
+ const db = openStore(hippoRoot);
376
+ try {
377
+ db.exec('BEGIN IMMEDIATE');
378
+ try {
379
+ const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
380
+ const changes = allowed ? transitionCard(db, tenantId, id, ['running'], 'review') : 0;
381
+ if (changes === 0) {
382
+ if (!loadCardRow(db, tenantId, id)) {
383
+ throw new Error(`unknown card id: ${id}`);
384
+ }
385
+ db.exec('ROLLBACK');
386
+ return null;
387
+ }
388
+ db.exec('COMMIT');
389
+ }
390
+ catch (error) {
391
+ try {
392
+ db.exec('ROLLBACK');
393
+ }
394
+ catch { /* commit may have already rolled back */ }
395
+ throw error;
396
+ }
397
+ return loadCardRow(db, tenantId, id);
398
+ }
399
+ finally {
400
+ closeHippoDb(db);
401
+ }
402
+ }
403
+ /** Requires the card be in review; closes the live run with outcome. Outcome 'success' moves the card to done and, in the same transaction, promotes any child whose parents are now all done; 'failure' or 'partial' moves it to shelved and promotes nothing. Throws on an unknown card id; returns null for a card not in review. When runId is given, returns null unless it is the card's live run. */
404
+ export function completeCard(hippoRoot, tenantId, id, outcome, runId) {
405
+ assertTenantId('completeCard', tenantId);
406
+ if (!isHandoffOutcome(outcome)) {
407
+ throw new Error(`invalid card outcome: ${String(outcome)}`);
408
+ }
409
+ if (runId !== undefined)
410
+ assertRunId(runId);
411
+ const db = openStore(hippoRoot);
412
+ try {
413
+ db.exec('BEGIN IMMEDIATE');
414
+ let promotedChildren = [];
415
+ try {
416
+ const target = outcome === 'success' ? 'done' : 'shelved';
417
+ const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
418
+ const changes = allowed ? transitionCard(db, tenantId, id, ['review'], target) : 0;
419
+ if (changes === 0) {
420
+ if (!loadCardRow(db, tenantId, id)) {
421
+ throw new Error(`unknown card id: ${id}`);
422
+ }
423
+ db.exec('ROLLBACK');
424
+ return null;
425
+ }
426
+ const now = new Date().toISOString();
427
+ closeLiveRun(db, tenantId, id, outcome, now);
428
+ // Not best-effort (rule 12): promotion runs in this same transaction, so a
429
+ // card can never be `done` with an un-evaluated child.
430
+ if (target === 'done') {
431
+ // SAFETY: rows' shape matches the single `child` column named in the SELECT below.
432
+ const children = db.prepare(`SELECT child FROM card_deps WHERE tenant_id = ? AND parent = ?`).all(tenantId, id).map((r) => r.child);
433
+ for (const childId of children) {
434
+ // SAFETY: row's shape matches the single `status` column named in the SELECT below.
435
+ const child = db.prepare(`SELECT status FROM cards WHERE tenant_id = ? AND id = ?`).get(tenantId, childId);
436
+ if (!child || child.status !== 'backlog')
437
+ continue;
438
+ // SAFETY: rows' shape matches the single `parent` column named in the SELECT below.
439
+ const parents = db.prepare(`SELECT parent FROM card_deps WHERE tenant_id = ? AND child = ?`).all(tenantId, childId).map((r) => r.parent);
440
+ const placeholders = parents.map(() => '?').join(', ');
441
+ // SAFETY: row's shape matches the single `c` column named in the SELECT below.
442
+ const doneCount = db.prepare(`SELECT COUNT(*) as c FROM cards WHERE tenant_id = ? AND id IN (${placeholders}) AND status = 'done'`).get(tenantId, ...parents).c;
443
+ if (doneCount === parents.length) {
444
+ transitionCard(db, tenantId, childId, ['backlog'], 'ready');
445
+ promotedChildren.push(childId);
446
+ }
447
+ }
448
+ }
449
+ db.exec('COMMIT');
450
+ }
451
+ catch (error) {
452
+ try {
453
+ db.exec('ROLLBACK');
454
+ }
455
+ catch { /* commit may have already rolled back */ }
456
+ throw error;
457
+ }
458
+ return { card: loadCardRow(db, tenantId, id), promotedChildren };
459
+ }
460
+ finally {
461
+ closeHippoDb(db);
462
+ }
463
+ }
464
+ /** Returns to ready every running card of the tenant whose lease has expired or is missing: clears its assignee, closes its live run as 'reclaimed' and leaves its handoffs alone, all in one write transaction. Returns the reclaimed card ids in id order. */
465
+ export function reclaimExpiredCards(hippoRoot, tenantId) {
466
+ assertTenantId('reclaimExpiredCards', tenantId);
467
+ const db = openStore(hippoRoot);
468
+ try {
469
+ db.exec('BEGIN IMMEDIATE');
470
+ try {
471
+ // Read lease times under the write lock, so a heartbeat that committed while we waited wins.
472
+ const now = new Date().toISOString();
473
+ // SAFETY: rows' shape matches the single `id` column named in the SELECT below.
474
+ const ids = db.prepare(`
475
+ SELECT id FROM cards
476
+ WHERE tenant_id = ? AND status = 'running' AND (lease_until IS NULL OR lease_until < ?)
477
+ ORDER BY id
478
+ `).all(tenantId, now).map((r) => r.id);
479
+ for (const id of ids) {
480
+ transitionCard(db, tenantId, id, ['running'], 'ready', { setSql: 'assignee_runtime = NULL' });
481
+ closeLiveRun(db, tenantId, id, 'reclaimed', now);
482
+ }
483
+ db.exec('COMMIT');
484
+ return ids;
485
+ }
486
+ catch (error) {
487
+ try {
488
+ db.exec('ROLLBACK');
489
+ }
490
+ catch { /* commit may have already rolled back */ }
491
+ throw error;
492
+ }
493
+ }
494
+ finally {
495
+ closeHippoDb(db);
496
+ }
497
+ }
498
+ /** Appends a comment to cardId in any card status; throws if cardId is not a card of this tenant. */
499
+ export function addCardComment(hippoRoot, tenantId, cardId, author, body) {
500
+ assertTenantId('addCardComment', tenantId);
501
+ if (body.trim() === '') {
502
+ throw new Error('body must not be empty');
503
+ }
504
+ const db = openStore(hippoRoot);
505
+ try {
506
+ return insertCardComment(db, tenantId, cardId, author, body);
507
+ }
508
+ finally {
509
+ closeHippoDb(db);
510
+ }
511
+ }
512
+ //# sourceMappingURL=store-cards.js.map